SLOPSHOPPER

clarvis

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…

newpanebandspinnerrowsguard
★ 2v1.0.0MITupdated 2026-10-11shahsanket2107/clarvis
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · clarvis
│ ┃ avengers-arcade ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Click the game to play · 1–5 pick · q quits… │ clarvis │ │ ┃ ▣ client module ./arcade.tsx ⏺ Read(src/auth.ts) │ Your terminal shows 256 colours, so │ │ ⎿ Read 6 lines │ Clarvis is in lite mode. Ghostty, iTerm2, │ │ ⏺ Update(src/auth.ts) │ WezTerm or kitty get the full art · │ │ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ │ ⎿ 3 pass, 1 fail │ clarvis │ │ │ ★ Trophy unlocked · Cache Wizard: 90%+ │ │ ● Done. refresh now re│ cache hits │ │ ╰────────────────────────────────────────────╯ │ ✓ Mission complete in 42s · 9 tools │ │ › /clarvis │ ⎿ clarvis: CLARVIS · lite · unofficial fan mod │ ⎿ clarvis: │ ⎿ clarvis: LOOK │ ⎿ clarvis: /clarvis full|calm|lite|off art, no motion, text │ ⎿ clarvis: /clarvis fx on|off tint the transcript │ ⎿ clarvis: /clarvis sound on|off chiptune sounds (on; │ │ ◢ app · ✓ standing by · ctx 49% · $0.42 spent · 97.4k tok · ↑97.4k ↓1.5k · cache 93% · 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◢ app · ✓ standing by · ctx 49% · $0.42 spent · 97.4k tok · ↑97.4k ↓1.5k · cache 93% · 5h 31%
Pane · avengers-arcade
Click the game to play · 1–5 pick · q quits · Esc gives the… ▣ client module ./arcade.tsx
Pane · avengers-theater
Click a movie or press 1–0 / ↑↓ + SPACE · ←/→ beats · P pau… ▣ client module ./theater.tsx
Turn line
✓ Mission complete in 42s · 9 tools
README

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


What it does

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.

  • A hero for every moment. While Claude works, a rotating cast of 30 heroes takes a 3D stage: Iron Man, Thor, Hulk, Spider-Man, Loki, the Guardians and more, a new one every 8 seconds. Each roasts what Claude is actually doing.
  • CLAUVENGERS, ASSEMBLE! Finish a task and the team assembles with your time, actions and cost. Commit, and it says "Commit a1b2c3d is in the books." Turn your tests green: "Tests: 142 passed. Bugs, dusted."
  • Something breaks? Thanos shows up. If Claude's last command fails, he snaps. When Claude asks you a question, Nick Fury holds the screen until you answer.
  • Live usage HUD. Context gauge, tokens in and out, cache hit rate, cost so far, and your 5-hour limit, always on screen. /clarvis stats gives the full C.L.A.R.V.I.S. mission report.
  • Stark Arcade. Five pixel games in a pane: an Ultron drone siege, a 3-vs-3 Civil War tag fight, two free-pick arenas with 16 fighters, and an Endgame boss fight.
  • Avengers Theater. Ten short comic-panel episodes with speech balloons and sound effects, reshuffled every viewing.
  • Trophies, streaks and seasons. 27 trophies to collect, a daily streak, villains taking over for Halloween week, snow in December, and a few rare easter eggs.
  • Chiptune sounds, generated in code (on macOS). /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>

Show off your setup

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.

Install

Needs: Claude Code 2.1.287 or later (claude --version) in a terminal or the Desktop app's Code tab.

  1. Open Claude Code and paste: `` /plugin install clarvis --marketplace shahsanket2107/clarvis ``
  2. Press y to add the marketplace, then Enter to install for your user. The band appears straight away.
  3. Type /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.

Commands

CommandWhat it does
/clarvisThe briefing: every command and your current settings
/clarvis demoA 45-second showreel with sample numbers (nothing is counted)
/clarvis full · calm · lite · offFull 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 trophiesYour 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 playStark 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.

Arcade controls

Pick a game with 1–5, the arrows and SPACE, or a click.

  • Siege: arrows or the mouse fly, SPACE fires, 1/2/3 are specials.
  • Fights: tap ←/→ to step and double-tap to run, ↑ jumps, tap ↓ to guard (on the hit, it parries). SPACE ×3 chains into C (special) and X (super, once the gold meter is full). → then SPACE up close throws.

P pauses, Q twice quits, and the siege high score is saved.

Privacy and safety

Clarvis is a mod: code that runs inside Claude Code. Here's exactly what it does:

  • It only watches and draws. It never changes a tool call, a prompt or a permission decision, and never answers a permission request for you.
  • Nothing leaves your machine. No network requests, no analytics, no tracking. It starts no processes and reads or writes none of your files.
  • What it reads: the session's model, usage and cost from Claude Code, the 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.
  • What it stores, in Claude Code's per-plugin store: your settings, trophies, streak, lifetime totals, watched episodes and the arcade high score.
  • Idle cost: zero. Frames repaint only while something moves. At rest it does nothing.

Check it yourself: claude plugin validate . on a clone lists every hook and every call the mod makes.

FAQ

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.

Develop

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

License

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.

Source 47 files
hooks/register.tsx 1151 lines
1// 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}
1151
hooks/art.ts 63 lines
1// 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}
63
hooks/pixel.ts 325 lines
1// 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}
325
hooks/metrics.ts 276 lines
1// 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}
276
hooks/lines.ts 108 lines
1// 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('') }
108
hooks/hud.ts 183 lines
1// 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}
183
hooks/movies.ts 25 lines
1// 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}
25
hooks/ship.ts 108 lines
1// 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}
108
hooks/daily.ts 119 lines
1// 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}
119
hooks/trophies.ts 104 lines
1// 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}
104
hooks/team.ts 119 lines
1// 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}
119
hooks/season.ts 41 lines
1// 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