SLOPSHOPPER

glitch-pets

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.

newguardcommandtoastprompttimer
v0.1.2MITupdated 2026-10-14Sri-Ln/glitch/plugins/glitch-pets
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · glitch-pets
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /glitch-pets ⎿ glitch-pets: Pets: screenhead, blob, pixel, dot, fork, bud (current) · /glitch pet <name> | off ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Glitch

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.

PluginWhat it does
glitch-coreShared themes and the /glitch command.
glitch-hudThe band above the prompt: turn, speed, cost and budget, cache countdown with keep-warm, context, git. Depends on glitch-core.
glitch-petsA pixel pet at the end of the HUD row, with moods, short remarks and a daily streak. Depends on glitch-core and glitch-hud.

The HUD

One line above the prompt. In the terminal it is text with theme colors and icons:

PartExampleSource
turn#12count of main-session turns
speed⚡ 84 tok/soutput 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 todaythe 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 ↑1branch, 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.

Budget guard

Set a cap per session (budgetSession), per local day (budgetDaily), or both. 0 turns a cap off.

  • 75% of a cap: the cost turns amber.
  • 90%: red, plus a toast.
  • 100%: toast plus a notification.

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.

Cache keeper

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).

  • Warnings. At each warning mark, once per answer, while idle, you get a notification titled <project> — Glitch: "Prompt cache expires in N min".
  • Notification chain. With 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).
  • Keep-warm. ↻ 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).
  • Cost. One ping is a cache read of the whole context: about $0.04 at 200k tokens of context on Opus 5.5, versus about $1.60 to rebuild an expired cache.
  • autoWarm (off by default). At the last warning mark, ping once if you submitted a prompt within autoWarmMaxIdle minutes; never twice in a row without a prompt from you in between.

glitch-pets

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.

PetKindTemperamentSignature move (idle)
Screenheadscreen botEarnest, literal and a little robotic; reports facts, proud of tidy output.Types; code scrolls on its screen face.
Byte blobblobBubbly and easily startled; squeaks with joy, wobbles when worried.Bounces: squash, stretch and a glitch slice.
PixelcatDry and unimpressed, secretly proud; says little, means it.Swishes its tail and sheds pixels.
DotladybugTidy, gentle and encouraging; notices the small things.Flutters her wings on a daisy.
ForkoctopusThe organised multitasker; a born lead who loves a plan.Taps four keys with four arms.
BuddogEager 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.

SettingDefaultMeaning
petbudyour pet: screenhead, blob, pixel, dot, fork, bud or off; /glitch pet <name> overrides it
voicetrueone-line remarks as toasts
voiceEveryMinutes3at most one remark per this many minutes
tiredMinutes10a turn this long makes the pet tired
sleepyMinutes10this long without a prompt makes the pet sleepy
/plugin install glitch-pets@glitch

Themes

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.

Commands

/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 ….

CommandOwnerDoes
/glitchcorehelp card: theme and the subcommands each plugin answers
`/glitch theme [name\default]`corelist themes, or switch and persist
`/glitch-pets [name\off]`petslist pets, switch and persist, or turn the pet off
`/glitch pet [name\off]`petsthe same, through the core command
/glitch hudhudshow or hide the band
/glitch hud parts <list>hudparts and order for this session, as a comma list, e.g. cost,cache,context or cost, cache
/glitch hud cost <list>hudcost figures and order for this session, e.g. session,today
`/glitch budget [+N \N \off \on]`hudshow, raise, set, disable or re-enable this session's caps
/glitch warmhudkeep-warm ping now
/glitch demo cache <min>hudpreview 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>hudpreview 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.

Settings

Set them in /config, or in ~/.claude/settings.json under pluginConfigs. Invalid values fall back to the default and toast once.

glitch-core

SettingDefaultMeaning
themeglitchdefault theme: glitch, neon, mono, sunset, matrix, paper
customemptyJSON object of theme tokens laid over the theme

glitch-hud

SettingDefaultMeaning
partsturn,speed,cost,cache,context,gitwhich parts show, in order
costShowsreply,session,todaywhich cost figures show, in order
budgetSession0USD cap per session; 0 is off
budgetDaily0USD cap per local day, across sessions; 0 is off
budgetBlocksAgentsfalserefuse new subagents at a cap
cacheTtlMinutes60prompt cache lifetime: 60 (most plans) or 5
cacheWarnMinutesemptyminutes-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
notifywindowswindows (native toast), toast (inside Claude Code) or off (no cache or cap notifications; budget toasts still show)
autoWarmfalsekeep the cache warm by itself at the last warning mark
autoWarmMaxIdle120minutes without a prompt from you after which autoWarm stops

Install

/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.

Notes

Mods use Claude Code's early-access function-hooks API; tested on Claude Code 2.1.296.

License

MIT, see LICENSE.

Source 19 files
hooks/register.tsx 213 lines
1import { 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}
213
hooks/behavior/contract.ts 44 lines
1// 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}
44
hooks/behavior/moods.ts 60 lines
1// 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}
60
hooks/behavior/player.ts 47 lines
1// 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}
47
hooks/behavior/streak.ts 39 lines
1// 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}
39
hooks/behavior/types.ts 45 lines
1// 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'
45
hooks/behavior/voice.ts 19 lines
1// 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}
19
hooks/commands.ts 29 lines
1// 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}
29
hooks/config.ts 57 lines
1// 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}
57
hooks/personas/index.ts 29 lines
1// 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}
29
hooks/tick.ts 78 lines
1// 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}
78
hooks/behavior/laptop.ts 43 lines
1// 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