Clarvis: an unofficial Avengers fan mod for Claude Code, with a live HUD, token, cost and rate-limit metrics, end-of-turn moments, a pixel arcade and a comic…

<h1 align="center">Clarvis: an Avengers fan mod for Claude Code</h1>
<b>A free Claude Code plugin that turns your terminal into Tony Stark's HUD.</b><br> Heroes animate above your prompt while Claude works, a live HUD tracks your tokens and cost,<br> and when the job's done the team assembles. Plus a pixel arcade and a comic theater, right in your terminal.
<a href="https://github.com/shahsanket2107/clarvis/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/shahsanket2107/clarvis/actions/workflows/ci.yml/badge.svg"></a> <a href="LICENSE"><img alt="MIT license" src="https://img.shields.io/badge/license-MIT-ffcf3f"></a> <img alt="Claude Code 2.1.287+" src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-6fd8ff"> <img alt="No network calls" src="https://img.shields.io/badge/network-none-6fd8ff"> <img alt="Unofficial fan project" src="https://img.shields.io/badge/fan%20project-unofficial-e2403a"> <a href="https://github.com/shahsanket2107/clarvis/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/shahsanket2107/clarvis?style=social"></a>
<img src="docs/media/hero.gif" width="100%" alt="Clarvis above the Claude Code prompt: Iron Man suits up, then Thor, Loki, Spider-Man and Star-Lord take the stage while Claude works, and the team assembles when the task is done.">
/plugin install clarvis --marketplace shahsanket2107/clarvis
Paste that at the Claude Code prompt, press y, then Enter. That's it. Try /clarvis demo for the 45-second showreel.
Unofficial fan project. Not made, licensed or endorsed by Marvel, Disney or Anthropic. All art is original pixel art drawn for this project, and it's free forever. See NOTICE.md.
Clarvis (Claude + J.A.R.V.I.S.) is a Claude Code plugin, a mod, that turns the space above your prompt into Tony Stark's heads-up display: a statusline-style HUD for token usage and cost, plus heroes, games and comics while Claude works.
/clarvis stats gives the full C.L.A.R.V.I.S. mission report./clarvis sound off mutes them.<table> <tr> <td width="50%"><img src="docs/media/band.png" alt="The working band: Thor on a 3D stage, the tool Claude is running, a typed line and the mission log"></td> <td width="50%"><img src="docs/media/moment.png" alt="The CLAUVENGERS, ASSEMBLE! moment when a task finishes"></td> </tr> <tr> <td><img src="docs/media/arcade.png" alt="The Stark Arcade title screen with five games"></td> <td><img src="docs/media/theater.png" alt="An Avengers Theater comic panel"></td> </tr> <tr> <td><img src="docs/media/stats.png" alt="The /clarvis stats mission report with tokens, cache, cost and limits"></td> <td><img src="docs/media/card.png" alt="The /clarvis card shareable mission card"></td> </tr> </table>
Run /clarvis card week and paste the card into a post. It's copied to your clipboard already. Tag it #Clarvis and #ClaudeCode: I'd love to see which hero is carrying your codebase.
If Clarvis made Claude Code a little more fun, give it a ⭐. It's the best way to help other Claude Code users find it.
Needs: Claude Code 2.1.287 or later (claude --version) in a terminal or the Desktop app's Code tab.
/plugin install clarvis --marketplace shahsanket2107/clarvis ``y to add the marketplace, then Enter to install for your user. The band appears straight away./clarvis for the briefing, or /clarvis demo to see everything.From your shell instead:
claude plugin marketplace add shahsanket2107/clarvis
claude plugin install clarvis@clarvis
then run /reload-plugins in any open session.
Best look: a truecolor terminal such as Ghostty, iTerm2, WezTerm or kitty. On terminals that can't show the art (macOS Terminal.app, or tmux without CLAUDE_CODE_TMUX_TRUECOLOR=1), Clarvis switches to a tidy text-only lite mode by itself. For the arcade and theater, Claude Code must be in fullscreen mode (/tui fullscreen), which passes clicks and keys to panes.
Update: /plugin, then the Installed tab, then update. Uninstall: same place, uninstall clarvis.
| Command | What it does | ||||
|---|---|---|---|---|---|
/clarvis | The briefing: every command and your current settings | ||||
/clarvis demo | A 45-second showreel with sample numbers (nothing is counted) | ||||
/clarvis full · calm · lite · off | Full art · same content, no motion · text only · stock Claude Code | ||||
/clarvis stats [reset] | The mission report: tokens, cache, cost, burn rate, limits, lifetime totals | ||||
| `/clarvis card [today\ | week\ | all] [nocost]` | A shareable mission card, copied to your clipboard | ||
/clarvis trophies | Your trophy case | ||||
| `/clarvis pick <hero\ | auto> [here]` | Put one hero on stage (here pins it to this project) | |||
| `/clarvis team <avengers\ | guardians\ | wakanda\ | cosmic\ | all>` | Choose who takes the stage |
/clarvis play | Stark Arcade (needs fullscreen) | ||||
/clarvis movies · `movie <1-9, 0 for 10\ | name\ | next\ | random>` | Avengers Theater (needs fullscreen) | |
| `/clarvis sound on\ | off` | Chiptune sounds (on by default, plays on macOS) | |||
| `/clarvis notify <minutes\ | off>` | A desktop ping when a task runs longer than that | |||
| `/clarvis fx on\ | off` | Tint the transcript with the theater's hit bursts (off by default) |
Settings (in /plugin, the plugin's options): Look, Show cost, Sound and Notify after minutes. To override the look for one launch, set CLARVIS=calm (or full, lite, off) in the environment.
Pick a game with 1–5, the arrows and SPACE, or a click.
P pauses, Q twice quits, and the siege high score is saved.
Clarvis is a mod: code that runs inside Claude Code. Here's exactly what it does:
CLARVIS environment variable, and the output of Bash commands only to spot a commit hash, a test pass count or a PR number for the celebration. That output is never stored.Check it yourself: claude plugin validate . on a clone lists every hook and every call the mod makes.
Does it slow Claude down? No. Drawing happens alongside Claude's work, never in front of it, and an idle band does no work at all.
Does it work in VS Code, claude -p or the cloud? Claude Code only draws mods in the terminal and the Desktop app's Code tab. Elsewhere Clarvis stays out of the way.
It's too much while I'm focusing. /clarvis calm keeps the information and drops the motion. /clarvis off brings back the stock look.
Why does the band show ~? You started Claude in your home folder. In a project it shows the project's name.
What's a "cache hit"? Claude re-reads your conversation on every reply, and repeated parts come from a cache at about a tenth of the price. A higher cache % means cheaper, faster replies.
Can I suggest a hero, game or episode? Please do. Open an issue.
git clone https://github.com/shahsanket2107/clarvis && cd clarvis
claude --plugin-dir . # run it from the folder; edits hot-reload
claude plugin validate . # what the engine will load
claude plugin test . # 500+ tests
bun scripts/demo.ts # every view of the band in your terminal, no session needed
bun scripts/gallery.ts # rebuild demo/gallery.html and open it in a browser
See CONTRIBUTING.md. Art lives in scripts/art.ts (bun scripts/bake.ts re-bakes it), and the README media is regenerated with bun scripts/record.ts (see docs/media/README.md).
MIT for the code written for this project. Marvel characters, names and marks belong to their owners and aren't covered by this license. Clarvis is an unofficial, non-commercial fan project. See NOTICE.md.
Made by Sanket Shah.
hooks/register.tsx 1151 lines1// clarvis: a Marvel-themed Claude Code mod.
2//
3// AbovePrompt one truecolor Raster (2 × 2 subpixels a cell, quadrant blocks):
4// idle, one Stark line (who, the suit's Mark, the core gauge,
5// the power draw); working, 8 rows: a 3D stage (ray-marched,
6// toon-shaded, pre-rendered turns) where the hero of the moment
7// plays, beside the content: what runs, on what, JARVIS, the
8// mission log, the gauges. Moments take it for a few seconds:
9// Stark boot (session start), CLAUVENGERS, ASSEMBLE! (done), Thanos
10// (error), Nick Fury (a permission ask waiting on you).
11// Spinner folded into the band while the band shows
12// TurnDuration "✓ Mission complete in 1m 04s"
13//
14// Frames are repainted with $.ui.blit (no render pass) from a clock that runs
15// only while something moves; at rest nothing ticks and nothing redraws.
16// Modes: full (default) · calm (no motion, same words) · lite (text rows, no
17// art, no clock) · off (stock UI). Which one a session starts in, first match
18// wins: CLARVIS, the choice `/clarvis <mode>` stored, the `mode` option, a
19// 256-colour terminal (lite), then full. Observe-only: no hook here changes a
20// tool call, a verdict or a prompt.
21import { atom, read, update } from 'claude-code'
22import type { Register } from 'claude-code'
23import type { AvengersMode, AvengersTurn, AvengersUsage, Fx, Hud } from '../types'
24import { detailOf, footerRow, spinnerRow, type Row } from './art'
25import { surface } from './pixel'
26import {
27 addLifetime, addTool, addTurn, bandNumbers, bandPieces, cacheHit, cleanLimits, contextNudge, crossed, emptyLifetime, fmtTokens, inputTotal, lifetimeTokens, milestoneText, newStats, num, sanitizeLifetime, statsText,
28 type Limit, type Lifetime,
29} from './metrics'
30import { lineFor, DONE } from './lines'
31import {
32 DEMO_CAST, DEMO_NOTE, DEMO_WORK_S, LITE_HINT, WELCOME_CAPTION, WELCOME_TOAST, briefing, folderName, demoDone, fmtMin, notifyBody, notifyChoice, parseNotify, demoPhaseAt, demoWork, endCaption, furyLine, liteRows, needsLite,
33 type CmdGroup, type CmdInfo, type DemoPhase,
34} from './hud'
35import { MOVIES, findMovie } from './movies'
36import { shipOf, mergeShip, shipCaption, type Ship } from './ship'
37import { addToLedger, bumpStreak, sanitizeLedger, sanitizeStreak, streakNow, sumDays, STREAK_TOASTS, dayKey } from './daily'
38import { earned, extOf, sanitizeTrophies, trophiesText, trophyOf, type Facts } from './trophies'
39import { TEAM_IDS, TEAMS, findHero, isTeam, orderFor, pickable, pinHere, sanitizeHere, sanitizeSel, stageIds, type TeamId } from './team'
40import { EGG_CAPTIONS, flairOf, isRealDate, isThursday, rollEgg, seasonCaption, seasonOf, type Flair } from './season'
41import { applyFlair, snow } from './flair'
42import { missionCard, parseCardArgs } from './card'
43import { COIN_DEFER_MS, coinReady, SOUND_GAIN, SOUND_NOTE, clipOf, soundChoice, soundGate, type ClipName } from './sound'
44import { BURST_S, transcriptFx, type BurstKind } from './burst'
45import {
46 HERO, IDLE_ROWS, ROSTER, ROSTER_SECONDS, MOMENTS, WORK_ROWS_COMPACT, heroFor, MOMENT_ROWS, MOMENT_SECONDS, WORK_ROWS, clip, drawIdle, drawWorking, fmtDur, fury,
47 type Activity, type Hero, type LogEntry, type Moment,
48} from './scenes'
49
50const hud = atom({ plugin: 'clarvis', key: 'hud' } as const, null as Hud | null)
51const mode = atom({ plugin: 'clarvis', key: 'mode' } as const, 'full' as AvengersMode)
52const turn = atom({ plugin: 'clarvis', key: 'turn' } as const, null as AvengersTurn | null)
53// A moment holding the band; a change is a new size, so a redraw.
54const fx = atom({ plugin: 'clarvis', key: 'fx' } as const, null as Fx | null)
55const usage = atom({ plugin: 'clarvis', key: 'usage' } as const, { percent: 0, tokens: 0, cost: 0 } as AvengersUsage) // + the session's token totals, cache hit and 5-hour window, for the band
56// A theater burst echoed on the transcript (fullscreen only): which, and its quarter-second step.
57const tfx = atom({ plugin: 'clarvis', key: 'tfx' } as const, null as { kind: string; step: number } | null)
58// Redraw requests, as counters only one kind of hook reads: `$.ui.invalidate('ui.render')` can't name a
59// component (every render hook of the plugin, the transcript rows too, would draw again), but a write
60// to a value redraws just the instances that read it.
61// Who is on the stage, in rotation order (what /clarvis pick, /clarvis team and the seasons made of the roster).
62const rosterAtom = atom({ plugin: 'clarvis', key: 'roster' } as const, [] as string[])
63const bandNudge = atom({ plugin: 'clarvis', key: 'bandNudge' } as const, 0) // the AbovePrompt hook reads it
64const paneNudge = atom({ plugin: 'clarvis', key: 'paneNudge' } as const, 0) // the theater pane reads it
65
66const FRAME_MS = 100 // 10 fps while something moves
67const CALM_MS = 1000 // calm mode: only the clock moves
68const MODES: AvengersMode[] = ['full', 'calm', 'lite', 'off']
69const isMode = (s: unknown): s is AvengersMode => typeof s === 'string' && (MODES as string[]).includes(s)
70const shortModel = (m: string) => /opus|sonnet|haiku|fable/.exec(m.toLowerCase())?.[0] ?? m
71// Fury, holding the band when a turn ends on a question for you, animates
72// this long and then holds still (an idle session draws nothing).
73const FURY_ANIM_MS = 6000
74
75// ── JARVIS's lines and the team's ───────────────────────────────────────────
76const doneLine = (n: number) => DONE[Math.abs(n) % DONE.length]!
77
78// ── the band's live state (module-local: a reload starts it over) ───────────
79// The clock: a chain of $.clock.after laid by session.start (whose `$` timers
80// outlive their hook). It parks when nothing moves; `kick` lays it again.
81let timer: unknown, timerAt = 0, chain = 0 // the pending step, when it was laid, and which chain it belongs to
82let kick: () => void = () => {}
83let fullMode = true, calmMode = false, liteMode = false
84const isOff = () => !fullMode && !calmMode && !liteMode
85// The plugin's options (manifest userConfig), read in register: showCost hides every $, notifyMin
86// is the minutes a turn must run before a native notification at its end (0: never), cfgMode the
87// `mode` option (undefined: unset).
88let showCost = true, notifyMin = 0, notifyCfg = 0, cfgMode: AvengersMode | undefined
89// /clarvis demo: a scripted showreel painted over the real band. Nothing is counted while it plays;
90// the next real turn.start (or any mode change) ends it.
91let demo: { at: number; phase: DemoPhase } | null = null
92let working = false
93let compacting = false // a compaction is running (it may come outside a turn)
94let lastCells = '' // the cells last sent, so a still frame costs nothing
95let bandReq = '', bandKey = '' // the band's requestId and the mounted Raster's key
96let bandCols = 100, bandMaxRows = 20
97let deniedAt = -1e12 // when a blit was last refused (the band is not mounted)
98// Subagents at work (by agentId, with when each was last heard from): the band keeps
99// working while any runs, even after the main turn has ended. One silent for
100// AGENT_STALE_MS is let go (a loop that ended without its turn.complete reaching us).
101const agents = new Map<string, number>()
102const AGENT_STALE_MS = 3 * 60_000 // an engine fork (compaction, memory) can call a tool and never raise a turn.complete: don't hold the band for it long
103const agentRunning = new Map<string, number>() // tool calls an agent has in flight: such an agent is not silent, however long the call
104function agentsBusy(): number { const t = now(); for (const [id, at] of agents) if (t - at > AGENT_STALE_MS && !agentRunning.get(id)) { agents.delete(id); agentRunning.delete(id) } return agents.size }
105const busy = (isWorking: boolean) => isWorking || working || compacting || agentsBusy() > 0 || demo?.phase === 'work'
106let act: Activity = 'thinking', actAt = 0, curTool = '', curDetail = '', toolAt = 0
107let line = '"At your service, sir."', lineBy = 'JARVIS', lineAt = 0
108let log: LogEntry[] = []
109let lastToolFailed = false // the turn's last (main-thread) tool call failed: Claude ended without recovering
110let spinMode = 'thinking'
111let hudInfo: Hud | null = null, usageNow: AvengersUsage = { percent: 0, tokens: 0, cost: 0 }, turnNow: AvengersTurn | null = null
112let currentFx: Fx | null = null
113let nextFx: Fx | null = null // a moment queued behind the one playing (no caller now: a success plays ASSEMBLE alone)
114let bump = 0
115const running: string[] = [] // the tools running right now (the newest last): row 0 says so at once
116let rotIdx = 0, rotStart = 0 // the roster: whose turn it is, and since when
117// Who is on the stage: /clarvis team and /clarvis pick (global, or pinned to this project), kept in $.store.
118let sel: { team: TeamId; pick?: string } = { team: 'all' }, hereMap: Record<string, string> = {}, sessionCwd = ''
119// The rotation's order: shuffled afresh for each session (and module load), so no two
120// sessions open on the same hero or run the same sequence; within a session every lap
121// runs that order, so each hero comes round exactly once every lap. The roster is filtered
122// by the team or pick, then the seasons touch it (Halloween's villains first, Thor twice on Thursdays).
123let order: (typeof ROSTER)[number][] = []
124function shuffleRoster(d?: Date) {
125 const ids = stageIds(ROSTER, sel, hereMap[sessionCwd])
126 const whole = ids.length > 1
127 const season = d ? seasonOf(d) : null
128 order = orderFor(ids, { rng: dice.rng, villains: whole && fullMode && season === 'halloween', thursday: whole && fullMode && !!d && isThursday(d) }) as (typeof ROSTER)[number][]
129 rotIdx = 0; rotStart = 0
130}
131// The dice: a test swaps in its own.
132export const dice = { rng: (() => Math.random()) as () => number }
133let eggs: 'always' | 'never' | 'chance' = 'chance' // CLARVIS_EGG=always|never: a test knob for the rare after-turn eggs
134shuffleRoster()
135// The order this session runs (tests read it).
136export const rotation = (): readonly (typeof ROSTER)[number][] => order
137function rosterAt(k: number): (typeof ROSTER)[number] { const n = order.length; return order[((k % n) + n) % n]! }
138// The session's real numbers (module-local: a reload starts them over): token totals from each
139// main turn's usage, tool counts, and the last reading of the engine's usage.
140const stats = newStats()
141let reading: { startedAt?: number; ctxTokens?: number; ctxWindow?: number; ctxPercent?: number; cost?: number; limits: Limit[] } = { limits: [] }
142let costAtStart = 0 // the session's spend when this turn began: the mission's cost is the difference
143const counts: Record<string, number> = {}
144// This session's shipped work and edits, and the streak, the sound and the hints that fire once a session.
145let turnShip: Ship | undefined, turnHeroes: Record<string, number> = {}
146const exts = new Set<string>()
147let streakDays = 0, nudged = false
148let soundOn = true, cfgSound = true, soundAt = -1e12, burstAt = -1e12, soundName: ClipName | undefined, coinDue = 0, closing = false
149let startedWall = 0 // when this session began, on the engine's clock (the shawarma egg)
150// AVENGERS THEATER: the movie to open straight into (a nonce per request), screen FX on or off,
151// and the transcript echo of a burst (when it began, its last step written)
152let theaterStart: number | undefined, theaterNonce = 0, fxOn = false // fxOn: the transcript echo of a theater burst (opt in with /clarvis fx on); the theater's own bursts need only full mode
153let echo: { kind: string; at: number; step: number } | undefined, echoLast = -1e12
154const ECHO_GAP_MS = 20_000
155// How the recent turns ended, by duration (what the engine's TurnDuration line carries): the closing line's words.
156const outcomes = new Map<number, { outcome: 'done' | 'failed' | 'aborted'; tools: number }>()
157
158// Time comes from $.clock (a test's mock clock too), read at each hook's start.
159let clockNow = Date.now()
160const now = () => clockNow
161const sync = async ($: any) => { try { clockNow = await $.clock.now() } catch { clockNow = Date.now() } }
162const secs = (ms: number) => ms / 1000 // seconds on one clock, never wrapping
163function say(next: { by: string; text: string } | string) {
164 const l = typeof next === 'string' ? { by: 'JARVIS', text: next } : next
165 if (l.text !== line) { line = l.text; lineBy = l.by; lineAt = now() }
166}
167function onStage(a: Activity) { if (a !== act) { act = a; actAt = now() } }
168// Nothing changes faster than you can read it: a hero and their line hold the
169// stage at least this long; a newer one waits its turn (the latest wins).
170const HOLD_MS = 7000
171let shownAt = 0
172let pending: { a: Activity; line: { by: string; text: string }; tool: string } | undefined
173function show(a: Activity, l: { by: string; text: string }, tool = '') {
174 if (now() - shownAt < HOLD_MS) { pending = { a, line: l, tool }; return }
175 pending = undefined; shownAt = now()
176 onStage(a); curTool = tool; say(l)
177}
178function showPending() { if (pending && now() - shownAt >= HOLD_MS) { const p = pending; pending = undefined; show(p.a, p.line, p.tool) } }
179
180// What the band is now, and its height.
181type View = { kind: 'idle'; busy?: boolean } | { kind: 'work' } | { kind: 'moment'; m: Moment; at: number; detail: string; caption: string; flair?: Flair }
182const NARROW_MIN = 16 // the least the band draws at: the name and the spend
183const WIDE_MIN = 40 // the stage and the moments need this much; narrower, the band is its one line
184function viewOf(x: Fx | null, isWorking: boolean): View {
185 if (bandCols < WIDE_MIN) return { kind: 'idle', busy: isWorking }
186 if (x) {
187 const m = x.moment as Moment
188 if (MOMENT_ROWS[m] && MOMENT_ROWS[m] <= bandMaxRows - 2) return { kind: 'moment', m, at: x.at, detail: x.detail ?? '', caption: x.caption ?? '', flair: x.flair as Flair | undefined }
189 }
190 return isWorking && bandMaxRows >= WORK_MIN ? { kind: 'work' } : { kind: 'idle', busy: isWorking } // too few rows to work in: the one line says so
191}
192// The working band takes the full-width lane when the terminal has the room. With
193// less (a notice, the tasks list growing a row per subagent) the lane is squeezed,
194// down to LANE_MIN rows, then the stage gives up its log rows: the lane always stays.
195// A tree of at most maxRows shows whole, so no margin is kept.
196const LANE_MIN = 3
197const WORK_MIN = 7 // the least a working band takes: 4 stage rows (head, line, gauges) and the lane
198const laneRows = () => Math.max(LANE_MIN, Math.min(WORK_ROWS - WORK_ROWS_COMPACT, bandMaxRows - WORK_ROWS_COMPACT))
199const stageRows = () => Math.min(WORK_ROWS_COMPACT, Math.max(WORK_MIN - LANE_MIN, bandMaxRows - laneRows()))
200const rowsOf = (v: View) => (v.kind === 'moment' ? MOMENT_ROWS[v.m] : v.kind === 'work' ? stageRows() + laneRows() : IDLE_ROWS)
201// Fits the full working frame into `to`: the stage's top rows as they are, the lane
202// (rows WORK_ROWS_COMPACT..WORK_ROWS) squeezed into the rest, its name tag on the last row.
203function squeeze(from: ReturnType<typeof surface>['cv'], to: ReturnType<typeof surface>['cv'], stage: number) {
204 const W = from.W, src0 = WORK_ROWS_COMPACT * 2, src = from.h - src0, top = stage * 2, dst = to.h - top
205 to.panel = from.panel
206 to.px.set(from.px.subarray(0, top * W))
207 for (let j = 0; j < dst; j++) { const sy = src0 + Math.min(src - 1, Math.floor(((j + 0.5) * src) / dst)); to.px.set(from.px.subarray(sy * W, (sy + 1) * W), (top + j) * W) }
208 const w = from.w, keep = stage * w, last = (to.rows - 1) * w, tag = (from.rows - 1) * w
209 to.glyph.set(from.glyph.subarray(0, keep)); to.glyphFg.set(from.glyphFg.subarray(0, keep)); to.glyphBg.set(from.glyphBg.subarray(0, keep))
210 to.glyph.set(from.glyph.subarray(tag, tag + w), last); to.glyphFg.set(from.glyphFg.subarray(tag, tag + w), last); to.glyphBg.set(from.glyphBg.subarray(tag, tag + w), last)
211}
212
213// The extras after the spend (tokens, cache, rate limit), and the usage the gauges draw: showCost off hides every $.
214const piecesOf = (turnCost?: number) => {
215 const p = bandPieces(usageNow, showCost && turnCost !== undefined ? { turnCost } : {})
216 if (streakDays >= 2) p.push({ text: `▲${streakDays}d`, tone: 'steel' }) // last: the first piece to go when the line is short
217 return p
218}
219const usageFor = (turnCost?: number) => ({ ...usageNow, pieces: piecesOf(turnCost), ...(showCost ? {} : { hideCost: true }) })
220const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`
221
222// Draws the band's current frame; returns its cells.
223function frame(v: View): { cells: string; rows: number } {
224 const rows = rowsOf(v)
225 const squeezed = v.kind === 'work' && rows < WORK_ROWS
226 const sf = surface('band', bandCols, squeezed ? WORK_ROWS : rows), cv = sf.cv
227 cv.clear()
228 const t = now()
229 const who = hudInfo ?? { name: 'session', model: 'opus' }
230 if (v.kind === 'moment') {
231 const s = (t - v.at) / 1000, args = { who, usage: usageNow, detail: v.detail, caption: v.caption }
232 if (v.m === 'fury') fury(cv, calmMode ? 0 : s, args, calmMode)
233 else MOMENTS[v.m](cv, calmMode ? Math.max(0, MOMENT_SECONDS[v.m] - 0.5) : Math.min(s, MOMENT_SECONDS[v.m]), args) // calm: one still frame, everything shown
234 if (fullMode && v.flair) applyFlair(cv, v.flair, Math.max(0, s)) // seasonal and easter-egg touches: full mode only
235 } else if (v.kind === 'work') {
236 // thinking or writing, the team takes turns on stage, each with a line of their own
237 // the whole team takes turns on stage, ROSTER_SECONDS each, in order, whatever
238 // the tool: the clock is the wall clock, so the order runs on across turns and
239 // every hero comes round. Calm mode keeps the hero of the job, still.
240 // each hero gets a full ROSTER_SECONDS from the moment they arrive; the order
241 // carries on across turns (a long pause resumes with the next hero, not a jump)
242 const step = ROSTER_SECONDS * 1000
243 if (!rotStart || t < rotStart || t - rotStart > step * 3) { if (rotStart) rotIdx++; rotStart = t }
244 while (t - rotStart >= step) { rotIdx++; rotStart += step }
245 const k = rotIdx
246 const D = demo?.phase === 'work' ? demoWork(secs(t - demo.at) - demoStart()) : null // the showreel: five heroes and a ticking log, sample numbers
247 const cast = D ? D.cast : compacting ? 'antman' : calmMode ? undefined : rosterAt(k)
248 const castLine = D ? lineFor(D.cast, D.idx, D.detail) : cast ? lineFor(cast, Math.floor(k / ROSTER.length), curDetail || curTool) : undefined
249 if (D) drawWorking(cv, {
250 who, usage: usageFor(0.42), calm: false, act: D.cast, actAt: secs(demo!.at) + demoStart() + D.idx * (DEMO_WORK_S / DEMO_CAST.length), tool: D.tool, detail: D.detail,
251 line: castLine!.text, speaker: castLine!.by, lineAt: secs(demo!.at) + demoStart() + D.idx * (DEMO_WORK_S / DEMO_CAST.length), elapsed: D.elapsed, tools: D.tools, log: D.log, lane: true, cast: D.cast,
252 }, secs(t))
253 else drawWorking(cv, {
254 who, usage: usageFor(Math.max(0, usageNow.cost - costAtStart)), calm: calmMode, act, actAt: cast ? secs(rotStart) : secs(actAt), tool: running[running.length - 1] ?? (!working && agents.size ? `${agents.size} agent${agents.size > 1 ? 's' : ''}` : ''), // a new hero starts their animation from the top
255 detail: curDetail || (turnNow?.prompt ? `◇ "${clip(turnNow.prompt, 120)}"` : ''),
256 line: castLine?.text ?? line, speaker: castLine?.by ?? lineBy, lineAt: castLine ? secs(rotStart) : secs(lineAt), elapsed: turnNow ? fmtDur(t - turnNow.startedAt) : '', tools: turnNow?.tools ?? 0, log, lane: true,
257 // thinking or writing, the team takes turns on stage
258 cast,
259 }, secs(t))
260 } else {
261 const n = agents.size, label = n && !working ? `◓ ${n} agent${n > 1 ? 's' : ''} at work` : '◓ at work'
262 drawIdle(cv, who, usageFor(), v.busy ? label : undefined)
263 }
264 if (fullMode && v.kind !== 'moment' && seasonOf(new Date(now())) === 'winter') snow(cv, secs(now())) // December: snow falls through the empty cells
265 if (squeezed) { const out = surface('band-squeezed', bandCols, rows); out.cv.clear(); squeeze(cv, out.cv, stageRows()); return { cells: out.cells(), rows } }
266 return { cells: sf.cells(), rows }
267}
268
269// ── the clock: on while anything moves, off at rest ─────────────────────────
270function wantMs(): number {
271 if (!fullMode && !calmMode) return 0
272 if (echo) return FRAME_MS // a transcript echo is stepping
273 if (coinDue > 0 && fullMode) return 250 // a trophy's coin is waiting for the end-of-turn clip to clear
274 if (!bandKey) return 0 // no band is mounted (Desktop, VS Code, -p, collapsed): nothing to paint; the band's hook kicks the clock when it mounts
275 if (demo && fullMode) return FRAME_MS // the showreel moves all the way through, Fury's closing seconds too
276 // Fury animates a few seconds, then holds still until you answer
277 if (currentFx?.moment === 'fury' && !working) return now() - currentFx.at < FURY_ANIM_MS ? (fullMode ? FRAME_MS : 0) : 0
278 if (fullMode && (working || compacting || currentFx || agentsBusy())) return FRAME_MS
279 if (calmMode && (working || compacting || currentFx || agentsBusy())) return CALM_MS
280 return 0
281}
282async function setFx($: any, x: Fx | null) {
283 currentFx = x
284 await update($, fx, () => x)
285 kick()
286}
287async function moment($: any, m: Moment, detail: string, caption = '', flair?: Flair) {
288 if (!fullMode && !(calmMode && (m === 'fury' || m === 'assemble'))) return // calm: Fury and the end-of-turn summary, both still
289 await setFx($, { moment: m, at: now(), detail, caption, ...(flair && fullMode ? { flair } : {}) })
290}
291// The showreel's phases: each moment is set when its phase begins, in the moments' own lengths.
292const demoStart = () => MOMENT_SECONDS.boot // the work phase begins when the boot ends
293async function demoAdvance($: any) {
294 if (!demo) return
295 const ph = demoPhaseAt(secs(now() - demo.at), MOMENT_SECONDS)
296 if (ph === demo.phase) return
297 demo.phase = ph
298 if (ph === 'done') { demo = null; await setFx($, null); return }
299 const x: Fx | null = ph === 'assemble' ? { moment: 'assemble', at: now(), detail: demoDone(showCost), caption: '"Avengers... assemble."' }
300 : ph === 'snap' ? { moment: 'snap', at: now(), detail: '✓', caption: lineFor('snap', 3).text }
301 : ph === 'fury' ? { moment: 'fury', at: now(), detail: 'Demo: shall I ship this?', caption: furyLine(0) }
302 : null
303 if (ph === 'assemble') playSound($, 'assemble')
304 else if (ph === 'snap') playSound($, 'whomp')
305 await setFx($, x)
306}
307async function tick($: any) {
308 await sync($)
309 if (coinDue > 0 && !working && !closing && coinReady({ now: now(), dueAt: coinDue, lastAt: soundAt })) { coinDue = 0; playSound($, 'coin') }
310 await demoAdvance($)
311 if (echo) { // the transcript echo of a theater burst: four quarter-second steps, then gone
312 const step = Math.floor((now() - echo.at) / ((BURST_S * 1000) / 4))
313 if (step >= 4) { echo = undefined; await update($, tfx, () => null) }
314 else if (step !== echo.step) { echo.step = step; const k = echo.kind; await update($, tfx, () => ({ kind: k, step })) }
315 }
316 showPending()
317 const x = currentFx
318 if (x && x.moment !== 'fury' && now() - x.at > MOMENT_SECONDS[x.moment as Moment] * 1000) { const n = nextFx; nextFx = null; await setFx($, n ? { ...n, at: now() } : null); return }
319 if (!bandReq || !bandKey) return
320 const f = frame(viewOf(x, busy(false)))
321 if (`${f.rows}x${bandCols}` !== bandKey) { await update($, bandNudge, n => n + 1); return } // another size: the band's hook draws again
322 if (f.cells === lastCells) return // nothing moved: send nothing
323 lastCells = f.cells
324 // never wait on the paint: a pane that isn't on screen can hold a blit back, and
325 // the clock must keep running regardless
326 const key = bandKey
327 void Promise.resolve($.ui.blit({ requestId: bandReq, key, cells: f.cells })).then(r => {
328 // refused: the band is not mounted (collapsed with [-], or gone). Stop painting and give the
329 // spinner back its row; if the band is still there its hook draws again and takes the key back.
330 if (r && typeof r === 'object' && 'deny' in r && key === bandKey) {
331 bandKey = ''; lastCells = ''
332 if (now() - deniedAt > 2000) { deniedAt = now(); void update($, bandNudge, n => n + 1) } // one redraw asked every 2 s: a band that is there takes the key back, one that isn't stays quiet
333 }
334 }).catch(() => {})
335}
336
337// Folds an engine reading (session.usage() or a session.measure) into the band's numbers.
338async function applyUsage($: any, u: any) {
339 const ctx = u?.context ?? {}
340 reading = {
341 startedAt: typeof u?.startedAt === 'number' && Number.isFinite(u.startedAt) ? u.startedAt : reading.startedAt,
342 ctxTokens: typeof ctx.tokens === 'number' ? ctx.tokens : undefined, ctxWindow: typeof ctx.window === 'number' ? ctx.window : undefined, ctxPercent: typeof ctx.percent === 'number' ? ctx.percent : undefined,
343 cost: typeof u?.cost?.usd === 'number' ? u.cost.usd : undefined, limits: cleanLimits(u?.rateLimits),
344 }
345 usageNow = { percent: ctx.percent ?? 0, tokens: ctx.tokens ?? 0, cost: u?.cost?.usd ?? 0, ...bandNumbers(stats, reading.limits) }
346 await update($, usage, () => usageNow)
347}
348async function refreshUsage($: any) {
349 try { await applyUsage($, await $.session.usage()) } catch { /* usage is decoration */ }
350}
351// Lifetime totals live in $.store; whatever is there is made safe before it is used.
352// Store writes that read, change and write back go one at a time, so two hooks can't lose each other's update.
353let storeQ: Promise<unknown> = Promise.resolve()
354const queued = <T,>(f: () => Promise<T>): Promise<T | undefined> => { const r = storeQ.then(f, f); storeQ = r.catch(() => {}); return r.catch(() => undefined) }
355// Adds to the lifetime totals; resolves the totals before and after (undefined if the store failed).
356function bumpLifetime($: any, d: Parameters<typeof addLifetime>[1]): Promise<{ before: Lifetime; after: Lifetime } | undefined> {
357 return queued(async () => {
358 const before = sanitizeLifetime(await $.store.get('lifetime'))
359 const after = addLifetime(before, d)
360 await $.store.set('lifetime', after)
361 return { before, after }
362 })
363}
364const today = () => new Date(now())
365// The ledger (daily buckets and shipped-work counts) gains a delta; resolves what it holds now.
366function bumpLedger($: any, d: Parameters<typeof addToLedger>[2]) {
367 return queued(async () => {
368 const lg = addToLedger(await $.store.get('ledger'), today(), d)
369 await $.store.set('ledger', lg)
370 return lg
371 })
372}
373
374// ── sound: opt-in, full mode only, one clip at a time, never in the way ──────
375function playSound($: any, name: ClipName) {
376 const m = fullMode ? 'full' : calmMode ? 'calm' : liteMode ? 'lite' : 'off'
377 if (!soundGate({ on: soundOn, mode: m, now: now(), lastAt: soundAt, burstAt, name, lastName: soundName })) return
378 soundAt = now(); soundName = name
379 try { void Promise.resolve($.audio.play(clipOf(name), { gain: SOUND_GAIN })).catch(() => {}) } catch { /* no player: silence */ }
380}
381
382// ── trophies: earned once ever, kept in $.store as {id: unlockedAtMs} ────────
383async function award($: any, id: string) {
384 const t = trophyOf(id)
385 if (!t || demo || isOff()) return // the showreel never awards
386 const fresh = await queued(async () => {
387 const got = sanitizeTrophies(await $.store.get('trophies'))
388 if (got[id]) return false
389 got[id] = Math.max(1, now())
390 await $.store.set('trophies', got)
391 return true
392 })
393 if (!fresh) return
394 try { await $.ui.toast(`★ Trophy unlocked · ${t.text}`) } catch { /* decoration */ }
395 // a coin earned while a turn runs (or as it closes) waits for the end-of-turn clip
396 if (working || closing) coinDue = Math.max(coinDue, now() + COIN_DEFER_MS)
397 else playSound($, 'coin')
398}
399/** Awards whatever these facts earn (the store says which are new). */
400async function check($: any, f: Facts) {
401 if (demo || isOff()) return
402 for (const id of earned(f)) await award($, id)
403}
404/** Facts about the whole history, read from the store. */
405async function history($: any): Promise<Facts> {
406 const lt = sanitizeLifetime(await $.store.get('lifetime').catch(() => undefined)), lg = sanitizeLedger(await $.store.get('ledger').catch(() => undefined))
407 return { commits: lg.life.commits, green: lg.life.green, prs: lg.life.prs, tokens: lifetimeTokens(lt), toolCalls: lt.toolCalls, sessions: lt.sessions, streak: streakDays }
408}
409// Panes need the fullscreen layout: only there does the Client get keys and clicks. On the main
410// screen every pane is inline and cannot be played, so none is opened there.
411const FULLSCREEN_HINT = 'Run /tui fullscreen to play'
412/** A sentence end: a title that already ends in punctuation (WHAT IF...?) gets no extra full stop. */
413const stop = (t: string) => (/[.!?…]$/.test(t) ? t : `${t}.`)
414const notFullscreen = (fs: boolean | undefined) => fs === false
415// Opens a pane; false when the engine left it unplaced (no attached surface places panes).
416let paneWhy = '' // why the last pane was left unplaced: the engine's own reason
417async function openPane($: any, id: string, title: string): Promise<boolean> {
418 // as big as the layout allows: a wide dock beside a fullscreen transcript,
419 // a tall block when inline (the person can still drag it to taste)
420 // no closeOnEscape: Esc only hands the keys back to the prompt; q (in the game) or ✕ closes
421 const r = await $.ui.open({ id, title, focus: true, rows: 60, columns: 180 })
422 paneWhy = ''
423 if (r && r.isPlaced === false) {
424 paneWhy = typeof r.reason === 'string' && r.reason ? r.reason : 'no attached surface places panes here'
425 try { await $.ui.toast(`${title} is open but not shown: ${paneWhy}`) } catch { /* decoration */ }
426 return false
427 }
428 return true
429}
430// STARK ARCADE: `/clarvis play`, or the play button on the band.
431const openArcade = ($: any) => openPane($, 'avengers-arcade', 'STARK ARCADE')
432// AVENGERS THEATER: `/clarvis movies`, the movies button, or `/clarvis movie <n>` (start: that movie).
433const notShown = (title: string) => `${title} is open but not shown: ${paneWhy || 'no attached surface places panes here'}. It appears once the layout allows it.`
434async function openTheater($: any, start?: number) {
435 theaterStart = start; theaterNonce++
436 const placed = await openPane($, 'avengers-theater', 'AVENGERS THEATER')
437 await update($, paneNudge, n => n + 1) // an open theater picks up the new movie
438 return placed
439}
440// What a play button does: open the pane, or say what it needs when the layout can't play it.
441async function playButton($: any, fullscreen: boolean | undefined, open: () => Promise<unknown>) {
442 if (notFullscreen(fullscreen)) { try { await $.ui.toast(FULLSCREEN_HINT) } catch { /* decoration */ } return }
443 await open()
444}
445async function seenMovies($: any): Promise<string[]> {
446 const v = await $.store.get('moviesSeen')
447 return Array.isArray(v) ? v.filter((x: unknown): x is string => typeof x === 'string') : []
448}
449async function standDownFury($: any) {
450 if (currentFx?.moment === 'fury') await setFx($, null)
451}
452
453// Draws one row of styled runs (the calm spinner and the turn's footer).
454function line1($: any, e: any, row: Row) {
455 const { Box, Text } = $.ui.resolve(e)
456 return (
457 <Box flexDirection="row">
458 {row.map(s => (s.bg
459 ? <Text color={s.color} backgroundColor={s.bg} bold={!!s.bold} wrap="truncate">{s.text}</Text>
460 : <Text color={s.color} bold={!!s.bold} wrap="truncate">{s.text}</Text>))}
461 </Box>
462 )
463}
464
465// A burst in the theater echoes on the transcript for a second (fullscreen only): a tint or
466// a jolt around each row, never its words. No burst: the row is drawn untouched.
467async function echoRow($: any, e: any, next: any) {
468 if (e.surface !== 'terminal' || e.viewport?.isFullscreen !== true || !fxOn || !fullMode) return next(e) // the cheap checks first: this runs on every transcript row
469 const x = await read($, tfx)
470 if (!x) return next(e)
471 const st = transcriptFx(x.kind as BurstKind, x.step / 4)
472 if (!st) return next(e)
473 const d = await next(e)
474 const { Box } = $.ui.resolve(e)
475 return <Box flexDirection="column" paddingLeft={st.pad ?? 0} {...(st.bg ? { backgroundColor: st.bg } : {})}>{d}</Box>
476}
477// The pane on the main screen: every pane is inline there and the Client gets no keys or clicks, so no game is drawn.
478function needsFullscreen($: any, e: any, title: string, id: string) {
479 const { Box, Text, Button } = $.ui.resolve(e)
480 return (
481 <Box flexDirection="row">
482 <Box flexGrow={1}><Text color="#FFB020" wrap="truncate">{title}: {FULLSCREEN_HINT} (the main screen gives a pane no keys or clicks)</Text></Box>
483 <Button key="close" plain label="✕ Close" onPress={() => { void $.ui.close({ id }) }} />
484 </Box>
485 )
486}
487function passRow($: any, e: any, next: any) { return next(e) } // decoration never stands in the way
488
489// ── /clarvis's subcommands: one table. A command is added by pushing a row; the
490// briefing card, the unknown-command reply, the command's description and its
491// argument hint all read this table, so a new row shows up in all of them. ───
492export type HeroCmd = CmdInfo & {
493 id: string // which case of runCmd runs it
494 names: string[] // what the person types (the first word of the args)
495 tag: string // the short word in the command's description and hint
496}
497export const COMMANDS: HeroCmd[] = []
498const findCmd = (verb: string) => COMMANDS.find(c => c.names.includes(verb))
499const heroTags = () => COMMANDS.map(c => c.tag).filter(Boolean)
500
501async function setMode($: any, want: AvengersMode) {
502 await update($, mode, () => want)
503 await $.store.set('mode', want)
504 fullMode = want === 'full'; calmMode = want === 'calm'; liteMode = want === 'lite'
505 const x = currentFx
506 const hadDemo = !!demo
507 if (!fullMode) demo = null
508 if (want === 'off' || want === 'lite' || (x && x.moment !== 'fury' && x.moment !== 'assemble' && !fullMode) || (hadDemo && !fullMode)) await setFx($, null)
509 if (want === 'off') { working = false; running.length = 0 }
510 if ((want === 'off' || want === 'lite') && echo) { echo = undefined; await update($, tfx, () => null) } // no tint is left on the transcript
511 await update($, bandNudge, n => n + 1)
512 kick()
513}
514
515async function cmd_look($: any, _e: any, verb: string, rest: string): Promise<{ text: string }> {
516 if (rest) return { text: `No /clarvis command "${verb} ${rest}". Type /clarvis for the list.` } // a mode takes no argument
517 await setMode($, verb as AvengersMode)
518 return { text: `Clarvis: ${verb}${verb === 'off' ? ' (stock Claude Code look)' : verb === 'lite' ? ' (text rows, no art, works with screen readers)' : ''}. New sessions start this way too.` }
519}
520async function cmd_fx($: any, _e: any, _v: string, rest: string): Promise<{ text: string }> {
521 if (rest !== 'on' && rest !== 'off') return { text: `Transcript FX are ${fxOn ? 'on' : 'off'}. Use /clarvis fx on or off.` }
522 fxOn = rest === 'on'; await $.store.set('fx', rest)
523 if (!fxOn && echo) { echo = undefined; await update($, tfx, () => null) }
524 await update($, paneNudge, n => n + 1)
525 return { text: `Transcript FX ${rest}: ${!fxOn ? 'theater bursts stay inside the theater' : fullMode ? 'theater bursts also tint the transcript (fullscreen)' : 'the transcript tint is drawn in full mode only (/clarvis full)'}.` }
526}
527async function cmd_play($: any, e: any, _v: string, _r: string): Promise<{ text: string }> {
528 if (notFullscreen(e.presentation?.isFullscreen)) return { text: `STARK ARCADE needs Claude Code's fullscreen mode (the main screen gives a game no keys or clicks). ${FULLSCREEN_HINT}, then /clarvis play.` }
529 if (!(await openArcade($))) return { text: notShown('STARK ARCADE') }
530 return { text: 'STARK ARCADE is up. Click the pane, then 1–5 or ↑↓ SPACE to pick a game; SPACE goes through every screen. Q quits. Drag the pane taller for a bigger arena, or use a terminal 110+ columns wide for the big docked pane.' }
531}
532async function cmd_movies($: any, e: any, _v: string, _r: string): Promise<{ text: string }> {
533 if (notFullscreen(e.presentation?.isFullscreen)) return { text: `AVENGERS THEATER needs Claude Code's fullscreen mode. ${FULLSCREEN_HINT}, then /clarvis movies.` }
534 if (!(await openTheater($))) return { text: notShown('AVENGERS THEATER') }
535 return { text: 'AVENGERS THEATER is up: 10 movies. Click a movie or press 1–0 (0 opens #10), or use ↑↓ and SPACE to watch; ←/→ skip shots, P pauses, Q goes back to the list.' }
536}
537async function cmd_movie($: any, e: any, _v: string, q: string): Promise<{ text: string }> {
538 if (!q) return { text: `Use /clarvis movie <1-9, 0 for 10>, a title (${MOVIES.map(x => x.title.toLowerCase()).join(', ')}), next or random.` }
539 const seen = await seenMovies($)
540 const unseen = MOVIES.filter(m => !seen.includes(m.id)), pool = unseen.length ? unseen : MOVIES
541 const m = q === 'next' ? pool[0] : q === 'random' ? pool[Math.floor(Math.random() * pool.length)] : findMovie(q)
542 if (!m) return { text: `No movie "${q}". Use /clarvis movie <1-9, 0 for 10>, a title (${MOVIES.map(x => x.title.toLowerCase()).join(', ')}), next or random.` }
543 if (notFullscreen(e.presentation?.isFullscreen)) return { text: `AVENGERS THEATER needs Claude Code's fullscreen mode. ${FULLSCREEN_HINT}, then /clarvis movie ${m.n}.` }
544 if (!(await openTheater($, MOVIES.indexOf(m)))) return { text: notShown('AVENGERS THEATER') }
545 return { text: `AVENGERS THEATER: now showing #${m.n} ${stop(m.title)} P pauses, ←/→ skip beats, Q goes to the list.` }
546}
547async function cmd_demo($: any, _e: any, _v: string, _r: string): Promise<{ text: string }> {
548 if (!fullMode) return { text: 'The demo needs full mode (it plays the band\'s art). Try /clarvis full, then /clarvis demo.' }
549 if (working) return { text: 'A mission is running. Try /clarvis demo when it is done.' }
550 demo = { at: now(), phase: 'boot' }
551 nextFx = null; pending = undefined
552 await setFx($, { moment: 'boot', at: now(), detail: '', caption: '' })
553 await update($, bandNudge, n => n + 1)
554 kick()
555 return { text: `${DEMO_NOTE} It runs about 45 seconds.` }
556}
557async function cmd_stats($: any, _e: any, _v: string, rest: string): Promise<{ text: string }> {
558 if (rest === 'reset') {
559 await $.store.set('lifetime', emptyLifetime())
560 await $.store.set('ledger', sanitizeLedger(undefined)) // the card's days and the shipped-work counts too (trophies are kept)
561 return { text: 'Lifetime totals cleared: sessions, turns, tokens and cost start from zero. This session\'s numbers are untouched.' }
562 }
563 await refreshUsage($)
564 const lifetime = await $.store.get('lifetime').catch(() => undefined)
565 return { text: statsText({
566 stats, now: now(), model: hudInfo?.model, startedAt: reading.startedAt, contextTokens: reading.ctxTokens, contextWindow: reading.ctxWindow, contextPercent: reading.ctxPercent,
567 costUsd: reading.cost, limits: reading.limits, lifetime, showCost, extras: (await history($)) as { commits: number; green: number; prs: number },
568 }) }
569}
570
571// ── trophies, the card, the team and the sound ──────────────────────────────
572const heroNames = (): Record<string, string> => Object.fromEntries(ROSTER.map(id => [id, HERO[id].name]))
573const idsLine = () => pickable(ROSTER).join(', ')
574async function saveSel($: any) { await $.store.set('heroSel', sel); await $.store.set('heroHere', hereMap) }
575async function reshuffle($: any) { shuffleRoster(today()); await update($, rosterAtom, () => [...order]) }
576async function cmd_trophies($: any, _e: any, _v: string, _r: string): Promise<{ text: string }> {
577 return { text: trophiesText(sanitizeTrophies(await $.store.get('trophies').catch(() => undefined))) }
578}
579async function cmd_card($: any, e: any, _v: string, rest: string): Promise<{ text: string }> {
580 const a = parseCardArgs(rest)
581 if (!a) return { text: `No card "${rest}". Use /clarvis card [today|week|all] [nocost].` }
582 const [rawLg, rawLt, rawStreak] = await Promise.all(['ledger', 'lifetime', 'streak'].map(k => $.store.get(k).catch(() => undefined)))
583 const lg = sanitizeLedger(rawLg), d = today()
584 const text = missionCard({
585 range: a.range, today: d, sum: sumDays(lg, d, a.range), life: sanitizeLifetime(rawLt), extras: lg.life, streak: streakNow(rawStreak, d),
586 showCost: showCost && !a.nocost, quote: DONE[(Math.floor(now() / 86_400_000) + ['today', 'week', 'all'].indexOf(a.range)) % DONE.length]!, heroName: id => HERO[id as keyof typeof HERO]?.name ?? id,
587 })
588 let copied = false
589 try { const r = await $.ui.copy({ text, ...(typeof e.surface === 'string' ? { surface: e.surface } : {}) }); copied = r?.isCopied === true } catch { /* no clipboard: the card is still the reply */ }
590 return { text: text + (copied ? '\nCopied to clipboard' : '') }
591}
592async function cmd_pick($: any, _e: any, _v: string, rest: string): Promise<{ text: string }> {
593 const here = /(?:^|\s)here$/.test(rest), q = rest.replace(/(?:^|\s)here$/, '').trim()
594 if (!q) return { text: `Use /clarvis pick <name|auto> [here]. Now: ${hereMap[sessionCwd] ? `${HERO[hereMap[sessionCwd] as keyof typeof HERO]?.name} (this project)` : sel.pick ? HERO[sel.pick as keyof typeof HERO]?.name : 'auto'}. Heroes: ${idsLine()}.` }
595 if (q === 'auto') {
596 if (here) hereMap = pinHere(hereMap, sessionCwd, undefined); else sel = { team: sel.team }
597 await saveSel($); await reshuffle($); await update($, bandNudge, n => n + 1)
598 return { text: here ? 'This project is back on auto: the rotation, or your global pick.' : `Back on auto: the ${sel.team === 'all' ? 'whole team takes' : sel.team + ' team takes'} the stage in turn.` }
599 }
600 const m = findHero(q, ROSTER, heroNames())
601 if (!m.id) return { text: `No hero "${q}". Try one of: ${(m.suggestions ?? []).join(', ')}.` }
602 if (here) hereMap = pinHere(hereMap, sessionCwd, m.id); else sel = { ...sel, pick: m.id }
603 await saveSel($); await reshuffle($); await update($, bandNudge, n => n + 1); kick()
604 return { text: `${HERO[m.id as keyof typeof HERO].name} has the stage${here ? ' in this project' : ''}. /clarvis pick auto${here ? ' here' : ''} brings the rotation back.` }
605}
606async function cmd_team($: any, _e: any, _v: string, rest: string): Promise<{ text: string }> {
607 if (!isTeam(rest)) return { text: `${rest ? `No team "${rest}". ` : ''}Use /clarvis team <${TEAM_IDS.join('|')}>. Now: ${sel.team}.` }
608 sel = { ...sel, team: rest }
609 await saveSel($); await reshuffle($); await update($, bandNudge, n => n + 1); kick()
610 const who = rest === 'all' ? 'the whole roster' : TEAMS[rest].map(id => HERO[id as keyof typeof HERO].name).join(', ')
611 return { text: `Team ${rest}: ${who}.${sel.pick || hereMap[sessionCwd] ? ' A pick still overrides the team (/clarvis pick auto).' : ''}` }
612}
613async function cmd_notify($: any, _e: any, _v: string, rest: string): Promise<{ text: string }> {
614 const now0 = notifyMin > 0 ? `Clarvis pings you when a mission runs ${fmtMin(notifyMin)}+ minutes.` : 'Clarvis notifications are off.'
615 if (!rest) return { text: `${now0} Use /clarvis notify <minutes|off>.` }
616 const want = parseNotify(rest)
617 if (want === undefined) return { text: `Use /clarvis notify <minutes|off>, for example /clarvis notify 5. ${now0}` }
618 notifyMin = want; await $.store.set('notify', want)
619 return { text: want > 0 ? `Clarvis will ping you when a mission runs ${fmtMin(want)}+ minutes.` : 'Clarvis will not send notifications. /clarvis notify <minutes> turns them back on.' }
620}
621async function cmd_sound($: any, _e: any, _v: string, rest: string): Promise<{ text: string }> {
622 const where = fullMode ? '' : ` You are in ${calmMode ? 'calm' : liteMode ? 'lite' : 'off'} mode, which stays silent (/clarvis full).`
623 if (rest !== 'on' && rest !== 'off') return { text: `Sound is ${soundOn ? 'on' : 'off'}. Use /clarvis sound ${soundOn ? 'off to mute' : 'on to turn it on'} (or /clarvis sound ${soundOn ? 'on' : 'off'} to set it). ${SOUND_NOTE}${where}` }
624 soundOn = rest === 'on'; await $.store.set('sound', rest)
625 if (soundOn) playSound($, 'coin')
626 return { text: `Sound ${rest}. ${soundOn ? 'Original chiptune clips, a quiet volume. ' : ''}${SOUND_NOTE}${soundOn ? where : ''}` }
627}
628
629COMMANDS.push(
630 { id: 'look', group: 'Look', names: ['full', 'calm', 'lite', 'off'], tag: 'full · calm · lite · off', usage: 'full|calm|lite|off', summary: 'art, no motion, text only, or stock' },
631 { id: 'fx', group: 'Look', names: ['fx'], tag: '', usage: 'fx on|off', summary: 'tint the transcript with theater bursts (off)' },
632 { id: 'play', group: 'Play', names: ['play'], tag: 'play', usage: 'play', summary: 'STARK ARCADE, five games' },
633 { id: 'movies', group: 'Play', names: ['movies', 'theater'], tag: 'movies', usage: 'movies', summary: 'AVENGERS THEATER, ten comic movies' },
634 { id: 'movie', group: 'Play', names: ['movie'], tag: '', usage: 'movie <1-9|0|name|next>', summary: 'open one movie straight away' },
635 { id: 'demo', group: 'Play', names: ['demo'], tag: 'demo', usage: 'demo', summary: '45-second showreel (sample numbers)' },
636 { id: 'stats', group: 'Stats', names: ['stats'], tag: 'stats', usage: 'stats [reset]', summary: 'C.L.A.R.V.I.S. mission report: tokens, limits' },
637 { id: 'card', group: 'Stats', names: ['card'], tag: 'card', usage: 'card [today|week|all]', summary: 'a shareable mission card (nocost hides $)' },
638 { id: 'trophies', group: 'Stats', names: ['trophies', 'trophy'], tag: 'trophies', usage: 'trophies', summary: 'your trophy case' },
639 { id: 'pick', group: 'Hero', names: ['pick'], tag: 'pick', usage: 'pick <name|auto> [here]', summary: 'one hero on stage (here: pin to project)' },
640 { id: 'team', group: 'Hero', names: ['team'], tag: '', usage: 'team <name>', summary: 'avengers · guardians · wakanda · cosmic · all' },
641 { id: 'sound', group: 'Look', names: ['sound'], tag: '', usage: 'sound on|off', summary: 'chiptune sounds (on; macOS)' },
642 { id: 'notify', group: 'Look', names: ['notify'], tag: '', usage: 'notify <minutes|off>', summary: 'ping me when a long mission ends' },
643)
644
645// To add a /clarvis command: a row in the table above, a function like these, and its case here.
646async function runCmd($: any, e: any, cmd: HeroCmd, verb: string, rest: string): Promise<{ text: string }> {
647 switch (cmd.id) {
648 case 'look': return cmd_look($, e, verb, rest)
649 case 'fx': return cmd_fx($, e, verb, rest)
650 case 'play': return cmd_play($, e, verb, rest)
651 case 'movies': return cmd_movies($, e, verb, rest)
652 case 'movie': return cmd_movie($, e, verb, rest)
653 case 'demo': return cmd_demo($, e, verb, rest)
654 case 'stats': return cmd_stats($, e, verb, rest)
655 case 'card': return cmd_card($, e, verb, rest)
656 case 'trophies': return cmd_trophies($, e, verb, rest)
657 case 'pick': return cmd_pick($, e, verb, rest)
658 case 'team': return cmd_team($, e, verb, rest)
659 case 'sound': return cmd_sound($, e, verb, rest)
660 case 'notify': return cmd_notify($, e, verb, rest)
661 default: return { text: `No /clarvis command "${verb}". Type /clarvis for the list.` }
662 }
663}
664
665/** The mode a session starts in: CLARVIS, the stored choice, the `mode` option, a 256-colour terminal (lite), then full. */
666async function startMode($: any): Promise<{ mode: AvengersMode; auto: boolean }> {
667 const env = await $.env.get('CLARVIS')
668 const fromEnv = typeof env === 'string' ? env.trim().toLowerCase() : ''
669 if (isMode(fromEnv)) return { mode: fromEnv, auto: false }
670 const saved = await $.store.get('mode')
671 if (isMode(saved)) return { mode: saved, auto: false }
672 if (cfgMode) return { mode: cfgMode, auto: false }
673 const safe = async (f: () => unknown) => { try { return await f() } catch { return undefined } }
674 const t = {
675 colorterm: await safe(() => $.env.get('COLORTERM')), termProgram: await safe(() => $.env.get('TERM_PROGRAM')),
676 tmux: await safe(() => $.env.get('TMUX')), tmuxTruecolor: await safe(() => $.env.get('CLAUDE_CODE_TMUX_TRUECOLOR')),
677 }
678 if (needsLite(t)) return { mode: 'lite', auto: true }
679 return { mode: 'full', auto: false }
680}
681
682// The output text of a finished Bash call (stdout and stderr, however the result carries them), read and never kept.
683function outputOf(r: any): string {
684 const part = (v: unknown) => (typeof v === 'string' ? v : '')
685 const res = r?.result
686 const text = typeof res === 'string' ? res : res && typeof res === 'object' ? `${part(res.stdout)}\n${part(res.stderr)}`.trim() : ''
687 return text || part(r?.text)
688}
689async function observe($: any, e: any, tool: string, r: any) {
690 if (demo || isOff()) return
691 if (/^(Edit|MultiEdit|Write|NotebookEdit)$/.test(tool) && !r.isError) {
692 const x = extOf((e as { file_path?: unknown; notebook_path?: unknown }).file_path ?? (e as { notebook_path?: unknown }).notebook_path)
693 if (x && !exts.has(x) && exts.size < 50) { exts.add(x); if (exts.size === 5) await check($, { session: { extensions: exts.size } }) }
694 return
695 }
696 if (tool !== 'Bash') return
697 const sh = shipOf((e as { command?: unknown }).command, outputOf(r), !!r.isError)
698 if (!sh) return
699 turnShip = mergeShip(turnShip, sh)
700 const lg = await bumpLedger($, { commits: sh.commit ? 1 : 0, green: sh.tests ? 1 : 0, prs: sh.pr ? 1 : 0 })
701 if (lg) await check($, { commits: lg.life.commits, green: lg.life.green, prs: lg.life.prs })
702}
703
704export const register: Register = (on, options) => {
705 const opt = (options ?? {}) as Record<string, unknown>
706 showCost = opt.showCost !== false
707 notifyCfg = typeof opt.notifyAfterMinutes === 'number' && Number.isFinite(opt.notifyAfterMinutes) && opt.notifyAfterMinutes > 0 ? opt.notifyAfterMinutes : 0
708 notifyMin = notifyCfg
709 cfgMode = isMode(opt.mode) ? opt.mode : undefined
710 cfgSound = opt.sound !== false
711 on('session.start', async ($, e, next) => {
712 await sync($)
713 startedWall = now()
714 nudged = false; turnShip = undefined; turnHeroes = {}; exts.clear(); for (const k of Object.keys(counts)) delete counts[k] // a new session: its own tallies
715 sessionCwd = e.cwd
716 { const v = String(await $.env.get('CLARVIS_EGG') ?? '').trim().toLowerCase(); eggs = v === 'always' || v === 'never' ? v : 'chance' }
717 fxOn = (await $.store.get('fx')) === 'on' // the transcript echo is opt-in
718 const start = await startMode($)
719 const m = await update($, mode, () => start.mode)
720 fullMode = m === 'full'; calmMode = m === 'calm'; liteMode = m === 'lite'
721 demo = null
722 try { // who takes the stage, the sound choice, the day streak: stored, and made safe on the way in
723 sel = sanitizeSel(await $.store.get('heroSel'), ROSTER); hereMap = sanitizeHere(await $.store.get('heroHere'), ROSTER)
724 soundOn = soundChoice(await $.store.get('sound'), cfgSound)
725 notifyMin = notifyChoice(await $.store.get('notify'), notifyCfg)
726 streakDays = streakNow(await $.store.get('streak'), today())
727 } catch { sel = { team: 'all' }; hereMap = {}; soundOn = cfgSound; notifyMin = notifyCfg }
728 await reshuffle($) // a new session, a new order
729 echo = undefined; echoLast = -1e12; await update($, tfx, () => null) // a reload may have left a tint in the atom with no echo to clear it
730 // A reload mid-turn keeps the state but not these flags: pick them back up.
731 turnNow = await read($, turn)
732 working = !!turnNow?.active
733 currentFx = await read($, fx)
734 const model = shortModel(await $.session.model())
735 const name = folderName(e.cwd, (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')))
736 hudInfo = await update($, hud, () => ({ name, model }))
737 usageNow = await read($, usage)
738 Object.assign(stats, newStats()); stats.model = model; reading = { limits: [] } // a new session: the totals start over
739 await Promise.resolve($.command.register({ name: 'clarvis', description: `Clarvis: ${heroTags().join(' · ')}`, argumentHint: `[${heroTags().join('|').replace(/ · /g, '|')}]` })).catch(() => {})
740 await refreshUsage($)
741 let reloaded = false // the same session's module loaded again (a plugin update, or a hot reload while developing)
742 try { // one more session in the lifetime totals (a reload of the same session is not another)
743 const sid = String(reading.startedAt ?? '')
744 if (sid && (await $.store.get('lifetimeSession')) === sid) reloaded = true
745 else if (sid) { await $.store.set('lifetimeSession', sid); await bumpLifetime($, { sessions: 1 }) }
746 else if (!sid) await bumpLifetime($, { sessions: 1 })
747 } catch { /* decoration */ }
748 if (m !== 'off') {
749 try { // one more day on the streak: a toast at 3, 7 and 30 days
750 const was = sanitizeStreak(await $.store.get('streak')), s2 = bumpStreak(was, today())
751 if (s2.last !== was.last) await $.store.set('streak', s2)
752 streakDays = streakNow(s2, today())
753 if (s2.n !== was.n && STREAK_TOASTS.includes(s2.n)) await $.ui.toast(`★ ${s2.n}-day streak. The team is proud of you.`)
754 await check($, { ...(await history($)) })
755 } catch { /* decoration */ }
756 }
757 // first run: say once what this is and how to leave it; a terminal that shows 256 colours: say once why it is lite
758 let firstRun = false
759 if (m !== 'off') {
760 try {
761 if ((await $.store.get('firstRun')) !== true) { firstRun = true; await $.store.set('firstRun', true); await $.ui.toast(WELCOME_TOAST) }
762 if (start.auto && (await $.store.get('liteHint')) !== true) { await $.store.set('liteHint', true); await $.ui.toast(LITE_HINT) }
763 } catch { /* decoration */ }
764 }
765 const loop = (id: number) => {
766 timerAt = Date.now()
767 timer = $.clock.after(wantMs() || CALM_MS, () => {
768 if (id !== chain) return // a dropped chain's late step: let it die
769 void tick($).catch(() => {}).then(() => { if (id !== chain) return; timer = undefined; if (wantMs()) loop(id) })
770 })
771 }
772 // a step that never came back (a stalled dispatch) is dropped and a new chain laid
773 kick = () => { if (timer && Date.now() - timerAt > 3000) { timer = undefined; chain++ } if (!timer && wantMs()) loop(chain) }
774 // the suit boots once a session: a reload of the module replays neither the boot moment nor its hum
775 if (fullMode && !working && !currentFx && !reloaded) { await moment($, 'boot', '', firstRun ? WELCOME_CAPTION : ''); playSound($, 'boot') }
776 kick()
777 return next(e)
778 }).catch(($, e, next) => next(e)) // decoration never stands in a session's way
779
780 on('command.run', { command: 'clarvis' }, async ($, e) => {
781 await sync($)
782 const want = e.args.trim().toLowerCase()
783 if (!want) return { text: briefing({ mode: await read($, mode), cmds: COMMANDS.map(c => c.id === 'sound' ? { ...c, summary: `chiptune sounds (${soundOn ? 'on' : 'off'}; macOS)` } : c), fullscreen: e.presentation?.isFullscreen }) }
784 const sp = want.search(/\s/)
785 const verb = sp < 0 ? want : want.slice(0, sp), rest = sp < 0 ? '' : want.slice(sp + 1).trim()
786 const cmd = findCmd(verb)
787 if (!cmd) return { text: `No /clarvis command "${e.args.trim().split(/\s+/)[0]}". Type /clarvis for the list.` }
788 return runCmd($, e, cmd, verb, rest)
789 })
790
791 on('turn.start', async ($, e, next) => {
792 const r = await next(e) // observe only: the turn goes on first, the bookkeeping follows
793 if (isOff()) return r
794 try {
795 await sync($)
796 working = true
797 const text = (e as unknown as { text?: unknown }).text
798 const prompt = (typeof text === 'string' ? text : '').split('\n').find(l => l.trim())?.trim() ?? ''
799 turnNow = await update($, turn, () => ({ startedAt: now(), tools: 0, active: true, prompt }))
800 try { // /model may have changed the model since the session began
801 const model = shortModel(await $.session.model())
802 stats.model = model
803 if (hudInfo && model !== hudInfo.model) hudInfo = await update($, hud, h => (h ? { ...h, model } : h))
804 } catch { /* decoration */ }
805 await refreshUsage($)
806 costAtStart = usageNow.cost
807 bump++
808 act = 'thinking'; actAt = now(); curTool = ''; curDetail = ''; log = []; lastToolFailed = false; closing = false; nextFx = null // a fresh turn: JARVIS opens, the roster starts over
809 say(lineFor('thinking', bump)); shownAt = now(); pending = undefined
810 demo = null // the showreel ends at the first real prompt
811 await setFx($, null) // a new prompt: whatever held the band gives way
812 kick()
813 } catch { /* decoration never stands in the way */ }
814 return r
815 }).catch(($, e, next) => next(e)) // decoration never stands in the way
816
817 on('tool.call', async ($, e, next) => {
818 if (isOff()) return next(e) // the stock look: nothing to keep
819 const agentId = (e as unknown as { agentId?: string }).agentId
820 await sync($)
821 if (agentId) { agents.set(agentId, now()); agentRunning.set(agentId, (agentRunning.get(agentId) ?? 0) + 1) } // heard from: still at work
822 const detail = detailOf(e as unknown as Record<string, unknown>)
823 const tool = String(e.tool)
824 turnNow = await update($, turn, t => (t ? { ...t, tools: t.tools + 1, tool, detail } : t))
825 const who = heroFor(tool, detail)
826 bump++
827 curDetail = detail; toolAt = now()
828 show(who, lineFor(who, bump, detail || tool), tool)
829 counts[who] = (counts[who] ?? 0) + 1; counts.all = (counts.all ?? 0) + 1
830 turnHeroes[who] = (turnHeroes[who] ?? 0) + 1
831 if (counts.hulk === 25) void award($, 'hulk').catch(() => {})
832 if (counts.hawkeye === 15) void award($, 'hawkeye').catch(() => {})
833 if (counts.all === 100) void award($, 'century').catch(() => {})
834 // The tool is running, so any ask on it was answered: Fury stands down.
835 await standDownFury($)
836 kick()
837 running.push(tool)
838 let r: Awaited<ReturnType<typeof next>>
839 try { r = await next(e) } finally {
840 const i = running.lastIndexOf(tool); if (i >= 0) running.splice(i, 1)
841 if (agentId) { const n = (agentRunning.get(agentId) ?? 1) - 1; if (n > 0) agentRunning.set(agentId, n); else agentRunning.delete(agentId) }
842 }
843 await sync($)
844 if (agentId && agents.has(agentId)) agents.set(agentId, now())
845 // The log gains a line.
846 addTool(stats, tool, !!r.isError)
847 log = [{ ok: !r.isError, tool, detail: detail || tool, ms: now() - toolAt }, ...log].slice(0, 3)
848 if (!agentId) lastToolFailed = !!r.isError
849 void refreshUsage($)
850 if (r.isError) {
851 counts.errors = (counts.errors ?? 0) + 1
852 }
853 // What the call shipped (a commit, a green test run, a pull request) and which kind of file an edit touched:
854 // read from the finished call, small facts only; the result is returned exactly as it came.
855 try { await observe($, e, tool, r) } catch { /* decoration */ }
856 return r
857 }).catch(($, e, next) => next(e)) // never in the way of a tool: replay or run it
858
859 // Observe the permission verdict only; the answer always comes from the engine.
860 on('tool.check', async ($, e, next) => {
861 const v = await next(e)
862 await sync($)
863 // The permission dialog takes the band's place while it shows, so Fury
864 // can't stand there; a line beneath the dialog says it instead.
865 if (v.decision === 'ask' && (await read($, mode)) !== 'off' && e.tool_use_id) void Promise.resolve($.ui.notice(e.tool_use_id, '◐ Nick Fury: the team is holding for your call.')).catch(() => {})
866 return v
867 }).catch(($, e, next) => next(e))
868
869 on('turn.complete', async ($, e, next) => {
870 const result = await next(e)
871 if (isOff()) return result
872 if (e.agentId) { agents.delete(e.agentId); agentRunning.delete(e.agentId); addTurn(stats, result?.usage, { agent: true }); kick(); await update($, bandNudge, n => n + 1); return result }
873 await sync($)
874 working = false; closing = true
875 running.length = 0 // a tool that never came back (cancelled) can't hold the header past its turn
876 turnNow = await update($, turn, x => (x ? { ...x, active: false } : x))
877 const tools = turnNow?.tools ?? 0
878 // the closing line is drawn from the engine's duration alone, so keep how this turn ended under it
879 const ended = e.reason === 'error' || e.reason === 'refusal' ? 'failed' : e.reason === 'aborted' || (e as { isAborted?: boolean }).isAborted ? 'aborted' : 'done'
880 outcomes.set(e.durationMs, { outcome: ended, tools }); if (outcomes.size > 50) outcomes.delete(outcomes.keys().next().value!)
881 await setFx($, null)
882 const tt = addTurn(stats, result?.usage, { ms: e.durationMs }) // the turn's real tokens (observed, never changed)
883 await refreshUsage($)
884 const spent = Math.max(0, usageNow.cost - costAtStart)
885 if (stats.last) stats.last.cost = num(spent)
886 const lifeRes = await bumpLifetime($, { turns: 1, toolCalls: tools, inputTokens: inputTotal(tt), outputTokens: tt.output, cost: spent, longestMs: e.durationMs, turnTools: tools })
887 await bumpLedger($, { turns: 1, actions: tools, inTok: inputTotal(tt), outTok: tt.output, cacheTok: tt.cacheRead, cost: spent, heroes: turnHeroes })
888 const ship = turnShip
889 turnShip = undefined; turnHeroes = {}
890 const toks = tt.output > 0 || inputTotal(tt) > 0 ? ` · ↑${fmtTokens(inputTotal(tt))} ↓${fmtTokens(tt.output)}` : ''
891 const money = showCost && spent > 0 ? ` · $${spent.toFixed(2)}` : ''
892 // Thanos snaps when the turn errored, or when Claude's last tool call failed and it never recovered
893 const snapped = e.reason === 'error' || (e.reason === 'answer' && lastToolFailed)
894 if (snapped) {
895 counts.failedTurns = (counts.failedTurns ?? 0) + 1
896 await moment($, 'snap', 'The turn hit an error.', log[0] && !log[0].ok ? `last: ${log[0].tool} failed` : '')
897 playSound($, 'whomp')
898 } else if (e.reason === 'answer' && (e.durationMs >= 5000 || tools > 0) && /\?\s*$/.test(String((e as unknown as { answer?: unknown }).answer ?? ''))) {
899 // the turn ended on a question for you: Nick Fury holds the band until you reply
900 const q = String((e as unknown as { answer?: unknown }).answer).trim().split('\n').filter(l => l.trim()).pop() ?? ''
901 await moment($, 'fury', clip(q.replace(/[*_`#>]/g, '').trim(), 160), endCaption('fury', { n: bump, pool: DONE }))
902 } else if (e.reason === 'answer' && (e.durationMs >= 5000 || tools > 0)) {
903 const big = tools >= 25 || e.durationMs >= 5 * 60_000
904 // the end of a task: the Avengers assemble (Thanos snaps only for a turn that failed)
905 // the season (full mode only) and a rare egg colour the caption; what was shipped outranks them (see endCaption)
906 const d = today(), season = fullMode && isRealDate(d) ? seasonOf(d) : null
907 const egg = fullMode && isRealDate(d) ? rollEgg(eggs === 'always' ? () => 0 : eggs === 'never' ? () => 0.99 : dice.rng, { sessionMs: Math.max(0, now() - startedWall) }) : undefined
908 const flair: Flair | undefined = egg ?? flairOf(season)
909 const caption = endCaption('done', { n: bump, big, pool: DONE, ship: shipCaption(ship), ...(egg ? { egg: EGG_CAPTIONS[egg] } : {}), ...(seasonCaption(season) ? { season: seasonCaption(season)! } : {}) })
910 await moment($, 'assemble', `✓ mission complete · ${fmtDur(e.durationMs)} · ${plural(tools, 'action')}${money}${toks}`, caption, flair)
911 playSound($, ship ? 'levelup' : 'assemble')
912 if (big) await award($, 'ironman')
913 if ((counts.failedTurns ?? 0) > 0) await award($, 'snap')
914 }
915 // the history's trophies and the lifetime milestones (observed after the moment has been set: nothing here slows the turn's end)
916 try {
917 const d = today(), real = isRealDate(d)
918 if (real && e.reason === 'answer') {
919 const h = d.getHours()
920 let nights = 0
921 if (h >= 1 && h < 5) { // a mission past 1am: remember the night (the last 30)
922 const list: unknown = await $.store.get('nights').catch(() => undefined)
923 const k = dayKey(d), have = Array.isArray(list) ? list.filter((x): x is string => typeof x === 'string') : []
924 if (!have.includes(k)) { have.push(k); await $.store.set('nights', have.slice(-30)) }
925 nights = have.length
926 }
927 await check($, { hour: h, nights })
928 }
929 await check($, {
930 durationMs: e.reason === 'answer' ? e.durationMs : 0, ...(await history($)),
931 session: { input: inputTotal(stats), ...(cacheHit(stats) !== undefined ? { cacheHit: cacheHit(stats)! } : {}), extensions: exts.size },
932 })
933 if (lifeRes) for (const m of crossed(lifetimeTokens(lifeRes.before), lifetimeTokens(lifeRes.after))) {
934 const done: unknown = await $.store.get('milestones').catch(() => undefined)
935 const seen = Array.isArray(done) ? done.filter((x): x is number => typeof x === 'number') : []
936 if (!seen.includes(m)) { await $.store.set('milestones', [...seen, m]); await $.ui.toast(milestoneText(m)) }
937 }
938 if (!nudged) { const n = contextNudge(usageNow.percent); if (n) { nudged = true; await $.ui.toast(n) } } // once a session, and sincere
939 } catch { /* decoration */ }
940 // a long mission: tell the person who has looked away (the notifyAfterMinutes option)
941 if (notifyMin > 0 && e.reason === 'answer' && e.durationMs >= notifyMin * 60_000) {
942 try { await $.ui.notify(notifyBody(e.durationMs, plural(tools, 'action'), ship), { title: 'Clarvis' }) } catch { /* decoration */ }
943 }
944 closing = false
945 if (coinDue > 0) coinDue = Math.max(coinDue, now() + COIN_DEFER_MS) // trophies of this turn: after the end-of-turn clip
946 kick()
947 return result
948 }).catch(($, e, next) => next(e)) // decoration never stands in the way
949
950 // The engine measured the session (context, a rate-limit window or the cost moved): observe it
951 // and keep the band's numbers fresh. The result is the engine's, passed on untouched.
952 on('session.measure', async ($, e, next) => {
953 const r = await next(e)
954 if (isOff()) return r
955 try { await applyUsage($, { ...e, startedAt: reading.startedAt }) } catch { /* decoration */ }
956 return r
957 }).catch(($, e, next) => next(e))
958
959 // Compaction: Ant-Man shrinks the context, then grows back to size.
960 on('session.compact', async ($, e, next) => {
961 await sync($)
962 compacting = true; curDetail = 'shrinking the context with Pym particles'
963 shownAt = 0; show('antman', lineFor('antman', bump++), 'compact')
964 kick()
965 try { return await next(e) } finally {
966 await sync($)
967 compacting = false; curTool = ''; curDetail = ''
968 say(lineFor('antman', 0))
969 kick()
970 await refreshUsage($) // the context shrank: the gauge must say so
971 }
972 }).catch(($, e, next) => next(e)) // decoration never stands in the way
973
974 // /clear ends the conversation and starts another under the same process, with no session.start:
975 // the context figures are stale until read again.
976 on('session.end', async ($, e, next) => {
977 const r = await next(e)
978 if (e.reason === 'clear') { await sync($); await refreshUsage($) }
979 return r
980 }).catch(($, e, next) => next(e))
981
982 // ── the spinner: folded into the band while it shows ─────────────────────
983 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
984 await sync($)
985 const m = await read($, mode)
986 if (m === 'off' || e.surface !== 'terminal') return next(e)
987 // thinking and responding take the stage back from the last tool
988 const sm = e.props.mode
989 if (sm !== spinMode) {
990 spinMode = sm
991 if (sm === 'thinking' || sm === 'responding') { curDetail = ''; bump++; show(sm, lineFor(sm, bump)) }
992 }
993 const { Box } = $.ui.resolve(e)
994 if (bandKey || m === 'lite') return <Box /> // the lite band's second row names the hero and the tool
995 const t = await read($, turn)
996 return line1($, e, spinnerRow({
997 mode: sm, hero: t?.tool ? HERO[heroFor(t.tool, t.detail ?? '')].name : undefined, tool: t?.tool, detail: t?.detail, tools: t?.tools ?? 0,
998 elapsed: t ? fmtDur(now() - t.startedAt) : '', width: Math.max(20, (e.viewport?.columns ?? 100) - 2),
999 }))
1000 })
1001
1002 // ── the band: one Raster ────────────────────────────────────────────────
1003 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1004 await sync($)
1005 const m = await read($, mode)
1006 const info = await read($, hud)
1007 await read($, bandNudge) // subscribes: the clock asks for a redraw (another size, an agent coming or going) through it
1008 if (m === 'off' || !info || e.props.hasSurvey || e.surface !== 'terminal' || e.props.maxRows < 3) { bandKey = ''; return next(e) }
1009 hudInfo = info
1010 if (m === 'lite') { // text rows: no Raster, no clock; a write to the turn or usage atoms redraws them
1011 bandKey = ''
1012 usageNow = await read($, usage); turnNow = await read($, turn)
1013 const { Box, Text } = $.ui.resolve(e)
1014 const isWork = !!turnNow?.active || e.props.isWorking
1015 const hero = turnNow?.tool ? HERO[heroFor(turnNow.tool, turnNow.detail ?? '')].name : 'JARVIS'
1016 const rows = liteRows({
1017 name: info.name, model: info.model, percent: usageNow.percent, tokens: usageNow.tokens, cost: usageNow.cost, showCost, pieces: piecesOf(), cols: Math.min(512, Math.max(NARROW_MIN, e.props.bodyColumns)),
1018 work: isWork ? { hero, tool: turnNow?.tool ?? '', detail: turnNow?.detail ?? '' } : null, agents: isWork ? 0 : agents.size,
1019 })
1020 return (
1021 <Box key="avengers-band" flexDirection="column">
1022 {rows.map((row, i) => (
1023 <Box key={`lite${i}`} flexDirection="row">
1024 {row.map(sg => <Text color={sg.color} bold={!!sg.bold} wrap="truncate">{sg.text}</Text>)}
1025 </Box>
1026 ))}
1027 </Box>
1028 )
1029 }
1030 showPending() // the clock parks without a band: a hero waiting its turn takes the stage now
1031 usageNow = await read($, usage) // subscribes: the idle line shows fresh numbers
1032 turnNow = await read($, turn)
1033 let x = await read($, fx)
1034 if (x && x.moment !== 'fury' && now() - x.at > MOMENT_SECONDS[x.moment as Moment] * 1000) x = null // over: never draw a stale moment
1035 currentFx = x
1036 bandReq = e.requestId
1037 bandCols = Math.min(512, Math.max(NARROW_MIN, e.props.bodyColumns)) // beside a docked pane the column can be ~20: draw at its true width, never wider (wider is clipped to nothing)
1038 bandMaxRows = e.props.maxRows
1039 const f = frame(viewOf(x, busy(e.props.isWorking)))
1040 bandKey = `${f.rows}x${bandCols}`
1041 lastCells = f.cells
1042 kick() // the band is mounted: the clock may run (it parks while no band is there)
1043 const { Raster, Box, Button } = $.ui.resolve(e)
1044 // the play button, over the band's top row: a click (or the band's hotkey) opens the arcade
1045 const v = viewOf(x, busy(e.props.isWorking))
1046 const fullscreen = e.viewport?.isFullscreen
1047 const right = v.kind === 'work' ? 13 : v.kind === 'idle' ? 1 : -1 // the arcade button; the movies button sits left of it
1048 return (
1049 <Box key="avengers-band">
1050 <Raster key={bandKey} columns={bandCols} rows={f.rows} cells={f.cells} />
1051 {right >= 0 && bandCols >= 90
1052 ? <Box position="absolute" top={0} right={right}><Button key="play" plain label="▶ STARK ARCADE" onPress={() => { void playButton($, fullscreen, () => openArcade($)) }} /></Box>
1053 : null}
1054 {right >= 0 && bandCols >= (v.kind === 'work' ? 100 : 90)
1055 ? <Box position="absolute" top={0} right={right + 16}><Button key="movies" plain label="▶ MOVIES" onPress={() => { void playButton($, fullscreen, () => openTheater($)) }} /></Box>
1056 : null}
1057 </Box>
1058 )
1059 })
1060
1061 // ── STARK ARCADE: a pane the person opens with /clarvis play ────────────
1062 on('ui.render', { component: 'Pane', requestId: 'avengers-arcade' }, async ($, e, next) => {
1063 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
1064 const { Box, Client, Text, Button } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
1065 if (notFullscreen(e.viewport?.isFullscreen)) return needsFullscreen($, e, 'STARK ARCADE', 'avengers-arcade')
1066 const best = Number(await $.store.get('arcadeBest')) || 0
1067 const inline = e.props.placement === 'inline'
1068 // exactly the room the pane has, less the top row: the game fills it and never scrolls
1069 const rows = Math.max(8, (e.props.scroll?.bodyRows || 32) - 1)
1070 return (
1071 <Box flexDirection="column">
1072 <Box flexDirection="row">
1073 <Box flexGrow={1}>
1074 {inline
1075 ? <Text color="#FFB020" wrap="truncate">Small view: this pane is under 110 columns. Widen it for the big docked arcade · click the game to play · q quits</Text>
1076 : <Text color="#8C90B4" wrap="truncate">Click the game to play · 1–5 pick · q quits · Esc gives the keys back to the prompt</Text>}
1077 </Box>
1078 <Button key="close" plain label="✕ Close" onPress={() => { void $.ui.close({ id: 'avengers-arcade' }) }} />
1079 </Box>
1080 <Client key="arcade" module="./arcade.tsx" props={{ best, focused: e.props.isFocused }} width={Math.max(40, e.props.bodyColumns)} height={rows} />
1081 </Box>
1082 )
1083 })
1084 // ── AVENGERS THEATER: the comic movies, in a pane ──────────────────────────
1085 on('ui.render', { component: 'Pane', requestId: 'avengers-theater' }, async ($, e, next) => {
1086 if (e.surface !== 'terminal' && e.surface !== 'desktop') return next(e)
1087 const { Box, Client, Text, Button } = $.ui.resolve(e as typeof e & { surface: 'terminal' })
1088 if (notFullscreen(e.viewport?.isFullscreen)) return needsFullscreen($, e, 'AVENGERS THEATER', 'avengers-theater')
1089 await read($, paneNudge) // subscribes: a new movie or setting redraws this pane alone
1090 const seen = await seenMovies($)
1091 const inline = e.props.placement === 'inline'
1092 const rows = Math.max(10, (e.props.scroll?.bodyRows || 32) - 1)
1093 return (
1094 <Box flexDirection="column">
1095 <Box flexDirection="row">
1096 <Box flexGrow={1}>
1097 {inline
1098 ? <Text color="#FFB020" wrap="truncate">Small view: widen the pane for bigger panels · click the theater, then 1–0 · q closes</Text>
1099 : <Text color="#8C90B4" wrap="truncate">Click a movie or press 1–0 / ↑↓ + SPACE · ←/→ beats · P pause · Q list · Esc gives the keys back</Text>}
1100 </Box>
1101 <Button key="close" plain label="✕ Close" onPress={() => { void $.ui.close({ id: 'avengers-theater' }) }} />
1102 </Box>
1103 <Client key="theater" module="./theater.tsx" props={{ seen, fx: fullMode, ...(theaterStart !== undefined ? { start: theaterStart, nonce: theaterNonce } : {}) }} width={Math.max(40, e.props.bodyColumns)} height={rows} />
1104 </Box>
1105 )
1106 })
1107 on('ui.render', { component: 'AssistantMessage' }, echoRow).catch(passRow)
1108 on('ui.render', { component: 'UserMessage' }, echoRow).catch(passRow)
1109 on('ui.render', { component: 'ToolUse' }, echoRow).catch(passRow)
1110 on('ui.render', { component: 'ToolResult' }, echoRow).catch(passRow)
1111 on('ui.render', { component: 'ToolGroup' }, echoRow).catch(passRow)
1112 on('ui.message', async ($, e, next) => {
1113 if (e.element === 'theater') {
1114 const d = e.data as { watched?: unknown; close?: unknown; fx?: unknown } | null
1115 if (d?.close === true) { await $.ui.close({ id: 'avengers-theater' }); return {} }
1116 if (typeof d?.watched === 'string' && MOVIES.some(m => m.id === d.watched)) {
1117 const seen = await seenMovies($)
1118 const all = seen.includes(d.watched) ? seen : [...seen, d.watched]
1119 await $.store.set('moviesSeen', all)
1120 await sync($)
1121 await check($, { moviesSeen: all.length, moviesTotal: MOVIES.length })
1122 return { props: { seen: all, fx: fullMode } }
1123 }
1124 if (typeof d?.fx === 'string') { await sync($); burstAt = now() } // sound keeps quiet while the theater bursts
1125 if (typeof d?.fx === 'string' && fxOn && fullMode) {
1126 await sync($)
1127 if (now() - echoLast >= ECHO_GAP_MS) { echoLast = now(); echo = { kind: d.fx, at: now(), step: 0 }; await update($, tfx, () => ({ kind: d.fx as string, step: 0 })); kick() }
1128 }
1129 return {}
1130 }
1131 if (e.element !== 'arcade') return next(e)
1132 const d = e.data as { score?: unknown; close?: unknown } | null
1133 if (d?.close === true) { await $.ui.close({ id: 'avengers-arcade' }); return {} }
1134 if (typeof d?.score === 'number' && Number.isFinite(d.score)) {
1135 const best = Math.min(1e9, Math.max(Number(await $.store.get('arcadeBest')) || 0, Math.floor(d.score)))
1136 await $.store.set('arcadeBest', best)
1137 await sync($)
1138 await check($, { arcadeBest: best })
1139 return { props: { best } }
1140 }
1141 return {}
1142 })
1143
1144 // ── the turn's closing line ─────────────────────────────────────────────
1145 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
1146 if ((await read($, mode)) === 'off') return next(e)
1147 const o = outcomes.get(e.props.durationMs)
1148 return line1($, e, footerRow(e.props.durationMs, o?.tools ?? 0, o?.outcome ?? 'done'))
1149 })
1150}
1151hooks/art.ts 63 lines1// clarvis: the text pieces drawn outside the band: the quiet spinner line
2// (calm mode, or when the band has no room) and the turn's closing line, plus
3// what a tool call is working on, short. The band itself is scenes.ts.
4
5export type Seg = { text: string; color: string; bg?: string; bold?: boolean }
6export type Row = Seg[]
7const C = { text: '#E6E6F5', muted: '#8A8FB0', arc: '#5FE1FF', gold: '#F5C542', thanos: '#8E5BD6' }
8const seg = (text: string, color: string, bold = false): Seg => ({ text, color, bold })
9function merge(row: Row): Row {
10 const out: Row = []
11 for (const s of row) {
12 const p = out[out.length - 1]
13 if (p && p.color === s.color && p.bg === s.bg && !!p.bold === !!s.bold) out[out.length - 1] = { ...p, text: p.text + s.text }
14 else out.push({ ...s })
15 }
16 return out
17}
18function fit(row: Row, w: number): Row {
19 const out: Row = []
20 let left = Math.max(0, w)
21 for (const s of row) {
22 if (left <= 0) break
23 const chars = [...s.text]
24 out.push({ ...s, text: chars.slice(0, left).join('') })
25 left -= Math.min(chars.length, left)
26 }
27 return out
28}
29
30// The quiet spinner: ◉ JARVIS and what's running, the clock kept visible.
31const VERB: Record<string, string> = { 'tool-use': 'running', 'tool-input': 'assembling the suit…', responding: 'composing a reply…', requesting: 'uplink to the satellite…', thinking: 'analysing…' }
32export type SpinnerArgs = { mode: string; hero?: string; tool?: string; detail?: string; elapsed: string; tools: number; width: number }
33export function spinnerRow(a: SpinnerArgs): Row {
34 const tail: Row = [seg(` ${a.elapsed}${a.tools ? ` · ${a.tools} tool${a.tools === 1 ? '' : 's'}` : ''}`, C.muted)]
35 const what = a.mode === 'tool-use' && a.tool ? a.tool /* the tool, never its command */ : VERB[a.mode] ?? VERB.thinking!
36 const who = a.mode === 'tool-use' && a.hero ? a.hero : 'JARVIS'
37 const row: Row = [seg('◉', C.arc, true), seg(` ${who}: `, C.arc, true), seg(what, C.text)]
38 const used = tail.reduce((n, s) => n + [...s.text].length, 0)
39 return merge([...fit(row, a.width - used), ...tail])
40}
41
42// 42s · 1m 04s · 1h 02m: the one format every elapsed time here uses.
43export const fmtDur = (ms: number) => {
44 const s = Math.max(0, Math.round(ms / 1000)), p2 = (n: number) => String(n).padStart(2, '0')
45 return s < 60 ? `${s}s` : s < 3600 ? `${Math.floor(s / 60)}m ${p2(s % 60)}s` : `${Math.floor(s / 3600)}h ${p2(Math.floor((s % 3600) / 60))}m`
46}
47export function footerRow(durationMs: number, tools: number, outcome: 'done' | 'failed' | 'aborted'): Row {
48 const what = outcome === 'failed' ? [seg('◆ ', C.thanos, true), seg('Snapped', C.thanos, true)]
49 : outcome === 'aborted' ? [seg('■ ', C.muted, true), seg('Stood down', C.muted, true)]
50 : [seg('✓ ', C.gold, true), seg('Mission complete', C.gold, true)]
51 return merge([...what, seg(` in ${fmtDur(durationMs)}${tools ? ` · ${tools} tool${tools === 1 ? '' : 's'}` : ''}`, C.muted)])
52}
53
54// What to say a tool is working on, short.
55export function detailOf(e: Record<string, unknown>): string {
56 const pick = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
57 let s = pick('file_path') || pick('notebook_path') || pick('command') || pick('pattern') || pick('query') || pick('url') || pick('description') || pick('path')
58 s = s.split('\n')[0] ?? ''
59 if (s.includes('/') && !s.includes(' ')) s = s.split('/').slice(-2).join('/')
60 const a = [...s] // by code point: never split a pair
61 return a.length > 48 ? a.slice(0, 47).join('') + '…' : s
62}
63hooks/pixel.ts 325 lines1// clarvis: the pixel engine. A Canvas is a grid of truecolor pixels; it
2// encodes to Raster cells as half blocks (▀ with the top pixel as foreground
3// and the bottom as background), so one terminal row holds two pixel rows.
4// Pure and allocation-free per frame: a Canvas and its cell buffer are made
5// once per size and reused, so animating never grows memory.
6
7export const CLEAR = -1 // a transparent pixel: the card's surface (or the terminal's background) shows
8export const HOLE = -2 // through the card too: the terminal's own background (the card's rounded corners)
9export const DEFAULT = 0x01000000 // Raster's "terminal default" colour
10// What a half-transparent pixel is blended onto: a Ghostty background.
11// A dark page behind the art; on any other dark theme the art still reads.
12export const BACKDROP = 0x1b1b29
13// A logical pixel (one column across, half a row down) is not square: in
14// a Ghostty a cell is 8.4 × 20.3, so a pixel is 0.83 as wide as tall.
15// Round things measure x in heights: dx * PXW.
16export const PXW = 8.4 / 10.15
17
18export const rgb = (hex: string) => parseInt(hex.slice(1, 7), 16)
19const R = (c: number) => (c >> 16) & 255, G = (c: number) => (c >> 8) & 255, B = (c: number) => c & 255
20export function lerp(a: number, b: number, t: number): number {
21 const k = t < 0 ? 0 : t > 1 ? 1 : t
22 return ((R(a) + (R(b) - R(a)) * k) << 16) | ((G(a) + (G(b) - G(a)) * k) << 8) | (B(a) + (B(b) - B(a)) * k) | 0
23}
24// Adds light (glows, beams, sparks): saturates instead of washing out.
25export function add(a: number, b: number, t: number): number {
26 const k = t < 0 ? 0 : t > 1 ? 1 : t
27 return (Math.min(255, R(a) + R(b) * k) << 16) | (Math.min(255, G(a) + G(b) * k) << 8) | Math.min(255, B(a) + B(b) * k)
28}
29export const scale = (c: number, k: number) => lerp(0, c, k)
30
31// ── sprites ─────────────────────────────────────────────────────────────────
32// Drawn as text art: one character per pixel, looked up in a palette; '.' is clear.
33export type Sprite = { w: number; h: number; px: Int32Array }
34export function sprite(rows: string[], pal: Record<string, string>): Sprite {
35 const w = Math.max(...rows.map(r => r.length)), h = rows.length
36 const px = new Int32Array(w * h).fill(CLEAR)
37 rows.forEach((r, y) => { for (let x = 0; x < r.length; x++) { const ch = r[x]!; if (ch !== '.' && ch !== ' ') { const c = pal[ch]; if (c === undefined) throw new Error(`sprite: no colour for '${ch}'`); px[y * w + x] = rgb(c) } } })
38 return { w, h, px }
39}
40// A sprite mirrored.
41export function flipX(s: Sprite): Sprite {
42 const px = new Int32Array(s.w * s.h)
43 for (let y = 0; y < s.h; y++) for (let x = 0; x < s.w; x++) px[y * s.w + x] = s.px[y * s.w + (s.w - 1 - x)]!
44 return { w: s.w, h: s.h, px }
45}
46
47// ── the canvas ──────────────────────────────────────────────────────────────
48// Scenes draw in logical pixels: one terminal column wide, half a row tall
49// (about square). Underneath, each logical pixel is two subpixels across, so
50// a cell holds 2 × 2 subpixels and encodes as a quadrant block: twice the
51// horizontal detail at edges. Baked art (HiSprite) is drawn at subpixel
52// resolution; the logical-pixel calls fill both subpixels.
53export type HiSprite = { w: number; h: number; px: Int32Array; a: Uint8Array } // w in subpixels
54export class Canvas {
55 readonly W: number // subpixels across
56 readonly px: Int32Array
57 // Text drawn over the art, one code point per cell (0 = none) with its colour.
58 readonly glyph: Uint32Array
59 readonly glyphFg: Int32Array
60 // A text cell's own background (-1: the card's surface), for speech bubbles and caption boxes.
61 readonly glyphBg: Int32Array
62 // The card's surface: what clear pixels and text sit on (DEFAULT: no card).
63 panel = DEFAULT
64 // A horizontal clip, in subpixels: drawing outside [cl, cr) is dropped (comic panels).
65 cl = 0
66 cr = 1e9
67 static made = 0 // canvases made since load: a frame must never make one
68 constructor(readonly w: number, readonly h: number) {
69 Canvas.made++
70 this.W = w * 2
71 this.px = new Int32Array(this.W * h).fill(CLEAR)
72 const cells = w * Math.ceil(h / 2)
73 this.glyph = new Uint32Array(cells)
74 this.glyphFg = new Int32Array(cells)
75 this.glyphBg = new Int32Array(cells).fill(-1)
76 }
77 get rows() { return Math.ceil(this.h / 2) }
78 clear() { this.px.fill(CLEAR); this.glyph.fill(0); this.glyphBg.fill(-1); this.panel = DEFAULT; this.cl = 0; this.cr = 1e9 }
79 clip(xs0: number, xs1: number) { this.cl = Math.floor(xs0); this.cr = Math.floor(xs1) }
80 unclip() { this.cl = 0; this.cr = 1e9 }
81 get(x: number, y: number) { const xs = Math.floor(x * 2); y = Math.floor(y); return xs < 0 || y < 0 || xs >= this.W || y >= this.h ? CLEAR : this.px[y * this.W + xs]! }
82 // one subpixel, at subpixel column xs
83 setSub(xs: number, y: number, c: number) {
84 xs = Math.floor(xs); y = Math.floor(y)
85 if (xs >= this.cl && xs < this.cr && xs >= 0 && y >= 0 && xs < this.W && y < this.h) this.px[y * this.W + xs] = c
86 }
87 glowSub(xs: number, y: number, c: number, a: number) {
88 xs = Math.floor(xs); y = Math.floor(y)
89 if (a <= 0 || xs < this.cl || xs >= this.cr || xs < 0 || y < 0 || xs >= this.W || y >= this.h) return
90 const i = y * this.W + xs, under = this.px[i]!
91 if (under === CLEAR) { if (a >= 0.12) this.px[i] = add(BACKDROP, c, a) }
92 else this.px[i] = add(under, c, a)
93 }
94 blendSub(xs: number, y: number, c: number, a: number) {
95 xs = Math.floor(xs); y = Math.floor(y)
96 if (a <= 0 || xs < this.cl || xs >= this.cr || xs < 0 || y < 0 || xs >= this.W || y >= this.h) return
97 const i = y * this.W + xs, under = this.px[i]!
98 this.px[i] = a >= 1 ? c : lerp(under === CLEAR ? BACKDROP : under, c, a)
99 }
100 set(x: number, y: number, c: number) { const xs = Math.floor(x) * 2; this.setSub(xs, y, c); this.setSub(xs + 1, y, c) }
101 blend(x: number, y: number, c: number, a: number) { const xs = Math.floor(x) * 2; this.blendSub(xs, y, c, a); this.blendSub(xs + 1, y, c, a) }
102 // Adds light; a faint glow over clear stays clear, so the art never paints
103 // muddy squares on the terminal's background.
104 glow(x: number, y: number, c: number, a: number) { const xs = Math.floor(x) * 2; this.glowSub(xs, y, c, a); this.glowSub(xs + 1, y, c, a) }
105 rect(x: number, y: number, w: number, h: number, c: number) {
106 for (let j = 0; j < h; j++) for (let i = 0; i < w * 2; i++) this.setSub(Math.floor(x * 2) + i, y + j, c)
107 }
108 // A text-art sprite (logical pixels) with its top-left at x, y.
109 draw(s: Sprite, x: number, y: number, o: { tint?: number; tintAmt?: number; alpha?: number; flip?: boolean } = {}) {
110 x = Math.round(x); y = Math.round(y)
111 const a = o.alpha ?? 1
112 for (let j = 0; j < s.h; j++) for (let i = 0; i < s.w; i++) {
113 let c = s.px[j * s.w + (o.flip ? s.w - 1 - i : i)]!
114 if (c === CLEAR) continue
115 if (o.tint !== undefined) c = lerp(c, o.tint, o.tintAmt ?? 0.5)
116 if (a >= 1) this.set(x + i, y + j, c); else this.blend(x + i, y + j, c, a)
117 }
118 }
119 // Baked art, anti-aliased, with its top-left at logical x, y.
120 hi(s: HiSprite, x: number, y: number, o: { tint?: number; tintAmt?: number; alpha?: number; flip?: boolean; add?: boolean } = {}) {
121 const xs0 = Math.round(x * 2), y0 = Math.round(y), k = o.alpha ?? 1
122 for (let j = 0; j < s.h; j++) {
123 const yy = y0 + j
124 if (yy < 0 || yy >= this.h) continue
125 for (let i = 0; i < s.w; i++) {
126 const xx = xs0 + i
127 if (xx < 0 || xx >= this.W || xx < this.cl || xx >= this.cr) continue
128 const si = j * s.w + (o.flip ? s.w - 1 - i : i), a = (s.a[si]! / 255) * k
129 if (a < 0.08) continue
130 let c = s.px[si]!
131 if (o.tint !== undefined) c = lerp(c, o.tint, o.tintAmt ?? 0.5)
132 if (o.add) this.glowSub(xx, yy, c, a)
133 else this.blendSub(xx, yy, c, a >= 0.92 ? 1 : a)
134 }
135 }
136 }
137 // A sprite (either kind) rotated by `ang` and scaled, its centre at cx, cy.
138 drawRot(s: Sprite | HiSprite, cx: number, cy: number, ang: number, o: { sx?: number; sy?: number } = {}) {
139 const hiRes = 'a' in s, sw = hiRes ? s.w / 2 : s.w // logical width
140 const sx = o.sx ?? 1, sy = o.sy ?? 1
141 const cos = Math.cos(ang), sin = Math.sin(ang)
142 const r = Math.ceil(Math.hypot(sw * sx, s.h * sy) / 2) + 1
143 for (let y = -r; y <= r; y++) for (let xs = -r * 2; xs <= r * 2; xs++) {
144 const x = (xs + 0.5) / 2, yy = y + 0.5
145 const u = (cos * x + sin * yy) / sx + sw / 2, v = (-sin * x + cos * yy) / sy + s.h / 2
146 if (u < 0 || v < 0 || u >= sw || v >= s.h) continue
147 const iu = hiRes ? Math.floor(u * 2) : Math.floor(u), iv = Math.floor(v)
148 const c = s.px[iv * s.w + iu]!
149 if (c === CLEAR) continue
150 if (hiRes) { const a = (s as HiSprite).a[iv * s.w + iu]! / 255; if (a >= 0.08) this.blendSub(Math.round(cx * 2) + xs, Math.round(cy) + y, c, a >= 0.92 ? 1 : a) }
151 else this.setSub(Math.round(cx * 2) + xs, Math.round(cy) + y, c)
152 }
153 }
154 // A soft round light: brightest at the centre, falling off to nothing at `r`.
155 radial(cx: number, cy: number, r: number, c: number, a = 1) {
156 const r2 = r * r
157 for (let y = Math.floor(cy - r); y <= Math.ceil(cy + r); y++) for (let xs = Math.floor((cx - r / PXW) * 2); xs <= Math.ceil((cx + r / PXW) * 2); xs++) {
158 const d2 = (((xs + 0.5) / 2 - cx) * PXW) ** 2 + (y + 0.5 - cy) ** 2
159 if (d2 < r2) { const f = 1 - Math.sqrt(d2) / r; this.glowSub(xs, y, c, a * f * f) }
160 }
161 }
162 // A filled ring between radii r0 and r1, coloured by `fn(angle)`.
163 ring(cx: number, cy: number, r0: number, r1: number, fn: (ang: number, d: number) => number) {
164 for (let y = Math.floor(cy - r1); y <= Math.ceil(cy + r1); y++) for (let xs = Math.floor((cx - r1 / PXW) * 2); xs <= Math.ceil((cx + r1 / PXW) * 2); xs++) {
165 const dx = ((xs + 0.5) / 2 - cx) * PXW, dy = y + 0.5 - cy, d = Math.hypot(dx, dy)
166 if (d >= r0 && d < r1) { const c = fn(Math.atan2(dy, dx), d); if (c !== CLEAR) this.setSub(xs, y, c) }
167 }
168 }
169 line(x0: number, y0: number, x1: number, y1: number, c: number, a = 1) {
170 const n = Math.max(1, Math.ceil(Math.max(Math.abs(x1 - x0) * 2, Math.abs(y1 - y0))))
171 for (let i = 0; i <= n; i++) {
172 const t = i / n, xs = (x0 + (x1 - x0) * t) * 2, y = y0 + (y1 - y0) * t
173 if (a >= 1) this.setSub(xs, y, c); else this.glowSub(xs, y, c, a)
174 }
175 }
176 // Writes text into cells (row = terminal row), over the art. Whatever it is
177 // given (a command, a prompt, a question), only safe cells come out.
178 text(col: number, row: number, s: string, color: number, bg = -1) {
179 let x = Math.round(col)
180 for (const ch of s) {
181 if (x >= this.w) break
182 if (ZERO_WIDTH.test(ch)) continue
183 const cp = safeCell(ch)
184 if (x >= 0 && row >= 0 && row < this.rows && x * 2 >= this.cl && x * 2 < this.cr) { const i = row * this.w + x; this.glyph[i] = cp; this.glyphFg[i] = color; this.glyphBg[i] = bg }
185 x++
186 }
187 }
188}
189
190// A Raster cell must hold one printable, width-1 BMP character, or the host
191// refuses the whole band. Anything else (emoji, CJK, controls, tabs) is '?'.
192const ZERO_WIDTH = /[\p{M}\u200B-\u200F\u2060-\u206F\uFE00-\uFE0F\uFEFF]/u
193const BAD = /[\p{Cc}\p{Cf}\p{Cs}\p{Co}\p{Cn}\p{Zl}\p{Zp}]/u
194const WIDE: [number, number][] = [
195 [0x1100, 0x115f], [0x231a, 0x231b], [0x2329, 0x232a], [0x23e9, 0x23ec], [0x23f0, 0x23f0], [0x23f3, 0x23f3], [0x25fd, 0x25fe],
196 [0x2614, 0x2615], [0x2648, 0x2653], [0x267f, 0x267f], [0x2693, 0x2693], [0x26a1, 0x26a1], [0x26aa, 0x26ab], [0x26bd, 0x26be],
197 [0x26c4, 0x26c5], [0x26ce, 0x26ce], [0x26d4, 0x26d4], [0x26ea, 0x26ea], [0x26f2, 0x26f3], [0x26f5, 0x26f5], [0x26fa, 0x26fa],
198 [0x26fd, 0x26fd], [0x2705, 0x2705], [0x270a, 0x270b], [0x2728, 0x2728], [0x274c, 0x274c], [0x274e, 0x274e], [0x2753, 0x2755],
199 [0x2757, 0x2757], [0x2795, 0x2797], [0x27b0, 0x27b0], [0x27bf, 0x27bf], [0x2b1b, 0x2b1c], [0x2b50, 0x2b50], [0x2b55, 0x2b55],
200 [0x2e80, 0x303e], [0x3041, 0x33ff], [0x3400, 0x4dbf], [0x4e00, 0x9fff], [0xa000, 0xa4cf], [0xa960, 0xa97f], [0xac00, 0xd7a3],
201 [0xf900, 0xfaff], [0xfe10, 0xfe19], [0xfe30, 0xfe6f], [0xff00, 0xff60], [0xffe0, 0xffe6],
202]
203export function safeCell(ch: string): number {
204 const cp = ch.codePointAt(0) ?? 0x3f
205 if (cp > 0xffff || cp < 0x20 || BAD.test(ch)) return 0x3f
206 for (const [a, b] of WIDE) if (cp >= a && cp <= b) return 0x3f
207 return cp
208}
209
210// ── cells ───────────────────────────────────────────────────────────────────
211// Each cell's 2 × 2 subpixels become the quadrant block (or half block) whose
212// two colours fit them best. Bits: top-left 8, top-right 4, bottom-left 2,
213// bottom-right 1.
214const QUAD = [0x20, 0x2597, 0x2596, 0x2584, 0x259D, 0x2590, 0x259E, 0x259F, 0x2598, 0x259A, 0x258C, 0x2599, 0x2580, 0x259C, 0x259B, 0x2588]
215const DEFAULT_BG = DEFAULT
216const sq = (a: number, b: number) => { const dr = ((a >> 16) & 255) - ((b >> 16) & 255), dg = ((a >> 8) & 255) - ((b >> 8) & 255), db = (a & 255) - (b & 255); return dr * dr + dg * dg + db * db }
217function avg(c: number[], n: number): number {
218 let r = 0, g = 0, b = 0
219 for (let i = 0; i < n; i++) { r += (c[i]! >> 16) & 255; g += (c[i]! >> 8) & 255; b += c[i]! & 255 }
220 return (((r / n) & 0xfc) << 16) | (((g / n) & 0xfc) << 8) | ((b / n) & 0xfc)
221}
222const BITS = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4]
223const quad = [0, 0, 0, 0], fgs = [0, 0, 0, 0], bgs = [0, 0, 0, 0]
224// Fills `out` (columns * rows * 3 u32 words) with the canvas as Raster cells.
225export function encode(cv: Canvas, out: Uint32Array): Uint32Array {
226 const { w, W, px, glyph, glyphFg } = cv, rows = cv.rows
227 for (let row = 0; row < rows; row++) for (let x = 0; x < w; x++) {
228 const ci = row * w + x, o = ci * 3
229 const g = glyph[ci]!
230 const pnl = cv.panel
231 if (g) { const gb = cv.glyphBg[ci]!; out[o] = g; out[o + 1] = glyphFg[ci]! & 0xffffff; out[o + 2] = gb >= 0 ? gb & 0xfcfcfc : pnl === DEFAULT ? DEFAULT : pnl & 0xfcfcfc; continue } // text sits on the card, through the art
232 const y0 = row * 2, y1 = y0 + 1 < cv.h ? y0 + 1 : -1
233 quad[0] = px[y0 * W + x * 2]!; quad[1] = px[y0 * W + x * 2 + 1]!
234 quad[2] = y1 < 0 ? CLEAR : px[y1 * W + x * 2]!; quad[3] = y1 < 0 ? CLEAR : px[y1 * W + x * 2 + 1]!
235 let clear = 0
236 for (let i = 0; i < 4; i++) { if (quad[i] === CLEAR && pnl !== DEFAULT) quad[i] = pnl; else if (quad[i] === HOLE) quad[i] = CLEAR; if (quad[i] === CLEAR) clear |= 8 >> i }
237 if (clear === 15) { out[o] = 0x20; out[o + 1] = DEFAULT_BG; out[o + 2] = DEFAULT_BG; continue }
238 if (!clear && quad[0] === quad[1] && quad[1] === quad[2] && quad[2] === quad[3]) { out[o] = 0x20; out[o + 1] = DEFAULT_BG; out[o + 2] = quad[0]! & 0xfcfcfc; continue } // one colour: a space on it, so it matches the text cells exactly
239 let best = Infinity, bm = 15, bf = 0, bb = DEFAULT_BG
240 for (let m = 1; m < 16; m++) {
241 if (m & clear) continue // the foreground is drawn ink: never a clear subpixel
242 let nf = 0, nb = 0, bgClear = true
243 for (let i = 0; i < 4; i++) {
244 const c = quad[i]!
245 if (m & (8 >> i)) fgs[nf++] = c
246 else { bgs[nb++] = c === CLEAR ? BACKDROP : c; if (c !== CLEAR) bgClear = false }
247 }
248 const f = avg(fgs, nf), b = nb ? (bgClear ? DEFAULT_BG : avg(bgs, nb)) : DEFAULT_BG
249 let err = 0
250 for (let i = 0; i < nf; i++) err += sq(fgs[i]!, f)
251 if (nb && !bgClear) for (let i = 0; i < nb; i++) err += sq(bgs[i]!, b)
252 // ties: the half block ▀, else the fewer-ink glyph (the commoner colour is the background, so runs merge)
253 if (err < best || (err === best && bm !== 12 && (m === 12 || BITS[m]! < BITS[bm]!))) { best = err; bm = m; bf = f; bb = b }
254 }
255 out[o] = QUAD[bm]!; out[o + 1] = bf; out[o + 2] = bm === 15 ? DEFAULT_BG : bb
256 }
257 return out
258}
259
260// Base64 of the cell words, little-endian, as Raster's `cells` wants.
261const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
262export function base64(bytes: Uint8Array): string {
263 const native = (bytes as unknown as { toBase64?: () => string }).toBase64
264 if (native) return native.call(bytes)
265 let s = ''
266 for (let i = 0; i < bytes.length; i += 3) {
267 const a = bytes[i]!, b = bytes[i + 1] ?? 0, c = bytes[i + 2] ?? 0
268 s += B64[a >> 2]! + B64[((a & 3) << 4) | (b >> 4)]! + (i + 1 < bytes.length ? B64[((b & 15) << 2) | (c >> 6)]! : '=') + (i + 2 < bytes.length ? B64[c & 63]! : '=')
269 }
270 return s
271}
272
273// A canvas and its cell buffer for one size, reused frame after frame.
274export class Surface {
275 readonly cv: Canvas
276 readonly words: Uint32Array
277 constructor(readonly columns: number, readonly rowCount: number) {
278 this.cv = new Canvas(columns, rowCount * 2)
279 this.words = new Uint32Array(columns * rowCount * 3)
280 }
281 cells(): string { return base64(new Uint8Array(encode(this.cv, this.words).buffer)) }
282}
283// Keeps one Surface per name, replaced when its size changes.
284const surfaces = new Map<string, Surface>()
285export function surface(name: string, columns: number, rows: number): Surface {
286 const s = surfaces.get(name)
287 if (s && s.columns === columns && s.rowCount === rows) return s
288 const n = new Surface(columns, rows)
289 surfaces.set(name, n)
290 return n
291}
292
293// ── easing ──────────────────────────────────────────────────────────────────
294export const clamp01 = (t: number) => (t < 0 ? 0 : t > 1 ? 1 : t)
295export const easeOut = (t: number) => 1 - (1 - clamp01(t)) ** 3
296export const easeIn = (t: number) => clamp01(t) ** 3
297export const easeInOut = (t: number) => { t = clamp01(t); return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2 }
298export const easeOutBack = (t: number) => { t = clamp01(t); const c = 1.70158; return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2 }
299export const easeOutBounce = (t: number) => {
300 t = clamp01(t); const n = 7.5625, d = 2.75
301 if (t < 1 / d) return n * t * t
302 if (t < 2 / d) return n * (t -= 1.5 / d) * t + 0.75
303 if (t < 2.5 / d) return n * (t -= 2.25 / d) * t + 0.9375
304 return n * (t -= 2.625 / d) * t + 0.984375
305}
306// Deterministic noise: the same frame draws the same every time.
307export const hash = (a: number, b = 0) => { const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453; return x - Math.floor(x) }
308
309// Baked art, decoded once at load: base64 RGBA into colours and coverage.
310const B64I = new Int16Array(128).fill(-1)
311for (let i = 0; i < 64; i++) B64I['ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.charCodeAt(i)] = i
312export function decodeSprite(w: number, h: number, b64: string): HiSprite {
313 const n = w * h, bytes = new Uint8Array(n * 4)
314 let o = 0
315 for (let i = 0; i < b64.length && o < bytes.length; i += 4) {
316 const a = B64I[b64.charCodeAt(i)]!, b = B64I[b64.charCodeAt(i + 1)]!, c = B64I[b64.charCodeAt(i + 2)]!, d = B64I[b64.charCodeAt(i + 3)]!
317 bytes[o++] = (a << 2) | (b >> 4)
318 if (c >= 0 && o < bytes.length) bytes[o++] = ((b & 15) << 4) | (c >> 2)
319 if (d >= 0 && o < bytes.length) bytes[o++] = ((c & 3) << 6) | d
320 }
321 const px = new Int32Array(n), al = new Uint8Array(n)
322 for (let i = 0; i < n; i++) { al[i] = bytes[i * 4 + 3]!; px[i] = al[i]! ? (bytes[i * 4]! << 16) | (bytes[i * 4 + 1]! << 8) | bytes[i * 4 + 2]! : CLEAR }
323 return { w, h, px, a: al }
324}
325hooks/metrics.ts 276 lines1// clarvis metrics: real numbers only, pure functions only.
2//
3// What the engine gives us is folded here into session totals (from each main
4// turn's `usage`), derived figures (cache hit rate, burn rate, output speed),
5// lifetime totals (kept in $.store by register.tsx) and the C.L.A.R.V.I.S.
6// mission report. Nothing here touches the engine: every function takes plain
7// values and returns plain values, and no result is ever NaN, Infinity or
8// negative.
9
10// ── guards and formats ──────────────────────────────────────────────────────
11const CAP = 1e15
12/** A finite, non-negative number or 0: the only way a figure enters this module. */
13export const num = (x: unknown): number => (typeof x === 'number' && Number.isFinite(x) && x > 0 ? Math.min(x, CAP) : 0)
14
15/** 950, 12.4k, 3.1M, 1.2B. */
16export function fmtTokens(n: unknown): string {
17 const v = Math.round(num(n))
18 if (v < 1000) return String(v)
19 if (v < 999_950) return `${(v / 1000).toFixed(1)}k`
20 if (v < 999_950_000) return `${(v / 1e6).toFixed(1)}M`
21 return `${(v / 1e9).toFixed(1)}B`
22}
23/** $0.42, $12.34, $1.2k, $3.4M. */
24export function fmtCost(n: unknown): string {
25 const v = num(n)
26 if (v < 1000) return `$${v.toFixed(2)}`
27 if (v < 999_950) return `$${(v / 1000).toFixed(1)}k`
28 return `$${(v / 1e6).toFixed(1)}M`
29}
30/** An integer percent, clamped to 0..999. */
31export const fmtPct = (n: unknown): string => `${Math.min(999, Math.round(num(n)))}%`
32/** 45s, 12m 04s, 2h 05m. */
33export function fmtSpan(ms: unknown): string {
34 const s = Math.round(num(ms) / 1000)
35 if (s < 60) return `${s}s`
36 if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
37 return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
38}
39/** In how long an ISO timestamp comes: 45m, 2h 14m, 3d 4h; "now" when past; "—" when unreadable. */
40export function resetsIn(iso: unknown, now: number): string {
41 const at = typeof iso === 'string' ? Date.parse(iso) : NaN
42 if (!Number.isFinite(at) || !Number.isFinite(now)) return '—'
43 const m = Math.floor((at - now) / 60_000)
44 if (m <= 0) return 'now'
45 if (m < 60) return `${m}m`
46 if (m < 1440) return `${Math.floor(m / 60)}h ${m % 60}m`
47 return `${Math.floor(m / 1440)}d ${Math.floor((m % 1440) / 60)}h`
48}
49
50// ── session totals ──────────────────────────────────────────────────────────
51export type TurnTokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
52export type LastTurn = { input: number; output: number; ms: number; cost: number }
53export type Stats = {
54 turns: number; agentTurns: number; toolCalls: number; errors: number
55 tools: Map<string, number>
56 longestMs: number
57 input: number; output: number; cacheRead: number; cacheWrite: number // the main thread: input is the uncached part only
58 agentInput: number; agentOutput: number // subagents' turns, counted apart
59 last: LastTurn | null
60 model: string
61}
62export const newStats = (): Stats => ({
63 turns: 0, agentTurns: 0, toolCalls: 0, errors: 0, tools: new Map(), longestMs: 0,
64 input: 0, output: 0, cacheRead: 0, cacheWrite: 0, agentInput: 0, agentOutput: 0, last: null, model: '',
65})
66
67/** A turn's `usage` (TurnUsage, or nothing) as four clean counts. */
68export function tokensOf(u: unknown): TurnTokens {
69 const o = (u && typeof u === 'object' ? u : {}) as Record<string, unknown>
70 return { input: num(o.input_tokens), output: num(o.output_tokens), cacheRead: num(o.cache_read_input_tokens), cacheWrite: num(o.cache_creation_input_tokens) }
71}
72/** Everything the prompt carried: fresh, cache-written and cache-read. */
73export const inputTotal = (t: { input: number; cacheRead: number; cacheWrite: number }) => t.input + t.cacheRead + t.cacheWrite
74
75/** Folds one finished turn in. `agent` turns (a subagent's loop) are kept apart from the main thread. */
76export function addTurn(s: Stats, usage: unknown, o: { ms?: unknown; agent?: boolean; cost?: unknown } = {}): TurnTokens {
77 const t = tokensOf(usage)
78 const model = usage && typeof usage === 'object' ? (usage as { model?: unknown }).model : undefined
79 if (o.agent) { s.agentTurns++; s.agentInput += inputTotal(t); s.agentOutput += t.output; return t }
80 s.turns++
81 s.input += t.input; s.output += t.output; s.cacheRead += t.cacheRead; s.cacheWrite += t.cacheWrite
82 const ms = num(o.ms)
83 s.longestMs = Math.max(s.longestMs, ms)
84 s.last = { input: inputTotal(t), output: t.output, ms, cost: num(o.cost) }
85 if (typeof model === 'string' && model) s.model = model
86 return t
87}
88export function addTool(s: Stats, tool: unknown, isError = false) {
89 s.toolCalls++
90 if (isError) s.errors++
91 const k = typeof tool === 'string' && tool ? tool : 'unknown'
92 s.tools.set(k, (s.tools.get(k) ?? 0) + 1)
93}
94export function topTools(s: Stats, n = 3): [string, number][] {
95 return [...s.tools].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).slice(0, n)
96}
97
98// ── derived ─────────────────────────────────────────────────────────────────
99/** cache_read / (input + cache_read + cache_creation), 0..1; undefined with no input at all. */
100export function cacheHit(t: { input: number; cacheRead: number; cacheWrite: number }): number | undefined {
101 const all = inputTotal(t)
102 return all > 0 ? Math.min(1, num(t.cacheRead) / all) : undefined
103}
104/** Dollars an hour over the session's elapsed time; undefined before two minutes (too noisy) or with no spend. */
105export function burnRate(costUsd: unknown, elapsedMs: unknown): number | undefined {
106 const c = num(costUsd), e = num(elapsedMs)
107 if (e < 120_000 || c <= 0) return undefined
108 return c / (e / 3_600_000)
109}
110/** Output tokens a second over a turn's wall time; undefined for a turn too short or silent to say. */
111export function outputSpeed(outputTokens: unknown, ms: unknown): number | undefined {
112 const o = num(outputTokens), m = num(ms)
113 return m >= 500 && o > 0 ? o / (m / 1000) : undefined
114}
115
116// ── lifetime totals ─────────────────────────────────────────────────────────
117export type Lifetime = { sessions: number; turns: number; toolCalls: number; inputTokens: number; outputTokens: number; cost: number; longestMs: number; bestTurnTools: number }
118export const emptyLifetime = (): Lifetime => ({ sessions: 0, turns: 0, toolCalls: 0, inputTokens: 0, outputTokens: 0, cost: 0, longestMs: 0, bestTurnTools: 0 })
119/** Whatever the store held, made safe: junk fields become 0, counts become whole numbers. */
120export function sanitizeLifetime(raw: unknown): Lifetime {
121 const o = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>
122 const w = (k: string) => Math.floor(num(o[k]))
123 return { sessions: w('sessions'), turns: w('turns'), toolCalls: w('toolCalls'), inputTokens: w('inputTokens'), outputTokens: w('outputTokens'), cost: num(o.cost), longestMs: w('longestMs'), bestTurnTools: w('bestTurnTools') }
124}
125export type LifetimeDelta = Partial<{ sessions: number; turns: number; toolCalls: number; inputTokens: number; outputTokens: number; cost: number; longestMs: number; turnTools: number }>
126export function addLifetime(lt: Lifetime, d: LifetimeDelta): Lifetime {
127 const out = sanitizeLifetime(lt)
128 out.sessions += Math.floor(num(d.sessions)); out.turns += Math.floor(num(d.turns)); out.toolCalls += Math.floor(num(d.toolCalls))
129 out.inputTokens = Math.min(CAP, out.inputTokens + Math.floor(num(d.inputTokens))); out.outputTokens = Math.min(CAP, out.outputTokens + Math.floor(num(d.outputTokens)))
130 out.cost = Math.min(CAP, out.cost + num(d.cost))
131 out.longestMs = Math.max(out.longestMs, Math.floor(num(d.longestMs))); out.bestTurnTools = Math.max(out.bestTurnTools, Math.floor(num(d.turnTools)))
132 return out
133}
134
135// ── what the band shows ─────────────────────────────────────────────────────
136export type Tone = 'steel' | 'arc' | 'text' | 'amber' | 'red'
137export type Piece = { text: string; tone: Tone }
138export type BandNumbers = { inTok?: number; outTok?: number; cachePct?: number; fiveHour?: number }
139export const limitTone = (pct: number): Tone => (pct >= 90 ? 'red' : pct >= 70 ? 'amber' : 'arc')
140/** The extras after the spend, in the order they are dropped from the end: tokens in/out, cache hit, the 5-hour window. */
141export function bandPieces(n: BandNumbers, o: { turnCost?: number } = {}): Piece[] {
142 const out: Piece[] = []
143 if (o.turnCost !== undefined && num(o.turnCost) > 0) out.push({ text: `turn ${fmtCost(o.turnCost)}`, tone: 'text' })
144 if (n.inTok !== undefined || n.outTok !== undefined) {
145 if (num(n.inTok) > 0 || num(n.outTok) > 0) out.push({ text: `↑${fmtTokens(n.inTok)} ↓${fmtTokens(n.outTok)}`, tone: 'steel' })
146 }
147 if (n.cachePct !== undefined && Number.isFinite(n.cachePct)) out.push({ text: `cache ${fmtPct(n.cachePct)}`, tone: 'arc' })
148 if (n.fiveHour !== undefined && Number.isFinite(n.fiveHour)) out.push({ text: `5h ${fmtPct(n.fiveHour)}`, tone: limitTone(num(n.fiveHour)) })
149 return out
150}
151/** The numbers the band keeps in its usage atom, from the totals and the engine's rate limits. */
152export function bandNumbers(s: Stats, limits: unknown): BandNumbers {
153 const hit = cacheHit(s), five = fiveHourOf(limits)
154 const out: BandNumbers = {}
155 if (s.turns > 0) { out.inTok = Math.round(inputTotal(s)); out.outTok = Math.round(s.output) }
156 if (hit !== undefined) out.cachePct = Math.round(hit * 100)
157 if (five !== undefined) out.fiveHour = five
158 return out
159}
160export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
161export function cleanLimits(raw: unknown): Limit[] {
162 if (!Array.isArray(raw)) return []
163 const out: Limit[] = []
164 for (const r of raw) {
165 if (!r || typeof r !== 'object') continue
166 const o = r as Record<string, unknown>
167 if (typeof o.kind !== 'string' || !o.kind) continue
168 out.push({ kind: o.kind, percentUsed: num(o.percentUsed), ...(typeof o.resetsAt === 'string' ? { resetsAt: o.resetsAt } : {}) })
169 }
170 return out
171}
172export function fiveHourOf(limits: unknown): number | undefined {
173 const l = cleanLimits(limits).find(x => x.kind === 'five_hour')
174 return l ? Math.round(l.percentUsed) : undefined
175}
176
177// ── /clarvis stats ─────────────────────────────────────────────────────────────
178export type ReportInput = {
179 stats: Stats
180 now: number
181 model?: string
182 startedAt?: number
183 contextTokens?: number; contextWindow?: number; contextPercent?: number
184 costUsd?: number
185 limits?: unknown
186 lifetime?: unknown
187 showCost?: boolean // false: no money in the report (the showCost option)
188 extras?: { commits: number; green: number; prs: number } // from the ledger: shipped work, all time
189}
190const LIMIT_NAMES: Record<string, string> = { five_hour: '5-hour window', seven_day: '7-day window', spend_limit: 'spend limit' }
191const D = '—'
192export function statsText(i: ReportInput): string {
193 const s = i.stats
194 const rows: string[] = []
195 const W = 18
196 const kv = (k: string, v: string) => rows.push(` ${k.padEnd(W)}${v || D}`)
197 const head = (t: string) => { rows.push(''); rows.push(t) }
198 const started = typeof i.startedAt === 'number' && Number.isFinite(i.startedAt) ? i.startedAt : undefined
199 const elapsed = started !== undefined && Number.isFinite(i.now) && i.now >= started ? i.now - started : undefined
200 const model = (i.model || s.model || '').trim()
201
202 rows.push('C.L.A.R.V.I.S. MISSION REPORT')
203 rows.push('===========================')
204 head('SESSION')
205 kv('elapsed', elapsed !== undefined ? fmtSpan(elapsed) : D)
206 kv('model', model)
207 kv('turns', String(s.turns) + (s.agentTurns ? ` (+${s.agentTurns} agent)` : ''))
208 kv('tool calls', String(s.toolCalls))
209 const tops = topTools(s, 3)
210 kv('top tools', tops.length ? tops.map(([k, n]) => `${k} ×${n}`).join(' · ') : D)
211 kv('errors', String(s.errors))
212 kv('longest turn', s.longestMs > 0 ? fmtSpan(s.longestMs) : D)
213
214 head('TOKENS')
215 const hit = cacheHit(s)
216 const tot = inputTotal(s)
217 kv('input', s.turns > 0 ? `${fmtTokens(tot)} (${fmtTokens(s.input)} fresh)` : D)
218 kv('output', s.turns > 0 ? fmtTokens(s.output) : D)
219 kv('cache read', s.turns > 0 ? fmtTokens(s.cacheRead) : D)
220 kv('cache write', s.turns > 0 ? fmtTokens(s.cacheWrite) : D)
221 kv('cache hit', hit !== undefined ? fmtPct(hit * 100) : D)
222 if (s.agentTurns > 0) kv('agent tokens', `↑${fmtTokens(s.agentInput)} ↓${fmtTokens(s.agentOutput)}`)
223 const ctxT = i.contextTokens, ctxW = i.contextWindow
224 const haveCtx = typeof ctxT === 'number' && Number.isFinite(ctxT) && typeof ctxW === 'number' && Number.isFinite(ctxW) && ctxW > 0
225 const pct = haveCtx ? (ctxT! / ctxW!) * 100 : i.contextPercent
226 kv('context', haveCtx ? `${fmtTokens(ctxT)} / ${fmtTokens(ctxW)} (${fmtPct(pct)})` : typeof pct === 'number' && Number.isFinite(pct) ? fmtPct(pct) : D)
227
228 const money = i.showCost !== false
229 head(money ? 'COST' : 'SPEED')
230 const haveCost = typeof i.costUsd === 'number' && Number.isFinite(i.costUsd)
231 if (money) {
232 kv('session', haveCost ? fmtCost(i.costUsd) : D)
233 kv('last turn', s.last && s.last.cost > 0 ? fmtCost(s.last.cost) : D)
234 const burn = elapsed !== undefined ? burnRate(i.costUsd, elapsed) : undefined
235 kv('burn rate', burn !== undefined ? `${fmtCost(burn)}/h` : D)
236 }
237 const speed = s.last ? outputSpeed(s.last.output, s.last.ms) : undefined
238 kv('output speed', speed !== undefined ? `${speed >= 100 ? Math.round(speed) : speed.toFixed(1)} tok/s (last turn)` : D)
239
240 head('RATE LIMITS')
241 const limits = cleanLimits(i.limits)
242 if (!limits.length) kv('windows', D + ' (none reported)')
243 for (const l of limits) kv(LIMIT_NAMES[l.kind] ?? l.kind, `${fmtPct(l.percentUsed)} used · resets in ${resetsIn(l.resetsAt, i.now)}`)
244
245 head('LIFETIME')
246 const lt = sanitizeLifetime(i.lifetime)
247 kv('sessions', String(lt.sessions))
248 kv('turns', String(lt.turns))
249 kv('tool calls', String(lt.toolCalls))
250 kv('tokens', `↑${fmtTokens(lt.inputTokens)} ↓${fmtTokens(lt.outputTokens)}`)
251 if (money) kv('cost', fmtCost(lt.cost))
252 kv('longest mission', lt.longestMs > 0 ? fmtSpan(lt.longestMs) : D)
253 kv('biggest mission', lt.bestTurnTools > 0 ? `${lt.bestTurnTools} actions` : D)
254 if (i.extras) { kv('commits', String(i.extras.commits)); kv('green test runs', String(i.extras.green)); kv('pull requests', String(i.extras.prs)) }
255 rows.push('')
256 rows.push(' /clarvis stats reset clears the lifetime totals.')
257 return rows.join('\n')
258}
259
260// ── milestones and the context nudge ────────────────────────────────────────
261export const TOKEN_MILESTONES = [1_000_000, 10_000_000, 100_000_000]
262const MILESTONE_NAMES: Record<number, string> = { 1_000_000: '1M', 10_000_000: '10M', 100_000_000: '100M' }
263export const lifetimeTokens = (lt: Lifetime) => lt.inputTokens + lt.outputTokens
264/** The milestones a lifetime total crossed going from `before` to `after`, smallest first. */
265export function crossed(before: number, after: number): number[] {
266 const b = num(before), a = num(after)
267 return TOKEN_MILESTONES.filter(m => b < m && a >= m)
268}
269export const milestoneText = (m: number) => `★ ${MILESTONE_NAMES[m] ?? fmtTokens(m)} tokens, all time. The suit has been busy.`
270export const NUDGE_PERCENT = 85
271/** Ant-Man's sincere nudge, once the context is nearly full; undefined below the line or on a bad reading. */
272export function contextNudge(percent: unknown): string | undefined {
273 const p = num(percent)
274 return p >= NUDGE_PERCENT ? `Ant-Man: Context at ${Math.min(999, Math.round(p))}%: /compact shrinks it.` : undefined
275}
276hooks/lines.ts 108 lines1// clarvis: who says what. Each hero has real lines from the films and
2// quips of their own about the work in hand, in their voice: the band
3// alternates between them, and the hero speaks under their own name.
4import type { Activity } from './scenes'
5import { EXTRA, WOLVERINE } from './quips'
6
7export type Line = { by: string; text: string }
8const q = (s: string) => `"${s}"`
9
10// Captions for the CLAUVENGERS, ASSEMBLE! moment (a turn is done): real film lines and cheers.
11export const DONE: string[] = [
12 "\"Avengers... assemble.\"",
13 "\"Whatever it takes.\"",
14 "\"I can do this all day.\"",
15 "\"Part of the journey is the end.\"",
16 "\"We're in the endgame now.\"",
17 "\"I love you 3000.\"",
18 "\"Wakanda forever!\"",
19 "\"On your left.\"",
20 "\"We have a Hulk.\"",
21 "\"Hulk, smash!\"",
22 "\"Higher, further, faster, baby.\"",
23 "\"There was an idea.\"",
24 "\"Mission complete. Shawarma, anyone?\"",
25 "\"Tests green, work done. Shawarma is on the team.\"",
26 "\"Another one for the win column. Go team.\"",
27 "\"That's the job. Everybody take five.\"",
28 "\"Ship it. Then nap. Then ship it again.\"",
29]
30
31// Real lines from the films.
32export const QUOTES: Partial<Record<Activity, string[]>> = {
33 thinking: ["At your service, sir.", "Working on a secret project, are we, sir?", "Sir, take a deep breath.", "I do anything and everything that Mr. Stark requires."],
34 jarvis: ["Welcome home, sir."],
35 ironman: ["I am Iron Man.", "Sometimes you gotta run before you can walk.", "Genius, billionaire, playboy, philanthropist.", "We have a Hulk.", "I told you, I don't want to join your super-secret boy band.", "Doth mother know you weareth her drapes?", "I love you 3000.", "And I... am... Iron Man."],
36 cap: ["I can do this all day.", "On your left.", "Language!", "I understood that reference.", "I'm with you till the end of the line.", "Compromise where you can. Where you can't, don't.", "Avengers, assemble.", "There's only one God, ma'am, and I'm pretty sure he doesn't dress like that."],
37 thor: ["Bring me Thanos!", "Another!", "I'm still worthy!", "I knew it!", "You people are so petty... and tiny.", "Hulk! Friend from work!", "Asgard is not a place. It's a people."],
38 hulk: ["HULK SMASH!", "That's my secret, Cap. I'm always angry.", "Puny god.", "Hulk, smash!", "We're not a team. We're a time bomb.", "Puny god!"],
39 assemble: ["Avengers... assemble.", "Whatever it takes.", "Part of the journey is the end."],
40 widow: ["I'm always picking up after you boys.", "I've got red in my ledger. I'd like to wipe it out."],
41 hawkeye: ["The city is flying. We're fighting an army of robots. And I have a bow and arrow.", "You and I remember Budapest very differently.", "I see better from a distance."],
42 spidey: ["Hey, everyone.", "Mr. Stark, I don't feel so good.", "Your friendly neighborhood Spider-Man."],
43 fury: ["I'm here to talk to you about the Avengers Initiative.", "Last time I trusted someone, I lost an eye.", "There was an idea, Mr. Stark... to bring together a group of remarkable people."],
44 strange: ["Dormammu, I've come to bargain.", "We're in the endgame now.", "I went forward in time to view alternate futures."],
45 witch: ["You took everything from me.", "I can't control their fear, only my own."],
46 antman: ["Is it too late to change the name?", "I do some dumb things, and the people I love the most pay the price."],
47 deadpool: ["Maximum effort!", "Time to make the chimichangas."],
48 snap: ["I am inevitable.", "Perfectly balanced, as all things should be.", "Dread it. Run from it. Destiny arrives all the same.", "The hardest choices require the strongest wills.", "You should have gone for the head.", "Reality is often disappointing.", "I know what it's like to lose."],
49 panther: ["Wakanda forever!", "In my culture, death is not the end.", "The wise build bridges, while the foolish build barriers."],
50 vision: ["I am... I am.", "A thing isn't beautiful because it lasts.", "What is grief, if not love persevering?", "I am on the side of life."],
51 loki: ["I am burdened with glorious purpose.", "Is not this simpler?", "Kneel before me.", "I am a god, you dull creature.", "I have an army."],
52 ultron: ["There are no strings on me.", "I'm going to show you something beautiful.", "Everyone creaking and screaming, 'Peace in our time.'", "When the dust settles, the only thing living in this world will be metal."],
53 starlord: ["I'm Star-Lord, man. Legendary outlaw?", "We're the Guardians of the Galaxy!", "Dance-off, bro. Me and you."],
54 gamora: ["I'm going to die surrounded by the biggest idiots in the galaxy."],
55 drax: ["Nothing goes over my head. My reflexes are too fast, I would catch it.", "I'm just going to stand here really still.", "Why is Gamora?"],
56 rocket: ["Ain't nothing like me, except me!", "I'm gonna need that guy's leg."],
57 groot: ["I am Groot.", "We are Groot.", "I am Steve Rogers."],
58 mantis: ["I am Mantis.", "Sleep!"],
59 nebula: ["I just wanted a sister."],
60 yondu: ["I'm Mary Poppins, y'all!", "He may have been your father, boy, but he wasn't your daddy."],
61 falcon: ["I do what he does, just slower."], // "On your left." is Cap's line here; Falcon's quips carry it
62 warmachine: ["War Machine, rollin' out.", "Next time, baby."],
63 cmarvel: ["Higher, further, faster, baby.", "I have nothing to prove to you."],
64 bucky: ["I'm with you till the end of the line, pal.", "Who the hell is Bucky?", "Don't do anything stupid until I get back.", "Your mom's name was Sarah. You used to wear newspapers in your shoes."],
65 responding: ["Shall I render using proposed specifications?", "Commencing automated assembly.", "As always, sir, a great pleasure watching you work."],
66}
67
68type Quip = (d: string) => Line | string
69// (hand-written quips now live in quips.ts, with the rest of each hero's pool)
70export const QUIPS: Partial<Record<Activity, Quip[]>> = {}
71
72export const SPEAKER: Partial<Record<Activity, string>> = {
73 thinking: 'JARVIS', responding: 'JARVIS', jarvis: 'JARVIS', ironman: 'IRON MAN', hulk: 'HULK', thor: 'THOR', cap: 'CAP', hawkeye: 'HAWKEYE',
74 spidey: 'SPIDER-MAN', strange: 'DR STRANGE', witch: 'WANDA', widow: 'NATASHA', ultron: 'ULTRON', panther: "T'CHALLA", vision: 'VISION', loki: 'LOKI', antman: 'ANT-MAN', deadpool: 'DEADPOOL', starlord: 'STAR-LORD', gamora: 'GAMORA', drax: 'DRAX', rocket: 'ROCKET', groot: 'GROOT', mantis: 'MANTIS', nebula: 'NEBULA', yondu: 'YONDU', falcon: 'FALCON', warmachine: 'WAR MACHINE', cmarvel: 'CAPTAIN MARVEL', bucky: 'BUCKY', fury: 'NICK FURY', snap: 'THANOS', assemble: 'CAP',
75}
76
77// The n-th line for a hero: a real quote and a quip of their own, in turn.
78export function lineFor(a: Activity, n: number, d = 'it'): Line {
79 n = Number.isFinite(n) ? Math.abs(Math.floor(n)) : 0 // a clock that steps back never makes a negative index
80 const by = SPEAKER[a] ?? 'JARVIS'
81 const quotes = QUOTES[a] ?? QUOTES.jarvis!, quips = quipPool(a)
82 if (a === 'deadpool' || n % 2 === 1 || !quotes.length) {
83 const r = quips[(a === 'deadpool' ? n : Math.floor(n / 2)) % quips.length]!(clipD(d))
84 if (typeof r !== 'string') return { by: r.by, text: q(r.text) }
85 return { by: a === 'deadpool' && WOLVERINE.has(r) ? 'WOLVERINE' : by, text: q(r) }
86 }
87 return { by, text: q(quotes[(Math.floor(n / 2) + quoteStart(a)) % quotes.length]!) }
88}
89// Each session shuffles every hero's lines its own way: a new session never opens on the same lines.
90let seed = (Math.random() * 0x100000000) >>> 0
91// (tests pin the order)
92export function setLineSeed(s: number) { seed = s >>> 0; pools.clear() }
93function quoteStart(a: Activity) { let h = seed; for (const c of a) h = Math.imul(h ^ c.charCodeAt(0), 16777619); return h >>> 0 }
94// Hand-written quips plus the big extra pool, shuffled once per hero (seeded by name) so none repeats until all are used.
95const pools = new Map<string, Quip[]>()
96export function quipPool(a: Activity): Quip[] {
97 let p = pools.get(a)
98 if (p) return p
99 const all: Quip[] = [...(QUIPS[a] ?? []), ...(EXTRA[a] ?? (a === 'responding' ? EXTRA.thinking! : [])).map((s): Quip => () => s)]
100 let h = 2166136261 ^ seed // a fresh order every session, the same all through one
101 for (const c of a) h = Math.imul(h ^ c.charCodeAt(0), 16777619)
102 const rnd = () => { h = (h + 0x6d2b79f5) | 0; let t = Math.imul(h ^ (h >>> 15), 1 | h); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296 }
103 for (let i = all.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [all[i], all[j]] = [all[j]!, all[i]!] }
104 pools.set(a, p = all)
105 return p
106}
107const clipD = (d: string) => { const a = [...(d || 'it')]; return a.length > 40 ? a.slice(0, 39).join('') + '…' : a.join('') }
108hooks/hud.ts 183 lines1// clarvis: the pure parts of the HUD's own words and plans. No engine access
2// here: register.tsx feeds these plain values and draws what comes back.
3// · the terminal check that picks lite mode for a 256-colour terminal
4// · the /clarvis briefing card (built from the command table, so a new command
5// shows up in it by being added to that table)
6// · Fury's captions and the one place the end-of-turn caption is chosen
7// · the lite band's text rows
8// · the /clarvis demo showreel's script
9import type { Piece, Tone } from './metrics'
10import { fmtCost, fmtTokens } from './metrics'
11import { fmtDur, type Row, type Seg } from './art'
12import type { Ship } from './ship'
13
14// ── terminal colour check ───────────────────────────────────────────────────
15export type TermEnv = { colorterm?: unknown; termProgram?: unknown; tmux?: unknown; tmuxTruecolor?: unknown }
16const s = (v: unknown) => (typeof v === 'string' ? v.trim() : '')
17/** True when the terminal is not known to show 24-bit colour: the full art would band into 256 colours. */
18export function needsLite(e: TermEnv): boolean {
19 if (s(e.termProgram) === 'Apple_Terminal') return true
20 const ct = s(e.colorterm).toLowerCase()
21 if (ct !== 'truecolor' && ct !== '24bit') return true
22 if (s(e.tmux) && s(e.tmuxTruecolor) !== '1') return true
23 return false
24}
25export const LITE_HINT = 'Your terminal shows 256 colours, so Clarvis is in lite mode. Ghostty, iTerm2, WezTerm or kitty get the full art · /clarvis full to force it.'
26export const WELCOME_TOAST = 'Clarvis is online. Type /clarvis for the briefing · /clarvis calm for no motion · /clarvis off for stock · /clarvis sound off to mute'
27export const WELCOME_CAPTION = 'Welcome home, sir. Type /clarvis.'
28
29// ── Nick Fury's captions (original lines, short) ────────────────────────────
30export const FURY_LINES: string[] = [
31 'Eyes up. I need a call from you.',
32 'The team is holding. Your move.',
33 'Decision needed. Make it quick.',
34 'Not my call this time. Yours.',
35 'Standing by for your word.',
36 'One question stands between us and done.',
37 'Brief me. Then we move.',
38 'Orders, please. The clock is running.',
39]
40export const furyLine = (n: number): string => `"${FURY_LINES[Math.abs(Math.floor(Number.isFinite(n) ? n : 0)) % FURY_LINES.length]!}"`
41
42/** The end-of-turn caption: the one place it is chosen. `done` is the ASSEMBLE caption, `fury` the question-for-you caption. */
43export type CaptionKind = 'done' | 'fury'
44export function endCaption(kind: CaptionKind, o: { n: number; big?: boolean; pool: readonly string[]; ship?: string; egg?: string; season?: string }): string {
45 if (kind === 'fury') return furyLine(o.n)
46 // what was shipped outranks a rare egg, which outranks the season's line, which outranks the big-mission line
47 if (o.ship) return o.ship
48 if (o.egg) return o.egg
49 if (o.season) return o.season
50 return o.big ? '"I am Iron Man."' : o.pool[Math.abs(o.n) % o.pool.length]!
51}
52
53// ── the /clarvis command table's shape, and the briefing card ──────────────────
54export type CmdGroup = 'Look' | 'Play' | 'Stats' | 'Hero'
55export const CMD_GROUPS: CmdGroup[] = ['Look', 'Play', 'Stats', 'Hero']
56export type CmdInfo = { group: CmdGroup; usage: string; summary: string }
57export function briefing(o: { mode: string; cmds: readonly CmdInfo[]; fullscreen?: boolean }): string {
58 const rows: string[] = [`CLARVIS · ${o.mode} · unofficial fan mod`, '']
59 for (const g of CMD_GROUPS) {
60 const mine = o.cmds.filter(c => c.group === g)
61 if (!mine.length) continue
62 rows.push(g.toUpperCase())
63 for (const c of mine) rows.push(` /clarvis ${c.usage.padEnd(24)}${c.summary}`)
64 rows.push('')
65 }
66 if (o.fullscreen === false) rows.push('Play and movies need fullscreen: /tui fullscreen')
67 else rows.pop()
68 return rows.join('\n')
69}
70
71// ── /clarvis notify: the ping for a long mission ────────────────────────────
72export const NOTIFY_MAX_MIN = 24 * 60
73/** `/clarvis notify <minutes|off>`: minutes (0 = off) or undefined for anything else. Decimals are fine (0.5); capped at a day. */
74export function parseNotify(s: string): number | undefined {
75 const t = s.trim().toLowerCase()
76 if (t === 'off' || t === 'no' || t === 'none' || t === '0') return 0
77 const m = /^(\d+(?:\.\d+)?)\s*(?:m|min|mins|minutes?)?$/.exec(t)
78 if (!m) return undefined
79 const n = Number(m[1])
80 return Number.isFinite(n) && n > 0 ? Math.min(NOTIFY_MAX_MIN, n) : n === 0 ? 0 : undefined
81}
82/** The stored choice (a number of minutes, 0 = off) beats the `notifyAfterMinutes` option; junk in the store is ignored. */
83export function notifyChoice(stored: unknown, option: number): number {
84 return typeof stored === 'number' && Number.isFinite(stored) && stored >= 0 && stored <= NOTIFY_MAX_MIN ? stored : option
85}
86/** 5 → "5", 0.5 → "0.5": minutes as the person typed them. */
87export const fmtMin = (n: number): string => String(Math.round(n * 100) / 100)
88/** The notification's body, in Clarvis's voice, with the turn's ship-it facts when it had any. */
89export function notifyBody(durationMs: number, actions: string, ship?: Ship): string {
90 const extra = [ship?.tests ? 'tests green' : '', ship?.commit ? `commit ${ship.commit}` : '', ship?.pr ? `PR #${ship.pr}` : ''].filter(Boolean)
91 return ['Mission complete, sir. ' + fmtDur(durationMs), actions, ...extra].join(' · ')
92}
93
94// ── lite mode: the band as text ─────────────────────────────────────────────
95const LC = { text: '#E6E6F5', muted: '#8A8FB0', arc: '#5FE1FF', gold: '#F5C542', green: '#5FD38D', amber: '#FFB020', red: '#FF5C5C', steel: '#9AA7C7' }
96const TONES: Record<Tone, string> = { steel: LC.steel, arc: LC.arc, text: LC.text, amber: LC.amber, red: LC.red }
97export type LiteIn = {
98 name: string; model: string; percent: number; tokens: number; cost: number; showCost: boolean
99 pieces: readonly Piece[]
100 work?: { hero: string; tool: string; detail: string } | null
101 agents?: number
102 cols: number
103}
104const len = (x: string) => [...x].length
105const cut = (x: string, n: number) => { const a = [...x]; return a.length <= n ? x : n <= 1 ? '…' : a.slice(0, n - 1).join('') + '…' }
106function clipRow(row: Row, w: number): Row {
107 const out: Row = []
108 let left = w
109 for (const sg of row) { if (left <= 0) break; out.push({ ...sg, text: cut(sg.text, left) }); left -= len(sg.text) }
110 return out
111}
112/** One row when idle, two while working. Plain text runs: nothing here moves, and a screen reader gets words. */
113export function liteRows(i: LiteIn): Row[] {
114 const w = Math.max(10, i.cols - 1)
115 const num0 = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0)
116 const state = i.work ? '◓ at work' : i.agents ? `◓ ${i.agents} agent${i.agents > 1 ? 's' : ''} at work` : '✓ standing by'
117 const parts: Seg[] = [
118 { text: '◢ ', color: LC.arc, bold: true }, { text: cut(i.name, Math.max(6, Math.floor(w * 0.3))), color: LC.text }, { text: ` · ${state}`, color: i.work ? LC.gold : LC.green },
119 ]
120 const tail: Seg[] = []
121 const add = (t: string, color: string) => tail.push({ text: ` · ${t}`, color })
122 add(`ctx ${Math.round(Math.min(999, num0(i.percent)))}%`, num0(i.percent) > 85 ? LC.red : LC.text)
123 if (i.showCost) add(`${fmtCost(i.cost)} spent`, LC.text)
124 if (num0(i.tokens) > 0) add(`${fmtTokens(i.tokens)} tok`, LC.text)
125 for (const p of i.pieces) add(p.text, TONES[p.tone] ?? LC.text)
126 add(i.model.toUpperCase(), LC.muted)
127 let used = parts.reduce((n, p) => n + len(p.text), 0)
128 for (const t of tail) { const l = len(t.text); if (used + l > w) break; parts.push(t); used += l }
129 const out: Row[] = [clipRow(parts, w)]
130 if (i.work) out.push([
131 { text: '▸ ', color: LC.gold, bold: true },
132 { text: cut(`${i.work.hero}${i.work.tool ? ` · ${i.work.tool}` : ''}${i.work.detail ? ` ${i.work.detail}` : ''}`, w - 2), color: LC.text },
133 ])
134 return out
135}
136
137// ── /clarvis demo: a 45 s showreel, scripted ───────────────────────────────────
138// boot → a fake working band (5 heroes, 6 s each so every animation plays whole, a ticking log) → ASSEMBLE with sample
139// numbers → snap → Fury, then idle. The phases' lengths follow the moments' own.
140export type DemoPhase = 'boot' | 'work' | 'assemble' | 'snap' | 'fury' | 'done'
141export const DEMO_WORK_S = 30
142export const DEMO_FURY_S = 3
143export function demoPhaseAt(sec: number, secs: { boot: number; assemble: number; snap: number }): DemoPhase {
144 const t = Number.isFinite(sec) ? sec : 0
145 const b = secs.boot, w = b + DEMO_WORK_S, a = w + secs.assemble, sn = a + secs.snap
146 return t < b ? 'boot' : t < w ? 'work' : t < a ? 'assemble' : t < sn ? 'snap' : t < sn + DEMO_FURY_S ? 'fury' : 'done'
147}
148// Iron Man suiting up (the nanotech suit-up animation), Thor, Loki, Spider-Man, Star-Lord
149export const DEMO_CAST = ['thinking', 'thor', 'loki', 'spidey', 'starlord'] as const
150const DEMO_STEPS: { tool: string; detail: string; ok: boolean }[] = [
151 { tool: 'Read', detail: 'src/auth/session.ts', ok: true },
152 { tool: 'Grep', detail: 'verifyToken', ok: true },
153 { tool: 'Edit', detail: 'src/auth/session.ts', ok: true },
154 { tool: 'Bash', detail: 'bun test', ok: false },
155 { tool: 'Edit', detail: 'src/auth/token.ts', ok: true },
156 { tool: 'Bash', detail: 'bun test', ok: true },
157 { tool: 'Bash', detail: 'git commit -m "fix auth"', ok: true },
158]
159/** What the fake working band shows `w` seconds into the work phase. */
160export function demoWork(w: number) {
161 const t = Math.max(0, Math.min(DEMO_WORK_S, Number.isFinite(w) ? w : 0))
162 const idx = Math.min(DEMO_CAST.length - 1, Math.floor(t / (DEMO_WORK_S / DEMO_CAST.length)))
163 const n = Math.min(DEMO_STEPS.length, 1 + Math.floor(t * DEMO_STEPS.length / DEMO_WORK_S))
164 const shown = DEMO_STEPS.slice(0, n)
165 const cur = shown[n - 1]!
166 const log = shown.slice(-3).reverse().map((x, k) => ({ ok: x.ok, tool: x.tool, detail: x.detail, ms: 400 + k * 730 }))
167 return {
168 cast: DEMO_CAST[idx]!, idx, tool: cur.tool, detail: cur.detail, log,
169 elapsed: fmtDur((125 + t * 20) * 1000), tools: 12 + Math.floor(t * 4),
170 }
171}
172/** The ASSEMBLE line of the demo, sample numbers only (never the session's). */
173export const demoDone = (showCost: boolean) => `✓ mission complete · ${fmtDur(432_000)} · 41 actions${showCost ? ' · $1.84' : ''} · ↑312k ↓9.1k`
174export const DEMO_NOTE = 'Clarvis demo: sample numbers, nothing is counted. Your next prompt ends it.'
175
176// The name the band shows: the session's folder. Started from the home folder, or a drive or
177// filesystem root, it says `~` or `/`, so a screenshot never shows the person's account name.
178export function folderName(cwd: string, home?: string): string {
179 const trim = (p: string) => p.replace(/[\\/]+$/, '').toLowerCase()
180 if (home && trim(home) && trim(cwd) === trim(home)) return '~'
181 return cwd.split(/[\\/]/).filter(Boolean).pop()?.replace(/^[A-Za-z]:$/, '/') || '/'
182}
183hooks/movies.ts 25 lines1// clarvis AVENGERS THEATER: the library, in issue order.
2import type { Movie } from './saga-types'
3import { MOVIE_01 } from './movie-01-assemble'
4import { MOVIE_02 } from './movie-02-ultron'
5import { MOVIE_03 } from './movie-03-civilwar'
6import { MOVIE_04 } from './movie-04-ragnarok'
7import { MOVIE_05 } from './movie-05-awesomemix'
8import { MOVIE_06 } from './movie-06-wakanda'
9import { MOVIE_07 } from './movie-07-infinitywar'
10import { MOVIE_08 } from './movie-08-endgame'
11import { MOVIE_09 } from './movie-09-multiverse'
12import { MOVIE_10 } from './movie-10-whatif'
13
14export const MOVIES: readonly Movie[] = [MOVIE_01, MOVIE_02, MOVIE_03, MOVIE_04, MOVIE_05, MOVIE_06, MOVIE_07, MOVIE_08, MOVIE_09, MOVIE_10]
15// A movie by number (1–10), id or title words; undefined when nothing matches.
16export function findMovie(q: string): Movie | undefined {
17 const s = q.trim().toLowerCase()
18 if (!s) return undefined
19 const n = Number(s)
20 if (Number.isInteger(n)) return MOVIES.find(m => m.n === (n === 0 ? 10 : n)) // key 0 is #10, as in the theater
21 const flat = (x: string) => x.toLowerCase().replace(/[^a-z0-9]/g, '')
22 if (!flat(s)) return undefined // punctuation alone matches every title
23 return MOVIES.find(m => m.id === s || flat(m.title) === flat(s)) ?? MOVIES.find(m => flat(m.title).includes(flat(s)) || flat(m.id).includes(flat(s)))
24}
25hooks/ship.ts 108 lines1// clarvis ship-it celebrations: read a finished Bash call and say whether it
2// shipped something (a commit, a green test run, a pull request).
3//
4// Pure and conservative. Only small facts leave this file (a 7-character sha,
5// a pass count, a PR number): the output text is read here and never kept.
6// A false negative costs nothing; a false positive would cheer for a failure,
7// so every rule asks for the command AND an unmistakable line in its output.
8
9export type Ship = { commit?: string; tests?: { pass: number; unit: 'tests' | 'packages' }; pr?: number }
10
11// ── the command side ────────────────────────────────────────────────────────
12/** The command's simple commands: split on newlines, ;, &&, || and pipes. Quoted text is not parsed: a segment must START like the command. */
13function segments(cmd: string): string[] {
14 return cmd.split(/\n|;|&&|\|\||\|/).map(x => x.trim()).filter(Boolean)
15}
16const ENV = /^(?:[A-Za-z_][A-Za-z0-9_]*=\S*\s+)*/
17const head = (seg: string) => seg.replace(ENV, '')
18
19const isCommit = (seg: string) => /^git(?:\s+(?:-C\s+\S+|-c\s+\S+|--no-pager))*\s+commit(?:\s|$)/.test(head(seg)) && !/\s--dry-run\b/.test(seg) && !/\s--help\b/.test(seg)
20const isPrCreate = (seg: string) => /^gh\s+pr\s+create(?:\s|$)/.test(head(seg)) && !/\s--help\b/.test(seg)
21const RUNNERS = /^(?:npm|npx|pnpm|yarn|bun|bunx|node|deno|python3?|py\.test|pytest|go|cargo|jest|vitest|mocha|rspec|uv|uvx|poetry|tox|nox|make|mvn|gradle|\.\/gradlew)(?:\s|$)/
22const TESTISH = /(?:^|[\s:/._-])(?:test|tests|jest|vitest|pytest|mocha|rspec|check|verify)(?:[\s:._-]|$)/
23const isTestRun = (seg: string) => { const h = head(seg); return RUNNERS.test(h) && TESTISH.test(h) && !/\s--(?:help|version|list|collect-only|co)\b/.test(h) }
24
25// ── the output side ─────────────────────────────────────────────────────────
26const int = (s: string | undefined) => (s === undefined ? NaN : parseInt(s, 10))
27
28/** `[main abc1234] message` / `[main (root-commit) abc1234] message`: the sha, shortened to 7. */
29export function commitSha(out: string): string | undefined {
30 const m = /^\[[^\]\s]+(?: \([^)]*\))? ([0-9a-f]{7,40})\] \S/m.exec(out)
31 return m ? m[1]!.slice(0, 7) : undefined
32}
33
34/** The pull request number from the URL `gh pr create` prints. */
35export function prNumber(out: string): number | undefined {
36 const m = /https?:\/\/[^\s/]+\/[^\s]+\/pull\/(\d{1,7})(?![\d])/.exec(out)
37 const n = m ? int(m[1]) : NaN
38 return Number.isFinite(n) && n > 0 ? n : undefined
39}
40
41/**
42 * A green run with an explicit count: jest/vitest ("Tests: 5 passed", "Tests 5 passed (5)"), bun ("5 pass" with "0 fail"),
43 * pytest ("5 passed in 0.2s"), go ("ok pkg" lines, `--- PASS` counts with -v), cargo ("test result: ok. 5 passed; 0 failed").
44 * Any failure line anywhere, or a count of zero, is not green.
45 */
46export function greenRun(out: string): { pass: number; unit: 'tests' | 'packages' } | undefined {
47 const text = out.length > 60_000 ? out.slice(-60_000) : out
48 // a failure marker anywhere vetoes the whole run
49 if (/^\s*(?:Tests?:?\s+.*\b[1-9]\d*\s+failed|Test Suites:.*\b[1-9]\d*\s+failed|FAIL\b|--- FAIL|panic:|error(?:\[E\d+\])?:\s+(?:could not compile|test failed))/m.test(text)) return undefined
50 if (/\b[1-9]\d*\s+(?:failed|failures?|errors?)\b/i.test(text)) return undefined
51 if (/^\s*[1-9]\d*\s+fail\b/m.test(text)) return undefined
52 if (/test result: FAILED/.test(text)) return undefined
53 const ok = (n: number) => (Number.isFinite(n) && n > 0 ? { pass: n, unit: 'tests' as const } : undefined)
54
55 // cargo: every "test result" line must be ok; sum the counts
56 const cargo = [...text.matchAll(/^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed;/gm)]
57 if (cargo.length) {
58 if (cargo.some(m => m[1] !== 'ok' || int(m[3]) !== 0)) return undefined
59 return ok(cargo.reduce((n, m) => n + int(m[2]), 0))
60 }
61 // jest: "Tests: 5 passed, 5 total" (skipped/todo may sit between)
62 const jest = /^\s*Tests:\s+(?:[^\n]*?,\s*)?(\d+) passed, \d+ total/m.exec(text)
63 if (jest) return ok(int(jest[1]))
64 // vitest: " Tests 5 passed (5)" (also "5 passed | 1 skipped (6)")
65 const vi = /^\s*Tests\s+(?:[^\n|]*\|\s*)*(\d+) passed(?:\s*\|[^\n(]*)?\s*\(\d+\)/m.exec(text)
66 if (vi) return ok(int(vi[1]))
67 // bun: " 404 pass" and " 0 fail" on their own lines
68 const pass = /^\s*(\d+) pass\s*$/m.exec(text), fail = /^\s*0 fail\s*$/m.exec(text)
69 if (pass && fail) return ok(int(pass[1]))
70 // pytest: "== 12 passed, 1 skipped in 0.5s ==" or "12 passed in 0.5s"
71 const py = /^(?:=+\s*)?(\d+) passed(?:,[^\n]*?)? in [\d.]+s?\b/m.exec(text)
72 if (py) return ok(int(py[1]))
73 // go: -v prints "--- PASS"; otherwise the "ok" package lines count packages
74 const goPass = [...text.matchAll(/^\s*--- PASS: /gm)].length
75 if (goPass > 0) return ok(goPass)
76 const goOk = [...text.matchAll(/^ok\s+\S+\s+(?:[\d.]+s?\b|\(cached\))/gm)].length
77 if (goOk > 0) return { pass: goOk, unit: 'packages' }
78 return undefined
79}
80
81/** What a finished Bash call shipped, if anything. Nothing for an errored call. */
82export function shipOf(command: unknown, output: unknown, isError = false): Ship | undefined {
83 if (isError || typeof command !== 'string' || typeof output !== 'string' || !command || !output) return undefined
84 const segs = segments(command.length > 4000 ? command.slice(0, 4000) : command)
85 const out: Ship = {}
86 if (segs.some(isCommit)) { const sha = commitSha(output); if (sha) out.commit = sha }
87 if (segs.some(isPrCreate)) { const n = prNumber(output); if (n) out.pr = n }
88 if (segs.some(isTestRun)) { const g = greenRun(output); if (g) out.tests = g }
89 return out.commit || out.pr || out.tests ? out : undefined
90}
91
92/** Joins what several calls shipped in one turn. */
93export function mergeShip(a: Ship | undefined, b: Ship | undefined): Ship | undefined {
94 if (!a) return b
95 if (!b) return a
96 const t = a.tests && b.tests ? (b.tests.pass >= a.tests.pass ? b.tests : a.tests) : (a.tests ?? b.tests)
97 return { ...(b.commit ?? a.commit ? { commit: b.commit ?? a.commit } : {}), ...(b.pr ?? a.pr ? { pr: b.pr ?? a.pr } : {}), ...(t ? { tests: t } : {}) }
98}
99
100/** The end-of-turn caption for what shipped: a pull request outranks a commit, a commit a green run. */
101export function shipCaption(s: Ship | undefined): string | undefined {
102 if (!s) return undefined
103 if (s.pr) return `PR #${s.pr} is up. Clauvengers, assemble.`
104 if (s.commit) return `Commit ${s.commit} is in the books.`
105 if (s.tests) return s.tests.unit === 'packages' ? `Tests: ${s.tests.pass} package${s.tests.pass === 1 ? '' : 's'} ok. Bugs, dusted.` : `Tests: ${s.tests.pass} passed. Bugs, dusted.`
106 return undefined
107}
108hooks/daily.ts 119 lines1// clarvis ledger: the daily buckets behind /clarvis card, the lifetime extras
2// (commits, green runs, pull requests, cache tokens, who did the work) and the
3// day streak. Pure: dates are local `YYYY-MM-DD` strings made from a Date the
4// caller hands in, so a test fixes the day. Whatever the store held is made
5// safe before use.
6import { num } from './metrics'
7
8export const KEEP_DAYS = 35
9export type Bucket = { turns: number; actions: number; commits: number; green: number; prs: number; inTok: number; outTok: number; cacheTok: number; cost: number; heroes: Record<string, number> }
10export type Extras = { commits: number; green: number; prs: number; cacheTok: number; heroes: Record<string, number> }
11export type Ledger = { life: Extras; days: Record<string, Bucket> }
12export type Streak = { last: string; n: number; best: number }
13
14/** Local calendar date, `2026-10-31`. */
15export function dayKey(d: Date): string {
16 const t = d.getTime()
17 if (!Number.isFinite(t)) return '1970-01-01'
18 const p = (n: number) => String(n).padStart(2, '0')
19 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
20}
21const DAY_RE = /^\d{4}-\d{2}-\d{2}$/
22/** The day `n` days before (positive n) a date, local. */
23export function daysAgo(d: Date, n: number): Date { return new Date(d.getFullYear(), d.getMonth(), d.getDate() - n, 12) }
24/** Whole days between two day keys (b - a), by calendar. */
25export function dayDiff(a: string, b: string): number {
26 const f = (k: string) => { const [y, m, d] = k.split('-').map(Number); return Date.UTC(y!, m! - 1, d!) }
27 return Math.round((f(b) - f(a)) / 86_400_000)
28}
29
30const whole = (x: unknown) => Math.floor(num(x))
31const tally = (raw: unknown): Record<string, number> => {
32 const out: Record<string, number> = {}
33 if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
34 let k = 0
35 for (const [id, n] of Object.entries(raw as Record<string, unknown>)) { if (k++ >= 60) break; if (/^[a-z0-9_-]{1,24}$/.test(id) && whole(n) > 0) out[id] = whole(n) }
36 }
37 return out
38}
39export const emptyBucket = (): Bucket => ({ turns: 0, actions: 0, commits: 0, green: 0, prs: 0, inTok: 0, outTok: 0, cacheTok: 0, cost: 0, heroes: {} })
40export const emptyExtras = (): Extras => ({ commits: 0, green: 0, prs: 0, cacheTok: 0, heroes: {} })
41export const emptyLedger = (): Ledger => ({ life: emptyExtras(), days: {} })
42function cleanBucket(raw: unknown): Bucket {
43 const o = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>
44 return { turns: whole(o.turns), actions: whole(o.actions), commits: whole(o.commits), green: whole(o.green), prs: whole(o.prs), inTok: whole(o.inTok), outTok: whole(o.outTok), cacheTok: whole(o.cacheTok), cost: num(o.cost), heroes: tally(o.heroes) }
45}
46export function sanitizeLedger(raw: unknown): Ledger {
47 const o = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>
48 const l = (o.life && typeof o.life === 'object' && !Array.isArray(o.life) ? o.life : {}) as Record<string, unknown>
49 const days: Record<string, Bucket> = {}
50 if (o.days && typeof o.days === 'object' && !Array.isArray(o.days)) {
51 for (const [k, v] of Object.entries(o.days as Record<string, unknown>)) if (DAY_RE.test(k)) days[k] = cleanBucket(v)
52 }
53 return { life: { commits: whole(l.commits), green: whole(l.green), prs: whole(l.prs), cacheTok: whole(l.cacheTok), heroes: tally(l.heroes) }, days }
54}
55
56export type Delta = Partial<{ turns: number; actions: number; commits: number; green: number; prs: number; inTok: number; outTok: number; cacheTok: number; cost: number; heroes: Record<string, number> }>
57const mergeHeroes = (a: Record<string, number>, b: Record<string, number> | undefined) => {
58 const out = { ...a }
59 for (const [k, n] of Object.entries(tally(b))) out[k] = (out[k] ?? 0) + n
60 return out
61}
62/** Adds a delta to today's bucket and to the lifetime extras; drops buckets older than KEEP_DAYS. */
63export function addToLedger(raw: unknown, today: Date, d: Delta): Ledger {
64 const lg = sanitizeLedger(raw)
65 const key = dayKey(today)
66 const b = lg.days[key] ?? emptyBucket()
67 b.turns += whole(d.turns); b.actions += whole(d.actions); b.commits += whole(d.commits); b.green += whole(d.green); b.prs += whole(d.prs)
68 b.inTok += whole(d.inTok); b.outTok += whole(d.outTok); b.cacheTok += whole(d.cacheTok); b.cost += num(d.cost); b.heroes = mergeHeroes(b.heroes, d.heroes)
69 lg.days[key] = b
70 lg.life = { commits: lg.life.commits + whole(d.commits), green: lg.life.green + whole(d.green), prs: lg.life.prs + whole(d.prs), cacheTok: lg.life.cacheTok + whole(d.cacheTok), heroes: mergeHeroes(lg.life.heroes, d.heroes) }
71 const oldest = dayKey(daysAgo(today, KEEP_DAYS - 1))
72 for (const k of Object.keys(lg.days)) if (k < oldest) delete lg.days[k]
73 return lg
74}
75
76// ── the streak ──────────────────────────────────────────────────────────────
77export function sanitizeStreak(raw: unknown): Streak {
78 const o = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>
79 return { last: typeof o.last === 'string' && DAY_RE.test(o.last) ? o.last : '', n: whole(o.n), best: whole(o.best) }
80}
81/** One more day of work: the same day changes nothing, the next day extends the run, a gap starts it again. */
82export function bumpStreak(raw: unknown, today: Date): Streak {
83 const s = sanitizeStreak(raw), key = dayKey(today)
84 if (s.last === key) return s
85 const gap = s.last ? dayDiff(s.last, key) : NaN
86 const n = gap === 1 ? s.n + 1 : 1
87 return { last: key, n, best: Math.max(s.best, n) }
88}
89/** The run as the band shows it today: a run that ended before yesterday is over. */
90export function streakNow(raw: unknown, today: Date): number {
91 const s = sanitizeStreak(raw)
92 if (!s.last) return 0
93 const gap = dayDiff(s.last, dayKey(today))
94 return gap === 0 || gap === 1 ? s.n : 0
95}
96export const STREAK_TOASTS = [3, 7, 30]
97
98// ── ranges for the card ─────────────────────────────────────────────────────
99export type Range = 'today' | 'week' | 'all'
100export type Sum = Bucket & { days: number }
101/** Today's bucket, the last 7 days', or (all) the whole ledger: totals only. */
102export function sumDays(lg: Ledger, today: Date, range: Range): Sum {
103 const out: Sum = { ...emptyBucket(), days: 0 }
104 const span = range === 'today' ? 1 : range === 'week' ? 7 : KEEP_DAYS
105 const from = dayKey(daysAgo(today, span - 1)), to = dayKey(today)
106 for (const [k, b] of Object.entries(lg.days)) {
107 if (k < from || k > to) continue
108 out.days++; out.turns += b.turns; out.actions += b.actions; out.commits += b.commits; out.green += b.green; out.prs += b.prs
109 out.inTok += b.inTok; out.outTok += b.outTok; out.cacheTok += b.cacheTok; out.cost += b.cost; out.heroes = mergeHeroes(out.heroes, b.heroes)
110 }
111 return out
112}
113/** The hero with the most actions, ties broken by id; undefined when nobody worked. */
114export function mvp(heroes: Record<string, number>): string | undefined {
115 let best: string | undefined
116 for (const [k, n] of Object.entries(heroes)) if (n > 0 && (best === undefined || n > heroes[best]! || (n === heroes[best] && k < best))) best = k
117 return best
118}
119hooks/trophies.ts 104 lines1// clarvis trophies: a fixed list, each earned once ever. The store keeps
2// {id: unlockedAtMs}; this file only decides what is earned from plain facts,
3// and words the /clarvis trophies list. Pure: no engine access.
4import { dayKey } from './daily'
5
6export type Trophy = { id: string; name: string; hint: string; text: string }
7// `text` is the toast's words (the five first trophies keep the wording they had as achievements).
8export const TROPHIES: readonly Trophy[] = [
9 { id: 'hulk', name: 'Hulk Out', hint: 'smash 25 times in one session', text: 'Hulk Out: 25 smashes this session' },
10 { id: 'hawkeye', name: 'Never Misses', hint: 'search 15 times in one session', text: 'Never Misses: 15 searches' },
11 { id: 'century', name: 'On Your Left', hint: '100 actions in one session', text: 'On Your Left: 100 actions this session' },
12 { id: 'ironman', name: 'I Am Iron Man', hint: 'a 25-action or 5-minute mission', text: 'I Am Iron Man: a 25-action (or 5-minute) mission' },
13 { id: 'snap', name: 'Snap Survivor', hint: 'get back on mission after a snap', text: 'Snap Survivor: back on mission after a snap' },
14 { id: 'firstCommit', name: 'First Commit', hint: 'let a commit land', text: 'First Commit: it is in the books' },
15 { id: 'tenCommits', name: 'Ten Commits', hint: 'land 10 commits', text: 'Ten Commits' },
16 { id: 'hundredCommits', name: 'Hundred Commits', hint: 'land 100 commits', text: 'Hundred Commits' },
17 { id: 'firstPr', name: 'Assembled', hint: 'open a pull request', text: 'Assembled: your first pull request is up' },
18 { id: 'greenWall', name: 'Green Wall', hint: '10 green test runs', text: 'Green Wall: 10 green test runs' },
19 { id: 'nightShift', name: 'Night Shift', hint: 'finish a mission after 1am', text: 'Night Shift: a mission after 1am' },
20 { id: 'nightOwl', name: 'Night Owl', hint: 'work past 1am on 3 different nights', text: 'Night Owl: three nights past 1am' },
21 { id: 'earlyBird', name: 'Early Bird', hint: 'finish a mission between 5am and 7am', text: 'Early Bird: a mission at dawn' },
22 { id: 'endgame', name: 'Endgame', hint: 'a mission that runs 60 minutes', text: 'Endgame: a 60-minute mission' },
23 { id: 'binge', name: 'Binge Watcher', hint: 'watch all 10 episodes in the theater', text: 'Binge Watcher: all 10 episodes' },
24 { id: 'opening', name: 'Opening Night', hint: 'watch an episode in the theater', text: 'Opening Night: your first episode' },
25 { id: 'arcade50k', name: 'Arcade 50k', hint: 'score 50,000 in the arcade', text: 'Arcade 50k: 50,000 points' },
26 { id: 'streak3', name: 'Three-Peat', hint: 'work 3 days in a row', text: 'Three-Peat: a 3-day streak' },
27 { id: 'streak7', name: 'Streak 7', hint: 'work 7 days in a row', text: 'Streak 7: a week without a break' },
28 { id: 'streak30', name: 'Monthly Mission', hint: 'work 30 days in a row', text: 'Monthly Mission: a 30-day streak' },
29 { id: 'million', name: 'Million Tokens', hint: 'a million tokens, all time', text: 'Million Tokens: a million and counting' },
30 { id: 'tenMillion', name: 'Ten Million Tokens', hint: 'ten million tokens, all time', text: 'Ten Million Tokens' },
31 { id: 'cacheWizard', name: 'Cache Wizard', hint: '90% cache hits over a 50k+ token session', text: 'Cache Wizard: 90%+ cache hits' },
32 { id: 'thousand', name: 'Thousand Actions', hint: '1,000 actions, all time', text: 'Thousand Actions' },
33 { id: 'polyglot', name: 'Polyglot', hint: 'edit 5 kinds of file in one session', text: 'Polyglot: edits in 5 file types' },
34 { id: 'regular', name: 'Regular', hint: 'open 10 sessions', text: 'Regular: ten sessions in' },
35 { id: 'veteran', name: 'Veteran', hint: 'open 100 sessions', text: 'Veteran: a hundred sessions in' },
36]
37export const TROPHY_IDS: readonly string[] = TROPHIES.map(t => t.id)
38const byId = new Map(TROPHIES.map(t => [t.id, t]))
39export const trophyOf = (id: string): Trophy | undefined => byId.get(id)
40
41/** The store's {id: ms}, made safe: only known ids with a positive finite time. */
42export function sanitizeTrophies(raw: unknown): Record<string, number> {
43 const out: Record<string, number> = {}
44 if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
45 for (const [k, v] of Object.entries(raw as Record<string, unknown>)) if (byId.has(k) && typeof v === 'number' && Number.isFinite(v) && v > 0) out[k] = v
46 }
47 return out
48}
49
50/** Facts the trophies read. Every field is optional: a trophy whose fact is missing is not earned. */
51export type Facts = {
52 hour?: number // local hour 0..23 of a finished mission
53 durationMs?: number
54 tools?: number
55 commits?: number; green?: number; prs?: number // lifetime counts
56 streak?: number
57 tokens?: number; toolCalls?: number; sessions?: number // lifetime
58 moviesSeen?: number; moviesTotal?: number
59 arcadeBest?: number
60 session?: { input?: number; cacheHit?: number; extensions?: number } // input: all prompt tokens; cacheHit 0..1
61 nights?: number // distinct days with a mission after 1am
62}
63/** Ids earned by these facts (earned or not before: the caller keeps only the new ones). */
64export function earned(f: Facts): string[] {
65 const out: string[] = []
66 const add = (ok: boolean, id: string) => { if (ok) out.push(id) }
67 const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
68 const h = f.hour
69 add(n(f.commits) >= 1, 'firstCommit'); add(n(f.commits) >= 10, 'tenCommits'); add(n(f.commits) >= 100, 'hundredCommits')
70 add(n(f.prs) >= 1, 'firstPr'); add(n(f.green) >= 10, 'greenWall')
71 add(typeof h === 'number' && h >= 1 && h < 5, 'nightShift'); add(n(f.nights) >= 3, 'nightOwl'); add(typeof h === 'number' && h >= 5 && h < 7, 'earlyBird')
72 add(n(f.durationMs) >= 60 * 60_000, 'endgame')
73 add(n(f.moviesSeen) >= 1, 'opening'); add(n(f.moviesTotal) > 0 && n(f.moviesSeen) >= n(f.moviesTotal), 'binge')
74 add(n(f.arcadeBest) >= 50_000, 'arcade50k')
75 add(n(f.streak) >= 3, 'streak3'); add(n(f.streak) >= 7, 'streak7'); add(n(f.streak) >= 30, 'streak30')
76 add(n(f.tokens) >= 1_000_000, 'million'); add(n(f.tokens) >= 10_000_000, 'tenMillion')
77 add(n(f.toolCalls) >= 1000, 'thousand')
78 add(n(f.sessions) >= 10, 'regular'); add(n(f.sessions) >= 100, 'veteran')
79 const s = f.session
80 add(!!s && n(s.input) >= 50_000 && n(s.cacheHit) >= 0.9, 'cacheWizard')
81 add(!!s && n(s.extensions) >= 5, 'polyglot')
82 return out
83}
84
85/** The file extension an edit touched, lower-case without the dot; '' for none. */
86export function extOf(path: unknown): string {
87 if (typeof path !== 'string') return ''
88 const base = path.split(/[\\/]/).pop() ?? ''
89 const i = base.lastIndexOf('.')
90 return i > 0 && i < base.length - 1 && base.length - i <= 8 ? base.slice(i + 1).toLowerCase() : ''
91}
92
93/** The /clarvis trophies text: unlocked with the date, locked as "??? · hint: ...", then "N/M unlocked". */
94export function trophiesText(unlocked: Record<string, number>): string {
95 const got = sanitizeTrophies(unlocked)
96 const w = Math.max(...TROPHIES.map(t => t.name.length)) + 2
97 const rows = ['TROPHIES', '']
98 const have = TROPHIES.filter(t => got[t.id]), lack = TROPHIES.filter(t => !got[t.id])
99 for (const t of have) rows.push(` ★ ${t.name.padEnd(w)}${dayKey(new Date(got[t.id]!))}`)
100 for (const t of lack) rows.push(` · ??? · hint: ${t.hint}`)
101 rows.push('', ` ${have.length}/${TROPHIES.length} unlocked`)
102 return rows.join('\n')
103}
104hooks/team.ts 119 lines1// clarvis team: which heroes take the stage. The rotation itself stays in
2// register.tsx (`order`, built from scenes.ts's ROSTER); this file only filters
3// and orders a roster it is handed, matches names, and cleans what the store
4// held. Pure: no engine access.
5import { VILLAINS } from './season'
6
7export type TeamId = 'avengers' | 'guardians' | 'wakanda' | 'cosmic' | 'all'
8export const TEAM_IDS: TeamId[] = ['avengers', 'guardians', 'wakanda', 'cosmic', 'all']
9// Presets name existing hero ids from ROSTER ('all' is the whole roster).
10export const TEAMS: Record<Exclude<TeamId, 'all'>, string[]> = {
11 avengers: ['ironman', 'cap', 'thor', 'hulk', 'widow', 'hawkeye', 'spidey', 'warmachine', 'falcon', 'bucky', 'vision', 'witch', 'antman', 'strange', 'cmarvel', 'jarvis', 'fury'],
12 guardians: ['starlord', 'gamora', 'drax', 'rocket', 'groot', 'mantis', 'nebula', 'yondu'],
13 wakanda: ['panther', 'cap', 'bucky', 'widow', 'falcon', 'hulk', 'warmachine', 'vision', 'witch', 'thor'],
14 cosmic: ['cmarvel', 'thor', 'strange', 'loki', 'starlord', 'gamora', 'drax', 'rocket', 'groot', 'mantis', 'nebula', 'yondu'],
15}
16export const isTeam = (s: unknown): s is TeamId => typeof s === 'string' && (TEAM_IDS as string[]).includes(s)
17
18/** The ids a person can pick: everyone on the roster except the generic "thinking" slot. */
19export const pickable = (roster: readonly string[]): string[] => roster.filter(r => r !== 'thinking')
20
21// ── names ───────────────────────────────────────────────────────────────────
22const ALIASES: Record<string, string[]> = {
23 ironman: ['tony', 'stark', 'tonystark', 'iron'], cap: ['captain', 'captainamerica', 'steve', 'rogers', 'steverogers'], thor: ['odinson'], hulk: ['banner', 'brucebanner'],
24 widow: ['natasha', 'blackwidow', 'romanoff'], hawkeye: ['clint', 'barton'], spidey: ['spiderman', 'spider', 'peter', 'parker'], panther: ['blackpanther', 'tchalla', 'wakanda'],
25 witch: ['scarletwitch', 'wanda'], strange: ['doctorstrange', 'drstrange', 'stephen'], antman: ['ant', 'scott', 'lang'], starlord: ['quill', 'peterquill', 'star'],
26 warmachine: ['rhodey', 'rhodes', 'war'], cmarvel: ['captainmarvel', 'carol', 'danvers', 'marvel'], bucky: ['wintersoldier', 'winter', 'barnes'], jarvis: ['friday'],
27 deadpool: ['wolverine', 'wade', 'deadpoolwolverine'], falcon: ['sam', 'wilson'], rocket: ['raccoon'], groot: ['tree'], fury: ['nick', 'nickfury'], loki: ['trickster'], ultron: ['robot'],
28}
29const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, '')
30function dist(a: string, b: string): number {
31 if (Math.abs(a.length - b.length) > 3) return 9
32 let prev = Array.from({ length: b.length + 1 }, (_, i) => i)
33 for (let i = 1; i <= a.length; i++) {
34 const cur = [i]
35 for (let j = 1; j <= b.length; j++) cur[j] = Math.min(prev[j]! + 1, cur[j - 1]! + 1, prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
36 prev = cur
37 }
38 return prev[b.length]!
39}
40export type Match = { id: string; suggestions?: undefined } | { id?: undefined; suggestions: string[] }
41/** Finds a hero from what was typed: an id, a display name, an alias, a prefix, a part of a name, or a near miss. */
42export function findHero(q: string, ids: readonly string[], names: Record<string, string>): Match {
43 const k = norm(q)
44 const list = pickable(ids)
45 if (!k) return { suggestions: list.slice(0, 8) }
46 const keys = (id: string) => [norm(id), norm(names[id] ?? ''), ...(ALIASES[id] ?? []).map(norm)].filter(Boolean)
47 const exact = list.find(id => keys(id).includes(k))
48 if (exact) return { id: exact }
49 const pre = list.filter(id => keys(id).some(x => x.startsWith(k)))
50 if (pre.length === 1) return { id: pre[0]! }
51 if (pre.length > 1) return { suggestions: pre.slice(0, 8) }
52 const part = list.filter(id => k.length >= 3 && keys(id).some(x => x.includes(k)))
53 if (part.length === 1) return { id: part[0]! }
54 if (part.length > 1) return { suggestions: part.slice(0, 8) }
55 const scored = list.map(id => ({ id, d: Math.min(...keys(id).map(x => dist(k, x))) })).filter(x => x.d <= 2).sort((a, b) => a.d - b.d)
56 if (scored.length === 1 || (scored.length > 1 && scored[0]!.d < scored[1]!.d)) return { id: scored[0]!.id }
57 return { suggestions: (scored.length ? scored.map(s => s.id) : list).slice(0, 8) }
58}
59
60// ── what is chosen, and where it is kept ────────────────────────────────────
61export type Selection = { team: TeamId; pick?: string; here?: string }
62export const HERE_MAX = 100
63/** The stored global choice {team, pick}, made safe against the roster. */
64export function sanitizeSel(raw: unknown, ids: readonly string[]): { team: TeamId; pick?: string } {
65 const o = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}) as Record<string, unknown>
66 const ok = pickable(ids)
67 return { team: isTeam(o.team) ? o.team : 'all', ...(typeof o.pick === 'string' && ok.includes(o.pick) ? { pick: o.pick } : {}) }
68}
69/** The per-project pins {cwd: id}, made safe and bounded. */
70export function sanitizeHere(raw: unknown, ids: readonly string[]): Record<string, string> {
71 const out: Record<string, string> = {}
72 if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
73 const ok = pickable(ids)
74 for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { if (Object.keys(out).length >= HERE_MAX) break; if (k && k.length < 1024 && typeof v === 'string' && ok.includes(v)) out[k] = v }
75 }
76 return out
77}
78export function pinHere(here: Record<string, string>, cwd: string, id: string | undefined): Record<string, string> {
79 const out = { ...here }
80 if (id === undefined) delete out[cwd]
81 else { delete out[cwd]; out[cwd] = id } // newest last
82 const keys = Object.keys(out)
83 for (const k of keys.slice(0, Math.max(0, keys.length - HERE_MAX))) delete out[k]
84 return out
85}
86
87/** The ids on the stage for a choice: a project pin beats a global pick, a pick beats the team. */
88export function stageIds(roster: readonly string[], sel: { team: TeamId; pick?: string }, here?: string): string[] {
89 const ok = pickable(roster)
90 if (here && ok.includes(here)) return [here]
91 if (sel.pick && ok.includes(sel.pick)) return [sel.pick]
92 if (sel.team === 'all') return [...roster]
93 const want = TEAMS[sel.team]
94 const out = roster.filter(r => want.includes(r))
95 return out.length ? out : [...roster]
96}
97
98/**
99 * The rotation order for a session: the stage ids shuffled by `rng`, then the seasons' touches. Villains come first at
100 * Halloween and Thor takes a second slot on Thursdays; both only for the whole roster or a team (a pick stays a pick).
101 */
102export function orderFor(ids: readonly string[], o: { rng: () => number; villains?: boolean; thursday?: boolean }): string[] {
103 const order = [...ids]
104 for (let i = order.length - 1; i > 0; i--) { const j = Math.floor(o.rng() * (i + 1)); [order[i], order[j]] = [order[j]!, order[i]!] }
105 if (order.length < 2) return order
106 let out = order
107 if (o.villains) {
108 const v = out.filter(x => VILLAINS.includes(x)), rest = out.filter(x => !VILLAINS.includes(x))
109 out = [...v, ...rest]
110 }
111 // Halloween week belongs to the villains: Thor's Thursday slot waits (Oct 29, 2026 is both).
112 if (o.thursday && !o.villains && out.includes('thor') && out.length >= 3) {
113 // a second slot for Thor, half a lap after the first (never next to it)
114 out = [...out]
115 out.splice(((out.indexOf('thor') + Math.floor(out.length / 2)) % out.length) + 1, 0, 'thor')
116 }
117 return out
118}
119hooks/season.ts 41 lines1// clarvis seasons and easter eggs: pure, and the date and the dice are handed
2// in so a test fixes both. Full mode only (the caller gates; nothing here
3// touches the engine).
4
5export type Season = 'halloween' | 'winter' | 'newyear'
6export type Flair = 'halloween' | 'newyear' | 'groot' | 'shawarma'
7/** Dates before this are never seasonal: a test clock at 0 (1 Jan 1970, a Thursday) must stay plain. */
8const MIN_YEAR = 2024
9
10/** Halloween Oct 25 to Nov 1, snow all of December, fireworks on New Year's Day. By local date. */
11export function seasonOf(d: Date): Season | null {
12 if (!Number.isFinite(d.getTime()) || d.getFullYear() < MIN_YEAR) return null
13 const m = d.getMonth(), day = d.getDate()
14 if ((m === 9 && day >= 25) || (m === 10 && day === 1)) return 'halloween'
15 if (m === 0 && day === 1) return 'newyear'
16 if (m === 11) return 'winter'
17 return null
18}
19export const isRealDate = (d: Date): boolean => Number.isFinite(d.getTime()) && d.getFullYear() >= MIN_YEAR
20export const isThursday = (d: Date): boolean => Number.isFinite(d.getTime()) && d.getFullYear() >= MIN_YEAR && d.getDay() === 4
21
22/** The flair a moment of the season carries (winter's snow lives on the band, not on a moment). */
23export function flairOf(s: Season | null): Flair | undefined { return s === 'halloween' ? 'halloween' : s === 'newyear' ? 'newyear' : undefined }
24export function seasonCaption(s: Season | null): string | undefined {
25 return s === 'halloween' ? '"Trick or treat. The villains have the floor tonight."' : s === 'newyear' ? '"Happy New Year, Avengers. Make it a good one."' : undefined
26}
27/** The villains Halloween features up front in the rotation. */
28export const VILLAINS: readonly string[] = ['ultron', 'loki', 'nebula', 'bucky']
29
30// ── easter eggs ─────────────────────────────────────────────────────────────
31export const EGG_ODDS = 150
32export const SHAWARMA_MS = 3 * 60 * 60_000
33export const EGG_CAPTIONS: Record<'groot' | 'shawarma', string> = { groot: '"I am Groot." (the dance floor is open)', shawarma: '"Shawarma break?"' }
34/** One roll per finished turn: 1 in 150. A session past three hours may get the shawarma line instead of the dance. */
35export function rollEgg(rng: () => number, o: { sessionMs: number }): 'groot' | 'shawarma' | undefined {
36 const r = rng()
37 if (!(typeof r === 'number' && r >= 0 && r < 1 / EGG_ODDS)) return undefined
38 if (o.sessionMs >= SHAWARMA_MS) return rng() < 0.5 ? 'shawarma' : 'groot'
39 return 'groot'
40}
41