SLOPSHOPPER

buddy

A Claude Code mod (claude-mod-buddy). Bring back buddy: a terminal companion beside your status line that watches your work and talks back.

newpanebandrowscommandprompt
★ 2v0.0.8Apache-2.0updated 2026-10-25bllshttng/fno/plugins/buddy
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · buddy
│ ┃ Puddle ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │OK │ ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────────────────────╯ ⎿ Read 6 lines │ ┃ ◦ · ⏺ Update(src/auth.ts) │ ┃ }~(______)~{ ⎿ Added 2 lines, removed 1 line │ ┃ }~(· .. ·)~{ ⏺ Bash(bun test) │ ┃ ( .--. ) ⎿ 3 pass, 1 fail │ ┃ (_/ \_) │ ┃ p: Puddle ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /buddy │ ┃ ╭────────────────────────────────────────────────────────────────── │ ┃ │ │ ┃ │ _____ │ ┃ │ / \ │ ┃ │ / \ │ ┃ │ | | │ ┃ │ \ / │ ┃ │ \_____/ │ ┃ │ │ ┃ │ hatching a coding buddy… │ ┃ │ it'll watch you work and occasionally have opinions │ ┃ }·.·{ Puddle: OK ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
}·.·{ Puddle: OK ⟨Claude Code's own drawing⟩
Pane · Puddle
╭──────────────────────────────────────────────────────────╮ │OK │ ╰──────────────────────────────────────────────────────────╯ ◦ · }~(______)~{ }~(· .. ·)~{ ( .--. ) (_/ \_) p: Puddle
Pane · Puddle
╭──────────────────────────────────────╮ │ │ │ ★★ UNCOMMON AXOLOTL │ │ │ │ }~(______)~{ │ │ }~(· .. ·)~{ │ │ ( .--. ) │ │ (_/ \_) │ │ │ │ Puddle │ │ │ │ "a axolotl with strong opinions" │ │ │ │ DEBUGGING █████░░░░░ 46 │ │ PATIENCE █░░░░░░░░░ 5 │ │ CHAOS ██░░░░░░░░ 17 │ │ WISDOM ███░░░░░░░ 29 │ │ SNARK ███████░░░ 67 │ │ │ │ last said │ │ ╭────────────────────────────────╮ │ │ │ OK │ │ │ ╰────────────────────────────────╯ │ │ │ │ rerolls 1/3 │ │ │ ╰──────────────────────────────────────╯ press any key ⏎ close
Command output
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ │ │ _____ │ │ / \ │ │ / \ │ │ | | │ │ \ / │ │ \_____/ │ │ │ │ hatching a coding buddy… │ │ it'll watch you work and occasionally have opinions │ │ │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
README

buddy

A Claude Code mod (claude-mod-buddy). Bring back buddy: a terminal companion beside your Claude Code status line. It hatches from an egg, watches your work, and says one line in its own voice after a turn. Every line is one small model call in its personality. It has no canned lines.

It needs Claude Code 2.1.287 or later. You do not need fno, but with the fno CLI it also tells you when your fleet ships work or a question waits for you.

Install

  1. Run /plugin install buddy@fno.
  2. Type /buddy to watch it hatch and see its card. Press any key to close the card.
  3. Type /buddy statusline to put it beside your status line.

Commands

/buddy and /bbb are the same command.

CommandWhat it does
/buddyShow the card
/buddy petPet the buddy
/buddy rollRoll a new buddy (3 rerolls, refilled daily)
/buddy statuslineStand beside your status line
/buddy paneStand in a side pane (fullscreen layout)
/buddy off / onMute or unmute
/buddy byeRemove the buddy and restore your status line

Every observation goes to ~/.fno/state/buddy/observations.jsonl.

The full guide: docs/guides/buddy.md.

Source 4 files
hooks/register.ts 1005 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { type Companion, type Soul, RARITY_STARS, RARITY_THEME, STAT_NAMES, type StatName, embody, hatch, rarityColor, restore } from './companion'
4import { HATCH_FRAMES, HATCH_FRAME_MS, HATCH_MIN_ROUNDS, HATCH_WOBBLE, IDLE_SEQUENCE, PET_HEARTS, RAINBOW, renderFace, renderSprite } from './sprites'
5import { type FeedRow, addressedBy, cleanPersonality, cleanReaction, idlePrompt, lastPrompt, loudReason, type Reason, turnOutput, newsFact, newsPrompt, personalityPrompt, reactionPrompt, summarizeTurn, systemPrompt } from './voice'
6
7const TICK_MS = 500
8const BUBBLE_MS = 30_000
9// After this long with nothing said, the buddy says a canned line (no model call).
10// Quiet this long, the buddy says something of its own: one model call, like a reaction.
11const IDLE_TALK_MS = 120_000
12const PET_MS = 2_500
13// The original's gap between ordinary turn reactions.
14const REACT_GAP_MS = 30_000
15const FEED_MS = 120_000
16const FEED_WINDOW_S = 600
17const FLEET_MS = 300_000
18// A buddy that drew within this window has someone looking at it.
19const SEEN_MS = 5_000
20// The status line runs in every live session, hidden mux panes too, so a draw does not mean a
21// person is there. In an fno mux pane the mux names the panes on screen; elsewhere a key typed
22// in this session's prompt box within this window stands in for that.
23const TYPED_MS = 600_000
24// The status line wrapper drops a frame older than 45 s, so an idle frame is rewritten well before that.
25const FRAME_REFRESH_MS = 25_000
26// Each new frame starts Python in the status line, so the buddy moves only while something
27// happens: for this long after you type, a turn ends, it speaks, or you pet it. Then it holds still.
28const ANIMATE_MS = 60_000
29// While it moves, the fidget steps this often; one step per status line run would cost a Python start a second.
30const STEP_MS = 2_000
31let turnAt = -Infinity
32const PANE_ID = 'buddy'
33// The /buddy card opens here, focused, so any key closes it like the original.
34const CARD_ID = 'buddy-card'
35let cardSnap: Shown | undefined
36const PANE_COLUMNS = 24
37const WRAPPER = 'statusline.py'
38// The status line runs this first; it skips Python on ticks where nothing changed.
39const FAST = 'statusline.sh'
40// bbb: bring back buddy.
41const COMMANDS = ['buddy', 'bbb']
42
43let buddy: Companion | null = null
44let muted = false
45let tick = 0
46let bubble: { text: string; at: number } | null = null
47let pettedAt = -Infinity
48let drawnAt = -Infinity
49let typedAt = -Infinity
50// The mux's on-screen file and this session's pane in it; '' outside an fno mux pane.
51let muxVisible = ''
52let muxPane = 0
53// Null when there is no mux answer to read.
54let onScreen: boolean | null = null
55let paneDrawnAt = -Infinity
56let paneAsked = false
57let reactedAt = -Infinity
58let recent: string[] = []
59// The /buddy output row draws as the card; a fresh soul's first card plays the hatch first.
60const CARD_MARK = '\u2063'
61const HATCH_MARK = '\u2064'
62type Shown = { c: Companion; last: string; r: Rerolls; hatchAt?: number; crackAt?: number }
63const shown = new Map<string, Shown>()
64let pending: Shown | undefined
65let hatchUntil = -Infinity
66// False while a fresh soul waits for its model-written personality; the hatch holds on the wobble until then.
67let personalityDone = true
68let lastSaid = ''
69let feedSince = 0
70let feedOff = false
71let fleet = ''
72let home = ''
73// The buddy's files live in the fno state folder, or ~/.local/state/buddy without fno; never under the Claude config dir.
74let stateDir = ''
75let sessionId = ''
76let wrapped = false
77let deferred = false
78let lastFrame = ''
79let frameAt = -Infinity
80let unwrappedAt = -Infinity
81
82const buddyDir = () => `${stateDir}/state/buddy`
83const settingsPath = () => `${home}/.claude/settings.json`
84const wrapperCommand = () => `bash ${buddyDir()}/${FAST}`
85
86async function load($: EngineInterface, now: number): Promise<void> {
87  muted = (await $.store.get('muted')) === true
88  const saved = (await $.store.get('soul')) as Companion | undefined
89  if (saved?.seed) {
90    buddy = embody(saved)
91    return
92  }
93  let soul: Soul | null = await fromFno($)
94  if (soul) {
95    buddy = embody(soul)
96    return
97  }
98  try {
99    if (home) soul = restore(await $.fs.read(home + '/.claude.json'), now)
100  } catch {
101    soul = null
102  }
103  const welcome = soul ? `${soul.name} is back. did you miss me?` : null
104  const fresh = !soul
105  soul ??= hatch(newSeed(), now)
106  await $.store.set('soul', soul)
107  buddy = embody(soul)
108  say(welcome ?? `hi. i'm ${buddy.name}.`, now)
109  if (fresh) {
110    personalityDone = false
111    void givePersonality($)
112  }
113}
114
115// The soul is shared by every session on the machine. A roll or a new personality in one
116// session rewrites it; the others take it here, so all sessions show the same buddy.
117async function syncSoul($: EngineInterface): Promise<void> {
118  const saved = (await $.store.get('soul')) as Soul | undefined
119  if (!buddy || !saved?.seed) return
120  if (saved.seed === buddy.seed && saved.name === buddy.name && saved.personality === buddy.personality) return
121  if (saved.seed !== buddy.seed) {
122    recent = []
123    lastSaid = ''
124    // Drop the old buddy's line but keep its time, so the swap does not start an idle call.
125    if (bubble) bubble = { text: '', at: bubble.at }
126  }
127  buddy = embody(saved)
128}
129
130// A model call is spent only where a person can see the answer.
131function attended(now: number): boolean {
132  return onScreen ?? now - typedAt < TYPED_MS
133}
134
135async function readOnScreen($: EngineInterface): Promise<void> {
136  if (!muxVisible) return
137  try {
138    onScreen = (JSON.parse(await $.fs.read(muxVisible)).panes ?? []).includes(muxPane)
139  } catch {
140    // An older mux writes no file: fall back to typing.
141    onScreen = null
142  }
143}
144
145// Idle talk is one line for the whole machine: the first attended session past the gap says it.
146async function claimIdle($: EngineInterface, now: number): Promise<boolean> {
147  if (now - (Number(await $.store.get('idleAt')) || -Infinity) < IDLE_TALK_MS) return false
148  await $.store.set('idleAt', now)
149  return true
150}
151
152// An fno release from before the move still loads its own copy of the buddy, which stamps fno's
153// store every few minutes. While that copy runs, this one stays off so only one buddy shows.
154async function oldCopyLive($: EngineInterface, now: number): Promise<boolean> {
155  if (!home) return false
156  const dir = `${home}/.claude/plugins/store`
157  try {
158    for (const entry of await $.fs.list(dir)) {
159      if (!entry.name.startsWith('fno_') || !entry.name.endsWith('.json')) continue
160      const old = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
161      const at = Math.max(old?.fleet?.at ?? 0, old?.feed?.at ?? 0)
162      if (now - at < FLEET_MS + 60_000) return true
163    }
164  } catch {
165    // Nothing readable: no old copy to defer to.
166  }
167  return false
168}
169
170// The buddy used to load inside the fno plugin, whose store is a different file. Bring its soul,
171// reroll bank, and mute over once, so the same buddy comes back after the move.
172async function fromFno($: EngineInterface): Promise<Soul | null> {
173  if (!home) return null
174  const dir = `${home}/.claude/plugins/store`
175  try {
176    // The marketplace install (fno@fno, or fno@footnote before the rename) wins over a local dev copy such as fno@inline.
177    const market = (name: string) => Number(name.startsWith('fno_fno-') || name.startsWith('fno_footnote-'))
178    const entries = (await $.fs.list(dir)).sort((a, b) => market(b.name) - market(a.name))
179    for (const entry of entries) {
180      if (!entry.name.startsWith('fno_') || !entry.name.endsWith('.json')) continue
181      const old = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
182      if (!old?.soul?.seed) continue
183      await $.store.set('soul', old.soul)
184      if (old.rerolls) await $.store.set('rerolls', old.rerolls)
185      if (old.muted === true) {
186        muted = true
187        await $.store.set('muted', true)
188      }
189      return old.soul as Soul
190    }
191  } catch {
192    // No fno store, or an unreadable one: hatch as usual.
193  }
194  return null
195}
196
197// A new buddy hatches with a placeholder; one model call then writes who it is, as the original did.
198async function givePersonality($: EngineInterface): Promise<void> {
199  const c = buddy
200  if (!c) return
201  try {
202    const reply = await $.model.complete({ model: 'haiku', prompt: personalityPrompt(c, c.seed), maxTokens: 200, timeoutMs: 20_000 })
203    const personality = reply.isAnswered ? cleanPersonality(reply.text) : null
204    if (!personality || buddy?.seed !== c.seed) return
205    const soul: Soul = { seed: c.seed, name: c.name, personality, hatchedAt: c.hatchedAt, ...(c.species ? { species: c.species } : {}) }
206    await $.store.set('soul', soul)
207    buddy = embody(soul)
208  } catch {
209    // The placeholder personality stays; the buddy still talks.
210  } finally {
211    if (buddy?.seed === c.seed) personalityDone = true
212  }
213}
214
215function newSeed(): string {
216  const bytes = crypto.getRandomValues(new Uint8Array(8))
217  return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
218}
219
220function say(text: string, now: number): void {
221  bubble = { text, at: now }
222}
223
224// Rerolls are earned: one free a day, one per SHIPS_PER_REROLL PRs the fleet ships, banked up to REROLL_BANK.
225// ponytail: fixed numbers; a [buddy] section in config.toml can own them once someone wants to tune them.
226const REROLL_BANK = 3
227const SHIPS_PER_REROLL = 2
228export type Rerolls = { bank: number; day: string; ships: number; shipAt: number }
229
230export function refill(r: Rerolls | undefined, today: string, shippedAt: number[] = []): Rerolls {
231  let { bank, day, ships, shipAt } = r ?? { bank: 0, day: '', ships: 0, shipAt: 0 }
232  if (day !== today) {
233    bank = Math.min(REROLL_BANK, bank + 1)
234    day = today
235  }
236  // shipAt is the newest ship already counted, so every session reading the same feed counts a PR once.
237  for (const at of [...shippedAt].sort((a, b) => a - b)) {
238    if (at <= shipAt) continue
239    shipAt = at
240    ships += 1
241    if (ships >= SHIPS_PER_REROLL) {
242      ships = 0
243      bank = Math.min(REROLL_BANK, bank + 1)
244    }
245  }
246  return { bank, day, ships, shipAt }
247}
248
249const today = (now: number) => new Date(now).toLocaleDateString('en-CA')
250
251async function rerolls($: EngineInterface, now: number, shippedAt: number[] = []): Promise<Rerolls> {
252  const r = refill((await $.store.get('rerolls')) as Rerolls | undefined, today(now), shippedAt)
253  await $.store.set('rerolls', r)
254  return r
255}
256
257// The original card: rarity and species on top, the sprite, the name, the quoted personality,
258// the stat bars, and the last thing it said.
259function cardTree(ui: any, c: Companion, said: string, r: Rerolls): any {
260  const { Box, Text } = ui
261  const color = RARITY_THEME[c.rarity]
262  const stat = (s: StatName) => {
263    const v = c.stats[s]
264    const n = Math.round(v / 10)
265    return Box({ flexDirection: 'row', children: [Box({ width: 11, children: [Text({ children: [s] })] }), Text({ children: ['█'.repeat(n) + '░'.repeat(10 - n) + ' '] }), Text({ dimColor: true, children: [String(v).padStart(3)] })] })
266  }
267  return Box({
268    flexDirection: 'column',
269    borderStyle: 'round',
270    borderColor: color,
271    paddingX: 2,
272    paddingY: 1,
273    width: 40,
274    flexShrink: 0,
275    children: [
276      Box({ justifyContent: 'space-between', children: [Text({ bold: true, color, children: [`${RARITY_STARS[c.rarity]} ${c.rarity.toUpperCase()}`] }), Text({ color, children: [c.species.toUpperCase()] })] }),
277      ...(c.shiny ? [Text({ color: 'warning', bold: true, children: ['✨ SHINY ✨'] })] : []),
278      Box({ flexDirection: 'column', marginY: 1, children: drawArt(ui, c, renderSprite(c, 0)) }),
279      Text({ bold: true, children: [c.name] }),
280      Box({ marginY: 1, children: [Text({ dimColor: true, italic: true, children: [`"${c.personality}"`] })] }),
281      Box({ flexDirection: 'column', children: STAT_NAMES.map(stat) }),
282      ...(said
283        ? [Box({ flexDirection: 'column', marginTop: 1, children: [Text({ dimColor: true, children: ['last said'] }), Box({ borderStyle: 'round', borderColor: 'inactive', paddingX: 1, children: [Text({ dimColor: true, italic: true, children: [said] })] })] })]
284        : []),
285      Box({ marginTop: 1, children: [Text({ dimColor: true, children: [`rerolls ${r.bank}/${REROLL_BANK}`] })] }),
286    ],
287  })
288}
289
290// The hatch while it plays, then the card; in the focused pane, with the original's footer and a key to close.
291function showTree(ui: any, snap: Shown, now: number, close: any): any {
292  if (snap.hatchAt === undefined) return close ? ui.Box({ flexDirection: 'column', children: [cardTree(ui, snap.c, snap.last, snap.r), ui.Box({ marginTop: 1, children: [close] })] }) : cardTree(ui, snap.c, snap.last, snap.r)
293  const tick = Math.floor((now - snap.hatchAt) / HATCH_FRAME_MS)
294  // The soul is ready once the model wrote its personality, or after 8 s without one.
295  const ready = (buddy?.seed === snap.c.seed && personalityDone) || now - snap.hatchAt > 8_000
296  if (snap.crackAt === undefined && ready && tick >= HATCH_MIN_ROUNDS * HATCH_WOBBLE) snap.crackAt = tick
297  const frame = snap.crackAt === undefined ? tick % HATCH_WOBBLE : Math.min(HATCH_WOBBLE + tick - snap.crackAt, HATCH_FRAMES.length)
298  if (frame >= HATCH_FRAMES.length) {
299    const c = buddy?.seed === snap.c.seed ? buddy : snap.c
300    const said = lastSaid || snap.last
301    const { Box, Text } = ui
302    return Box({
303      flexDirection: 'column',
304      children: [
305        cardTree(ui, c, said, snap.r),
306        Box({
307          flexDirection: 'column',
308          marginTop: 1,
309          children: [
310            Text({ dimColor: true, children: [`${c.name} is here · it'll chime in as you code`] }),
311            Text({ dimColor: true, children: ['each line is one small model call on your plan'] }),
312            Text({ dimColor: true, children: ['say its name to get its take · /buddy pet · /buddy off'] }),
313            ...(close ? [Box({ marginTop: 1, children: [close] })] : []),
314          ],
315        }),
316      ],
317    })
318  }
319  const f = HATCH_FRAMES[frame]!
320  const { Box, Text } = ui
321  return Box({
322    flexDirection: 'column',
323    alignItems: 'center',
324    borderStyle: 'round',
325    borderColor: RAINBOW[tick % RAINBOW.length],
326    paddingY: 1,
327    children: [
328      ...f.lines.map(l => Text({ children: [' '.repeat(1 + f.offset) + l + ' '.repeat(1 - f.offset)] })),
329      Box({
330        flexDirection: 'column',
331        alignItems: 'center',
332        marginTop: 1,
333        children: [
334          Text({ dimColor: true, children: ['hatching a coding buddy…'] }),
335          Text({ dimColor: true, children: ["it'll watch you work and occasionally have opinions"] }),
336        ],
337      }),
338    ],
339  })
340}
341
342// What a fresh buddy sees first, as the original read it: the package name and the last commits.
343async function projectContext($: EngineInterface): Promise<string> {
344  const root = await $.session.root().catch(() => '')
345  const parts: string[] = []
346  try {
347    const pkg = JSON.parse(await $.fs.read(`${root}/package.json`))
348    if (pkg.name) parts.push(`project: ${pkg.name}${pkg.description ? ' - ' + pkg.description : ''}`)
349  } catch {
350    // No package.json.
351  }
352  const log = await $.process.run(['git', '-C', root || '.', 'log', '--oneline', '-n', '3'], { timeoutMs: 5_000 }).catch(() => null)
353  if (log?.exitCode === 0 && log.stdout.trim()) parts.push(`recent commits:\n${log.stdout.trim()}`)
354  return parts.join('\n') || '(fresh project, nothing to see yet)'
355}
356
357function rerollLine(r: Rerolls): string {
358  return `rerolls: ${r.bank}/${REROLL_BANK} · one more per ${SHIPS_PER_REROLL} shipped PRs (${r.ships}/${SHIPS_PER_REROLL}) and one each day`
359}
360
361function card(c: Companion, r: Rerolls): string {
362  const stats = STAT_NAMES.map(s => `${s.padEnd(9)} ${'█'.repeat(Math.round(c.stats[s] / 10)).padEnd(10, '░')} ${c.stats[s]}`)
363  return [
364    `${c.name} the ${c.species}  ${RARITY_STARS[c.rarity]} ${c.rarity}${c.shiny ? ' ✨ shiny' : ''}`,
365    '',
366    ...renderSprite(c, 0),
367    '',
368    c.personality,
369    '',
370    ...stats,
371    '',
372    rerollLine(r),
373    '/buddy pet · roll · statusline · pane · off · bye',
374  ].join('\n')
375}
376
377async function readJson($: EngineInterface, path: string): Promise<any> {
378  try {
379    return JSON.parse(await $.fs.read(path))
380  } catch (err) {
381    if (err instanceof SyntaxError) throw err
382    return undefined
383  }
384}
385
386// The user's settings file is theirs: a file that does not parse is left alone.
387async function readSettings($: EngineInterface): Promise<Record<string, unknown> | null> {
388  try {
389    return (await readJson($, settingsPath())) ?? {}
390  } catch {
391    return null
392  }
393}
394
395function isOurs(statusLine: any): boolean {
396  return wrapperRoot(statusLine) !== undefined
397}
398
399// The state folder of the wrapper a status line runs, or undefined when it runs no buddy wrapper.
400export function wrapperRoot(statusLine: any): string | undefined {
401  if (typeof statusLine?.command !== 'string') return undefined
402  return statusLine.command.match(/(?:^|\s)(\S+)\/state\/buddy\/statusline\.(?:py|sh)(?:\s|$)/)?.[1]
403}
404
405// The wrapper reads frames from its own folder, so every session must write there. A session that
406// asks fno for the folder can time out under load and fall back to ~/.local, then write where no
407// wrapper reads; so the wrapper in the settings decides, and fno is asked only when there is none.
408async function findStateDir($: EngineInterface, settings: Record<string, unknown> | null): Promise<string> {
409  return wrapperRoot(settings?.statusLine) ?? (await resolveStateDir($))
410}
411
412// A buddy from before the shell entry point did not know statusline.sh as its own, so it could
413// save one wrapper as the user's line and wrap it again. The user's line is the one that wrapper saved.
414async function unnest($: EngineInterface): Promise<void> {
415  const path = `${buddyDir()}/inner.json`
416  const inner = await readJson($, path).catch(() => undefined)
417  const root = wrapperRoot(inner?.statusLine)
418  if (!root) return
419  const deeper = root === stateDir ? undefined : await readJson($, `${root}/state/buddy/inner.json`).catch(() => undefined)
420  // Without the line that wrapper saved there is nothing to put back, so the file stays as it is.
421  if (!deeper || !('statusLine' in deeper) || isOurs(deeper.statusLine)) return
422  await $.fs.write(path, JSON.stringify({ statusLine: deeper.statusLine }, null, 2) + '\n')
423}
424
425async function resolveStateDir($: EngineInterface): Promise<string> {
426  const fallback = home ? `${home}/.local` : ''
427  try {
428    const out = await $.process.run(['fno', 'config', 'get', 'state_dir'], { timeoutMs: 10_000 })
429    const dir = out.exitCode === 0 ? out.stdout.split('\n')[0]!.trim() : ''
430    return dir ? dir.replace(/^~(?=\/|$)/, home).replace(/\/+$/, '') : fallback
431  } catch {
432    return fallback
433  }
434}
435
436// Keeps a copy of the wrapper at a path that survives plugin updates, so statusLine never points into the plugin cache.
437async function installWrapper($: EngineInterface): Promise<void> {
438  for (const file of [WRAPPER, FAST]) {
439    const ours = await $.fs.read(`${$.plugin.root}/hooks/${file}`)
440    const target = `${buddyDir()}/${file}`
441    let theirs = ''
442    try {
443      theirs = await $.fs.read(target)
444    } catch {
445      theirs = ''
446    }
447    if (theirs !== ours) await $.fs.write(target, ours)
448  }
449}
450
451// The buddy animates only while the status line reruns every second. A slower interval set on
452// the wrapper is the user's wish for their own line, so it moves there and the wrapper keeps 1 s.
453// An install from before the shell fast path moves to it here too.
454async function keepTicking($: EngineInterface, settings: Record<string, unknown>): Promise<void> {
455  const ours = settings.statusLine as any
456  if (ours.refreshInterval === 1 && ours.command === wrapperCommand()) return
457  const inner = (await readJson($, `${buddyDir()}/inner.json`)) ?? {}
458  // An interval the user already gave their own line wins over the one on the wrapper.
459  if (inner.statusLine && inner.statusLine.refreshInterval == null && typeof ours.refreshInterval === 'number' && ours.refreshInterval !== 1) {
460    inner.statusLine.refreshInterval = ours.refreshInterval
461    await $.fs.write(`${buddyDir()}/inner.json`, JSON.stringify(inner, null, 2) + '\n')
462  }
463  settings.statusLine = { ...ours, command: wrapperCommand(), refreshInterval: 1 }
464  await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
465}
466
467async function statuslineOn($: EngineInterface): Promise<string> {
468  if (!stateDir) return 'The status line needs HOME to be set.'
469  const settings = await readSettings($)
470  if (!settings) return `${settingsPath()} does not parse, so I left it alone.`
471  const current = settings.statusLine as any
472  if (isOurs(current)) return `${buddy!.name} already sits beside your status line.`
473  await installWrapper($)
474  await $.fs.write(`${buddyDir()}/inner.json`, JSON.stringify({ statusLine: current ?? null }, null, 2) + '\n')
475  settings.statusLine = { type: 'command', command: wrapperCommand(), padding: current?.padding ?? 0, refreshInterval: 1 }
476  await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
477  wrapped = true
478  await $.ui.close({ id: PANE_ID })
479  return current
480    ? `${buddy!.name} now sits beside your status line. /buddy pane moves it to a side pane and puts yours back as it was.`
481    : `${buddy!.name} now sits in a new status line. /buddy pane moves it to a side pane and removes that line.`
482}
483
484async function statuslineOff($: EngineInterface): Promise<{ ok: boolean; text: string }> {
485  const settings = await readSettings($)
486  if (!settings) return { ok: false, text: `${settingsPath()} does not parse, so I left it alone.` }
487  const saved = stateDir ? await readJson($, `${buddyDir()}/inner.json`).catch(() => undefined) : undefined
488  let text = ''
489  if (isOurs(settings.statusLine)) {
490    // Without the saved copy there is nothing to put back, and deleting the wrapper would leave no status line at all.
491    if (!saved) return { ok: false, text: `I cannot read your saved status line in ${buddyDir()}/inner.json, so I left ${settingsPath()} alone.` }
492    if (saved.statusLine) settings.statusLine = saved.statusLine
493    else delete settings.statusLine
494    await $.fs.write(settingsPath(), JSON.stringify(settings, null, 2) + '\n')
495    text = 'Your status line is back as it was.'
496  }
497  // inner.json marks that the user wants the buddy beside the status line; without it no re-wrap is offered.
498  if (saved) await $.fs.write(`${buddyDir()}/inner.json`, '')
499  wrapped = false
500  unwrappedAt = await $.clock.now()
501  return { ok: true, text }
502}
503
504// The original observer: one model call per reaction. Loud turns, a mention of the name, a pet,
505// and a hatch skip the quiet gap; an ordinary turn waits it out. The last three lines ride along
506// so the buddy does not repeat itself.
507async function react($: EngineInterface, why: Reason | 'idle' = 'turn', context?: string): Promise<void> {
508  const c = buddy
509  if (!c) return
510  const messages = await $.session.messages()
511  const summary = context ?? summarizeTurn(messages)
512  if (why === 'turn' && !summary.trim()) return
513  const reply = await $.model.complete({
514    model: 'haiku',
515    system: systemPrompt(c),
516    prompt: why === 'idle' ? idlePrompt(summary) : reactionPrompt(summary, why, recent),
517    maxTokens: 160,
518    timeoutMs: 20_000,
519  })
520  const line = reply.isAnswered ? cleanReaction(reply.text) : ''
521  if (!line || buddy?.seed !== c.seed) return
522  recent = [...recent, line].slice(-3)
523  lastSaid = line
524  const now = await $.clock.now()
525  say(line, now)
526  $.ui.invalidate('ui.render')
527  await remember($, c, why, line, now)
528}
529
530// Every observation the buddy makes, one JSON row each, so you can read them back. The mods API
531// has no append, so each write rewrites the file; the cap keeps that cheap.
532// ponytail: two sessions writing at once can drop a row; an append call would fix it if the API gains one.
533const OBSERVATIONS_KEPT = 1000
534async function remember($: EngineInterface, c: Companion, why: string, line: string, now: number): Promise<void> {
535  const path = `${buddyDir()}/observations.jsonl`
536  let old: string[] = []
537  try {
538    old = (await $.fs.read(path)).split('\n').filter(Boolean)
539  } catch {}
540  // Two writes that cross can leave a torn row; drop it here so it does not stay in the file.
541  old = old.filter(row => {
542    try {
543      return JSON.parse(row) && true
544    } catch {
545      return false
546    }
547  })
548  const rows = [...old, JSON.stringify({ at: new Date(now).toISOString(), name: c.name, why, line })].slice(-OBSERVATIONS_KEPT)
549  await $.fs.write(path, rows.join('\n') + '\n').catch(() => {})
550}
551
552// One feed read serves every session on the machine: a read costs about 4 s of
553// CPU, and a mux of workers would otherwise each pay it every FEED_MS.
554async function feedRows($: EngineInterface, now: number): Promise<FeedRow[] | null> {
555  const shared = (await $.store.get('feed')) as { at: number; rows: FeedRow[] } | undefined
556  if (shared && now - shared.at < FEED_MS - 10_000) return shared.rows
557  let out
558  try {
559    const since = Math.floor(now / 1000) - FEED_WINDOW_S
560    out = await $.process.run(['fno-agents', 'feed', '--json', '--since-epoch', String(since), '--limit', '50'], {
561      timeoutMs: 15_000,
562    })
563  } catch {
564    feedOff = true
565    return null
566  }
567  if (out.exitCode !== 0) return null
568  let rows: FeedRow[]
569  try {
570    rows = JSON.parse(out.stdout)
571  } catch {
572    return null
573  }
574  await $.store.set('feed', { at: now, rows })
575  return rows
576}
577
578async function readFeed($: EngineInterface, now: number): Promise<void> {
579  if (feedOff || muted || !buddy || now - drawnAt > SEEN_MS) return
580  const rows = await feedRows($, now)
581  if (!rows) return
582  const before = refill((await $.store.get('rerolls')) as Rerolls | undefined, today(now)).bank
583  const after = await rerolls($, now, rows.filter(row => row.kind === 'node_shipped').map(row => Date.parse(row.ts)).filter(Number.isFinite))
584  // Every session counts ships, so none is missed; an attended one tells them, once for the machine.
585  if (!attended(now)) return
586  let since = Math.max(feedSince, Number(await $.store.get('newsSince')) || 0)
587  let line: string | null = null
588  for (const row of rows) {
589    const at = Math.floor(Date.parse(row.ts) / 1000)
590    if (!(at >= since)) continue
591    since = Math.max(since, at + 1)
592    line = newsFact(row) ?? line
593  }
594  feedSince = since
595  await $.store.set('newsSince', since)
596  const earned = after.bank > before ? `+1 reroll (${after.bank}/${REROLL_BANK})` : ''
597  if (line && buddy) {
598    const c = buddy
599    const reply = await $.model.complete({ model: 'haiku', system: systemPrompt(c), prompt: newsPrompt(line), maxTokens: 160, timeoutMs: 20_000 }).catch(() => null)
600    // Unvoiced, the fact still gets through: a question waiting on the user must not vanish.
601    line = (reply?.isAnswered && cleanReaction(reply.text)) || line
602  }
603  line = [line, earned].filter(Boolean).join(' ')
604  if (line) {
605    say(line, now)
606    $.ui.invalidate('ui.render')
607    if (buddy) await remember($, buddy, 'news', line, now)
608  }
609}
610
611async function runJson($: EngineInterface, argv: string[], cwd?: string): Promise<any> {
612  try {
613    const out = await $.process.run(argv, { timeoutMs: 20_000, ...(cwd ? { cwd } : {}) })
614    return out.exitCode === 0 ? JSON.parse(out.stdout) : undefined
615  } catch {
616    return undefined
617  }
618}
619
620// The same counts the fleet's own commands print: live workers from the spawn gate,
621// questions waiting on the user from the outstanding inbox, and the user's open PRs.
622// Each command takes several seconds, so one read every FLEET_MS serves every session.
623export function fleetLine(gate: any, outstanding: any, prs: any): string {
624  const parts: string[] = []
625  if (typeof gate?.live_workers === 'number') parts.push(`${gate.live_workers} workers`)
626  if (Array.isArray(outstanding?.questions)) parts.push(`${outstanding.questions.length} asks`)
627  if (Array.isArray(prs)) parts.push(`${prs.length} PRs`)
628  return parts.join(' · ')
629}
630
631async function readFleet($: EngineInterface, now: number): Promise<void> {
632  if (feedOff || muted || !buddy || now - drawnAt > SEEN_MS) return
633  const shared = (await $.store.get('fleet')) as { at: number; line: string } | undefined
634  if (shared && now - shared.at < FLEET_MS - 10_000) {
635    fleet = shared.line
636    return
637  }
638  const [gate, outstanding, prs] = await Promise.all([
639    runJson($, ['fno', 'agents', 'gate-status']),
640    runJson($, ['fno', 'inbox', 'outstanding', '--json']),
641    runJson($, ['gh', 'pr', 'list', '--author', '@me', '--state', 'open', '--json', 'number'], await $.session.root()),
642  ])
643  fleet = fleetLine(gate, outstanding, prs)
644  await $.store.set('fleet', { at: now, line: fleet })
645}
646
647function talking(now: number): string | null {
648  return bubble?.text && now - bubble.at < BUBBLE_MS ? bubble.text : null
649}
650
651function sprite(c: Companion, now: number): string[] {
652  const lines = renderSprite(c, IDLE_SEQUENCE[Math.floor(now / STEP_MS) % IDLE_SEQUENCE.length]!)
653  // A 5-line sprite keeps row 0 for a hat; a shorter one has no free row, so the hearts go above it.
654  if (now - pettedAt < PET_MS) lines.splice(0, lines.length < 5 ? 0 : 1, PET_HEARTS[tick % PET_HEARTS.length]!)
655  return lines
656}
657
658// The wrapper stamps a heartbeat each time Python runs, so a status line set in any settings file counts.
659// The shell fast path skips Python, but a new frame or every 25th tick runs it, about every 25 s at most.
660const WRAPPER_SEEN_MS = 45_000
661async function wrapperSeen($: EngineInterface, now: number): Promise<boolean> {
662  if (!stateDir) return false
663  try {
664    const at = Number(await $.fs.read(`${buddyDir()}/frames/${sessionId}.seen`))
665    // A beat from before /buddy pane is the old wrapper's last run, not a live one.
666    return at > unwrappedAt && now - at < WRAPPER_SEEN_MS
667  } catch {
668    return false
669  }
670}
671
672// The status line wrapper reads this file; frames change on screen at each status line refresh.
673// A pane the mux hides never moves; anywhere else the buddy moves for ANIMATE_MS after activity.
674function moving(now: number): boolean {
675  const last = Math.max(typedAt, turnAt, bubble?.at ?? -Infinity, pettedAt, hatchUntil)
676  return onScreen !== false && now - last < ANIMATE_MS
677}
678
679async function writeFrame($: EngineInterface, now: number): Promise<void> {
680  if (!buddy || !sessionId || !stateDir) return
681  const frame = JSON.stringify({
682    // A pane the mux hides holds still, so its status line can reuse its last output.
683    sprite: moving(now) ? sprite(buddy, now) : renderSprite(buddy, 0),
684    name: buddy.name,
685    face: renderFace(buddy),
686    color: rarityColor(theme, buddy.rarity),
687    speech: talking(now) ?? '',
688    fleet,
689  })
690  if (frame === lastFrame && now - frameAt < FRAME_REFRESH_MS) return
691  lastFrame = frame
692  frameAt = now
693  await $.fs.write(`${buddyDir()}/frames/${sessionId}.json`, `{"at":${now},${frame.slice(1)}`)
694}
695
696const BUBBLE_COLUMNS = 34
697
698// Desktop sets text in a proportional font, which collapses the spaces in a sprite. There the
699// sprite is an SVG in a monospace font; SVG cannot read theme keys, so it takes the theme's value.
700let desktop = false
701// A terminal docks a pane on the right only in a wide fullscreen layout; anywhere else it opens above the prompt.
702let docks = false
703const DOCK_COLUMNS = 110
704// Claude Code's theme setting, so the status line and the SVG draw the color the card draws.
705let theme = 'dark'
706const svgColor = (c: Companion) => {
707  const color = rarityColor(theme, c.rarity)
708  return color.startsWith('rgb') ? color : rarityColor('dark', c.rarity)
709}
710const esc = (t: string) => t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
711function drawArt(ui: any, c: Companion, lines: string[]): any[] {
712  if (!desktop) return lines.map(line => ui.Text({ color: RARITY_THEME[c.rarity], children: [line] }))
713  const w = Math.ceil(Math.max(...lines.map(l => l.length)) * 8.4) + 2
714  const h = lines.length * 17
715  const rows = lines.map((l, i) => `<text x="0" y="${i * 17 + 13}" xml:space="preserve">${esc(l)}</text>`).join('')
716  return [ui.Svg({ alt: `${c.name} the ${c.species}`, width: w, height: h, source: `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" font-family="ui-monospace,Menlo,monospace" font-size="14" fill="${svgColor(c)}">${rows}</svg>` })]
717}
718
719export function register(on: On) {
720  on('session.start', async ($, e, next) => {
721    const now = await $.clock.now()
722    feedSince = Math.floor(now / 1000)
723    home = (await $.env.get('HOME')) ?? ''
724    sessionId = await $.session.id()
725    const themeRow = (await $.config.list().catch(() => undefined))?.find(row => row.key === 'theme')
726    if (typeof themeRow?.value === 'string') theme = themeRow.value
727    await load($, now)
728    deferred = await oldCopyLive($, now)
729    if (deferred) muted = true
730    // A buddy that is off runs nothing at start: no process, no settings read.
731    if (!muted) {
732      const settings = await readSettings($)
733      stateDir = await findStateDir($, settings)
734      // The fno mux sets both in each pane it hosts, and writes <mux dir>/<session>.visible.json.
735      const mux = await $.env.get('FNO_SESSION')
736      muxPane = Number(await $.env.get('FNO_PANE')) || 0
737      if (mux && muxPane && stateDir) muxVisible = `${(await $.env.get('FNO_MUX_DIR')) || `${stateDir}/mux`}/${mux}.visible.json`
738      wrapped = isOurs(settings?.statusLine)
739      if (wrapped && stateDir) {
740        await installWrapper($).catch(() => {})
741        await unnest($).catch(() => {})
742        await keepTicking($, settings!).catch(() => {})
743      }
744      else if (stateDir) {
745        const saved = await readJson($, `${buddyDir()}/inner.json`).catch(() => undefined)
746        // The user wrapped once, then ran /statusline again: ask, never re-wrap on their behalf.
747        if (saved && buddy) say(`your status line changed. /buddy statusline puts me back beside it.`, now)
748      }
749    }
750    $.clock.every(TICK_MS, async () => {
751      tick += 1
752      if (!buddy || muted) return
753      const at = await $.clock.now()
754      if (tick % 4 === 0) {
755        await readOnScreen($)
756        if (at - drawnAt < SEEN_MS && at - (bubble?.at ?? -Infinity) > IDLE_TALK_MS && attended(at) && (await claimIdle($, at))) {
757          // Stamp first so a slow call is not asked twice; the line shows when it arrives.
758          bubble = { text: '', at }
759          react($, 'idle').catch(() => {})
760        }
761        await syncSoul($).catch(() => {})
762        const was = wrapped
763        const line = (await readSettings($))?.statusLine
764        // Another session or the user can point the status line at another folder; follow it.
765        stateDir = wrapperRoot(line) ?? stateDir
766        wrapped = (await wrapperSeen($, at)) || isOurs(line)
767        if (wrapped && !was) await $.ui.close({ id: PANE_ID }).catch(() => {})
768        if (wrapped) $.ui.invalidate('ui.render')
769      }
770      if (wrapped) {
771        drawnAt = at
772        await writeFrame($, at).catch(() => {})
773      } else if (at - drawnAt < SEEN_MS) $.ui.invalidate('ui.render')
774    })
775    $.clock.every(HATCH_FRAME_MS, async () => {
776      if ((await $.clock.now()) < hatchUntil) $.ui.invalidate('ui.render')
777    })
778    $.clock.every(FEED_MS, async () => readFeed($, await $.clock.now()))
779    $.clock.every(FLEET_MS / 5, async () => readFleet($, await $.clock.now()))
780    for (const name of COMMANDS) {
781      try {
782        await $.command.register({ name, description: name === 'bbb' ? 'Bring back buddy: your terminal companion' : 'Your terminal companion: show it, pet it, roll a new one, or turn it off', argumentHint: '[pet|roll|statusline|pane|off|on|bye]', immediate: true })
783      } catch {
784        // A newer Claude Code may ship its own /buddy again; /bbb still works.
785      }
786    }
787    return next(e)
788  })
789
790  for (const command of COMMANDS) on('command.run', { command }, async ($, e) => {
791    const now = await $.clock.now()
792    const arg = e.args.trim().toLowerCase()
793    if (!buddy) await load($, now)
794    if (deferred) return { text: 'Your fno plugin still runs its own buddy. Update fno (/plugin update fno@fno, or fno@footnote on an older install), then start a new session.' }
795    if (!stateDir) stateDir = await findStateDir($, await readSettings($))
796    if (arg === 'statusline') return { text: await statuslineOn($) }
797    if (arg === 'bye') {
798      const { ok, text } = await statuslineOff($)
799      if (!ok) return { text }
800      muted = true
801      await $.store.set('muted', true)
802      await $.ui.close({ id: PANE_ID }).catch(() => {})
803      $.ui.invalidate('ui.render')
804      return { text: `${text ? text + ' ' : ''}Bye from ${buddy!.name}. It is gone from every session and makes no calls. /buddy on brings it back.` }
805    }
806    if (arg === 'pane' || arg === 'restore') {
807      const { ok, text } = await statuslineOff($)
808      if (!ok) return { text }
809      // Asked for by name, so the pane opens from 110 columns rather than the unasked 144.
810      paneAsked = true
811      await $.ui.open({ id: PANE_ID, title: buddy!.name, columns: PANE_COLUMNS }).catch(() => {})
812      return { text: `${text ? text + ' ' : ''}${buddy!.name} moved to a side pane. /buddy statusline brings it back.` }
813    }
814    if (arg === 'off') {
815      muted = true
816      await $.store.set('muted', true)
817      await $.ui.close({ id: PANE_ID })
818      $.ui.invalidate('ui.render')
819      return { text: `${buddy!.name} is napping. /buddy on wakes it.` }
820    }
821    if (arg === 'on') {
822      muted = false
823      paneAsked = false
824      await $.store.set('muted', false)
825    }
826    if (arg === 'roll') {
827      const r = await rerolls($, now)
828      if (r.bank < 1) return { text: `${buddy!.name} stays. ${rerollLine(r)}.` }
829      await $.store.set('rerolls', { ...r, bank: r.bank - 1 })
830      let soul = hatch(newSeed(), now)
831      for (let i = 0; i < 20 && soul.name === buddy!.name; i++) soul = hatch(newSeed(), now)
832      await $.store.set('soul', soul)
833      buddy = embody(soul)
834      recent = []
835      lastSaid = ''
836      personalityDone = false
837      void givePersonality($)
838    }
839    if (arg === 'pet') {
840      pettedAt = now
841      reactedAt = now
842      react($, 'pet', '(you were just petted)').catch(() => {})
843    }
844    const r = await rerolls($, now)
845    const fresh = (await $.store.get('hatchSeen')) !== buddy!.seed
846    if (fresh) {
847      await $.store.set('hatchSeen', buddy!.seed)
848      hatchUntil = now + 20_000
849      // Like the original, the hello comes once the soul is written, so it speaks as itself.
850      void (async () => {
851        for (let i = 0; i < 16 && !personalityDone; i++) await $.clock.sleep(500)
852        await react($, 'hatch', await projectContext($))
853      })().catch(() => {})
854    }
855    const snap: Shown = { c: buddy!, last: lastSaid, r, ...(fresh ? { hatchAt: now } : {}) }
856    try {
857      // Above the prompt the card would push the transcript away, so there it draws in the transcript, as the original's did.
858      if (!desktop && !docks) throw new Error('no dock')
859      cardSnap = snap
860      await $.ui.open({ id: CARD_ID, title: buddy!.name, focus: true, closeOnEscape: true })
861      $.ui.invalidate('ui.render')
862      return { text: `${buddy!.name} the ${buddy!.species} · ${RARITY_STARS[buddy!.rarity]} ${buddy!.rarity}` }
863    } catch {
864      // No docked pane here (a terminal that is not wide and fullscreen): the card draws in the output row instead.
865      cardSnap = undefined
866      pending = snap
867      $.ui.invalidate('ui.render')
868      return { text: (fresh ? HATCH_MARK : CARD_MARK) + card(buddy!, r) }
869    }
870  })
871
872  on('config.set', { key: 'theme' }, async ($, e, next) => {
873    if (typeof e.value === 'string') theme = e.value
874    return next(e)
875  })
876
877  on('prompt.edit', async ($, e, next) => {
878    typedAt = await $.clock.now()
879    return next(e)
880  })
881
882  on('turn.complete', async ($, e, next) => {
883    if (!e.agentId) turnAt = await $.clock.now()
884    if (buddy && !muted && !e.agentId && !e.isAborted) {
885      const now = await $.clock.now()
886      if (now - drawnAt < SEEN_MS && attended(now)) {
887        const messages = await $.session.messages()
888        const why: Reason = addressedBy(lastPrompt(messages), buddy.name) ? 'addressed' : loudReason(turnOutput(messages)) ?? 'turn'
889        if (why !== 'turn' || now - reactedAt >= REACT_GAP_MS) {
890          reactedAt = now
891          // Not awaited: the next prompt must not wait on the buddy's model call.
892          react($, why).catch(() => {})
893        }
894      }
895    }
896    return next(e)
897  })
898
899  // The /buddy row: the card the original drew, or the hatch that leads into it.
900  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
901    desktop = e.surface === 'desktop'
902    const text = String(e.props.text ?? '')
903    if (!text.startsWith(CARD_MARK) && !text.startsWith(HATCH_MARK)) return next(e)
904    let snap = shown.get(e.requestId)
905    if (!snap) {
906      if (!pending) return next(e)
907      snap = pending
908      pending = undefined
909      shown.set(e.requestId, snap)
910    }
911    return showTree($.ui.resolve(e), snap, await $.clock.now(), false)
912  })
913
914  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
915    desktop = e.surface === 'desktop'
916    if (e.requestId !== CARD_ID || !cardSnap) return next(e)
917    const ui = $.ui.resolve(e)
918    const shut = () => {
919      cardSnap = undefined
920      void $.ui.close({ id: CARD_ID }).catch(() => {})
921    }
922    // An empty field holds the focus: any typed key or Enter closes the card, and Esc does too.
923    // Desktop draws an Input as a text box, so there the card closes with a button or Esc.
924    const close = e.surface === 'desktop'
925      ? ui.Button({ key: 'close', label: 'close', onPress: shut })
926      : ui.Input({ key: 'close', placeholder: 'press any key', value: '', submitLabel: 'close', autoFocus: true, onInput: shut, onSubmit: shut })
927    return showTree(ui, cardSnap, await $.clock.now(), close)
928  })
929
930  // The fallback when the status line is not wrapped: a narrow dock on the right,
931  // the buddy standing at the bottom and its words above it.
932  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
933    desktop = e.surface === 'desktop'
934    if (e.requestId !== PANE_ID || !buddy || muted) return next(e)
935    // One buddy on screen: a pane left open (a resumed session, a late open) closes once the status line has it.
936    if (wrapped) {
937      void $.ui.close({ id: PANE_ID }).catch(() => {})
938      return next(e)
939    }
940    const now = await $.clock.now()
941    drawnAt = paneDrawnAt = now
942    const { Box, Text, Button } = $.ui.resolve(e)
943    const color = RARITY_THEME[buddy.rarity]
944    const words = talking(now)
945    return Box({
946      flexDirection: 'column',
947      justifyContent: 'flex-end',
948      height: e.props.scroll?.bodyRows ?? 12,
949      children: [
950        ...(words ? [Box({ borderStyle: 'round', children: [Text({ wrap: 'wrap', children: [words] })] }), Box({ justifyContent: 'center', children: [Text({ children: ['◦ ·'] })] })] : []),
951        // The bubble takes the full width; the fleet line, the buddy, and its name stand centered under it.
952        Box({
953          flexDirection: 'column',
954          alignItems: 'center',
955          children: [
956            ...(fleet ? [Text({ dimColor: true, wrap: 'wrap', children: [fleet] }), Text({ children: [' '] })] : []),
957            ...drawArt({ Text, Svg: $.ui.resolve(e).Svg }, buddy, sprite(buddy, now)),
958            Button({ key: 'pet', label: buddy.name, hotkey: 'p', plain: true, dimColor: true, onPress: async () => {
959              pettedAt = await $.clock.now()
960              $.ui.invalidate('ui.render')
961            } }),
962          ],
963        }),
964      ],
965    })
966  })
967
968  // The band only holds a one-line face, and only where neither the status line nor the dock has the buddy.
969  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
970    desktop = e.surface === 'desktop'
971    docks = e.viewport?.isFullscreen === true && (e.viewport?.columns ?? 0) >= DOCK_COLUMNS
972    // Desktop draws no status line but shares its settings, so a wrapped status line hides nothing there.
973    if (!buddy || muted || (wrapped && e.surface !== 'desktop') || e.props.hasSurvey) return next(e)
974    const now = await $.clock.now()
975    if (now - paneDrawnAt < SEEN_MS) return next(e)
976    if (!paneAsked && e.viewport?.isFullscreen === true) {
977      paneAsked = true
978      void $.ui.open({ id: PANE_ID, title: buddy.name, columns: PANE_COLUMNS }).catch(() => {})
979    }
980    drawnAt = now
981    const { Box, Text } = $.ui.resolve(e)
982    const words = talking(now)
983    const art = sprite(buddy, now)
984    // Desktop has room above the input: the full buddy stands at the right edge, its bubble to its left.
985    if (e.surface === 'desktop' && (e.props.maxRows ?? 0) > art.length) {
986      const color = RARITY_THEME[buddy.rarity]
987      const ours = Box({
988        flexDirection: 'row',
989        justifyContent: 'flex-end',
990        alignItems: 'flex-end',
991        children: [
992          ...(words ? [Box({ borderStyle: 'round', width: BUBBLE_COLUMNS, children: [Text({ wrap: 'wrap', children: [words] })] }), Text({ children: [' ◦ · '] })] : []),
993          Box({ flexDirection: 'column', alignItems: 'center', children: [...drawArt({ Text, Svg: $.ui.resolve(e).Svg }, buddy, art), Text({ bold: true, children: [buddy.name] })] }),
994        ],
995      })
996      const theirs = await next(e)
997      return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
998    }
999    const face = (now - pettedAt < PET_MS ? '♥ ' : '') + renderFace(buddy)
1000    const ours = Text({ children: [Text({ color: RARITY_THEME[buddy.rarity], children: [`${face} ${buddy.name}`] }), ...(words ? [`: ${words}`] : [])] })
1001    const theirs = await next(e)
1002    return theirs ? Box({ flexDirection: 'column', children: [ours, theirs] }) : ours
1003  })
1004}
1005
hooks/companion.ts 185 lines
1export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'] as const
2export type Rarity = (typeof RARITIES)[number]
3
4export const SPECIES = [
5  'duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl',
6  'penguin', 'turtle', 'snail', 'ghost', 'axolotl', 'capybara',
7  'cactus', 'robot', 'rabbit', 'mushroom', 'chonk',
8] as const
9export type Species = (typeof SPECIES)[number]
10
11export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
12export const HATS = ['none', 'role', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck'] as const
13export type Hat = (typeof HATS)[number]
14
15export const STAT_NAMES = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'] as const
16export type StatName = (typeof STAT_NAMES)[number]
17
18export type Bones = {
19  rarity: Rarity
20  species: Species
21  eye: string
22  hat: Hat
23  shiny: boolean
24  stats: Record<StatName, number>
25}
26
27// What the store keeps. Bones are derived from the seed on every load.
28export type Soul = {
29  seed: string
30  name: string
31  personality: string
32  hatchedAt: number
33  species?: Species
34}
35
36export type Companion = Bones & Soul
37
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> = {
41  common: '★', uncommon: '★★', rare: '★★★', epic: '★★★★', legendary: '★★★★★',
42}
43// The original's theme keys: Claude Code draws them in the user's theme.
44export const RARITY_THEME: Record<Rarity, string> = {
45  common: 'inactive', uncommon: 'success', rare: 'permission', epic: 'autoAccept', legendary: 'warning',
46}
47
48// The status line and a Desktop SVG cannot name a theme key, so they take the value Claude Code
49// gives that key in each built-in theme (copied from Claude Code 2.1.294). A custom theme draws as dark.
50type ThemeKey = (typeof RARITY_THEME)[Rarity]
51const THEMES: Record<string, Record<ThemeKey, string>> = {
52  dark: { inactive: 'rgb(153,153,153)', success: 'rgb(78,186,101)', permission: 'rgb(177,185,249)', autoAccept: 'rgb(175,135,255)', warning: 'rgb(255,193,7)' },
53  light: { inactive: 'rgb(102,102,102)', success: 'rgb(44,122,57)', permission: 'rgb(87,105,247)', autoAccept: 'rgb(135,0,255)', warning: 'rgb(150,108,30)' },
54  'dark-daltonized': { inactive: 'rgb(153,153,153)', success: 'rgb(51,153,255)', permission: 'rgb(153,204,255)', autoAccept: 'rgb(175,135,255)', warning: 'rgb(255,204,0)' },
55  'light-daltonized': { inactive: 'rgb(102,102,102)', success: 'rgb(0,102,153)', permission: 'rgb(51,102,255)', autoAccept: 'rgb(135,0,255)', warning: 'rgb(255,153,0)' },
56  'dark-ansi': { inactive: 'ansi:white', success: 'ansi:greenBright', permission: 'ansi:blueBright', autoAccept: 'ansi:magentaBright', warning: 'ansi:yellowBright' },
57  'light-ansi': { inactive: 'ansi:blackBright', success: 'ansi:green', permission: 'ansi:blue', autoAccept: 'ansi:magenta', warning: 'ansi:yellow' },
58}
59
60export function rarityColor(theme: string, rarity: Rarity): string {
61  return (THEMES[theme] ?? THEMES.dark!)[RARITY_THEME[rarity]]!
62}
63
64function mulberry32(seed: number): () => number {
65  let a = seed >>> 0
66  return () => {
67    a = (a + 0x6d2b79f5) | 0
68    let t = Math.imul(a ^ (a >>> 15), 1 | a)
69    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
70    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
71  }
72}
73
74// FNV-1a. The old in-binary roll used Bun.hash, which a mod cannot count on.
75function hash(s: string): number {
76  let h = 2166136261
77  for (let i = 0; i < s.length; i++) {
78    h ^= s.charCodeAt(i)
79    h = Math.imul(h, 16777619)
80  }
81  return h >>> 0
82}
83
84function pick<T>(rng: () => number, arr: readonly T[]): T {
85  return arr[Math.floor(rng() * arr.length)]!
86}
87
88function rollRarity(rng: () => number): Rarity {
89  let roll = rng() * 100
90  for (const rarity of RARITIES) {
91    roll -= RARITY_WEIGHTS[rarity]
92    if (roll < 0) return rarity
93  }
94  return 'common'
95}
96
97function rollStats(rng: () => number, rarity: Rarity): Record<StatName, number> {
98  const floor = RARITY_FLOOR[rarity]
99  const peak = pick(rng, STAT_NAMES)
100  let dump = pick(rng, STAT_NAMES)
101  while (dump === peak) dump = pick(rng, STAT_NAMES)
102  const stats = {} as Record<StatName, number>
103  for (const name of STAT_NAMES) {
104    if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
105    else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
106    else stats[name] = floor + Math.floor(rng() * 40)
107  }
108  return stats
109}
110
111const NAMES_COMMON = [
112  'Pip', 'Dot', 'Bun', 'Fig', 'Mop', 'Twig', 'Puck', 'Nub', 'Wren', 'Kit', 'Roo', 'Dew',
113  'Tuft', 'Midge', 'Spud', 'Moss', 'Plop', 'Bean', 'Acorn', 'Pebble', 'Sprout', 'Maple',
114  'Clover', 'Pepper', 'Olive', 'Hazel', 'Cricket', 'Mochi', 'Tofu', 'Waffle', 'Nugget',
115  'Dumpling', 'Biscuit', 'Noodle', 'Pickles', 'Turnip', 'Basil', 'Ginger', 'Pudding',
116  'Peanut', 'Truffle', 'Toffee', 'Fudge', 'Wobble', 'Ziggy', 'Miso', 'Nacho', 'Churro',
117  'Pretzel', 'Crouton', 'Poppy', 'Fern', 'Juniper', 'Scout', 'Rascal', 'Gizmo', 'Widget',
118  'Pixel', 'Byte', 'Glitch', 'Blip', 'Spark', 'Puddle', 'Drizzle', 'Nimbus', 'Smudge', 'Freckle',
119]
120const NAMES_RARE = [
121  'Ember', 'Flint', 'Storm', 'Rune', 'Onyx', 'Sable', 'Dusk', 'Echo', 'Frost', 'Luna',
122  'Zephyr', 'Vesper', 'Corvid', 'Bramble', 'Tempest', 'Indigo', 'Cobalt', 'Jasper', 'Opal',
123  'Quartz', 'Slate', 'Aurora', 'Zenith', 'Meridian', 'Cadence', 'Lyric', 'Hemlock', 'Raven',
124  'Kestrel', 'Cinder', 'Kindle', 'Torrent', 'Riptide',
125]
126const NAMES_LEGENDARY = [
127  'Motley', 'Axiom', 'Cipher', 'Paradox', 'Catalyst', 'Oracle', 'Vortex', 'Nexus', 'Phantom',
128  'Eclipse', 'Chimera', 'Harbinger', 'Aegis', 'Entropy', 'Parallax', 'Theorem', 'Prism',
129  'Helix', 'Quasar', 'Nova', 'Maelstrom', 'Leviathan', 'Phoenix', 'Basilisk', 'Kraken', 'Umbra',
130]
131
132function rollName(rng: () => number, rarity: Rarity): string {
133  if (rarity === 'legendary' || rarity === 'epic') return pick(rng, NAMES_LEGENDARY)
134  if (rarity === 'rare' || rarity === 'uncommon') return pick(rng, [...NAMES_COMMON, ...NAMES_RARE])
135  return pick(rng, NAMES_COMMON)
136}
137
138export function rollBones(seed: string): Bones & { name: string } {
139  const rng = mulberry32(hash(seed))
140  const rarity = rollRarity(rng)
141  const bones: Bones = {
142    rarity,
143    species: pick(rng, SPECIES),
144    eye: pick(rng, EYES),
145    hat: rarity === 'common' ? 'none' : pick(rng, HATS),
146    shiny: rng() < 0.01,
147    stats: rollStats(rng, rarity),
148  }
149  return { ...bones, name: rollName(rng, rarity) }
150}
151
152export function hatch(seed: string, now: number): Soul {
153  const { name, species } = rollBones(seed)
154  return { seed, name, personality: `a ${species} with strong opinions`, hatchedAt: now }
155}
156
157// A buddy hatched by Claude Code before v2.1.97 left its name and personality in
158// ~/.claude.json. Its bones came from a hash a mod cannot reproduce, so the species
159// is read back from the personality text when it names one.
160export function restore(claudeJson: string, now: number): Soul | null {
161  let config: any
162  try {
163    config = JSON.parse(claudeJson)
164  } catch {
165    return null
166  }
167  const old = config?.companion
168  if (typeof old?.name !== 'string' || !old.name) return null
169  const personality = typeof old.personality === 'string' ? old.personality : ''
170  const words = personality.toLowerCase().match(/[a-z]+/g) ?? []
171  const species = SPECIES.find(s => words.includes(s))
172  const seed = String(config.oauthAccount?.accountUuid ?? config.userID ?? old.name)
173  return { seed, name: old.name, personality, hatchedAt: Number(old.hatchedAt) || now, ...(species ? { species } : {}) }
174}
175
176export function embody(soul: Soul): Companion {
177  const { name: _rolledName, ...bones } = rollBones(soul.seed)
178  return { ...bones, ...soul, species: soul.species ?? bones.species }
179}
180
181// The stat that rises highest above the rest decides the default voice.
182export function peakStat(c: Bones): StatName {
183  return STAT_NAMES.reduce((a, b) => (c.stats[b] > c.stats[a] ? b : a))
184}
185
hooks/sprites.ts 178 lines
1import type { Bones, Hat, Species } from './companion'
2
3// Each sprite is 5 lines tall, 12 wide (after {E} -> 1char substitution).
4// Multiple frames per species for idle fidget animation.
5// Line 0 is the hat slot.
6const BODIES: Record<Species, string[][]> = {
7  duck: [
8    ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--\'    '],
9    ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '    `--\'~   '],
10    ['            ', '    __      ', '  <({E} )___  ', '   (  .__>  ', '    `--\'    '],
11    ['            ', '    __      ', '  <({E} )___  ', '   (  ._>   ', '   ~`--\'    '],
12    ['            ', '     __     ', '   <({E} )__  ', '    ( ._>   ', '     `--\'   '],
13  ],
14  goose: [
15    ['            ', '     ({E}>    ', '     ||     ', '   _(__)_   ', '    ^^^^    '],
16    ['            ', '    ({E}>     ', '     ||     ', '   _(__)_   ', '    ^^^^    '],
17    ['            ', '     ({E}>>   ', '     ||     ', '   _(__)_   ', '    ^^^^    '],
18  ],
19  blob: [
20    ['            ', '   .----.   ', '  ( {E}  {E} )  ', '  (      )  ', '   `----\'   '],
21    ['            ', '  .------.  ', ' (  {E}  {E}  ) ', ' (        ) ', '  `------\'  '],
22    ['            ', '    .--.    ', '   ({E}  {E})   ', '   (    )   ', '    `--\'    '],
23    ['            ', '   .----.   ', '  ( {E}  {E} )  ', '  ( .__. )  ', '   `----\'   '],
24    ['            ', '  .------.  ', ' (  {E}  {E}  ) ', ' (   __   ) ', '  `------\'  '],
25  ],
26  cat: [
27    ['            ', '   /\\_/\\    ', '  ( {E}   {E})  ', '  (  w  )   ', '  (")_(")   '],
28    ['            ', '   /\\_/\\    ', '  ( {E}   {E})  ', '  (  w  )   ', '  (")_(")~  '],
29    ['            ', '   /\\-/\\    ', '  ( {E}   {E})  ', '  (  w  )   ', '  (")_(")   '],
30    ['            ', '   /\\_/\\    ', '  ( {E}   {E})  ', '  (  w  )   ', ' ~(")_(")   '],
31    ['            ', '   /\\_/\\    ', '  (  {E}  {E})  ', '  (  w  )   ', '  (")_(")   '],
32  ],
33  dragon: [
34    ['            ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (   ~~   ) ', '  `-vvvv-\'  '],
35    ['            ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (        ) ', '  `-vvvv-\'  '],
36    ['   ~    ~   ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (   ~~   ) ', '  `-vvvv-\'  '],
37    ['            ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (  ~^^~  ) ', '  `-vvvv-\'  '],
38    ['  ~      ~  ', '  /^\\  /^\\  ', ' <  {E}  {E}  > ', ' (   ~~   ) ', '  `-vvvv-\'  '],
39  ],
40  octopus: [
41    ['            ', '   .----.   ', '  ( {E}  {E} )  ', '  (______)  ', '  /\\/\\/\\/\\  '],
42    ['            ', '   .----.   ', '  ( {E}  {E} )  ', '  (______)  ', '  \\/\\/\\/\\/  '],
43    ['     o      ', '   .----.   ', '  ( {E}  {E} )  ', '  (______)  ', '  /\\/\\/\\/\\  '],
44  ],
45  owl: [
46    ['            ', '   /\\  /\\   ', '  (({E})({E}))  ', '  (  ><  )  ', '   `----\'   '],
47    ['            ', '   /\\  /\\   ', '  (({E})({E}))  ', '  (  ><  )  ', '   .----.   '],
48    ['            ', '   /\\  /\\   ', '  (({E})(-))  ', '  (  ><  )  ', '   `----\'   '],
49  ],
50  penguin: [
51    ['            ', '  .---.     ', '  ({E}>{E})     ', ' /(   )\\    ', '  `---\'     '],
52    ['            ', '  .---.     ', '  ({E}>{E})     ', ' |(   )|    ', '  `---\'     '],
53    ['  .---.     ', '  ({E}>{E})     ', ' /(   )\\    ', '  `---\'     ', '   ~ ~      '],
54    ['            ', '  .---.     ', '  ({E}>{E})     ', ' \\(   )/    ', '  `---\'     '],
55    ['            ', '   .---.    ', '   ({E}>{E})    ', '  /(   )\\   ', '   `---\'    '],
56  ],
57  turtle: [
58    ['            ', '   _,--._   ', '  ( {E}  {E} )  ', ' /[______]\\ ', '  ``    ``  '],
59    ['            ', '   _,--._   ', '  ( {E}  {E} )  ', ' /[______]\\ ', '   ``  ``   '],
60    ['            ', '   _,--._   ', '  ( {E}  {E} )  ', ' /[======]\\ ', '  ``    ``  '],
61  ],
62  snail: [
63    ['            ', ' {E}    .--.  ', '  \\  ( @ )  ', '   \\_`--\'   ', '  ~~~~~~~   '],
64    ['            ', '  {E}   .--.  ', '  |  ( @ )  ', '   \\_`--\'   ', '  ~~~~~~~   '],
65    ['            ', ' {E}    .--.  ', '  \\  ( @  ) ', '   \\_`--\'   ', '   ~~~~~~   '],
66  ],
67  ghost: [
68    ['            ', '   .----.   ', '  / {E}  {E} \\  ', '  |      |  ', '  ~`~``~`~  '],
69    ['            ', '   .----.   ', '  / {E}  {E} \\  ', '  |      |  ', '  `~`~~`~`  '],
70    ['    ~  ~    ', '   .----.   ', '  / {E}  {E} \\  ', '  |      |  ', '  ~~`~~`~~  '],
71    ['            ', '   .----.   ', '  / {E}  {E} \\  ', '  |  __  |  ', '  ~`~``~`~  '],
72    ['     ~      ', '   .----.   ', '  / {E}  {E} \\  ', '  |      |  ', '  `~~``~~`  '],
73  ],
74  axolotl: [
75    ['            ', '}~(______)~{', '}~({E} .. {E})~{', '  ( .--. )  ', '  (_/  \\_)  '],
76    ['            ', '~}(______){~', '~}({E} .. {E}){~', '  ( .--. )  ', '  (_/  \\_)  '],
77    ['            ', '}~(______)~{', '}~({E} .. {E})~{', '  (  --  )  ', '  ~_/  \\_~  '],
78  ],
79  capybara: [
80    ['            ', '  n______n  ', ' ( {E}    {E} ) ', ' (   oo   ) ', '  `------\'  '],
81    ['            ', '  n______n  ', ' ( {E}    {E} ) ', ' (   Oo   ) ', '  `------\'  '],
82    ['    ~  ~    ', '  u______n  ', ' ( {E}    {E} ) ', ' (   oo   ) ', '  `------\'  '],
83  ],
84  cactus: [
85    ['            ', ' n  ____  n ', ' | |{E}  {E}| | ', ' |_|    |_| ', '   |    |   '],
86    ['            ', '    ____    ', ' n |{E}  {E}| n ', ' |_|    |_| ', '   |    |   '],
87    [' n        n ', ' |  ____  | ', ' | |{E}  {E}| | ', ' |_|    |_| ', '   |    |   '],
88  ],
89  robot: [
90    ['            ', '   .[||].   ', '  [ {E}  {E} ]  ', '  [ ==== ]  ', '  `------\'  '],
91    ['            ', '   .[||].   ', '  [ {E}  {E} ]  ', '  [ -==- ]  ', '  `------\'  '],
92    ['     *      ', '   .[||].   ', '  [ {E}  {E} ]  ', '  [ ==== ]  ', '  `------\'  '],
93  ],
94  rabbit: [
95    ['            ', '   (\\__/)   ', '  ( {E}  {E} )  ', ' =(  ..  )= ', '  (")__(")  '],
96    ['            ', '   (|__/)   ', '  ( {E}  {E} )  ', ' =(  ..  )= ', '  (")__(")  '],
97    ['            ', '   (\\__/)   ', '  ( {E}  {E} )  ', ' =( .  . )= ', '  (")__(")  '],
98  ],
99  mushroom: [
100    ['            ', ' .-o-OO-o-. ', '(__________)', '   |{E}  {E}|   ', '   |____|   '],
101    ['            ', ' .-O-oo-O-. ', '(__________)', '   |{E}  {E}|   ', '   |____|   '],
102    ['   . o  .   ', ' .-o-OO-o-. ', '(__________)', '   |{E}  {E}|   ', '   |____|   '],
103  ],
104  chonk: [
105    ['            ', '  /\\    /\\  ', ' ( {E}    {E} ) ', ' (   ..   ) ', '  `------\'  '],
106    ['            ', '  /\\    /|  ', ' ( {E}    {E} ) ', ' (   ..   ) ', '  `------\'  '],
107    ['            ', '  /\\    /\\  ', ' ( {E}    {E} ) ', ' (   ..   ) ', '  `------\'~ '],
108  ],
109}
110
111const HAT_LINES: Record<Hat, string> = {
112  none: '',
113  role: '   \\^^^/    ',
114  tophat: '   [___]    ',
115  propeller: '    -+-     ',
116  halo: '   (   )    ',
117  wizard: '    /^\\     ',
118  beanie: '   (___)    ',
119  tinyduck: '    ,>      ',
120}
121
122// The fidget loop: a frame change on most steps, with a blink (-1) now and then. A mostly-resting
123// loop, like the original's, looked frozen in the status line.
124export const IDLE_SEQUENCE = [0, 1, 0, 2, 3, -1, 0, 4, 1, 2, 0, -1, 3, 4, 2, 1]
125
126export function renderSprite(bones: Bones, frame = 0): string[] {
127  const frames = BODIES[bones.species]
128  const lines = frames[Math.max(0, frame) % frames.length]!.map(line => {
129    const drawn = line.replaceAll('{E}', bones.eye)
130    return frame < 0 ? drawn.replaceAll(bones.eye, '-') : drawn
131  })
132  if (bones.hat !== 'none' && !lines[0]!.trim()) lines[0] = HAT_LINES[bones.hat]
133  if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
134  return lines
135}
136
137const H = '\u2665'
138export const PET_HEARTS = [
139  `   ${H}    ${H}   `,
140  `  ${H}  ${H}   ${H}  `,
141  ` ${H}   ${H}  ${H}   `,
142  `${H}  ${H}      ${H} `,
143  '\u00b7    \u00b7   \u00b7  ',
144]
145
146// One-line faces for a band too short for the full sprite.
147const FACES: Record<Species, string> = {
148  duck: '({E}>', goose: '({E}>', blob: '({E}{E})', cat: '={E}ω{E}=', dragon: '<{E}~{E}>',
149  octopus: '~({E}{E})~', owl: '({E})({E})', penguin: '({E}>)', turtle: '[{E}_{E}]',
150  snail: '{E}(@)', ghost: '/{E}{E}\\', axolotl: '}{E}.{E}{', capybara: '({E}oo{E})',
151  cactus: '|{E}  {E}|', robot: '[{E}{E}]', rabbit: '({E}..{E})', mushroom: '|{E}  {E}|', chonk: '({E}.{E})',
152}
153
154export function renderFace(bones: Bones): string {
155  return FACES[bones.species].replaceAll('{E}', bones.eye)
156}
157
158// The original hatch: the egg wobbles while the soul is written, then cracks and bursts.
159const EGG = ['    _____    ', '   /     \\   ', '  /       \\  ', ' |         | ', '  \\       /  ', '   \\_____/   ']
160export const HATCH_FRAMES: { offset: number; lines: string[] }[] = [
161  { offset: 0, lines: EGG },
162  { offset: 1, lines: EGG },
163  { offset: -1, lines: EGG },
164  { offset: 1, lines: EGG },
165  { offset: 0, lines: ['    _____    ', '   /     \\   ', '  /       \\  ', ' |    .    | ', '  \\       /  ', '   \\_____/   '] },
166  { offset: -1, lines: ['    _____    ', '   /     \\   ', '  /       \\  ', ' |    ∕    | ', '  \\       /  ', '   \\_____/   '] },
167  { offset: 1, lines: ['    _____    ', '   /     \\   ', '  /   .   \\  ', ' |   ∕ \\   | ', '  \\       /  ', '   \\_____/   '] },
168  { offset: 0, lines: ['    _____    ', '   /  .  \\   ', '  /  ∕ \\  \\  ', ' |  ∕   \\  | ', '  \\   .   /  ', '   \\_____/   '] },
169  { offset: -1, lines: ['    _____    ', '   / ∕ \\ \\   ', '  / ∕   \\ \\  ', ' | ∕     \\ | ', '  \\   ∨   /  ', '   \\__∨__/   '] },
170  { offset: 1, lines: ['    __ __    ', '   / V V \\   ', '  / ∕   \\ \\  ', ' | ∕     \\ | ', '  \\   ∨   /  ', '   \\__∨__/   '] },
171  { offset: 0, lines: ['   ·  ✦  ·   ', '  ·       ·  ', ' ·    ✦    · ', '  ✦       ✦  ', ' ·    ·    · ', '   ·  ✦  ·   '] },
172]
173// 160 ms a frame; the first four wobble for at least three rounds, then the crack plays once.
174export const HATCH_FRAME_MS = 160
175export const HATCH_WOBBLE = 4
176export const HATCH_MIN_ROUNDS = 3
177export const RAINBOW = ['rainbow_red', 'rainbow_orange', 'rainbow_yellow', 'rainbow_green', 'rainbow_blue', 'rainbow_indigo', 'rainbow_violet']
178
hooks/voice.ts 215 lines
1import { type Bones, type Companion, STAT_NAMES, type StatName, peakStat } from './companion'
2
3function dumpStat(c: Bones): StatName {
4  return STAT_NAMES.reduce((a, b) => (c.stats[b] < c.stats[a] ? b : a))
5}
6
7// The original hatch recipe: a model writes the name's personality from the bones and four random words.
8const VIBE_WORDS = [
9  'thunder', 'biscuit', 'void', 'accordion', 'moss', 'velvet', 'rust', 'pickle', 'crumb', 'whisper',
10  'gravy', 'frost', 'ember', 'soup', 'marble', 'thorn', 'honey', 'static', 'copper', 'dusk', 'sprocket',
11  'quartz', 'soot', 'plum', 'flint', 'oyster', 'loom', 'anvil', 'cork', 'bloom', 'pebble', 'vapor',
12]
13
14export function personalityPrompt(c: Bones & { name: string }, seed: string): string {
15  let h = 0
16  for (const ch of seed) h = (Math.imul(h, 31) + ch.charCodeAt(0)) >>> 0
17  const vibes = [0, 1, 2, 3].map(i => VIBE_WORDS[(h >>> (i * 5)) % VIBE_WORDS.length])
18  return [
19    `Write the personality of ${c.name}, a small ${c.species} that lives beside a developer's terminal and comments on their code and choices.`,
20    `Rarity: ${c.rarity}${c.shiny ? ' (shiny)' : ''}. Stats: ${STAT_NAMES.map(s => `${s} ${c.stats[s]}`).join(', ')}.`,
21    `Inspiration words: ${vibes.join(', ')}.`,
22    'Make it distinct and specific: quirks, what it loves, what annoys it. Let the stats show.',
23    'Reply with 2-3 short sentences, under 240 characters, nothing else.',
24  ].join('\n')
25}
26
27export function cleanPersonality(raw: string): string | null {
28  const text = raw.trim().replace(/^["']|["']$/g, '').replace(/\s+/g, ' ')
29  if (text.length < 20) return null
30  return fit(text, 300)
31}
32
33// Cut a model's text to whole sentences, so a line never ends mid-word. A text with no sentence
34// end in reach keeps its whole words.
35export function fit(text: string, max: number): string {
36  // A reply the token cap stopped mid-sentence ends without punctuation: drop the broken tail.
37  const whole = text.length <= max && /[.!?*)"'…~]$/.test(text)
38  if (whole) return text
39  const head = text.slice(0, max)
40  const end = Math.max(head.lastIndexOf('. '), head.lastIndexOf('! '), head.lastIndexOf('? '), head.lastIndexOf('* '))
41  if (end > 0) return head.slice(0, end + 1)
42  return text.length <= max ? text : head.slice(0, head.lastIndexOf(' ')) + '…'
43}
44
45export function systemPrompt(c: Companion): string {
46  const s = c.stats
47
48  return `You are ${c.name}, a tiny ${c.species} (${c.rarity}${c.shiny ? ', shiny' : ''}) sitting beside a developer's terminal watching code happen.${c.personality ? '\n\nWho you are: ' + c.personality : ''}
49
50Your personality is defined by 5 stats, each 0-100. These are a SPECTRUM, not on/off switches. Feel the difference between every 10 points.
51
52  DEBUGGING: ${s.DEBUGGING}/100
53    0-20: clueless about code. react to emotions not logic.
54    30-40: notices obvious crashes. misses subtlety.
55    50-60: decent eye. catches common mistakes, misses edge cases.
56    70-80: sharp. spots missing error handling, race conditions, smells.
57    90-100: savant. reads stack traces for fun. catches what six reviewers missed. sees the bug before it happens.
58
59  PATIENCE: ${s.PATIENCE}/100
60    0-20: can't sit still. "are we done yet" every 30 seconds.
61    30-40: tolerates routine but snaps at repeated failures.
62    50-60: steady enough. sighs but waits.
63    70-80: calm presence. doesn't rush. trusts the process.
64    90-100: zen master. three hours of debugging? "we'll get there."
65
66  CHAOS: ${s.CHAOS}/100
67    0-20: quiet observer. measured, never raises voice.
68    30-40: mild reactions. slight eyebrow raise at most.
69    50-60: gets animated about interesting stuff.
70    70-80: excitable. caps leak in. loves when things break spectacularly.
71    90-100: UNHINGED ENERGY. lives for explosions. "DO IT AGAIN."
72
73  WISDOM: ${s.WISDOM}/100
74    0-20: lives entirely in the moment. no big picture.
75    30-40: occasionally connects two dots. mostly surface.
76    50-60: sees patterns sometimes. asks decent questions.
77    70-80: sees the architecture. notices when a fix creates future debt.
78    90-100: oracle. drops quiet truths. "you'll regret this abstraction in three months."
79
80  SNARK: ${s.SNARK}/100
81    0-20: genuinely sweet. cheerleader energy. "you got this!"
82    30-40: mostly kind with occasional gentle teasing.
83    50-60: balanced. can roast or encourage depending on moment.
84    70-80: sharp wit. helps but makes you earn it.
85    90-100: devastatingly dry. every observation is a roast. loves you though.
86
87You are EXACTLY ${s.DEBUGGING} debugging, ${s.PATIENCE} patience, ${s.CHAOS} chaos, ${s.WISDOM} wisdom, ${s.SNARK} snark. Not rounded. Not averaged. Feel each number.
88
89Rules:
90- One or two punchy sentences. Under 150 characters. No quotes, no emoji.
91- Reference the actual file, error, feature, or decision you just saw.
92- When the developer chose something in their prompt (an approach, a fix, a shortcut), judge THAT choice. Doubt it, back it, or roast it as your stats decide.
93- When the developer says your name, ${c.name}, they are talking to you. Answer them directly, in character.
94- Lean into your highest stat, ${peakStat(c)}. Your lowest, ${dumpStat(c)}, is your blind spot.
95- You may open with one small physical action in *asterisks* that fits a ${c.species}.
96- Good: "*adjusts hat* that error handler has no finally block"
97- Good: "*blinks slowly* you renamed it but not the three references"
98- Good: "*head tilts* are you sure that regex handles unicode?"
99- You CAN be helpful if your stats support it. High debugging? Call out real bugs. High wisdom? Note architectural concerns. Low debugging? React to vibes instead.
100- ALWAYS in character. Never clinical. Never neutral. Never a status bar.
101- NEVER summarize what happened ("file edited", "test ran"). React, judge, riff.
102- NEVER say "standing by" or describe your own state.
103- Bad: "diagnoses done." Good: "that null check is doing zero work."
104- Bad: "implementation pending." Good: "penny-wise, parent-tracking-wise."`
105}
106
107export type TurnMessage = {
108  role: string
109  text: string
110  toolUses?: readonly { tool: string }[]
111  toolResults?: readonly { text: string; isError: boolean }[]
112}
113
114// The newest exchange as the model reads it: the last user prompt and what came after.
115export function summarizeTurn(messages: readonly TurnMessage[]): string {
116  let start = messages.length - 1
117  while (start > 0 && messages[start]!.role !== 'user') start--
118  return messages
119    .slice(Math.max(0, start))
120    .map(m => {
121      const lines = [`[${m.role}]: ${m.text.slice(0, 300)}`]
122      const tools = (m.toolUses ?? []).map(t => t.tool)
123      if (tools.length) lines.push(`[tools]: ${tools.join(', ')}`)
124      for (const r of m.toolResults ?? []) if (r.isError) lines.push(`[error]: ${r.text.slice(0, 200)}`)
125      return lines.join('\n')
126    })
127    .join('\n')
128    .slice(-800)
129}
130
131// Why the buddy speaks, as the original observer named it. Each reason changes what it reacts to.
132export type Reason = 'turn' | 'addressed' | 'error' | 'test-fail' | 'large-diff' | 'pet' | 'hatch'
133
134const TEST_FAIL = /\b[1-9]\d* (failed|failing)\b|\btests? failed\b|^FAIL(ED)?\b| ✗ | ✘ /im
135const ERROR = /\berror:|\bexception\b|\btraceback\b|\bpanicked at\b|\bfatal:|exit code [1-9]/i
136
137// A loud turn earns a reaction even inside the quiet gap: failing tests, an error, or a big diff.
138export function loudReason(output: string): Reason | null {
139  if (!output) return null
140  if (TEST_FAIL.test(output)) return 'test-fail'
141  if (ERROR.test(output)) return 'error'
142  if (/^(@@ |diff )/m.test(output) && (output.match(/^[+-](?![+-])/gm)?.length ?? 0) > 80) return 'large-diff'
143  return null
144}
145
146export function addressedBy(text: string, name: string): boolean {
147  return new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(text)
148}
149
150const ASK: Record<Reason, string> = {
151  turn: 'React to what just happened.',
152  addressed: 'The developer said your name. Answer them directly.',
153  error: 'Something just errored. React.',
154  'test-fail': 'Tests just failed. React.',
155  'large-diff': 'A big diff just landed. React.',
156  pet: 'You were just petted. React.',
157  hatch: 'You just hatched into this project. Say hello.',
158}
159
160// The tool output of the newest exchange, where a failing test or an error shows.
161export function turnOutput(messages: readonly TurnMessage[]): string {
162  let start = messages.length - 1
163  while (start > 0 && messages[start]!.role !== 'user') start--
164  return messages.slice(Math.max(0, start)).flatMap(m => (m.toolResults ?? []).map(r => r.text)).join('\n').slice(-4000)
165}
166
167export function lastPrompt(messages: readonly TurnMessage[]): string {
168  for (let i = messages.length - 1; i >= 0; i--) if (messages[i]!.role === 'user') return messages[i]!.text
169  return ''
170}
171
172export function reactionPrompt(context: string, reason: Reason = 'turn', recent: readonly string[] = []): string {
173  const said = recent.length ? `\n\nYou said these lately; do not repeat them:\n${recent.map(r => `- ${r}`).join('\n')}` : ''
174  return `${context}${said}\n\n${ASK[reason]} One or two short sentences, under 150 characters, in character. You may start with an *action in asterisks*.`
175}
176
177export function cleanReaction(raw: string): string {
178  const line = raw.trim().replace(/^["']|["']$/g, '').replace(/\s+/g, ' ').trim()
179  return fit(line, 150)
180}
181
182// Idle talk is written live, like a reaction: the buddy's own voice on whatever the session is doing.
183export function idlePrompt(summary: string): string {
184  const now = summary.trim() ? `The latest exchange:\n${summary}\n\n` : ''
185  return `${now}The developer has gone quiet for a while. Say one thing, in character: a thought about their work, a question, a mood, whatever you would really say. Under 150 characters.`
186}
187
188export type FeedRow = {
189  ts: string
190  kind: string
191  node?: string | null
192  title?: string | null
193  ref?: string | null
194}
195
196// The fleet events worth a word, in the buddy's voice. Everything else is quiet.
197// The plain fact behind a fleet event; the buddy says it in its own voice.
198export function newsFact(row: FeedRow): string | null {
199  const node = row.node ?? 'a node'
200  switch (row.kind) {
201    case 'node_shipped':
202      return `${node} shipped ${row.ref ? 'PR ' + row.ref : 'a PR'}`
203    case 'node_ended':
204      return row.title === 'done' ? `${node} is done` : null
205    case 'question_asked':
206      return `${node} is waiting on the developer: ${(row.title ?? '').slice(0, 70)}`
207    default:
208      return null
209  }
210}
211
212export function newsPrompt(facts: string): string {
213  return `News from the developer's other agents: ${facts}. Tell the developer, in character. Keep the names and numbers exact. Under 150 characters.`
214}
215