Glitch pets: pick one of six pixel critters; it sits at the end of the HUD row, shows moods and says a word now and then.

Glitch is a kit of Claude Code mods, published from this folder as a marketplace named glitch. It puts one band above the prompt that shows the session at a glance, makes spending and prompt-cache expiry visible before they cost money, and themes everything from one place. A Glitch bug never breaks the session, and Glitch refuses nothing unless you ask it to: the one refusal is the opt-in subagent block at a budget cap.
| Plugin | What it does |
|---|---|
glitch-core | Shared themes and the /glitch command. |
glitch-hud | The band above the prompt: turn, speed, cost and budget, cache countdown with keep-warm, context, git. Depends on glitch-core. |
glitch-pets | A pixel pet at the end of the HUD row, with moods, short remarks and a daily streak. Depends on glitch-core and glitch-hud. |
One line above the prompt. In the terminal it is text with theme colors and icons:
| Part | Example | Source |
|---|---|---|
| turn | #12 | count of main-session turns |
| speed | ⚡ 84 tok/s | output tokens divided by streaming time of the last main response (ignores answers under 20 tokens or 0.5 s) |
| cost | $ 0.42 reply · 3.10 session · 21.40 today | the last reply, this session, and today across every session on this machine; pick them with costShows. Colored by budget level |
| cache | ⧗ 47:12 ↻ | countdown to prompt-cache expiry (cacheTtlMinutes, 60 by default); … while a turn runs |
| context | ▓▓▓▓░░ 63% | context window used; amber from 65%, red from 85% |
| git | main ±4 ↑1 | branch, changed files, ahead/behind; hidden outside a repo |
Desktop, VS Code and mobile. These apps draw the band with vector icons, a real context bar and worded values (Turn 12, $0.42 reply · $3.10 session · $21.40 today, 47:12 left, 63% context). The desktop already shows the branch above the band, so git is left out there unless you change Parts in the settings or run /glitch hud parts ….
The three costs. A reply is everything Claude does to answer one message: every model call, tool use and subagent until it stops. It is left out while it equals the session figure (the first reply). Session is the figure /cost shows. Today adds up every session that ran glitch-hud on this computer since local midnight, in any project; it is not your account's spend on other machines or on claude.ai. With a cap set, the figure shows it: $3.10 of $5.00 session.
Width. As the window narrows, parts drop in the order git, speed, turn; then the cost figures lose their words and the context bar becomes just the percent. The row never wraps.
Hiding. The × at the end of the band hides it for the session; /glitch hud toggles it again. The band is not drawn before the first turn or while a survey shows. Other mods' bands render beneath it.
Set a cap per session (budgetSession), per local day (budgetDaily), or both. 0 turns a cap off.
The 90% and 100% toasts appear in the session whatever notify says; notify only picks how the 100% notification (and the cache warnings) reach you, and off drops those.
Each level fires once per cap per session; raising a cap re-arms the higher levels. The daily ledger sums every session's spend for the local date: each session keeps its own entry per day under its session id, so sessions running side by side, a reload, a /clear or a resume never double count; entries older than 7 days are pruned. Caps changed in /config apply when the plugin reloads, except a session cap you already set with /glitch budget.
With budgetBlocksAgents on, new subagents are refused once either cap is reached. At the session cap the refusal asks Claude to have you raise it with /glitch budget +N; at the daily cap it asks you to run /glitch budget off for this session or raise budgetDaily in /config (+N moves the session cap only). The main conversation is never blocked, and if the check itself fails the call is allowed.
Costs are Claude Code's own estimate at API prices, not a bill; on a subscription they are not charged.
Claude Code's prompt cache lives for a fixed time after the last request: 1 hour on most plans, 5 minutes on others. Set cacheTtlMinutes to match (60 by default; set 5 if your account's cache lasts 5 minutes). The cache part counts down to that deadline from each answer and pauses (…) while a turn runs; a turn interrupted before any request leaves it as it was, and /clear drops it.
Left empty, cacheWarnMinutes fits the lifetime: marks at 10 and 5 minutes left for 1 hour, at 2 and 1 for 5 minutes. Marks you type at or above the lifetime can never fire: they are ignored and named once at session start. The countdown turns the warning color at a third of the lifetime left (20 minutes of an hour, 1:40 of 5 minutes) and the alert color at a sixth (10 minutes, 0:50).
<project> — Glitch: "Prompt cache expires in N min".notify = windows, a native Windows toast is tried first; if that fails (or the OS is not Windows) Claude Code's own notification channel is used, then an in-app toast. toast goes straight to the in-app toast; off turns off the cache warnings and the budget-cap notification (the budget toasts above still show).↻ on the cache part, or /glitch warm, sends a tool-less fork of the session's last request with a one-line question after it. The request is served from the prompt cache, which refreshes it, and nothing is added to the transcript. It is refused while a turn runs. The reply reports how many tokens were read from cache, and the countdown resets to the full lifetime (60:00, or 05:00).autoWarmMaxIdle minutes; never twice in a row without a prompt from you in between.Pick a pet and it sits at the end of the HUD row on the desktop app. While Claude works, your pet types on a little laptop; between turns it does its signature move. It reacts to the session through moods, says a short word now and then as a toast, and keeps a daily streak. It is not drawn in the terminal; there, remarks arrive as toasts.
| Pet | Kind | Temperament | Signature move (idle) |
|---|---|---|---|
| Screenhead | screen bot | Earnest, literal and a little robotic; reports facts, proud of tidy output. | Types; code scrolls on its screen face. |
| Byte blob | blob | Bubbly and easily startled; squeaks with joy, wobbles when worried. | Bounces: squash, stretch and a glitch slice. |
| Pixel | cat | Dry and unimpressed, secretly proud; says little, means it. | Swishes its tail and sheds pixels. |
| Dot | ladybug | Tidy, gentle and encouraging; notices the small things. | Flutters her wings on a daisy. |
| Fork | octopus | The organised multitasker; a born lead who loves a plan. | Taps four keys with four arms. |
| Bud | dog | Eager and loyal; cheers for everything and wants to go again. | Wags, pants and flops an ear. |
Moods: idle, working, happy, tired, sleepy, worried, oops, cheering, leading, reviewing, crying. It cries when the budget cap is reached, when a daily streak of two or more days is broken, and after three failures in a row.
/glitch-pets <name | off> switches to a pet and remembers it, or empties the slot; /glitch-pets alone lists the pets. /glitch pet [name | off] works the same way.
| Setting | Default | Meaning |
|---|---|---|
pet | bud | your pet: screenhead, blob, pixel, dot, fork, bud or off; /glitch pet <name> overrides it |
voice | true | one-line remarks as toasts |
voiceEveryMinutes | 3 | at most one remark per this many minutes |
tiredMinutes | 10 | a turn this long makes the pet tired |
sleepyMinutes | 10 | this long without a prompt makes the pet sleepy |
/plugin install glitch-pets@glitch
Six built-in themes: glitch (default, electric cyan and magenta), neon, mono, sunset, matrix, paper. /glitch theme lists them, /glitch theme neon switches and remembers the choice across sessions, /glitch theme default clears it.
Resolution order: the last /glitch theme <name>, then the theme setting, then glitch. The custom setting, a JSON object of tokens, is merged over the result:
{ "accent": "#ff6ad5", "bar": "█░" }
Color tokens are accent, good, warn, alert, muted, info (as #rrggbb); bar is the full and empty characters of the context bar; icons is emoji, symbols or ascii. An unknown theme name falls back to glitch; malformed custom JSON is ignored and the chosen theme is kept. Either problem toasts at session start.
/glitch is owned by glitch-core. The HUD subcommands are answered by glitch-hud directly, the pet subcommand by glitch-pets, and the same subcommands also work as /glitch-hud ….
| Command | Owner | Does | |||
|---|---|---|---|---|---|
/glitch | core | help card: theme and the subcommands each plugin answers | |||
| `/glitch theme [name\ | default]` | core | list themes, or switch and persist | ||
| `/glitch-pets [name\ | off]` | pets | list pets, switch and persist, or turn the pet off | ||
| `/glitch pet [name\ | off]` | pets | the same, through the core command | ||
/glitch hud | hud | show or hide the band | |||
/glitch hud parts <list> | hud | parts and order for this session, as a comma list, e.g. cost,cache,context or cost, cache | |||
/glitch hud cost <list> | hud | cost figures and order for this session, e.g. session,today | |||
| `/glitch budget [+N \ | N \ | off \ | on]` | hud | show, raise, set, disable or re-enable this session's caps |
/glitch warm | hud | keep-warm ping now | |||
/glitch demo cache <min> | hud | preview the cache part and warnings at N minutes left; demo warnings say "(demo)", never auto-warm, and the real countdown comes back when the demo runs out or at the next answer | |||
/glitch demo budget <pct> | hud | preview the budget levels at N percent |
An unknown subcommand shows the help card. /glitch budget +N and N change this session's cap only; off disables both caps for this session.
Set them in /config, or in ~/.claude/settings.json under pluginConfigs. Invalid values fall back to the default and toast once.
glitch-core
| Setting | Default | Meaning |
|---|---|---|
theme | glitch | default theme: glitch, neon, mono, sunset, matrix, paper |
custom | empty | JSON object of theme tokens laid over the theme |
glitch-hud
| Setting | Default | Meaning |
|---|---|---|
parts | turn,speed,cost,cache,context,git | which parts show, in order |
costShows | reply,session,today | which cost figures show, in order |
budgetSession | 0 | USD cap per session; 0 is off |
budgetDaily | 0 | USD cap per local day, across sessions; 0 is off |
budgetBlocksAgents | false | refuse new subagents at a cap |
cacheTtlMinutes | 60 | prompt cache lifetime: 60 (most plans) or 5 |
cacheWarnMinutes | empty | minutes-left marks that notify; empty fits the lifetime (10,5 for 1 hour, 2,1 for 5 minutes); marks at or above the lifetime are ignored |
notify | windows | windows (native toast), toast (inside Claude Code) or off (no cache or cap notifications; budget toasts still show) |
autoWarm | false | keep the cache warm by itself at the last warning mark |
autoWarmMaxIdle | 120 | minutes without a prompt from you after which autoWarm stops |
/plugin install glitch-hud --marketplace <owner>/<repo>
Answer y to add the marketplace, then pick a scope. This also installs glitch-core.
From a local folder:
claude plugin marketplace add C:\path\to\this\folder
/plugin install glitch-hud@glitch
To add the pets: /plugin install glitch-pets@glitch.
Or load the plugin folders directly with CLAUDE_CODE_PLUGIN_DIRS, separated by ; on Windows. In cmd:
set CLAUDE_CODE_PLUGIN_DIRS=C:\path\to\this\folder\plugins\glitch-core;C:\path\to\this\folder\plugins\glitch-hud
In PowerShell:
$env:CLAUDE_CODE_PLUGIN_DIRS = 'C:\path\to\this\folder\plugins\glitch-core;C:\path\to\this\folder\plugins\glitch-hud'
Both last for that terminal only; to keep it, put CLAUDE_CODE_PLUGIN_DIRS under env in ~/.claude/settings.json.
Edits reach a running session with /reload-plugins.
Mods use Claude Code's early-access function-hooks API; tested on Claude Code 2.1.296.
MIT, see LICENSE.
hooks/register.tsx 213 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { MoodState, PetsView } from '../types'
5import { personaProblems } from './behavior/contract'
6import type { BudgetLike } from './behavior/moods'
7import { createPlayer } from './behavior/player'
8import { STORE_STREAK, advance, asStreak } from './behavior/streak'
9import type { MoodId, Persona, PetId, Sprite, VoiceMoment } from './behavior/types'
10import { PET_IDS } from './behavior/types'
11import { INITIAL_VOICE, pickLine } from './behavior/voice'
12import type { VoiceState } from './behavior/voice'
13import { PETS_USAGE, ROUTED_PETS, STORE_PET, parsePetArg, petArgsOf, petList } from './commands'
14import { parsePetsConfig } from './config'
15import type { PetsConfig } from './config'
16import { PERSONAS } from './personas/index'
17import { onToolResult, onTurnEnd, onTurnStartStreak, tickOnce } from './tick'
18
19const INITIAL_MOOD: MoodState = { mood: 'idle', since: 0, turnSince: null, lastPromptAt: 0, oopsUntil: 0, happyUntil: 0, cheerUntil: 0, cryUntil: 0, failStreak: 0 }
20
21export const sprite = atom({ plugin: 'glitch-pets', key: 'sprite' } as const, null as Sprite | null)
22export const mood = atom({ plugin: 'glitch-pets', key: 'mood' } as const, INITIAL_MOOD)
23export const view = atom({ plugin: 'glitch-pets', key: 'view' } as const, { petId: 'bud' } as PetsView)
24
25export const TICK_MS = 100
26const LONG_TURN_MS = 120_000
27const SPEAKS_ON_ENTRY: Partial<Record<MoodId, VoiceMoment>> = { worried: 'worried', sleepy: 'sleepy', oops: 'oops', cheering: 'cheering', crying: 'crying' }
28
29// Pets that break the contract are left out (named once at start).
30// Composed personas hold the laptop keys 4/6/7 by design; the raw-file reserved-key check lives in personas/index.ts (composeSafely) and personas.test.
31const usable = Object.fromEntries(Object.entries(PERSONAS).filter(([, p]) => p && personaProblems(p, { composed: true }).length === 0)) as Partial<Record<PetId, Persona>>
32const broken = (Object.keys(PERSONAS) as PetId[]).filter(id => !usable[id])
33const available = PET_IDS.filter(id => usable[id])
34const personaFor = (id: PetId | 'off'): Persona | null => (id === 'off' ? null : (usable[id] ?? usable.screenhead ?? null))
35
36// Per-load state, set in `register`.
37let config: PetsConfig = parsePetsConfig({}).config
38let player = createPlayer()
39let voice: VoiceState = INITIAL_VOICE
40let lastSource: string | null = null
41let ticking = false
42
43// $.state.get needs a literal plugin and key, so these three cannot be merged.
44async function hudStats($: EngineInterface): Promise<unknown> {
45 try {
46 return (await $.state.get({ plugin: 'glitch-hud', key: 'stats' } as const)).value
47 } catch {
48 return null
49 }
50}
51
52async function hudBudget($: EngineInterface): Promise<unknown> {
53 try {
54 return (await $.state.get({ plugin: 'glitch-hud', key: 'budget' } as const)).value
55 } catch {
56 return null
57 }
58}
59
60async function hudCache($: EngineInterface): Promise<unknown> {
61 try {
62 return (await $.state.get({ plugin: 'glitch-hud', key: 'cache' } as const)).value
63 } catch {
64 return null
65 }
66}
67
68async function say($: EngineInterface, moment: VoiceMoment) {
69 const p = personaFor((await read($, view)).petId)
70 if (!p) return
71 const m = await read($, mood)
72 const picked = pickLine(p, moment, voice, { now: await $.clock.now(), isTurnRunning: m.turnSince !== null, enabled: config.voice, everyMs: config.voiceEveryMs, random: Math.random })
73 if (!picked) return
74 voice = picked.state
75 $.ui.toast(picked.text, { timeoutMs: 6000 })
76}
77
78async function tick($: EngineInterface) {
79 const now = await $.clock.now()
80 const p = personaFor((await read($, view)).petId)
81 if (!p) {
82 if (lastSource !== null) {
83 lastSource = null
84 await update($, sprite, () => null)
85 }
86 return
87 }
88 const stats = (await hudStats($)) as { total?: number } | null
89 const b = (await hudBudget($)) as BudgetLike | null
90 const c = (await hudCache($)) as { secondsLeft?: number | null } | null
91 const m = await read($, mood)
92 const r = tickOnce({
93 now, persona: p, player, state: m, lastSource, total: stats?.total ?? 0, budget: b, cacheSecondsLeft: c?.secondsLeft ?? null,
94 helpersRunning: 0, reviewerRunning: false, tiredMs: config.tiredMs, sleepyMs: config.sleepyMs,
95 })
96 if (r.moodChanged) {
97 await update($, mood, x => ({ ...x, mood: r.mood, since: r.since }))
98 const moment = SPEAKS_ON_ENTRY[r.mood]
99 if (moment) await say($, moment)
100 }
101 if (r.sprite) {
102 lastSource = r.sprite.source
103 await update($, sprite, () => r.sprite)
104 }
105}
106
107// `arg` is the pet argument (see petArgsOf): empty lists the pets.
108async function answerPets($: EngineInterface, arg: string): Promise<string> {
109 const word = arg.trim().split(/\s+/)[0]
110 if (word?.toLowerCase() === 'crew') return PETS_USAGE
111 const parsed = parsePetArg(word)
112 if (parsed.kind === 'show') return petList((await read($, view)).petId, available)
113 if (parsed.kind === 'unknown') return `Unknown pet "${parsed.name}". ${petList((await read($, view)).petId, available)}`
114 const petId: PetId | 'off' = parsed.kind === 'off' ? 'off' : parsed.id
115 await $.store.set(STORE_PET, petId).catch(() => undefined)
116 await update($, view, v => ({ ...v, petId }))
117 const p = personaFor(petId)
118 return p ? `Pet: ${p.name} the ${p.kind}.` : 'Pet off.'
119}
120
121export const register: Register = (on, options) => {
122 const parsed = parsePetsConfig(options as Record<string, unknown>)
123 const problems = parsed.problems
124 config = parsed.config
125 player = createPlayer()
126 voice = INITIAL_VOICE
127 lastSource = null
128 ticking = false
129
130 on('session.start', async ($, e, next) => {
131 const started = await next(e)
132 try {
133 await $.command.register({ name: 'glitch-pets', description: 'Glitch pets: /glitch-pets <name | off> (also /glitch pet)' })
134 } catch {
135 $.ui.toast('Glitch pets: could not register /glitch-pets', { timeoutMs: 8000 })
136 }
137 if (problems.length) $.ui.toast(`Glitch pets: ${problems.join('; ')}`, { timeoutMs: 8000 })
138 if (broken.length) $.ui.toast(`Glitch pets: left out (art problems): ${broken.join(', ')}`, { timeoutMs: 8000 })
139 const saved = await $.store.get(STORE_PET).catch(() => undefined)
140 const petId = saved === 'off' || (PET_IDS as readonly unknown[]).includes(saved) ? (saved as PetId | 'off') : config.pet
141 const now = await $.clock.now()
142 await update($, view, v => ({ ...v, petId }))
143 await update($, mood, m => ({ ...m, since: now, lastPromptAt: m.lastPromptAt || now }))
144 if (!ticking) {
145 ticking = true
146 $.clock.every(TICK_MS, async () => {
147 try {
148 await tick($)
149 } catch {
150 // The next tick draws again.
151 }
152 })
153 }
154 return started
155 })
156
157 on('prompt.submit', async ($, e, next) => {
158 if (e.origin.kind !== 'plugin') {
159 try {
160 const now = await $.clock.now()
161 await update($, mood, m => ({ ...m, lastPromptAt: now }))
162 } catch {
163 // The pet just misses this one.
164 }
165 }
166 return next(e)
167 })
168
169 on('turn.start', async ($, e, next) => {
170 if ((e as { agentId?: string }).agentId === undefined) {
171 try {
172 const now = await $.clock.now()
173 await update($, mood, m => ({ ...m, turnSince: now }))
174 const r = advance(asStreak(await $.store.get(STORE_STREAK).catch(() => null)), now)
175 if (r.changed) await $.store.set(STORE_STREAK, r.streak).catch(() => undefined)
176 await update($, mood, m => onTurnStartStreak(m, r, now))
177 } catch {
178 // The pet just misses this one.
179 }
180 }
181 return next(e)
182 })
183
184 on('turn.complete', async ($, e, next) => {
185 try {
186 return await next(e)
187 } finally {
188 if (e.agentId === undefined) {
189 try {
190 const now = await $.clock.now()
191 await update($, mood, m => onTurnEnd(m, e.reason, now))
192 if (e.reason === 'answer' && e.durationMs >= LONG_TURN_MS) await say($, 'happy')
193 } catch {
194 // Leave the mood as it was.
195 }
196 }
197 }
198 })
199
200 on('tool.call', async ($, e, next) => {
201 const ran = await next(e)
202 if (e.agentId === undefined) {
203 const now = await $.clock.now().catch(() => 0)
204 await update($, mood, m => onToolResult(m, e, ran, now)).catch(() => undefined)
205 }
206 return ran
207 })
208
209 on('command.run', { command: 'glitch-pets' }, async ($, e) => ({ text: await answerPets($, petArgsOf('glitch-pets', e.args)) }))
210 on('command.run', { command: 'glitch' }, async ($, e, next) =>
211 ROUTED_PETS.includes(e.args.trim().split(/\s+/)[0] ?? '') ? { text: await answerPets($, petArgsOf('glitch', e.args)) } : next(e))
212}
213hooks/behavior/contract.ts 44 lines1// The rules every persona file must keep. The persona tests run it on each pet,
2// and register.tsx skips a pet that breaks it.
3import type { Persona } from './types'
4import { GRID, MOOD_IDS, VOICE_MOMENTS } from './types'
5
6const HEX = /^#[0-9a-f]{6}$/i
7const RESERVED = ['1', '2', '3']
8const LAPTOP_KEYS = ['4', '6', '7']
9
10/** `composed`: the persona already went through withLaptop, so it holds the laptop's keys by design. */
11export function personaProblems(p: Persona, opts: { composed?: boolean } = {}): string[] {
12 const out: string[] = []
13 for (const [key, color] of Object.entries(p.palette)) {
14 if (RESERVED.includes(key)) out.push(`palette key "${key}" is reserved for hats`)
15 if (!opts.composed && LAPTOP_KEYS.includes(key)) out.push(`palette key "${key}" is reserved for the laptop`)
16 if (key === '.' || key.length !== 1) out.push(`palette key "${key}" must be one char other than "."`)
17 if (!HEX.test(color)) out.push(`palette "${key}" must be #rrggbb, got ${color}`)
18 }
19 for (const mood of MOOD_IDS) {
20 const art = p.moods[mood]
21 if (!art) {
22 out.push(`mood ${mood} is missing`)
23 continue
24 }
25 if (!(art.fps > 0)) out.push(`${mood} fps must be above 0`)
26 if (art.frames.length === 0) out.push(`${mood} has no frames`)
27 art.frames.forEach((frame, f) => {
28 if (frame.length !== GRID.height) out.push(`${mood} frame ${f} has ${frame.length} rows, not ${GRID.height}`)
29 frame.forEach((row, y) => {
30 if (row.length !== GRID.width) out.push(`${mood} frame ${f} row ${y} is ${row.length} wide, not ${GRID.width}`)
31 for (const ch of row) if (ch !== '.' && !(ch in p.palette)) out.push(`${mood} frame ${f} row ${y} uses "${ch}", not in the palette`)
32 })
33 })
34 }
35 for (const moment of VOICE_MOMENTS) {
36 const lines = p.voice[moment]
37 if (!lines || lines.length < 3 || lines.length > 5) out.push(`voice ${moment} needs 3 to 5 lines`)
38 }
39 if (!(p.laptopPaw in p.palette)) out.push(`laptopPaw "${p.laptopPaw}" is not in the palette`)
40 const { x, y } = p.hatAnchor
41 if (!(x >= 0 && x < GRID.width && y >= 0 && y < GRID.height)) out.push('hatAnchor must be inside the 16 × 13 grid')
42 return out
43}
44hooks/behavior/moods.ts 60 lines1// Plain inputs → the one mood to show. Pure: timed moods are "until" timestamps.
2import type { MoodId } from './types'
3
4export const OOPS_MS = 4000
5export const HAPPY_MS = 5000
6export const CHEER_MS = 6000
7export const CRY_MS = 6000
8/** Failures in a row (main loop) that make the pet cry. */
9export const CRY_FAILS = 3
10export const WORRY_CACHE_S = 60
11const WORRY_BUDGET = 0.9
12
13/** The fields of glitch-hud's Budget this plugin reads. */
14export type BudgetLike = { sessionCap: number; dailyCap: number; isOff: boolean; dailySpent: number; demoPercent: number | null }
15
16export type MoodInputs = {
17 now: number
18 turnSince: number | null
19 lastPromptAt: number
20 oopsUntil: number
21 happyUntil: number
22 cheerUntil: number
23 cryUntil: number
24 capReached: boolean
25 /** Spend as a fraction of the nearest cap (budgetFraction), null with no caps. */
26 budget: number | null
27 /** glitch-hud's countdown; null before the first answer. */
28 cacheSecondsLeft: number | null
29 helpersRunning: number
30 reviewerRunning: boolean
31 tiredMs: number
32 sleepyMs: number
33}
34
35export function budgetFraction(b: BudgetLike | null, sessionTotal: number): number | null {
36 if (!b) return null
37 if (b.demoPercent !== null) return b.demoPercent / 100
38 if (b.isOff) return null
39 const parts = [b.sessionCap > 0 ? sessionTotal / b.sessionCap : null, b.dailyCap > 0 ? b.dailySpent / b.dailyCap : null].filter((x): x is number => x !== null)
40 return parts.length ? Math.round(Math.max(...parts) * 1000) / 1000 : null
41}
42
43export const isCapReached = (b: BudgetLike | null, sessionTotal: number): boolean =>
44 !!b && !b.isOff && ((b.sessionCap > 0 && sessionTotal >= b.sessionCap) || (b.dailyCap > 0 && b.dailySpent >= b.dailyCap))
45
46export function moodOf(i: MoodInputs): MoodId {
47 const running = i.turnSince !== null
48 if (i.cryUntil > i.now || i.capReached) return 'crying'
49 if (i.oopsUntil > i.now) return 'oops'
50 if ((i.budget !== null && i.budget >= WORRY_BUDGET) || (i.cacheSecondsLeft !== null && i.cacheSecondsLeft > 0 && i.cacheSecondsLeft < WORRY_CACHE_S)) return 'worried'
51 if (i.cheerUntil > i.now) return 'cheering'
52 if (i.reviewerRunning) return 'reviewing'
53 if (i.helpersRunning > 0) return 'leading'
54 if (i.happyUntil > i.now) return 'happy'
55 if (running && i.now - (i.turnSince as number) >= i.tiredMs) return 'tired'
56 if (running) return 'working'
57 if (i.now - i.lastPromptAt >= i.sleepyMs || i.cacheSecondsLeft === 0) return 'sleepy'
58 return 'idle'
59}
60hooks/behavior/player.ts 47 lines1// Frames to images. Pure, and gallery-safe: only type imports, so Node can load it.
2import type { Frame, MoodArt, MoodId, Persona, Sprite } from './types'
3
4/** CSS px per sprite pixel in the band: a 16 × 13 pet draws 24 × 20, about the HUD row's height. */
5export const PIXEL = 1.5
6const W = 16
7
8export const frameIndex = (art: MoodArt, elapsedMs: number): number =>
9 Math.floor((Math.max(0, elapsedMs) * art.fps) / 1000) % art.frames.length
10
11/** One frame as an SVG document: one rect per run of a color within a row. */
12export function frameSvg(palette: Readonly<Record<string, string>>, frame: Frame, pixel: number): string {
13 let rects = ''
14 frame.forEach((row, y) => {
15 let x = 0
16 while (x < row.length) {
17 const ch = row[x] as string
18 let end = x + 1
19 while (end < row.length && row[end] === ch) end++
20 const color = palette[ch]
21 if (ch !== '.' && color) rects += `<rect x="${x}" y="${y}" width="${end - x}" height="1" fill="${color}"/>`
22 x = end
23 }
24 })
25 const h = frame.length
26 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${Math.round(W * pixel)}" height="${Math.round(h * pixel)}" shape-rendering="crispEdges">${rects}</svg>`
27}
28
29/** Builds each (pet, mood, frame, size) image once; a tick only picks which. */
30export function createPlayer(): { sprite(p: Persona, mood: MoodId, elapsedMs: number, pixel?: number): Sprite } {
31 const cache = new Map<string, Sprite>()
32 return {
33 sprite(p, mood, elapsedMs, pixel = PIXEL) {
34 const art = p.moods[mood]
35 const i = frameIndex(art, elapsedMs)
36 const key = `${p.id}|${mood}|${i}|${pixel}`
37 let s = cache.get(key)
38 if (!s) {
39 const frame = art.frames[i] as Frame
40 s = { source: frameSvg(p.palette, frame, pixel), alt: `${p.name} the ${p.kind}, ${mood}`, width: Math.round(W * pixel), height: Math.round(frame.length * pixel) }
41 cache.set(key, s)
42 }
43 return s
44 },
45 }
46}
47hooks/behavior/streak.ts 39 lines1// The daily streak: consecutive local days with at least one turn. Pure; the
2// caller keeps it in $.store under STORE_STREAK.
3export type Streak = { lastDay: string; count: number; cheered: number }
4
5export const STORE_STREAK = 'streak'
6
7const pad = (n: number) => String(n).padStart(2, '0')
8export const dayKey = (ms: number): string => {
9 const d = new Date(ms)
10 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
11}
12
13const yesterdayOf = (ms: number): string => {
14 const d = new Date(ms)
15 d.setDate(d.getDate() - 1)
16 return dayKey(d.getTime())
17}
18
19const FIRST = [3, 7, 14, 30, 50, 100]
20export const isMilestone = (n: number): boolean => FIRST.includes(n) || (n > 100 && n % 100 === 0)
21
22export function advance(s: Streak | null, nowMs: number): { streak: Streak; cheer: boolean; changed: boolean; broken: boolean } {
23 const today = dayKey(nowMs)
24 if (s && s.lastDay === today) return { streak: s, cheer: false, changed: false, broken: false }
25 const continues = !!s && s.lastDay === yesterdayOf(nowMs)
26 const count = continues ? (s as Streak).count + 1 : 1
27 const broken = !!s && !continues && s.count > 1
28 const cheer = isMilestone(count) && (s?.cheered ?? 0) !== count
29 return { streak: { lastDay: today, count, cheered: cheer ? count : (s?.cheered ?? 0) }, cheer, changed: true, broken }
30}
31
32export function asStreak(v: unknown): Streak | null {
33 if (!v || typeof v !== 'object') return null
34 const o = v as Record<string, unknown>
35 return typeof o.lastDay === 'string' && typeof o.count === 'number' && typeof o.cheered === 'number'
36 ? { lastDay: o.lastDay, count: o.count, cheered: o.cheered }
37 : null
38}
39hooks/behavior/types.ts 45 lines1// The shapes every persona and every behavior module agree on. No imports: the
2// gallery script loads this file directly.
3
4export const PET_IDS = ['screenhead', 'blob', 'pixel', 'dot', 'fork', 'bud'] as const
5export type PetId = (typeof PET_IDS)[number]
6
7export const MOOD_IDS = ['idle', 'working', 'happy', 'tired', 'sleepy', 'worried', 'oops', 'cheering', 'leading', 'reviewing', 'crying'] as const
8export type MoodId = (typeof MOOD_IDS)[number]
9
10export const VOICE_MOMENTS = ['happy', 'worried', 'oops', 'cheering', 'sleepy', 'crying', 'crewDone'] as const
11export type VoiceMoment = (typeof VOICE_MOMENTS)[number]
12
13/** The sprite grid: 16 columns × 13 rows. */
14export const GRID = { width: 16, height: 13 } as const
15
16/** One frame: 13 strings of 16 chars; '.' is empty, any other char a palette key. */
17export type Frame = readonly string[]
18
19export type MoodArt = { frames: readonly Frame[]; fps: number }
20
21export type Persona = {
22 id: PetId
23 /** Shown in toasts and the crew pane: "Bud". */
24 name: string
25 /** For alt text: "dog". */
26 kind: string
27 /** One line, for the gallery and the README. */
28 temperament: string
29 /** Palette key → #rrggbb. Keys 1, 2, 3 are reserved for crew hats; 4, 6, 7 for the laptop. */
30 palette: Readonly<Record<string, string>>
31 moods: Readonly<Record<MoodId, MoodArt>>
32 /** Top-center of the head, where a crew hat's brim sits (x 0–15, y 0–12). */
33 hatAnchor: { x: number; y: number }
34 /** Palette key of the pet's paw or body color; the laptop's typing paws use it. */
35 laptopPaw: string
36 /** 3–5 lines per moment, in this pet's own voice. */
37 voice: Readonly<Record<VoiceMoment, readonly string[]>>
38}
39
40/** What glitch-hud draws: one frame as an image. */
41export type Sprite = { source: string; alt: string; width: number; height: number }
42
43export type Role = 'builder' | 'researcher' | 'designer' | 'tester' | 'reviewer' | 'other'
44export type Tier = 'light' | 'medium' | 'heavy'
45hooks/behavior/voice.ts 19 lines1// When a pet may speak, and which line. Pure.
2import type { Persona, VoiceMoment } from './types'
3
4export type VoiceState = { lastAt: number; lastLine: string | null }
5export const INITIAL_VOICE: VoiceState = { lastAt: -Infinity, lastLine: null }
6
7export function pickLine(
8 p: Persona,
9 moment: VoiceMoment,
10 state: VoiceState,
11 o: { now: number; isTurnRunning: boolean; enabled: boolean; everyMs: number; random: () => number },
12): { text: string; state: VoiceState } | null {
13 if (!o.enabled || o.isTurnRunning || o.now - state.lastAt < o.everyMs) return null
14 const lines = p.voice[moment].filter(l => l !== state.lastLine)
15 if (lines.length === 0) return null
16 const line = lines[Math.min(lines.length - 1, Math.floor(o.random() * lines.length))] as string
17 return { text: `${p.name}: ${line}`, state: { lastAt: o.now, lastLine: line } }
18}
19hooks/commands.ts 29 lines1// Pure helpers of /glitch-pets: argument parsing and texts.
2import type { PetId } from './behavior/types'
3import { PET_IDS } from './behavior/types'
4
5export const ROUTED_PETS = ['pet']
6export const STORE_PET = 'pet'
7export const PETS_USAGE = 'Glitch pets: /glitch-pets [name | off] · /glitch pet [name | off]'
8
9export type PetArg = { kind: 'show' } | { kind: 'off' } | { kind: 'pick'; id: PetId } | { kind: 'unknown'; name: string }
10
11// The pet argument of either entry point: `/glitch-pets [pet] <name>` or the routed `/glitch pet <name>`.
12export function petArgsOf(command: 'glitch-pets' | 'glitch', args: string): string {
13 const words = args.trim().split(/\s+/).filter(Boolean)
14 const routed = command === 'glitch' || words[0] === ROUTED_PETS[0]
15 return (routed ? words.slice(1) : words).join(' ')
16}
17
18export function parsePetArg(arg: string | undefined): PetArg {
19 if (!arg) return { kind: 'show' }
20 const a = arg.toLowerCase()
21 if (a === 'off') return { kind: 'off' }
22 return (PET_IDS as readonly string[]).includes(a) ? { kind: 'pick', id: a as PetId } : { kind: 'unknown', name: arg }
23}
24
25export function petList(current: PetId | 'off', available: readonly PetId[]): string {
26 const names = available.map(id => (id === current ? `${id} (current)` : id)).join(', ')
27 return `Pets: ${names}${current === 'off' ? ' · pet is off' : ''} · /glitch pet <name> | off`
28}
29hooks/config.ts 57 lines1// The plugin's settings, parsed once at load. Bad values fall back to defaults and
2// are reported, never thrown.
3import { PET_IDS } from './behavior/types'
4import type { PetId } from './behavior/types'
5
6export type PetsConfig = {
7 pet: PetId | 'off'
8 voice: boolean
9 voiceEveryMs: number
10 tiredMs: number
11 sleepyMs: number
12 crewPane: 'auto' | 'manual'
13 crewReview: boolean
14}
15
16const MIN = 60_000
17
18export function parsePetsConfig(o: Record<string, unknown>): { config: PetsConfig; problems: string[] } {
19 const problems: string[] = []
20 const minutes = (key: string, def: number): number => {
21 const v = o[key]
22 if (v === undefined) return def * MIN
23 if (typeof v === 'number' && Number.isFinite(v) && v > 0) return v * MIN
24 problems.push(`${key} must be a positive number of minutes`)
25 return def * MIN
26 }
27 const flag = (key: string, def: boolean): boolean => {
28 const v = o[key]
29 if (v === undefined) return def
30 if (typeof v === 'boolean') return v
31 problems.push(`${key} must be true or false`)
32 return def
33 }
34 let pet: PetId | 'off' = 'bud'
35 if (o.pet !== undefined) {
36 if (o.pet === 'off' || (PET_IDS as readonly unknown[]).includes(o.pet)) pet = o.pet as PetId | 'off'
37 else problems.push(`pet must be one of ${PET_IDS.join(', ')}, off`)
38 }
39 let crewPane: 'auto' | 'manual' = 'auto'
40 if (o.crewPane !== undefined) {
41 if (o.crewPane === 'auto' || o.crewPane === 'manual') crewPane = o.crewPane
42 else problems.push('crewPane must be auto or manual')
43 }
44 return {
45 config: {
46 pet,
47 voice: flag('voice', true),
48 voiceEveryMs: minutes('voiceEveryMinutes', 3),
49 tiredMs: minutes('tiredMinutes', 10),
50 sleepyMs: minutes('sleepyMinutes', 10),
51 crewPane,
52 crewReview: flag('crewReview', true),
53 },
54 problems,
55 }
56}
57hooks/personas/index.ts 29 lines1// Every pet, by id. A new pet is one persona file and one line here.
2import { personaProblems } from '../behavior/contract'
3import { withLaptop } from '../behavior/laptop'
4import type { Persona, PetId } from '../behavior/types'
5import blob from './blob'
6import bud from './bud'
7import dot from './dot'
8import fork from './fork'
9import pixel from './pixel'
10import screenhead from './screenhead'
11
12/** The pet on its laptop, or undefined when its file breaks the contract or composing throws: a broken pet is left out, never fatal. */
13export function composeSafely(p: Persona): Persona | undefined {
14 try {
15 return personaProblems(p).length === 0 ? withLaptop(p) : undefined
16 } catch {
17 return undefined
18 }
19}
20
21export const PERSONAS: Readonly<Partial<Record<PetId, Persona>>> = {
22 screenhead: composeSafely(screenhead),
23 blob: composeSafely(blob),
24 pixel: composeSafely(pixel),
25 dot: composeSafely(dot),
26 fork: composeSafely(fork),
27 bud: composeSafely(bud),
28}
29hooks/tick.ts 78 lines1// One timer tick as a pure decision: which mood, which frame, and did anything change.
2import { CHEER_MS, CRY_FAILS, CRY_MS, HAPPY_MS, OOPS_MS, budgetFraction, isCapReached, moodOf } from './behavior/moods'
3import type { BudgetLike } from './behavior/moods'
4import type { MoodState } from '../types'
5import type { MoodId, Persona, Sprite } from './behavior/types'
6
7export type TickInputs = {
8 now: number
9 persona: Persona
10 player: { sprite(p: Persona, mood: MoodId, elapsedMs: number): Sprite }
11 state: MoodState
12 lastSource: string | null
13 total: number
14 budget: BudgetLike | null
15 cacheSecondsLeft: number | null
16 helpersRunning: number
17 reviewerRunning: boolean
18 tiredMs: number
19 sleepyMs: number
20}
21
22export type TickResult = {
23 mood: MoodId
24 since: number
25 /** True when the mood differs from the one in `state`. */
26 moodChanged: boolean
27 /** The frame to draw, only when its image differs from `lastSource`. */
28 sprite: Sprite | null
29}
30
31export function tickOnce(i: TickInputs): TickResult {
32 const m = i.state
33 const mood = moodOf({
34 now: i.now, turnSince: m.turnSince, lastPromptAt: m.lastPromptAt, oopsUntil: m.oopsUntil, happyUntil: m.happyUntil, cheerUntil: m.cheerUntil, cryUntil: m.cryUntil,
35 capReached: isCapReached(i.budget, i.total), budget: budgetFraction(i.budget, i.total), cacheSecondsLeft: i.cacheSecondsLeft,
36 helpersRunning: i.helpersRunning, reviewerRunning: i.reviewerRunning, tiredMs: i.tiredMs, sleepyMs: i.sleepyMs,
37 })
38 const moodChanged = mood !== m.mood
39 const since = moodChanged ? i.now : m.since
40 const s = i.player.sprite(i.persona, mood, i.now - since)
41 return { mood, since, moodChanged, sprite: s.source !== i.lastSource ? s : null }
42}
43
44/** The failure count after one more main-loop event: a failure adds one, an answer or a clean tool call clears it, three make the pet cry (and clear it). */
45export function failTick(count: number, event: 'fail' | 'ok', now: number): { failStreak: number; cryUntil: number | null } {
46 if (event === 'ok') return { failStreak: 0, cryUntil: null }
47 return count + 1 >= CRY_FAILS ? { failStreak: 0, cryUntil: now + CRY_MS } : { failStreak: count + 1, cryUntil: null }
48}
49
50/** A failed tool call in the main loop (not a helper's) upsets the pet. */
51export const isMainLoopError = (e: { agentId?: unknown }, result: unknown): boolean =>
52 e.agentId === undefined && !!result && !!(result as { isError?: boolean }).isError
53
54const withFail = (m: MoodState, event: 'fail' | 'ok', now: number): MoodState => {
55 const f = failTick(m.failStreak, event, now)
56 return { ...m, failStreak: f.failStreak, ...(f.cryUntil !== null ? { cryUntil: f.cryUntil } : {}) }
57}
58
59/** After a tool call: a main-loop failure upsets the pet (and counts toward crying), a clean main-loop call clears the count; a helper's changes nothing. */
60export function onToolResult(m: MoodState, e: { agentId?: unknown }, result: unknown, now: number): MoodState {
61 if (e.agentId !== undefined) return m
62 if (!isMainLoopError(e, result)) return withFail(m, 'ok', now)
63 return { ...withFail(m, 'fail', now), oopsUntil: now + OOPS_MS }
64}
65
66/** After a main-loop turn ends: no turn running; an error upsets (and counts), an answer pleases (and clears the count). */
67export function onTurnEnd(m: MoodState, reason: string, now: number): MoodState {
68 const ended = { ...m, turnSince: null }
69 if (reason === 'error') return { ...withFail(ended, 'fail', now), oopsUntil: now + OOPS_MS }
70 if (reason === 'answer') return { ...withFail(ended, 'ok', now), happyUntil: now + HAPPY_MS }
71 return ended
72}
73
74/** At the first main-loop turn of a day: a milestone cheers, a broken streak cries. */
75export function onTurnStartStreak(m: MoodState, r: { cheer: boolean; broken: boolean }, now: number): MoodState {
76 return { ...m, ...(r.cheer ? { cheerUntil: now + CHEER_MS } : {}), ...(r.broken ? { cryUntil: now + CRY_MS } : {}) }
77}
78hooks/behavior/laptop.ts 43 lines1// The shared laptop every pet works on: the back of a small gray laptop faces the
2// viewer, the pet's head peeks over the lid and two paws tap at the sides.
3// Gallery-safe: only `import type`.
4import type { Frame, Persona } from './types'
5
6export const LAPTOP_PALETTE = { '4': '#8b91a8', '6': '#cfe8ff', '7': '#3f4459' } as const
7
8const W = 16
9const LID = 7
10const line = (fill: string, from: number, to: number): string => '.'.repeat(from) + fill.repeat(to - from + 1) + '.'.repeat(W - 1 - to)
11const put = (rows: string[], x: number, y: number, ch: string): string[] =>
12 rows.map((r, yy) => (yy === y && x >= 0 && x < W ? r.slice(0, x) + ch + r.slice(x + 1) : r))
13
14/** Six typing frames over a pet's head: rows 0-6 from `base`, laptop on rows 7-12. */
15export function laptopFrames(base: Frame, paw: string): Frame[] {
16 return [0, 1, 2, 3, 4, 5].map(n => {
17 let r: string[] = base.slice(0, LID)
18 for (let y = LID; y < 12; y++) r.push(line('4', 3, 12))
19 r.push(line('7', 2, 13))
20 if (n % 3 !== 2) r = put(put(r, 7, 9, '6'), 8, 9, '6')
21 const leftUp = n % 2 === 0
22 r = put(r, 2, leftUp ? 10 : 11, paw)
23 r = put(r, 13, leftUp ? 11 : 10, paw)
24 return r
25 })
26}
27
28/** The persona as it plays: working is typing on the laptop, the signature move moves to idle. */
29export function withLaptop(p: Persona): Persona {
30 const base = p.moods.idle.frames[0] as Frame
31 const { working } = p.moods
32 return {
33 ...p,
34 palette: { ...p.palette, ...LAPTOP_PALETTE },
35 moods: {
36 ...p.moods,
37 idle: { frames: working.frames, fps: Math.max(2, Math.round(working.fps / 2)) },
38 working: { frames: laptopFrames(base, p.laptopPaw), fps: 6 },
39 tired: { frames: laptopFrames(base, p.laptopPaw), fps: 3 },
40 },
41 }
42}
43