A pixel Luffy above the Claude Code prompt who acts out each tool call in five gears, an HP/MP/ST HUD for context, rate limits, and prompt cache, a shield that…

A pixel Luffy lives above your Claude Code prompt and acts out every tool call, shifting through five gears as Claude reads, runs, edits, fails and wins. Below the prompt, a game HUD shows your context window, 5-hour and weekly rate limits, and prompt cache, so you always know how much is left. Turn on its shield, and in bypass mode it stops destructive commands until you say so; failing tests summon a bug boss to beat.
<img src="docs/images/demo.gif" alt="oxen-pet in Claude Code: a pixel Luffy reads, searches, fetches, edits and runs tests while the HP, MP and ST bars below the prompt track context, rate limits and prompt cache" width="860">
Install · The pet · The HUD · The shield · /pet · Make it yours · Settings · Security · FAQ · oxen-meter
Read, sweeps a magnifier on Grep, types on Bash, spins a globe on WebFetch, and cheers when a turn ends.empty ~1h20m before the 5-hour limit runs out.rm -rf, git push --force, git reset --hard, DROP TABLE and other destructive commands run unasked, the pet raises a shield and asks you, with how many files each target holds. It is off by default./pet opens a pane with tool calls, files touched, test runs, and your burn rate.The same marketplace has a second plugin, with no pet. oxen-meter shows the prompt cache of every session, subagent and model while it runs, asks before a cold resume sends a whole context to the model again, counts the quota each session used, and exports anonymized numbers for a team report. A command-line companion does the same for Codex CLI and Devin CLI, from their own logs and hooks, so one report covers all three.
<img src="docs/images/meter-pane.png" alt="The oxen-meter pane: the cache hit rate, token counts and TTLs of the session, the main thread warm for 53 more minutes, and a subagent that sat 7 minutes shown cold in red" width="860">
claude plugin install oxen-meter@oxen-pet
Then type /meter. The picture is from a scripted demo; the oxen-meter guide has real captures from a live machine beside it: the pane, the cold resume question, the Codex and Devin hooks, and a month's report. It also covers setting up Codex and Devin, reading the report, the team report, and what it records.
You need Claude Code v2.1.287 or later (claude --version).
claude plugin marketplace add thanhnhoncntt/oxen-pet
claude plugin install oxen-pet@oxen-pet
Start a new session, or run /reload-plugins in an open one. Luffy appears above the prompt.
To uninstall, run claude plugin uninstall oxen-pet@oxen-pet.
| Gear | When | Luffy |
|---|---|---|
| Gear 1 | Idle, sleeping, thinking, reading, searching | Straw hat, red vest, yellow sash; gnaws on meat while he reads. "Gonna be King of the Pirates!" |
| Gear 2 | Bash, running, jumping | Pink steam; a flaming Red Hawk punch on every command. "Gear Second! $ npm test" |
| Gear 3 | Editing | A giant Haki fist: Elephant Gun. "Gomu Gomu no Elephant Gun!" |
| Gear 4 | A failed call, the shield up | Boundman, and a Kong Gun when a call fails. "Not on my ship!" |
| Gear 5 | A turn ends, a boss falls | Nika: white cloud hair, clouds curling behind his back, the drums of liberation. "Shishishi! Freedom!" |
He calls the web on a Den Den Mushi ("puru puru puru…"), throws a Gatling and sends straw-hat minis out as subagents ("Zoro, don't get lost!"), and runs over the sea under the Jolly Roger.
| When | The pet |
|---|---|
| Claude is idle | Breathes, blinks, and looks around. Falls asleep after the Sleep after time, a minute by default. |
| A turn starts | Jumps |
| A tool call ends | Runs back and forth for 4 seconds |
| Claude thinks longer | Looks around, with ? and dots |
Read | Reads a book |
Grep, Glob | Sweeps a magnifier |
Edit, MultiEdit, Write, NotebookEdit, TodoWrite | Writes with a pen |
Bash, and any tool not listed | Types in a small terminal |
WebFetch, WebSearch | Spins a globe |
| A subagent starts | Smiles. A mini joins the trail behind the pet until that subagent finishes. |
| A tool call fails | x x eyes and a sweat drop |
| A turn ends | Cheers |
| A destructive command waits for you | Holds up a shield (The shield) |
| A test run fails | A bug boss walks in at the right, with a pip per failed run. The next passing run defeats it, and the pet cheers. |
| The context runs low | Says so in red, and a toast suggests /compact or a hand-off |
The pet's face follows the HUD too: it looks worried as the context fills up, and tired when a rate limit runs low. With Name files and commands on, the status line also names the target, such as reading app.ts or $ npm test. It is off by default.
<img src="docs/images/hud.png" alt="The oxen-pet HUD in one line: HP 78% with the prompt cache warm for an hour, MP 88% resetting in 3h39m, ST 81% resetting in 3d0h" width="819">
One line below the prompt holds up to three bars. Each shows what is left, not what is used.
| Bar | Tracks | Beside the reading |
|---|---|---|
| ♥ HP | The context window left | cache 52m: how long the prompt cache stays warm after the last turn, then cache cold. ⚠ HP and /compact under 20 %. |
| ✦ MP | The 5-hour rate limit left | The time to its reset, and how far ahead of an even pace you are (15% spare) or behind (5% over). empty ~1h20m in red when the burn so far would empty it before the reset. |
| ◆ ST | The 7-day (weekly) rate limit left | The time to its reset, and spare or over as for MP. |
Reading the pace mark. The light column on the MP and ST bars is where the bar would be if the limit were used evenly through its window. Fill to the right of the mark means you are using less than the pace and can push harder. Fill to the left means you are burning faster than the window allows.
The line shows each reading and one short detail: the cache for HP, the time to reset for MP and ST, or a warning in their place. For every detail in the table above, spare and over included, set HUD layout to stacked: a framed window with one bar per line. A terminal too narrow for the line (about 90 columns) gets the stacked window. One narrower than the window (64 columns), such as a pane split beside other agents, gets one bar per line with no window, the bars shorter and the text cut at the edge; under 16 columns the HUD hides.
HP turns yellow at 50 % or less and red at 25 % or less. As it drops under 20 %, a toast suggests /compact, or handing off to a fresh session, once until HP climbs back to 30 %. MP and ST turn red under 15 %, and show on Pro and Max plans once a response has reported its limit.
The shield is off by default: in bypass mode it asked about every destructive command, which is more than most people running bypass mode want. Turn Shield on in /plugin configure oxen-pet@oxen-pet for a second check.
Bypass mode and auto mode are fast, until Claude runs the wrong rm -rf. The shield steps in only when Claude Code would run a Bash command without asking you, and only for commands that destroy work:
oxen-pet shield:
rm -rf build distdeletes files and folders for good. build: 132 files · dist: 2000+ files. Run it?
rm, git push --force, git reset --hard, git clean -f, git checkout -- ., git restore, git branch -D, git stash drop/clear, DROP/TRUNCATE TABLE, terraform destroy, kubectl delete, docker system prune, find -delete, dd and mkfs.claude -p, CI), the command is blocked. Leave Shield off for unattended runs.Type /pet to open a pane with what this session did; /pet again closes it.
Session 1h12m · 14 turns
Tools 96 calls: 31 read · 12 search · 18 edit · 33 bash · 2 agent · 3 failed
Files 22 read · 9 edited
Tests 6 runs: 4 passed · 2 failed · 1 boss beaten
Shield 2 asked: 1 blocked · 1 ran
Burn MP 9%/h · context 41% used
File names stay out unless Name files and commands is on.
Ask Claude in any session, or run /oxen-pet:oxen-pet:
"Make my pet an orange cat with a fish instead of the book, and put it on the moon."
Claude can change the pet, its props, minis, status lines, HUD colors and labels, its look in each mode (forms, like Luffy's gears), or add a scene with ground, sky and obstacles the pet leaps over. It writes a preview page (/tmp/oxen-pet-preview-*.html) with every motion and face first, and sets the theme only when you approve. To undo, ask for the default pet back.
Your pets survive updates. Claude saves each pet you make as <name>.theme.json in ~/.claude/oxen-pet/themes/, outside the plugin's install folder, which an update replaces. Drop theme files there yourself, or point Custom folder at a folder you sync.
/pet theme list the built-in pets and yours
/pet theme zoro switch now, no tokens, and keep it for later sessions
Set Theme to make one the pet every session starts with. Built in: luffy (default), slime, duck, and alien (which uses every field). The theme format is in FORMAT.md.
In a session, run /plugin configure oxen-pet@oxen-pet.
| Setting | Default | What it does |
|---|---|---|
| Speed | normal | How quickly the pet runs and animates: slow, normal, or fast. |
| Sleep after (seconds) | 60 | Idle time before the pet falls asleep. 0 keeps it awake. |
| HUD | on | The HP, MP, and ST bars below the prompt. |
| Status line | on | The text beside the pet. |
| Name files and commands | off | The status line names the file, pattern, command, host, or search query a tool works on. |
| Subagent minis | on | A mini behind the pet for each running subagent. |
| Cache timer | 1h | How long the HUD counts the prompt cache warm after a turn: 1h, 5m, or off. |
| Theme | luffy | The pet a session starts with: luffy, slime, duck, alien, or the name of one of your own. |
| Custom folder | empty | The folder of your own <name>.theme.json files. Empty: ~/.claude/oxen-pet/themes. |
| HUD layout | row | row: the three bars in one line with no frame, each with its reading and one short detail; stacked on a terminal under about 90 columns. stacked: a framed window, one bar per line, with every detail. |
| Shield | off | Ask before a destructive Bash command runs unasked. With no one to answer, it is blocked. |
| Bug boss | on | A failed test run brings a bug boss into the band. |
From a shell:
echo '{"speed": "fast", "targets": "true"}' | claude plugin configure oxen-pet@oxen-pet --values-stdin
Settings apply after Claude Code restarts.
oxen-pet runs inside your Claude Code session, so it is built to do as little as possible:
SECURITY-AUDIT.md.rm target with the file system's own listing, never following a symbolic link. It refuses a call only on your Block it, or when no one can answer. If the shield itself fails, Claude Code's own decision stands.oxen-pet-preview*.html files, and refuses .. paths and symbolic links.Install oxen-pet. The MP bar is the 5-hour limit and the ST bar the weekly (7-day) limit, each with the percentage left and the time to its reset. They appear on Pro and Max plans after the first response of a session.
You have 38 points more of the limit left than you would at an even pace. For example, three days before a weekly reset an even pace leaves about 43 %; at 81 % left you are 38 points ahead.
Claude Code caches the conversation so each new message re-reads it cheaply. The cache lapses some time after the last turn: an hour or five minutes, depending on your setup. Set Cache timer to match. Once it reads cache cold, the next message rebuilds the cache and costs more of your limit.
In any terminal with 24-bit color, and in the Desktop app's Code tab, where the pet, its scene, the boss and the HUD draw as SVG. The /pet pane shows on every surface that shows panes. The VS Code chat panel, claude -p, and cloud sessions do not show the band.
Safer with the shield on (it is off by default): a destructive Bash command waits for your answer instead of running. It is a pattern match, not a sandbox, so it can miss a command spelled in a way it does not know (a script that deletes files, say). Keep your work committed, and use Claude Code's own permission rules for anything that must never run.
oxen-pet is a fork of pixel-pet with a security audit and fixes (a guarded preview write, file names hidden by default), plus the pace marks, the MP forecast, the prompt cache timer, the shield, the bug boss, the /pet pane and the desktop HUD. What each version changed is in CHANGELOG.md.
claude plugin marketplace update oxen-pet
claude plugin update oxen-pet@oxen-pet
Installed copies update only when version in plugins/oxen-pet/.claude-plugin/plugin.json changes. What each version changed is in CHANGELOG.md.
.claude-plugin/marketplace.json the repo is a marketplace with two plugins: oxen-pet and oxen-meter
plugins/oxen-pet/ the plugin: a Claude Code mod
.claude-plugin/plugin.json name, version, and settings
hooks/hooks.json points Claude Code at register.tsx
hooks/register.tsx wires Claude Code's events to the modules, and serves the tools
hooks/anim.ts what the pet does on each tick
hooks/pixels.ts draws a frame
hooks/theme.ts reads a theme and makes its frames
hooks/scene.ts lays out and draws a theme's scene
hooks/preview.ts writes the preview page
hooks/previewPath.ts where preview_theme may write
hooks/status.ts the status line
hooks/hud.ts the HP, MP, and ST bars, the pace marks, the cache timer, the low-context alert
hooks/guard.ts the shield: which commands destroy work, and the question it asks
hooks/boss.ts the bug boss: test runs, hits, and its drawing
hooks/stats.ts what the /pet pane counts
hooks/minis.ts a mini per subagent
hooks/settings.ts reads the settings
hooks/*.test.ts the tests, one file per module
types/index.d.ts the mod's state
assets/ luffy (default), slime, duck, alien themes
hooks/custom.ts the custom themes folder and theme names
skills/oxen-pet/ the skill that draws a pet with you, and the pet format
plugins/oxen-meter/ the prompt cache meter: a mod with no band
hooks/register.tsx wires Claude Code's events to the modules
hooks/record.ts step and event records, and the collector
hooks/provider.ts a model's provider and family, and what its tokens weigh
hooks/analyze.ts hit rate, token equivalent, TTLs, gap curves, cold resumes, handoffs, quota, anti-patterns
hooks/sessionFile.ts a session's file: written whole, kept under 3 MiB, emptied when expired
hooks/dataPath.ts where the meter (and its CLI) may write
hooks/resume.ts the cold resume guard: who a message resumes, the risk, what the user is asked
hooks/hookGuard.ts the CLI's guard for Codex and Devin: the risk, the warning, the held-back prompt
hooks/codexLog.ts Codex's rollout files, a line at a time, as records
hooks/devinLog.ts Devin's session nodes as records
hooks/exportFile.ts /meter export: the sessions of the last days, anonymized
hooks/project.ts the project's name, hashed
hooks/report.ts the /meter pane's rows and /meter report
hooks/codex.ts which Bash calls are Codex handoffs or outcomes
hooks/timing.ts how long the meter's own hooks take
hooks/settings.ts reads the settings
README.md for the team: install, use, Codex and Devin, reading the report, what it records
tools/meter/oxen-meter.mjs the CLI for Codex and Devin: import, report, export, setup, hook (ships in the clone)
tools/meter/lib/ its parts: files and guards, imports, hooks, setup
tools/meter/aggregate.mjs builds the team report from exports (developer tool)
tools/preview/build.mjs writes the preview of a theme file
tools/demo/record.mjs records docs/images/demo.gif and hud.png (developer tool, never shipped)
tools/demo/meter.mjs records docs/images/meter-demo.gif and meter-pane.png (the same)
docs/ design spec and plan of the fork, and the README images
Load your working copy for one session with claude --plugin-dir ./plugins/oxen-pet. Before a push, run:
claude plugin validate . --strict
claude plugin validate plugins/oxen-pet --strict
claude plugin test plugins/oxen-pet
claude plugin validate plugins/oxen-meter --strict
claude plugin test plugins/oxen-meter
node --test 'tools/meter/*.test.mjs'
After one --plugin-dir session, npx -p typescript tsc -p plugins/oxen-pet type-checks the mod. node tools/preview/build.mjs [theme file] (Node 22.18+) writes tools/preview/preview.html. node tools/demo/record.mjs (Node 22.18+, Google Chrome and ffmpeg) records the README's GIF from the mod's own modules: no screen capture and no tokens. node tools/demo/meter.mjs does the same for oxen-meter's guide, from the meter's modules; --text prints its key frames without Chrome. Contributor rules are in CLAUDE.md.
Luffy, the Straw Hat Jolly Roger and One Piece are © Eiichiro Oda / Shueisha / Toei Animation. The pixel Luffy here is fan art, drawn for this project; oxen-pet is not affiliated with or endorsed by them. The code is MIT.
MIT. Original work © 2026 halluqinate (pixel-pet); fork changes © 2026 NhonNguyen.
<sub>Keywords: Claude Code plugin, Claude Code mod, Claude Code statusline, Claude Code HUD, bypass permissions guard, rm -rf protection, usage tracker, One Piece Luffy pixel art, Gear 5, rate limit monitor, context window, prompt cache, terminal pet, pixel art, Anthropic Claude.</sub>
hooks/register.tsx 790 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Anim, Mode } from '../types'
5import { TICK_MS, fail, leapClipMs, step } from './anim'
6import { BUILT_IN, DEFAULT_THEME, customDirOf, isThemeName, themeFile, themeList, themeNames } from './custom'
7import { BOSS_W, bossAfter, bossAlt, bossOnScreen, drawBoss, isTestCommand, testOutcome } from './boss'
8import type { Boss } from './boss'
9import { GUARD_OPTIONS, guardLine, guardQuestion, riskOf, sizeOf } from './guard'
10import type { Risk } from './guard'
11import { newStats, noteMp, recordShield, recordTest, recordTool, recordTurn, statsRows } from './stats'
12import { DETAIL_COLOR, HUD_WINDOW_W, ROW_GAP, cacheLeftMin, compactBarW, hudRowWidth, contextAlert, contextAlertText, frameColor, hudFrom, hudRows, mood, windowEdges } from './hud'
13import type { Hud } from './hud'
14import { minisOnScreen, reconcile } from './minis'
15import type { Mini } from './minis'
16import { animate, readTheme, restingFrame } from './theme'
17import { previewPage } from './preview'
18import { previewPathError, previewTargetError } from './previewPath'
19import { readSettings } from './settings'
20import { BODY_W, FACES, HEIGHT, MAX_MINIS, compose, composeFace, crop, encodeCells, encodeSvg, overlay, trailWidth } from './pixels'
21import type { Body } from './pixels'
22import { DESKTOP_BAND_W, GROUND_H, drawBand, layScene, obstacleSpans } from './scene'
23import type { SceneLayout } from './scene'
24import { lineColor, lineWidth, statusLine, targetOf, toolMode } from './status'
25import type { ToolMode } from './status'
26
27const ROWS = 10 // a cell is two pixels tall, so the frames are 20 px high
28const GROUND_ROWS = GROUND_H / 2
29const STATUS_ROOM = 20 // columns kept free beside a running pet for its status line
30const USAGE_EVERY_BEATS = 20
31const AGENTS_EVERY_BEATS = 5
32const SLOW_BEATS: Partial<Record<Mode, number>> = { idle: 2, sleep: 4 } // ticks per redraw while nothing moves fast
33const SVG_PX = 4 // CSS pixels per pet pixel on the desktop
34const HUD_SVG_PX = 6 // CSS pixels per bar pixel on the desktop, so a bar is as tall as its text
35
36const GUARD_HOLD_MS = 2500 // how long the shield stays up after the answer, so it shows even when the dialog hid the band
37const NOTICE_MS = 8000 // how long the pet says the context is almost full
38const NOTICE_COLOR = '#f0506e'
39const BLOCKED = 'The user blocked this command with the oxen-pet shield. Ask them before trying it another way.'
40const UNANSWERED = 'Blocked by the oxen-pet shield: no one answered its question. To let destructive commands run unasked, turn off Shield in /plugin configure oxen-pet@oxen-pet.'
41
42const THEME_KEY = 'theme' // in $.store: the theme set_theme last took
43const CHOSEN_KEY = 'chosen' // in $.store: the theme /pet theme last put on screen, by name
44const OWN_TOOLS = 'mcp__oxen-pet__'
45// Literals, so `claude plugin validate` can read the hooks' matchers.
46const SET_THEME = 'mcp__oxen-pet__set_theme'
47const PREVIEW_THEME = 'mcp__oxen-pet__preview_theme'
48const GET_THEME = 'mcp__oxen-pet__get_theme'
49const PET_COMMAND = 'pet'
50const PANE_ID = 'pet'
51
52const anim = atom({ plugin: 'oxen-pet', key: 'anim' } as const, {
53 mode: 'idle',
54 since: 0,
55 x: 0,
56 dir: 1,
57 tick: 0,
58 target: '',
59 working: false,
60} as Anim)
61
62/** The HUD from fresh usage, or `last` when the usage call fails. */
63async function usageOr($: EngineInterface, now: number, last: Hud | undefined) {
64 try {
65 return hudFrom(await $.session.usage(), now)
66 } catch {
67 return last
68 }
69}
70
71/** The minis after a fresh look at the session's agents, or `last` when the list call fails. */
72async function minisOr($: EngineInterface, now: number, last: Mini[]) {
73 try {
74 return reconcile(last, await $.agent.list(), now)
75 } catch {
76 return last
77 }
78}
79
80/** The stat of `path` with where it lands, or undefined when nothing is there. */
81async function statOf($: EngineInterface, path: string) {
82 try {
83 return await $.fs.stat(path, { resolve: true })
84 } catch {
85 return undefined
86 }
87}
88
89/** Why preview_theme must not write to `path`: its spelling first, then where it leads on disk. */
90async function previewWriteError($: EngineInterface, path: unknown) {
91 const spelled = previewPathError(path)
92 if (spelled !== undefined || typeof path !== 'string') {
93 return spelled
94 }
95 const cut = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
96
97 return previewTargetError(await statOf($, path), await statOf($, path.slice(0, cut + 1)))
98}
99
100/** How many files each of `risk`'s targets holds, undefined where its spelling cannot be sized. */
101async function sizesOf($: EngineInterface, risk: Risk) {
102 const fs = { stat: (path: string) => $.fs.stat(path), list: (path: string) => $.fs.list(path) }
103
104 return Promise.all(risk.targets.map(t => (t.unsized ? undefined : sizeOf(t.path, fs))))
105}
106
107/** What preview_theme and set_theme tell Claude about a theme's pet: the clips and faces made, the resting frame, and readTheme's notes. */
108function themeReport(pet: Body, notes: string[]) {
109 const made = `${Object.keys(pet.clips).join(', ')}, and ${FACES.length} faces`
110 const noted = notes.length > 0 ? `\n\nNotes, to act on or leave as drawn:\n- ${notes.join('\n- ')}` : ''
111
112 return `The mod made every clip and face from the sprite: ${made}.\n\nResting frame (@ is a pupil, * a cheek):\n${restingFrame(pet)}${noted}`
113}
114
115/** The default pet, from the plugin's own file. */
116async function defaultBody($: EngineInterface) {
117 const read = readTheme(JSON.parse(await $.fs.read(`${$.plugin.root}/assets/${DEFAULT_THEME}.json`)))
118 if (read.errors) {
119 throw new Error(`assets/${DEFAULT_THEME}.json: ${read.errors.join(' ')}`)
120 }
121
122 return animate(read.theme)
123}
124
125/**
126 * A theme by name, as its file spells it: the custom folder's `<name>.theme.json` first, so the user's own wins over a
127 * built-in one of the same name, then the plugin's `assets/<name>.json`. Throws when neither is there or it is not JSON.
128 */
129async function namedTheme($: EngineInterface, name: string, dir: string | undefined): Promise<unknown> {
130 if (!isThemeName(name)) {
131 throw new Error(`No theme named ${name}.`)
132 }
133 if (dir !== undefined && (await $.fs.exists(themeFile(dir, name)).catch(() => false))) {
134 return JSON.parse(await $.fs.read(themeFile(dir, name)))
135 }
136 if ((BUILT_IN as readonly string[]).includes(name)) {
137 return JSON.parse(await $.fs.read(`${$.plugin.root}/assets/${name}.json`))
138 }
139 throw new Error(`No theme named ${name}.`)
140}
141
142/**
143 * The pet a session shows: the theme set_theme kept, else the one /pet theme chose, else the Theme setting's, else
144 * the default. One that no longer reads gives way to the next, with a toast.
145 */
146async function keptBody($: EngineInterface, setting: string, customDir: string) {
147 const kept = await $.store.get(THEME_KEY)
148 if (kept !== undefined) {
149 const read = readTheme(kept)
150 if (!read.errors) {
151 return animate(read.theme)
152 }
153 $.ui.toast(`oxen-pet: your theme no longer reads (${read.errors[0]}). Showing ${DEFAULT_THEME}.`)
154 }
155 const chosen = await $.store.get(CHOSEN_KEY)
156 const name = typeof chosen === 'string' ? chosen : setting
157 try {
158 const read = readTheme(await namedTheme($, name, customDirOf($.plugin.root, customDir)))
159 if (read.errors) {
160 throw new Error(read.errors[0])
161 }
162 return animate(read.theme)
163 } catch (err) {
164 $.ui.toast(`oxen-pet: the ${name} theme does not read (${err instanceof Error ? err.message : String(err)}). Showing ${DEFAULT_THEME}.`)
165 }
166
167 return defaultBody($)
168}
169
170export const register: Register = (on, options) => {
171 const settings = readSettings(options)
172 let isWorking = false
173 let lastToolAt = 0
174 let activeTools = 0
175 let activeMode: ToolMode = 'bash'
176 let activeTarget = ''
177 let bodyColumns = 80
178 let beat = 0
179 let showsError = false
180 let body: Body | undefined
181 let hud: Hud | undefined
182 let lastTurnEndAt: number | undefined // when the main thread's last turn ended, which keeps its prompt cache warm
183 let minis: Mini[] = []
184 let previewed: unknown // the last theme preview_theme drew, for set_theme to apply without resending it
185 let layout: SceneLayout | undefined // the scene of `layoutOf` on a band `bandWidth()` wide
186 let layoutOf: Body | undefined
187 let guarding = 0 // risky commands waiting for the user's answer
188 let shield: { result: 'blocked' | 'ran'; until: number } | undefined // the last answer, shown until `until`
189 let boss: Boss | undefined
190 let alertArmed = true // the low-context alert fires once per drop under LOW_HP
191 let notice: { text: string; until: number } | undefined // what the pet says in place of its status line, until `until`
192 let stats = newStats(0)
193
194 // The band leaves the last column free, so a full row never wraps.
195 const bandWidth = () => Math.max(BODY_W, bodyColumns - 1)
196 /** The layout of the pet's scene on the band as wide as it is now, or undefined for a pet with no scene. */
197 const sceneLayout = (pet: Body) => {
198 if (!pet.scene) {
199 return undefined
200 }
201 if (layout?.width !== bandWidth() || layoutOf !== pet) {
202 layout = layScene(pet.scene, bandWidth())
203 layoutOf = pet
204 }
205 return layout
206 }
207
208 on('session.start', async ($, e, next) => {
209 const now = await $.clock.now()
210 await update($, anim, () => ({ mode: 'idle', since: now, x: 0, dir: 1, tick: 0, target: '', working: false }))
211 hud = await usageOr($, now, undefined)
212 lastTurnEndAt = undefined
213 stats = newStats(now)
214 try {
215 await $.tool.register({
216 name: 'preview_theme',
217 description:
218 'Writes the preview of a oxen-pet theme to `path`: an HTML page with every motion, face, status line, and HUD look of its pet, and the pet running through its scene. It does not change what is on screen. `theme` is in the format the `oxen-pet:oxen-pet` skill describes. Returns the resting frame and notes on anything repaired.',
219 inputSchema: {
220 type: 'object',
221 properties: {
222 theme: { type: 'object', description: 'The theme as a JSON object, in the format FORMAT.md documents.' },
223 path: { type: 'string', description: 'Absolute path of the HTML file to write, in an existing folder, named oxen-pet-preview….html, such as /tmp/oxen-pet-preview-cat.html. No `..`, no symbolic link; any other path is refused.' },
224 },
225 required: ['theme', 'path'],
226 },
227 })
228 await $.tool.register({
229 name: 'set_theme',
230 description:
231 'Sets the oxen-pet theme: the pet, its props, minis, status lines, HUD look, and scene, at once, kept for later sessions. `theme` is in the format the `oxen-pet:oxen-pet` skill describes, or null for the default pet. Leave `theme` out to set the last theme preview_theme drew in this session. Returns the resting frame and notes on anything repaired.',
232 inputSchema: {
233 type: 'object',
234 properties: { theme: { type: ['object', 'null'], description: 'The theme as a JSON object, null for the default pet, or left out for the last preview.' } },
235 },
236 })
237 await $.tool.register({
238 name: 'get_theme',
239 description:
240 'Returns the oxen-pet theme on screen: the one set_theme kept, or the one chosen by name (by default Luffy), and where the user\'s own themes folder is. Start a change from it, so set_theme keeps everything the change leaves alone.',
241 inputSchema: { type: 'object', properties: {} },
242 })
243 } catch {
244 // Without the tools the pet still draws; only changing the theme is missing.
245 }
246 try {
247 await $.command.register({ name: PET_COMMAND, description: 'oxen-pet: open or close the session stats pane; /pet theme lists or switches pets', argumentHint: '[theme [name]]', immediate: true })
248 } catch {
249 // Without the command the pet and the HUD still draw; only the stats pane is missing.
250 }
251
252 $.clock.every(TICK_MS, async () => {
253 const t = await $.clock.now()
254 beat += 1
255 if (beat % USAGE_EVERY_BEATS === 0) {
256 hud = await usageOr($, t, hud)
257 if (hud?.mp !== undefined) {
258 stats = noteMp(stats, hud.mp, t)
259 }
260 if (settings.hud && hud) {
261 const alert = contextAlert(alertArmed, hud.hp)
262 alertArmed = alert.armed
263 if (alert.alert) {
264 $.ui.toast(contextAlertText(hud.hp), { timeoutMs: 10000 })
265 notice = { text: 'context almost full: /compact?', until: t + NOTICE_MS }
266 }
267 }
268 $.ui.invalidate('ui.render')
269 }
270 if (settings.minis && beat % AGENTS_EVERY_BEATS === 0) {
271 minis = await minisOr($, t, minis)
272 }
273
274 const trail = trailWidth(minis.length)
275 // A boss stands at the right of the band, so the running pet turns before it.
276 const bossRoom = settings.boss && bossOnScreen(boss, t) ? BOSS_W + 2 : 0
277 const room = Math.max(0, bodyColumns - BODY_W - trail - STATUS_ROOM - bossRoom)
278 const scene = body && sceneLayout(body)
279 const obstacles = scene ? obstacleSpans(scene) : []
280 const isGuarding = guarding > 0 || (shield !== undefined && t < shield.until)
281 await update($, anim, a => {
282 const moved = step(a, { isWorking, activeTools, activeMode, activeTarget, lastToolAt, room, obstacles, trail, guarding: isGuarding }, t, settings)
283 // Minis and the boss move on every tick, so they keep the redraw rate up while the pet idles.
284 const slowBeat = minis.length > 0 || bossRoom > 0 ? undefined : SLOW_BEATS[moved.mode]
285 return slowBeat !== undefined && moved.mode === a.mode && beat % slowBeat !== 0 ? a : moved
286 })
287 })
288
289 return next(e)
290 })
291
292 // A subagent's requests carry its own prompt, so only the main thread's turns keep the session's cache warm.
293 on('turn.complete', async ($, e, next) => {
294 const result = await next(e)
295 if (e.agentId === undefined) {
296 lastTurnEndAt = await $.clock.now()
297 stats = recordTurn(stats)
298 $.ui.invalidate('ui.render')
299 }
300
301 return result
302 })
303
304 // The shield: a destructive command that would run unasked waits for the user's answer, the pet holding up its shield.
305 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
306 const verdict = await next(e)
307 // A query (no tool_use_id) asks what would happen, and must never open a dialog.
308 if (!settings.guard || e.tool_use_id === undefined || verdict.decision === 'deny') {
309 return verdict
310 }
311 let command: string
312 let risk: Risk | undefined
313 try {
314 const given = (e.input as { command?: unknown } | undefined)?.command
315 command = typeof given === 'string' ? given : ''
316 risk = riskOf(command)
317 } catch {
318 return verdict
319 }
320 if (!risk) {
321 return verdict
322 }
323 const question = guardQuestion(command, risk, await sizesOf($, risk).catch(() => []))
324 // Claude Code asks already: its dialog shows what the command deletes.
325 if (verdict.decision === 'ask') {
326 return { ...verdict, reason: question }
327 }
328 guarding += 1
329 try {
330 const answer = await $.ui.ask(question, { header: 'Shield', options: [GUARD_OPTIONS.block, GUARD_OPTIONS.run] }).catch(() => undefined)
331 const ran = answer === GUARD_OPTIONS.run
332 shield = { result: ran ? 'ran' : 'blocked', until: (await $.clock.now()) + GUARD_HOLD_MS }
333 stats = recordShield(stats, shield.result)
334 if (ran) {
335 return { decision: 'allow' as const, reason: 'The user let it run when the oxen-pet shield asked.' }
336 }
337
338 return { decision: 'deny' as const, reason: answer === undefined ? UNANSWERED : BLOCKED }
339 } finally {
340 guarding -= 1
341 }
342 })
343
344 on('tool.call', async ($, e, next) => {
345 // The shield's own question is a call of AskUserQuestion; the pet holds its shield through it.
346 if (e.tool.startsWith(OWN_TOOLS) || (guarding > 0 && e.tool === 'AskUserQuestion')) {
347 return next(e)
348 }
349 const t = await $.clock.now()
350 activeTools += 1
351 activeMode = toolMode(e.tool)
352 const input = e as unknown as Record<string, unknown>
353 activeTarget = settings.targets ? targetOf(e.tool, input) : ''
354 lastToolAt = t
355
356 let result: Awaited<ReturnType<typeof next>>
357 try {
358 result = await next(e)
359 } finally {
360 activeTools = Math.max(0, activeTools - 1)
361 lastToolAt = await $.clock.now()
362 }
363 const failed = 'deny' in result || ('isError' in result && result.isError === true)
364 if (failed) {
365 await update($, anim, a => fail(a, lastToolAt))
366 }
367 stats = recordTool(stats, e.tool, input, failed)
368 const outcome = e.tool === 'Bash' && typeof input.command === 'string' && isTestCommand(input.command) ? testOutcome(result) : undefined
369 if (outcome) {
370 const before = settings.boss ? bossOnScreen(boss, lastToolAt) : undefined
371 boss = settings.boss ? bossAfter(before, outcome, lastToolAt) : undefined
372 const beat = before !== undefined && before.defeatedAt === undefined && boss?.defeatedAt !== undefined
373 stats = recordTest(stats, outcome, beat)
374 // The pet cheers over a defeated boss.
375 if (beat) {
376 const at = lastToolAt
377 await update($, anim, a => ({ ...a, mode: 'cheer' as const, since: at }))
378 }
379 $.ui.invalidate('ui.render')
380 }
381
382 return result
383 })
384
385 on('tool.call', { tool: PREVIEW_THEME }, async ($, e) => {
386 const { theme, path } = e as unknown as { theme?: unknown; path?: unknown }
387 const read = readTheme(theme)
388 if (read.errors) {
389 return { deny: `No preview was written: ${read.errors.join(' ')}` }
390 }
391 const badPath = await previewWriteError($, path)
392 if (badPath !== undefined || typeof path !== 'string') {
393 return { deny: `No preview was written: ${badPath}` }
394 }
395 const preview = animate(read.theme)
396 await $.fs.write(path, previewPage(preview, read.notes))
397 previewed = theme
398
399 return { result: `Wrote the preview of ${read.theme.name} to ${path}. What is on screen has not changed.\n\n${themeReport(preview, read.notes)}` }
400 })
401
402 on('tool.call', { tool: GET_THEME }, async $ => {
403 const dir = customDirOf($.plugin.root, settings.customDir)
404 const folder = dir === undefined ? '' : `\n\nThe user's own themes go in ${dir} as <name>.theme.json, which updates never touch; /pet theme <name> puts one on screen.`
405 const kept = await $.store.get(THEME_KEY)
406 if (kept !== undefined) {
407 return { result: `The theme set_theme kept:\n\n${JSON.stringify(kept, null, 2)}${folder}` }
408 }
409 const chosen = await $.store.get(CHOSEN_KEY)
410 const name = typeof chosen === 'string' ? chosen : settings.theme
411 const theme = await namedTheme($, name, dir).catch(() => namedTheme($, DEFAULT_THEME, undefined))
412
413 return { result: `No theme is kept, so ${name} is on screen. Its theme:\n\n${JSON.stringify(theme, null, 2)}${folder}` }
414 })
415
416 on('tool.call', { tool: SET_THEME }, async ($, e) => {
417 const given = (e as unknown as { theme?: unknown }).theme
418 if (given === undefined && previewed === undefined) {
419 return { deny: 'The theme was not set: no preview_theme call in this session to apply. Pass `theme`.' }
420 }
421 const value = given === undefined ? previewed : given
422 if (value === null) {
423 await $.store.delete(THEME_KEY)
424 await $.store.delete(CHOSEN_KEY)
425 body = await keptBody($, settings.theme, settings.customDir)
426 $.ui.invalidate('ui.render')
427
428 return { result: 'The default pet is back, for this session and later ones.' }
429 }
430 const read = readTheme(value)
431 if (read.errors) {
432 return { deny: `The theme was not set: ${read.errors.join(' ')}` }
433 }
434 await $.store.set(THEME_KEY, value)
435 await $.store.delete(CHOSEN_KEY)
436 body = animate(read.theme)
437 $.ui.invalidate('ui.render')
438
439 return { result: `The ${read.theme.name} theme is on screen now, for this session and later ones.\n\n${themeReport(body, read.notes)}` }
440 })
441
442 // /pet opens the pane with what the session did, and closes it when it is open.
443 on('command.run', { command: PET_COMMAND }, async ($, e) => {
444 // /pet theme lists the pets; /pet theme <name> puts one on screen, for this session and later ones.
445 const [sub, name] = e.args.trim().split(/\s+/)
446 if (sub === 'theme') {
447 const dir = customDirOf($.plugin.root, settings.customDir)
448 if (name === undefined) {
449 const own = dir === undefined ? [] : themeNames(await $.fs.list(dir).catch(() => []))
450 const kept = await $.store.get(THEME_KEY)
451 const chosen = await $.store.get(CHOSEN_KEY)
452 return { text: themeList(own, kept !== undefined ? undefined : typeof chosen === 'string' ? chosen : settings.theme, dir) }
453 }
454 let read: ReturnType<typeof readTheme>
455 try {
456 read = readTheme(await namedTheme($, name, dir))
457 } catch (err) {
458 return { text: `${err instanceof Error ? err.message : String(err)} Run /pet theme for the list.` }
459 }
460 if (read.errors) {
461 return { text: `The ${name} theme does not read: ${read.errors.join(' ')}` }
462 }
463 await $.store.set(CHOSEN_KEY, name)
464 await $.store.delete(THEME_KEY)
465 body = animate(read.theme)
466 $.ui.invalidate('ui.render')
467 return { text: `${read.theme.name} is on screen now, and stays for later sessions.` }
468 }
469 if ((await $.ui.panes()).some(p => p.id === PANE_ID)) {
470 await $.ui.close({ id: PANE_ID })
471 return {}
472 }
473 const opened = await $.ui.open({ id: PANE_ID, title: 'oxen-pet', closeOnEscape: true, rows: 10 })
474 if (opened.isPlaced) {
475 return {}
476 }
477 // Where no pane shows, the stats print as the command's output.
478 const rows = statsRows(stats, await $.clock.now(), hud, settings.targets)
479
480 return { text: rows.map(r => `${r.label}: ${r.value}`).join('\n') }
481 })
482
483 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
484 if (e.requestId !== PANE_ID) {
485 return next(e)
486 }
487 if (!body) {
488 body = await keptBody($, settings.theme, settings.customDir)
489 }
490 const now = await $.clock.now()
491 const rows = statsRows(stats, now, hud, settings.targets)
492 const labelW = Math.max(...rows.map(r => r.label.length))
493 const face = composeFace(body, stats.bosses > 0 ? 'star' : 'happy', now)
494 const frame = frameColor(body.look.hud)
495 if (e.surface === 'terminal') {
496 const { Box, Raster, Text } = $.ui.resolve(e)
497
498 return (
499 <Box gap={2}>
500 <Raster key="face" columns={BODY_W} rows={ROWS} cells={encodeCells(face)} />
501 <Box flexDirection="column">
502 {rows.map(r => (
503 <Box key={r.label}>
504 <Text color={frame} bold>{`${r.label.padEnd(labelW)} `}</Text>
505 <Text color={DETAIL_COLOR}>{r.value}</Text>
506 </Box>
507 ))}
508 </Box>
509 </Box>
510 )
511 }
512 const { Box, Svg, Text } = $.ui.resolve(e)
513
514 return (
515 <Box gap={2}>
516 <Svg key="face" source={encodeSvg(face)} alt={`${body.name}, happy`} width={BODY_W * SVG_PX} height={HEIGHT * SVG_PX} />
517 <Box flexDirection="column">
518 {rows.map(r => (
519 <Box key={r.label}>
520 <Text color={frame} bold>{`${r.label.padEnd(labelW)} `}</Text>
521 <Text color={DETAIL_COLOR}>{r.value}</Text>
522 </Box>
523 ))}
524 </Box>
525 </Box>
526 )
527 })
528
529 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
530 if (!settings.hud || !hud || e.surface !== 'terminal') {
531 return next(e)
532 }
533 if (!body) {
534 body = await keptBody($, settings.theme, settings.customDir)
535 }
536 const cacheMin = cacheLeftMin(lastTurnEndAt, settings.cacheTtlMin, await $.clock.now())
537 const rows = hudRows({ ...hud, cacheMin }, body.look.hud)
538 if (rows.length === 0) {
539 return next(e)
540 }
541 const { Box, Raster, Text } = $.ui.resolve(e)
542 const frame = frameColor(body.look.hud)
543 // Laid in a row when the user asked and the terminal has the room, else stacked.
544 const inRow = settings.hudRow ? hudRows({ ...hud, cacheMin }, body.look.hud, true) : []
545 const rowW = hudRowWidth(inRow)
546 // A row is one line with no window, the bars apart by gaps in the frame's color.
547 if (inRow.length > 0 && (e.viewport === undefined || e.viewport.columns >= rowW + 2)) {
548 return (
549 <Box flexDirection="column">
550 {await next(e)}
551 <Box key="row" marginLeft={1}>
552 {inRow.map((r, k) => (
553 <Box key={r.key}>
554 {k > 0 && <Text color={frame}>{ROW_GAP}</Text>}
555 <Text color={r.color}>{r.label} </Text>
556 <Raster key={`bar-${r.key}`} columns={r.bar.w} rows={1} cells={r.cells} />
557 {r.parts.map((p, i) => (
558 <Text key={String(i)} color={p.color} bold={p.bold}>
559 {p.text}
560 </Text>
561 ))}
562 </Box>
563 ))}
564 </Box>
565 </Box>
566 )
567 }
568 // Narrower than the window, as in a pane split beside others: one bar a line, no window, the text cut at the edge.
569 if (e.viewport !== undefined && e.viewport.columns < HUD_WINDOW_W + 1) {
570 const barW = compactBarW(e.viewport.columns)
571 if (barW === undefined) {
572 return next(e)
573 }
574 const compact = hudRows({ ...hud, cacheMin }, body.look.hud, true, barW)
575
576 return (
577 <Box flexDirection="column">
578 {await next(e)}
579 <Box key="compact" flexDirection="column" marginLeft={1} width={e.viewport.columns - 2}>
580 {compact.map(r => (
581 <Box key={r.key}>
582 {/* The label, the bar and the reading keep their width; only the detail gives way at the edge. */}
583 <Box key="fixed" flexShrink={0}>
584 <Text color={r.color}>{r.label} </Text>
585 <Raster key={`bar-${r.key}`} columns={r.bar.w} rows={1} cells={r.cells} />
586 <Text color={r.parts[0]?.color} bold>
587 {r.parts[0]?.text ?? ''}
588 </Text>
589 </Box>
590 {r.parts.length > 1 && (
591 <Text color={r.parts[1]?.color} wrap="truncate">
592 {r.parts
593 .slice(1)
594 .map(p => p.text)
595 .join('')}
596 </Text>
597 )}
598 </Box>
599 ))}
600 </Box>
601 </Box>
602 )
603 }
604 const edges = windowEdges(HUD_WINDOW_W)
605
606 return (
607 <Box flexDirection="column">
608 {await next(e)}
609 <Box flexDirection="column" marginLeft={1}>
610 <Text color={frame}>{edges.top}</Text>
611 {rows.map(r => (
612 <Box key={r.key}>
613 <Text color={frame}>{edges.side}</Text>
614 <Box width={HUD_WINDOW_W - 2} paddingLeft={1}>
615 <Text color={r.color}>{r.label} </Text>
616 <Raster key={`bar-${r.key}`} columns={r.bar.w} rows={1} cells={r.cells} />
617 {r.parts.map((p, i) => (
618 <Text key={String(i)} color={p.color} bold={p.bold} wrap="truncate">
619 {p.text}
620 </Text>
621 ))}
622 </Box>
623 <Text color={frame}>{edges.side}</Text>
624 </Box>
625 ))}
626 <Text color={frame}>{edges.bottom}</Text>
627 </Box>
628 </Box>
629 )
630 })
631
632 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
633 try {
634 if (e.props.hasSurvey) {
635 return next(e)
636 }
637 isWorking = e.props.isWorking
638 bodyColumns = e.props.bodyColumns
639
640 if (!body) {
641 body = await keptBody($, settings.theme, settings.customDir)
642 }
643 const a = await read($, anim)
644 const now = await $.clock.now()
645 const elapsed = now - a.since
646 const views = minisOnScreen(minis, now)
647 // A leap is the run mode playing the jump clip, slowed, while the pet travels.
648 const drawn = a.leap ? { mode: 'jump' as const, ms: leapClipMs((now - a.leap.since) * settings.pace) } : { mode: a.mode, ms: elapsed * settings.pace }
649 const picture = compose(body, drawn.mode, drawn.ms, a.dir, hud ? mood(hud) : 'ok', views)
650 const extra = views.length > MAX_MINIS ? ` (+${views.length - MAX_MINIS} minis)` : ''
651 const shielded = shield !== undefined && guarding === 0 && now < shield.until ? guardLine(shield.result) : undefined
652 const noticed = notice !== undefined && now < notice.until ? notice.text : undefined
653 const line = settings.statusLine ? (shielded ?? noticed ?? statusLine(a.mode, a.since, elapsed, a.target, body.look.lines[a.mode])) + extra : ''
654 const tint = noticed && !shielded ? NOTICE_COLOR : lineColor(a.mode, body.look.lineColors)
655 if (showsError) {
656 showsError = false
657 $.ui.status(undefined)
658 }
659
660 const scene = sceneLayout(body)
661 const shownBoss = settings.boss ? bossOnScreen(boss, now) : undefined
662 const bossArt = shownBoss && drawBoss(shownBoss, now)
663 const bossW = bossArt ? BOSS_W + 1 : 0
664 if (e.surface === 'terminal' && scene && body.scene) {
665 const { Box, Raster, Text } = $.ui.resolve(e)
666 const width = scene.width
667 const textW = line ? lineWidth(line) + 3 : 0
668 const left = Math.max(0, Math.min(Math.round(a.x), width - picture.w - textW - bossW))
669 const band = drawBand(body, body.scene, scene, picture, left, now)
670 if (bossArt) {
671 overlay(band, bossArt, width - bossW)
672 }
673 const cells = (x: number, y: number, w: number, h: number) => encodeCells(crop(band, x, y, w, h))
674 // The status line cuts a hole in the band; the band shows above, below, and right of it.
675 const textAt = left + picture.w
676 const shown = Math.min(textW, width - textAt)
677 const rest = width - textAt - shown
678
679 return (
680 <Box flexDirection="column" height={ROWS + GROUND_ROWS}>
681 <Box height={ROWS}>
682 <Raster key="pet" columns={textAt} rows={ROWS} cells={cells(0, 0, textAt, HEIGHT)} />
683 {shown > 0 && (
684 <Box key="line" flexDirection="column" width={shown}>
685 <Raster key="above" columns={shown} rows={ROWS - 2} cells={cells(textAt, 0, shown, HEIGHT - 4)} />
686 <Text color={tint} bold wrap="truncate">
687 {` › ${line}`}
688 </Text>
689 <Raster key="below" columns={shown} rows={1} cells={cells(textAt, HEIGHT - 2, shown, 2)} />
690 </Box>
691 )}
692 {rest > 0 && <Raster key="rest" columns={rest} rows={ROWS} cells={cells(textAt + shown, 0, rest, HEIGHT)} />}
693 </Box>
694 <Raster key="ground" columns={width} rows={GROUND_ROWS} cells={cells(0, HEIGHT, width, GROUND_H)} />
695 </Box>
696 )
697 }
698 if (e.surface === 'terminal') {
699 const { Box, Raster, Text } = $.ui.resolve(e)
700 const room = Math.max(0, bodyColumns - picture.w - line.length - 4 - bossW)
701
702 return (
703 <Box height={ROWS} width={bandWidth()}>
704 <Box marginLeft={Math.min(Math.round(a.x), room)} alignItems="flex-end">
705 <Raster key="pet" columns={picture.w} rows={ROWS} cells={encodeCells(picture)} />
706 {line && (
707 <Box marginBottom={1} marginLeft={1}>
708 <Text color={tint} bold>
709 › {line}
710 </Text>
711 </Box>
712 )}
713 </Box>
714 {bossArt && <Box flexGrow={1} />}
715 {bossArt && <Raster key="boss" columns={BOSS_W} rows={ROWS} cells={encodeCells(bossArt)} />}
716 </Box>
717 )
718 }
719 if (e.surface === 'desktop') {
720 // The desktop has no Raster: the pet, its scene and the HUD's bars draw as SVG, and the HUD sits in the band,
721 // since the desktop's hint line under the prompt is its own.
722 const { Box, Svg, Text } = $.ui.resolve(e)
723 const rows = settings.hud && hud ? hudRows({ ...hud, cacheMin: cacheLeftMin(lastTurnEndAt, settings.cacheTtlMin, now) }, body.look.hud, settings.hudRow) : []
724 const hudFrame = frameColor(body.look.hud)
725 const hudBox = rows.length > 0 && (
726 <Box key="hud" flexDirection={settings.hudRow ? 'row' : 'column'} alignSelf="flex-start" {...(settings.hudRow ? {} : { borderStyle: 'round', borderColor: hudFrame, paddingX: 1 })}>
727 {rows.map((r, k) => (
728 <Box key={r.key} alignItems="center">
729 {settings.hudRow && k > 0 && <Text color={hudFrame}>{ROW_GAP}</Text>}
730 <Text color={r.color}>{r.label} </Text>
731 <Svg source={encodeSvg(r.bar)} alt={`${r.key.toUpperCase()} bar, ${r.pct}% left`} width={r.bar.w * HUD_SVG_PX} height={r.bar.h * HUD_SVG_PX} />
732 {r.parts.map((p, i) => (
733 <Text key={String(i)} color={p.color} bold={p.bold}>
734 {p.text}
735 </Text>
736 ))}
737 </Box>
738 ))}
739 </Box>
740 )
741 if (scene && body.scene) {
742 const width = Math.min(scene.width, DESKTOP_BAND_W)
743 const left = Math.max(0, Math.min(Math.round(a.x), width - picture.w - bossW))
744 const band = crop(drawBand(body, body.scene, scene, picture, left, now), 0, 0, width, HEIGHT + GROUND_H)
745 if (bossArt) {
746 overlay(band, bossArt, width - bossW)
747 }
748
749 return (
750 <Box flexDirection="column">
751 <Svg source={encodeSvg(band)} alt={`${body.name}, ${a.mode}, in its scene`} width={width * SVG_PX} height={band.h * SVG_PX} />
752 {line && (
753 <Text color={tint} bold>
754 › {line}
755 </Text>
756 )}
757 {hudBox}
758 </Box>
759 )
760 }
761
762 return (
763 <Box flexDirection="column">
764 <Box alignItems="flex-end">
765 <Box marginLeft={Math.round(a.x)}>
766 <Svg source={encodeSvg(picture)} alt={`${body.name}, ${a.mode}`} width={picture.w * SVG_PX} height={HEIGHT * SVG_PX} />
767 </Box>
768 {line && (
769 <Text color={tint} bold>
770 {line}
771 </Text>
772 )}
773 {shownBoss && bossArt && <Box flexGrow={1} />}
774 {shownBoss && bossArt && <Svg key="boss" source={encodeSvg(bossArt)} alt={bossAlt(shownBoss)} width={BOSS_W * SVG_PX} height={HEIGHT * SVG_PX} />}
775 </Box>
776 {hudBox}
777 </Box>
778 )
779 }
780
781 return next(e)
782 } catch (err) {
783 showsError = true
784 $.ui.status(`oxen-pet: ${String(err)}`)
785
786 return next(e)
787 }
788 })
789}
790hooks/anim.ts 158 lines1import type { Anim, Leap, Mode } from '../types'
2import { BODY_W, MODES } from './pixels'
3import type { Span } from './scene'
4import { DEFAULTS } from './settings'
5import type { Settings } from './settings'
6import type { ToolMode } from './status'
7
8export const TICK_MS = 100
9const SPEED = 9 // cells per second at normal pace
10const RUN_AFTER_TOOL_MS = 4000
11const JUMP_YIELDS_MS = 400 // a tool call cuts the start-of-turn hop short after this long
12const JUMP_MS = MODES.jump.once as number
13/** A leap plays the jump clip this much slower, so the pet clears its own width while in the air. */
14const LEAP_SLOW = 4 / 3
15export const LEAP_MS = JUMP_MS * LEAP_SLOW
16// The part of the jump clip in which a leaping pet travels: from the frame it has lifted 5 px of 9 to the one it
17// has come down to 6. The pet crouches before it and drops straight down after it.
18const AIR = [3 / 14, 9 / 14] as const
19const LEAP_GAP = 2 // the most columns between the body canvas and an obstacle when the pet takes off
20
21/** What the session is doing, as the hooks last saw it. */
22export type Activity = {
23 isWorking: boolean
24 activeTools: number
25 activeMode: ToolMode // the latest tool call's mode
26 activeTarget: string
27 lastToolAt: number
28 room: number // the furthest column a running pet may reach
29 obstacles: Span[] // the scene's obstacles, in band columns
30 trail: number // the minis' width, drawn behind the pet
31 guarding?: boolean // a risky command waits for the user's answer, or just got it
32}
33
34/** The mode the pet holds while nothing starts or ends. */
35function settle(w: Activity, t: number): Mode {
36 if (!w.isWorking) {
37 return 'idle'
38 }
39 if (w.activeTools > 0) {
40 return w.activeMode
41 }
42
43 return t - w.lastToolAt < RUN_AFTER_TOOL_MS ? 'run' : 'think'
44}
45
46/**
47 * The pet one tick later, at time `t`. A turn starting makes it jump and a turn ending makes it cheer. A mode
48 * with a fixed length (jump, cheer, error) runs out before the pet settles, sooner at a faster pace. Idle turns
49 * to sleep after `sleepAfterMs`. A running pet moves and turns at `room`; at an obstacle it leaps, or turns when
50 * the landing is past the edge. A leap plays out before anything else changes. An `a` saved by another version
51 * of the mod, with a mode this one lacks, starts over idle.
52 */
53export function step(a: Anim, w: Activity, t: number, s: Pick<Settings, 'pace' | 'sleepAfterMs'> = DEFAULTS): Anim {
54 if (a.leap) {
55 const u = (t - a.leap.since) * s.pace
56 // Nothing else starts mid-air: `working` holds, so a turn that ended or began still cheers or jumps on landing.
57 if (u < LEAP_MS) {
58 return { ...a, x: leapX(a.leap, u), tick: a.tick + 1 }
59 }
60 const { leap: _, ...landed } = a
61 a = { ...landed, x: a.leap.to }
62 }
63 let { mode, since, x, dir } = a
64 let leap: Leap | undefined
65 if (!(mode in MODES)) {
66 mode = 'idle'
67 since = t
68 }
69 const length = MODES[mode].once
70 const once = length === undefined ? undefined : length / s.pace
71
72 if (w.guarding) {
73 // The shield goes up at once, whatever the pet was doing, and stays up until the user answers.
74 if (mode !== 'guard') {
75 mode = 'guard'
76 since = t
77 }
78 } else if (a.working && !w.isWorking) {
79 mode = 'cheer'
80 since = t
81 } else if (!a.working && w.isWorking) {
82 mode = 'jump'
83 since = t
84 } else if (once !== undefined) {
85 const yields = mode === 'jump' && w.activeTools > 0 && t - since >= JUMP_YIELDS_MS / s.pace
86 if (t - since >= once || yields) {
87 mode = settle(w, t)
88 since = t
89 }
90 } else {
91 let want = settle(w, t)
92 const canSleep = s.sleepAfterMs > 0
93 if (want === 'idle' && canSleep && (mode === 'sleep' || (mode === 'idle' && t - since >= s.sleepAfterMs))) {
94 want = 'sleep'
95 }
96 if (want !== mode) {
97 mode = want
98 since = t
99 }
100 }
101
102 const ahead = mode === 'run' ? obstacleAhead(x, dir, w) : undefined
103 if (ahead) {
104 const to = landing(ahead, dir, w)
105 if (to >= 0 && to <= w.room) {
106 leap = { since: t, from: x, to }
107 } else {
108 dir = dir === 1 ? -1 : 1
109 }
110 } else if (mode === 'run') {
111 x += (dir * SPEED * s.pace * TICK_MS) / 1000
112 if (x >= w.room) {
113 x = w.room
114 dir = -1
115 } else if (x <= 0) {
116 x = 0
117 dir = 1
118 }
119 }
120
121 return { mode, since, x, dir, tick: a.tick + 1, working: w.isWorking, target: w.activeTools > 0 ? w.activeTarget : a.target, ...(leap && { leap }) }
122}
123
124// The body canvas holds every pose, the crouch and the landing dust included, so a leap measured on it clears the
125// obstacle at every frame.
126/** The first column of the body canvas, for a picture at column `x`: the trail is on its left unless it faces left. */
127const bodyAt = (x: number, dir: 1 | -1, w: Activity) => x + (dir === 1 ? w.trail : 0)
128
129/** The obstacle just ahead of a running pet, close enough to leap now. */
130function obstacleAhead(x: number, dir: 1 | -1, w: Activity) {
131 const left = bodyAt(x, dir, w)
132 const right = left + BODY_W
133 return w.obstacles.find(o => {
134 const gap = dir === 1 ? o.x - right : left - (o.x + o.w)
135 return gap >= 0 && gap <= LEAP_GAP
136 })
137}
138
139/** Where a leap over `o` lands: the picture's column with the body canvas one column past the obstacle. */
140function landing(o: Span, dir: 1 | -1, w: Activity) {
141 const body = dir === 1 ? o.x + o.w + 1 : o.x - 1 - BODY_W
142 return body - bodyAt(0, dir, w)
143}
144
145/** The picture's column `u` ms (at pace) into `leap`. */
146export function leapX(leap: Leap, u: number) {
147 const p = Math.min(1, Math.max(0, (u / LEAP_MS - AIR[0]) / (AIR[1] - AIR[0])))
148 return leap.from + (leap.to - leap.from) * p
149}
150
151/** How far into the jump clip a leap `u` ms (at pace) in has come, at the clip's own speed. */
152export const leapClipMs = (u: number) => u / LEAP_SLOW
153
154/** The pet after a failed tool call: an error face, unless it is cheering. */
155export function fail(a: Anim, t: number): Anim {
156 return a.mode === 'cheer' ? a : { ...a, mode: 'error', since: t }
157}
158hooks/custom.ts 47 lines1/**
2 * The user's own themes: a folder of `<name>.theme.json` files outside the plugin's install folder, which an update
3 * replaces. The mod reads no environment, so it finds the user's `.claude` folder from where the plugin is installed,
4 * unless the Custom folder setting names another.
5 */
6
7/** The pets that ship with the plugin, in `assets/`; the first is the default. */
8export const BUILT_IN = ['luffy', 'slime', 'duck', 'alien'] as const
9export const DEFAULT_THEME = BUILT_IN[0]
10const SUFFIX = '.theme.json'
11
12const isAbsolute = (path: string) => path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
13
14/** The custom folder: the setting's, when it is an absolute path with no `..`; else `oxen-pet/themes` in the `.claude` folder the plugin is installed under. */
15export function customDirOf(pluginRoot: string, setting: string): string | undefined {
16 const asked = setting.trim()
17 if (isAbsolute(asked) && !asked.split(/[\\/]/).includes('..') && !/[$~]/.test(asked)) {
18 return asked.replace(/[\\/]+$/, '')
19 }
20 const at = pluginRoot.search(/[\\/]\.claude[\\/]/)
21
22 return at < 0 ? undefined : `${pluginRoot.slice(0, at + '/.claude'.length)}/oxen-pet/themes`
23}
24
25/** Whether `name` names a theme: letters, digits, `-` and `_`, so it stays one file in the folder. */
26export const isThemeName = (name: string) => /^[A-Za-z0-9_-]{1,40}$/.test(name)
27
28export const themeFile = (dir: string, name: string) => `${dir}/${name}${SUFFIX}`
29
30/** The theme names among a folder's entries: its `<name>.theme.json` files, sorted. */
31export const themeNames = (entries: readonly { name: string; kind: string }[]) =>
32 entries
33 .filter(e => e.kind === 'file' && e.name.endsWith(SUFFIX))
34 .map(e => e.name.slice(0, -SUFFIX.length))
35 .filter(isThemeName)
36 .sort()
37
38/** What `/pet theme` prints: the built-in pets and the user's own, the one on screen marked, and where the folder is. */
39export function themeList(own: string[], current: string | undefined, dir: string | undefined) {
40 const mark = (name: string) => (name === current ? `${name} ◀ on screen` : name)
41 const where = dir
42 ? `Your themes folder: ${dir}\nPut <name>.theme.json there, or ask Claude to draw a pet and save it there. Updates never touch it.`
43 : 'No themes folder yet. Set Custom folder in /plugin configure oxen-pet@oxen-pet to an absolute path.'
44
45 return [`Built in: ${BUILT_IN.map(mark).join(', ')}`, `Yours: ${own.length > 0 ? own.map(mark).join(', ') : 'none yet'}`, where, 'Switch with /pet theme <name>.'].join('\n')
46}
47hooks/boss.ts 127 lines1import { HEIGHT, canvas, stamp } from './pixels'
2import type { Canvas } from './pixels'
3
4/**
5 * The boss: a failed test run brings a bug into the band, every failed run after hits harder, and the next run
6 * that passes defeats it. A run piped into another command (`npm test | tail`) ends with that command's exit
7 * code, so it reads as passed.
8 */
9export type Boss = { since: number; hits: number; defeatedAt?: number }
10export type TestOutcome = 'passed' | 'failed'
11
12export const BOSS_W = 16
13export const DEFEAT_MS = 1600
14const FLASH_MS = 600 // the first part of the defeat, the boss flashing white before it puffs away
15const MAX_PIPS = 5
16
17const TEST_RUNS = [
18 /\b(npm|pnpm|yarn|bun)\s+(run\s+)?test\b/,
19 /\bnpx\s+(jest|vitest|mocha|playwright\s+test)\b/,
20 /(^|[;&|(]\s*)(jest|vitest|mocha|pytest|rspec|phpunit)\b/,
21 /\bpython3?\s+-m\s+pytest\b/,
22 /\b(go|cargo|deno|dotnet|make)\s+test\b/,
23 /\b(mvnw?|gradlew?)\s+test\b/,
24 /\bclaude\s+plugin\s+test\b/,
25]
26
27/** Whether `command` runs a test suite with one of the common tools. */
28export const isTestCommand = (command: string) => TEST_RUNS.some(re => re.test(command))
29
30/** A test run's outcome from its tool.call result; undefined when it was refused, interrupted, or sent to the background. */
31export function testOutcome(result: { deny?: string; isError?: true; result?: unknown; text?: string }): TestOutcome | undefined {
32 if (result.deny !== undefined) {
33 return undefined
34 }
35 if (result.isError) {
36 return 'failed'
37 }
38 const out = (result.result ?? {}) as { interrupted?: boolean; backgroundTaskId?: string }
39
40 return out.interrupted || out.backgroundTaskId !== undefined ? undefined : 'passed'
41}
42
43/** The boss after a test run with `outcome` at `t`. */
44export function bossAfter(boss: Boss | undefined, outcome: TestOutcome | undefined, t: number): Boss | undefined {
45 if (outcome === 'failed') {
46 return boss && boss.defeatedAt === undefined ? { ...boss, hits: boss.hits + 1 } : { since: t, hits: 1 }
47 }
48 if (outcome === 'passed' && boss && boss.defeatedAt === undefined) {
49 return { ...boss, defeatedAt: t }
50 }
51
52 return boss
53}
54
55/** The boss while it is on screen: until its defeat has played out. */
56export const bossOnScreen = (boss: Boss | undefined, t: number) => (boss && (boss.defeatedAt === undefined || t - boss.defeatedAt < DEFEAT_MS) ? boss : undefined)
57
58// An original bug: antennae, a purple shell, red eyes and a toothy grin, on four legs.
59const BUG = [
60 '...A........A...',
61 '....A......A....',
62 '.....DDDDDD.....',
63 '...DDPPPPPPDD...',
64 '..DPPPPPPPPPPD..',
65 '..DPWWPPPPWWPD..',
66 '.DPPWRPPPPRWPPD.',
67 '.DPPPPPPPPPPPPD.',
68 '.DPPKKKKKKKKPPD.',
69 '.DPPKWKWKWKWPPD.',
70 '..DPPPPPPPPPPD..',
71 '..GDDPPPPPPDDG..',
72]
73const LEGS = [
74 ['.G..G.DDDD.G..G.', 'G...G......G...G'],
75 ['..G.G.DDDD.G.G..', '..G..G....G..G..'],
76]
77const PALETTE = { A: 0xa78bfa, D: 0x4c1d95, P: 0x7c3aed, W: 0xffffff, R: 0xf43f5e, K: 0x1e1033, G: 0x8b5cf6 }
78const WHITE = Object.fromEntries(Object.keys(PALETTE).map(ch => [ch, 0xffffff]))
79const PIP = 0xf43f5e
80const SMOKE = 0x9ca3af
81const STAR = 0xffe25a
82
83function drawBug(c: Canvas, t: number, palette: Record<string, number>) {
84 const step = Math.floor(t / 400) % 2
85 const rows = [...BUG, ...(LEGS[step] as string[])]
86 stamp(c, 0, HEIGHT - rows.length - step, rows, palette)
87}
88
89/** The boss at `t`, BOSS_W by HEIGHT: bobbing, with a pip per hit above it, or flashing and puffing away once defeated. */
90export function drawBoss(boss: Boss, t: number): Canvas {
91 const c = canvas(BOSS_W, HEIGHT)
92 if (boss.defeatedAt === undefined) {
93 drawBug(c, t, PALETTE)
94 for (let k = 0; k < Math.min(boss.hits, MAX_PIPS); k++) {
95 c.px[2 * BOSS_W + 2 + k * 3] = PIP
96 c.px[2 * BOSS_W + 3 + k * 3] = PIP
97 }
98 return c
99 }
100 const u = t - boss.defeatedAt
101 if (u < FLASH_MS) {
102 drawBug(c, t, Math.floor(u / 100) % 2 ? WHITE : PALETTE)
103 return c
104 }
105 // A ring of smoke and four stars spreading from where the bug stood.
106 const k = (u - FLASH_MS) / (DEFEAT_MS - FLASH_MS)
107 const dot = (x: number, y: number, color: number) => {
108 const [px, py] = [Math.round(x), Math.round(y)]
109 if (px >= 0 && px < BOSS_W && py >= 0 && py < HEIGHT) {
110 c.px[py * BOSS_W + px] = color
111 }
112 }
113 for (let i = 0; i < 8; i++) {
114 const a = (i / 8) * 2 * Math.PI
115 dot(8 + Math.cos(a) * (2 + 5 * k), 13 + Math.sin(a) * (2 + 4 * k), SMOKE)
116 }
117 for (let i = 0; i < 4; i++) {
118 const a = (i / 4) * 2 * Math.PI + Math.PI / 4
119 dot(8 + Math.cos(a) * (1 + 6 * k), 13 + Math.sin(a) * (1 + 5 * k), STAR)
120 }
121
122 return c
123}
124
125/** What the boss drawing says to a reader that cannot see it. */
126export const bossAlt = (boss: Boss) => (boss.defeatedAt === undefined ? `a bug boss, ${boss.hits} hit${boss.hits === 1 ? '' : 's'}` : 'a bug boss, defeated')
127hooks/guard.ts 252 lines1/**
2 * The shield: which Bash commands destroy work, and what to tell the user before one runs. Pure, so the hooks in
3 * register.tsx only ask and answer. A false alarm costs one question; a missed command costs work, so a command
4 * that only mentions a risky word (`echo "rm -rf"`) is asked about too.
5 */
6
7/** One path a command deletes: `unsized` when its spelling (a glob, a variable, `~`, a path after `cd`) names no file the mod can look at. */
8export type Target = { path: string; unsized?: true }
9/** What a risky command does, in a few words each, and the paths it deletes when it names them. */
10export type Risk = { what: string[]; targets: Target[] }
11/** How many files a target holds: at least `files` when capped; `isMissing` when nothing is there. */
12export type Size = { files: number; isCapped?: true; isMissing?: true }
13/** The two calls `sizeOf` needs, which register.tsx answers with `$.fs`. */
14export type GuardFs = {
15 stat: (path: string) => Promise<{ kind: string; isLink: boolean }>
16 list: (path: string) => Promise<{ name: string; kind: string; isLink: boolean }[]>
17}
18
19export const GUARD_CAP = 2000 // files counted per target before the count reads `2000+`
20const GUARD_DEPTH = 8 // folders deep the count goes
21const GUARD_DIRS = 300 // folders listed per target
22const MAX_COMMAND = 80
23const MAX_PATH = 40
24const MAX_TARGETS = 4
25
26/** The two answers, block first, so a dialog that answers for an absent user picks it. */
27export const GUARD_OPTIONS = { block: 'Block it', run: 'Run it' } as const
28
29const DELETES = 'deletes files and folders for good'
30
31/** The command split where the shell splits it into commands: `&&`, `||`, `;`, `|`, and new lines, outside quotes. */
32function segments(command: string) {
33 const out: string[] = []
34 let cur = ''
35 let quote = ''
36 for (let i = 0; i < command.length; i++) {
37 const ch = command[i] as string
38 if (quote) {
39 quote = ch === quote ? '' : quote
40 cur += ch
41 } else if (ch === "'" || ch === '"') {
42 quote = ch
43 cur += ch
44 } else if (ch === ';' || ch === '\n' || ch === '|' || ch === '&') {
45 out.push(cur)
46 cur = ''
47 } else {
48 cur += ch
49 }
50 }
51
52 return [...out, cur].map(s => s.trim()).filter(Boolean)
53}
54
55/** One command's words, with quotes taken off as the shell would. */
56function words(segment: string) {
57 const out: string[] = []
58 let cur = ''
59 let quote = ''
60 let has = false
61 for (const ch of segment) {
62 if (quote) {
63 if (ch === quote) {
64 quote = ''
65 } else {
66 cur += ch
67 }
68 } else if (ch === "'" || ch === '"') {
69 quote = ch
70 has = true
71 } else if (/\s/.test(ch)) {
72 if (has || cur) {
73 out.push(cur)
74 }
75 cur = ''
76 has = false
77 } else {
78 cur += ch
79 }
80 }
81 if (has || cur) {
82 out.push(cur)
83 }
84
85 return out
86}
87
88const WRAPPERS = new Set(['sudo', 'command', 'exec', 'time', 'nice', 'nohup', 'xargs', 'doas'])
89
90/** The words from the command's own name on: past `sudo`, `xargs` and the like, their flags, and `VAR=value`. */
91function commandWords(all: string[]) {
92 let i = 0
93 while (i < all.length && (WRAPPERS.has(all[i] as string) || /^-/.test(all[i] as string) || /^[A-Za-z_]\w*=/.test(all[i] as string))) {
94 i += 1
95 }
96
97 return all.slice(i)
98}
99
100const hasFlag = (args: string[], short: string, long?: string) => args.some(a => (long !== undefined && a === long) || new RegExp(`^-[a-zA-Z]*${short}[a-zA-Z]*$`).test(a))
101
102/** The git subcommand and its words, past `-C dir` and `-c key=value`. */
103function gitWords(args: string[]) {
104 let i = 0
105 while (i < args.length && /^-/.test(args[i] as string)) {
106 i += args[i] === '-C' || args[i] === '-c' ? 2 : 1
107 }
108
109 return { sub: args[i] ?? '', rest: args.slice(i + 1) }
110}
111
112/** What one git command destroys, if anything. */
113function gitRisk(args: string[]) {
114 const { sub, rest } = gitWords(args)
115 switch (sub) {
116 case 'push':
117 return rest.includes('--force') || rest.some(a => /^-[a-zA-Z]*f[a-zA-Z]*$/.test(a)) || rest.some(a => /^\+/.test(a)) ? "force-pushes, which rewrites the remote branch's history" : undefined
118 case 'reset':
119 return rest.includes('--hard') ? 'throws away uncommitted changes' : undefined
120 case 'clean':
121 return (hasFlag(rest, 'f', '--force') && !hasFlag(rest, 'n', '--dry-run')) ? 'deletes untracked files' : undefined
122 case 'checkout':
123 return rest.includes('--') || rest.includes('.') ? 'throws away uncommitted changes to files' : undefined
124 case 'restore':
125 return rest.includes('--staged') && !rest.includes('--worktree') ? undefined : 'throws away uncommitted changes to files'
126 case 'branch':
127 return rest.includes('-D') || (rest.includes('--delete') && rest.includes('--force')) ? 'deletes a branch, merged or not' : undefined
128 case 'stash':
129 return rest[0] === 'drop' || rest[0] === 'clear' ? 'drops stashed changes' : undefined
130 default:
131 return undefined
132 }
133}
134
135const isUnsizable = (path: string) => /[*?[\]{}$`~]/.test(path)
136
137/**
138 * What `command` destroys, or undefined for a command that destroys nothing the shield knows of. A recursive `rm`
139 * names its targets; after a `cd`, a relative one is unsized, since it names a folder the mod cannot place.
140 */
141export function riskOf(command: string): Risk | undefined {
142 const what = new Set<string>()
143 const targets: Target[] = []
144 let movedDir = false
145 for (const segment of segments(command)) {
146 const [word = '', ...args] = commandWords(words(segment))
147 const name = word.split('/').pop() ?? '' // `/bin/rm` is rm
148 if (name === 'cd' || name === 'pushd') {
149 movedDir = true
150 } else if (name === 'rm' && hasFlag(args.filter((_, i) => !args.slice(0, i).includes('--')), '[rR]', '--recursive')) {
151 what.add(DELETES)
152 const end = args.indexOf('--')
153 for (const [i, path] of args.entries()) {
154 const isPath = end >= 0 ? i > end : !/^-/.test(path)
155 if (isPath) {
156 targets.push(isUnsizable(path) || (movedDir && !path.startsWith('/')) ? { path, unsized: true } : { path })
157 }
158 }
159 } else if (name === 'git') {
160 const risk = gitRisk(args)
161 if (risk) {
162 what.add(risk)
163 }
164 } else if (name === 'find' && (args.includes('-delete') || (args.includes('-exec') && args.includes('rm')))) {
165 what.add('deletes every file find matches')
166 } else if (/^(terraform|tofu|pulumi)$/.test(name) && (args[0] === 'destroy' || args.includes('-destroy'))) {
167 what.add('destroys infrastructure')
168 } else if ((name === 'kubectl' && args[0] === 'delete') || (name === 'helm' && /^(uninstall|delete)$/.test(args[0] ?? ''))) {
169 what.add('deletes cluster resources')
170 } else if (name === 'docker' && ((args[0] === 'system' && args[1] === 'prune') || (args[0] === 'volume' && /^(rm|prune)$/.test(args[1] ?? '')))) {
171 what.add('deletes Docker data')
172 } else if (/^mkfs/.test(name) || (name === 'dd' && args.some(a => a.startsWith('of=/dev/')))) {
173 what.add('writes over a disk')
174 }
175 if (/\bdrop\s+(table|database|schema)\b|\btruncate\s+table\b/i.test(segment)) {
176 what.add('drops or empties database tables')
177 }
178 }
179
180 return what.size > 0 ? { what: [...what], targets } : undefined
181}
182
183/** How many files `path` holds, counted through `fs`: links are counted, never followed, and the count stops at the cap. */
184export async function sizeOf(path: string, fs: GuardFs): Promise<Size> {
185 let top: { kind: string; isLink: boolean }
186 try {
187 top = await fs.stat(path)
188 } catch {
189 return { files: 0, isMissing: true }
190 }
191 if (top.isLink || top.kind !== 'dir') {
192 return { files: 1 }
193 }
194 let files = 0
195 let listed = 0
196 const queue = [{ path, depth: 0 }]
197 while (queue.length > 0) {
198 const dir = queue.shift() as { path: string; depth: number }
199 listed += 1
200 if (listed > GUARD_DIRS) {
201 return { files, isCapped: true }
202 }
203 let entries: { name: string; kind: string; isLink: boolean }[]
204 try {
205 entries = await fs.list(dir.path)
206 } catch {
207 continue
208 }
209 for (const entry of entries) {
210 if (entry.kind === 'dir' && !entry.isLink) {
211 if (dir.depth + 1 > GUARD_DEPTH) {
212 return { files, isCapped: true }
213 }
214 queue.push({ path: `${dir.path.replace(/\/$/, '')}/${entry.name}`, depth: dir.depth + 1 })
215 continue
216 }
217 files += 1
218 if (files >= GUARD_CAP) {
219 return { files: GUARD_CAP, isCapped: true }
220 }
221 }
222 }
223
224 return { files }
225}
226
227const cut = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
228
229function sizeText(target: Target, size: Size | undefined) {
230 if (size === undefined) {
231 return 'not sized'
232 }
233 // Claude's shell may have moved since the session began, so a relative path the mod cannot find may still be there.
234 if (size.isMissing) {
235 return target.path.startsWith('/') ? 'not there' : 'not found from the project folder'
236 }
237
238 return `${size.files}${size.isCapped ? '+' : ''} file${size.files === 1 && !size.isCapped ? '' : 's'}`
239}
240
241/** The question the shield asks before `command` runs; `sizes` line up with `risk.targets`, undefined where unsized. */
242export function guardQuestion(command: string, risk: Risk, sizes: (Size | undefined)[]) {
243 const shown = risk.targets.slice(0, MAX_TARGETS).map((t, i) => `${cut(t.path, MAX_PATH)}: ${sizeText(t, sizes[i])}`)
244 const more = risk.targets.length > MAX_TARGETS ? [`${risk.targets.length - MAX_TARGETS} more`] : []
245 const radius = shown.length > 0 ? ` ${[...shown, ...more].join(' · ')}.` : ''
246
247 return `oxen-pet shield: \`${cut(command.replace(/\s+/g, ' ').trim(), MAX_COMMAND)}\` ${risk.what.join(', and ')}.${radius} Run it?`
248}
249
250/** The status line once the user answered: what the shield did. */
251export const guardLine = (result: 'blocked' | 'ran') => (result === 'blocked' ? 'shield up: blocked it' : 'okay, let it through')
252hooks/stats.ts 100 lines1import type { Hud } from './hud'
2import { fmtMin } from './hud'
3import { toolMode } from './status'
4import type { ToolMode } from './status'
5import type { TestOutcome } from './boss'
6
7/** What the session did, as the hooks saw it, for the `/pet` pane. Files are kept as paths and shown as counts. */
8export type Stats = {
9 startedAt: number
10 turns: number
11 tools: Partial<Record<ToolMode, number>>
12 failed: number
13 read: string[]
14 edited: string[]
15 tests: { passed: number; failed: number }
16 bosses: number // defeated
17 shield: { blocked: number; ran: number }
18 firstMp?: { left: number; at: number } // MP's first reading in its window, for the burn rate
19}
20
21/** One row of the pane: a label and its value. */
22export type StatsRow = { label: string; value: string }
23
24const MODE_ORDER: ToolMode[] = ['read', 'search', 'edit', 'bash', 'web', 'agent']
25const EDITS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
26const MAX_FILES = 1000
27const MAX_NAMES = 4
28const BURN_AFTER_MIN = 15 // MP's burn rate is noise before this long
29
30export const newStats = (t: number): Stats => ({ startedAt: t, turns: 0, tools: {}, failed: 0, read: [], edited: [], tests: { passed: 0, failed: 0 }, bosses: 0, shield: { blocked: 0, ran: 0 } })
31
32const withFile = (files: string[], path: unknown) => (typeof path === 'string' && path !== '' && !files.includes(path) && files.length < MAX_FILES ? [...files, path] : files)
33
34/** The stats after one tool call of `tool` with `input`, which `failed` or not. */
35export function recordTool(s: Stats, tool: string, input: Record<string, unknown>, failed: boolean): Stats {
36 const mode = toolMode(tool)
37 const path = input.file_path ?? input.notebook_path
38
39 return {
40 ...s,
41 tools: { ...s.tools, [mode]: (s.tools[mode] ?? 0) + 1 },
42 failed: s.failed + (failed ? 1 : 0),
43 read: tool === 'Read' ? withFile(s.read, path) : s.read,
44 edited: EDITS.has(tool) ? withFile(s.edited, path) : s.edited,
45 }
46}
47
48export const recordTurn = (s: Stats): Stats => ({ ...s, turns: s.turns + 1 })
49
50export const recordTest = (s: Stats, outcome: TestOutcome, beatBoss: boolean): Stats => ({
51 ...s,
52 tests: { ...s.tests, [outcome]: s.tests[outcome] + 1 },
53 bosses: s.bosses + (beatBoss ? 1 : 0),
54})
55
56export const recordShield = (s: Stats, result: 'blocked' | 'ran'): Stats => ({ ...s, shield: { ...s.shield, [result]: s.shield[result] + 1 } })
57
58/** The stats after an MP reading of `left` at `t`: the first one in its window starts the burn rate. A reset, MP climbing, starts it over. */
59export const noteMp = (s: Stats, left: number, t: number): Stats => (s.firstMp === undefined || left > s.firstMp.left ? { ...s, firstMp: { left, at: t } } : s)
60
61const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
62const names = (paths: string[]) => {
63 const shown = paths.slice(0, MAX_NAMES).map(p => p.split(/[\\/]/).pop())
64 return `${shown.join(', ')}${paths.length > MAX_NAMES ? ', …' : ''}`
65}
66
67/** The pane's rows at `now`: only what the session did, and file names only when `showNames`. */
68export function statsRows(s: Stats, now: number, hud: Hud | undefined, showNames: boolean): StatsRow[] {
69 const rows: StatsRow[] = [{ label: 'Session', value: `${fmtMin(Math.max(0, Math.floor((now - s.startedAt) / 60000)))} · ${plural(s.turns, 'turn')}` }]
70 const calls = MODE_ORDER.reduce((n, m) => n + (s.tools[m] ?? 0), 0)
71 if (calls > 0) {
72 const parts = MODE_ORDER.filter(m => s.tools[m]).map(m => `${s.tools[m]} ${m}`)
73 rows.push({ label: 'Tools', value: `${plural(calls, 'call')}: ${[...parts, ...(s.failed > 0 ? [`${s.failed} failed`] : [])].join(' · ')}` })
74 }
75 if (s.read.length + s.edited.length > 0) {
76 const read = s.read.length > 0 ? [`${s.read.length} read`] : []
77 const edited = s.edited.length > 0 ? [`${s.edited.length} edited${showNames ? `: ${names(s.edited)}` : ''}`] : []
78 rows.push({ label: 'Files', value: [...read, ...edited].join(' · ') })
79 }
80 if (s.tools.agent) {
81 rows.push({ label: 'Subagents', value: String(s.tools.agent) })
82 }
83 const runs = s.tests.passed + s.tests.failed
84 if (runs > 0) {
85 const beaten = s.bosses > 0 ? [`${s.bosses} boss${s.bosses === 1 ? '' : 'es'} beaten`] : []
86 rows.push({ label: 'Tests', value: `${plural(runs, 'run')}: ${[`${s.tests.passed} passed`, `${s.tests.failed} failed`, ...beaten].join(' · ')}` })
87 }
88 const asked = s.shield.blocked + s.shield.ran
89 if (asked > 0) {
90 rows.push({ label: 'Shield', value: `${asked} asked: ${s.shield.blocked} blocked · ${s.shield.ran} ran` })
91 }
92 if (hud) {
93 const hours = s.firstMp ? (now - s.firstMp.at) / 3600000 : 0
94 const burn = s.firstMp && hud.mp !== undefined && hours * 60 >= BURN_AFTER_MIN ? [`MP ${Math.round((s.firstMp.left - hud.mp) / hours)}%/h`] : []
95 rows.push({ label: 'Burn', value: [...burn, `context ${100 - hud.hp}% used`].join(' · ') })
96 }
97
98 return rows
99}
100hooks/hud.ts 306 lines1import type { SessionUsage } from 'claude-code'
2
3import { encodeCells } from './pixels'
4import type { Canvas } from './pixels'
5
6export const BAR_W = 20 // cells; a bar is one cell row, two pixels tall
7export const ROW_BAR_W = 10 // a bar's cells when the HUD lays its bars in a row
8
9// The window frame around the HUD. Blue, not white, so it shows on a light terminal too.
10export const FRAME_COLOR = '#5aa9ff'
11
12export type Hud = {
13 hp: number // context left, %
14 mp?: number // the 5-hour rate limit left, %; absent off a subscription or before its first reading
15 mpResetsInMin?: number
16 st?: number // the 7-day rate limit left, %; absent like MP
17 stResetsInMin?: number
18 cacheMin?: number // minutes the prompt cache stays warm, 0 once cold; absent before the first turn or with the timer off
19}
20
21// Each rate-limit window's length in minutes, which the pace runs through.
22export const MP_WINDOW_MIN = 300
23export const ST_WINDOW_MIN = 10080
24// How long a window runs before its burn rate is steady enough to forecast from.
25const FORECAST_AFTER_MIN = 15
26
27export type Mood = 'ok' | 'worried' | 'critical' | 'tired'
28
29/** HP under this warns: `⚠ HP`, `/compact` beside the reading, and the low-context alert. */
30export const LOW_HP = 20
31const REARM_HP = 30 // HP must climb back to this, by a /compact or a new session, before the alert can fire again
32
33/** Whether the low-context alert fires at `hp`, and whether it is armed after: it fires once per drop under LOW_HP. */
34export function contextAlert(armed: boolean, hp: number) {
35 if (armed && hp < LOW_HP) {
36 return { alert: true, armed: false }
37 }
38
39 return { alert: false, armed: armed || hp >= REARM_HP }
40}
41
42/** The toast the low-context alert shows. */
43export const contextAlertText = (hp: number) => `oxen-pet: ${hp}% of the context left. Run /compact now, or hand off to a fresh session.`
44
45/** A pet's look for one bar: its label, the label's color, and the fill while the bar is healthy. */
46export type BarLook = { label?: string; color?: string; fill?: [string, string] }
47/** A pet's look for the HUD: the frame's color, and each bar's look, or false to hide it. */
48export type HudLook = { frame?: string; hp?: BarLook | false; mp?: BarLook | false; st?: BarLook | false }
49
50/** The frame's color: the pet's own, else the mod's. */
51export const frameColor = (look: HudLook) => look.frame ?? FRAME_COLOR
52
53const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
54const finite = (v: number | undefined) => (v !== undefined && Number.isFinite(v) ? v : undefined)
55
56/** One rate-limit window: the share left, and the minutes until it resets. Both absent without a reading. */
57function limitLeft(u: SessionUsage, kind: string, now: number) {
58 const limit = u.rateLimits.find(r => r.kind === kind && finite(r.percentUsed) !== undefined)
59 const resets = limit?.resetsAt ? Date.parse(limit.resetsAt) : Number.NaN
60
61 return {
62 left: limit ? clamp(Math.round(100 - limit.percentUsed), 0, 100) : undefined,
63 resetsInMin: !limit || Number.isNaN(resets) ? undefined : Math.max(0, Math.round((resets - now) / 60000)),
64 }
65}
66
67export function hudFrom(u: SessionUsage, now: number): Hud {
68 const mp = limitLeft(u, 'five_hour', now)
69 const st = limitLeft(u, 'seven_day', now)
70
71 return {
72 hp: clamp(Math.round(100 - (finite(u.context.percent) ?? 0)), 0, 100),
73 mp: mp.left,
74 mpResetsInMin: mp.resetsInMin,
75 st: st.left,
76 stResetsInMin: st.resetsInMin,
77 }
78}
79
80/** How many points a limit's share left is ahead of an even pace through its window; negative when behind. */
81export function spareOf(left: number, resetsInMin: number, windowMin: number) {
82 const evenLeft = (clamp(resetsInMin, 0, windowMin) / windowMin) * 100
83
84 return Math.round(left - evenLeft)
85}
86
87/**
88 * Minutes until a limit runs out at its average burn so far in the window, when that comes before its reset.
89 * Absent when it lasts to the reset, or in the window's first minutes, when the rate is still noise.
90 */
91export function emptyInMin(left: number, resetsInMin: number, windowMin: number) {
92 const elapsed = windowMin - clamp(resetsInMin, 0, windowMin)
93 const used = 100 - left
94 if (elapsed < FORECAST_AFTER_MIN || used <= 0) {
95 return undefined
96 }
97 const empty = Math.round((left * elapsed) / used)
98
99 return empty < resetsInMin ? empty : undefined
100}
101
102/** Minutes the prompt cache stays warm after the main thread's last turn ended at `endedAt`, 0 once cold. */
103export function cacheLeftMin(endedAt: number | undefined, ttlMin: number, now: number) {
104 if (endedAt === undefined || ttlMin <= 0) {
105 return undefined
106 }
107
108 return Math.max(0, Math.ceil((ttlMin * 60000 - (now - endedAt)) / 60000))
109}
110
111export function mood(h: Hud): Mood {
112 if (h.hp <= 25) {
113 return 'critical'
114 }
115 if (h.hp <= 50) {
116 return 'worried'
117 }
118
119 return [h.mp, h.st].some(left => left !== undefined && left < 20) ? 'tired' : 'ok'
120}
121
122/** Minutes as the HUD prints them: `36m`, `2h13m`, then whole hours past a day, `3d4h`. */
123export function fmtMin(m: number) {
124 if (m >= 1440) {
125 return `${Math.floor(m / 1440)}d${Math.floor((m % 1440) / 60)}h`
126 }
127
128 return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m` : `${m}m`
129}
130
131type Pair = [number, number]
132const GREEN: Pair = [0x166534, 0x4ade80]
133const YELLOW: Pair = [0xa16207, 0xfacc15]
134const RED: Pair = [0x991b1b, 0xf87171]
135const BLUE: Pair = [0x3b5bdb, 0xa78bfa]
136const GOLD: Pair = [0xb45309, 0xfbbf24]
137
138const channel = (c: number, shift: number) => (c >> shift) & 255
139const mix = (a: number, b: number, t: number) =>
140 [16, 8, 0].reduce((out, s) => out | (Math.round(channel(a, s) + (channel(b, s) - channel(a, s)) * t) << s), 0)
141// The even-pace mark on a bar. Light, so it shows on the bar's dark track and on its fill.
142export const MARKER_COLOR = 0xf1f5f9
143
144const darker = (c: number) => [16, 8, 0].reduce((out, s) => out | (Math.round(channel(c, s) * 0.6) << s), 0)
145
146/** A bevelled bar `w` cells long: grey end caps, a gradient fill with a darker lower half, and a mark at `markPct` when given. */
147export function barCanvas(pct: number, [from, to]: Pair, markPct?: number, w = BAR_W): Canvas {
148 const px = new Array<number>(w * 2).fill(0x1b1e26)
149 const fill = Math.round((clamp(pct, 0, 100) / 100) * (w - 2))
150 for (let i = 0; i < fill; i++) {
151 const c = mix(from, to, i / (w - 3))
152 px[1 + i] = c
153 px[w + 1 + i] = darker(c)
154 }
155 for (const x of [0, w - 1]) {
156 px[x] = 0x6f7787
157 px[w + x] = 0x6f7787
158 }
159 if (markPct !== undefined) {
160 const x = Math.min(1 + Math.round((clamp(markPct, 0, 100) / 100) * (w - 2)), w - 2)
161 px[x] = MARKER_COLOR
162 px[w + x] = MARKER_COLOR
163 }
164
165 return { w, h: 2, px }
166}
167
168/** One run of a row's text. The reading is bold in its bar's color; the details beside it are grey. */
169export type HudPart = { text: string; color: string; bold?: boolean }
170
171/** One bar's row: `pct` is its reading, `bar` its pixels, and `cells` those pixels as Raster cells. */
172export type HudRow = { key: string; label: string; color: string; pct: number; bar: Canvas; cells: string; parts: HudPart[] }
173
174// A mid grey, so the details read on a dark terminal and on a light one.
175export const DETAIL_COLOR = '#8b93b8'
176
177const cssColor = (c: number) => `#${c.toString(16).padStart(6, '0')}`
178const reading = (text: string, [, bright]: Pair): HudPart => ({ text: ` ${text}`, color: cssColor(bright), bold: true })
179const detail = (bits: (string | undefined)[]): HudPart[] => {
180 const text = bits.filter(b => b !== undefined).join(' ')
181
182 return text ? [{ text: ` ${text}`, color: DETAIL_COLOR }] : []
183}
184
185const resets = (min: number | undefined) => (min === undefined ? undefined : `reset in ${fmtMin(min)}`)
186const shortReset = (min: number | undefined) => (min === undefined ? undefined : fmtMin(min))
187const cache = (min: number | undefined) => (min === undefined ? undefined : min > 0 ? `cache ${fmtMin(min)}` : 'cache cold')
188const evenLeft = (resetsInMin: number | undefined, windowMin: number) =>
189 resetsInMin === undefined ? undefined : (clamp(resetsInMin, 0, windowMin) / windowMin) * 100
190const pace = (left: number, resetsInMin: number | undefined, windowMin: number) => {
191 if (resetsInMin === undefined) {
192 return undefined
193 }
194 const spare = spareOf(left, resetsInMin, windowMin)
195
196 return spare === 0 ? 'on pace' : spare > 0 ? `${spare}% spare` : `${-spare}% over`
197}
198/** The red warning that a limit runs out before its reset. */
199const runsOut = (min: number | undefined): HudPart[] => (min === undefined ? [] : [{ text: ` empty ~${fmtMin(min)}`, color: cssColor(RED[1]) }])
200
201const pairOf = (fill: [string, string] | undefined, fallback: Pair): Pair => (fill ? [parseInt(fill[0].slice(1), 16), parseInt(fill[1].slice(1), 16)] : fallback)
202
203/**
204 * The HUD's rows: HP, and MP and ST when the session has their readings, each in the pet's `look` unless
205 * the look hides it. A bar's warning colors (yellow and red) replace its fill whatever the look. `isRow` lays
206 * them side by side: shorter bars, and beside each reading only one short detail, or its warning. `barW` sets
207 * the bars' width in cells, for a HUD that must fit a narrow terminal.
208 */
209export function hudRows(h: Hud, look: HudLook = {}, isRow = false, barW?: number): HudRow[] {
210 const w = barW ?? (isRow ? ROW_BAR_W : BAR_W)
211 const rows: (Omit<HudRow, 'cells'> & { look: BarLook | false | undefined })[] = []
212 const hpFill = h.hp > 50 ? pairOf(look.hp ? look.hp.fill : undefined, GREEN) : h.hp > 25 ? YELLOW : RED
213 rows.push({
214 key: 'hp',
215 look: look.hp,
216 label: h.hp < LOW_HP ? '⚠ HP' : '♥ HP',
217 color: '#f87171',
218 pct: h.hp,
219 bar: barCanvas(h.hp, hpFill, undefined, w),
220 parts: [
221 reading(`${h.hp}%`, hpFill),
222 ...(h.hp < LOW_HP ? [{ ...reading('/compact', RED), text: ' /compact' }] : []),
223 ...(isRow && h.hp < LOW_HP ? [] : detail([cache(h.cacheMin)])),
224 ],
225 })
226 if (h.mp !== undefined) {
227 const mpFill = h.mp < 15 ? RED : pairOf(look.mp ? look.mp.fill : undefined, BLUE)
228 // Running out before the reset says more than how far over pace MP is, so it takes that place.
229 const empty = h.mpResetsInMin === undefined ? undefined : emptyInMin(h.mp, h.mpResetsInMin, MP_WINDOW_MIN)
230 rows.push({
231 key: 'mp',
232 look: look.mp,
233 label: '✦ MP',
234 color: '#7aa7ff',
235 pct: h.mp,
236 bar: barCanvas(h.mp, mpFill, evenLeft(h.mpResetsInMin, MP_WINDOW_MIN), w),
237 parts: isRow
238 ? [reading(`${h.mp}%`, mpFill), ...(empty === undefined ? detail([shortReset(h.mpResetsInMin)]) : runsOut(empty))]
239 : [
240 reading(`${h.mp}%`, mpFill),
241 ...detail([resets(h.mpResetsInMin), empty === undefined ? pace(h.mp, h.mpResetsInMin, MP_WINDOW_MIN) : undefined]),
242 ...runsOut(empty),
243 ],
244 })
245 }
246 if (h.st !== undefined) {
247 const stFill = h.st < 15 ? RED : pairOf(look.st ? look.st.fill : undefined, GOLD)
248 rows.push({
249 key: 'st',
250 look: look.st,
251 label: '◆ ST',
252 color: '#fbbf24',
253 pct: h.st,
254 bar: barCanvas(h.st, stFill, evenLeft(h.stResetsInMin, ST_WINDOW_MIN), w),
255 parts: [reading(`${h.st}%`, stFill), ...detail(isRow ? [shortReset(h.stResetsInMin)] : [resets(h.stResetsInMin), pace(h.st, h.stResetsInMin, ST_WINDOW_MIN)])],
256 })
257 }
258 const shown = rows
259 .filter(r => r.look !== false)
260 .map(({ look: bar, ...r }) => (bar ? { ...r, label: bar.label ?? r.label, color: bar.color ?? r.color } : r))
261 // Stacked, labels pad to one width, so the bars line up.
262 const width = isRow ? 0 : Math.max(0, ...shown.map(r => [...r.label].length))
263
264 return shown.map(r => ({ ...r, label: r.label + ' '.repeat(Math.max(0, width - [...r.label].length)), cells: encodeCells(r.bar) }))
265}
266
267/**
268 * The window's edges as text, `width` cells across, side cells included. A full block is one pixel wide
269 * and a half block one pixel tall, so the lines are as thick as the pet's pixels. The corner cells stay
270 * empty, which notches each corner by one pixel.
271 */
272export function windowEdges(width: number) {
273 const run = Math.max(0, width - 2)
274
275 return { top: ` ${'▄'.repeat(run)} `, side: '█', bottom: ` ${'▀'.repeat(run)} ` }
276}
277
278/** The cells between bars laid in a row. */
279export const ROW_GAP = ' │ '
280
281/** The width in cells of `rows` laid in a row, one line with no window: each label, bar and text, and the gaps between them. */
282export const hudRowWidth = (rows: HudRow[]) =>
283 rows.reduce((n, r) => n + [...r.label].length + 1 + r.bar.w + r.parts.reduce((m, p) => m + [...p.text].length, 0), 0) + ROW_GAP.length * (rows.length - 1)
284
285/** The HUD window's width in cells, sides included: room for a label, a bar and the longest row's text. */
286export const HUD_WINDOW_W = 64
287
288/** The cells a compact row needs beside its bar: the margin, a label, a gap, a reading and one short detail. */
289const COMPACT_TEXT_W = 19
290const COMPACT_MIN_BAR_W = 4
291/** Under this many columns the HUD draws nothing: a bar would be too short to read. */
292export const COMPACT_MIN_W = 16
293
294/**
295 * The bar width of the compact HUD, one bar a line with no window, for a terminal narrower than the window, such
296 * as a pane split beside others: the row's bar width when it fits, shorter down to four cells, none under
297 * COMPACT_MIN_W columns. Text past the edge is cut, never wrapped.
298 */
299export function compactBarW(columns: number): number | undefined {
300 if (columns < COMPACT_MIN_W) {
301 return undefined
302 }
303
304 return Math.max(COMPACT_MIN_BAR_W, Math.min(ROW_BAR_W, columns - COMPACT_TEXT_W))
305}
306hooks/minis.ts 39 lines1import type { AgentInfo } from 'claude-code'
2
3import type { MiniView } from './pixels'
4
5/** A subagent's mini: it joins when the agent starts and leaves `LEAVE_MS` after the agent ends. */
6export type Mini = { id: string; since: number; doneAt?: number; failed?: boolean }
7
8const LEAVE_MS = 1500
9
10const ALIVE = new Set(['running', 'pending'])
11
12/** The minis after a look at the session's agents at time `t`. */
13export function reconcile(minis: Mini[], agents: AgentInfo[], t: number): Mini[] {
14 const byId = new Map(agents.map(a => [a.id, a]))
15 const kept = minis.flatMap((m): Mini[] => {
16 if (m.doneAt !== undefined) {
17 return t - m.doneAt < LEAVE_MS ? [m] : []
18 }
19 const a = byId.get(m.id)
20 if (a && ALIVE.has(a.status)) {
21 return [m]
22 }
23
24 // An agent gone from the list counts as finished, not failed.
25 return [{ ...m, doneAt: t, failed: a !== undefined && a.status !== 'completed' }]
26 })
27 const known = new Set(minis.map(m => m.id))
28 const born = agents.filter(a => ALIVE.has(a.status) && !known.has(a.id)).map(a => ({ id: a.id, since: t }))
29
30 return [...kept, ...born]
31}
32
33/** The minis to draw at time `t`, as compose takes them: every running one, and every one that ended under `LEAVE_MS` ago. */
34export function minisOnScreen(minis: Mini[], t: number): MiniView[] {
35 return minis
36 .filter(m => m.doneAt === undefined || t - m.doneAt < LEAVE_MS)
37 .map(m => ({ age: t - m.since, doneFor: m.doneAt === undefined ? undefined : t - m.doneAt, failed: m.failed }))
38}
39hooks/theme.ts 581 lines1import type { Mode } from '../types'
2import type { BarLook, HudLook } from './hud'
3import { BODY_W, EYE_COLOR, HEIGHT, MINI_SIZE, MODES, PROP_W } from './pixels'
4import type { Body, BodyFrame, Look } from './pixels'
5import { EVERY, SCENE_SIZE } from './scene'
6import type { Scene } from './scene'
7
8/** A theme as its file spells it: the pet's sprite and everything else it changes. `skills/oxen-pet/FORMAT.md` documents each field for the people who write one. */
9export type Theme = {
10 name: string
11 scale: number
12 squash: number // how much the poses squash and stretch the sprite: 1 fully, 0 not at all, so it only lifts
13 sprite: string[]
14 palette: Record<string, string>
15 outline?: string
16 eyes?: [[number, number], [number, number]] // absent: no eyes and no faces
17 eyeColor: string
18 cheeks?: [[number, number], [number, number]]
19 cheekColor?: string
20 mini: { top: string; body: string; edge: string }
21 miniSprite?: string[]
22 props: Body['props']
23 scene?: Scene
24 forms: Partial<Record<Mode, Form>> // how the pet looks in a mode, in place of its own sprite fields
25} & Look
26
27/** The sprite fields a form sets for one mode; those it leaves out are the pet's own. */
28export type Form = Pick<Theme, 'scale' | 'squash' | 'sprite' | 'palette' | 'outline' | 'eyes' | 'eyeColor' | 'cheeks' | 'cheekColor'>
29const FORM_KEYS = ['sprite', 'palette', 'scale', 'squash', 'outline', 'eyes', 'eyeColor', 'cheeks', 'cheekColor'] as const
30
31// The frames mark cheeks and sparkles, and the resting frame pupils, with characters a palette may not use.
32const CHEEK_MARK = '*'
33const SPARKLE_MARK = '+'
34const PUPIL_MARK = '@'
35const RESERVED = ['.', CHEEK_MARK, SPARKLE_MARK, PUPIL_MARK]
36const SPARKLE_COLOR = 0xffe25a
37const SLIME_MINI = { top: '#9ad2ff', body: '#3d84f0', edge: '#1e3a8a' }
38const PROP_SIZE = { w: PROP_W, h: HEIGHT }
39const MAX_PROP_FRAMES = 8
40const MAX_LINE = 40 // characters in a status line, so it fits beside the pet
41const MAX_LABEL = 6 // characters in a HUD label, so the HUD fits its window
42const BARS = ['hp', 'mp', 'st'] as const
43
44// Each pose squashes the sprite by sx, sy and lifts it by dy pixels, all before `scale`. The run and jump poses
45// also carry an eye hint. Tuned on the slime; any pet that fits the canvas reuses them.
46type Pose = [sx: number, sy: number, dy: number, hint?: string]
47const STAND: Pose[] = Array.from({ length: 16 }, (_, i) => {
48 const s = (1 - Math.cos((2 * Math.PI * i) / 16)) / 2
49 return [1 + 0.06 * s, 1 - 0.09 * s, 0]
50})
51const RUN: Pose[] = [[1.18, 0.74, 0, 'open'], [0.86, 1.22, 1, 'wide'], [0.9, 1.15, 4, 'wide'], [0.95, 1.05, 6, 'open'], [0.92, 1.12, 4, 'open'], [0.9, 1.18, 1, 'open'], [1.22, 0.7, 0, 'bar'], [1.08, 0.9, 0, 'open']]
52const JUMP: Pose[] = [[1.15, 0.8, 0, 'open'], [1.22, 0.7, 0, 'bar'], [0.85, 1.25, 2, 'wide'], [0.88, 1.2, 5, 'wide'], [0.92, 1.12, 8, 'wide'], [0.96, 1.04, 9, 'open'], [1, 1, 9, 'open'],
53 [0.96, 1.04, 8, 'open'], [0.92, 1.1, 6, 'open'], [0.9, 1.15, 3, 'open'], [0.9, 1.2, 1, 'open'], [1.25, 0.68, 0, 'bar'], [1.12, 0.85, 0, 'bar'], [1.04, 0.95, 0, 'open']]
54const THINK: Pose[] = Array.from({ length: 12 }, (_, i) => {
55 const wobble = 1 + 0.03 * Math.sin((2 * Math.PI * i) / 6)
56 return [wobble, 2 - wobble, 0]
57})
58const CHEER: Pose[] = [[1, 1, 0], [1.18, 0.8, 0], [0.9, 1.18, 2], [0.95, 1.08, 4], [0.98, 1.02, 4], [0.92, 1.12, 2], [1.2, 0.76, 0], [0.88, 1.2, 2], [0.95, 1.08, 4],
59 [0.98, 1.02, 4], [0.92, 1.12, 2], [1.2, 0.76, 0], [0.88, 1.2, 2], [0.95, 1.08, 4], [0.98, 1.02, 4], [0.92, 1.12, 2], [1.2, 0.76, 0], [1, 1, 0]]
60const POSES = [...STAND, ...RUN, ...JUMP, ...THINK, ...CHEER]
61
62function sparkle(x: number, y: number, isBig: boolean): [number, number][] {
63 return isBig ? [[x, y], [x - 1, y], [x + 1, y], [x, y - 1], [x, y + 1]] : [[x, y]]
64}
65
66const cheerSparkles = (i: number) =>
67 i % 3 ? [...sparkle(2, 8, i % 2 === 0), ...sparkle(16, 6, i % 2 === 1)] : [...sparkle(3, 6, true), ...sparkle(15, 9, false)]
68
69// Round half to even. The frames theme.test.ts pins depend on it.
70function roundHalfEven(v: number) {
71 const r = Math.round(v)
72 return Math.abs(v % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r
73}
74
75/** The largest sprite, in pixels, that fits the canvas in every pose at `scale`. */
76export function maxSize(scale: number) {
77 return {
78 w: Math.floor(Math.min(...POSES.map(([sx]) => BODY_W / (sx * scale))) + 1e-9),
79 h: Math.floor(Math.min(...POSES.map(([, sy, dy]) => (HEIGHT / scale - dy) / sy)) + 1e-9),
80 }
81}
82
83const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
84const isPoint = (v: unknown): v is [number, number] => Array.isArray(v) && v.length === 2 && v.every(n => Number.isInteger(n))
85const isPair = (v: unknown): v is [[number, number], [number, number]] => Array.isArray(v) && v.length === 2 && v.every(isPoint)
86
87/** `#rrggbb` from `#rrggbb` or `#rgb`, or undefined for anything else. */
88function color(v: unknown) {
89 if (typeof v !== 'string') {
90 return undefined
91 }
92 if (/^#[0-9a-fA-F]{6}$/.test(v)) {
93 return v.toLowerCase()
94 }
95
96 return /^#[0-9a-fA-F]{3}$/.test(v) ? `#${[...v.slice(1)].map(c => c + c).join('')}`.toLowerCase() : undefined
97}
98
99/** The largest scale, up to `scale`, at which a w×h sprite fits the canvas in every pose. */
100function fitScale(w: number, h: number, scale: number) {
101 const fits = Math.min(scale, ...POSES.map(([sx, sy, dy]) => Math.min(BODY_W / (sx * w), HEIGHT / (dy + sy * h))))
102
103 return fits < scale ? Math.floor(fits * 100) / 100 : scale
104}
105
106/** Rows drawn in the pet's palette, cut to `max`, or undefined when `v` is not rows. Notes say what changed. */
107function paletteRows(v: unknown, max: { w: number; h: number }, palette: Record<string, string>, what: string, notes: string[]) {
108 if (!Array.isArray(v) || !v.some(r => typeof r === 'string' && r.length > 0)) {
109 notes.push(`${what} is not a list of text rows, so it is left out.`)
110 return undefined
111 }
112 let rows = v.map(r => (typeof r === 'string' ? r : ''))
113 const w = Math.max(...rows.map(r => r.length))
114 if (w > max.w || rows.length > max.h) {
115 notes.push(`${what} is ${w}×${rows.length}, past the largest, ${max.w}×${max.h}, so its bottom-left part is kept.`)
116 rows = rows.slice(-max.h).map(r => r.slice(0, max.w))
117 }
118 rows = rows.map(r => r.padEnd(Math.min(w, max.w), '.'))
119 if (rows.some(r => /[*+@]/.test(r))) {
120 notes.push(`In ${what}, "*", "+", "@" pixels are drawn clear: the mod marks its own drawings with them.`)
121 rows = rows.map(r => r.replace(/[*+@]/g, '.'))
122 }
123 const uncolored = [...new Set(rows.join(''))].filter(c => c !== '.' && !(c in palette))
124 if (uncolored.length > 0) {
125 notes.push(`In ${what}, ${uncolored.map(c => `"${c}"`).join(', ')} has no palette color, so it is drawn clear.`)
126 }
127
128 return rows
129}
130
131/** The pet's own props by mode: frames of rows, or null for no prop. */
132function readProps(v: unknown, palette: Record<string, string>, notes: string[]) {
133 const props: Body['props'] = {}
134 if (v === undefined) {
135 return props
136 }
137 if (!isObject(v)) {
138 notes.push('`props` maps a mode to its prop, so it is left out.')
139 return props
140 }
141 for (const [mode, value] of Object.entries(v)) {
142 if (!(mode in MODES) || mode === 'run') {
143 notes.push(`"${mode}" in \`props\` is not a mode that can hold a prop, so it is left out.`)
144 continue
145 }
146 if (value === false || value === null) {
147 props[mode as Mode] = null
148 continue
149 }
150 const frames = Array.isArray(value) && value.length > 0 && value.every(Array.isArray) ? value : [value]
151 if (frames.length > MAX_PROP_FRAMES) {
152 notes.push(`The ${mode} prop keeps its first ${MAX_PROP_FRAMES} frames.`)
153 }
154 const kept = frames
155 .slice(0, MAX_PROP_FRAMES)
156 .map((f, i) => paletteRows(f, PROP_SIZE, palette, frames.length > 1 ? `frame ${i + 1} of the ${mode} prop` : `the ${mode} prop`, notes))
157 .filter((f): f is string[] => f !== undefined)
158 if (kept.length > 0) {
159 props[mode as Mode] = kept
160 }
161 }
162
163 return props
164}
165
166/** Status lines or line colors by mode, each value checked by `read`. */
167function byMode<T>(v: unknown, field: string, read: (value: unknown, mode: string) => T | undefined, notes: string[]) {
168 const out: Partial<Record<Mode, T>> = {}
169 if (v === undefined) {
170 return out
171 }
172 if (!isObject(v)) {
173 notes.push(`\`${field}\` maps a mode to its value, so it is left out.`)
174 return out
175 }
176 for (const [mode, value] of Object.entries(v)) {
177 if (!(mode in MODES)) {
178 notes.push(`"${mode}" in \`${field}\` is not a mode, so it is left out.`)
179 continue
180 }
181 const kept = read(value, mode)
182 if (kept !== undefined) {
183 out[mode as Mode] = kept
184 }
185 }
186
187 return out
188}
189
190function readLines(v: unknown, notes: string[]) {
191 return byMode(v, 'lines', (value, mode) => {
192 const lines = (Array.isArray(value) ? value : [value]).filter((l): l is string => typeof l === 'string' && l.trim() !== '').map(l => l.trim())
193 if (lines.length === 0) {
194 notes.push(`\`lines.${mode}\` has no text, so the ${mode} mode keeps its own lines.`)
195 return undefined
196 }
197 if (lines.some(l => l.length > MAX_LINE)) {
198 notes.push(`Lines in \`lines.${mode}\` are cut to ${MAX_LINE} characters.`)
199 }
200 return lines.map(l => l.slice(0, MAX_LINE))
201 }, notes)
202}
203
204function readLineColors(v: unknown, notes: string[]) {
205 return byMode(v, 'lineColors', (value, mode) => {
206 const c = color(value)
207 if (c === undefined) {
208 notes.push(`\`lineColors.${mode}\`, ${JSON.stringify(value)}, is not "#rrggbb", so the ${mode} mode keeps its own color.`)
209 }
210 return c
211 }, notes)
212}
213
214function readHud(v: unknown, notes: string[]) {
215 const hud: HudLook = {}
216 if (v === undefined) {
217 return hud
218 }
219 if (!isObject(v)) {
220 notes.push('`hud` is an object, so the HUD keeps its own look.')
221 return hud
222 }
223 if (v.frame !== undefined) {
224 hud.frame = color(v.frame)
225 if (hud.frame === undefined) {
226 notes.push(`\`hud.frame\`, ${JSON.stringify(v.frame)}, is not "#rrggbb", so the frame keeps its own color.`)
227 }
228 }
229 for (const key of BARS) {
230 const b = v[key]
231 if (b === undefined) {
232 continue
233 }
234 if (b === false) {
235 hud[key] = false
236 continue
237 }
238 if (!isObject(b)) {
239 notes.push(`\`hud.${key}\` is an object or false, so that bar keeps its own look.`)
240 continue
241 }
242 const look: BarLook = {}
243 if (typeof b.label === 'string' && b.label.trim() !== '') {
244 const label = [...b.label.trim()]
245 if (label.length > MAX_LABEL) {
246 notes.push(`\`hud.${key}.label\` is cut to ${MAX_LABEL} characters.`)
247 }
248 look.label = label.slice(0, MAX_LABEL).join('')
249 }
250 if (b.color !== undefined) {
251 look.color = color(b.color)
252 if (look.color === undefined) {
253 notes.push(`\`hud.${key}.color\` is not "#rrggbb", so the label keeps its own color.`)
254 }
255 }
256 if (b.fill !== undefined) {
257 const fill = Array.isArray(b.fill) && b.fill.length === 2 ? b.fill.map(color) : []
258 if (fill[0] !== undefined && fill[1] !== undefined) {
259 look.fill = [fill[0], fill[1]]
260 } else {
261 notes.push(`\`hud.${key}.fill\` is two "#rrggbb" colors, so the bar keeps its own fill.`)
262 }
263 }
264 hud[key] = look
265 }
266
267 return hud
268}
269
270/** A list of drawings in the pet's palette, each cut to `max`; at most SCENE_SIZE.items of them. */
271function drawings(v: unknown, max: { w: number; h: number }, palette: Record<string, string>, what: string, notes: string[]) {
272 if (v === undefined) {
273 return []
274 }
275 const list = Array.isArray(v) && v.length > 0 && v.every(Array.isArray) ? v : [v]
276 if (list.length > SCENE_SIZE.items) {
277 notes.push(`\`${what}\` keeps its first ${SCENE_SIZE.items}.`)
278 }
279
280 return list
281 .slice(0, SCENE_SIZE.items)
282 .map((d, i) => paletteRows(d, max, palette, `\`${what}\` ${i + 1}`, notes))
283 .filter((d): d is string[] => d !== undefined)
284}
285
286function readScene(v: unknown, palette: Record<string, string>, notes: string[]): Scene | undefined {
287 if (v === undefined) {
288 return undefined
289 }
290 if (!isObject(v)) {
291 notes.push('`scene` is an object, so the pet has no scene.')
292 return undefined
293 }
294 const ground = v.ground === undefined ? undefined : paletteRows(v.ground, SCENE_SIZE.ground, palette, '`scene.ground`', notes)
295 const sky = v.sky === undefined ? undefined : paletteRows(v.sky, SCENE_SIZE.sky, palette, '`scene.sky`', notes)
296 const obstacles = drawings(v.obstacles, SCENE_SIZE.obstacle, palette, 'scene.obstacles', notes)
297 const decor = drawings(v.decor, SCENE_SIZE.decor, palette, 'scene.decor', notes)
298 if (!ground && !sky && obstacles.length === 0 && decor.length === 0) {
299 notes.push('`scene` has no ground, sky, obstacles, or decor to draw, so the pet has no scene.')
300 return undefined
301 }
302 let every = EVERY.normal
303 if (v.every !== undefined) {
304 const asked = typeof v.every === 'number' ? Math.round(v.every) : NaN
305 every = Number.isNaN(asked) ? EVERY.normal : Math.min(EVERY.max, Math.max(EVERY.min, asked))
306 if (every !== asked) {
307 notes.push(`\`scene.every\` is a number of columns from ${EVERY.min} to ${EVERY.max}, so it is ${every}.`)
308 }
309 }
310
311 return { ground, ...(sky && { sky }), obstacles, decor, every }
312}
313
314/**
315 * A theme from a parsed theme file, or why there is none. Only a value with no sprite is refused. Anything else
316 * draws: the mod repairs what it can and says what it did in `notes`, which the drawer may act on or ignore.
317 */
318export function readTheme(v: unknown): { theme: Theme; notes: string[]; errors?: undefined } | { errors: string[] } {
319 if (!isObject(v)) {
320 return { errors: ['A theme is a JSON object with a `sprite`.'] }
321 }
322 if (!Array.isArray(v.sprite) || !v.sprite.some(r => typeof r === 'string' && r.length > 0)) {
323 return { errors: ['`sprite` is a list of text rows, one character per pixel.'] }
324 }
325 const notes: string[] = []
326 const name = typeof v.name === 'string' && v.name.trim() !== '' ? v.name.trim().slice(0, 24) : 'pet'
327
328 let rows = (v.sprite as unknown[]).map(r => (typeof r === 'string' ? r : ''))
329 const w = Math.max(...rows.map(r => r.length))
330 if (rows.some(r => r.length !== w)) {
331 notes.push(`Rows of different widths were padded with "." to ${w}.`)
332 rows = rows.map(r => r.padEnd(w, '.'))
333 }
334 if (rows.some(r => RESERVED.slice(1).some(c => r.includes(c)))) {
335 notes.push(`The sprite's ${RESERVED.slice(1).map(c => `"${c}"`).join(', ')} pixels are drawn clear: the mod marks its own drawings with them.`)
336 rows = rows.map(r => r.replace(/[*+@]/g, '.'))
337 }
338 const h = rows.length
339
340 const palette: Record<string, string> = {}
341 for (const [ch, value] of Object.entries(isObject(v.palette) ? v.palette : {})) {
342 const c = color(value)
343 if (c === undefined) {
344 notes.push(`The palette color for "${ch}", ${JSON.stringify(value)}, is not "#rrggbb", so "${ch}" is drawn clear.`)
345 } else if (!RESERVED.includes(ch)) {
346 palette[ch] = c
347 }
348 }
349 const uncolored = [...new Set(rows.join(''))].filter(c => c !== '.' && !(c in palette))
350 if (uncolored.length > 0) {
351 notes.push(`${uncolored.map(c => `"${c}"`).join(', ')} has no palette color, so it is drawn clear.`)
352 }
353
354 const asked = typeof v.scale === 'number' && v.scale > 0 ? v.scale : 1
355 const scale = fitScale(w, h, asked)
356 if (scale < asked) {
357 notes.push(`A ${w}×${h} sprite is drawn at scale ${scale} to fit every pose. At scale 1 the largest is ${maxSize(1).w}×${maxSize(1).h}, and a smaller scale blurs detail.`)
358 }
359
360 let squash = 1
361 if (v.squash !== undefined) {
362 const isNumber = typeof v.squash === 'number' && Number.isFinite(v.squash)
363 squash = isNumber ? Math.min(1, Math.max(0, v.squash as number)) : 1
364 if (!isNumber || squash !== v.squash) {
365 notes.push(`\`squash\` is a number from 0 to 1, so ${JSON.stringify(v.squash)} reads as ${squash}.`)
366 }
367 }
368
369 const outline = typeof v.outline === 'string' && v.outline in palette ? v.outline : undefined
370 const eyes = isPair(v.eyes) ? v.eyes : undefined
371 if (v.eyes !== undefined && eyes === undefined) {
372 notes.push('`eyes` is two [x, y] points, so the pet has no eyes and no faces.')
373 }
374 const inside = (x: number, y: number) => x >= 0 && x < w && y >= 0 && y < h
375 for (const [x, y] of eyes ?? []) {
376 if (!inside(x, y - 1) || !inside(x + 2, y + 1)) {
377 notes.push(`The eye at [${x}, ${y}] draws its 3×3 box partly off the ${w}×${h} sprite.`)
378 }
379 }
380 if (eyes && Math.abs(eyes[0][0] - eyes[1][0]) < 3 && Math.abs(eyes[0][1] - eyes[1][1]) < 3) {
381 notes.push('The eye boxes overlap, so the wide eyes and hearts merge into one shape. Pupils 4 pixels apart keep them apart.')
382 }
383 const cheeks = isPair(v.cheeks) ? v.cheeks : undefined
384 if (v.cheeks !== undefined && cheeks === undefined) {
385 notes.push('`cheeks` is two [x, y] pixels, so the pet has no cheeks.')
386 }
387 const inEyeBox = ([x, y]: [number, number]) => (eyes ?? []).some(([ex, ey]) => x >= ex && x <= ex + 2 && y >= ey - 1 && y <= ey + 1)
388 if (cheeks?.some(inEyeBox)) {
389 notes.push('A cheek sits inside an eye box, where some faces draw over it.')
390 }
391 const mini = isObject(v.mini) ? { top: color(v.mini.top), body: color(v.mini.body), edge: color(v.mini.edge) } : undefined
392 const hasMini = mini?.top !== undefined && mini.body !== undefined && mini.edge !== undefined
393 if (v.mini !== undefined && !hasMini) {
394 notes.push('`mini` needs three colors, `top`, `body`, and `edge`, so the minis keep the slime\'s blues.')
395 }
396 const miniSprite = v.miniSprite === undefined ? undefined : paletteRows(v.miniSprite, MINI_SIZE, palette, '`miniSprite`', notes)
397
398 return {
399 theme: {
400 name,
401 scale,
402 squash,
403 sprite: rows,
404 palette,
405 outline,
406 eyes,
407 eyeColor: color(v.eyeColor) ?? '#000000',
408 cheeks,
409 cheekColor: cheeks ? (color(v.cheekColor) ?? '#ff8aaa') : undefined,
410 mini: hasMini ? (mini as Theme['mini']) : SLIME_MINI,
411 miniSprite,
412 props: readProps(v.props, palette, notes),
413 lines: readLines(v.lines, notes),
414 lineColors: readLineColors(v.lineColors, notes),
415 hud: readHud(v.hud, notes),
416 scene: readScene(v.scene, palette, notes),
417 forms: readForms(v, notes),
418 },
419 notes,
420 }
421}
422
423/**
424 * The forms by mode. Each is read as a sprite of its own, from the pet's sprite fields with the form's over them and
425 * the palettes merged, so a form may only recolor. Its notes say which form they are about, past those the pet's own
426 * fields gave already.
427 */
428function readForms(v: Record<string, unknown>, notes: string[]): Theme['forms'] {
429 const forms: Theme['forms'] = {}
430 if (v.forms === undefined) {
431 return forms
432 }
433 if (!isObject(v.forms)) {
434 notes.push('`forms` maps a mode to its form, so it is left out.')
435 return forms
436 }
437 const fields = (o: Record<string, unknown>) => Object.fromEntries(FORM_KEYS.filter(k => o[k] !== undefined).map(k => [k, o[k]]))
438 const before = new Set(notes)
439 for (const [mode, form] of Object.entries(v.forms)) {
440 if (!(mode in MODES)) {
441 notes.push(`"${mode}" in \`forms\` is not a mode, so it is left out.`)
442 continue
443 }
444 if (!isObject(form)) {
445 notes.push(`\`forms.${mode}\` is not an object of sprite fields, so it is left out.`)
446 continue
447 }
448 const palette = { ...(isObject(v.palette) ? v.palette : {}), ...(isObject(form.palette) ? form.palette : {}) }
449 const read = readTheme({ ...fields(v), ...fields(form), palette })
450 if (read.errors) {
451 notes.push(`forms.${mode}: ${read.errors.join(' ')}`)
452 continue
453 }
454 notes.push(...read.notes.filter(n => !before.has(n)).map(n => `forms.${mode}: ${n}`))
455 const t = read.theme
456 forms[mode as Mode] = { scale: t.scale, squash: t.squash, sprite: t.sprite, palette: t.palette, outline: t.outline, eyes: t.eyes, eyeColor: t.eyeColor, cheeks: t.cheeks, cheekColor: t.cheekColor }
457 }
458
459 return forms
460}
461
462const colorOf = (color: string) => parseInt(color.slice(1), 16)
463
464function poseFrame(theme: Form, pose: Pose, extra: [number, number][] = []): BodyFrame {
465 // squash scales how far the pose bends the sprite; the lift stays whole.
466 const bend = (f: number) => 1 + (f - 1) * theme.squash
467 const [sx, sy, dy] = [bend(pose[0]) * theme.scale, bend(pose[1]) * theme.scale, pose[2] * theme.scale]
468 const sw = (theme.sprite[0] as string).length
469 const sh = theme.sprite.length
470 const cx = sw / 2
471 const tcx = sw % 2 ? BODY_W / 2 : Math.floor(BODY_W / 2) // whole-pixel aligned, so at scale 1 a resting sprite is copied, not resampled
472 const tb = HEIGHT - dy
473 const g = Array.from({ length: HEIGHT }, () => new Array<string>(BODY_W).fill('.'))
474 const put = (x: number, y: number, ch: string) => {
475 const [px, py] = [roundHalfEven(x), roundHalfEven(y)]
476 if (px >= 0 && px < BODY_W && py >= 0 && py < HEIGHT) {
477 ;(g[py] as string[])[px] = ch
478 }
479 }
480 // Each canvas pixel takes the sprite color that covers most of it; the outline counts extra, so it survives a squash.
481 for (let ty = 0; ty < HEIGHT; ty++) {
482 for (let tx = 0; tx < BODY_W; tx++) {
483 const u0 = cx + (tx - tcx) / sx
484 const u1 = cx + (tx + 1 - tcx) / sx
485 const v0 = sh - (tb - ty) / sy
486 const v1 = sh - (tb - ty - 1) / sy
487 const cover = new Map<string, number>()
488 for (let v = Math.max(0, Math.floor(v0)); v < Math.min(sh, Math.ceil(v1)); v++) {
489 for (let u = Math.max(0, Math.floor(u0)); u < Math.min(sw, Math.ceil(u1)); u++) {
490 const area = (Math.min(u1, u + 1) - Math.max(u0, u)) * (Math.min(v1, v + 1) - Math.max(v0, v))
491 const ch = (theme.sprite[v] as string)[u] as string
492 if (area > 0 && ch !== '.') {
493 cover.set(ch, (cover.get(ch) ?? 0) + area * (ch === theme.outline ? 1.6 : 1))
494 }
495 }
496 }
497 let total = 0
498 let best = ''
499 for (const [ch, area] of cover) {
500 total += area
501 if (best === '' || area > (cover.get(best) as number)) {
502 best = ch
503 }
504 }
505 if (best !== '' && total >= 0.45 * (u1 - u0) * (v1 - v0)) {
506 ;(g[ty] as string[])[tx] = best
507 }
508 }
509 }
510 const at = ([x, y]: [number, number]) => [tcx + (x - cx) * sx, tb - (sh - y) * sy] as const
511 for (const [x, y] of theme.cheeks ?? []) {
512 const [px, py] = at([x + 0.5, y + 0.5])
513 put(px - 0.5, py - 0.5, CHEEK_MARK)
514 }
515 for (const [x, y] of extra) {
516 put(x, y, SPARKLE_MARK)
517 }
518 const [l, r] = (theme.eyes ?? [[0, 0], [0, 0]]).map(([x, y]) => {
519 const [px, py] = at([x + 1.5, y + 0.5])
520 return [Math.floor(px - 1.5 + 0.5), Math.floor(py - 0.5 + 0.5) - 1] as [number, number]
521 }) as [[number, number], [number, number]]
522
523 return { g: g.map(row => row.join('')), l, r, e: pose[3] ?? '' }
524}
525
526/** A sprite's colors as the drawing code reads them, and its frames for every clip. */
527function drawn(theme: Form): Pick<Body, 'palette' | 'eye' | 'clips'> {
528 const palette: Record<string, number> = { [SPARKLE_MARK]: SPARKLE_COLOR }
529 for (const [ch, color] of Object.entries(theme.palette)) {
530 palette[ch] = colorOf(color)
531 }
532 if (theme.cheekColor !== undefined) {
533 palette[CHEEK_MARK] = colorOf(theme.cheekColor)
534 }
535
536 return {
537 palette,
538 eye: theme.eyes ? { ...EYE_COLOR, K: colorOf(theme.eyeColor) } : {},
539 clips: {
540 stand: { fps: 8, frames: STAND.map(p => poseFrame(theme, p)) },
541 run: { fps: 12, frames: RUN.map(p => poseFrame(theme, p)) },
542 jump: { fps: 12, frames: JUMP.map(p => poseFrame(theme, p)) },
543 think: { fps: 6, frames: THINK.map(p => poseFrame(theme, p)) },
544 cheer: { fps: 10, frames: CHEER.map((p, i) => poseFrame(theme, p, cheerSparkles(i))) },
545 },
546 }
547}
548
549/** The pet's frames for every clip and every form, with its colors as the drawing code reads them. */
550export function animate(theme: Theme): Body {
551 return {
552 name: theme.name,
553 w: BODY_W,
554 h: HEIGHT,
555 ...drawn(theme),
556 mini: { top: colorOf(theme.mini.top), body: colorOf(theme.mini.body), edge: colorOf(theme.mini.edge) },
557 miniSprite: theme.miniSprite,
558 props: theme.props,
559 look: { lines: theme.lines, lineColors: theme.lineColors, hud: theme.hud },
560 scene: theme.scene,
561 forms: Object.fromEntries(Object.entries(theme.forms).map(([mode, form]) => [mode, drawn(form)])),
562 }
563}
564
565/** The resting frame as text, trimmed to the rows in use: palette characters, `@` for a pupil, `*` for a cheek. */
566export function restingFrame(body: Body) {
567 const frame = body.clips.stand.frames[0] as BodyFrame
568 const g = frame.g.map(row => [...row])
569 for (const [x, y] of Object.keys(body.eye).length > 0 ? [frame.l, frame.r] : []) {
570 for (const [dx, dy] of [[0, 1], [1, 1], [0, 2], [1, 2]] as const) {
571 const row = g[y + dy]
572 if (row && x + dx >= 0 && x + dx < row.length) {
573 row[x + dx] = PUPIL_MARK
574 }
575 }
576 }
577 const rows = g.map(r => r.join(''))
578
579 return rows.slice(rows.findIndex(r => /[^.]/.test(r))).join('\n')
580}
581hooks/preview.ts 249 lines1import type { Anim, Mode } from '../types'
2import { TICK_MS, leapClipMs, step } from './anim'
3import { HUD_WINDOW_W, ROW_GAP, frameColor, hudRows, windowEdges } from './hud'
4import type { Hud } from './hud'
5import { BOSS_W, DEFEAT_MS, drawBoss } from './boss'
6import { BODY_W, FACES, HEIGHT, MODES, canvas, compose, composeFace, overlay } from './pixels'
7import type { Body, Canvas, Clip } from './pixels'
8import { GROUND_H, drawBand, layScene, obstacleSpans } from './scene'
9import { LINES, lineColor } from './status'
10
11// When each mode plays, in the words of the README's table.
12const WHEN: Record<Mode, string> = {
13 idle: 'Claude is idle',
14 sleep: 'Idle for a while',
15 jump: 'A turn starts',
16 think: 'Claude thinks longer',
17 read: 'Read',
18 search: 'Grep, Glob',
19 edit: 'Edit, MultiEdit, Write, NotebookEdit, TodoWrite',
20 bash: 'Bash, and any tool not listed',
21 web: 'WebFetch, WebSearch',
22 run: 'A tool call ends',
23 agent: 'A subagent starts',
24 error: 'A tool call fails',
25 cheer: 'A turn ends',
26 guard: 'A destructive command waits for your answer',
27}
28// What the status line names in each mode, for the lines that name something.
29const SAMPLE_TARGET: Partial<Record<Mode, string>> = { read: 'app.ts', search: 'useState', edit: 'app.ts', bash: 'npm test', web: 'docs.anthropic.com', guard: 'rm -rf build' }
30const SAMPLE_HUDS: [string, Hud][] = [
31 ['Early in a session', { hp: 86, cacheMin: 52, mp: 72, mpResetsInMin: 213, st: 64, stResetsInMin: 4560 }],
32 ['Running low', { hp: 18, cacheMin: 0, mp: 12, mpResetsInMin: 41, st: 9, stResetsInMin: 1500 }],
33 ['Burning fast', { hp: 70, cacheMin: 4, mp: 40, mpResetsInMin: 180, st: 20, stResetsInMin: 5040 }],
34]
35const BOSS_GAP = 12 // columns between the pet and the boss in the boss tile
36const BOSS_BEATEN_AT = 2600
37const MOTION_FPS = 10
38const SCENE_W = 96 // columns of the sample band
39const SCENE_MS = 12000
40const FACE_FPS = 5
41const LOOP_MS = 2400
42
43// One character per palette entry; the set leaves out what would need escaping in HTML or a JS string.
44const CODES = [...Array.from({ length: 94 }, (_, i) => String.fromCharCode(33 + i)).filter(c => !`"'\\<>&\``.includes(c)), ...Array.from({ length: 200 }, (_, i) => String.fromCharCode(192 + i))]
45
46const escapeHtml = (s: string) => s.replace(/[&<>"']/g, c => `&#${c.charCodeAt(0)};`)
47
48type Tile = { label: string; note: string; w: number; h: number; fps: number; frames: string[] }
49
50/**
51 * The preview: a self-contained HTML page with every motion, face, and frame the mod makes for `body`. It shows each mode in
52 * motion with when it plays, the pet running through its scene when it has one, the boss fight, each face, each mode's status lines,
53 * the HUD in two sample states, and each clip's frames before eyes go on. A button switches to a light
54 * terminal's background. `notes` are readTheme's.
55 */
56export function previewPage(body: Body, notes: string[]): string {
57 const colors: number[] = []
58 const encode = (c: Canvas) =>
59 c.px
60 .map(color => {
61 if (color === -1) {
62 return ' '
63 }
64 let i = colors.indexOf(color)
65 if (i === -1) {
66 i = colors.push(color) - 1
67 }
68 return CODES[i] ?? ' '
69 })
70 .join('')
71 const frames = (count: number, fps: number, draw: (t: number) => Canvas) => Array.from({ length: count }, (_, i) => draw((i * 1000) / fps))
72
73 const motions: Tile[] = (Object.keys(WHEN) as Mode[]).map(mode => {
74 const length = MODES[mode].once ?? LOOP_MS
75 const minis = mode === 'agent' ? [{ age: 1000 }, { age: 400 }] : []
76 const shots = frames(Math.ceil((length / 1000) * MOTION_FPS), MOTION_FPS, t => compose(body, mode, t, 1, 'ok', minis.map(m => ({ age: m.age + t }))))
77 const first = shots[0] as Canvas
78 return { label: mode, note: WHEN[mode], w: first.w, h: first.h, fps: MOTION_FPS, frames: shots.map(encode) }
79 })
80 const faces: Tile[] = FACES.map(name => {
81 const shots = frames((LOOP_MS / 1000) * FACE_FPS, FACE_FPS, t => composeFace(body, name, t))
82 const first = shots[0] as Canvas
83 return { label: name, note: '', w: first.w, h: first.h, fps: FACE_FPS, frames: shots.map(encode) }
84 })
85 const clips: Tile[] = (Object.keys(body.clips) as Clip[]).map(clip => {
86 const { fps, frames: list } = body.clips[clip]
87 const shots = list.map(f => {
88 const c: Canvas = { w: BODY_W, h: HEIGHT, px: [] }
89 for (const row of f.g) {
90 for (const ch of row) {
91 c.px.push(body.palette[ch] ?? -1)
92 }
93 }
94 return c
95 })
96 return { label: clip, note: `${list.length} frames at ${fps} fps`, w: BODY_W, h: HEIGHT, fps, frames: shots.map(encode) }
97 })
98 const scenes: Tile[] = []
99 if (body.scene) {
100 const layout = layScene(body.scene, SCENE_W)
101 const between = { isWorking: true, activeTools: 0, activeMode: 'bash' as const, activeTarget: '', lastToolAt: Infinity, room: SCENE_W - BODY_W, obstacles: obstacleSpans(layout), trail: 0 }
102 let a: Anim = { mode: 'run', since: 0, x: 0, dir: 1, tick: 0, target: '', working: true }
103 const shots: string[] = []
104 for (let t = 0; t < SCENE_MS; t += TICK_MS) {
105 a = step(a, between, t)
106 const picture = a.leap ? compose(body, 'jump', leapClipMs(t - a.leap.since), a.dir) : compose(body, 'run', t - a.since, a.dir)
107 shots.push(encode(drawBand(body, body.scene, layout, picture, Math.round(a.x), t)))
108 }
109 scenes.push({ label: 'run', note: 'Between tool calls, leaping each obstacle on the way', w: SCENE_W, h: HEIGHT + GROUND_H, fps: 1000 / TICK_MS, frames: shots })
110 }
111 // A test run fails and brings the boss; the next run passes and defeats it, and the pet cheers.
112 const bossShots: string[] = []
113 const BOSS_FIGHT_W = BODY_W + BOSS_GAP + BOSS_W
114 for (let t = 0; t < BOSS_BEATEN_AT + DEFEAT_MS; t += TICK_MS) {
115 const isBeaten = t >= BOSS_BEATEN_AT
116 const c = canvas(BOSS_FIGHT_W, HEIGHT)
117 overlay(c, isBeaten ? compose(body, 'cheer', t - BOSS_BEATEN_AT, 1) : compose(body, t < MODES.error.once! ? 'error' : 'idle', t, 1), 0)
118 overlay(c, drawBoss(isBeaten ? { since: 0, hits: 2, defeatedAt: BOSS_BEATEN_AT } : { since: 0, hits: t < 1200 ? 1 : 2 }, t), BODY_W + BOSS_GAP)
119 bossShots.push(encode(c))
120 }
121 const bossFight: Tile[] = [{ label: 'boss', note: 'Failed test runs bring a bug; the next passing run defeats it', w: BOSS_FIGHT_W, h: HEIGHT, fps: 1000 / TICK_MS, frames: bossShots }]
122 const palette = colors.map(c => `#${c.toString(16).padStart(6, '0')}`)
123 const name = escapeHtml(body.name)
124 const tiles = (list: Tile[], kind: string) =>
125 list.map((t, i) => `<figure><canvas data-kind="${kind}" data-i="${i}" width="${t.w * 4}" height="${t.h * 4}"></canvas><figcaption><b>${t.label}</b>${t.note ? `<span>${escapeHtml(t.note)}</span>` : ''}</figcaption></figure>`).join('')
126 const statusLines = (Object.keys(WHEN) as Mode[])
127 .map(mode => {
128 const lines = (body.look.lines[mode] ?? LINES[mode]).map(l => `<li>› ${escapeHtml(l.replace('{}', SAMPLE_TARGET[mode] ?? 'something'))}</li>`).join('')
129 return `<div class="lines"><b>${mode}</b><ul style="color: ${lineColor(mode, body.look.lineColors)}">${lines}</ul></div>`
130 })
131 .join('')
132 const edges = windowEdges(HUD_WINDOW_W)
133 const huds = SAMPLE_HUDS.map(([label, h]) => {
134 const rows = hudRows(h, body.look.hud)
135 const inside = rows.length === 0
136 ? '<p>The pet hides every bar, so the HUD does not show.</p>'
137 : `<pre class="hud" style="color: ${frameColor(body.look.hud)}">${escapeHtml(edges.top)}\n${rows
138 .map(r => {
139 // Padded to the window's inside, so the right side lands where the terminal draws it.
140 const used = 1 + [...r.label].length + 1 + r.bar.w + r.parts.reduce((n, p) => n + [...p.text].length, 0)
141 const pad = ' '.repeat(Math.max(0, HUD_WINDOW_W - 2 - used))
142 return `${edges.side} <span style="color: ${r.color}">${escapeHtml(r.label)} </span><canvas class="bar" data-cells="${r.cells}"></canvas>${r.parts.map(p => `<span style="color: ${p.color}${p.bold ? '; font-weight: bold' : ''}">${escapeHtml(p.text)}</span>`).join('')}${pad}${edges.side}`
143 })
144 .join('\n')}\n${escapeHtml(edges.bottom)}</pre>`
145 return `<figure class="wide"><figcaption><b>${label}</b></figcaption>${inside}</figure>`
146 }).join('')
147 // The first sample again, laid in a row as the HUD layout setting's `row` draws it.
148 const inRow = hudRows((SAMPLE_HUDS[0] as [string, Hud])[1], body.look.hud, true)
149 const rowHud = inRow.length === 0
150 ? ''
151 : `<figure class="wide"><figcaption><b>In a row</b><span>HUD layout: row, the default: one line, no window</span></figcaption><pre class="hud-row" style="color: ${frameColor(body.look.hud)}">${inRow
152 .map(r => `<span style="color: ${r.color}">${escapeHtml(r.label)} </span><canvas class="bar" data-cells="${r.cells}"></canvas>${r.parts.map(p => `<span style="color: ${p.color}${p.bold ? '; font-weight: bold' : ''}">${escapeHtml(p.text)}</span>`).join('')}`)
153 .join(escapeHtml(ROW_GAP))}</pre></figure>`
154 const noteList = notes.length > 0 ? `<section class="notes"><h2>Notes</h2><ul>${notes.map(n => `<li>${escapeHtml(n)}</li>`).join('')}</ul></section>` : ''
155
156 return `<!doctype html>
157<html lang="en">
158<head>
159<meta charset="utf-8">
160<meta name="viewport" content="width=device-width, initial-scale=1">
161<title>${name}: preview</title>
162<style>
163 body { margin: 0; padding: 32px; font: 15px/1.5 ui-monospace, Menlo, monospace; background: #0b0f2a; color: #e3e8ff; }
164 body.light { background: #f4f5f9; color: #1b1e2b; }
165 header { display: flex; flex-wrap: wrap; gap: 16px; align-items: baseline; justify-content: space-between; max-width: 1100px; }
166 h1 { margin: 0; font-size: 28px; }
167 h2 { font-size: 18px; margin: 32px 0 12px; }
168 p { margin: 4px 0 0; opacity: 0.8; }
169 button { font: inherit; padding: 8px 14px; border: 2px solid currentColor; background: transparent; color: inherit; cursor: pointer; }
170 .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 16px; max-width: 1100px; }
171 figure { margin: 0; padding: 12px; border: 2px solid #2a3470; display: grid; gap: 8px; justify-items: center; }
172 body.light figure { border-color: #c9cede; }
173 canvas { image-rendering: pixelated; max-width: 100%; }
174 figcaption { display: grid; text-align: center; }
175 figcaption span { font-size: 12px; opacity: 0.7; }
176 .notes { max-width: 1100px; border: 2px solid #e0b400; padding: 0 16px 8px; }
177 .wide-cells { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
178 .stack { display: grid; gap: 16px; max-width: 1100px; }
179 .wide { justify-items: start; overflow-x: auto; }
180 .wide canvas[data-kind] { width: 100%; height: auto; }
181 .lines ul { margin: 4px 0 0; padding: 0; list-style: none; font-weight: bold; }
182 .hud, .hud-row { margin: 0; line-height: 18px; }
183 .hud canvas.bar, .hud-row canvas.bar { height: 18px; vertical-align: top; } /* as wide as its cells, which the script sets */
184</style>
185</head>
186<body>
187<header>
188 <div><h1>${name}</h1><p>Every motion, face, status line, and HUD look of this pet${scenes.length > 0 ? ', and its scene' : ''}. Approve it in Claude Code, or say what to change.</p></div>
189 <button type="button" aria-pressed="false" id="light">Light terminal</button>
190</header>
191${noteList}
192<h2>Motions</h2>
193<div class="grid">${tiles(motions, 'motion')}</div>
194${scenes.length > 0 ? `<h2>Scene</h2>\n<div class="stack">${tiles(scenes, 'scene').replace('<figure>', '<figure class="wide">')}</div>` : ''}
195<h2>Boss</h2>
196<div class="grid">${tiles(bossFight, 'boss')}</div>
197<h2>Faces</h2>
198<div class="grid">${tiles(faces, 'face')}</div>
199<h2>Status lines</h2>
200<p>The line beside the pet in each mode. One shows at a time, and the next takes over every 4 seconds.</p>
201<div class="grid wide-cells">${statusLines}</div>
202<h2>HUD</h2>
203<div class="stack">${huds}${rowHud}</div>
204<h2>Frames</h2>
205<p>Each clip as the mod squashes and stretches the sprite, before eyes and props go on.</p>
206<div class="grid">${tiles(clips, 'clip')}</div>
207<script>
208const PALETTE = ${JSON.stringify(palette)}
209const CODES = ${JSON.stringify(CODES.slice(0, palette.length).join(''))}
210const TILES = { motion: ${JSON.stringify(motions)}, scene: ${JSON.stringify(scenes)}, boss: ${JSON.stringify(bossFight)}, face: ${JSON.stringify(faces)}, clip: ${JSON.stringify(clips)} }
211const still = matchMedia('(prefers-reduced-motion: reduce)').matches
212const canvases = [...document.querySelectorAll('canvas[data-kind]')].map(c => ({ ctx: c.getContext('2d'), tile: TILES[c.dataset.kind][c.dataset.i] }))
213function draw(ctx, tile, frame) {
214 ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
215 for (let i = 0; i < frame.length; i++) {
216 const k = CODES.indexOf(frame[i])
217 if (k < 0) continue
218 ctx.fillStyle = PALETTE[k]
219 ctx.fillRect((i % tile.w) * 4, Math.floor(i / tile.w) * 4, 4, 4)
220 }
221}
222function loop(now) {
223 for (const { ctx, tile } of canvases) draw(ctx, tile, tile.frames[still ? 0 : Math.floor((now / 1000) * tile.fps) % tile.frames.length])
224 if (!still) requestAnimationFrame(loop)
225}
226requestAnimationFrame(loop)
227// A bar's cells as the terminal draws them: little-endian u32 triplets [glyph, fg, bg], ▀ or ▄, 2 pixels per cell.
228for (const bar of document.querySelectorAll('canvas.bar')) {
229 const b = atob(bar.dataset.cells)
230 const u32 = k => (b.charCodeAt(k) | (b.charCodeAt(k + 1) << 8) | (b.charCodeAt(k + 2) << 16) | (b.charCodeAt(k + 3) << 24)) >>> 0
231 const cells = b.length / 12
232 bar.width = cells * 9
233 bar.height = 18
234 const ctx = bar.getContext('2d')
235 for (let i = 0; i < cells; i++) {
236 const [glyph, fg, bg] = [u32(i * 12), u32(i * 12 + 4), u32(i * 12 + 8)]
237 const half = (color, y) => { if (color < 0x1000000) { ctx.fillStyle = '#' + color.toString(16).padStart(6, '0'); ctx.fillRect(i * 9, y, 9, 9) } }
238 if (glyph === 0x2580) { half(fg, 0); half(bg, 9) }
239 if (glyph === 0x2584) half(fg, 9)
240 }
241}
242const light = document.getElementById('light')
243light.onclick = () => light.setAttribute('aria-pressed', String(document.body.classList.toggle('light')))
244</script>
245</body>
246</html>
247`
248}
249hooks/previewPath.ts 51 lines1import type { FsStat } from 'claude-code'
2
3/** The name every preview file starts with. preview_theme writes nowhere else, so a misled call cannot overwrite a dotfile or source code. */
4export const PREVIEW_PREFIX = 'oxen-pet-preview'
5
6const baseName = (p: string) => p.split(/[\\/]/).pop() ?? ''
7const isPreviewName = (name: string) => name.startsWith(PREVIEW_PREFIX) && name.endsWith('.html')
8
9const isAbsolute = (p: string) => p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p)
10
11/** Why preview_theme must not write to `path`, or undefined when it may: an absolute path with no `..`, to a file named oxen-pet-preview….html. */
12export function previewPathError(path: unknown): string | undefined {
13 if (typeof path !== 'string' || path === '') {
14 return '`path` is the HTML file to write.'
15 }
16 if (!isAbsolute(path)) {
17 return '`path` must be absolute.'
18 }
19 const parts = path.split(/[\\/]/)
20 if (parts.includes('..')) {
21 return '`path` must not contain `..`.'
22 }
23 if (!isPreviewName(parts[parts.length - 1] ?? '')) {
24 return `\`path\` must name a file that starts with ${PREVIEW_PREFIX} and ends with .html, such as /tmp/${PREVIEW_PREFIX}-cat.html.`
25 }
26
27 return undefined
28}
29
30/**
31 * Why preview_theme must not write where an allowed `path` leads, or undefined when it may. `own` is the path's stat
32 * with `realPath`, undefined when nothing is there yet; `folder` is its folder's. The spelling check alone lets a
33 * symbolic link planted at an allowed name send the write to any file, and lets the write create missing folders.
34 */
35export function previewTargetError(own: FsStat | undefined, folder: FsStat | undefined): string | undefined {
36 if (own === undefined) {
37 return folder?.kind === 'dir' ? undefined : 'its folder must already exist.'
38 }
39 if (own.isLink) {
40 return '`path` is a symbolic link.'
41 }
42 if (own.kind !== 'file') {
43 return '`path` is not a plain file.'
44 }
45 if (own.realPath === undefined || !isPreviewName(baseName(own.realPath))) {
46 return `\`path\` lands on ${own.realPath ?? 'a place that cannot be resolved'}, not an ${PREVIEW_PREFIX} file.`
47 }
48
49 return undefined
50}
51hooks/settings.ts 48 lines1import { DEFAULT_THEME, isThemeName } from './custom'
2
3/** The user's settings, from the plugin's `userConfig`, as the mod uses them. */
4export type Settings = {
5 pace: number // 1 is normal; the pet runs and animates this many times as fast
6 sleepAfterMs: number // 0 keeps the pet awake
7 hud: boolean
8 statusLine: boolean
9 targets: boolean // the status line names files, patterns, commands, and hosts
10 minis: boolean
11 cacheTtlMin: number // how long the prompt cache stays warm after a turn; 0 hides the cache timer
12 guard: boolean // the shield asks before a destructive command runs unasked
13 boss: boolean // a failed test run brings a bug boss into the band
14 hudRow: boolean // the HUD lays its bars side by side in one row
15 theme: string // the pet a session starts with: a built-in one or one in the custom folder, by name
16 customDir: string // where the user's own themes are; empty for the .claude folder's oxen-pet/themes
17}
18
19const PACE: Record<string, number> = { slow: 0.6, normal: 1, fast: 1.6 }
20const CACHE_TTL: Record<string, number> = { '1h': 60, '5m': 5, off: 0 }
21
22export const DEFAULTS: Settings = { pace: 1, sleepAfterMs: 60000, hud: true, statusLine: true, targets: false, minis: true, cacheTtlMin: 60, guard: false, boss: true, hudRow: true, theme: DEFAULT_THEME, customDir: '' }
23
24const flag = (v: unknown, fallback: boolean) => (typeof v === 'boolean' ? v : fallback)
25
26/**
27 * Settings from the options Claude Code passes to `register`. A missing or malformed value takes its default,
28 * so settings saved by another version of the mod never stop it loading.
29 */
30export function readSettings(options: Readonly<Record<string, unknown>>): Settings {
31 const sleep = options.sleepAfter
32
33 return {
34 pace: PACE[String(options.speed)] ?? DEFAULTS.pace,
35 sleepAfterMs: typeof sleep === 'number' && Number.isFinite(sleep) && sleep >= 0 ? sleep * 1000 : DEFAULTS.sleepAfterMs,
36 hud: flag(options.hud, DEFAULTS.hud),
37 statusLine: flag(options.statusLine, DEFAULTS.statusLine),
38 targets: flag(options.targets, DEFAULTS.targets),
39 minis: flag(options.minis, DEFAULTS.minis),
40 cacheTtlMin: Object.hasOwn(CACHE_TTL, String(options.cacheTtl)) ? CACHE_TTL[String(options.cacheTtl)]! : DEFAULTS.cacheTtlMin,
41 guard: flag(options.guard, DEFAULTS.guard),
42 boss: flag(options.boss, DEFAULTS.boss),
43 hudRow: options.hudLayout !== 'stacked',
44 theme: typeof options.theme === 'string' && isThemeName(options.theme.trim()) ? options.theme.trim() : DEFAULT_THEME,
45 customDir: typeof options.customDir === 'string' ? options.customDir : DEFAULTS.customDir,
46 }
47}
48