SLOPSHOPPER

pixlings

A tiny pixel creature that lives above your Claude Code prompt. It counts down your prompt cache and guards it on model switches, wakes Claude when your usage…

newpanebandspinnerrowsguard
v0.3.0MITupdated 2026-10-05cglremrcn/pixlings/plugins/pixlings
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pixlings
│ ┃ pixling-room ✕ › fix the failing auth test and add an audit log call │ ┃ Hatching… │ ┃ Any second now. ⏺ Read(src/auth.ts) │ ┃ ▄▀▀▀▀▄ ⎿ Read 6 lines │ ┃ ▄▀█▀▀███▀▄ ⏺ Update(src/auth.ts) │ ┃ ▄▀████▀███▀▄ ⎿ Added 2 lines, removed 1 line │ ┃ ███▀█████▀██ ⏺ Bash(bun test) │ ┃ ████████▀█▀█ ⎿ 3 pass, 1 fail │ ┃ █▀█████▀██ │ ┃ ▀▀▀▀▀▀▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Hissed for 42s · done 4:20 PM │ │ › /pixling │ ⎿ pixlings: Tofu the Goose, level 1. /pixling share saves its card │ ⎿ pixlings: Personality: Chaotic, loyal, and very loud about missi │ │ ✻ Stealing a semicolon… (•ᴗ•) Tofu · Lv 1 I'm hiding under the desk. (rm -rf) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
(•ᴗ•) Tofu · Lv 1 I'm hiding under the desk. (rm -rf)
Pane · pixling-room
Hatching… Any second now. ▄▀▀▀▀▄ ▄▀█▀▀███▀▄ ▄▀████▀███▀▄ ███▀█████▀██ ████████▀█▀█ █▀█████▀██ ▀▀▀▀▀▀▀▀
README

Pixlings

A tiny pixel creature that lives above your Claude Code prompt. It counts down your prompt cache and asks before a model switch throws a warm one away, wakes Claude when your usage limit resets, brings a little helper for every subagent, makes sound and desktop notifications on Windows, macOS and Linux, yelps before risky shell commands, and rolls its eyes at "You're absolutely right".

Coming from the retired /buddy? /pixling adopt brings your companion back with its name, personality and days together, read locally from your Claude Code config.

Install

claude plugin marketplace add cglremrcn/pixlings
claude plugin install pixlings@pixlings

Needs Claude Code 2.1.287 or newer. Start a new session and an egg hatches.

Use

  • /pixling shows its card; /pixling help lists every command.
  • /pixling room opens its room, which works in the terminal, the desktop app, VS Code and the mobile app.
  • Every setting is in /config under Pixlings.

Out of the box it never calls a model and adds nothing to Claude's prompt. AI quips (Claude Haiku, about a hundred tokens each, at most one a minute, cost shown) are off until you turn them on.

Full documentation: https://github.com/cglremrcn/pixlings

Source 22 files
hooks/register.tsx 2530 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, PromptOrigin, Register, Timer } from 'claude-code'
3
4import type { PixlingsBubble, PixlingsCache, PixlingsEffect, PixlingsMini, PixlingsNap, PixlingsVital, PixlingsView } from '../types'
5import type { PixlingsPersona } from '../types'
6import { award, BADGES, countDay, earnedBadges, localDate, recapLine, touchDay } from './lib/badges.ts'
7import { baseMood, holdFor, moodNow, PRIORITY, react, speak } from './lib/brain.ts'
8import type { Bubble, Held } from './lib/brain.ts'
9import { adopt, companionOf, configPath, speciesNamed } from './lib/buddy.ts'
10import type { Companion } from './lib/buddy.ts'
11import { CARD_BG, CARD_SCALE, renderCard, shareText } from './lib/card.ts'
12import { CANVAS_W, HATCH_MS, MINI_LEAVE_MS, MINI_POOF_MS, miniTint, renderFrame, renderHatch, renderSilhouette, samePixels, SPRITE_X, SQUAD_CAP, squadWidth } from './lib/canvas.ts'
13import type { Face, Hat, Icon, MiniView, Mood, Pixels } from './lib/canvas.ts'
14import { blockingWindow, clockTime, formatDuration, iconFor, isTestCommand, linesWritten, riskOf, runsGitCommit, testOutcome } from './lib/detect.ts'
15import type { LimitWindow, Risk } from './lib/detect.ts'
16import { say } from './lib/lines.ts'
17import type { LineKey, Slots } from './lib/lines.ts'
18import {
19  assetPath,
20  linuxPlayer,
21  linuxSpeaker,
22  notificationArgv,
23  openArgv,
24  platformOfUname,
25  speakable,
26  windowsPlayer,
27  wavPipeArgv,
28  windowsSpeech,
29  writeBytesArgv,
30} from './lib/platform.ts'
31import type { Platform, Player } from './lib/platform.ts'
32import { encodePng } from './lib/png.ts'
33import { mayQuip, quipOf, quipRequest, tokensOf } from './lib/quips.ts'
34import type { QuipFacts, QuipKind, QuipReply, QuipRequest } from './lib/quips.ts'
35import {
36  bump,
37  daysTogether,
38  gain,
39  gearOf,
40  hatchPixling,
41  isUnreadable,
42  levelOf,
43  mergeSave,
44  rehatch,
45  revive,
46  UNLOCKS,
47  xpBar,
48} from './lib/progress.ts'
49import type { Day, Pixling, Stats, XpEvent } from './lib/progress.ts'
50import { base64, rasterOf, rowsFor, toSvg } from './lib/raster.ts'
51import { isWalking, newWalker, roamRange, walk } from './lib/roam.ts'
52import type { RoamMode, Walker } from './lib/roam.ts'
53import { CARD_MAX, ROOM_EGG_KEY, ROOM_SPRITE_KEY, briefLine, cardModel, drawCard, drawRoom, personaLine, seatOf, tabOf } from './lib/room.ts'
54import type { RoomActions, RoomKit, RoomModel } from './lib/room.ts'
55import { RARITY_COLOR, RARITY_STARS, SPECIES, speciesById } from './lib/sprites.ts'
56import type { Species } from './lib/sprites.ts'
57import { addTics, isEyeRoll, ticsIn, topTics } from './lib/tics.ts'
58import type { Tic } from './lib/tics.ts'
59import {
60  applyKnownTtl,
61  decodeTtl,
62  encodeTtl,
63  freshCache,
64  guardDecision,
65  hitPercent,
66  modelLabel,
67  observe,
68  remainingMs,
69  tokens,
70  turnQuota,
71  vitals,
72} from './lib/vitals.ts'
73import { animalese, durationMs, wavOf } from './lib/voice.ts'
74import type { Cache, QuotaDelta, RequestUsage, StoredTtl, Ttl } from './lib/vitals.ts'
75
76/** The elements the band and the card share across surfaces. */
77type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
78
79const view = atom({ plugin: 'pixlings', key: 'view' } as const, null as PixlingsView | null)
80const bubbleAtom = atom({ plugin: 'pixlings', key: 'bubble' } as const, null as PixlingsBubble)
81const napAtom = atom({ plugin: 'pixlings', key: 'nap' } as const, null as PixlingsNap)
82const hatchAtom = atom({ plugin: 'pixlings', key: 'hatchAt' } as const, null as number | null)
83const moodAtom = atom({ plugin: 'pixlings', key: 'mood' } as const, 'idle')
84const vitalsAtom = atom({ plugin: 'pixlings', key: 'vitals' } as const, [] as PixlingsVital[])
85const cacheAtom = atom({ plugin: 'pixlings', key: 'cache' } as const, null as PixlingsCache)
86
87const STORE_KEY = 'pixling'
88/** Where a save this version cannot read is copied before the session runs without saving. */
89const BACKUP_KEY = 'pixlingBackup'
90const SEEN_KEY = 'lastSeen'
91const TTL_KEY = 'cacheTtl'
92/** The cache warning comes this long before it expires, and only over a context worth saving. */
93const COOLING_WARN_MS = 60_000
94const MIN_WARN_CONTEXT = 20_000
95const RASTER_KEY = 'pixling'
96const FRAME_MS = 100
97const DOZE_AFTER_MS = 15 * 60_000
98const LONG_TURN_MS = 45_000
99// An edit this many lines long is worth a quip, when quips are on.
100const BIG_DIFF_LINES = 150
101const NAP_RETRY_MS = 20 * 60_000
102const NAP_GRACE_MS = 20_000
103/** Past this a nap's conversation is stale: the reset is announced, Claude is not continued. */
104const NAP_MAX_AGE_MS = 6 * 3_600_000
105/** Continue prompts tried when no reset time can be read (an API key's limits are unreported). */
106const NAP_MAX_RETRIES = 3
107/** A reset further off than this is a weekly one: its time is given with the day. */
108const FAR_RESET_MS = 20 * 3_600_000
109const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] as const
110const CONTINUE_PROMPT =
111  'The usage limit has reset. Please continue the task you were working on where you left off.'
112const RETRY_LINE = 'Trying again, in case the limit has reset...'
113const MAYBE_LINE = 'The limit may have reset by now.'
114const GIVE_UP_LINE = "I can't tell when the limit resets. Send a message when you're ready."
115const HATCH_LABEL = 'Hatch a new egg'
116
117/** Prompts no person sent: another plugin's, a background task's, a schedule's, another agent's. */
118const NOT_THE_PERSON: ReadonlySet<PromptOrigin['kind']> = new Set<PromptOrigin['kind']>([
119  'plugin',
120  'task-notification',
121  'scheduled-trigger',
122  'peer',
123  'peer-send-message',
124  'projects-relay',
125  'coordinator',
126  'observer',
127  'observer-activity',
128  'unclassified',
129])
130
131const FACES: Readonly<Record<Mood, string>> = {
132  idle: '(•ᴗ•)',
133  working: '(•_•)…',
134  happy: '(^ᴗ^)',
135  celebrate: '\\(^ᴗ^)/',
136  sad: '(╥﹏╥)',
137  alarmed: '(°ロ°)!',
138  attention: '(•o•)!',
139  sleep: '(-ᴗ-)zz',
140  love: '(♡ᴗ♡)',
141  dizzy: '(@_@)',
142  walk: '(•ᴗ•)♪',
143  unimpressed: '(¬_¬)',
144}
145
146const TONE_COLOR: Readonly<Record<PixlingsVital['tone'], string | undefined>> = {
147  good: '#6fdc8c',
148  warn: '#ffc53d',
149  bad: '#ff5f5f',
150  cold: '#7fd3ff',
151  dim: undefined,
152}
153
154/** The hover card's fill and ink: its own pair, readable whatever the terminal's colors are. */
155const CARD_FILL = '#1a1b26'
156const CARD_FILL_PX = parseInt(CARD_FILL.slice(1), 16)
157const CARD_INK = '#c0caf5'
158
159const isTtl = (value: unknown): value is Ttl => value === '5m' || value === '1h'
160
161const hex = (color: number): string => `#${color.toString(16).padStart(6, '0')}`
162
163type Reaction = {
164  mood?: Mood
165  priority: number
166  holdMs?: number
167  line?: LineKey
168  slots?: Slots
169  /** A line said as written, in place of one picked for `line`. */
170  text?: string
171  /** Shown after the line in the bubble but not said aloud: a quip's token cost. */
172  suffix?: string
173  sound?: string
174  /** Plays under the "important" sound setting too, and wins over a held mood. */
175  isImportant?: boolean
176  notify?: string
177}
178
179type GitOperation = {
180  commit?: { sha: string; branch?: string }
181  push?: { branch: string }
182  pr?: { number: number; action: string }
183}
184
185/**
186 * The side effects the pixling needs, bound once per load in `session.start`. Mods spell `$`
187 * only at its call sites, so the logic below talks to the engine through these closures.
188 */
189type Io = {
190  now: () => Promise<number>
191  after: (ms: number, fn: () => void) => Timer
192  setView: (value: PixlingsView) => Promise<void>
193  setBubble: (value: PixlingsBubble) => Promise<void>
194  setNap: (value: PixlingsNap) => Promise<void>
195  setHatchAt: (value: number | null) => Promise<void>
196  setMood: (value: Mood) => Promise<void>
197  /** The saved pixling as the store holds it now, which another session may have written. */
198  load: () => Promise<unknown>
199  save: (value: Pixling) => Promise<void>
200  backup: (value: unknown) => Promise<void>
201  /** The conversation's id; null when it cannot be read. */
202  sessionId: () => Promise<string | null>
203  run: (argv: string[], timeoutMs: number) => Promise<string>
204  playAsset: (asset: string) => Promise<void>
205  toast: (text: string, timeoutMs: number) => void
206  invalidate: () => void
207  blit: (requestId: string, key: string, cells: string) => Promise<boolean>
208  usage: () => Promise<readonly LimitWindow[]>
209  submit: (text: string) => Promise<void>
210  setVitals: (value: PixlingsVital[]) => Promise<void>
211  setCache: (value: Cache) => Promise<void>
212  /** The TTL with when it was learned and from where (encodeTtl), as the store keeps it. */
213  saveTtl: (stored: StoredTtl) => Promise<void>
214  /** The prompt box's draft: a person typing is about to reply anyway. */
215  draft: () => Promise<string>
216  /** macOS's own voice, through the engine. */
217  speak: (text: string) => Promise<void>
218  pipe: (argv: string[], stdin: string, timeoutMs: number) => Promise<void>
219}
220
221export const register: Register = (on, options) => {
222  const soundMode = String(options['sound'] ?? 'all')
223  const bandMode = String(options['band'] ?? 'full')
224  const chatter = String(options['chatter'] ?? 'normal')
225  const wantsNotifications = options['notifications'] !== false
226  const wantsAutoContinue = options['autoContinue'] !== false
227  const wantsVitals = options['vitals'] !== false
228  const wantsCacheWarning = options['cacheWarning'] !== false
229  const ttlSetting = String(options['cacheTtl'] ?? 'auto')
230  const wantsRoam = options['roam'] !== false
231  const voiceMode = String(options['voice'] ?? 'animalese')
232
233  // The engine's clock is the one timers run on; Date.now() is synced to it every second so
234  // reading the time stays synchronous (a frame is drawn ten times a second).
235  let clockOffset = 0
236  let ticks = 0
237  const now = (): number => Date.now() + clockOffset
238  const syncClock = async (port: Io): Promise<void> => {
239    clockOffset = (await port.now()) - Date.now()
240  }
241
242  // Everything below lives as long as this load of the module; a hot reload starts it over,
243  // while the pixling itself is in $.store and the band's values in $.state.
244  let io: Io | null = null
245  let pixling: Pixling | null = null
246  let platform: Platform = 'unknown'
247  let player: Player = { kind: 'none' }
248  let soundRoot = ''
249  let held: Held | null = null
250  let bubble: Bubble | null = null
251  let bubbleId = 0
252  let base: Mood = 'idle'
253  let baseSince = 0
254  let isWorking = false
255  let icon: { icon: Icon; until: number } | null = null
256  let lastActivity = 0
257  let hatchAt: number | null = null
258  let nap: PixlingsNap = null
259  let napTimer: Timer | null = null
260  let napRetries = 0
261  /** Counts cancellations: a wake that is still on its way checks it before it says continue. */
262  let napEpoch = 0
263  let bandId: string | null = null
264  let bandRows = 0
265  let lastFrame: Pixels | null = null
266  let svgSeenAt = 0
267  let lastInvalidate = 0
268  /** The room's sprite as a terminal shows it, by its key: the frame clock repaints it in place. */
269  let roomSprite: { key: string; columns: number; rows: number; cells: string } | null = null
270  let lastSoundAt = 0
271  let lastTest: 'pass' | 'fail' | null = null
272  let lastFailed: number | null = null
273  let turnVerb: string | null = null
274  let turnPast: string | null = null
275  let lastLimitAt = 0
276  let petsToday = 0
277  let saidLateNight = ''
278  let warnedWindows = new Set<string>()
279  let lastMoodShown: Mood = 'idle'
280  let saveTimer: Timer | null = null
281  let cache: Cache = freshCache()
282  let isTtlPinned = false
283  let contextPercent: number | null = null
284  let limits: readonly LimitWindow[] = []
285  let warnedCacheAt: number | null = null
286  let lastVitals = ''
287  let walker: Walker | null = null
288  let bandColumns = 0
289  let speaker: ((text: string) => string[]) | null = null
290  let lastSpokenAt = 0
291  let voicePipe: string[] | null = null
292  let voiceUntil = 0
293  /**
294   * Nobody watches: a `-p` run, or an SDK run with no surface attached. Nothing is shown, played
295   * or said, and stats still count. An SDK host that draws (the desktop app, VS Code) attaches a
296   * surface, at the start or later, and the session is watched from then on.
297   */
298  let isQuiet = false
299  /** The pixling as this session loaded or last wrote it: what its own changes are counted from. */
300  let savedAs: Pixling | null = null
301  /** The store holds a save this version cannot read: it is left alone, nothing is written. */
302  let isReadOnly = false
303  let saving: Promise<void> = Promise.resolve()
304  /** The conversation a nap fell asleep in, and when: it continues only that one, and not late. */
305  let napSession: string | null = null
306  let napSince = 0
307  /** The band had too few rows for the sprite and drew one line instead. */
308  let isCramped = false
309
310  const species = (): Species => (pixling && speciesById(pixling.species)) || SPECIES[0]!
311
312  // Persistence ---------------------------------------------------------------------------
313
314  const persist = (): void => {
315    if (!io || saveTimer || isReadOnly) return
316    const port = io
317    saveTimer = port.after(400, () => {
318      saveTimer = null
319      void checkBadges()
320      void commit()
321    })
322  }
323
324  /**
325   * Saves by folding what this session changed into what the store holds now, since another
326   * terminal may have saved meanwhile. One save at a time; a save this version cannot read is
327   * never written over.
328   */
329  const commit = (): Promise<void> => {
330    saving = saving.then(writeSave).catch(() => undefined)
331    return saving
332  }
333
334  const writeSave = async (): Promise<void> => {
335    const port = io
336    if (!port || !pixling || isReadOnly) return
337    const raw = await port.load()
338    if (isUnreadable(raw)) {
339      await readOnly(raw)
340      return
341    }
342    const ours = pixling
343    const stored = revive(raw)
344    const merged = stored ? mergeSave(stored, savedAs, ours) : ours
345    await port.save(merged)
346    savedAs = merged
347    const shown = pixling
348    // What changed while the write was out is carried over onto what was written.
349    pixling = shown === ours ? merged : mergeSave(merged, ours, shown)
350    if (viewKey(pixling) !== viewKey(shown)) await publishView()
351  }
352
353  const viewKey = (p: Pixling): string => `${p.species}|${p.isShiny}|${p.name}|${p.xp}|${p.hat}|${p.face}`
354
355  /** A save this version cannot read (a newer version's, or damaged): copied aside, left alone. */
356  const readOnly = async (raw: unknown): Promise<void> => {
357    const port = io
358    if (!port || isReadOnly) return
359    isReadOnly = true
360    await port.backup(raw).catch(() => undefined)
361    toast(`Pixlings can't read its save (a newer version's?). It is left as it was, a copy kept as "${BACKUP_KEY}"; nothing is saved this session.`, 12_000)
362  }
363
364  const toast = (text: string, timeoutMs: number): void => {
365    if (io && !isQuiet && !isAway) io.toast(text, timeoutMs)
366  }
367
368  const publishView = async (): Promise<void> => {
369    if (!io || !pixling) return
370    const { level, into, need } = levelOf(pixling.xp)
371    const { hat, face } = gearOf(pixling)
372    await io.setView({
373      name: pixling.name,
374      speciesId: pixling.species,
375      isShiny: pixling.isShiny,
376      level,
377      xpInto: into,
378      xpNeed: need,
379      hat,
380      face,
381    })
382    await publishPersona()
383  }
384
385  // Sound and notifications ---------------------------------------------------------------
386
387  const play = (id: string, isImportant = false): void => {
388    const port = io
389    if (!port || isQuiet || isAway || soundMode === 'off' || (soundMode === 'important' && !isImportant)) return
390    lastSoundAt = now()
391    const asset = `sounds/${id}.wav`
392    port.after(0, () => {
393      const playing =
394        player.kind === 'engine'
395          ? port.playAsset(asset)
396          : player.kind === 'argv'
397            ? port.run(player.argv(assetPath(soundRoot, asset, platform)), 20_000)
398            : Promise.resolve()
399      // No player on this machine: the pixling stays silent rather than noisy.
400      void Promise.resolve(playing).catch(() => undefined)
401    })
402  }
403
404  const notify = (body: string): void => {
405    const port = io
406    if (!port || isQuiet || isAway || !wantsNotifications || !pixling) return
407    const argv = notificationArgv(platform, `${pixling.name} · Claude Code`, body)
408    if (!argv) return
409    port.after(0, () => {
410      void port.run(argv, 15_000).catch(() => undefined)
411    })
412  }
413
414  /**
415   * The line said in the pixling's own voice: synthesized for this line and played from memory.
416   * Chatter never talks over a sound that means something, nor over itself.
417   */
418  const chirp = (text: string): void => {
419    const port = io
420    const argv = voicePipe
421    if (!port || !argv || soundMode !== 'all') return
422    const at = now()
423    if (at - lastSoundAt < 1500 || at < voiceUntil) return
424    const pcm = animalese(text, species().voice)
425    if (pcm.length === 0) return
426    voiceUntil = at + durationMs(pcm) + latency()
427    const wav = base64(wavOf(pcm))
428    port.after(0, () => {
429      void port.pipe(argv, wav, 20_000).catch(() => undefined)
430    })
431  }
432
433  /** Reads a line aloud with the system voice; false when there is no voice to read it. */
434  const speakLine = (text: string, delayMs: number): boolean => {
435    const port = io
436    if (!port || soundMode === 'off') return true
437    const at = now()
438    if (at - lastSpokenAt < 4000) return true
439    const line = speakable(text)
440    const reader = speaker
441    const speaking: (() => Promise<unknown>) | null =
442      platform === 'mac'
443        ? () => port.speak(line)
444        : platform === 'windows' || platform === 'wsl'
445          ? () => port.pipe(windowsSpeech(), line, 20_000)
446          : reader
447            ? () => port.run(reader(line), 20_000)
448            : null
449    if (!speaking || !line) return false
450    lastSpokenAt = at
451    port.after(delayMs, () => {
452      void Promise.resolve()
453        .then(speaking)
454        .catch(() => undefined)
455    })
456    return true
457  }
458
459  const voice = (text: string, priority: number, hasSound: boolean, isImportant: boolean): void => {
460    if (voiceMode === 'off' || isQuiet || isAway) return
461    if (voiceMode === 'speech') {
462      // Under the "important" sound setting only what matters is read aloud.
463      if (soundMode === 'important' && !isImportant) return
464      if (priority >= PRIORITY.done && speakLine(text, hasSound ? 800 : 0)) return
465    }
466    if (!hasSound) chirp(text)
467  }
468
469  // Expression ----------------------------------------------------------------------------
470
471  const currentMood = (at: number): { mood: Mood; since: number } => {
472    const nextBase = baseMood({
473      isSleeping: nap !== null,
474      isWorking,
475      isDozing: at - lastActivity > DOZE_AFTER_MS,
476    })
477    if (nextBase !== base) {
478      base = nextBase
479      baseSince = at
480    }
481    return moodNow(held, base, baseSince, at)
482  }
483
484  const syncMinimal = async (): Promise<void> => {
485    if (!io || (bandMode !== 'minimal' && !isCramped)) return
486    const { mood } = currentMood(now())
487    if (mood !== lastMoodShown) {
488      lastMoodShown = mood
489      await io.setMood(mood)
490    }
491  }
492
493  const express = async (r: Reaction): Promise<void> => {
494    const port = io
495    if (!port || !pixling) return
496    const at = now()
497    if (r.mood) {
498      const next = react(held, { mood: r.mood, priority: r.priority, holdMs: r.holdMs ?? 3000 }, at)
499      if (next) held = next
500      else if (!r.isImportant) return
501    }
502    if ((r.line || r.text) && (chatter !== 'quiet' || r.priority >= PRIORITY.testFail)) {
503      const text = r.text ?? (r.line ? say(r.line, { name: pixling.name, ...r.slots }, pixling.species, Math.random) : '')
504      const shown = r.suffix ? `${text}${r.suffix}` : text
505      const spoken = speak(bubble, shown, r.priority, holdFor(shown), at)
506      if (spoken) {
507        bubble = spoken
508        bubbleId += 1
509        const id = bubbleId
510        await port.setBubble({ text: shown, id })
511        voice(text, r.priority, r.sound !== undefined, r.isImportant === true)
512        port.after(spoken.until - at, () => {
513          if (bubbleId === id) {
514            bubble = null
515            void port.setBubble(null)
516          }
517        })
518      }
519    }
520    if (r.sound) play(r.sound, r.isImportant)
521    if (r.notify) notify(r.notify)
522    await syncMinimal()
523  }
524
525  const grant = async (event: XpEvent, stat?: keyof Stats): Promise<void> => {
526    if (!io || !pixling) return
527    const { pixling: next, levelUp } = gain(stat ? bump(pixling, stat) : pixling, event)
528    pixling = next
529    persist()
530    await publishView()
531    if (levelUp) {
532      const unlock = levelUp.unlocks[levelUp.unlocks.length - 1]
533      await express({
534        mood: 'celebrate',
535        priority: PRIORITY.celebrate,
536        holdMs: 3500,
537        line: unlock ? 'unlock' : 'levelUp',
538        slots: { level: levelUp.level, item: unlock?.label },
539        sound: 'levelup',
540      })
541      // A tall hat makes the band taller: redraw at the new size.
542      io.invalidate()
543    }
544  }
545
546  const count = (stat: keyof Stats): void => {
547    if (pixling) {
548      pixling = bump(pixling, stat)
549      persist()
550    }
551  }
552
553  const tally = (field: Exclude<keyof Day, 'date' | 'xp'>): void => {
554    if (pixling) {
555      pixling = countDay(pixling, field)
556      persist()
557    }
558  }
559
560  const isHatching = (at: number): boolean => hatchAt !== null && at - hatchAt < HATCH_MS + 2600
561
562  const checkBadges = async (): Promise<void> => {
563    const port = io
564    if (!port || !pixling || isHatching(now())) return
565    const { pixling: next, earned } = award(pixling, now())
566    const [first] = earned
567    if (!first) return
568    // Saved by the persist that called this, right after.
569    pixling = next
570    await express({
571      mood: 'celebrate',
572      priority: PRIORITY.celebrate,
573      holdMs: 3500,
574      line: 'badge',
575      slots: { item: `${first.emoji} ${first.name}` },
576      sound: 'badge',
577    })
578    const more = earned.length > 1 ? ` (+${earned.length - 1} more)` : ''
579    toast(`🏅 Badge: ${first.emoji} ${first.name}, ${first.how.toLowerCase()}${more}. See /pixling badges`, 8000)
580  }
581
582  /** The pixling's first look at today: moves the streak, and hands back yesterday's work once. */
583  const greetDay = (): Day | null => {
584    if (!pixling) return null
585    const day = touchDay(pixling, now())
586    pixling = day.pixling
587    if (day.isNewDay) persist()
588    return day.recap
589  }
590
591  /** Yesterday's work in the bubble, and the streak in a toast. */
592  const showRecap = async (recap: Day, priority: number): Promise<void> => {
593    if (!pixling) return
594    await express({ mood: 'happy', priority, holdMs: 7000, line: 'recap', slots: { label: recapLine(recap, localDate(now())) } })
595    const streak = pixling.streak.days
596    if (streak >= 2) toast(`🔥 Day ${streak} in a row with ${pixling.name}. Keep it going!`, 7000)
597  }
598
599  const touch = async (): Promise<void> => {
600    const at = now()
601    const away = at - lastActivity
602    lastActivity = at
603    if (away > DOZE_AFTER_MS && !nap) {
604      await express({ mood: 'happy', priority: PRIORITY.ambient, line: 'welcomeBack', slots: { dur: formatDuration(away) } })
605    }
606  }
607
608  // Frames --------------------------------------------------------------------------------
609
610  const frameAt = (at: number): Pixels => {
611    const s = species()
612    const p = pixling
613    const { hat, face } = p ? gearOf(p) : { hat: null, face: null }
614    if (p && hatchAt !== null && at - hatchAt < HATCH_MS + 2600) {
615      return renderHatch(
616        { species: s, isShiny: p.isShiny, mood: 'celebrate', t: Math.max(0, at - hatchAt), hat, face },
617        RARITY_COLOR[s.rarity],
618      )
619    }
620    const { mood, since } = currentMood(at)
621    const activeIcon = icon && at < icon.until ? icon.icon : null
622    const range = rangeNow()
623    const w = walker
624    const isStepping = w !== null && isWalking(w) && (mood === 'idle' || mood === 'working')
625    const { shown, hidden } = squadAt(at)
626    return renderFrame({
627      species: s,
628      isShiny: p?.isShiny ?? false,
629      mood: isStepping ? 'walk' : mood,
630      t: isStepping ? at : at - since,
631      hat,
632      face,
633      icon: activeIcon,
634      width: CANVAS_W + range,
635      x: SPRITE_X + Math.min(w?.x ?? range, range),
636      isFlipped: isStepping ? (w?.isFlipped ?? false) : false,
637      hasCap: isPlanning,
638      isSweating: contextPercent !== null && contextPercent >= SWEAT_AT,
639      squishT: compactingSince === null ? null : at - compactingSince,
640      reliefT: relievedAt === null ? null : at - relievedAt,
641      squad: shown,
642      hidden,
643    })
644  }
645
646  /**
647   * Columns the pixling may walk: the band's width past the canvas and the speech bubble. The
648   * squad's strip is taken out of it first, so minis widen the band only once that runs out.
649   */
650  const rangeNow = (): number =>
651    wantsRoam && bandMode === 'full' && !isHatching(now()) ? Math.max(0, roamRange(bandColumns) - squadColumns(now())) : 0
652
653  const roam = (at: number): void => {
654    const range = rangeNow()
655    if (!walker) walker = newWalker(range, at)
656    const { mood } = currentMood(at)
657    const isHeld = held !== null && at < held.until
658    const mode: RoamMode = nap || mood === 'sleep' || isHeld || compactingSince !== null ? 'stay' : isWorking ? 'home' : 'wander'
659    walker = walk(walker, range, mode, at, Math.random)
660  }
661
662  // Vitals --------------------------------------------------------------------------------
663
664  const refreshVitals = async (at: number): Promise<void> => {
665    const port = io
666    if (!port || !wantsVitals) return
667    const pieces = vitals({ cache, now: at, isWorking, contextPercent, limits, model, effort, turnDelta })
668    const key = pieces.map(v => `${v.text}:${v.tone}`).join('|')
669    if (key !== lastVitals) {
670      lastVitals = key
671      await port.setVitals(pieces)
672    }
673  }
674
675  /** A minute before the cache expires over a sizable context, the pixling taps the glass. */
676  const warnCooling = async (at: number): Promise<void> => {
677    const port = io
678    const left = remainingMs(cache, at)
679    if (!port || !wantsCacheWarning || isWorking || nap || left === null || left <= 0 || left > COOLING_WARN_MS) return
680    if (cache.lastAt === warnedCacheAt || cache.context < MIN_WARN_CONTEXT) return
681    warnedCacheAt = cache.lastAt
682    const draft = await port.draft().catch(() => '')
683    if (draft.trim() !== '') return
684    await express({
685      mood: 'attention',
686      priority: PRIORITY.commit,
687      holdMs: 5000,
688      line: 'cacheCooling',
689      slots: { dur: formatDuration(left) },
690      sound: 'clock',
691    })
692  }
693
694  /** What one main-thread request taught: the cache's state, and the model's verbal tics. */
695  const afterStep = async (usage: RequestUsage | null, answer: string, sentAt: number): Promise<void> => {
696    const port = io
697    if (!port || !pixling) return
698    if (usage) {
699      const { cache: seen, news } = observe(cache, usage, sentAt, isTtlPinned)
700      cache = seen
701      await port.setCache(cache)
702      if (news?.kind === 'learned') {
703        await port.saveTtl(encodeTtl(news.ttl, sentAt, 'learned')).catch(() => undefined)
704        await express({
705          priority: PRIORITY.ambient,
706          line: 'cacheLearned',
707          slots: { item: news.ttl === '1h' ? 'an hour' : 'five minutes' },
708        })
709      } else if (news?.kind === 'cold') {
710        // A full miss overturned a stored hour: what the store says is wrong, and is replaced.
711        if (news.relearned) await port.saveTtl(encodeTtl(news.relearned, sentAt, 'learned')).catch(() => undefined)
712        count('coldStarts')
713        await express({
714          mood: 'sad',
715          priority: PRIORITY.commit,
716          holdMs: 2600,
717          line: 'cacheCold',
718          slots: { n: tokens(news.rewritten), dur: formatDuration(news.gapMs) },
719          sound: 'freeze',
720        })
721      }
722    }
723    if (answer) await hearTics(answer)
724  }
725
726  const hearTics = async (text: string): Promise<void> => {
727    if (!pixling) return
728    const found = ticsIn(text)
729    const kinds = Object.keys(found) as Tic[]
730    if (kinds.length === 0) return
731    pixling = { ...pixling, tics: addTics(pixling.tics, found) }
732    persist()
733    const roll = kinds.find(isEyeRoll)
734    if (!roll) return
735    await express({
736      mood: 'unimpressed',
737      priority: PRIORITY.commit,
738      holdMs: 2600,
739      line: roll,
740      slots: { n: pixling.tics[roll] ?? 1 },
741      sound: 'eyeroll',
742    })
743  }
744
745  // The terminal's room repaints its sprite in place, as the band does: a redraw would re-run
746  // every hook of this plugin's on what it drew, a heckled transcript's messages too. A new size
747  // is the one redraw, after which the room says what it shows again.
748  const paintRoom = async (port: Io, t: number): Promise<void> => {
749    const sprite = roomSprite
750    if (!sprite) return
751    const raster = rasterOf(roomFrame(t))
752    if (raster.cells === sprite.cells) return
753    if (raster.columns !== sprite.columns || raster.rows !== sprite.rows) {
754      roomSprite = null
755      port.invalidate()
756      return
757    }
758    roomSprite = { ...sprite, cells: raster.cells }
759    if (!(await port.blit(ROOM_ID, sprite.key, raster.cells))) roomSprite = null
760  }
761
762  const tick = async (): Promise<void> => {
763    const port = io
764    if (!port || !pixling) return
765    ticks += 1
766    if (ticks % 10 === 0) await syncClock(port)
767    const t = now()
768    if (ticks % 10 === 1) {
769      // The warning is its own setting: neither the vitals row nor the band turns it off.
770      await refreshVitals(t)
771      await warnCooling(t)
772    }
773    await paintRoom(port, t)
774    if (bandMode === 'minimal' || isCramped) {
775      await syncMinimal()
776      return
777    }
778    roam(t)
779    const frame = frameAt(t)
780    if (samePixels(lastFrame, frame)) return
781    const hasResized = lastFrame !== null && (lastFrame.h !== frame.h || lastFrame.w !== frame.w)
782    lastFrame = frame
783    if (hasResized) {
784      port.invalidate()
785      return
786    }
787    if (bandId && bandRows * 2 >= frame.h) {
788      const isShown = await port.blit(bandId, RASTER_KEY, rasterOf(frame).cells)
789      if (!isShown) bandId = null
790    }
791    // Surfaces without Raster draw an SVG: redraw them a few times a second.
792    if (t - svgSeenAt < 30_000 && t - lastInvalidate > 280) {
793      lastInvalidate = t
794      port.invalidate()
795    }
796  }
797
798  // Hatching ------------------------------------------------------------------------------
799
800  // PowerShell takes about half a second to start playing; start the animation with the sound.
801  const latency = (): number => (platform === 'windows' || platform === 'wsl' ? 450 : 80)
802
803  const hatch = async (): Promise<void> => {
804    const port = io
805    if (!port) return
806    const egg = pixling ? rehatch(pixling, Math.random, now()) : hatchPixling(Math.random, now())
807    pixling = touchDay(bump(egg, 'sessions'), now()).pixling
808    await commit()
809    const s = species()
810    const tier = s.rarity === 'legendary' ? 3 : s.rarity === 'rare' || s.rarity === 'epic' ? 2 : 1
811    play(`hatch-${tier}`, true)
812    hatchAt = now() + latency()
813    held = null
814    await port.setHatchAt(hatchAt)
815    await publishView()
816    port.after(latency() + 4400, () => {
817      void (async () => {
818        await express({ mood: 'celebrate', priority: PRIORITY.hatch, holdMs: 3000, line: 'hatch' })
819        const shiny = pixling?.isShiny ? ' ✦ SHINY!' : ''
820        toast(`${RARITY_STARS[s.rarity]} ${s.rarity.toUpperCase()}: a ${s.name} hatched!${shiny}  Try /pixling`, 8000)
821      })()
822    })
823  }
824
825  // Rate-limit nap ------------------------------------------------------------------------
826
827  const armWake = (at: number): void => {
828    if (!io) return
829    napTimer?.cancel()
830    napTimer = io.after(Math.max(1000, at - now()), () => {
831      void wake()
832    })
833  }
834
835  /** "14:00", or "Thu 14:00" for a reset more than 20 hours off (a weekly limit). */
836  const resetTime = (at: number): string =>
837    at - now() > FAR_RESET_MS ? `${WEEKDAYS[new Date(at).getDay()] ?? ''} ${clockTime(at)}` : clockTime(at)
838
839  const fallAsleep = async (): Promise<void> => {
840    const port = io
841    const at = now()
842    if (!port || nap || at - lastLimitAt < 3000) return
843    lastLimitAt = at
844    let resetsAt: number | null = null
845    let kind = 'five_hour'
846    try {
847      const window = blockingWindow(await port.usage(), at)
848      resetsAt = window?.resetsAt ?? null
849      kind = window?.kind ?? kind
850    } catch {
851      // Usage unreadable: nap on a retry timer instead.
852    }
853    // A continue prompt that ran into the limit again: the same nap, gone on quietly.
854    const isRetry = napRetries > 0
855    held = null
856    if (isRetry && resetsAt === null && napRetries >= NAP_MAX_RETRIES) {
857      napRetries = 0
858      await express({ mood: 'sleep', priority: PRIORITY.limit, holdMs: 4000, text: GIVE_UP_LINE })
859      return
860    }
861    const wakeAt = resetsAt !== null ? resetsAt + NAP_GRACE_MS : at + NAP_RETRY_MS * Math.min(4, 1 + napRetries)
862    // A reset days off (a weekly limit) is announced when it comes; the conversation is stale by then.
863    const isAuto = wantsAutoContinue && !isQuiet && !isAway && (resetsAt === null || resetsAt - at <= NAP_MAX_AGE_MS)
864    nap = { until: resetsAt, isAuto, kind }
865    napSince = at
866    napSession = await port.sessionId()
867    await port.setNap(nap)
868    const time = resetsAt !== null ? resetTime(resetsAt) : null
869    await express({
870      priority: PRIORITY.limit,
871      line: time !== null ? 'limit' : 'limitUnknown',
872      slots: { time },
873      sound: 'sleep',
874      isImportant: !isRetry,
875      notify: isRetry
876        ? undefined
877        : time === null
878          ? 'Usage limit hit.'
879          : isAuto
880            ? `Usage limit hit. I'll wake Claude at ${time}.`
881            : `Usage limit hit. It resets: ${time}.`,
882    })
883    if (!isRetry) {
884      count('naps')
885      await grant('nap')
886    }
887    armWake(wakeAt)
888  }
889
890  const wake = async (): Promise<void> => {
891    const port = io
892    if (!port || !nap) return
893    const asleep = nap
894    // A timer's own moment: the next one is armed from it.
895    await syncClock(port)
896    if (nap !== asleep) return
897    const at = now()
898    // A reset is claimed only once the limits, read again, no longer block.
899    const windows = await port.usage().catch((): readonly LimitWindow[] => [])
900    // The person spoke (or the conversation ended) while the limits were read: nothing to wake.
901    if (nap !== asleep) return
902    const still = blockingWindow(windows, at)
903    if (still && still.resetsAt !== null) {
904      nap = { ...asleep, until: still.resetsAt, kind: still.kind, isAuto: asleep.isAuto && still.resetsAt - at <= NAP_MAX_AGE_MS }
905      await port.setNap(nap)
906      armWake(still.resetsAt + NAP_GRACE_MS)
907      return
908    }
909    const isSure = asleep.until !== null || (windows.length > 0 && !still)
910    const epoch = napEpoch
911    nap = null
912    napTimer = null
913    await port.setNap(null)
914    // Only into the conversation that hit the limit, and only while it is fresh.
915    const isSameConversation = (await port.sessionId()) === napSession
916    const shouldContinue = asleep.isAuto && isSameConversation && at - napSince <= NAP_MAX_AGE_MS
917    if (isSure) {
918      napRetries = 0
919      await express({
920        mood: 'attention',
921        priority: PRIORITY.wake,
922        holdMs: 4000,
923        line: 'wake',
924        sound: 'wake',
925        isImportant: true,
926        notify: shouldContinue ? 'Limit reset. Claude is back at work.' : 'Your usage limit has reset.',
927      })
928    } else {
929      // No reset time was ever read (an API key's limits go unreported): a quiet try, not news.
930      napRetries += 1
931      await express({ mood: 'attention', priority: PRIORITY.wake, holdMs: 4000, text: shouldContinue ? RETRY_LINE : MAYBE_LINE, sound: 'wake' })
932    }
933    // A prompt, a /clear or a resume that came while it woke up still cancels the continue.
934    if (shouldContinue && napEpoch === epoch) {
935      // A session that cannot take a prompt now keeps the reminder in the bubble.
936      await port.submit(CONTINUE_PROMPT).catch(() => undefined)
937    }
938  }
939
940  const cancelNap = async (): Promise<void> => {
941    napRetries = 0
942    napEpoch += 1
943    if (!io || !nap) return
944    napTimer?.cancel()
945    napTimer = null
946    nap = null
947    napRetries = 0
948    await io.setNap(null)
949  }
950
951  // Reactions to shell work ---------------------------------------------------------------
952
953  const reactRisk = async (risk: Risk): Promise<void> => {
954    count('risky')
955    await express({
956      mood: 'alarmed',
957      priority: risk.level === 'danger' ? PRIORITY.alarm : PRIORITY.testFail,
958      holdMs: 3500,
959      line: risk.level,
960      slots: { label: risk.label },
961      sound: risk.level === 'danger' ? 'alarm' : 'spicy',
962      isImportant: risk.level === 'danger',
963    })
964  }
965
966  const afterTests = async (output: string, isError: boolean): Promise<void> => {
967    const outcome = testOutcome(output, isError)
968    if (outcome.status === 'fail') {
969      lastFailed = outcome.failed
970      count('testsFailed')
971      await express({
972        mood: 'sad',
973        priority: PRIORITY.testFail,
974        holdMs: 4200,
975        line: 'testFail',
976        slots: { failed: outcome.failed },
977        sound: 'fail',
978      })
979      maybeQuip('testFail', { failed: outcome.failed })
980    } else if (lastTest === 'fail') {
981      count('bugsSquashed')
982      tally('squashed')
983      await express({
984        mood: 'celebrate',
985        priority: PRIORITY.celebrate,
986        holdMs: 3500,
987        line: 'bugSquashed',
988        slots: { failed: lastFailed ?? 'all' },
989        sound: 'squash',
990      })
991      await grant('bugSquashed', 'testsPassed')
992    } else {
993      await express({
994        mood: 'happy',
995        priority: PRIORITY.testPass,
996        holdMs: 2600,
997        line: 'testPass',
998        slots: { passed: outcome.passed },
999        sound: 'pass',
1000      })
1001      await grant('testPass', 'testsPassed')
1002    }
1003    if (outcome.status === 'pass') tally('tests')
1004    lastTest = outcome.status
1005  }
1006
1007  const afterGit = async (command: string, git: GitOperation | undefined): Promise<void> => {
1008    if (git?.pr && (git.pr.action === 'created' || git.pr.action === 'merged')) {
1009      const isMerged = git.pr.action === 'merged'
1010      await express({
1011        mood: 'celebrate',
1012        priority: PRIORITY.celebrate,
1013        holdMs: 3500,
1014        line: isMerged ? 'prMerged' : 'prCreated',
1015        slots: { pr: git.pr.number },
1016        sound: isMerged ? 'squash' : 'levelup',
1017      })
1018      await grant(isMerged ? 'prMerged' : 'prCreated', 'prs')
1019    } else if (git?.push) {
1020      await express({ mood: 'happy', priority: PRIORITY.commit, line: 'push', slots: { branch: git.push.branch }, sound: 'push' })
1021      await grant('push', 'pushes')
1022    } else if (git?.commit || runsGitCommit(command)) {
1023      await express({ mood: 'happy', priority: PRIORITY.commit, line: 'commit', sound: 'commit' })
1024      tally('commits')
1025      await grant('commit', 'commits')
1026    }
1027  }
1028
1029  // Session -------------------------------------------------------------------------------
1030
1031  /** Finds out where the session runs and how to play sound there; nothing plays when it can't. */
1032  const detectPlatform = async (port: Io, os: string | undefined, root: string): Promise<void> => {
1033    try {
1034      platform = os === 'Windows_NT' ? 'windows' : platformOfUname(await port.run(['uname', '-sr'], 5000))
1035      await choosePlayer(port, root)
1036    } catch {
1037      player = { kind: 'none' }
1038    }
1039  }
1040
1041  const choosePlayer = async (port: Io, root: string): Promise<void> => {
1042    soundRoot = root
1043    voicePipe = wavPipeArgv(platform)
1044    if (platform === 'mac') {
1045      player = { kind: 'engine' }
1046    } else if (platform === 'windows') {
1047      player = { kind: 'argv', argv: windowsPlayer }
1048    } else if (platform === 'wsl') {
1049      soundRoot = (await port.run(['wslpath', '-w', root], 5000)).trim()
1050      player = soundRoot ? { kind: 'argv', argv: windowsPlayer } : { kind: 'none' }
1051    } else if (platform === 'linux') {
1052      const found = await port.run(['sh', '-c', 'command -v paplay || command -v pw-play || command -v aplay'], 5000)
1053      player = linuxPlayer(found)
1054      voicePipe = wavPipeArgv(platform, found)
1055      if (voiceMode === 'speech') {
1056        speaker = linuxSpeaker(await port.run(['sh', '-c', 'command -v spd-say || command -v espeak-ng || command -v espeak'], 5000))
1057      }
1058    }
1059  }
1060
1061  // Reactions: compaction, context sweat, plan mode, the squad, model switches ----------------
1062
1063  const squadAtom = atom({ plugin: 'pixlings', key: 'squad' } as const, [] as PixlingsMini[])
1064  const planningAtom = atom({ plugin: 'pixlings', key: 'isPlanning' } as const, false)
1065  const effectAtom = atom({ plugin: 'pixlings', key: 'effect' } as const, null as PixlingsEffect)
1066
1067  const wantsCacheGuard = options['cacheGuard'] !== false
1068  /**
1069   * Re-writing a warm cache dearer than this is put to the person before a model switch. At
1070   * cache-write prices (1.25× input for 5m, 2× for 1h) a quarter is ~50k tokens of context
1071   * moving to Opus 5.5, ~100k to Sonnet 5.5, and never a switch down to Haiku: a session well
1072   * under way, where one keypress costs less than the money it saves. Below it, a dialog on
1073   * every /model would nag more than it saves.
1074   */
1075  const GUARD_USD = 0.25
1076  /** The context fill that makes it sweat, and the fill it must fall under to sweat again. */
1077  const SWEAT_AT = 85
1078  const SWEAT_AGAIN = 80
1079  /** A compaction that never reports its end lets the press up after this. */
1080  const COMPACT_MAX_MS = 10 * 60_000
1081
1082  type Mini = PixlingsMini & { icon: { icon: Icon; until: number } | null }
1083
1084  /** The $.state setters of this section, bound in session.start beside the Io port. */
1085  let stage: {
1086    squad: (value: PixlingsMini[]) => Promise<void>
1087    isPlanning: (value: boolean) => Promise<void>
1088    effect: (value: PixlingsEffect) => Promise<void>
1089  } | null = null
1090  /** Subagents' minis, oldest first; a finished one stays MINI_LEAVE_MS to celebrate and go. */
1091  let squad: Mini[] = []
1092  /** Minis that joined since the squad last stood empty: the high fives the last one counts. */
1093  let squadRun = 0
1094  let isPlanning = false
1095  let compactingSince: number | null = null
1096  let relievedAt: number | null = null
1097  let hasSweated = false
1098  let contextWindow: number | null = null
1099  /** turn.step's `model` and `effort` on the main loop (index.d.ts TurnStepInput). */
1100  let model: string | undefined
1101  let effort: string | number | undefined
1102  /** The rate-limit windows as a main turn began, and what the last one took from them. */
1103  let quotaBefore: readonly LimitWindow[] | null = null
1104  let turnDelta: readonly QuotaDelta[] = []
1105
1106  /** The last short effect, for a surface that mirrors the band (the room). */
1107  const flash = async (kind: string): Promise<void> => {
1108    await stage?.effect({ kind, at: now() }).catch(() => undefined)
1109  }
1110
1111  const publishSquad = async (): Promise<void> => {
1112    await stage?.squad(squad.map(({ icon: _icon, ...mini }) => mini)).catch(() => undefined)
1113  }
1114
1115  const seedOf = (id: string): number => {
1116    let h = 0
1117    for (const ch of id) h = (Math.imul(h, 31) + ch.charCodeAt(0)) | 0
1118    return Math.abs(h) % 97
1119  }
1120
1121  /** The minis on the strip at `at`, oldest first, the rest counted; gone once they poofed. */
1122  const squadAt = (at: number): { shown: MiniView[]; hidden: number } => {
1123    const here = squad.filter(m => m.doneAt === null || at - m.doneAt < MINI_LEAVE_MS)
1124    const shown = here.slice(0, SQUAD_CAP).map(m => ({
1125      tint: miniTint(m.label),
1126      // A frame timed a moment before the event it follows draws the event's first frame.
1127      t: Math.max(0, at - m.startedAt),
1128      doneT: m.doneAt === null ? null : Math.max(0, at - m.doneAt),
1129      icon: m.icon && at < m.icon.until ? m.icon.icon : null,
1130      seed: seedOf(m.agentId),
1131    }))
1132    return { shown, hidden: here.length - shown.length }
1133  }
1134
1135  const squadColumns = (at: number): number => {
1136    const { shown, hidden } = squadAt(at)
1137    return squadWidth(shown.length, hidden)
1138  }
1139
1140  /** A subagent started: a mini tinted for its type hops onto the strip. */
1141  const joinSquad = async (agentId: string, agentType: string): Promise<void> => {
1142    if (!pixling || squad.some(m => m.agentId === agentId)) return
1143    const isFirst = squad.every(m => m.doneAt !== null)
1144    squadRun = isFirst ? 1 : squadRun + 1
1145    squad = [...squad, { agentId, label: agentType, speciesId: pixling.species, startedAt: now(), doneAt: null, icon: null }]
1146    await publishSquad()
1147    if (isFirst) await express({ priority: PRIORITY.ambient, line: 'squadUp', slots: { label: agentType } })
1148  }
1149
1150  /**
1151   * A mini's agent finished: it jumps for joy, the pixling high-fives it, and it poofs away.
1152   * Stopped by an interrupt instead, it goes straight to the poof, with nothing to celebrate.
1153   */
1154  const leaveSquad = async (agentId: string, isAborted = false): Promise<void> => {
1155    const port = io
1156    const mini = squad.find(m => m.agentId === agentId)
1157    if (!port || !mini || mini.doneAt !== null) return
1158    const doneAt = isAborted ? now() - MINI_POOF_MS : now()
1159    squad = squad.map(m => (m.agentId === agentId ? { ...m, doneAt, icon: null } : m))
1160    await publishSquad()
1161    port.after(Math.max(0, MINI_LEAVE_MS - (now() - doneAt)), () => {
1162      squad = squad.filter(m => m.agentId !== agentId)
1163      void publishSquad()
1164    })
1165    if (isAborted) return
1166    await flash('highFive')
1167    if (squad.some(m => m.doneAt === null)) {
1168      await express({ mood: 'happy', priority: PRIORITY.ambient, holdMs: 900 })
1169    } else {
1170      const isOne = squadRun <= 1
1171      await express({
1172        mood: 'happy',
1173        priority: PRIORITY.ambient,
1174        holdMs: 1800,
1175        line: isOne ? 'squadDoneOne' : 'squadDone',
1176        slots: { n: squadRun, label: mini.label },
1177      })
1178    }
1179  }
1180
1181  /** A subagent's tool: its icon over that agent's mini. */
1182  const showMiniIcon = (agentId: string, tool: Icon): void => {
1183    squad = squad.map(m => (m.agentId === agentId && m.doneAt === null ? { ...m, icon: { icon: tool, until: now() + 2500 } } : m))
1184  }
1185
1186  /** A main-conversation compaction began: the press comes down on the pixling. */
1187  const squish = async (): Promise<void> => {
1188    const since = now()
1189    compactingSince = since
1190    relievedAt = null
1191    io?.after(COMPACT_MAX_MS, () => {
1192      if (compactingSince === since) compactingSince = null
1193    })
1194    await flash('squish')
1195  }
1196
1197  /**
1198   * The compaction ended: the press lifts and the pixling springs back, saying what was freed.
1199   * `session.compact`'s result carries the conversation's size before and after, when core
1200   * recorded them; the context fill is the new size over the window the last measure reported,
hooks/lib/badges.ts 254 lines
1// Badges, the daily streak and the morning recap. A badge is earned once, from the pixling's own
2// record, so a badge added in a later version is awarded the first time its test passes.
3
4import type { Day, Pixling } from './progress.ts'
5import { emptyDay, levelOf } from './progress.ts'
6
7export type BadgeId =
8  | 'hello'
9  | 'exterminator'
10  | 'dangerous'
11  | 'nightOwl'
12  | 'survivor'
13  | 'shipper'
14  | 'liftoff'
15  | 'centurion'
16  | 'onFire'
17  | 'lucky'
18  | 'absolutelyRight'
19  | 'bestFriends'
20  | 'greenThumb'
21  | 'collector'
22  | 'archmage'
23  | 'brainFreeze'
24
25export type Badge = {
26  readonly id: BadgeId
27  readonly name: string
28  readonly emoji: string
29  readonly how: string
30  /** Progress toward it: [have, need]. */
31  readonly progress: (p: Pixling) => readonly [number, number]
32  /** A 7×7 icon for the share card; each letter a color of `colors`. */
33  readonly icon: readonly string[]
34  readonly colors: Readonly<Record<string, number>>
35}
36
37const at = (have: number, need: number): readonly [number, number] => [Math.min(have, need), need]
38
39export const BADGES: readonly Badge[] = [
40  {
41    id: 'hello',
42    name: 'Hello, World',
43    emoji: '🥚',
44    how: 'Hatch a pixling',
45    progress: () => at(1, 1),
46    icon: ['..WWW..', '.WWWWW.', '.WKWWW.', 'WWKWKWW', 'WWWKWWW', 'WWWWWWW', '.WWWWW.'],
47    colors: { W: 0xf4efe1, K: 0x6b5b45 },
48  },
49  {
50    id: 'exterminator',
51    name: 'Exterminator',
52    emoji: '🐛',
53    how: 'Squash 10 bugs (red tests → green)',
54    progress: p => at(p.stats.bugsSquashed, 10),
55    // The antennae are a darker green: the near-black of the eyes vanishes on a dark card.
56    icon: ['.A...A.', '..A.A..', '.GGGGG.', 'GGKGKGG', '.GGGGG.', 'GG.G.GG', '.......'],
57    colors: { G: 0x6fdc8c, K: 0x1b1424, A: 0x3f9a4a },
58  },
59  {
60    id: 'dangerous',
61    name: 'Living Dangerously',
62    emoji: '💣',
63    how: 'Run 10 risky commands',
64    progress: p => at(p.stats.risky, 10),
65    icon: ['....Y.O', '...O...', '..KK...', '.KKKK..', 'KKWKKK.', 'KKKKKK.', '.KKKK..'],
66    colors: { K: 0x4f4870, W: 0xb8c2cc, Y: 0xffe14d, O: 0xff8a3d },
67  },
68  {
69    id: 'nightOwl',
70    name: 'Night Owl',
71    emoji: '🦉',
72    how: 'Work 25 turns after midnight',
73    progress: p => at(p.stats.nights, 25),
74    icon: ['..YYY..', '.YY...W', 'YY.....', 'YY...W.', 'YY.....', '.YY....', '..YYY..'],
75    colors: { Y: 0xffe98a, W: 0xffffff },
76  },
77  {
78    id: 'survivor',
79    name: 'Limit Survivor',
80    emoji: '⏳',
81    how: 'Sleep through a rate limit',
82    progress: p => at(p.stats.naps, 1),
83    icon: ['BBBBBBB', '.Y...Y.', '..YYY..', '...Y...', '..Y.Y..', '.YYYYY.', 'BBBBBBB'],
84    colors: { B: 0x8a6b4a, Y: 0xffd23f },
85  },
86  {
87    id: 'shipper',
88    name: 'Shipper',
89    emoji: '📦',
90    how: 'Make 50 commits',
91    progress: p => at(p.stats.commits, 50),
92    icon: ['.......', 'TTTYTTT', 'TTTYTTT', 'DDDDDDD', 'TTTTTTT', 'TTTTTTT', 'TTTTTTT'],
93    colors: { T: 0xc98a4b, D: 0x8a5a2b, Y: 0xffe14d },
94  },
95  {
96    id: 'liftoff',
97    name: 'Liftoff',
98    emoji: '🚀',
99    how: 'Open a pull request',
100    progress: p => at(p.stats.prs, 1),
101    icon: ['...W...', '..WWW..', '..WBW..', '..WWW..', '.RWWWR.', '.R.O.R.', '...Y...'],
102    colors: { W: 0xe6e9f0, B: 0x5ad1ff, R: 0xff4d6d, O: 0xff8a3d, Y: 0xffe14d },
103  },
104  {
105    id: 'centurion',
106    name: 'Centurion',
107    emoji: '🏆',
108    how: 'Finish 100 turns',
109    progress: p => at(p.stats.turns, 100),
110    icon: ['YYYYYYY', 'YYYYYYY', '.YYYYY.', '..YYY..', '...Y...', '..YYY..', '.YYYYY.'],
111    colors: { Y: 0xffc53d },
112  },
113  {
114    id: 'onFire',
115    name: 'On Fire',
116    emoji: '🔥',
117    how: 'Code 7 days in a row',
118    progress: p => at(p.streak.best, 7),
119    icon: ['...R...', '..RR...', '.RORR..', '.ROORR.', 'RROYOR.', '.RYYOR.', '..RRR..'],
120    colors: { R: 0xff4d3d, O: 0xff8a3d, Y: 0xffe14d },
121  },
122  {
123    id: 'lucky',
124    name: 'Lucky',
125    emoji: '✨',
126    how: 'Hatch a shiny (1 in 64)',
127    progress: p => at(p.isShiny ? 1 : 0, 1),
128    icon: ['...Y...', '...Y...', '..YYY..', 'YYYWYYY', '..YYY..', '...Y...', '...Y...'],
129    colors: { Y: 0xffe14d, W: 0xffffff },
130  },
131  {
132    id: 'absolutelyRight',
133    name: 'Absolutely Right',
134    emoji: '🙄',
135    how: 'Hear "You\'re absolutely right" 25 times',
136    progress: p => at(p.tics.absolutelyRight ?? 0, 25),
137    icon: ['WWWWWWW', 'WWWWWGW', 'WGWWGWW', 'WWGGWWW', 'WWWWWWW', '.WW....', '.W.....'],
138    colors: { W: 0xe6e9f0, G: 0x3fa45a },
139  },
140  {
141    id: 'bestFriends',
142    name: 'Best Friends',
143    emoji: '💖',
144    how: 'Pet your pixling 100 times',
145    progress: p => at(p.stats.pets, 100),
146    icon: ['.PP.PP.', 'PPPPPPP', 'PWPPPPP', 'PPPPPPP', '.PPPPP.', '..PPP..', '...P...'],
147    colors: { P: 0xff4d8d, W: 0xffc2d8 },
148  },
149  {
150    id: 'greenThumb',
151    name: 'Green Thumb',
152    emoji: '✅',
153    how: 'Pass 100 test runs',
154    progress: p => at(p.stats.testsPassed, 100),
155    icon: ['......G', '.....GG', 'G...GG.', 'GG.GG..', '.GGG...', '..G....', '.......'],
156    colors: { G: 0x5cff8a },
157  },
158  {
159    id: 'collector',
160    name: 'Collector',
161    emoji: '📖',
162    how: 'Discover 5 species',
163    progress: p => at(p.dex.length, 5),
164    icon: ['.......', '.RR.BB.', '.RR.BB.', '.......', '.GG.YY.', '.GG.YY.', '.......'],
165    colors: { R: 0xff5fa2, B: 0x5ad1ff, G: 0x6fdc8c, Y: 0xffe14d },
166  },
167  {
168    id: 'archmage',
169    name: 'Archmage',
170    emoji: '🧙',
171    how: 'Reach level 25',
172    progress: p => at(levelOf(p.xp).level, 25),
173    icon: ['...P...', '..PP...', '..PYP..', '.PPPP..', '.PPPPP.', 'PPPPPPP', 'BBBBBBB'],
174    colors: { P: 0x7b4dff, Y: 0xffe14d, B: 0x4a2fb8 },
175  },
176  {
177    id: 'brainFreeze',
178    name: 'Brain Freeze',
179    emoji: '🧊',
180    how: 'Let the prompt cache go cold 10 times',
181    progress: p => at(p.stats.coldStarts, 10),
182    icon: ['...C...', '.C.C.C.', '..CCC..', 'CCCWCCC', '..CCC..', '.C.C.C.', '...C...'],
183    colors: { C: 0x7fd3ff, W: 0xffffff },
184  },
185]
186
187export const badgeById = (id: string): Badge | undefined => BADGES.find(b => b.id === id)
188
189export const isEarnedBy = (b: Badge, p: Pixling): boolean => {
190  const [have, need] = b.progress(p)
191  return have >= need
192}
193
194/** Awards every badge the record now earns; returns the pixling and the badges new to it. */
195export const award = (p: Pixling, now: number): { pixling: Pixling; earned: Badge[] } => {
196  const earned = BADGES.filter(b => p.badges[b.id] === undefined && isEarnedBy(b, p))
197  if (earned.length === 0) return { pixling: p, earned }
198  const badges = { ...p.badges }
199  for (const b of earned) badges[b.id] = now
200  return { pixling: { ...p, badges }, earned }
201}
202
203/** The badges earned, in the order they were. */
204export const earnedBadges = (p: Pixling): Badge[] =>
205  BADGES.filter(b => p.badges[b.id] !== undefined).sort((a, b) => (p.badges[a.id] ?? 0) - (p.badges[b.id] ?? 0))
206
207// The day ----------------------------------------------------------------------------------------
208
209/** "2026-10-03", by local time. */
210export const localDate = (at: number): string => {
211  const d = new Date(at)
212  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
213}
214
215const dayBefore = (date: string): string => {
216  const [y, m, d] = date.split('-').map(Number)
217  return localDate(new Date(y ?? 1970, (m ?? 1) - 1, (d ?? 1) - 1, 12).getTime())
218}
219
220/**
221 * The pixling's first sight of `at`'s day: moves the streak and starts a new day record.
222 * `recap` is yesterday's (or the last active day's) work, handed back once, on the day's first turn.
223 */
224export const touchDay = (p: Pixling, at: number): { pixling: Pixling; recap: Day | null; isNewDay: boolean } => {
225  const today = localDate(at)
226  if (p.day.date === today) return { pixling: p, recap: null, isNewDay: false }
227  const { last, days, best } = p.streak
228  const run = last === today ? days : last === dayBefore(today) ? days + 1 : 1
229  const recap = p.day.date !== '' && p.day.turns > 0 ? p.day : null
230  return {
231    pixling: { ...p, streak: { last: today, days: run, best: Math.max(best, run) }, day: emptyDay(today) },
232    recap,
233    isNewDay: true,
234  }
235}
236
237export const countDay = (p: Pixling, field: Exclude<keyof Day, 'date' | 'xp'>, by = 1): Pixling => ({
238  ...p,
239  day: { ...p.day, [field]: p.day[field] + by },
240})
241
242/** "Yesterday: 41 turns · 6 commits · 2 bugs squashed · +212 XP". */
243export const recapLine = (recap: Day, today: string): string => {
244  const when = recap.date === dayBefore(today) ? 'Yesterday' : `On ${recap.date}`
245  const parts = [
246    `${recap.turns} turn${recap.turns === 1 ? '' : 's'}`,
247    recap.commits ? `${recap.commits} commit${recap.commits === 1 ? '' : 's'}` : '',
248    recap.squashed ? `${recap.squashed} bug${recap.squashed === 1 ? '' : 's'} squashed` : '',
249    recap.tests ? `${recap.tests} green test run${recap.tests === 1 ? '' : 's'}` : '',
250    recap.xp ? `+${recap.xp} XP` : '',
251  ].filter(Boolean)
252  return `${when}: ${parts.join(' · ')}`
253}
254
hooks/lib/brain.ts 55 lines
1// The pixling's moment-to-moment mood: reactions with a priority and a hold time over a base
2// mood that follows the session (working, idle, asleep).
3
4import type { Mood } from './canvas.ts'
5
6export type Reaction = { mood: Mood; priority: number; holdMs: number }
7
8export type Held = { mood: Mood; since: number; until: number; priority: number }
9
10export type Bubble = { text: string; since: number; until: number; priority: number }
11
12export const PRIORITY = {
13  ambient: 10,
14  pet: 20,
15  done: 30,
16  commit: 45,
17  testPass: 50,
18  testFail: 55,
19  error: 60,
20  celebrate: 70,
21  alarm: 80,
22  attention: 90,
23  limit: 92,
24  wake: 95,
25  hatch: 100,
26} as const
27
28/** A reaction replaces the held one unless that one still holds with a higher priority. */
29export const react = (held: Held | null, r: Reaction, now: number): Held | null => {
30  if (held && now < held.until && r.priority < held.priority) return null
31  return { mood: r.mood, since: now, until: now + r.holdMs, priority: r.priority }
32}
33
34export const speak = (
35  bubble: Bubble | null,
36  text: string,
37  priority: number,
38  holdMs: number,
39  now: number,
40): Bubble | null => {
41  if (bubble && now < bubble.until && priority < bubble.priority) return null
42  return { text, since: now, until: now + holdMs, priority }
43}
44
45export type Session = { isSleeping: boolean; isWorking: boolean; isDozing: boolean }
46
47export const baseMood = (s: Session): Mood => (s.isSleeping || s.isDozing ? 'sleep' : s.isWorking ? 'working' : 'idle')
48
49/** The mood to draw now: a held reaction while it lasts, else the session's base mood. */
50export const moodNow = (held: Held | null, base: Mood, baseSince: number, now: number): { mood: Mood; since: number } =>
51  held && now < held.until ? { mood: held.mood, since: held.since } : { mood: base, since: baseSince }
52
53/** Reading time for a bubble: long enough to read, never a wall. */
54export const holdFor = (text: string): number => Math.min(9000, 3500 + text.length * 70)
55
hooks/lib/buddy.ts 88 lines
1// The bridge from Claude Code's retired /buddy. Its companion was left in the global config file
2// (`~/.claude.json`, or `$CLAUDE_CONFIG_DIR/.claude.json`) under `companion`: a name, a free-text
3// personality and when it hatched. That file holds much else of the person's; only those three
4// fields are ever kept from it. The file's text is parsed here and dropped, never printed,
5// stored or sent, and a parse error is not passed on (an engine's message can quote the text).
6
7import { oneLine } from './persona.ts'
8import type { Pixling } from './progress.ts'
9import { SPECIES } from './sprites.ts'
10import type { Species } from './sprites.ts'
11
12export type Companion = { name: string; personality: string | null; hatchedAt: number | null }
13
14/** The file name, and the variable that moves the folder it lives in. */
15export const CONFIG_FILE = '.claude.json'
16
17/** Where the companion is looked for: the config folder when one is set, else the home folder. */
18export const configPath = (home: string, configDir: string | undefined, isWindows: boolean): string => {
19  const sep = isWindows ? '\\' : '/'
20  const folder = configDir && configDir.trim() !== '' ? configDir : home
21  return `${folder.replace(/[\\/]+$/, '')}${sep}${CONFIG_FILE}`
22}
23
24/**
25 * Epoch milliseconds from epoch milliseconds, epoch seconds or an ISO date; null when it is none
26 * of those. A number under 1e11 is seconds: 1e11 ms is 1973, 1e11 s is the year 5138.
27 */
28export const epochMs = (value: unknown): number | null => {
29  const n =
30    typeof value === 'number'
31      ? value
32      : typeof value === 'string' && /^\d+(\.\d+)?$/.test(value.trim())
33        ? Number(value)
34        : typeof value === 'string'
35          ? Date.parse(value)
36          : NaN
37  if (!Number.isFinite(n) || n <= 0) return null
38  return Math.round(n < 1e11 ? n * 1000 : n)
39}
40
41/** A name the pixling can wear: what `/pixling name` allows, at most 20 characters. */
42export const cleanName = (name: string): string =>
43  name
44    .replace(/[^\p{L}\p{N} _'-]/gu, '')
45    .slice(0, 20)
46    .trim()
47
48/**
49 * The companion in a config file's text, or null when there is none to adopt. Everything but
50 * its name, personality and hatch time is left behind with the parsed text.
51 */
52export const companionOf = (text: string): Companion | null => {
53  let parsed: unknown
54  try {
55    parsed = JSON.parse(text.replace(/^/, ''))
56  } catch {
57    return null
58  }
59  if (!isRecord(parsed) || !isRecord(parsed['companion'])) return null
60  const c = parsed['companion']
61  const name = typeof c['name'] === 'string' ? cleanName(c['name']) : ''
62  if (!name) return null
63  const personality = typeof c['personality'] === 'string' ? oneLine(c['personality']) : ''
64  return { name, personality: personality || null, hatchedAt: epochMs(c['hatchedAt']) }
65}
66
67/**
68 * The pixling taking the companion in: its name, its personality (a template's stays when the
69 * Buddy had none) and its hatch time for the days together. Level, stats, badges, streak, tics
70 * and dex are untouched; a chosen species is worn and joins the dex. Adopting again changes
71 * nothing more.
72 */
73export const adopt = (p: Pixling, c: Companion, species?: Species): Pixling => ({
74  ...p,
75  name: c.name,
76  persona: c.personality ?? p.persona,
77  adoptedFrom: { name: c.name, hatchedAt: c.hatchedAt },
78  ...(species ? { species: species.id, dex: p.dex.includes(species.id) ? p.dex : [...p.dex, species.id] } : {}),
79})
80
81/** A species named by id or by name, any case; null when there is no such species. */
82export const speciesNamed = (word: string): Species | null => {
83  const w = word.trim().toLowerCase()
84  return SPECIES.find(s => s.id === w || s.name.toLowerCase() === w) ?? null
85}
86
87const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
88
hooks/lib/card.ts 190 lines
1// The share card: a trading card of the pixling, drawn at 240×135 and saved five times larger,
2// 1200×675, the size a link preview shows whole.
3
4import { BADGES } from './badges.ts'
5import { blank, renderFrame, stamp, TRANSPARENT } from './canvas.ts'
6import type { Pixels } from './canvas.ts'
7import { print, printSpans, spansWidth, textWidth } from './font.ts'
8import type { Span } from './font.ts'
9import { daysTogether, gearOf, levelOf, metAt } from './progress.ts'
10import type { Pixling } from './progress.ts'
11import { RARITY_COLOR, RARITY_ORDER, speciesById } from './sprites.ts'
12import { topTics } from './tics.ts'
13
14export const CARD_W = 240
15export const CARD_H = 135
16export const CARD_SCALE = 5
17export const CARD_BG = 0x12131c
18
19const PANEL = 0x1d1f2e
20const PANEL_LIGHT = 0x25283b
21const INK = 0xe8ecf4
22const DIM = 0x7a819c
23const FAINT = 0x2c2f45
24const GOLD = 0xffe14d
25
26export const REPO = 'github.com/cglremrcn/pixlings'
27
28const fill = (p: Pixels, x: number, y: number, w: number, h: number, color: number): void => {
29  for (let yy = Math.max(0, y); yy < Math.min(p.h, y + h); yy++) {
30    p.px.fill(color, yy * p.w + Math.max(0, x), yy * p.w + Math.min(p.w, x + w))
31  }
32}
33
34const blitScaled = (p: Pixels, src: Pixels, x0: number, y0: number, scale: number): void => {
35  for (let y = 0; y < src.h; y++) {
36    for (let x = 0; x < src.w; x++) {
37      const c = src.px[y * src.w + x] ?? TRANSPARENT
38      if (c !== TRANSPARENT) fill(p, x0 + x * scale, y0 + y * scale, scale, scale, c)
39    }
40  }
41}
42
43/** A frame in the rarity's color with its corners cut, the way pixel-art cards draw them. */
44const frame = (p: Pixels, x: number, y: number, w: number, h: number, color: number, thick: number): void => {
45  fill(p, x + thick, y, w - thick * 2, thick, color)
46  fill(p, x + thick, y + h - thick, w - thick * 2, thick, color)
47  fill(p, x, y + thick, thick, h - thick * 2, color)
48  fill(p, x + w - thick, y + thick, thick, h - thick * 2, color)
49}
50
51/** 12,345: grouped by hand, as the mod's environment may have no Intl. */
52const n = (value: number): string => String(Math.round(value)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
53
54/** "1 turn", "1,342 turns". */
55const count = (value: number, noun: string): string => `${n(value)} ${noun}${value === 1 ? '' : 's'}`
56
57type Stat = { label: Span; value: string }
58
59/** The stat sheet beside the pixling: four rows, label left, value right. */
60const statRows = (px: Pixling, now: number): Stat[] => {
61  const s = px.stats
62  const streak = px.streak.days
63  return [
64    { label: ['TURNS', DIM], value: n(s.turns) },
65    { label: ['COMMITS', DIM], value: n(s.commits) },
66    { label: ['BUGS SQUASHED', DIM], value: n(s.bugsSquashed) },
67    streak >= 2
68      ? { label: ['🔥 STREAK', 0xff8a3d], value: `${n(streak)} DAYS` }
69      : { label: ['DAYS TOGETHER', DIM], value: n(daysTogether(px, now)) },
70  ]
71}
72
73/** The line under the divider: the tic the model said most, or when they met. */
74const quoteLine = (px: Pixling): Span[] => {
75  const [top] = topTics(px.tics, 1)
76  if (top && top.n > 0) {
77    return [
78      ['HEARD ', DIM],
79      [top.label.toUpperCase(), INK],
80      [` ×${n(top.n)}`, GOLD],
81    ]
82  }
83  const d = new Date(metAt(px))
84  const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
85  return [
86    ['FRIENDS SINCE ', DIM],
87    [date, INK],
88  ]
89}
90
91/** The card at its drawn size, 240×135; `t` picks the animation frame of the pose. */
92export const renderCard = (px: Pixling, now: number, t = 600): Pixels => {
93  const species = speciesById(px.species)
94  if (!species) throw new Error(`unknown species ${px.species}`)
95  const accent = RARITY_COLOR[species.rarity]
96  const p = blank(CARD_W, CARD_H)
97  fill(p, 0, 0, CARD_W, CARD_H, CARD_BG)
98  frame(p, 0, 0, CARD_W, CARD_H, accent, 2)
99  frame(p, 3, 3, CARD_W - 6, CARD_H - 6, FAINT, 1)
100
101  // Header: the name of the game, and the rarity in stars.
102  print(p, 8, 7, 'PIXLINGS', accent)
103  const stars = RARITY_ORDER.indexOf(species.rarity) + 1
104  const rarity = species.rarity.toUpperCase()
105  const rarityW = textWidth('★★★★★ ') + textWidth(rarity)
106  let rx = CARD_W - 8 - rarityW
107  for (let i = 0; i < 5; i++) rx = print(p, rx, 7, '★', i < stars ? accent : FAINT)
108  print(p, rx + 6, 7, rarity, accent)
109
110  // The pixling on a spotlit panel.
111  const panel = { x: 8, y: 18, w: 72, h: 80 }
112  fill(p, panel.x, panel.y, panel.w, panel.h, PANEL)
113  const cx = panel.x + panel.w / 2
114  const cy = panel.y + panel.h / 2 + 4
115  for (let y = panel.y; y < panel.y + panel.h; y++) {
116    for (let x = panel.x; x < panel.x + panel.w; x++) {
117      const d = Math.hypot((x - cx) / 1.1, y - cy)
118      if (d < 30 && (d < 24 || (x + y) % 2 === 0)) p.px[y * p.w + x] = PANEL_LIGHT
119    }
120  }
121  frame(p, panel.x, panel.y, panel.w, panel.h, accent, 1)
122  const gear = gearOf(px)
123  const sprite = renderFrame({ species, isShiny: px.isShiny, mood: 'happy', t, hat: gear.hat, face: gear.face })
124  const scale = 3
125  const sx = Math.round(panel.x + (panel.w - sprite.w * scale) / 2)
126  const sy = panel.y + panel.h - 4 - sprite.h * scale
127  blitScaled(p, sprite, sx, Math.max(panel.y + 1, sy), scale)
128  if (px.isShiny) {
129    const spark = ['..W..', '..Y..', 'WYWYW', '..Y..', '..W..']
130    stamp(p, panel.x + 4, panel.y + 4, spark, { W: 0xffffff, Y: GOLD })
131    stamp(p, panel.x + panel.w - 9, panel.y + 12, spark, { W: 0xffffff, Y: GOLD })
132  }
133
134  // The right column: name, level, experience, days and streak, stats, badges.
135  const col = 88
136  const width = CARD_W - 8 - col
137  const name = px.name.toUpperCase()
138  const nameScale = textWidth(name, 2) <= width ? 2 : 1
139  print(p, col, nameScale === 2 ? 18 : 22, name, INK, nameScale)
140
141  const { level, into, need } = levelOf(px.xp)
142  const kind: Span[] = [
143    [`LV ${level}`, accent],
144    [' · ', FAINT],
145    ...(px.isShiny ? ([['✦ SHINY ', GOLD]] as Span[]) : []),
146    [species.name.toUpperCase(), DIM],
147  ]
148  printSpans(p, col, 36, kind)
149  fill(p, col, 46, width, 4, FAINT)
150  fill(p, col, 46, Math.max(1, Math.round((into / Math.max(1, need)) * width)), 4, accent)
151
152  statRows(px, now).forEach(({ label, value }, i) => {
153    const y = 55 + i * 9
154    const end = printSpans(p, col, y, [label])
155    const valueX = col + width - textWidth(value)
156    print(p, valueX, y, value, INK)
157    for (let x = end + 1; x < valueX - 3; x += 3) p.px[(y + 6) * p.w + x] = FAINT
158  })
159
160  BADGES.forEach((b, i) => {
161    const isEarned = px.badges[b.id] !== undefined
162    const colors = isEarned ? b.colors : Object.fromEntries(Object.keys(b.colors).map(k => [k, FAINT]))
163    stamp(p, col + i * 9, 91, b.icon, colors)
164  })
165
166  // Divider, the quote and the footer.
167  fill(p, 8, 101, CARD_W - 16, 1, FAINT)
168  const quote = quoteLine(px)
169  printSpans(p, Math.round((CARD_W - spansWidth(quote)) / 2), 106, quote)
170  const repo = REPO.toUpperCase()
171  print(p, Math.round((CARD_W - textWidth(repo)) / 2), 120, repo, DIM)
172  return p
173}
174
175/** What to post with the card. */
176export const shareText = (px: Pixling): string => {
177  const species = speciesById(px.species)
178  const { level } = levelOf(px.xp)
179  const rarity = species ? species.rarity : 'common'
180  const shiny = px.isShiny ? 'SHINY ' : ''
181  const [top] = topTics(px.tics, 1)
182  const heard = top ? ` Claude has said ${top.label} ${top.n}× in front of it.` : ''
183  const badges = Object.keys(px.badges).length
184  return (
185    `Meet ${px.name}, my Lv ${level} ${shiny}${rarity} ${species?.name.toLowerCase() ?? 'pixling'} in Claude Code. ` +
186    `${count(px.stats.turns, 'turn')}, ${count(px.stats.bugsSquashed, 'bug')} squashed, ${badges}/${BADGES.length} badges.${heard}` +
187    ` Hatch yours: https://${REPO}`
188  )
189}
190
hooks/lib/canvas.ts 1057 lines
1// Frame composition: a species body on a small canvas, with eyes, mouth, cheeks, hats and
2// particles drawn for a mood at a moment. Pure and deterministic in `t`, so the README media
3// is rendered by this exact code.
4
5import type { Point, Species } from './sprites.ts'
6
7export const CANVAS_W = 22
8/** The canvas height without a tall hat; a hat that needs headroom adds two rows at a time. */
9export const CANVAS_H = 18
10export const SPRITE_X = 3
11const SPRITE_Y = 2
12
13export const TRANSPARENT = -1
14
15export type Pixels = { readonly w: number; readonly h: number; readonly px: Int32Array }
16
17export type Mood =
18  | 'idle'
19  | 'working'
20  | 'happy'
21  | 'celebrate'
22  | 'sad'
23  | 'alarmed'
24  | 'attention'
25  | 'sleep'
26  | 'love'
27  | 'dizzy'
28  | 'walk'
29  | 'unimpressed'
30
31export type Icon = 'read' | 'edit' | 'bash' | 'web' | 'agent' | 'test'
32
33export type Hat = 'sprout' | 'shroom' | 'party' | 'crown' | 'halo' | 'wizard'
34
35export type Face = 'shades'
36
37export type FrameInput = {
38  readonly species: Species
39  readonly isShiny: boolean
40  readonly mood: Mood
41  /** Milliseconds since the mood began. */
42  readonly t: number
43  readonly hat?: Hat | null
44  readonly face?: Face | null
45  readonly icon?: Icon | null
46  /** Draw the cactus bloom and similar species-specific flourishes. */
47  readonly isBlooming?: boolean
48  /** The canvas width; wider than CANVAS_W gives the pixling room to walk. */
49  readonly width?: number
50  /** The sprite's left edge on the canvas. */
51  readonly x?: number
52  /** Faces left (the art faces right or front). */
53  readonly isFlipped?: boolean
54  /** Plan mode: the propeller thinking cap, in place of the worn hat. */
55  readonly hasCap?: boolean
56  /** The context window is nearly full: a bead of sweat runs down the brow. */
57  readonly isSweating?: boolean
58  /** Milliseconds into a compaction, squished under the press; null or absent when none runs. */
59  readonly squishT?: number | null
60  /** Milliseconds since a compaction ended, springing back up; null or absent when none did. */
61  readonly reliefT?: number | null
62  /** Subagents' minis, on a strip of their own right of the canvas `width`. */
63  readonly squad?: readonly MiniView[]
64  /** Minis past the strip's cap, counted as "+N" at its end. */
65  readonly hidden?: number
66}
67
68/** One subagent's mini as the strip draws it. */
69export type MiniView = {
70  /** Its body color (miniTint of its agent type). */
71  readonly tint: number
72  /** Milliseconds since it joined. */
73  readonly t: number
74  /** Milliseconds since its agent finished; null while it works. */
75  readonly doneT: number | null
76  /** The tool its agent is running, held over its head. */
77  readonly icon?: Icon | null
78  /** Offsets its bob and glances, so a squad does not move in lockstep. */
79  readonly seed?: number
80}
81
82export const blank = (w = CANVAS_W, h = CANVAS_H): Pixels => ({
83  w,
84  h,
85  px: new Int32Array(w * h).fill(TRANSPARENT),
86})
87
88const put = (p: Pixels, x: number, y: number, color: number): void => {
89  if (x >= 0 && y >= 0 && x < p.w && y < p.h) {
90    p.px[y * p.w + x] = color
91  }
92}
93
94const get = (p: Pixels, x: number, y: number): number =>
95  x >= 0 && y >= 0 && x < p.w && y < p.h ? (p.px[y * p.w + x] ?? TRANSPARENT) : TRANSPARENT
96
97/** Draws a small pattern: `.` skips, other characters map through `colors`. */
98export const stamp = (
99  p: Pixels,
100  x: number,
101  y: number,
102  rows: readonly string[],
103  colors: Readonly<Record<string, number>>,
104): void => {
105  rows.forEach((row, dy) => {
106    for (let dx = 0; dx < row.length; dx++) {
107      const ch = row[dx] ?? '.'
108      const color = colors[ch]
109      if (ch !== '.' && color !== undefined) {
110        put(p, x + dx, y + dy, color)
111      }
112    }
113  })
114}
115
116// A tiny deterministic PRNG so particles look random but render the same every time.
117const hash = (n: number): number => {
118  let x = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b)
119  x ^= x >>> 13
120  x = Math.imul(x, 0xc2b2ae35)
121  x ^= x >>> 16
122  return (x >>> 0) / 0xffffffff
123}
124
125const step = (t: number, ms: number): number => Math.floor(t / ms)
126
127const pick = <T>(list: readonly T[], i: number): T => list[((i % list.length) + list.length) % list.length] as T
128
129// ---------------------------------------------------------------------------------------------
130// Faces
131
132type EyeStyle = 'open' | 'closed' | 'happy' | 'sad' | 'wide' | 'spin' | 'lookLeft' | 'lookRight' | 'rolled' | 'lidded'
133type MouthStyle = 'smile' | 'flat' | 'open' | 'frown' | 'o' | 'wavy' | 'none'
134
135const WHITE = 0xffffff
136const TONGUE = 0xff6b81
137const CHEEK = 0xff8fab
138const TEAR = 0x7fd3ff
139
140const drawEyes = (p: Pixels, s: Species, ox: number, oy: number, style: EyeStyle, t: number): void => {
141  const [w, h] = s.eyeSize
142  const isGlowing = s.eyeColor !== 0x1b1424
143  s.eyes.forEach(([ex, ey], index) => {
144    const x = ox + ex
145    const y = oy + ey
146    const fill = (color: number): void => {
147      for (let dy = 0; dy < h; dy++) for (let dx = 0; dx < w; dx++) put(p, x + dx, y + dy, color)
148    }
149    switch (style) {
150      case 'open':
151      case 'lookLeft':
152      case 'lookRight': {
153        fill(s.eyeColor)
154        if (!isGlowing && w >= 2) {
155          put(p, style === 'lookRight' ? x + w - 1 : x, y, WHITE)
156        }
157        if (isGlowing && style !== 'open') {
158          put(p, x, y + (style === 'lookLeft' ? 0 : h - 1), WHITE)
159        }
160        return
161      }
162      case 'closed':
163        for (let dx = 0; dx < w; dx++) put(p, x + dx, y + h - 1, s.eyeColor)
164        return
165      case 'happy':
166        // A small arch: ∩.
167        for (let dx = 0; dx < w; dx++) put(p, x + dx, y, s.eyeColor)
168        put(p, x - 1, y + 1, s.eyeColor)
169        put(p, x + w, y + 1, s.eyeColor)
170        return
171      case 'sad': {
172        fill(s.eyeColor)
173        if (!isGlowing && w >= 2) put(p, x + w - 1, y + h - 1, WHITE)
174        const outer = index === 0 ? x : x + w - 1
175        const drop = step(t, 220) % 3
176        put(p, outer, y + h + drop, TEAR)
177        return
178      }
179      case 'wide':
180        for (let dy = 0; dy < Math.max(h, 2); dy++) {
181          for (let dx = 0; dx < Math.max(w, 2); dx++) put(p, x + dx, y + dy, WHITE)
182        }
183        put(p, x + (index === 0 ? Math.max(w, 2) - 1 : 0), y + Math.max(h, 2) - 1, s.eyeColor)
184        return
185      case 'rolled':
186        // Pupils up, whites below: an eye roll.
187        fill(WHITE)
188        for (let dx = 0; dx < w; dx++) put(p, x + dx, y, s.eyeColor)
189        return
190      case 'lidded':
191        // A flat lid and a pupil peeking sideways: ¬_¬
192        for (let dx = 0; dx < w; dx++) put(p, x + dx, y, s.eyeColor)
193        put(p, x + w - 1, y + Math.max(h, 2) - 1, s.eyeColor)
194        return
195      case 'spin': {
196        const phase = step(t, 120) % 2
197        if (w >= 2 && h >= 2) {
198          put(p, x + phase, y, s.eyeColor)
199          put(p, x + 1 - phase, y + 1, s.eyeColor)
200        } else {
201          put(p, x, y + phase, s.eyeColor)
202        }
203        return
204      }
205    }
206  })
207}
208
209const drawMouth = (p: Pixels, s: Species, ox: number, oy: number, style: MouthStyle): void => {
210  if (!s.mouth || style === 'none') return
211  const [mx, my] = s.mouth
212  const x = ox + mx
213  const y = oy + my
214  const ink = s.eyeColor
215  const dots: Record<Exclude<MouthStyle, 'none'>, Point[]> = {
216    smile: [
217      [0, 0],
218      [3, 0],
219      [1, 1],
220      [2, 1],
221    ],
222    flat: [
223      [1, 0],
224      [2, 0],
225    ],
226    open: [
227      [0, 0],
228      [1, 0],
229      [2, 0],
230      [3, 0],
231    ],
232    frown: [
233      [1, 0],
234      [2, 0],
235      [0, 1],
236      [3, 1],
237    ],
238    o: [
239      [1, 0],
240      [2, 0],
241      [1, 1],
242      [2, 1],
243    ],
244    wavy: [
245      [0, 1],
246      [1, 0],
247      [2, 1],
248      [3, 0],
249    ],
250  }
251  for (const [dx, dy] of dots[style]) put(p, x + dx, y + dy, ink)
252  if (style === 'open') {
253    put(p, x + 1, y + 1, TONGUE)
254    put(p, x + 2, y + 1, TONGUE)
255  }
256}
257
258const drawCheeks = (p: Pixels, s: Species, ox: number, oy: number): void => {
259  for (const [cx, cy] of s.cheeks) {
260    put(p, ox + cx, oy + cy, CHEEK)
261    put(p, ox + cx + 1, oy + cy, CHEEK)
262  }
263}
264
265// ---------------------------------------------------------------------------------------------
266// Hats and face gear, anchored on the head's center column and top row.
267
268type Gear = { rows: readonly string[]; colors: Readonly<Record<string, number>>; lift: number }
269
270const HATS: Readonly<Record<Hat, Gear>> = {
271  sprout: {
272    rows: ['LL.LL', '.LGL.', '..G..'],
273    colors: { L: 0x8ee08a, G: 0x3f9a4a },
274    lift: 3,
275  },
276  shroom: {
277    rows: ['...W...', '.RRRRR.', 'RWRWRWR'],
278    colors: { R: 0xe8475f, W: 0xfff4f4 },
279    lift: 2,
280  },
281  party: {
282    rows: ['..Y..', '..P..', '.PBP.', '.BPB.', 'PBPBP'],
283    colors: { Y: 0xffe14d, P: 0xff5fa2, B: 0x5ad1ff },
284    lift: 4,
285  },
286  crown: {
287    rows: ['Y.Y.Y', 'YYYYY', 'YRYBY'],
288    colors: { Y: 0xffd23f, R: 0xff4d6d, B: 0x4dc3ff },
289    lift: 2,
290  },
291  halo: {
292    rows: ['.YYYYY.', 'Y.....Y', '.YYYYY.'],
293    colors: { Y: 0xffe98a },
294    lift: 5,
295  },
296  wizard: {
297    rows: ['....P..', '...PP..', '..PYP..', '..PPPP.', '.PPPPPP', 'BBBBBBBB'],
298    colors: { P: 0x7b4dff, Y: 0xffe14d, B: 0x4a2fb8 },
299    lift: 5,
300  },
301}
302
303const topRow = (s: Species): number => {
304  const row = s.art.findIndex(line => /[^.]/.test(line))
305  return row < 0 ? 0 : row
306}
307
308/** Where the sprite sits and how tall the canvas is, so a hat or a jump never clips. */
309export const layoutFor = (s: Species, hat?: Hat | null): { y: number; h: number } => {
310  const need = hat ? HATS[hat].lift - s.head[1] + (hat === 'halo' ? 1 : 0) : 0
311  let y = SPRITE_Y
312  while (y < need) y += 2
313  return { y, h: y + 16 }
314}
315
316/** How far up the sprite may jump without leaving the canvas (hat, or thinking cap, included). */
317const headroom = (s: Species, y: number, hat?: Hat | null, hasCap = false): number => {
318  const hatTop = hasCap
319    ? s.head[1] - CAP_LIFT
320    : hat
321      ? s.head[1] - HATS[hat].lift - (hat === 'halo' ? 1 : 0)
322      : topRow(s)
323  return Math.max(0, y + Math.min(topRow(s), hatTop))
324}
325
326const drawHat = (p: Pixels, s: Species, ox: number, oy: number, hat: Hat, t: number): void => {
327  const gear = HATS[hat]
328  const width = Math.max(...gear.rows.map(r => r.length))
329  const [hx, hy] = s.head
330  const bob = hat === 'halo' ? step(t, 500) % 2 : 0
331  stamp(p, ox + hx - Math.floor(width / 2), oy + hy - gear.lift - bob, gear.rows, gear.colors)
332}
333
334const drawShades = (p: Pixels, s: Species, ox: number, oy: number): void => {
335  const [w, h] = s.eyeSize
336  const left = s.eyes[0]
337  const right = s.eyes[s.eyes.length - 1]
338  if (!left || !right) return
339  const frame = 0x101018
340  const glint = 0x8fa3ff
341  const y = oy + left[1]
342  for (let x = ox + left[0] - 1; x <= ox + right[0] + w; x++) put(p, x, y, frame)
343  for (const [ex, ey] of s.eyes) {
344    for (let dy = 0; dy < h; dy++) {
345      for (let dx = -1; dx <= w; dx++) put(p, ox + ex + dx, oy + ey + dy, frame)
346    }
347    put(p, ox + ex, oy + ey, glint)
348  }
349}
350
351// ---------------------------------------------------------------------------------------------
352// Particles
353
354const Z_SMALL = ['###', '.#.', '###']
355const Z_BIG = ['####', '..#.', '.#..', '####']
356const HEART = ['##.##', '#####', '.###.', '..#..']
357const SPARKLE = ['.#.', '#W#', '.#.']
358const CLOUD = ['...##.##...', '.#########.', '###########', '.#########.']
359const BANG = ['#', '#', '#', '.', '#']
360
361const ICONS: Readonly<Record<Icon, Gear>> = {
362  read: {
363    rows: ['WW.WW', 'WWKWW', 'WWKWW', 'BBBBB'],
364    colors: { W: 0xf4f1e6, K: 0x6b5b45, B: 0x4d7cff },
365    lift: 0,
366  },
367  edit: {
368    rows: ['...PP', '..YY.', '.YY..', 'K....'],
369    colors: { P: 0xff8fab, Y: 0xffcf3f, K: 0x333344 },
370    lift: 0,
371  },
372  bash: {
373    rows: ['DDDDD', 'DGDDD', 'DDGDD', 'DGDWW'],
374    colors: { D: 0x1c2333, G: 0x5cff8a, W: 0xe6e6e6 },
375    lift: 0,
376  },
377  web: {
378    rows: ['.BBB.', 'BGBGB', 'BBGBB', '.BBB.'],
379    colors: { B: 0x4da3ff, G: 0x5cd67a },
380    lift: 0,
381  },
382  agent: {
383    rows: ['O.O..', 'OOO.O', '.O.OO', '...O.'],
384    colors: { O: 0xffa64d },
385    lift: 0,
386  },
387  test: {
388    rows: ['.W.', '.W.', 'WGW', 'GGG'],
389    colors: { W: 0xd8e6ff, G: 0x5cff8a },
390    lift: 0,
391  },
392}
393
394const CONFETTI = [0xff5fa2, 0xffe14d, 0x5ad1ff, 0x6fdc8c, 0xc77dff, 0xff8a3d]
395
396const drawZzz = (p: Pixels, ox: number, oy: number, s: Species, t: number): void => {
397  const [hx, hy] = s.head
398  const baseX = ox + hx + 4
399  const baseY = oy + hy + 1
400  const cycle = 2600
401  for (let i = 0; i < 2; i++) {
402    const local = (t + i * (cycle / 2)) % cycle
403    const rise = Math.floor((local / cycle) * 6)
404    const color = local < cycle * 0.8 ? 0xcfe3ff : 0x7d93b8
405    stamp(p, baseX + Math.floor(rise / 2), baseY - rise, i === 0 ? Z_BIG : Z_SMALL, { '#': color })
406  }
407}
408
409const drawSparkles = (p: Pixels, ox: number, t: number, colors: readonly number[]): void => {
410  const spots: Point[] = [
411    [1, 3],
412    [18, 2],
413    [0, 11],
414    [19, 9],
415    [17, 15],
416    [2, 15],
417  ]
418  spots.forEach(([sx, y], i) => {
419    const x = sx + ox - SPRITE_X
420    const phase = (step(t, 110) + i * 2) % 6
421    const color = pick(colors, i)
422    if (phase < 2) put(p, x + 1, y + 1, color)
423    else if (phase < 4) stamp(p, x, y, SPARKLE, { '#': color, W: WHITE })
424  })
425}
426
427const drawConfetti = (p: Pixels, t: number): void => {
428  for (let i = 0; i < Math.round(14 * (p.w / CANVAS_W)); i++) {
429    const speed = 60 + Math.floor(hash(i * 7) * 60)
430    const x = Math.floor(hash(i * 13 + 1) * p.w)
431    const y = (step(t, speed) + Math.floor(hash(i * 31) * p.h)) % (p.h + 4)
432    put(p, x + (step(t, 300) % 2), y - 2, pick(CONFETTI, i))
433  }
434}
435
436const drawHearts = (p: Pixels, ox: number, oy: number, s: Species, t: number): void => {
437  const [hx, hy] = s.head
438  const cycle = 1800
439  for (let i = 0; i < 2; i++) {
440    const local = (t + i * (cycle / 2)) % cycle
441    const rise = Math.floor((local / cycle) * 7)
442    const x = ox + hx + (i === 0 ? 3 : -8)
443    stamp(p, x, oy + hy + 2 - rise, HEART, { '#': i === 0 ? 0xff4d8d : 0xff8fb8 })
444  }
445}
446
447const drawRain = (p: Pixels, ox: number, s: Species, t: number): void => {
448  const [hx] = s.head
449  const x = ox + hx - 6
450  for (let i = 0; i < 5; i++) {
451    const fall = (step(t, 80) + i * 5) % 7
452    put(p, x + 1 + i * 2, 4 + fall, TEAR)
453  }
454  stamp(p, x, 0, CLOUD, { '#': 0x9aa5bd })
455}
456
457const drawBang = (p: Pixels, ox: number, oy: number, s: Species, t: number, color: number): void => {
458  if (step(t, 260) % 4 === 3) return
459  const [hx, hy] = s.head
460  stamp(p, ox + hx + 6, Math.max(0, oy + hy - 2), BANG, { '#': color })
461}
462
463const drawSweat = (p: Pixels, ox: number, oy: number, s: Species, t: number): void => {
464  const [hx, hy] = s.head
465  const slide = step(t, 160) % 3
466  stamp(p, ox + hx - 8, oy + hy + 2 + slide, ['.#', '##', '##'], { '#': TEAR })
467}
468
469const drawStars = (p: Pixels, ox: number, oy: number, s: Species, t: number): void => {
470  const [hx, hy] = s.head
471  for (let i = 0; i < 3; i++) {
472    const angle = t / 260 + (i * Math.PI * 2) / 3
473    const x = ox + hx + Math.round(Math.cos(angle) * 6)
474    const y = oy + hy - 1 + Math.round(Math.sin(angle) * 1.5)
475    put(p, x, y, 0xffe14d)
476  }
477}
478
479const drawBloom = (p: Pixels, ox: number, oy: number, s: Species): void => {
480  const [hx, hy] = s.head
481  stamp(p, ox + hx - 2, oy + hy - 2, ['.P.P.', 'PPYPP', '.P.P.'], { P: 0xff6fae, Y: 0xffe14d })
482}
483
484// ---------------------------------------------------------------------------------------------
485// Reactions: the thinking cap, context sweat, the compaction press, the subagent squad
486
487/**
488 * Plan mode's propeller beanie. Three rows above the head at most, so it fits the headroom every
489 * species already has: putting it on never makes the band taller.
490 */
491const CAP_LIFT = 3
492const CAP_DOME = ['..RYB..', '.RRYBB.', 'RRRYBBB']
493/** The propeller seen from the side as it turns: one blade light, one dark, swapping. */
494const CAP_SPIN = ['LLLYDDD', '.LLYDD.', '...Y...', '.DDYLL.', 'DDDYLLL', '.DDYLL.', '...Y...', '.LLYDD.']
495const CAP_COLORS: Readonly<Record<string, number>> = { R: 0xff5a6e, Y: 0xffd23f, B: 0x4dc3ff, L: 0xe9f7ff, D: 0x7d8aa3 }
496
497const drawCap = (p: Pixels, s: Species, ox: number, oy: number, t: number): void => {
498  const [hx, hy] = s.head
499  stamp(p, ox + hx - 3, oy + hy - CAP_LIFT, [pick(CAP_SPIN, step(t, 90)), ...CAP_DOME], CAP_COLORS)
500}
501
502const BEAD = ['.D', 'DW', 'DD']
503
504/** A bead of sweat forms at the brow beside the far eye, runs down, and comes again. */
505const drawBead = (p: Pixels, s: Species, ox: number, oy: number, t: number): void => {
506  const eye = s.eyes[s.eyes.length - 1]
507  if (!eye) return
508  const local = t % 2400
509  if (local >= 2000) return
510  const x = eye[0] + s.eyeSize[0]
511  // It forms on the brow, never in the air above a head that slopes away (the turtle's).
512  const brow = s.art.findIndex(row => [x, x + 1, x + 2].some(c => (row[c] ?? '.') !== '.'))
513  const y = Math.max(eye[1] - 2, brow - 1)
514  const slide = Math.min(3, Math.floor(local / 500))
515  stamp(p, ox + x, oy + y + slide, BEAD, { D: 0x4aa8ff, W: 0xe6f6ff })
516}
517
518/** How long a compaction's end springs the pixling back up. */
519export const SPRING_MS = 480
520
521/** The squish and the spring as scale factors about the feet: [across, up]. */
522const squashOf = (squishT: number | null, springT: number | null): readonly [number, number] => {
523  if (squishT !== null) return step(squishT, 380) % 2 === 0 ? [1.22, 0.6] : [1.18, 0.66]
524  if (springT !== null && springT < 160) return [0.88, 1.12]
525  return [1, 1]
526}
527
528/** Copies the sprite layer onto the canvas scaled about its feet (`cx`, `floor`). */
529const scaleInto = (p: Pixels, layer: Pixels, cx: number, floor: number, sx: number, sy: number): void => {
530  for (let y = 0; y < p.h; y++) {
531    const from = Math.round(floor - (floor - y) / sy)
532    if (from < 0 || from >= layer.h) continue
533    for (let x = 0; x < p.w; x++) {
534      const color = get(layer, Math.floor(cx + (x + 0.5 - cx) / sx), from)
535      if (color !== TRANSPARENT) put(p, x, y, color)
536    }
537  }
538}
539
540const topOf = (p: Pixels): number => {
541  for (let i = 0; i < p.px.length; i++) if (p.px[i] !== TRANSPARENT) return Math.floor(i / p.w)
542  return p.h
543}
544
545const PRESS_HI = 0xdfe6f2
546const PRESS = 0x9aa5bd
547const PRESS_LO = 0x5d6a80
548
549/** The compactor: a plate resting on the squished head, on a rod from the top of the band. */
550const drawPress = (p: Pixels, cx: number, top: number): void => {
551  const plate = top - 2
552  for (let x = cx - 9; x < cx + 9; x++) {
553    put(p, x, plate, PRESS_HI)
554    put(p, x, plate + 1, x === cx - 9 || x === cx + 8 ? PRESS_LO : PRESS)
555  }
556  for (let y = 0; y < plate; y++) {
557    put(p, cx - 1, y, PRESS_LO)
558    put(p, cx, y, PRESS)
559  }
560}
561
562/** Squeezed tight: >_< at where the eyes and mouth land on the squished body. */
563const drawStrain = (p: Pixels, s: Species, ox: number, oy: number, map: (x: number, y: number) => Point): void => {
564  const [w, h] = s.eyeSize
565  s.eyes.forEach(([ex, ey], i) => {
566    const [x, y] = map(ox + ex + (w - 1) / 2, oy + ey + (h - 1) / 2)
567    // The left eye points right (>), the right eye left (<).
568    const back = i === 0 ? -1 : 1
569    put(p, x + back, y - 1, s.eyeColor)
570    put(p, x, y, s.eyeColor)
571    put(p, x + back, y + 1, s.eyeColor)
572  })
573  if (s.mouth) {
574    const [mx, my] = map(ox + s.mouth[0] + 1.5, oy + s.mouth[1])
575    put(p, mx - 1, my, s.eyeColor)
576    put(p, mx, my, s.eyeColor)
577  }
578}
579
580/** Mid-tones that hold on a dark terminal and a light one alike. */
581const PUFF = 0xb4bfd0
582
583// The squad: one mini per running subagent, on a strip right of the pixling's own canvas.
584
585/**
586 * A mini's art: a little critter on two feet with two feelers, `a` and `b` their tips (which
587 * blink in turn while it works). The face is drawn on top. Two feelers, not one: a single stalk
588 * with a lit tip reads as a bomb's fuse at this size.
589 */
590const MINI_ART = [
591  '.a....b.',
592  '..k..k..',
593  '..KKKK..',
594  '.KLLBBK.',
595  'KLBBBBBK',
596  'KBBBBBBK',
597  'KSBBBBSK',
598  '.KSSSSK.',
599  '.KK..KK.',
600]
601export const MINI_W = 8
602export const MINI_H = MINI_ART.length
603/** A mini and the gap after it. */
604export const MINI_SLOT = MINI_W + 1
605/** How many minis stand on the strip; the rest are counted. */
606export const SQUAD_CAP = 4
607/** A finished mini celebrates, then poofs away this long after its agent stopped. */
608export const MINI_LEAVE_MS = 3000
609/** When the celebration gives way to the poof. */
610export const MINI_POOF_MS = 2400
611/** Working: tall eyes and a small smile, the whole face glancing left or right now and then. */
612const MINI_FACE: readonly Point[] = [
613  [2, 4],
614  [2, 5],
615  [5, 4],
616  [5, 5],
617  [3, 6],
618  [4, 6],
619]
620/** Done: eyes shut with joy and a wide-open mouth. */
621const MINI_JOY: readonly Point[] = [
622  [1, 4],
623  [2, 4],
624  [5, 4],
625  [6, 4],
626  [3, 5],
627  [4, 5],
628]
629const MINI_GLANCE = [0, -1, 0, 1]
630const MINI_INK = 0x1b1424
631const MINI_LIT = 0xffe14d
632
633/** Agent types the engine ships, each in its own color; any other type hashes into the set. */
634const MINI_TINTS = [0xffa64d, 0x4fd1c5, 0xb18cff, 0x6fdc8c, 0xff7eb6, 0x5aa9ff, 0xffd23f, 0xff6b6b]
635const KNOWN_TINT: Readonly<Record<string, number>> = {
636  'general-purpose': 0,
637  Explore: 1,
638  Plan: 2,
639  'statusline-setup': 3,
640  'claude-code-guide': 5,
641}
642
643/** The body color a subagent of this type wears. */
644export const miniTint = (agentType: string): number => {
645  const known = KNOWN_TINT[agentType]
646  if (known !== undefined) return MINI_TINTS[known] ?? MINI_TINTS[0]!
647  let h = 0
648  for (const ch of agentType) h = (Math.imul(h, 31) + ch.charCodeAt(0)) | 0
649  return pick(MINI_TINTS, h)
650}
651
652const mix = (a: number, b: number, k: number): number => {
653  const ch = (shift: number): number => {
654    const x = (a >> shift) & 255
655    const y = (b >> shift) & 255
656    return Math.round(x + (y - x) * k) << shift
657  }
658  return ch(16) | ch(8) | ch(0)
659}
660
661/** The tips stay the body color unless lit: pale tips vanish on a light terminal. */
662const miniPalette = (tint: number, lit: 'a' | 'b' | 'both'): Readonly<Record<string, number>> => ({
663  K: mix(tint, 0x000000, 0.6),
664  B: tint,
665  L: mix(tint, 0xffffff, 0.5),
666  S: mix(tint, 0x000000, 0.25),
667  k: mix(tint, 0x000000, 0.6),
668  a: lit === 'b' ? tint : MINI_LIT,
669  b: lit === 'a' ? tint : MINI_LIT,
670})
671
672const MINI_HOP = [-3, -2, -1, 0]
673const MINI_JUMP = [0, -1, -2, -3, -3, -2, -1, 0, 0, 0]
674const POOF: readonly (readonly string[])[] = [
675  ['.##.', '####', '####', '.##.'],
676  ['#..#', '.##.', '.##.', '#..#'],
677  ['#..#', '....', '....', '#..#'],
678]
679
680/** One mini with its feet on row `floor`, its left edge at `x`. */
681const drawMini = (p: Pixels, x: number, floor: number, m: MiniView): void => {
682  const seed = m.seed ?? 0
683  const top = floor - MINI_H + 1
684  if (m.doneT !== null && m.doneT >= MINI_POOF_MS) {
685    const frame = POOF[Math.min(POOF.length - 1, step(m.doneT - MINI_POOF_MS, 150))] ?? []
686    stamp(p, x + 2, floor - 4, frame, { '#': PUFF })
687    return
688  }
689  if (m.doneT !== null) {
690    // Its agent is done: a jump with both arms up, eyes shut with joy, a sparkle overhead.
691    const t = m.doneT
692    const y = top + pick(MINI_JUMP, step(t, 70))
693    stamp(p, x, y, MINI_ART, miniPalette(m.tint, 'both'))
694    for (const [ax, ay] of [[-1, 4], [-1, 3], [MINI_W, 4], [MINI_W, 3]] as const) put(p, x + ax, y + ay, mix(m.tint, 0x000000, 0.6))
695    for (const [fx, fy] of MINI_JOY) put(p, x + fx, y + fy, MINI_INK)
696    put(p, x + 3, y + 6, TONGUE)
697    put(p, x + 4, y + 6, TONGUE)
698    // A twinkle off one shoulder, then the other: clear of the feelers, never a cross on its head.
699    const beat = step(t, 200) % 3
700    if (beat !== 1) stamp(p, beat === 0 ? x + 6 : x - 1, y - 4, SPARKLE, { '#': 0xffe14d, W: WHITE })
701    return
702  }
703  const dy = m.t < 300 ? pick(MINI_HOP, step(m.t, 75)) : step(m.t + seed * 97, 300) % 2
704  const y = top + dy
705  stamp(p, x, y, MINI_ART, miniPalette(m.tint, step(m.t + seed * 131, 400) % 2 === 0 ? 'a' : 'b'))
706  const glance = pick(MINI_GLANCE, step(m.t + seed * 211, 900))
707  for (const [fx, fy] of MINI_FACE) put(p, x + fx + glance, y + fy, MINI_INK)
708  if (m.icon) {
709    const gear = ICONS[m.icon]
710    const width = Math.max(...gear.rows.map(r => r.length))
711    stamp(p, x + Math.floor((MINI_W - width) / 2), y - gear.rows.length - 1, gear.rows, gear.colors)
712  }
713}
714
715/** A 3×5 numeral set for the strip's "+N". */
716const DIGITS: Readonly<Record<string, readonly string[]>> = {
717  '+': ['...', '.#.', '###', '.#.', '...'],
718  '0': ['###', '#.#', '#.#', '#.#', '###'],
719  '1': ['.#.', '##.', '.#.', '.#.', '###'],
720  '2': ['###', '..#', '###', '#..', '###'],
721  '3': ['###', '..#', '.##', '..#', '###'],
722  '4': ['#.#', '#.#', '###', '..#', '..#'],
723  '5': ['###', '#..', '###', '..#', '###'],
724  '6': ['###', '#..', '###', '#.#', '###'],
725  '7': ['###', '..#', '.#.', '.#.', '.#.'],
726  '8': ['###', '#.#', '###', '#.#', '###'],
727  '9': ['###', '#.#', '###', '..#', '###'],
728}
729const COUNT_INK = 0x8b95a8
730
731const countText = (hidden: number): string => `+${Math.min(hidden, 99)}`
732
733/** Columns the strip takes: nothing without minis, so the band is as it was. */
734export const squadWidth = (shown: number, hidden = 0): number =>
735  shown * MINI_SLOT + (hidden > 0 ? countText(hidden).length * 4 : 0)
736
737const drawSquad = (p: Pixels, x0: number, squad: readonly MiniView[], hidden: number): void => {
738  const floor = p.h - 1
739  squad.forEach((m, i) => drawMini(p, x0 + i * MINI_SLOT, floor, m))
740  if (hidden <= 0) return
741  let x = x0 + squad.length * MINI_SLOT
742  for (const ch of countText(hidden)) {
743    stamp(p, x, floor - 4, DIGITS[ch] ?? [], { '#': COUNT_INK })
744    x += 4
745  }
746}
747
748/** One mini on a canvas of its own, room left for its antenna, icon and jump. */
749export const renderMini = (m: MiniView): Pixels => {
750  const p = blank(MINI_W + 2, MINI_H + 7)
751  drawMini(p, 1, p.h - 1, m)
752  return p
753}
754
755// ---------------------------------------------------------------------------------------------
756// Body
757
758const drawBody = (p: Pixels, s: Species, ox: number, oy: number, palette: Readonly<Record<string, number>>, t: number): void => {
759  const beat = step(t, 320) % 2
760  s.art.forEach((row, y) => {
761    const wiggle = s.wiggle?.find(w => w.row === y)
762    const line = wiggle ? (pick(wiggle.frames, beat) ?? row) : row
763    for (let x = 0; x < line.length; x++) {
764      const ch = line[x] ?? '.'
765      const color = palette[ch]
766      if (ch !== '.' && color !== undefined) put(p, ox + x, oy + y, color)
767    }
768  })
769}
770
771type Pose = { dx: number; dy: number; eyes: EyeStyle; mouth: MouthStyle; hasCheeks: boolean }
772
773const JUMP = [0, -2, -4, -5, -5, -4, -2, 0, 0, 0, 0]
774const HOP = [0, -1, -2, -1, 0, 0, 0, 0]
775const BOUNCE = [0, -1, -2, -1]
776
777const blinking = (t: number): boolean => t % 4200 > 4040
778
779const pose = (mood: Mood, t: number): Pose => {
780  switch (mood) {
781    case 'idle':
782      return {
783        dx: 0,
784        dy: step(t, 650) % 2,
785        eyes: blinking(t) ? 'closed' : 'open',
786        mouth: 'smile',
787        hasCheeks: false,
788      }
789    case 'working':
790      return {
791        dx: 0,
792        dy: step(t, 260) % 2,
793        eyes: blinking(t) ? 'closed' : step(t, 900) % 2 === 0 ? 'lookLeft' : 'lookRight',
794        mouth: 'flat',
795        hasCheeks: false,
796      }
797    case 'happy':
798      return { dx: 0, dy: pick(BOUNCE, step(t, 130)), eyes: 'happy', mouth: 'open', hasCheeks: true }
799    case 'celebrate':
800      return { dx: 0, dy: pick(JUMP, step(t, 70)), eyes: 'happy', mouth: 'open', hasCheeks: true }
801    case 'sad':
802      return { dx: 0, dy: 1, eyes: 'sad', mouth: 'frown', hasCheeks: false }
803    case 'alarmed':
804      return {
805        dx: t < 1400 ? (step(t, 55) % 2 === 0 ? -1 : 1) : 0,
806        dy: 0,
807        eyes: 'wide',
808        mouth: 'o',
809        hasCheeks: false,
810      }
811    case 'attention':
812      return { dx: 0, dy: pick(HOP, step(t, 90)), eyes: 'open', mouth: 'open', hasCheeks: false }
813    case 'sleep':
814      return { dx: 0, dy: step(t, 1400) % 2, eyes: 'closed', mouth: step(t, 2800) % 2 ? 'o' : 'flat', hasCheeks: false }
815    case 'love':
816      return { dx: 0, dy: step(t, 400) % 2, eyes: 'happy', mouth: 'smile', hasCheeks: true }
817    case 'dizzy':
818      return { dx: step(t, 400) % 2, dy: 0, eyes: 'spin', mouth: 'wavy', hasCheeks: false }
819    case 'walk':
820      return { dx: 0, dy: -(step(t, 140) % 2), eyes: blinking(t) ? 'closed' : 'open', mouth: 'smile', hasCheeks: false }
821    case 'unimpressed':
822      return { dx: 0, dy: 0, eyes: t < 900 ? 'rolled' : 'lidded', mouth: 'flat', hasCheeks: false }
823  }
824}
825
826const mirrors = new Map<string, Species>()
827
828/** The species facing the other way: art reversed, anchors moved to match. */
829export const mirror = (s: Species): Species => {
830  const cached = mirrors.get(s.id)
831  if (cached) return cached
832  const flip = (row: string): string => [...row].reverse().join('')
833  const [w] = s.eyeSize
834  const m: Species = {
835    ...s,
836    art: s.art.map(flip),
837    eyes: [...s.eyes].reverse().map(([x, y]) => [16 - x - w, y] as const),
838    mouth: s.mouth ? [16 - s.mouth[0] - 4, s.mouth[1]] : null,
839    cheeks: [...s.cheeks].reverse().map(([x, y]) => [16 - x - 2, y] as const),
840    head: [16 - s.head[0], s.head[1]],
841    wiggle: s.wiggle?.map(w2 => ({ row: w2.row, frames: w2.frames.map(flip) })),
842  }
843  mirrors.set(s.id, m)
844  return m
845}
846
847/** Composes one frame of a pixling. */
848export const renderFrame = (input: FrameInput): Pixels => {
849  const { mood, t } = input
850  const s = input.isFlipped ? mirror(input.species) : input.species
851  const layout = layoutFor(s, input.hat)
852  const width = input.width ?? CANVAS_W
853  const squad = input.squad ?? []
854  const hidden = input.hidden ?? 0
855  const p = blank(width + squadWidth(squad.length, hidden), layout.h)
856  const spriteX = input.x ?? SPRITE_X
857  const palette = input.isShiny ? s.shiny : s.palette
858  const squishT = input.squishT ?? null
859  const springT = squishT === null && input.reliefT != null && input.reliefT >= 0 && input.reliefT < SPRING_MS ? input.reliefT : null
860  const isPressed = squishT !== null
861  // Squished or springing back, the body is drawn alone, scaled about its feet, then the face.
862  const isScaled = isPressed || springT !== null
863  const look: Pose = isPressed
864    ? { dx: 0, dy: 0, eyes: 'closed', mouth: 'none', hasCheeks: false }
865    : springT !== null
866      ? { dx: 0, dy: springT < 160 ? 0 : springT < 320 ? -3 : -1, eyes: 'happy', mouth: 'open', hasCheeks: true }
867      : pose(mood, t)
868  const { dx, eyes, mouth, hasCheeks } = look
869  const dy = Math.max(look.dy, -headroom(s, layout.y, input.hat, input.hasCap))
870  const ox = spriteX + dx
871  const oy = layout.y + dy
872  const eyeColor = (input.isShiny && s.shinyEyeColor) || s.eyeColor
873  const body = eyeColor === s.eyeColor ? s : { ...s, eyeColor }
874
875  if (!isScaled && mood === 'sad') drawRain(p, spriteX, s, t)
876  if (!isScaled && mood === 'celebrate') drawConfetti(p, t)
877
878  const layer = isScaled ? blank(p.w, p.h) : p
879  drawBody(layer, s, ox, oy, palette, t)
880  if (!isPressed) {
881    if (input.face === 'shades' && mood !== 'sleep' && mood !== 'sad') {
882      drawShades(layer, body, ox, oy)
883    } else {
884      drawEyes(layer, body, ox, oy, eyes, t)
885    }
886    drawMouth(layer, body, ox, oy, mouth)
887    if (hasCheeks) drawCheeks(layer, s, ox, oy)
888  }
889
890  // The robot's antenna blinks while it thinks.
891  if (!isScaled && s.id === 'robot' && (mood === 'working' || mood === 'attention') && step(t, 300) % 2 === 1) {
892    put(p, ox + 7, oy, 0xfff1a8)
893    put(p, ox + 8, oy, 0xfff1a8)
894  }
895  if (input.hasCap) {
896    drawCap(layer, s, ox, oy, t)
897  } else if (input.isBlooming || (s.id === 'cactus' && (mood === 'celebrate' || mood === 'love'))) {
898    drawBloom(layer, ox, oy, s)
899  } else if (input.hat) {
900    drawHat(layer, s, ox, oy, input.hat, t)
901  }
902
903  if (isScaled) {
904    const cx = ox + 8
905    const floor = oy + 15
906    const [sx, stretch] = squashOf(squishT, springT)
907    // A stretch never lifts the top of the head (or a tall hat) off the canvas.
908    const top = topOf(layer)
909    const sy = stretch > 1 && floor > top ? Math.min(stretch, floor / (floor - top)) : stretch
910    scaleInto(p, layer, cx, floor, sx, sy)
911    if (isPressed) {
912      const map = (x: number, y: number): Point => [Math.floor(cx + (x + 0.5 - cx) * sx), Math.round(floor - (floor - y) * sy)]
913      drawStrain(p, body, ox, oy, map)
914      drawPress(p, cx, topOf(p))
915    }
916    if (squad.length > 0 || hidden > 0) drawSquad(p, width, squad, hidden)
917    return p
918  }
919
920  switch (mood) {
921    case 'sleep':
922      drawZzz(p, ox, oy, s, t)
923      break
924    case 'celebrate':
925      drawSparkles(p, ox, t, [0xffe14d, 0xffffff, 0x5ad1ff])
926      break
927    case 'happy':
928      drawSparkles(p, ox, t + 300, [0xffe14d])
929      break
930    case 'love':
931      drawHearts(p, ox, oy, s, t)
932      break
933    case 'alarmed':
934      drawBang(p, ox, oy, s, t, 0xff4d4d)
935      drawSweat(p, ox, oy, s, t)
936      break
937    case 'attention':
938      drawBang(p, ox, oy, s, t, 0xffd23f)
939      break
940    case 'dizzy':
941      drawStars(p, ox, oy, s, t)
942      break
943    default:
944      break
945  }
946  // The alarmed face sweats already; asleep, nothing worries it.
947  if (input.isSweating && mood !== 'alarmed' && mood !== 'sleep') drawBead(p, body, ox, oy, t)
948  if (input.icon && (mood === 'working' || mood === 'idle' || mood === 'walk')) {
949    const gear = ICONS[input.icon]
950    stamp(p, Math.min(width - 5, ox + s.head[0] + 5), Math.max(0, oy + s.head[1] - 3), gear.rows, gear.colors)
951  }
952  if (input.isShiny && step(t, 1700) % 3 === 0) {
953    put(p, ox - 2, oy + 1 + (step(t, 200) % 2), 0xfff6a8)
954  }
955  if (squad.length > 0 || hidden > 0) drawSquad(p, width, squad, hidden)
956  return p
957}
958
959// ---------------------------------------------------------------------------------------------
960// The egg
961
962const EGG = [
963  '................',
964  '................',
965  '......KKKK......',
966  '.....KWWWWK.....',
967  '....KWWLWWWK....',
968  '...KWWLWWWWWK...',
969  '...KWWWWWSWWK...',
970  '..KWWWWWSSWWWK..',
971  '..KWSWWWWWWWWK..',
972  '..KWSSWWWWWLWK..',
973  '..KWWWWWWWLWWK..',
974  '..KWWWWWWWWWSK..',
975  '...KWWWWWWWSK...',
976  '...KSWWWWWSSK...',
977  '....KKSSSSKK....',
978  '......KKKK......',
979]
980
981const CRACKS: readonly Point[][] = [
982  [
983    [6, 6],
984    [7, 7],
985    [8, 6],
986  ],
987  [
988    [5, 7],
989    [9, 7],
990    [10, 6],
991    [4, 8],
992  ],
993  [
994    [11, 7],
995    [3, 8],
996    [12, 8],
997    [7, 8],
998  ],
999]
1000
1001export const HATCH_MS = 4600
1002
1003/**
1004 * The hatching sequence: the egg wobbles, cracks, flashes in the rarity's color, and the
1005 * creature appears in a celebration. `t` runs from 0; past HATCH_MS the creature celebrates.
1006 */
1007export const renderHatch = (input: FrameInput, rarityColor: number): Pixels => {
1008  const { t } = input
1009  if (t >= 4300) {
1010    return renderFrame({ ...input, mood: 'celebrate', t: t - 4300 })
1011  }
1012  const p = blank()
1013  if (t >= 4000) {
1014    const fade = (t - 4000) / 300
1015    const color = fade < 0.5 ? WHITE : rarityColor
1016    const r = Math.floor(3 + fade * 14)
1017    for (let y = 0; y < CANVAS_H; y++) {
1018      for (let x = 0; x < CANVAS_W; x++) {
1019        const d = Math.hypot((x - 11) * 0.8, (y - 10) * 1.2)
1020        if (d < r) put(p, x, y, color)
1021      }
1022    }
1023    return p
1024  }
1025  const urgency = t < 1500 ? 400 : t < 3000 ? 240 : 110
1026  const isShaking = t % urgency < urgency * 0.35
1027  const dx = isShaking ? (step(t, 60) % 2 === 0 ? -1 : 1) : 0
1028  stamp(p, SPRITE_X + dx, SPRITE_Y, EGG, { K: 0x5d4a3a, W: 0xfaf3e3, L: 0xffffff, S: 0xd9c9a8 })
1029  const stages = t < 1500 ? 0 : t < 2500 ? 1 : t < 3300 ? 2 : 3
1030  for (let i = 0; i < stages; i++) {
1031    for (const [x, y] of CRACKS[i] ?? []) put(p, SPRITE_X + dx + x, SPRITE_Y + y, 0x5d4a3a)
1032  }
1033  if (t > 3300) {
1034    // Light leaks through the cracks.
1035    for (const [x, y] of CRACKS.flat()) {
1036      if (step(t, 80) % 2 === 0) put(p, SPRITE_X + dx + x, SPRITE_Y + y - 1, rarityColor)
1037    }
1038  }
1039  return p
1040}
1041
1042/** A dark silhouette of a species, for the dex's undiscovered entries. */
1043export const renderSilhouette = (s: Species): Pixels => {
1044  const p = blank()
1045  drawBody(p, s, SPRITE_X, SPRITE_Y, Object.fromEntries(Object.keys(s.palette).map(k => [k, 0x3a3f4b])), 0)
1046  return p
1047}
1048
1049/** True when two frames are pixel-identical (lets the animation loop skip a blit). */
1050export const samePixels = (a: Pixels | null, b: Pixels): boolean => {
1051  if (!a || a.w !== b.w || a.h !== b.h) return false
1052  for (let i = 0; i < a.px.length; i++) if (a.px[i] !== b.px[i]) return false
1053  return true
1054}
1055
1056export { get as pixelAt }
1057
hooks/lib/detect.ts 763 lines
1// Reading what Claude is doing: risky shell commands, test runs and their outcome, which tool is
2// busy, and when a rate-limit window opens again.
3
4import type { Icon } from './canvas.ts'
5
6export type Risk = { level: 'danger' | 'spicy'; label: string }
7
8// The shell line ------------------------------------------------------------------------------
9// A small tokenizer: quotes become plain words, heredoc bodies are kept apart, and $(…), `…` and
10// <(…) are read as commands of their own. Risks and test runs are judged by the program at
11// command position, never by text that only mentions one. It reads each character once.
12
13type Simple = { words: string[]; bodies: string[] }
14type Pipeline = Simple[]
15
16const MAX_DEPTH = 8
17
18const closeParen = (src: string, open: number): number => {
19  let depth = 0
20  for (let i = open; i < src.length; i++) {
21    const c = src.charAt(i)
22    if (c === '(') depth++
23    else if (c === ')' && --depth === 0) return i + 1
24  }
25  return src.length
26}
27
28const closeTick = (src: string, from: number): number => {
29  let i = from
30  while (i < src.length && src.charAt(i) !== '`') i += src.charAt(i) === '\\' ? 2 : 1
31  return Math.min(i, src.length)
32}
33
34/** Reads `src` from `start` into pipelines; in a $(…) it stops after the closing paren. */
35const scan = (src: string, start: number, depth: number, inSub: boolean, out: Pipeline[]): number => {
36  let pipe: Pipeline = []
37  let seg: Simple = { words: [], bodies: [] }
38  let word = ''
39  let inWord = false
40  let next: 'arg' | 'skip' | 'body' = 'arg'
41  let parens = 0
42  const pending: { delim: string; strip: boolean; seg: Simple }[] = []
43
44  const endWord = (): void => {
45    if (inWord) {
46      if (next === 'arg') seg.words.push(word)
47      else if (next === 'body') seg.bodies.push(word)
48      next = 'arg'
49    }
50    word = ''
51    inWord = false
52  }
53  const endSeg = (): void => {
54    endWord()
55    next = 'arg'
56    if (seg.words.length > 0 || seg.bodies.length > 0) pipe.push(seg)
57    seg = { words: [], bodies: [] }
58  }
59  const endPipe = (): void => {
60    endSeg()
61    if (pipe.length > 0) out.push(pipe)
62    pipe = []
63  }
64  const sub = (from: number): number =>
65    depth < MAX_DEPTH ? scan(src, from, depth + 1, true, out) : closeParen(src, from - 1)
66  const backtick = (from: number): number => {
67    const end = closeTick(src, from)
68    if (depth < MAX_DEPTH) scan(src.slice(from, end), 0, depth + 1, false, out)
69    return end + 1
70  }
71
72  const doubleQuoted = (from: number): number => {
73    let k = from
74    while (k < src.length) {
75      const d = src.charAt(k)
76      if (d === '"') break
77      if (d === '\\') {
78        const e = src.charAt(k + 1)
79        if (e === '\n') k += 2
80        else if (e !== '' && '$`"\\'.includes(e)) {
81          word += e
82          k += 2
83        } else {
84          word += d
85          k++
86        }
87      } else if (d === '$' && src.charAt(k + 1) === '(') {
88        k = src.charAt(k + 2) === '(' ? closeParen(src, k + 1) : sub(k + 2)
89      } else if (d === '`') {
90        k = backtick(k + 1)
91      } else {
92        word += d
93        k++
94      }
95    }
96    inWord = true
97    return k + 1
98  }
99
100  const delimiter = (from: number, strip: boolean): number => {
101    let k = from
102    while (src.charAt(k) === ' ' || src.charAt(k) === '\t') k++
103    let delim = ''
104    for (; k < src.length; k++) {
105      const d = src.charAt(k)
106      if (' \t\r\n;&|<>()'.includes(d)) break
107      if (d !== "'" && d !== '"' && d !== '\\') delim += d
108    }
109    if (delim) pending.push({ delim, strip, seg })
110    return k
111  }
112
113  const heredocs = (from: number): number => {
114    let k = from
115    for (const doc of pending) {
116      const lines: string[] = []
117      while (k < src.length) {
118        const nl = src.indexOf('\n', k)
119        const end = nl < 0 ? src.length : nl
120        const line = src.slice(k, end)
121        const bare = (doc.strip ? line.replace(/^\t+/, '') : line).replace(/\r$/, '')
122        if (bare === doc.delim) {
123          k = end + 1
124          break
125        }
126        if (inSub && bare.startsWith(doc.delim + ')')) {
127          k += line.indexOf(doc.delim) + doc.delim.length
128          break
129        }
130        lines.push(line)
131        k = end + 1
132      }
133      doc.seg.bodies.push(lines.join('\n'))
134    }
135    pending.length = 0
136    return Math.min(k, src.length)
137  }
138
139  const plain = /[^\s\\'"`$#;&|()<>]+/y
140  let i = start
141  while (i < src.length) {
142    plain.lastIndex = i
143    if (plain.test(src)) {
144      word += src.slice(i, plain.lastIndex)
145      inWord = true
146      i = plain.lastIndex
147      continue
148    }
149    const c = src.charAt(i)
150    const n = src.charAt(i + 1)
151    if (c === '\\') {
152      if (n === '\r' && src.charAt(i + 2) === '\n') i++
153      else if (n !== '\n') {
154        word += n
155        inWord = true
156      }
157      i += 2
158    } else if (c === "'") {
159      const close = src.indexOf("'", i + 1)
160      const end = close < 0 ? src.length : close
161      word += src.slice(i + 1, end)
162      inWord = true
163      i = end + 1
164    } else if (c === '"') {
165      i = doubleQuoted(i + 1)
166    } else if (c === '`') {
167      // PowerShell continues a line with a trailing backtick.
168      if (n === '\n') i += 2
169      else if (n === '\r' && src.charAt(i + 2) === '\n') i += 3
170      else {
171        i = backtick(i + 1)
172        inWord = true
173      }
174    } else if (c === '$' && n === '(') {
175      i = src.charAt(i + 2) === '(' ? closeParen(src, i + 1) : sub(i + 2)
176      inWord = true
177    } else if (c === '$' && n === '{') {
178      const close = src.indexOf('}', i + 2)
179      const end = close < 0 ? src.length : close + 1
180      word += src.slice(i, end)
181      inWord = true
182      i = end
183    } else if (c === '#' && !inWord) {
184      const nl = src.indexOf('\n', i)
185      i = nl < 0 ? src.length : nl
186    } else if (c === ' ' || c === '\t' || c === '\r') {
187      endWord()
188      i++
189    } else if (c === '\n') {
190      endWord()
191      i = pending.length > 0 ? heredocs(i + 1) : i + 1
192      // A line that ends in a pipe goes on below.
193      if (seg.words.length > 0 || pipe.length === 0) endPipe()
194    } else if (c === ';') {
195      endPipe()
196      i++
197    } else if (c === '&' && n === '>') {
198      endWord()
199      i += src.charAt(i + 2) === '>' ? 3 : 2
200      next = 'skip'
201    } else if (c === '&') {
202      endPipe()
203      i += n === '&' ? 2 : 1
204    } else if (c === '|') {
205      if (n === '|') endPipe()
206      else endSeg()
207      i += n === '|' || n === '&' ? 2 : 1
208    } else if (c === '(') {
209      endPipe()
210      parens++
211      i++
212    } else if (c === ')') {
213      endPipe()
214      i++
215      if (inSub && parens === 0) return i
216      parens = Math.max(0, parens - 1)
217    } else if ((c === '<' || c === '>') && n === '(') {
218      i = sub(i + 2)
219      inWord = true
220    } else if (c === '<' || c === '>') {
221      if (inWord && /^\d+$/.test(word)) {
222        word = ''
223        inWord = false
224      } else endWord()
225      if (c === '<' && n === '<' && src.charAt(i + 2) === '<') {
226        i += 3
227        next = 'body'
228      } else if (c === '<' && n === '<') {
229        const strip = src.charAt(i + 2) === '-'
230        i = delimiter(i + (strip ? 3 : 2), strip)
231      } else {
232        i++
233        if (src.charAt(i) === c) i++
234        const d = src.charAt(i)
235        if (d === '&' || d === '|' || (c === '<' && d === '>')) i++
236        next = 'skip'
237      }
238    } else {
239      word += c
240      inWord = true
241      i++
242    }
243  }
244  endPipe()
245  return i
246}
247
248type Command = { program: string; argv: string[]; bodies: string[]; sudo: boolean; script: boolean }
249
250const ASSIGNMENT = /^[A-Za-z_]\w*\+?=/
251const KEYWORDS = new Set(['!', '{', '}', 'if', 'then', 'elif', 'else', 'while', 'until', 'do'])
252// Programs that run the rest of their line, with their short options that take a value.
253const WRAPPERS: ReadonlyMap<string, string> = new Map([
254  ['sudo', 'ugpChDrtUT'],
255  ['doas', 'uC'],
256  ['env', 'uCS'],
257  ['time', 'fo'],
258  ['nohup', ''],
259  ['exec', 'a'],
260  ['command', ''],
261  ['builtin', ''],
262  ['nice', 'n'],
263  ['ionice', 'cnp'],
264  ['timeout', 'sk'],
265  ['xargs', 'IPLnsdEa'],
266  ['stdbuf', 'ioe'],
267  ['cross-env', ''],
268  ['dotenv', 'ecv'],
269  ['xvfb-run', 'nsfep'],
270])
271const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd'])
272
273const programOf = (word: string): string => {
274  const name = word.slice(Math.max(word.lastIndexOf('/'), word.lastIndexOf('\\')) + 1).toLowerCase()
275  return name.includes('.') ? name.replace(/\.(?:exe|cmd|bat|ps1)$/, '') : name
276}
277
278/** Drops assignments, keywords and wrappers like sudo, env and xargs off the front. */
279const unwrap = (words: readonly string[]): { program: string; argv: string[]; sudo: boolean } => {
280  let i = 0
281  let sudo = false
282  let program = ''
283  while (i < words.length) {
284    const word = words[i] ?? ''
285    if ((word.includes('=') && ASSIGNMENT.test(word)) || KEYWORDS.has(word)) {
286      i++
287      continue
288    }
289    program = programOf(word)
290    const valued = WRAPPERS.get(program)
291    if (valued === undefined) break
292    if (program === 'command' && /^-[vV]/.test(words[i + 1] ?? '')) break
293    if (program === 'sudo' || program === 'doas') sudo = true
294    const wrapper = program
295    program = ''
296    i++
297    while (i < words.length) {
298      const opt = words[i] ?? ''
299      if (!opt.startsWith('-') || opt === '-') break
300      i++
301      if (opt === '--') break
302      if (opt.length === 2 && valued.includes(opt.charAt(1))) i++
303    }
304    if (wrapper === 'timeout') i++
305  }
306  return { program, argv: words.slice(i), sudo }
307}
308
309/** The script a command hands to another shell: `bash -c "…"`, `eval …`, `ssh host …`. */
310const scriptOf = (program: string, argv: readonly string[]): string | null => {
311  if (program === 'eval') return argv.slice(1).join(' ')
312  if (program === 'ssh') {
313    let j = 1
314    while ((argv[j] ?? '').startsWith('-')) {
315      const opt = argv[j] ?? ''
316      j += opt.length === 2 && 'bcDEeFIiJLlmOoPpQRSWw'.includes(opt.charAt(1)) ? 2 : 1
317    }
318    return argv.slice(j + 1).join(' ') || null
319  }
320  // `docker exec web sh -c …`: the shell sits near the front.
321  for (let k = 0; k < Math.min(argv.length, 16); k++) {
322    if (!SHELLS.has(k === 0 ? program : programOf(argv[k] ?? ''))) continue
323    for (let j = k + 1; j < Math.min(argv.length, k + 5); j++) {
324      const flag = argv[j] ?? ''
325      if (/^(?:-[a-z]*c|-command|\/[ck])$/i.test(flag)) return argv[j + 1] ?? null
326      if (!/^[-/]/.test(flag)) break
327    }
328  }
329  return null
330}
331
332/** The command `find … -exec` runs. */
333const execOf = (program: string, argv: readonly string[]): string[] | null => {
334  if (program !== 'find') return null
335  const at = argv.findIndex(a => /^-(?:exec|execdir|ok|okdir)$/.test(a))
336  if (at < 0) return null
337  const end = argv.findIndex((a, i) => i > at && (a === ';' || a === '+'))
338  return argv.slice(at + 1, end < 0 ? argv.length : end)
339}
340
341const pipelinesOf = (src: string, depth = 0): Command[][] => {
342  const raw: Pipeline[] = []
343  scan(src, 0, depth, false, raw)
344  const out: Command[][] = []
345  for (const pipe of raw) {
346    const commands: Command[] = []
347    for (const seg of pipe) {
348      const { program, argv, sudo } = unwrap(seg.words)
349      const script = depth < MAX_DEPTH ? scriptOf(program, argv) : null
350      commands.push({ program, argv, bodies: seg.bodies, sudo, script: script !== null })
351      const exec = execOf(program, argv)
352      if (exec) out.push([{ ...unwrap(exec), bodies: [], script: false }])
353      if (script) out.push(...pipelinesOf(script, depth + 1))
354    }
355    out.push(commands)
356  }
357  return out
358}
359
360// One tool call asks about the same line several times (icon, risk, test run): parse it once.
361let last: { src: string; pipelines: Command[][] } = { src: '', pipelines: [] }
362const parsed = (src: string): Command[][] => {
363  if (last.src !== src) last = { src, pipelines: pipelinesOf(src) }
364  return last.pipelines
365}
366
367/** Each simple command in a shell line as [program, ...args], wrappers like sudo and env dropped. */
368export const commandsOf = (command: string): string[][] =>
369  parsed(command).flatMap(p => p.map(c => [...c.argv])).filter(argv => argv.length > 0)
370
371// Risks ---------------------------------------------------------------------------------------
372
373const danger = (label: string): Risk => ({ level: 'danger', label })
374const spicy = (label: string): Risk => ({ level: 'spicy', label })
375
376const RM_RF = danger('rm -rf')
377const REMOVE_ITEM = danger('Remove-Item -Recurse -Force')
378const RMDIR = danger('rmdir /s /q')
379const FORCE_PUSH = danger('force push')
380const LEASE_PUSH = spicy('force-with-lease push')
381const RESET_HARD = danger('git reset --hard')
382const CLEAN = danger('git clean -f')
383const CHECKOUT_DOT = danger('git checkout -- .')
384const RESTORE_DOT = danger('git restore .')
385const BRANCH_D = spicy('git branch -D')
386const STASH_DROP = spicy('git stash drop')
387const DROP_TABLE = danger('DROP TABLE')
388const DELETE_ALL = danger('DELETE without WHERE')
389const CURL_SH = spicy('curl | sh')
390const CHMOD = spicy('chmod 777')
391const DISK = danger('disk write')
392const SUDO = spicy('sudo')
393const TERRAFORM = danger('terraform destroy')
394const KUBECTL = danger('kubectl delete')
395const DOCKER_PRUNE = spicy('docker prune')
396const PUBLISH = spicy('publish')
397
398// When a line carries several, the first danger in this order wins, else the first spicy one.
399const RANKED: readonly Risk[] = [
400  RM_RF, REMOVE_ITEM, RMDIR, FORCE_PUSH, LEASE_PUSH, RESET_HARD, CLEAN, CHECKOUT_DOT, RESTORE_DOT,
401  BRANCH_D, STASH_DROP, DROP_TABLE, DELETE_ALL, CURL_SH, CHMOD, DISK, SUDO, TERRAFORM, KUBECTL,
402  DOCKER_PRUNE, PUBLISH,
403]
404
405const SQL_CLIENTS = new Set([
406  'psql', 'mysql', 'mariadb', 'sqlite3', 'sqlcmd', 'duckdb', 'clickhouse-client', 'mongosh',
407  'pgcli', 'mycli', 'litecli', 'usql', 'invoke-sqlcmd', 'snowsql', 'cockroach',
408])
409// Programs whose arguments are text: SQL there is a search, a message or a note, unless the
410// text is piped into a database client.
411const TEXT_PROGRAMS = new Set([
412  'echo', 'printf', 'grep', 'egrep', 'fgrep', 'rg', 'ag', 'ack', 'git', 'gh', 'cat', 'tee', 'sed',
413  'awk', 'head', 'tail', 'less', 'more', 'find', 'ls', 'wc', 'sort', 'jq', 'man', 'write-output',
414  'write-host', 'select-string',
415])
416
417const RISKY = new Set([
418  'rm', 'remove-item', 'ri', 'del', 'erase', 'rd', 'rmdir', 'git', 'chmod', 'dd', 'terraform', 'tofu',
419  'kubectl', 'docker', 'npm', 'cargo', 'twine',
420])
421
422const isCluster = (arg: string, flag: string): boolean => /^-[a-zA-Z]+$/.test(arg) && arg.includes(flag)
423
424const rmIsForced = (args: readonly string[]): boolean => {
425  let recursive = false
426  let force = false
427  for (const arg of args) {
428    if (arg === '--') break
429    const flags = arg.toLowerCase()
430    if (flags === '--recursive' || (/^-[a-z]{3,}$/.test(flags) && 'recurse'.startsWith(flags.slice(1)))) {
431      recursive = true
432    } else if (flags === '--force' || (/^-[a-z]{3,}$/.test(flags) && 'force'.startsWith(flags.slice(1)))) {
433      force = true
434    } else if (/^-[^-]/.test(flags)) {
435      if (flags.includes('r')) recursive = true
436      if (flags.includes('f')) force = true
437    }
438  }
439  return recursive && force
440}
441
442/** A PowerShell switch, which may be shortened to any unambiguous prefix. */
443const psSwitch = (args: readonly string[], name: string, min: number): boolean =>
444  args.some(arg => {
445    const word = arg.toLowerCase().replace(/:.*$/, '')
446    return word.length > min && word.startsWith('-') && name.startsWith(word.slice(1))
447  })
448
449/** Where git's subcommand sits among its arguments, past options like `-C dir` and `-c key=value`. */
450const gitVerbAt = (args: readonly string[]): number => {
451  let i = 0
452  while ((args[i] ?? '').startsWith('-')) i += /^(?:-C|-c|--git-dir|--work-tree|--namespace)$/.test(args[i] ?? '') ? 2 : 1
453  return i
454}
455
456const gitRisks = (args: readonly string[], found: Set<Risk>): void => {
457  const i = gitVerbAt(args)
458  const rest = args.slice(i + 1)
459  const has = (long: string, short: string): boolean =>
460    rest.some(a => a === long || (short !== '' && isCluster(a, short)))
461  switch (args[i]) {
462    case 'push':
463      if (has('--force', 'f') || rest.some(a => a.length > 1 && a.startsWith('+'))) found.add(FORCE_PUSH)
464      else if (rest.some(a => a.startsWith('--force-with-lease') || a === '--force-if-includes')) found.add(LEASE_PUSH)
465      break
466    case 'reset':
467      if (rest.includes('--hard')) found.add(RESET_HARD)
468      break
469    case 'clean':
470      if (has('--force', 'f') && !has('--dry-run', 'n')) found.add(CLEAN)
471      break
472    case 'checkout':
473      if (rest.includes('.')) found.add(CHECKOUT_DOT)
474      break
475    case 'restore':
476      if (rest.includes('.') && (!has('--staged', 'S') || has('--worktree', 'W'))) found.add(RESTORE_DOT)
477      break
478    case 'branch':
479      if (has('', 'D')) found.add(BRANCH_D)
480      break
481    case 'stash':
482      if (rest[0] === 'drop' || rest[0] === 'clear') found.add(STASH_DROP)
483      break
484  }
485}
486
487const sqlRisks = (text: string, found: Set<Risk>): void => {
488  if (!/\b(?:DROP|TRUNCATE|DELETE)\s/i.test(text)) return
489  if (/\b(?:DROP\s+(?:TABLE|DATABASE|SCHEMA)|TRUNCATE\s+TABLE)\b/i.test(text)) found.add(DROP_TABLE)
490  for (const statement of text.split(';')) {
491    for (const part of statement.split(/(?=\bDELETE\s+FROM\s)/i)) {
492      if (/^DELETE\s+FROM\s+\S/i.test(part) && !/\bWHERE\b/i.test(part)) found.add(DELETE_ALL)
493    }
494  }
495}
496
497const commandRisks = (command: Command, fed: boolean, found: Set<Risk>): void => {
498  if (command.sudo) found.add(SUDO)
499  const { program } = command
500  const args = RISKY.has(program) ? command.argv.slice(1) : []
501  if (args.length > 0) switch (program) {
502    case 'rm':
503      if (rmIsForced(args)) found.add(RM_RF)
504      break
505    case 'remove-item':
506    case 'ri':
507    case 'del':
508    case 'erase':
509    case 'rd':
510    case 'rmdir':
511      if (psSwitch(args, 'recurse', 1) && psSwitch(args, 'force', 2)) found.add(REMOVE_ITEM)
512      else if ((program === 'rd' || program === 'rmdir') && args.some(a => /^\/s$/i.test(a)) && args.some(a => /^\/q$/i.test(a))) {
513        found.add(RMDIR)
514      }
515      break
516    case 'git':
517      gitRisks(args, found)
518      break
519    case 'chmod':
520      if (args.includes('777') || args.includes('0777')) found.add(CHMOD)
521      break
522    case 'dd':
523      if (args.some(a => /^of=\/dev\/(?!null$|zero$|std(?:out|err)$|fd\/)/.test(a))) found.add(DISK)
524      break
525    case 'terraform':
526    case 'tofu':
527      if (args.find(a => !a.startsWith('-')) === 'destroy') found.add(TERRAFORM)
528      break
529    case 'kubectl':
530      if (args.includes('delete')) found.add(KUBECTL)
531      break
532    case 'docker':
533      if (/^(?:system|volume|image)$/.test(args[0] ?? '') && args[1] === 'prune') found.add(DOCKER_PRUNE)
534      break
535    case 'npm':
536    case 'cargo':
537      if (args[0] === 'publish') found.add(PUBLISH)
538      break
539    case 'twine':
540      if (args[0] === 'upload') found.add(PUBLISH)
541      break
542  }
543  if (program.startsWith('mkfs') && /^mkfs(?:\.\w+)?$/.test(program)) found.add(DISK)
544  if (!command.script && (fed || !TEXT_PROGRAMS.has(program))) {
545    sqlRisks(command.argv.join(' '), found)
546    for (const body of command.bodies) sqlRisks(body, found)
547  }
548}
549
550/** The most serious risk a shell command carries, or null. */
551export const riskOf = (command: string): Risk | null => {
552  const found = new Set<Risk>()
553  for (const pipeline of parsed(command)) {
554    const fed = pipeline.some(c => SQL_CLIENTS.has(c.program))
555    let fetched = false
556    for (const c of pipeline) {
557      commandRisks(c, fed, found)
558      if (fetched && (c.program === 'sh' || c.program === 'bash' || c.program === 'zsh')) found.add(CURL_SH)
559      if (c.program === 'curl' || c.program === 'wget') fetched = true
560    }
561  }
562  return RANKED.find(r => r.level === 'danger' && found.has(r)) ?? RANKED.find(r => found.has(r)) ?? null
563}
564
565// Test runs -----------------------------------------------------------------------------------
566
567const RUNNERS = new Set(['pytest', 'py.test', 'jest', 'vitest', 'mocha', 'ava', 'rspec', 'phpunit', 'pest', 'ctest', 'tox', 'nox'])
568const CONTAINERS = new Set(['docker', 'podman', 'docker-compose', 'kubectl'])
569// Programs that may run tests through a subcommand or another program.
570const LAUNCHERS = new Set([
571  'npx', 'bunx', 'pnpx', 'npm', 'pnpm', 'yarn', 'bun', 'uv', 'poetry', 'pipenv', 'hatch', 'pdm', 'rye',
572  'bundle', 'deno', 'go', 'dotnet', 'mix', 'swift', 'playwright', 'cargo', 'cypress', 'zig', 'mvn',
573  'mvnw', 'gradle', 'gradlew', 'claude', ...CONTAINERS,
574])
575const NONE: ReadonlySet<string> = new Set()
576const NPX_VALUED = new Set(['-p', '--package'])
577const PM_VALUED = new Set(['--prefix', '-C', '--dir', '--filter', '-F', '-w', '--workspace', '--cwd'])
578const RUN_VALUED = new Set(['--with', '--python', '-p', '--extra', '--group', '--package', '--env-file', '--directory', '--project', '--from', '-e', '--env'])
579
580const withoutOptions = (args: readonly string[], valued: ReadonlySet<string> = NONE): string[] => {
581  let i = 0
582  while ((args[i] ?? '').startsWith('-')) {
583    const opt = args[i] ?? ''
584    i += valued.has(opt) ? 2 : 1
585    if (opt === '--') break
586  }
587  return args.slice(i)
588}
589
590/** Whether argv invokes a test runner (not installs, looks up or mentions one). */
591const runsTests = (argv: readonly string[], depth = 0, program = programOf(argv[0] ?? '')): boolean => {
592  if (RUNNERS.has(program)) return true
593  const isPython = program.startsWith('py') && /^(?:python[\d.]*|py)$/.test(program)
594  if (depth > 3 || !(isPython || LAUNCHERS.has(program))) return false
595  const args = argv.slice(1)
596  const [first = '', second = ''] = args
597  if (isPython) return args.some((a, i) => a === '-m' && /^(?:pytest|unittest)$/.test(args[i + 1] ?? ''))
598  switch (program) {
599    case 'npx':
600    case 'bunx':
601    case 'pnpx':
602      return runsTests(withoutOptions(args, NPX_VALUED), depth + 1)
603    case 'npm':
604    case 'pnpm':
605    case 'yarn':
606    case 'bun': {
607      const rest = withoutOptions(args, PM_VALUED)
608      const [verb = '', script = ''] = rest
609      if (/^test(?::\S+)?$/.test(verb)) return true
610      if (verb === 'run' || verb === 'run-script') return /^test(?::\S+)?$/.test(script)
611      if (verb === 'exec' || verb === 'dlx' || verb === 'x') return runsTests(withoutOptions(rest.slice(1), NPX_VALUED), depth + 1)
612      return RUNNERS.has(programOf(verb))
613    }
614    case 'uv':
615    case 'poetry':
616    case 'pipenv':
617    case 'hatch':
618    case 'pdm':
619    case 'rye':
620    case 'bundle':
621      return (first === 'run' || first === 'exec') && runsTests(withoutOptions(args.slice(1), RUN_VALUED), depth + 1)
622    case 'deno':
623    case 'go':
624    case 'dotnet':
625    case 'mix':
626    case 'swift':
627    case 'playwright':
628      return first === 'test'
629    case 'cargo':
630      return /^(?:test|nextest)$/.test(args.find(a => !a.startsWith('+')) ?? '')
631    case 'cypress':
632      return first === 'run'
633    case 'zig':
634      return first === 'build' && second === 'test'
635    case 'mvn':
636    case 'mvnw':
637    case 'gradle':
638    case 'gradlew':
639      return args.includes('test')
640    case 'claude':
641      return first === 'plugin' && second === 'test'
642  }
643  if (CONTAINERS.has(program)) {
644    const head = args.slice(0, 32)
645    return head.some((a, i) => !CONTAINERS.has(programOf(a)) && runsTests(head.slice(i), depth + 1))
646  }
647  return false
648}
649
650export const isTestCommand = (command: string): boolean =>
651  parsed(command).some(pipeline => pipeline.some(c => runsTests(c.argv, 0, c.program)))
652
653/** Whether the line runs `git commit`, not merely mentions it as `echo "git commit"` does. */
654export const runsGitCommit = (command: string): boolean =>
655  parsed(command).some(pipeline =>
656    pipeline.some(c => {
657      const args = c.argv.slice(1)
658      return c.program === 'git' && args[gitVerbAt(args)] === 'commit'
659    }),
660  )
661
662export type TestOutcome = { status: 'pass' | 'fail'; passed: number | null; failed: number | null }
663
664const num = (m: RegExpMatchArray | null, i = 1): number | null => (m ? Number(m[i]) : null)
665
666/** Reads a test run's output: counts where the runner prints them, else the exit status. */
667export const testOutcome = (output: string, isError: boolean): TestOutcome => {
668  const text = output.slice(-20_000)
669  // pytest: "3 failed, 41 passed in 1.2s", but not "1 failed to import"
670  const pyFailed = num(text.match(/(?<!\d)(\d+) failed(?=\s*(?:[,;|()=]|in\s|$))/m))
671  const pyPassed = num(text.match(/(?<!\d)(\d+) passed(?=\s*(?:[,;|()=]|in\s|$))/m))
672  // jest/vitest: "Tests:  1 failed, 12 passed" / "Tests  2 failed | 10 passed"
673  const jsFailed = num(text.match(/Tests?:?\s+(\d+) failed/))
674  const jsPassed = num(text.match(/Tests?:?\s+(?:\d+ failed[,|\s]+)?(\d+) passed/))
675  // cargo: "test result: FAILED. 10 passed; 2 failed"
676  const cargo = text.match(/test result: \w+\. (\d+) passed; (\d+) failed/)
677  // bun / claude plugin test: " 12 pass\n 0 fail"
678  const bunPass = num(text.match(/^\s*(\d+) pass\s*$/m))
679  const bunFail = num(text.match(/^\s*(\d+) fail\s*$/m))
680
681  const failed = cargo ? Number(cargo[2]) : (jsFailed ?? pyFailed ?? bunFail)
682  const passed = cargo ? Number(cargo[1]) : (jsPassed ?? pyPassed ?? bunPass)
683  const goFailed = /^(?:FAIL|--- FAIL)/m.test(text)
684
685  const hasFailed = isError || goFailed || (failed !== null && failed > 0)
686  return { status: hasFailed ? 'fail' : 'pass', passed, failed: hasFailed ? (failed ?? null) : 0 }
687}
688
689/** The little icon over the pixling's head while a tool runs. */
690export const iconFor = (tool: string, command = ''): Icon | null => {
691  switch (tool) {
692    case 'Read':
693    case 'Grep':
694    case 'Glob':
695    case 'NotebookRead':
696      return 'read'
697    case 'Edit':
698    case 'Write':
699    case 'MultiEdit':
700    case 'NotebookEdit':
701      return 'edit'
702    case 'Bash':
703    case 'PowerShell':
704      return isTestCommand(command) ? 'test' : 'bash'
705    case 'WebFetch':
706    case 'WebSearch':
707      return 'web'
708    case 'Agent':
709    case 'Task':
710      return 'agent'
711    default:
712      return tool.startsWith('mcp__') ? 'web' : null
713  }
714}
715
716export type LimitWindow = { kind: string; percentUsed: number; resetsAt?: string }
717
718/** The rate-limit window that is exhausted (or the fullest one), and when it opens again. */
719export const blockingWindow = (
720  windows: readonly LimitWindow[],
721  now: number,
722): { kind: string; resetsAt: number | null } | null => {
723  const sorted = [...windows].sort((a, b) => b.percentUsed - a.percentUsed)
724  const full = sorted.find(w => w.percentUsed >= 100) ?? null
725  if (!full) return null
726  const at = full.resetsAt ? Date.parse(full.resetsAt) : NaN
727  return { kind: full.kind, resetsAt: Number.isFinite(at) && at > now ? at : null }
728}
729
730export const formatDuration = (ms: number): string => {
731  const s = Math.max(0, Math.round(ms / 1000))
732  if (s < 60) return `${s}s`
733  const m = Math.round(s / 60)
734  if (m < 60) return `${m}m`
735  const h = Math.floor(m / 60)
736  const rest = m % 60
737  return rest ? `${h}h ${rest}m` : `${h}h`
738}
739
740export const clockTime = (at: number): string => {
741  const d = new Date(at)
742  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
743}
744
745/** Lines a file edit writes: a Write's whole content, an Edit's new text, a MultiEdit's edits. */
746export const linesWritten = (tool: string, input: Record<string, unknown>): number => {
747  const count = (text: unknown): number =>
748    typeof text === 'string' && text !== '' ? text.split('\n').length - (text.endsWith('\n') ? 1 : 0) : 0
749  if (tool === 'Write') return count(input['content'])
750  if (tool === 'Edit') return count(input['new_string'])
751  if (tool === 'MultiEdit' && Array.isArray(input['edits'])) {
752    return input['edits'].reduce((n: number, edit: unknown) => n + count((edit as Record<string, unknown> | null)?.['new_string']), 0)
753  }
754  return 0
755}
756
757/** File name of a tool's target, for lines like "reading auth.ts". */
758export const fileOf = (input: Record<string, unknown>): string | null => {
759  const path = input['file_path'] ?? input['path'] ?? input['notebook_path']
760  if (typeof path !== 'string' || path === '') return null
761  return path.split(/[\\/]/).pop() ?? null
762}
763
hooks/lib/lines.ts 303 lines
1// What the pixling says. Lines are short (they share a row with the sprite), templated with
2// {name}-style slots, and picked so the same line does not come back twice in a row.
3
4export type LineKey =
5  | 'hatch'
6  | 'hello'
7  | 'welcomeBack'
8  | 'done'
9  | 'doneLong'
10  | 'aborted'
11  | 'testPass'
12  | 'testFail'
13  | 'bugSquashed'
14  | 'commit'
15  | 'push'
16  | 'prCreated'
17  | 'prMerged'
18  | 'danger'
19  | 'spicy'
20  | 'permission'
21  | 'idle'
22  | 'error'
23  | 'overloaded'
24  | 'refusal'
25  | 'limit'
26  | 'limitUnknown'
27  | 'wake'
28  | 'tired'
29  | 'lateNight'
30  | 'levelUp'
31  | 'unlock'
32  | 'pet'
33  | 'rename'
34  | 'doze'
35  | 'absolutelyRight'
36  | 'greatQuestion'
37  | 'apology'
38  | 'perfect'
39  | 'badge'
40  | 'streak'
41  | 'recap'
42  | 'cacheCooling'
43  | 'cacheCold'
44  | 'cacheLearned'
45  | 'share'
46  // The buddy bridge and its commands
47  | 'buddyBack'
48  | 'backFromAway'
49
50  // Reactions: compaction, plan mode, the squad, model switches
51  | 'compacted'
52  | 'compactedPlain'
53  | 'contextSweat'
54  | 'planOn'
55  | 'planOff'
56  | 'squadUp'
57  | 'squadDone'
58  | 'squadDoneOne'
59  | 'modelSwitch'
60  | 'modelSwitchWarm'
61
62  // The room and the transcript
63
64const LINES: Record<LineKey, readonly string[]> = {
65  hatch: [
66    "Hi! I'm {name}. I live here now.",
67    "*blinks* ...Are you my developer?",
68    "{name}, reporting for duty!",
69    "Fresh out of the egg. What are we building?",
70  ],
71  hello: [
72    'Back at it!',
73    "Let's ship something.",
74    'Morning, code wrangler.',
75    'I stretched. I am ready.',
76    'New session, who dis?',
77  ],
78  welcomeBack: ['You were gone {dur}. I counted.', 'Oh! You came back!', 'I kept your seat warm for {dur}.'],
79  done: ['Done! Your turn.', 'Ball is in your court.', 'Finished. *bows*', 'All yours.', 'Ta-da!'],
80  doneLong: [
81    'Done after {dur}. I made tea.',
82    "That took {dur}. Claude's tired, I'm tired.",
83    '{dur} later... finished!',
84    'Wake up! Claude finished ({dur}).',
85  ],
86  aborted: ['Stopped. Probably for the best.', 'Okay okay, stopping.', 'Brakes engaged.'],
87  testPass: [
88    'All green! Framing this one.',
89    '{passed} passed. Not a single tear.',
90    'Tests pass. Ship it? Ship it.',
91    'Green across the board!',
92    'The tests have spoken: yes.',
93  ],
94  testFail: [
95    'Red. So much red.',
96    '{failed} failed. I believe in you. Mostly.',
97    'The tests said no.',
98    'Oof. {failed} down.',
99    'It was working on my machine...',
100    'Failure is just success in beta.',
101  ],
102  bugSquashed: [
103    'BUG SQUASHED! {failed} → 0!',
104    'From red to green! *happy dance*',
105    'Fixed it! (Claude did. I cheered.)',
106    'The bug has been defeated!',
107  ],
108  commit: [
109    'Committed! History will remember this.',
110    'Saved to the timeline. 📦',
111    'Another one for the log.',
112    'Commit made. No take-backs. (Well, some.)',
113  ],
114  push: ['Pushed to {branch}! It is out there now.', 'Up it goes! 🚀', 'Sent to the cloud. Bye, code!'],
115  prCreated: ['PR #{pr} is up! Fingers crossed.', 'A pull request! So official.', 'PR opened. Reviewers, assemble!'],
116  prMerged: ['PR #{pr} MERGED!', 'Merged! Pop the confetti!', 'It is in main now. Legendary.'],
117  danger: [
118    'WAIT. {label}?! Hold me.',
119    '{label}... are we sure?!',
120    'Eep! {label}! Checking twice?',
121    "I'm hiding under the desk. ({label})",
122  ],
123  spicy: ['Ooh, {label}. Spicy.', '{label}? Bold move.', 'Careful with that {label}...'],
124  permission: [
125    'Psst! Claude needs your OK.',
126    'Knock knock! Approval needed.',
127    'Your call, boss!',
128    'Claude is waiting on you!',
129  ],
130  idle: ['Still there?', '*taps glass*', 'Hellooo?', 'I can wait. I am very patient.'],
131  error: ['Something broke. Not me though.', 'Ow. An error.', 'Hmm. That did not go well.'],
132  overloaded: ['The servers are packed. Deep breaths.', 'Busy servers. Even the cloud needs a nap.'],
133  refusal: ['Claude said no to that one.', 'That one is off-limits, apparently.'],
134  limit: [
135    'Out of juice. Napping till {time}.',
136    'Limit hit. Wake me at {time}.',
137    'Rate limited! Back at {time}. Zzz.',
138  ],
139  limitUnknown: ["Limit hit. Napping... I'll check back in a while.", 'Out of juice! Little nap, then I check again.'],
140  // Said only when the reset is certain; whether Claude carries on is the notification's to say.
141  wake: ["I'm up! The limit has reset.", 'Limit reset! Back to work, everyone!', 'Rise and shine! The limit reset.'],
142  tired: ['{n}% of the {window} window used. Pace yourself.', 'We are at {n}%. Getting sleepy...'],
143  lateNight: ["It's {time}. Even bugs sleep.", '{time}? Bold.', 'Night shift again, huh.'],
144  levelUp: ['Level {level}! I feel... pixelier.', 'LEVEL {level}!', 'Ding! Level {level}.'],
145  unlock: ['Unlocked {item}! How do I look?', 'New look: {item}!', 'I got {item}! Fancy.'],
146  pet: ['♥', 'Hehe.', 'Again!', '*purrs?*', 'Best developer.', '*happy wiggle*'],
147  rename: ['{name}? I love it!', 'Call me {name}!', '{name}. Has a nice ring to it.'],
148  doze: ['*yawn*', 'Just resting my eyes...'],
149  absolutelyRight: [
150    "\"You're absolutely right.\" That's {n} now.",
151    'Absolutely right, #{n}. I keep a tally.',
152    '*eye roll* Absolutely right number {n}.',
153    "You're absolutely right ({n}). Are you though?",
154  ],
155  greatQuestion: ['Great question, apparently. ({n})', "Ah yes, a 'great question'. #{n}"],
156  apology: ['Apologies for the confusion, #{n}. Sure.', 'Another apology. {n} and counting.'],
157  perfect: ['"Perfect!" Is it, though? ({n})', 'Perfect! #{n}. Everything is perfect.'],
158  badge: ['Badge earned: {item}!', 'New badge: {item}. Shiny.', '{item}! Pinning it on.'],
159  streak: ['Day {n} in a row. 🔥', '{n}-day streak! Keep it going.', 'Streak: {n} days. We are unstoppable.'],
160  recap: ['{label}', 'Good to see you. {label}'],
161  cacheCooling: [
162    'Cache cools in {dur}. Reply soon, save tokens.',
163    'Psst: the prompt cache expires in {dur}.',
164    '{dur} until the cache goes cold!',
165  ],
166  cacheCold: [
167    'Cache went cold. Re-read {n} tokens. 🧊',
168    'Brrr. {n} tokens re-cached after {dur}.',
169    'Cold start: {n} tokens paid again.',
170  ],
171  cacheLearned: ['Noted: your cache lives {item}.', 'Your prompt cache lasts {item}. Countdown fixed.'],
172  share: ['Saved my card! Show me off.', 'Card printed. I look great.', 'Trading card ready. Post me!'],
173
174  // The buddy bridge and its commands
175  buddyBack: ['{name} is back!', '{name} is back! Did you miss me?', "{name} is back! Where were we?"],
176  backFromAway: ["I'm back! What did I miss?", 'Back on duty!', 'Missed me? I counted your commits.'],
177
178  // Reactions: compaction, plan mode, the squad, model switches
179  // {label} is the context before and after ("150k → 31k"), {n} what was freed ("119k").
180  compacted: ['Phew! {label} tokens. I can breathe again.', 'Squeezed out {n} tokens. So roomy!', '*pop* {label}. Much better.'],
181  compactedPlain: ['Phew. Squished, but lighter.', '*pop* All compacted. Roomy in here.'],
182  contextSweat: ['Context at {n}%. Getting snug in here.', '{n}% full. *sweats*', 'We are at {n}% context. A compaction is coming.'],
183  planOn: ["Thinking cap on. Let's plan first.", 'Plan mode! Look, think, no touching.', '*propeller whirs* Planning...'],
184  planOff: ['Cap off, sleeves up!', 'Thinking cap off. Time to build.', 'Out of plan mode. Go go go!'],
185  squadUp: ['Calling in a helper!', 'Go, little {label}!', 'Backup has arrived!'],
186  squadDone: ['The squad is back! {n} high fives.', 'All {n} helpers done. Teamwork!', '{n} helpers, {n} high fives.'],
187  squadDoneOne: ['High five, little helper!', 'Nice work, {label}!', 'Helper is back. Good job!'],
188  modelSwitch: ['New brain: {label}.', 'Switched to {label}. Hi there!', 'Now running on {label}.'],
189  modelSwitchWarm: ['Switched to {label}. Re-caching ~{item}.', '{label} now. That cache costs ~{item} to write again.'],
190
191  // The room and the transcript
192}
193
194/** A species' own lines, mixed in with the shared ones. */
195export const SPECIES_LINES: Partial<Record<string, Partial<Record<LineKey, readonly string[]>>>> = {
196  duck: {
197    testFail: ['Explain it to me, line by line. Quack.', 'Have you tried explaining it to a duck?'],
198    hello: ['Quack. Ready to listen.'],
199    pet: ['Quack!', '*squeak*'],
200  },
201  cat: {
202    testFail: ['*knocks failing test off the table*', 'I am judging this code. Silently.'],
203    pet: ['*purr*', 'Mrrp.', '*slow blink*'],
204    idle: ['*sits on keyboard*'],
205  },
206  robot: {
207    testPass: ['ASSERTIONS: SATISFIED. BEEP.'],
208    testFail: ['ERROR: SADNESS.EXE'],
209    pet: ['AFFECTION RECEIVED. BOOP.'],
210  },
211  cactus: {
212    testPass: ['I bloomed a little!'],
213    pet: ['Ouch. Worth it.', 'Gently! I am pointy.'],
214  },
215  owl: {
216    lateNight: ['{time}. My favorite hour. Hoo.'],
217    testFail: ['Hoo broke the build?'],
218  },
219  axolotl: {
220    testFail: ['It will grow back. Everything does.'],
221    pet: ['*smiles wider*'],
222  },
223  ghost: {
224    danger: ['{label}?! I am already dead and that scared me.'],
225    pet: ['Your hand went right through me. Sweet.'],
226    hello: ['Boo! Did I scare you?'],
227  },
228  dragon: {
229    commit: ['Another treasure for the hoard.'],
230    danger: ['{label}? Not on MY branch.'],
231    pet: ['*smoke puff*'],
232  },
233  capybara: {
234    hello: ['Hello. No rush.'],
235    danger: ['{label}? Hm. Okay.', '*keeps soaking* ({label})'],
236    testFail: ['Red tests. The water is still warm, though.'],
237    error: ['An error. Anyway.'],
238    pet: ['*content squeak*', '*leans into it*'],
239  },
240  turtle: {
241    hello: ['Ah, you return. Let us begin. Slowly.'],
242    testFail: ['Patience. Read the stack trace twice.'],
243    push: ['Off it goes. I remember when we used FTP.'],
244    lateNight: ['{time}. In my day, we slept.'],
245    pet: ['*retreats into shell, pleased*'],
246  },
247  snail: {
248    testPass: ['Slow and steady. All green.'],
249    done: ['Done. I was never in a hurry.'],
250    cacheCooling: ['Slow is fine. Cold is not. Cache cools in {dur}.'],
251    cacheCold: ['The cache went cold. I would have kept it warm.'],
252    pet: ['*antennae wiggle*', '*leaves a little trail*'],
253  },
254  penguin: {
255    hello: ['Booted. Kernel nominal.'],
256    testPass: ['All green! Belly slide!'],
257    commit: ['Committed. Linus would approve. Probably.'],
258    danger: ['{label}? That is how home directories vanish.'],
259    pet: ['*happy flipper flap*'],
260  },
261  goose: {
262    hello: ['HONK. I am here to cause problems.'],
263    testFail: ['HONK. Who wrote this?', 'HONK HONK. Bad code detected.'],
264    danger: ['{label}? Finally, some chaos. HONK.'],
265    absolutelyRight: ['"Absolutely right" #{n}? HONK.'],
266    pet: ['*suspicious honk*', '*hisses, affectionately*'],
267  },
268  rabbit: {
269    hello: ['Hi hi hi! What are we doing? Go go go!'],
270    done: ['Done! Next! Next! Next!'],
271    testPass: ['Green! *does a binky*'],
272    idle: ['*thump thump* Hurry up!'],
273    pet: ['*nose twitch*', '*happy binky*'],
274  },
275}
276
277export type Slots = Readonly<Record<string, string | number | null | undefined>>
278
279const recent: string[] = []
280
281const fill = (template: string, slots: Slots): string =>
282  template.replace(/\{(\w+)\}/g, (whole, key: string) => {
283    const value = slots[key]
284    return value === undefined || value === null ? whole : String(value)
285  })
286
287/** Picks a line for an event, the species' own included, skipping recent repeats. */
288export const say = (key: LineKey, slots: Slots, speciesId: string, random: () => number): string => {
289  const pool = [...LINES[key], ...(SPECIES_LINES[speciesId]?.[key] ?? [])]
290  // A template whose slot has no value would print the braces: drop it while others remain.
291  const usable = pool.filter(t => [...t.matchAll(/\{(\w+)\}/g)].every(m => slots[m[1] ?? ''] !== undefined && slots[m[1] ?? ''] !== null))
292  const choices = usable.length > 0 ? usable : pool
293  const fresh = choices.filter(t => !recent.includes(t))
294  const from = fresh.length > 0 ? fresh : choices
295  const template = from[Math.floor(random() * from.length)] ?? choices[0] ?? ''
296  recent.push(template)
297  if (recent.length > 6) recent.shift()
298  return fill(template, slots)
299}
300
301export const allLines = (): { key: LineKey; text: string }[] =>
302  (Object.keys(LINES) as LineKey[]).flatMap(key => LINES[key].map(text => ({ key, text })))
303
hooks/lib/platform.ts 179 lines
1// Sound and desktop notifications on every OS. Claude Code's own `$.audio.play` plays only on
2// macOS (claude-code.d.ts: "a Linux or Windows terminal, having no player, plays nothing"), so
3// Windows and Linux get a player of their own here. These builders are pure: they return the
4// argv a host process runs.
5
6export type Platform = 'mac' | 'windows' | 'wsl' | 'linux' | 'unknown'
7
8export type Player = { kind: 'engine' } | { kind: 'argv'; argv: (path: string) => string[] } | { kind: 'none' }
9
10const psQuote = (s: string): string => `'${s.replace(/'/g, "''")}'`
11
12export const POWERSHELL = 'powershell.exe'
13
14// No `-ExecutionPolicy Bypass`: the policy governs script files, never `-Command`, and EDR tools flag it.
15export const PS_FLAGS = ['-NoProfile', '-NonInteractive', '-Command'] as const
16
17export const windowsPlayer = (path: string): string[] => [
18  POWERSHELL,
19  ...PS_FLAGS,
20  `(New-Object System.Media.SoundPlayer ${psQuote(path)}).PlaySync()`,
21]
22
23/** The first Linux player found by `command -v`, as an argv builder. */
24export const linuxPlayer = (found: string): Player => {
25  const bin = found.trim().split('\n')[0]?.trim() ?? ''
26  if (bin.endsWith('paplay') || bin.endsWith('pw-play')) return { kind: 'argv', argv: path => [bin, path] }
27  if (bin.endsWith('aplay')) return { kind: 'argv', argv: path => [bin, '-q', path] }
28  if (bin.endsWith('afplay')) return { kind: 'argv', argv: path => [bin, path] }
29  return { kind: 'none' }
30}
31
32const xml = (s: string): string =>
33  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
34
35// PowerShell's own AppUserModelID: toasts under it show without registering an app.
36const PS_APP_ID = '{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe'
37
38export const windowsToast = (title: string, body: string): string[] => {
39  const toast = `<toast><visual><binding template="ToastGeneric"><text>${xml(title)}</text><text>${xml(body)}</text></binding></visual><audio silent="true"/></toast>`
40  const script = [
41    '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null',
42    '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null',
43    '$x = New-Object Windows.Data.Xml.Dom.XmlDocument',
44    `$x.LoadXml(${psQuote(toast)})`,
45    `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(${psQuote(PS_APP_ID)}).Show([Windows.UI.Notifications.ToastNotification]::new($x))`,
46  ].join('; ')
47  return [POWERSHELL, ...PS_FLAGS, script]
48}
49
50const appleQuote = (s: string): string => `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
51
52export const macNotification = (title: string, body: string): string[] => [
53  'osascript',
54  '-e',
55  `display notification ${appleQuote(body)} with title ${appleQuote(title)}`,
56]
57
58export const linuxNotification = (title: string, body: string): string[] => ['notify-send', '--app-name=Pixlings', title, body]
59
60export const notificationArgv = (platform: Platform, title: string, body: string): string[] | null => {
61  switch (platform) {
62    case 'mac':
63      return macNotification(title, body)
64    case 'windows':
65    case 'wsl':
66      return windowsToast(title, body)
67    case 'linux':
68      return linuxNotification(title, body)
69    case 'unknown':
70      return null
71  }
72}
73
74/** Reads `uname -sr` output into a platform. */
75export const platformOfUname = (uname: string): Platform => {
76  if (/darwin/i.test(uname)) return 'mac'
77  if (/microsoft|wsl/i.test(uname)) return 'wsl'
78  if (/linux|bsd/i.test(uname)) return 'linux'
79  if (/mingw|msys|cygwin/i.test(uname)) return 'windows'
80  return 'unknown'
81}
82
83/** Joins a plugin root and a relative asset path with the platform's separator. */
84export const assetPath = (root: string, relative: string, platform: Platform): string => {
85  const sep = platform === 'windows' || /^[A-Za-z]:\\/.test(root) ? '\\' : '/'
86  return root.replace(/[\\/]+$/, '') + sep + relative.split('/').join(sep)
87}
88
89// Files and speech ------------------------------------------------------------------------------
90
91/**
92 * A process that writes its standard input, base64, to the file named by the `PIXLING_OUT`
93 * environment variable: the engine's `$.fs.write` writes text, and a PNG is bytes.
94 */
95export const writeBytesArgv = (platform: Platform): string[] =>
96  platform === 'windows'
97    ? [
98        POWERSHELL,
99        ...PS_FLAGS,
100        '[IO.File]::WriteAllBytes($env:PIXLING_OUT, [Convert]::FromBase64String([Console]::In.ReadToEnd()))',
101      ]
102    : ['sh', '-c', 'base64 --decode > "$PIXLING_OUT"']
103
104/** Opens a file in the desktop's own viewer; null where there is none to reach. */
105export const openArgv = (platform: Platform, path: string): string[] | null => {
106  switch (platform) {
107    case 'windows':
108      return ['explorer.exe', path]
109    case 'mac':
110      return ['open', path]
111    case 'linux':
112      return ['xdg-open', path]
113    case 'wsl':
114    case 'unknown':
115      return null
116  }
117}
118
119/**
120 * Speaks its standard input aloud in an English voice, through Windows' own synthesizer. Windows
121 * PowerShell reads standard input in the OEM code page; the text arrives as UTF-8.
122 */
123export const windowsSpeech = (): string[] => [
124  POWERSHELL,
125  ...PS_FLAGS,
126  [
127    'try { [Console]::InputEncoding = [Text.Encoding]::UTF8 } catch {}',
128    'Add-Type -AssemblyName System.Speech',
129    '$s = New-Object System.Speech.Synthesis.SpeechSynthesizer',
130    "$v = $s.GetInstalledVoices() | Where-Object { $_.VoiceInfo.Culture.Name -like 'en*' } | Select-Object -First 1",
131    'if ($v) { $s.SelectVoice($v.VoiceInfo.Name) }',
132    '$s.Rate = 1',
133    '$s.Speak([Console]::In.ReadToEnd())',
134  ].join('; '),
135]
136
137/** The first Linux speaker found by `command -v`, as an argv builder taking the text. */
138export const linuxSpeaker = (found: string): ((text: string) => string[]) | null => {
139  const bin = found.trim().split('\n')[0]?.trim() ?? ''
140  // `--` ends the options: a line that starts with "-" (a quip can) is said, never obeyed.
141  if (bin.endsWith('spd-say')) return text => [bin, '--wait', '--', text]
142  if (bin.endsWith('espeak-ng') || bin.endsWith('espeak')) return text => [bin, '--', text]
143  return null
144}
145
146/** A line made fit to say: emoji, kaomoji and markup dropped, whitespace folded. */
147export const speakable = (text: string): string =>
148  text
149    .replace(/\p{Extended_Pictographic}|\p{Emoji_Modifier}|‍|️/gu, '')
150    .replace(/[*_`~<>[\]{}|\^]/g, '')
151    .replace(/\s+/g, ' ')
152    .trim()
153
154/**
155 * A process that plays a WAV given on standard input as base64: the pixling's voice is made
156 * fresh for every line, so there is no file of the mod's to play.
157 */
158export const wavPipeArgv = (platform: Platform, linuxPlayer = ''): string[] | null => {
159  switch (platform) {
160    case 'windows':
161    case 'wsl':
162      return [
163        POWERSHELL,
164        ...PS_FLAGS,
165        '$b = [Convert]::FromBase64String([Console]::In.ReadToEnd()); (New-Object System.Media.SoundPlayer (New-Object System.IO.MemoryStream (,$b))).PlaySync()',
166      ]
167    case 'mac':
168      return ['sh', '-c', 'd=$(mktemp -d) && base64 --decode > "$d/v.wav" && afplay "$d/v.wav"; rm -rf "$d"']
169    case 'linux': {
170      const bin = linuxPlayer.trim().split('\n')[0]?.trim() ?? ''
171      if (!bin) return null
172      const play = bin.endsWith('aplay') ? `"${bin}" -q` : `"${bin}"`
173      return ['sh', '-c', `d=$(mktemp -d) && base64 --decode > "$d/v.wav" && ${play} "$d/v.wav"; rm -rf "$d"`]
174    }
175    case 'unknown':
176      return null
177  }
178}
179
hooks/lib/png.ts 198 lines
1// A PNG encoder small enough to live in the mod: indexed color, one fixed-Huffman deflate block.
2// Pixel art compresses well with the two matches it uses: a run of the byte before (distance 1),
3// and a scaled row equal to the one above it, sent as PNG's "Up" filter, which turns it to zeros.
4
5import { TRANSPARENT } from './canvas.ts'
6import type { Pixels } from './canvas.ts'
7
8const CRC_TABLE = (() => {
9  const table = new Uint32Array(256)
10  for (let n = 0; n < 256; n++) {
11    let c = n
12    for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
13    table[n] = c >>> 0
14  }
15  return table
16})()
17
18export const crc32 = (bytes: Uint8Array, start = 0, end = bytes.length): number => {
19  let c = 0xffffffff
20  for (let i = start; i < end; i++) c = (CRC_TABLE[(c ^ (bytes[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8)
21  return (c ^ 0xffffffff) >>> 0
22}
23
24export const adler32 = (bytes: Uint8Array): number => {
25  let a = 1
26  let b = 0
27  for (let i = 0; i < bytes.length; i++) {
28    a = (a + (bytes[i] ?? 0)) % 65521
29    b = (b + a) % 65521
30  }
31  return ((b << 16) | a) >>> 0
32}
33
34class Bits {
35  private bytes: Uint8Array
36  private length = 0
37  private acc = 0
38  private count = 0
39
40  constructor(capacity: number) {
41    this.bytes = new Uint8Array(capacity)
42  }
43
44  /** Writes `n` bits of `value`, least significant first, as deflate packs its fields. */
45  put(value: number, n: number): void {
46    for (let i = 0; i < n; i++) {
47      this.acc |= ((value >>> i) & 1) << this.count
48      if (++this.count === 8) this.flushByte()
49    }
50  }
51
52  /** Writes a Huffman code, most significant bit first. */
53  code(value: number, n: number): void {
54    for (let i = n - 1; i >= 0; i--) this.put((value >>> i) & 1, 1)
55  }
56
57  private flushByte(): void {
58    if (this.length === this.bytes.length) {
59      const grown = new Uint8Array(this.bytes.length * 2)
60      grown.set(this.bytes)
61      this.bytes = grown
62    }
63    this.bytes[this.length++] = this.acc
64    this.acc = 0
65    this.count = 0
66  }
67
68  finish(): Uint8Array {
69    if (this.count > 0) this.flushByte()
70    return this.bytes.subarray(0, this.length)
71  }
72}
73
74const LENGTH_BASE = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258]
75const LENGTH_EXTRA = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0]
76
77const symbol = (bits: Bits, sym: number): void => {
78  if (sym < 144) bits.code(0x30 + sym, 8)
79  else if (sym < 256) bits.code(0x190 + sym - 144, 9)
80  else if (sym < 280) bits.code(sym - 256, 7)
81  else bits.code(0xc0 + sym - 280, 8)
82}
83
84const match = (bits: Bits, length: number): void => {
85  let i = LENGTH_BASE.length - 1
86  while ((LENGTH_BASE[i] ?? 0) > length) i--
87  symbol(bits, 257 + i)
88  bits.put(length - (LENGTH_BASE[i] ?? 0), LENGTH_EXTRA[i] ?? 0)
89  bits.code(0, 5) // distance code 0: distance 1
90}
91
92/** zlib-wrapped deflate of `data` in one fixed-Huffman block. */
93export const deflate = (data: Uint8Array): Uint8Array => {
94  const bits = new Bits(Math.ceil(data.length / 4) + 64)
95  bits.put(0x78, 8)
96  bits.put(0x01, 8)
97  bits.put(1, 1) // the last block
98  bits.put(1, 2) // fixed Huffman codes
99  let i = 0
100  while (i < data.length) {
101    const byte = data[i] ?? 0
102    if (i > 0 && byte === data[i - 1]) {
103      let run = 1
104      while (run < 258 && i + run < data.length && data[i + run] === byte) run++
105      if (run >= 3) {
106        match(bits, run)
107        i += run
108        continue
109      }
110    }
111    symbol(bits, byte)
112    i++
113  }
114  symbol(bits, 256)
115  const body = bits.finish()
116  const out = new Uint8Array(body.length + 4)
117  out.set(body)
118  new DataView(out.buffer).setUint32(body.length, adler32(data))
119  return out
120}
121
122const chunk = (type: string, data: Uint8Array): Uint8Array => {
123  const out = new Uint8Array(12 + data.length)
124  const view = new DataView(out.buffer)
125  view.setUint32(0, data.length)
126  for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
127  out.set(data, 8)
128  view.setUint32(8 + data.length, crc32(out, 4, 8 + data.length))
129  return out
130}
131
132const concat = (parts: readonly Uint8Array[]): Uint8Array => {
133  const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
134  let at = 0
135  for (const p of parts) {
136    out.set(p, at)
137    at += p.length
138  }
139  return out
140}
141
142/**
143 * The pixels as a PNG, each scaled to a `scale`×`scale` block; transparent pixels take
144 * `background`. Throws past 256 colors, which a card never reaches.
145 */
146export const encodePng = (p: Pixels, scale: number, background: number): Uint8Array => {
147  const palette: number[] = []
148  const index = new Map<number, number>()
149  const indexOf = (color: number): number => {
150    const c = color === TRANSPARENT ? background : color
151    let i = index.get(c)
152    if (i === undefined) {
153      if (palette.length === 256) throw new Error('more than 256 colors')
154      i = palette.length
155      palette.push(c)
156      index.set(c, i)
157    }
158    return i
159  }
160
161  const w = p.w * scale
162  const h = p.h * scale
163  const stride = w + 1
164  const raw = new Uint8Array(stride * h)
165  for (let y = 0; y < h; y++) {
166    const row = y * stride
167    if (y % scale !== 0) {
168      raw[row] = 2 // Up: the same as the row above, all zeros
169      continue
170    }
171    const sy = y / scale
172    for (let x = 0; x < p.w; x++) {
173      const i = indexOf(p.px[sy * p.w + x] ?? TRANSPARENT)
174      raw.fill(i, row + 1 + x * scale, row + 1 + (x + 1) * scale)
175    }
176  }
177
178  const header = new Uint8Array(13)
179  const hv = new DataView(header.buffer)
180  hv.setUint32(0, w)
181  hv.setUint32(4, h)
182  header[8] = 8 // bit depth
183  header[9] = 3 // indexed color
184  const plte = new Uint8Array(palette.length * 3)
185  palette.forEach((c, i) => {
186    plte[i * 3] = (c >> 16) & 255
187    plte[i * 3 + 1] = (c >> 8) & 255
188    plte[i * 3 + 2] = c & 255
189  })
190  return concat([
191    new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
192    chunk('IHDR', header),
193    chunk('PLTE', plte),
194    chunk('IDAT', deflate(raw)),
195    chunk('IEND', new Uint8Array(0)),
196  ])
197}
198
hooks/lib/quips.ts 118 lines
1// Opt-in quips: now and then the pixling asks Claude Haiku for one line in its own voice. This
2// module holds the request and the policy; the call itself is the engine's `$.model.complete`.
3// What the model is told is the pixling's name, species and persona and a short summary of what
4// happened ("3 tests failed"): never code, never the person's prompt, never a file's contents.
5
6import { oneLine } from './persona.ts'
7
8export type QuipKind = 'testFail' | 'named' | 'error' | 'bigDiff'
9
10/** Counts only: a summary is built from numbers, never from text the session saw. */
11export type QuipFacts = { failed?: number | null; lines?: number; files?: number }
12
13export const QUIP_GAP_MS = 60_000
14export const QUIP_MAX_TOKENS = 120
15export const QUIP_TIMEOUT_MS = 8_000
16export const QUIP_MAX_CHARS = 60
17
18const SYSTEM = [
19  'You voice a tiny pixel creature that lives above a developer\'s terminal prompt.',
20  `Answer with exactly one line of ${QUIP_MAX_CHARS} characters or fewer, in the creature's personality.`,
21  'No quotation marks, no emoji, no hashtags, no preamble: only the line itself.',
22].join(' ')
23
24const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
25
26const count = (n: number | null | undefined): number | null =>
27  typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.round(n) : null
28
29/** What happened, in a few words, from counts alone. */
30export const quipEvent = (kind: QuipKind, facts: QuipFacts = {}): string => {
31  switch (kind) {
32    case 'testFail': {
33      const failed = count(facts.failed)
34      return failed ? `${plural(failed, 'test')} just failed` : 'the tests just failed'
35    }
36    case 'named':
37      return 'the developer just said your name'
38    case 'error':
39      return 'Claude just ran into an error'
40    case 'bigDiff': {
41      const lines = count(facts.lines)
42      const files = count(facts.files)
43      return lines
44        ? `Claude just changed ${plural(lines, 'line')}${files ? ` across ${plural(files, 'file')}` : ''}`
45        : 'Claude just made a big change'
46    }
47  }
48}
49
50export type QuipPolicy = {
51  mode: string
52  isQuiet: boolean
53  isAway: boolean
54  /** A quip is already on its way. */
55  isBusy: boolean
56  lastAt: number | null
57  now: number
58}
59
60/** Haiku is asked only when quips are on, someone is watching, and the last ask is a minute old. */
61export const mayQuip = (p: QuipPolicy): boolean =>
62  p.mode === 'haiku' && !p.isQuiet && !p.isAway && !p.isBusy && (p.lastAt === null || p.now - p.lastAt >= QUIP_GAP_MS)
63
64export type Who = { name: string; species: string; persona: string }
65
66export type QuipRequest = {
67  model: string
68  system: string
69  prompt: string
70  maxTokens: number
71  timeoutMs: number
72}
73
74export const quipRequest = (who: Who, kind: QuipKind, facts: QuipFacts = {}): QuipRequest => ({
75  model: 'haiku',
76  system: SYSTEM,
77  prompt: [
78    `You are ${oneLine(who.name, 24)}, a pixel ${oneLine(who.species, 24)}.`,
79    `Your personality: ${oneLine(who.persona, 160)}`,
80    `What just happened: ${quipEvent(kind, facts)}.`,
81    'Your one-line reaction:',
82  ].join('\n'),
83  maxTokens: QUIP_MAX_TOKENS,
84  timeoutMs: QUIP_TIMEOUT_MS,
85})
86
87type Usage = {
88  input_tokens: number
89  output_tokens: number
90  cache_read_input_tokens: number
91  cache_creation_input_tokens: number
92}
93
94/** The shape of `$.model.complete`'s result this module reads. */
95export type QuipReply = { isAnswered: true; text: string; usage: Usage } | { isAnswered: false; usage?: Usage }
96
97/** Every token the call was billed for, input and output alike. */
98export const tokensOf = (usage: Usage | undefined): number =>
99  usage
100    ? usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
101    : 0
102
103/**
104 * The line to show and what it cost, or null when there is nothing fit to say: no answer, an
105 * empty one. Wrapping quotes go, the first line stays, and a long line is cut at a word.
106 */
107export const quipOf = (reply: QuipReply): { text: string; tokens: number } | null => {
108  if (!reply.isAnswered) return null
109  const first = reply.text.split(/\r?\n/).find(l => l.trim() !== '') ?? ''
110  let line = oneLine(first, 400).replace(/^["'`“”‘’*_\s]+|["'`“”‘’*_\s]+$/g, '')
111  if (line.length > QUIP_MAX_CHARS) {
112    const cut = line.slice(0, QUIP_MAX_CHARS - 1)
113    const space = cut.lastIndexOf(' ')
114    line = `${(space > QUIP_MAX_CHARS / 2 ? cut.slice(0, space) : cut).trimEnd()}…`
115  }
116  return line ? { text: line, tokens: tokensOf(reply.usage) } : null
117}
118
hooks/lib/progress.ts 357 lines
1// The pixling's life: hatching rolls, names, experience, levels and what each level unlocks.
2
3import type { Face, Hat } from './canvas.ts'
4import { oneLine, personaFor } from './persona.ts'
5import { RARITY_ORDER, RARITY_WEIGHT, SPECIES } from './sprites.ts'
6import type { Rarity, Species } from './sprites.ts'
7import type { TicCounts } from './tics.ts'
8
9export type Stats = {
10  turns: number
11  testsPassed: number
12  testsFailed: number
13  bugsSquashed: number
14  commits: number
15  pushes: number
16  prs: number
17  risky: number
18  sessions: number
19  pets: number
20  naps: number
21  permissions: number
22  /** Turns between midnight and five. */
23  nights: number
24  /** Times the prompt cache went cold under a pause. */
25  coldStarts: number
26  /** Opt-in Haiku quips said, and every token they were billed for. */
27  quips: number
28  quipTokens: number
29}
30
31/** Consecutive days with at least one turn, by local date. */
32export type Streak = { last: string | null; days: number; best: number }
33
34/** One local day's work, for the morning recap. */
35export type Day = { date: string; turns: number; commits: number; squashed: number; tests: number; xp: number }
36
37/** The old /buddy companion this pixling was adopted from: its days together count from then. */
38export type AdoptedFrom = { name: string; hatchedAt: number | null }
39
40export type Pixling = {
41  v: 1
42  species: string
43  isShiny: boolean
44  name: string
45  hatchedAt: number
46  xp: number
47  /** The hat and face gear chosen with `/pixling wear`; null picks the best unlocked. */
48  hat: Hat | 'none' | null
49  face: Face | 'none' | null
50  stats: Stats
51  /** Every species this person has hatched, for the dex. */
52  dex: string[]
53  /** Badge id → when it was earned. */
54  badges: Record<string, number>
55  streak: Streak
56  day: Day
57  /** The model's verbal tics the pixling has heard, by kind. */
58  tics: TicCounts
59  /** Its one-line personality: a template's, or an adopted Buddy's own. */
60  persona: string
61  adoptedFrom: AdoptedFrom | null
62}
63
64export const emptyStats = (): Stats => ({
65  turns: 0,
66  testsPassed: 0,
67  testsFailed: 0,
68  bugsSquashed: 0,
69  commits: 0,
70  pushes: 0,
71  prs: 0,
72  risky: 0,
73  sessions: 0,
74  pets: 0,
75  naps: 0,
76  permissions: 0,
77  nights: 0,
78  coldStarts: 0,
79  quips: 0,
80  quipTokens: 0,
81})
82
83export const emptyDay = (date: string): Day => ({ date, turns: 0, commits: 0, squashed: 0, tests: 0, xp: 0 })
84
85export const SHINY_ODDS = 64
86
87const NAMES = [
88  'Mochi', 'Pixel', 'Byte', 'Nibble', 'Biscuit', 'Pip', 'Sprocket', 'Tofu', 'Noodle', 'Waffles',
89  'Bean', 'Kiwi', 'Miso', 'Pickle', 'Gizmo', 'Dumpling', 'Boba', 'Chip', 'Cookie', 'Fig',
90  'Juniper', 'Lumen', 'Nova', 'Orbit', 'Pebble', 'Quill', 'Rune', 'Tango', 'Echo', 'Ziggy',
91  'Null', 'Sudo', 'Lambda', 'Kernel', 'Cache', 'Async', 'Regex', 'Grep', 'Bit', 'Loop',
92  'Hex', 'Tux', 'Ping', 'Pong', 'Turbo', 'Wasabi', 'Yuzu', 'Mango', 'Olive', 'Clover',
93] as const
94
95/** Picks a rarity by weight, then a species of it, then the 1-in-64 shiny roll. */
96export const roll = (random: () => number): { species: Species; isShiny: boolean; name: string } => {
97  const total = RARITY_ORDER.reduce((sum, r) => sum + RARITY_WEIGHT[r], 0)
98  let x = random() * total
99  let rarity: Rarity = 'common'
100  for (const r of RARITY_ORDER) {
101    x -= RARITY_WEIGHT[r]
102    if (x < 0) {
103      rarity = r
104      break
105    }
106  }
107  const pool = SPECIES.filter(s => s.rarity === rarity)
108  const species = pool[Math.floor(random() * pool.length)] ?? SPECIES[0]!
109  const isShiny = random() < 1 / SHINY_ODDS
110  const name = NAMES[Math.floor(random() * NAMES.length)] ?? 'Pip'
111  return { species, isShiny, name }
112}
113
114export const hatchPixling = (random: () => number, now: number, dex: readonly string[] = []): Pixling => {
115  const { species, isShiny, name } = roll(random)
116  return {
117    v: 1,
118    species: species.id,
119    isShiny,
120    name,
121    hatchedAt: now,
122    xp: 0,
123    hat: null,
124    face: null,
125    stats: emptyStats(),
126    dex: [...new Set([...dex, species.id])],
127    badges: {},
128    streak: { last: null, days: 0, best: 0 },
129    day: emptyDay(''),
130    tics: {},
131    persona: personaFor(species.id, now),
132    adoptedFrom: null,
133  }
134}
135
136/** A new egg for the same person: the badges, streak, tics and dex are theirs and stay. */
137export const rehatch = (p: Pixling, random: () => number, now: number): Pixling => ({
138  ...hatchPixling(random, now, p.dex),
139  badges: p.badges,
140  streak: p.streak,
141  tics: p.tics,
142})
143
144// Experience ------------------------------------------------------------------------------------
145
146export const XP = {
147  turn: 4,
148  testPass: 8,
149  bugSquashed: 25,
150  commit: 12,
151  push: 15,
152  prCreated: 30,
153  prMerged: 50,
154  pet: 1,
155  session: 5,
156  nap: 10,
157} as const
158
159export type XpEvent = keyof typeof XP
160
161/** Total experience needed to reach `level` (level 1 needs none). */
162export const xpForLevel = (level: number): number =>
163  level <= 1 ? 0 : Math.round(30 * Math.pow(level - 1, 1.8))
164
165export const MAX_LEVEL = 99
166
167export const levelOf = (xp: number): { level: number; into: number; need: number } => {
168  let level = 1
169  while (level < MAX_LEVEL && xp >= xpForLevel(level + 1)) level++
170  const floor = xpForLevel(level)
171  const next = xpForLevel(level + 1)
172  return { level, into: xp - floor, need: next - floor }
173}
174
175// Unlocks --------------------------------------------------------------------------------------
176
177export type Unlock = { level: number; hat?: Hat; face?: Face; label: string }
178
179export const UNLOCKS: readonly Unlock[] = [
180  { level: 2, hat: 'sprout', label: 'a sprout' },
181  { level: 4, hat: 'shroom', label: 'a mushroom cap' },
182  { level: 6, face: 'shades', label: 'sunglasses' },
183  { level: 9, hat: 'party', label: 'a party hat' },
184  { level: 12, hat: 'crown', label: 'a crown' },
185  { level: 18, hat: 'halo', label: 'a halo' },
186  { level: 25, hat: 'wizard', label: 'a wizard hat' },
187]
188
189export const unlockedAt = (level: number): Unlock[] => UNLOCKS.filter(u => u.level <= level)
190
191export const gearOf = (p: Pixling): { hat: Hat | null; face: Face | null } => {
192  const { level } = levelOf(p.xp)
193  const open = unlockedAt(level)
194  const hats = open.flatMap(u => (u.hat ? [u.hat] : []))
195  const faces = open.flatMap(u => (u.face ? [u.face] : []))
196  const hat = p.hat === 'none' ? null : p.hat && hats.includes(p.hat) ? p.hat : (hats[hats.length - 1] ?? null)
197  const face = p.face === 'none' ? null : p.face && faces.includes(p.face) ? p.face : (faces[faces.length - 1] ?? null)
198  return { hat, face }
199}
200
201/** Adds experience; returns the new pixling and the levels crossed, with what they unlocked. */
202export const gain = (
203  p: Pixling,
204  event: XpEvent,
205): { pixling: Pixling; levelUp: { level: number; unlocks: Unlock[] } | null } => {
206  const before = levelOf(p.xp).level
207  const pixling = { ...p, xp: p.xp + XP[event], day: { ...p.day, xp: p.day.xp + XP[event] } }
208  const after = levelOf(pixling.xp).level
209  if (after <= before) return { pixling, levelUp: null }
210  return {
211    pixling,
212    levelUp: { level: after, unlocks: UNLOCKS.filter(u => u.level > before && u.level <= after) },
213  }
214}
215
216export const bump = (p: Pixling, stat: keyof Stats, by = 1): Pixling => ({
217  ...p,
218  stats: { ...p.stats, [stat]: p.stats[stat] + by },
219})
220
221/** Reads a stored value back into a pixling, or null when it is not one. */
222export const revive = (value: unknown): Pixling | null => {
223  if (!value || typeof value !== 'object') return null
224  const v = value as Partial<Pixling>
225  if (v.v !== 1 || typeof v.species !== 'string' || typeof v.name !== 'string') return null
226  if (!SPECIES.some(s => s.id === v.species)) return null
227  const hatchedAt = typeof v.hatchedAt === 'number' ? v.hatchedAt : Date.now()
228  const persona = typeof v.persona === 'string' ? oneLine(v.persona) : ''
229  return {
230    v: 1,
231    species: v.species,
232    isShiny: v.isShiny === true,
233    name: v.name.slice(0, 24),
234    hatchedAt,
235    xp: typeof v.xp === 'number' && v.xp >= 0 ? v.xp : 0,
236    hat: v.hat ?? null,
237    face: v.face ?? null,
238    stats: { ...emptyStats(), ...(v.stats ?? {}) },
239    dex: Array.isArray(v.dex) ? v.dex.filter((d): d is string => typeof d === 'string') : [v.species],
240    badges: isRecord(v.badges) ? numbersOf(v.badges) : {},
241    streak: isRecord(v.streak)
242      ? {
243          last: typeof v.streak.last === 'string' ? v.streak.last : null,
244          days: numberOr(v.streak.days, 0),
245          best: numberOr(v.streak.best, 0),
246        }
247      : { last: null, days: 0, best: 0 },
248    day: isRecord(v.day) && typeof v.day.date === 'string'
249      ? { ...emptyDay(v.day.date), ...numbersOf(v.day), date: v.day.date }
250      : emptyDay(''),
251    tics: isRecord(v.tics) ? numbersOf(v.tics) : {},
252    // A save from before personalities gets one seeded by its hatch: the same every session.
253    persona: persona || personaFor(v.species, hatchedAt),
254    adoptedFrom: isRecord(v.adoptedFrom) && typeof v.adoptedFrom.name === 'string'
255      ? {
256          name: v.adoptedFrom.name.slice(0, 24),
257          hatchedAt: typeof v.adoptedFrom.hatchedAt === 'number' && Number.isFinite(v.adoptedFrom.hatchedAt) ? v.adoptedFrom.hatchedAt : null,
258        }
259      : null,
260  }
261}
262
263/** A stored value that is there but is no pixling this version can read: never to be written over. */
264export const isUnreadable = (value: unknown): boolean => value !== undefined && value !== null && revive(value) === null
265
266// Saving ----------------------------------------------------------------------------------------
267// Every session keeps the pixling it loaded (its base) and saves by folding what it changed since
268// into what the store holds now, so two terminals never overwrite each other's progress.
269
270/** `stored` moved on by what `ours` added since `base`, per key, never below zero. */
271const moved = <K extends string>(
272  stored: Partial<Record<K, number>>,
273  base: Partial<Record<K, number>>,
274  ours: Partial<Record<K, number>>,
275): Partial<Record<K, number>> => {
276  const out: Partial<Record<K, number>> = { ...stored }
277  for (const key of Object.keys(ours) as K[]) {
278    const by = (ours[key] ?? 0) - (base[key] ?? 0)
279    if (by !== 0) out[key] = Math.max(0, (stored[key] ?? 0) + by)
280  }
281  return out
282}
283
284/** The later `last` wins; on the same day, the longer run. The best is the best either saw. */
285const laterStreak = (a: Streak, b: Streak): Streak => {
286  const [x, y] = [a.last ?? '', b.last ?? '']
287  const later = x > y ? a : y > x ? b : a.days >= b.days ? a : b
288  return { ...later, best: Math.max(a.best, b.best, later.days) }
289}
290
291const DAY_COUNTS = ['turns', 'commits', 'squashed', 'tests', 'xp'] as const
292
293/** Today's record: the newer date wins whole; on the same date, both sessions' counts add up. */
294const mergeDay = (stored: Day, base: Day, ours: Day): Day => {
295  if (ours.date !== stored.date) return ours.date > stored.date ? ours : stored
296  const since = base.date === ours.date ? base : emptyDay(ours.date)
297  const day = { ...stored }
298  for (const k of DAY_COUNTS) day[k] = Math.max(0, stored[k] + ours[k] - since[k])
299  return day
300}
301
302/**
303 * What to write when the store holds `stored`, this session loaded or last wrote `base` (null
304 * before its first save) and holds `ours` now. A different `hatchedAt` is another egg: the later
305 * hatch wins. Counters add up; name, hat and face are ours when we changed them; badges, the dex,
306 * the streak and the tics belong to the person and are merged across eggs.
307 */
308export const mergeSave = (stored: Pixling, base: Pixling | null, ours: Pixling): Pixling => {
309  const isSameEgg = stored.hatchedAt === ours.hatchedAt
310  // No base, yet the store holds this very egg: ours is the newer copy of it.
311  const since = base ?? (isSameEgg ? stored : null)
312  const badges = { ...stored.badges }
313  for (const [id, at] of Object.entries(ours.badges)) badges[id] = Math.min(at, badges[id] ?? at)
314  const person = {
315    dex: [...new Set([...stored.dex, ...ours.dex])],
316    badges,
317    streak: laterStreak(stored.streak, ours.streak),
318    tics: moved(stored.tics, since?.tics ?? {}, ours.tics),
319  }
320  if (!isSameEgg || !since) return { ...(stored.hatchedAt > ours.hatchedAt ? stored : ours), ...person }
321  const pick = <T>(theirs: T, was: T, now: T): T => (now === was ? theirs : now)
322  // A record read back from the store is a new object each time: compared by value.
323  const pickValue = <T>(theirs: T, was: T, now: T): T => (JSON.stringify(now) === JSON.stringify(was) ? theirs : now)
324  return {
325    ...stored,
326    species: pick(stored.species, since.species, ours.species),
327    persona: pick(stored.persona, since.persona, ours.persona),
328    adoptedFrom: pickValue(stored.adoptedFrom, since.adoptedFrom, ours.adoptedFrom),
329    name: pick(stored.name, since.name, ours.name),
330    hat: pick(stored.hat, since.hat, ours.hat),
331    face: pick(stored.face, since.face, ours.face),
332    xp: Math.max(0, stored.xp + ours.xp - since.xp),
333    stats: moved(stored.stats, since.stats, ours.stats) as Stats,
334    day: mergeDay(stored.day, since.day, ours.day),
335    ...person,
336  }
337}
338
339const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
340
341const numberOr = (v: unknown, fallback: number): number =>
342  typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback
343
344const numbersOf = (v: object): Record<string, number> =>
345  Object.fromEntries(Object.entries(v).filter(([, n]) => typeof n === 'number' && Number.isFinite(n) && n >= 0))
346
347export const xpBar = (into: number, need: number, width: number): string => {
348  const filled = need <= 0 ? width : Math.min(width, Math.round((into / need) * width))
349  return '█'.repeat(filled) + '░'.repeat(width - filled)
350}
351
352/** When the person met this pixling: an adopted Buddy's hatch, else its own. */
353export const metAt = (p: Pixling): number => p.adoptedFrom?.hatchedAt ?? p.hatchedAt
354
355export const daysTogether = (p: Pixling, now: number): number =>
356  Math.max(1, Math.floor((now - metAt(p)) / 86_400_000) + 1)
357