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…

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.
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.
/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./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
hooks/register.tsx 2530 lines1import { 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 lines1// 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}
254hooks/lib/brain.ts 55 lines1// 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)
55hooks/lib/buddy.ts 88 lines1// 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)
88hooks/lib/card.ts 190 lines1// 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}
190hooks/lib/canvas.ts 1057 lines1// 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 }
1057hooks/lib/detect.ts 763 lines1// 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}
763hooks/lib/lines.ts 303 lines1// 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 })))
303hooks/lib/platform.ts 179 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
179hooks/lib/png.ts 198 lines1// 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}
198hooks/lib/quips.ts 118 lines1// 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}
118hooks/lib/progress.ts 357 lines1// 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