SLOPSHOPPER

mascot

A desktop mascot overlay that reacts to turns, tool calls and subagents

newguardcommandtoastpromptprocess
★ 1v0.19.0MITupdated 2026-10-04desuqcafe/cc-mascot/mascot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mascot
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mascot ⎿ mascot: Mascot shown: drag to move, double-click to send it back to its spot, right-click to hide. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

mascot

A Claude Code plugin that puts a character on your desktop and shows what Claude is doing: thinking, working, waiting on you, happy when it is done, upset when something fails.

Use

Every Claude Code session has a mascot of its own, showing only that session's mood. It appears when the session starts and goes when it ends (closing its terminal included), like a concert hologram: a ring of light on the floor, a beam, and she is projected from her feet up while teal and pink bits stream into her, locking in with a sparkle; going, she dissolves upward into bits and notes. Showing and hiding (/mascot, right-click) play the same; a /clear changes her channel with a quick glitch; reloading the plugin leaves her standing. A tag under her feet names the session's project (numbered, app ·1, app ·2, when several sessions share one).

  • /mascot shows or hides this session's mascot; /mascot show and /mascot hide say which. A new session starts the way you last chose.
  • /mascot show all / /mascot hide all does it for every session at once.
  • /mascot character lists the characters with art; /mascot character NAME picks one for this project's sessions: this one's mascot leaves and the new character arrives on the same spot. The settings window picks one too, for the project or (with "Remember for this project" off) for this session alone.
  • /mascot beam fires her beam (below) on demand, then she goes back to what she was doing.
  • Cursor magic (off by default; /mascot magic after MINUTES): a round of work that lasted that long ends with her sending magic to your mouse pointer, on whichever display it is. A star leaves her heart hands trailing sparkles and notes and flies to the pointer on an arc, homing in as you move it. It bursts there, then two notes and a heart circle the pointer, following it, until you click (or 6 s). Hidden, she sends it all the same: sparkles gather at the pointer.
  • It goes at once, whatever window is in front and however long since you touched the mouse: neither says where you are looking (a video on one display, the terminal active on the other). It waits only while a game runs in exclusive fullscreen, Windows is in presentation mode, or your screen is off or locked; then it lands as you come back. A pointer an app hides (a playing video) still gets it.
  • It flies in a window of its own that clicks pass through and that never takes the focus.
  • /mascot magic sends one now, to try it (so does the settings window's "Send one now").
  • With sound on (off by default: /mascot sound on), she has a sound for the moments that tell you something: a chime once she has waited on you for 30 seconds (a question or a permission), a cheer when a round of work is done, her own fanfare for the beam, a soft "uh-oh" when a turn dies on an error. Cursor magic and her coming and going can have one too (off until you turn them on). Each character has her own sounds; any moment can play a file of yours instead. Two never play at once, even from several sessions, and she is quiet while hidden or while your screen is off or locked (nothing she missed plays later).
  • She can tell you more, each off until you turn it on (/mascot call, remote, away, or the settings window's Notifications page), each her own way: Miku's messenger is a little phone, Yunseul's a bat carrying a letter sealed in crimson wax.
  • Call me: once she has waited on you as long as her waiting sound waits (30 seconds), her messenger calls by her head and her terminal's button blinks in the taskbar until you bring it forward. A click on her brings her terminal's window forward (Windows Terminal keeps several sessions in one window, on the tab you left it at).
  • Remote Control: a prompt you send from the Claude app on your phone, from the web, or from a chat a channel relays comes in with her messenger.
  • Away notes: when nobody has touched the keyboard or mouse for 5 minutes, or your screen is off or locked, she keeps a note of what happens (work done, a turn that failed, waiting on you, prompts a chat sent in). Back at your PC, she holds the note at her feet; hover her and the card shows it, then she puts it away.
  • Like her sounds, none of it plays while she is hidden.
  • /mascot settings opens the settings window; the other settings commands are below.
  • /mascot update updates her the way she was installed: claude plugin update for a marketplace install, git pull --ff-only for a clone (a copy that is neither says how to do it by hand). It runs in the background and says how it went; /reload-plugins then loads the new version (a clone's files changing may reload it on their own). Your other sessions keep the version they loaded until then: each says so in a toast once, and on her card and in the settings window, until you type /reload-plugins there.
  • A new version greets you: the first time one newer than the last she ran loads (however it came), she holds up a "NEW! v0.15.0" banner in her main color while stars and notes fountain up around her, and a toast says what is new (whatsnew.json): one line, news before fixes, and how many more since the version you had. Once per version in each session: the first to load it, and each one you reload into it. /mascot news lists all of it (all: every version), as does the settings window's What's new page.
  • Several mascots stand side by side, never on top of each other: the first in the main display's bottom-right corner, each next one to the left of the one before, wherever you dragged that one, on to your other displays when a display is full. Drag one to move it, to any display (each spot remembers where it was dragged, and a spot on a display you unplug comes back with it); double-click sends it back to its spot; right-click hides it.
  • Picked up, she says "!" and rides a little ring of light, shy in her held pose, legs dangling; she swings from where you hold her, leaning back as you move her and swaying when you stop, and a quick flick smears her into teal and pink ghosts and shakes bits and notes loose. Set down, she lands with a little bounce and the ring ripples out.
  • A hidden mascot keeps running, so showing it is instant, but holds no art in memory while hidden. A shown one builds a mood's frames the first time it takes the mood on, and keeps idle's and the two moods used last.
  • While your screen is off or your PC is locked, she rests and draws nothing, and is back the moment you are. She never keeps your PC or your screen awake.
  • Hover over it for a card about the session: context used (and how fast it grows, with an estimate of the turns left before auto-compact), the 5-hour and weekly usage limits, model, session length, prompts, what Claude is doing and for how long, subagents with their own context, and background work. A subagent's context shows as a percentage only when it runs on the main model (same window); otherwise as tokens. The last line is the session id. The usage limits are your account's: each reply reports them, and the card shows the latest any session has had, so an idle session's card keeps up with a busy one. Use outside Claude Code (claude.ai, the app) shows once some session sends its next request.

Settings

Every setting starts at its default (extras off), and settings are shared: every mascot follows a change at once, live. They live in ~/.claude/mascot/settings.json, which holds only what you changed.

CommandSetting
/mascot settingsOpens the settings window (below) and lists every setting.
`/mascot size [small\normal\large\PX]`Her height: 300, 420 (the default) or 560 px, or any from 240 to 640. She grows or shrinks where she stands, feet kept in place.
`/mascot calm [on\off]`Calm mode: no glitch, particles, flicker or flashes. Her symbols, the aura's color and the hologram's scanlines stay; she comes and goes in a plain fade, and the beam keeps its hearts and banner but not its flash or speed lines. Off by default.
`/mascot smooth [on\off]`Smooth sparkles: her aura's sparkles, bits and flicker move as smoothly as her symbols, rather than in step with her drawn frames. It asks more of your computer while she works. Off by default; calm mode has no sparkles to smooth.
`/mascot aura [A B C\off\default]`The context at which her aura's three levels start, going up (300k 400k 500k by default; 1.2M works too), or no aura.
`/mascot beam after [MINUTES\never\default]`How long a round of work lasts before it ends in the beam instead of happy (2 minutes; up to 120), or never.
`/mascot beam agents [on\off]`Whether a round that used subagents or background agents ends in the beam too (on).
`/mascot magic after [MINUTES\never\default]`How long a round of work lasts before its end sends magic to your pointer (0 for every round; up to 120), or never (the default).
`/mascot updates [on\off]`Look for a newer version once a day (off): one read of this plugin's plugin.json on GitHub, nothing sent. When there is one, her hover card says so and the settings window offers it. With no value, it also says her version.
/mascot news [all]What is new since the version you had before her last update, by version (all: every version).
`/mascot sound [on\off]`Her sounds (off). With no value, it lists what each moment plays.
`/mascot sound volume [0-100\default]`Their volume (60).
`/mascot sound wait [SECONDS\default]`How long she waits on you before her waiting sound (30; 10 to 300).
`/mascot sound MOMENT [on\off\default\FILE]`What a moment plays: waiting, done, beam, error, magic, intro or outro. default is her own sound; FILE is the name of a .wav or .mp3 of yours in ~/.claude/mascot/sounds/ (up to 8 seconds). Waiting, done, beam and error play hers by default; the rest nothing.
/mascot sound try MOMENTPlays a moment's sound now, to hear it.
`/mascot call [on\off]`Call me: once she has waited on you as long as her waiting sound waits, her messenger calls and her terminal blinks in the taskbar; a click on her brings her terminal forward. Off by default.
`/mascot remote [on\off]`Prompts sent from Remote Control (your phone, the web) or a chat come in with her messenger. Off by default.
`/mascot away [on\off]`Away notes: what happened while you were away, on a note she holds when you are back. Off by default.
/mascot resetEvery setting back to its default.

With no value, each command says what the setting is now. The file can be edited by hand too: a value it cannot use counts as the default, so a slip never breaks a mascot.

The settings window has the same settings in the character's own colors (Miku's teal and pink; frames/<character>/theme.json): her character (a tile for each, in her colors, and "Remember for this project"), her size with a preview of her at it, calm mode and smooth sparkles, the aura's three thresholds on one track, the beam, cursor magic (with a "Send one now" to try it, and "Notifications…": a page with Call me, Remote Control and away notes, each pictured in her style), sound (on or off and its volume, and "Choose sounds…": a page with how long she waits on you first and a row per moment, each with its own switch, Try, Choose… and Default; a file you choose is copied into ~/.claude/mascot/sounds/, so moving the original never breaks it), and updates (her version, what is new since the one before, a page of every version's news, the daily check, and an Update button when a newer version is out, following the update as it runs). A change is saved at once, and a change made elsewhere (a command, the file) shows in it within a second. Running /mascot settings again brings it forward; run from another session, the window reopens for that one (its character picks are that session's), where it stood.

Calm mode is for comfort rather than speed, though it does make her a little lighter while warnings show.

Moods

MoodWhen
idleNothing running; also after an interrupted reply.
thinkingClaude is working out its answer: after your message, between tool calls, and while the conversation is compacted.
workingA tool runs (file reads, commands, edits), and while Claude waits on work it started: subagents, background agents, a background command or monitor, a wakeup it scheduled. Not a dev server or watcher, which runs on by design, and not past 30 minutes: then she goes back to idle without cheering, since nobody can tell the work is done.
waitingClaude needs you: a permission prompt, a question (AskUserQuestion, plan approval), or an MCP server asking something. After you approve a prompt it stays until that tool finishes (no event marks the approval).
worriedA model request has streamed nothing for 10 s: usually an API error being retried, or a dropped connection.
happy3 s when everything is done: the reply and all the work it waited on.
beamInstead of happy, when the work took 2 minutes or more, or used subagents or background agents: her big finish, 3.6 s. Both are settings (/mascot beam after, /mascot beam agents).
error3 s when a tool fails, or a reply ends on an API error or refusal. Declining a permission prompt is not an error.
sleepyIdle for 5 minutes.

Each mood but idle has a symbol drawn and animated over her, in her character's colors (Miku's teal and pink below) with white sticker borders and soft glows:

MoodSymbol
thinkinga mint thought bubble whose three dots light up in turn, teal to pink
workinga little equalizer bouncing to a beat, notes (♪ ♫) rising from it
waitinga speech bubble with a pink "?" that hops, bursting little stars
worrieda sweat drop sliding down by her temple, flustered pink lines
happystage stars bursting over her head, then twinkling; hearts floating up
errora grumpy cloud with a pink scribble, dropping a cracked note
sleepysoft z's drifting up
beam"Miku Miku Beam!": sparkles and notes spiral into her heart hands, then hollow hearts burst out at you with manga speed lines, a sticker banner (ミクミクビーム!) and little hearts flying; then hearts float up and pop

She also glitches, splitting into teal and pink ghosts with bands of her sliding sideways: briefly at every mood change, and hard when error starts, then in short bursts. The window itself never moves on its own.

A long session and a usage limit running out show on her too, whatever her mood, from the figures on her hover card (they fade in and out). The aura's thresholds are a setting (/mascot aura); these are the defaults:

WhenWhat she shows
context past 300k tokensa soft teal aura around her, breathing
past 400kthe aura turns pink; sparkles and notes drift up off her
past 500koverload: a magenta aura beating like a heart, pixels crackling off her edges, a flicker of static now and then (time to /compact)
5-hour limit 90% useda failing stage light: a ring of light on the floor at her feet that hums and sputters
weekly limit 90% useda hologram fading from the stage: scanlines, a bright band rolling down her, bits of her flaking away

Characters

Two characters come with it, each with her own art, colors and effects (/mascot character NAME):

  • miku (the default): Hatsune Miku in teal and pink, as everything above describes.
  • yunseul: Yunseul, a sleepy little vampire doll from Inhyeong RPG, in gothic lolita black and crimson with long silver hair. Her moods are her own (fists up and hopping when happy, sleeves to her mouth when worried, a pouty stamping tantrum on error, yawning with her bunny doll when sleepy), and so are her effects, in moonlight silver and crimson:
Yunseul
thinkinga lace thought bubble, gem dots lighting in turn
workinga needle sewing cross stitches, bats flapping up
waitinga lace speech bubble with a hopping "?", a bat peeking
worrieda sweat drop and a little ghost trembling beside her
happya burst of bats, roses and stitched hearts
errora pouting cloud with a >< face, dropping cracked hearts
sleepya sleepy crescent moon with a bat asleep under it, z's
beam"Love Bite" (러브 바이트!): bats spiral into her heart hands, then stitched hearts and a swarm of bats burst out, crimson rays and a shockwave behind her, the banner on bat wings; petals drift down
glitcha haunt: silver and crimson afterimages, an ectoplasm ripple
coming, goinga summoning circle traces itself, candles light, mist rises, bats swirl in and she forms out of smoke; she leaves in a burst of bats (a /clear blows the candles out)
soundsa music box, low bells, an organ and bats; Love Bite is an organ sting with a little nibble
messengera bat with a letter sealed in crimson wax: it shakes the letter at you, drops it in with a prompt from elsewhere, and leaves one at her feet while you are away
auramoonlight; crimson with petals and bats; a blood moon behind her, beating like a heart
5-hour limitcandles guttering at her feet
weekly limita ghost fade from her feet up

Art

Source art lives in ../art/, named with the character and the mood (Miku_Happy.png). A number after the mood makes flipbook frames played at 6 fps (Miku_Working_1.png, Miku_Working_2.png). Import a character with:

python scripts/import_frames.py [--character NAME]

The character defaults to miku. This lines every frame's feet up with idle's (so the character never hops), saves 512x768 PNGs into frames/<character>/, and replaces a mood's old frames. Whatever floats apart from the character (a bubble or notes drawn into the art) is dropped: the overlay draws each mood's symbol itself, so new art is made without one. A mood with no art plays idle's frames under its symbol. A new character needs at least its idle art; then /mascot character NAME shows it.

Her sounds are frames/<character>/sounds/<moment>.wav (a character without them has Miku's). Miku's and Yunseul's are made from nothing but code by scripts/make_sounds.py (bells, music box tines, an organ, sweeps and filtered noise: no recordings, no voice samples); the same code makes the same files, and --check says whether they match.

Its theme.json gives the settings window's palette and names, and under effects the colors its symbols, aura, beam, hologram and glitch are drawn in. Each is a role: main and accent (each with a ...Light for its glows, the projector and the hologram, and a ...Shade for rims), accentSoft, pale, hot, gold, storm/stormDeep/stormShade (error's cloud), sky/skyDeep/skyShade (the sweat drop) and dim; aura is the three levels' [glow, edge] colors (a color or a role), and call the beam's banner. What it leaves out is Miku's; a light left out is its own color. style names a module of the character's own effects (gothic, overlay/gothic.py, is Yunseul's): it draws her symbols, status, glitch, coming and going and finisher in its own shapes; left out, she has Miku's.

A mood can instead be a rigged loop: the art split into layers (hair, face, skirt...) that move on their own, so she breathes, blinks and her twin tails and skirt sway. Miku's idle, thinking (a "hmm" head tilt), working (swaying to her song), happy (two little hops, tails flying), error (dizzy rings in her eyes), waiting (an "excuse me" wave), worried (a flustered fidget, holding her twin tails), sleepy (nodding off), the beam's heart hands and the held pose (legs dangling) are rigged, and so are all ten of Yunseul's, made by ../rig/animate.py --pose MOOD from each pose's config in ../rig/poses.py (the rig's README has the setup). frames/<character>/moods.json sets a mood's speed and marks it rigged ({"idle": {"fps": 12, "rigged": true}}); import_frames.py leaves a rigged mood's frames alone.

Tests

From this folder:

claude plugin test .
python -m unittest discover -s overlay -p "test_*.py"
python -m unittest discover -s scripts -p "test_*.py"

Needs

Python 3 with Pillow (pythonw on the PATH, as the Microsoft Store Python and the python.org installer with "Add to PATH" set it up). The overlay draws into a tkinter window made a per-pixel-alpha layered window (Windows).

Source 2 files
hooks/register.tsx 1604 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type {
5  MascotCall,
6  MascotCue,
7  MascotFrame,
8  MascotMoment,
9  MascotMood,
10  MascotSessionFile,
11  MascotSettings,
12  MascotUpdate,
13  MascotVisit,
14  MascotVisibility,
15  MascotWork,
16} from '../types'
17
18const HOLD_MS = 3000
19// A round of work (from a prompt until all of it is done) of the `beamAfter`
20// setting's minutes or more, or one that used subagents or background agents
21// (`beamForAgents`), ends in her big finish, the beam, held this long,
22// instead of happy. One of the `magicAfter` setting's minutes or more ends
23// in her call too: magic sent to the pointer.
24const BEAM_MS = 3600
25// After the last subagent ends, how long to wait for the main agent to pick
26// its results up before calling the work done.
27const SETTLE_MS = 2000
28// A model request that has streamed nothing for this long is stuck: the
29// engine retrying an API error, or a dropped connection.
30const STALL_MS = 10_000
31const STALL_TICK_MS = 1000
32// How long idle lasts before the mascot dozes off.
33const SLEEP_MS = 5 * 60_000
34// Background tasks that are agents at work.
35const AGENT_TASKS = ['subagent', 'workflow', 'remote_agent']
36// Other background work Claude starts and will hear back from (a shell when
37// it exits, Claude's Monitor tool at each event, a one-time wakeup when it
38// fires) holds the round open too, when started during it: an orchestrator
39// that ends its turn to wait on a build is not done. Not a shell that serves
40// or watches, which runs on by design (`isEndless`), and none of it past
41// WAIT_CAP_MS (a server that list missed): the round then ends quietly,
42// without happy or magic, since nobody can tell the work is done.
43// The Monitor tool's tasks are listed as shells (checked headless, 2.1.289).
44// A task of type `monitor` is a subscription with no end, such as the live
45// watch Claude Code keeps on an artifact it published (seen 0.16.5: she sat
46// in working for the cap after a publish), so it never holds a round.
47const WAIT_CAP_MS = 30 * 60_000
48// Shell commands that run until stopped: dev servers, watchers, log tails.
49const ENDLESS_COMMANDS = [
50  /\b(npm|pnpm|yarn|bun)\s+(run\s+)?(dev|serve|start|watch|preview)\b/,
51  /\b(next|nuxt|astro|remix|ng|webpack|hugo|jekyll|vue-cli-service|wrangler)\s+(dev|serve|server)\b/,
52  /\bvite\b(?!\s+(build|optimize))/,
53  /\b(nodemon|live-server|http-server|uvicorn|gunicorn|hypercorn|streamlit)\b/,
54  /\bhttp\.server\b|\bflask\s+run\b|\brunserver\b|\brails\s+s(erver)?\b|\bphp\s+-S\b/,
55  /--watch\b|\b(cargo|dotnet)\s+watch\b|\binotifywait\b.*\s-m\b/,
56  /\btail\s+(-\w+\s+)*-\w*[fF]\b|\bGet-Content\b.*\s-Wait\b/i,
57  /\bdocker(-compose|\s+compose)\s+up\b(?!.*\s(-d|--detach)\b)/,
58]
59// A bare forever loop runs until stopped; one that breaks or exits on its
60// own (a poll, a watch waiting for a line, as a monitor's script is) ends.
61const FOREVER_LOOP = /\bwhile\s+(true|:)\s*[;\n]/
62const ENDS_ITSELF = /\b(break|exit)\b/
63const isEndless = (command: string) =>
64  ENDLESS_COMMANDS.some(pattern => pattern.test(command)) ||
65  (FOREVER_LOOP.test(command) && !ENDS_ITSELF.test(command))
66// Tools that wait on the person: their whole run is waiting.
67const ASKING_TOOLS = ['AskUserQuestion', 'ExitPlanMode']
68// The hover card: how soon after an event it refreshes, how often on its own
69// (the usage limits move without events), and how often it rereads where
70// auto-compact starts. Replies kept for the context growth rate.
71const REFRESH_DELAY_MS = 500
72const REFRESH_EVERY_MS = 30_000
73const COMPACT_CHECK_MS = 5 * 60_000
74const GROWTH_REPLIES = 6
75// `$.store`: whether a new session's mascot starts shown (the last choice
76// made), and the character each project's sessions show.
77const OVERLAY_KEY = 'isOverlayOn'
78const CHARACTERS_KEY = 'characters'
79const DEFAULT_CHARACTER = 'miku'
80// How long a restart waits for the old overlay to go before starting anyway.
81const STOP_WAIT_MS = 3000
82// Updates. A version newer than the last one a session ran (`$.store`) gets
83// her banner once, however it came. Looking for a newer release online is
84// the `checkUpdates` setting's, off by default: then, once a day, a read of
85// the manifest on GitHub's main branch, and nothing sent.
86const LATEST_URL = 'https://raw.githubusercontent.com/desuqcafe/cc-mascot/main/mascot/.claude-plugin/plugin.json'
87const CHECK_EVERY_MS = 24 * 60 * 60_000
88const VERSION_KEY = 'lastVersion'
89const LATEST_KEY = 'latest'
90// `$.store`: the last update, { from, to }: what is new counts from `from`
91// while `to` runs, in every session (not only the one that saw it first).
92const UPGRADE_KEY = 'upgrade'
93const UPDATE_TIMEOUT_MS = 5 * 60_000
94// How long she stays happy under her banner.
95const CELEBRATE_MS = 3600
96
97const mood = atom({ plugin: 'mascot', key: 'mood' } as const, {
98  frame: 'idle',
99  holdUntil: 0,
100  then: 'idle',
101} as MascotMood)
102
103const work = atom({ plugin: 'mascot', key: 'work' } as const, {
104  inTurn: false,
105  agents: [],
106  hasBackground: false,
107  waitUntil: 0,
108  isSettled: true,
109} as MascotWork)
110
111// The settings, every session's: `settings.json` beside all.json, written
112// by /mascot (and the settings window, and by hand), followed live by every
113// overlay. overlay/settings.py holds the same rules: a key left out, or a
114// value that is not a valid one, is its default.
115type Sounds = Record<MascotMoment, boolean | string>
116type Settings = Required<{ [K in Exclude<keyof MascotSettings, 'sounds'>]: Exclude<MascotSettings[K], undefined> }> & {
117  sounds: Sounds
118}
119// The moments she has a sound for, and which of them sound once `sound` is
120// on: the ones that tell you something. Coming and going only when asked for.
121const MOMENTS: MascotMoment[] = ['waiting', 'done', 'beam', 'error', 'magic', 'intro', 'outro']
122const SOUNDS: Sounds = { waiting: true, done: true, beam: true, error: true, magic: false, intro: false, outro: false }
123// A file of yours: a bare name, looked for in the mascot folder's sounds/
124// and nowhere else; .wav or .mp3. settings.py checks it by the same pattern.
125const SOUND_FILE = /^[^\\/:*?"<>|\x00-\x1f]{1,120}\.(wav|mp3)$/i
126const DEFAULTS: Settings = {
127  size: 420,
128  calm: false,
129  smooth: false,
130  aura: [300_000, 400_000, 500_000],
131  beamAfter: 2,
132  beamForAgents: true,
133  magicAfter: false,
134  checkUpdates: false,
135  sound: false,
136  volume: 60,
137  sounds: SOUNDS,
138  waitingAfter: 30,
139  nudge: false,
140  remote: false,
141  away: false,
142}
143const SIZE_RANGE = [240, 640] as const
144const SIZES: Record<string, number> = { small: 300, normal: 420, large: 560 }
145const AURA_RANGE = [10_000, 10_000_000] as const
146const BEAM_RANGE = [1, 120] as const
147const MAGIC_RANGE = [0, 120] as const
148const VOLUME_RANGE = [0, 100] as const
149const WAITING_RANGE = [10, 300] as const
150
151const inRange = (v: unknown, [low, high]: readonly [number, number]): v is number =>
152  typeof v === 'number' && Number.isFinite(v) && v >= low && v <= high
153
154// A moment's sound as `sounds` takes it, or undefined when not a valid one.
155const soundChoice = (v: unknown): boolean | string | undefined =>
156  typeof v === 'boolean' ? v : typeof v === 'string' && v.trim() === v && SOUND_FILE.test(v) ? v : undefined
157
158// A checked value as the file holds it: `sounds` keeps only the moments
159// that differ from their defaults.
160const written = <K extends keyof Settings>(key: K, value: Settings[K]): unknown =>
161  key === 'sounds'
162    ? Object.fromEntries(Object.entries(value as Sounds).filter(([m, v]) => v !== SOUNDS[m as MascotMoment]))
163    : value
164
165const checks: { [K in keyof Settings]: (v: unknown) => Settings[K] | undefined } = {
166  size: v => (inRange(v, SIZE_RANGE) ? Math.round(v) : undefined),
167  calm: v => (typeof v === 'boolean' ? v : undefined),
168  smooth: v => (typeof v === 'boolean' ? v : undefined),
169  aura: v => {
170    if (v === false) return false
171    if (!Array.isArray(v) || v.length !== 3 || !v.every(t => inRange(t, AURA_RANGE))) return undefined
172    const [a, b, c] = v.map(t => Math.round(t as number)) as [number, number, number]
173    return a < b && b < c ? [a, b, c] : undefined
174  },
175  beamAfter: v => (v === false ? false : inRange(v, BEAM_RANGE) ? Math.round(v) : undefined),
176  beamForAgents: v => (typeof v === 'boolean' ? v : undefined),
177  magicAfter: v => (v === false ? false : inRange(v, MAGIC_RANGE) ? Math.round(v) : undefined),
178  checkUpdates: v => (typeof v === 'boolean' ? v : undefined),
179  sound: v => (typeof v === 'boolean' ? v : undefined),
180  volume: v => (inRange(v, VOLUME_RANGE) ? Math.round(v) : undefined),
181  // Every moment, each on its own: one not valid is its default alone.
182  sounds: v => {
183    if (!v || typeof v !== 'object' || Array.isArray(v)) return undefined
184    const given = v as Record<string, unknown>
185    return Object.fromEntries(MOMENTS.map(m => [m, soundChoice(given[m]) ?? SOUNDS[m]])) as Sounds
186  },
187  waitingAfter: v => (inRange(v, WAITING_RANGE) ? Math.round(v) : undefined),
188  nudge: v => (typeof v === 'boolean' ? v : undefined),
189  remote: v => (typeof v === 'boolean' ? v : undefined),
190  away: v => (typeof v === 'boolean' ? v : undefined),
191}
192
193const viewState = atom({ plugin: 'mascot', key: 'view' } as const, { visible: false, at: 0 } as MascotVisibility)
194const keyState = atom({ plugin: 'mascot', key: 'sessionKey' } as const, '')
195const characterState = atom({ plugin: 'mascot', key: 'character' } as const, '')
196const ranState = atom({ plugin: 'mascot', key: 'ran' } as const, '')
197
198// The overlay (overlay/mascot_overlay.py) is a desktop window of its own that
199// watches this session's file, which this module writes. It runs as long as
200// this module does, shown or hidden; unloading the module ends it. On Windows
201// the spawn passes through cmd.exe: the overlay looks past it for its Claude
202// Code, whose end is the session's.
203let overlay: { stop: () => Promise<void>; isEnding: boolean } | undefined
204// This session's file is named once, by the session id at its first load: a
205// /clear changes the id, not the mascot. Kept in `keyState` across reloads.
206let sessionKey: string | undefined
207// Shown or hidden, and when that was chosen; kept in `viewState`.
208let view: MascotVisibility = { visible: false, at: 0 }
209// The session is over: its last write said so, and nothing writes after it.
210let isEnded = false
211// When the conversation was last cleared (/clear, /resume): the overlay
212// changes channel when this changes.
213let clearedAt: number | undefined
214// Her last call to the pointer: the overlay sends one when this changes.
215let call: MascotCall | undefined
216// The last moment only this module knows (a round done, the beam, a turn
217// that died): the overlay plays its sound when this changes, as the
218// settings say. It finds the other moments itself.
219let cue: MascotCue | undefined
220// The last prompt that came from elsewhere (Remote Control, a channel): the
221// overlay shows it come in when the `remote` setting says so.
222let visit: MascotVisit | undefined
223// The character the overlay shows: a new one (/mascot character) makes it
224// play its outro, take on the new art and play its intro, on the same spot.
225let characterNow: string | undefined
226// A character picked for this session alone (the settings window, with
227// "Remember for this project" off); kept in `characterState`.
228let sessionCharacter: string | undefined
229// Her version and its updates, as the session file carries them; and how
230// this copy updates (`routeOf`).
231let release: MascotUpdate | undefined
232// The version this conversation ran last; kept in `ranState`.
233let ranVersion: string | undefined
234let updater: Route = { route: 'manual' }
235let isChecking = false
236let isUpdating = false
237
238// Permission dialogs shown, numbered, by tool: a call that failed after one
239// was shown for its tool was most likely declined, not broken.
240let askCount = 0
241const asks: { n: number; tool: string }[] = []
242// Calls the auto-mode classifier refused, by tool_use_id.
243const denied = new Set<string>()
244// Dozes off once idle has lasted SLEEP_MS.
245let sleepTimer: Timer | undefined
246// The background work (tasks and wakeups, by id) listed at the last Stop.
247let inFlight: string[] = []
248// The round of work under way: when it started, whether agents helped, the
249// background work already running then (none of it holds this round open),
250// and when each piece started since was first listed.
251type Round = { since?: number; hadAgents: boolean; before: Set<string>; seen: Map<string, number> }
252const newRound = (since?: number): Round => ({ since, hadAgents: false, before: new Set(inFlight), seen: new Map() })
253let round = newRound()
254
255// What the overlay's hover card shows, written beside the mood. All of it is
256// this one session's, so a mascot per session can show its own.
257type CardInfo = {
258  sessionId?: string
259  project?: string
260  branch?: string
261  model?: string
262  startedAt?: number
263  prompts?: number
264  context?: { tokens?: number; window: number; percent?: number; perTurn?: number; turnsToCompact?: number }
265  limits: { kind: string; percent: number; resetsAt?: string }[]
266  tool?: string
267  turnSince?: number
268  subagents: { type: string; tokens?: number; percent?: number }[]
269  background: Record<string, number>
270  // A newer release the daily check found, and a newer version installed
271  // than the one running (an update run from another session).
272  newVersion?: string
273  installed?: string
274}
275
276let frameNow: MascotFrame = 'idle'
277let card: CardInfo | undefined
278const live = {
279  tool: undefined as string | undefined,
280  turnSince: undefined as number | undefined,
281  subagents: new Map<string, { type: string; tokens?: number; model?: string }>(),
282  background: {} as Record<string, number>,
283  // The main loop's model as the API names it: subagents on it share its window.
284  mainModel: undefined as string | undefined,
285  // Context tokens after each of the last few main replies, for the growth rate.
286  history: [] as number[],
287  compactAt: undefined as number | undefined,
288  compactCheckedAt: 0,
289}
290let isRefreshPending = false
291
292// Where every session's mascot keeps its files (mascot_overlay.py has the layout).
293const stateDir = async ($: EngineInterface) => {
294  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
295  return home ? `${home}/.claude/mascot` : undefined
296}
297
298const settingsFile = async ($: EngineInterface) => {
299  const dir = await stateDir($)
300  return dir ? `${dir}/settings.json` : undefined
301}
302
303// The file's object as written (unchecked); {} when there is none.
304const readRawSettings = async ($: EngineInterface): Promise<Record<string, unknown>> => {
305  const path = await settingsFile($)
306  try {
307    const data: unknown = path ? JSON.parse(await $.fs.read(path)) : undefined
308    return data && typeof data === 'object' && !Array.isArray(data) ? (data as Record<string, unknown>) : {}
309  } catch {
310    return {}
311  }
312}
313
314const loadSettings = async ($: EngineInterface): Promise<Settings> => {
315  const raw = await readRawSettings($)
316  const pick = <K extends keyof Settings>(key: K): Settings[K] =>
317    (key in raw ? checks[key](raw[key]) : undefined) ?? DEFAULTS[key]
318  return {
319    size: pick('size'),
320    calm: pick('calm'),
321    smooth: pick('smooth'),
322    aura: pick('aura'),
323    beamAfter: pick('beamAfter'),
324    beamForAgents: pick('beamForAgents'),
325    magicAfter: pick('magicAfter'),
326    checkUpdates: pick('checkUpdates'),
327    sound: pick('sound'),
328    volume: pick('volume'),
329    sounds: pick('sounds'),
330    waitingAfter: pick('waitingAfter'),
331    nudge: pick('nudge'),
332    remote: pick('remote'),
333    away: pick('away'),
334  }
335}
336
337// Sets keys, keeping whatever else the file holds; a default value (or
338// null) takes its key out, so the file holds only what was changed.
339const saveSettings = async ($: EngineInterface, changes: { [K in keyof Settings]?: Settings[K] | null }) => {
340  const path = await settingsFile($)
341  if (!path) throw new Error('Mascot: no home folder to keep its settings in.')
342  const raw = await readRawSettings($)
343  for (const key of Object.keys(changes) as (keyof Settings)[]) {
344    const value = changes[key] === null ? undefined : checks[key](changes[key])
345    if (value === undefined || JSON.stringify(value) === JSON.stringify(DEFAULTS[key])) delete raw[key]
346    else raw[key] = written(key, value)
347  }
348  await $.fs.write(path, JSON.stringify(raw, null, 2))
349}
350
351const sessionFile = async ($: EngineInterface) => {
352  const dir = await stateDir($)
353  if (!dir) return undefined
354  const kept = await read($, keyState).catch(() => '')
355  if (!kept) {
356    // `$.state` is the conversation's: a /clear starts it empty, and only this
357    // module still knows the key. Handed on, so the next reload finds it, and
358    // the choice to show or hide with it.
359    if (sessionKey && view.at) await update($, viewState, () => view).catch(() => {})
360    if (sessionCharacter) await update($, characterState, () => sessionCharacter!).catch(() => {})
361    if (ranVersion) await update($, ranState, () => ranVersion!).catch(() => {})
362    sessionKey ??= await $.session.id()
363    await update($, keyState, () => sessionKey!).catch(() => {})
364  }
365  sessionKey ??= kept
366  return `${dir}/sessions/${sessionKey}.json`
367}
368
369const writeFile = async ($: EngineInterface) => {
370  if (isEnded) return
371  try {
372    const path = await sessionFile($)
373    const file: MascotSessionFile = {
374      frame: frameNow,
375      info: card,
376      visible: view.visible,
377      visibleAt: view.at,
378      cleared: clearedAt,
379      character: characterNow,
380      update: release,
381      call,
382      cue,
383      visit,
384    }
385    if (path) await $.fs.write(path, JSON.stringify(file))
386  } catch {
387    // The overlay keeps what it last read.
388  }
389}
390
391const branchOf = async ($: EngineInterface, root: string) => {
392  const head = (await $.fs.read(`${root}/.git/HEAD`)).trim()
393  return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : head.slice(0, 7)
394}
395
396const quiet = <T,>(p: Promise<T>) => p.catch(() => undefined)
397
398const projectOf = (root: string) => root.split(/[\\/]/).filter(Boolean).pop()
399
400// Gathers the card's figures and writes them. Each is optional: one the
401// engine will not give leaves the rest standing.
402const refreshCard = async ($: EngineInterface) => {
403  try {
404    const now = await $.clock.now()
405    if (now - live.compactCheckedAt > COMPACT_CHECK_MS) {
406      live.compactCheckedAt = now
407      // `summary` estimates locally: no token-count requests.
408      const full = await quiet($.session.usage({ breakdown: 'summary' }))
409      live.compactAt = full?.context.breakdown?.autoCompactThreshold ?? live.compactAt
410    }
411    const [usage, model, prompts, root, sessionId] = await Promise.all([
412      quiet($.session.usage()),
413      quiet($.session.model()),
414      quiet($.session.turns()),
415      quiet($.session.root()),
416      quiet($.session.id()),
417    ])
418    const branch = root ? await quiet(branchOf($, root)) : undefined
419    const window = usage?.context.window
420    const tokens = usage?.context.tokens
421    const first = live.history[0]
422    const last = live.history[live.history.length - 1]
423    const perTurn =
424      first !== undefined && last !== undefined && last > first
425        ? Math.round((last - first) / (live.history.length - 1))
426        : undefined
427    card = {
428      sessionId,
429      project: root ? projectOf(root) : undefined,
430      branch,
431      model,
432      startedAt: usage?.startedAt,
433      prompts,
434      context: window
435        ? {
436            tokens,
437            window,
438            percent: usage?.context.percent,
439            perTurn,
440            turnsToCompact:
441              perTurn && tokens !== undefined && live.compactAt
442                ? Math.max(0, Math.ceil((live.compactAt - tokens) / perTurn))
443                : undefined,
444          }
445        : undefined,
446      limits: (usage?.rateLimits ?? []).map(l => ({ kind: l.kind, percent: l.percentUsed, resetsAt: l.resetsAt })),
447      tool: live.tool,
448      turnSince: live.turnSince,
449      subagents: [...live.subagents.values()].map(a => ({
450        type: a.type,
451        tokens: a.tokens,
452        // Only a subagent on the main loop's model shares its window.
453        percent:
454          a.tokens !== undefined && window && a.model !== undefined && a.model === live.mainModel
455            ? Math.round((a.tokens / window) * 100)
456            : undefined,
457      })),
458      background: live.background,
459      newVersion: release?.latest,
460      installed: release?.installed,
461    }
462    await writeFile($)
463  } catch {
464    // A mascot never gets in the way of the session.
465  }
466}
467
468// Refreshes the card shortly, once for a burst of events.
469const refreshSoon = ($: EngineInterface) => {
470  if (isRefreshPending) return
471  isRefreshPending = true
472  $.clock.after(REFRESH_DELAY_MS, () => {
473    isRefreshPending = false
474    void refreshCard($)
475  })
476}
477
478const publish = async ($: EngineInterface, frame: MascotFrame) => {
479  frameNow = frame
480  await writeFile($)
481  sleepTimer?.cancel()
482  sleepTimer = frame === 'idle' ? $.clock.after(SLEEP_MS, () => void doze($)) : undefined
483}
484
485const doze = async ($: EngineInterface) => {
486  try {
487    const cur = await update($, mood, (cur): MascotMood =>
488      cur.frame === 'idle' && cur.holdUntil === 0 ? { frame: 'sleepy', holdUntil: 0, then: 'sleepy' } : cur,
489    )
490    if (cur.frame === 'sleepy') await publish($, 'sleepy')
491  } catch {
492    // A mascot never gets in the way of the session.
493  }
494}
495
496// This session's character: its own pick, else the project's, when its art
497// is there; else the default.
498const characterFor = async ($: EngineInterface) => {
499  sessionCharacter ??= (await read($, characterState).catch(() => '')) || undefined
500  const root = await quiet($.session.root())
501  const chosen = (await quiet($.store.get(CHARACTERS_KEY))) as Record<string, string> | undefined
502  for (const name of [sessionCharacter, root ? chosen?.[projectOf(root) ?? ''] : undefined]) {
503    if (name && (await quiet($.fs.exists(`${$.plugin.root}/frames/${name}`)))) return name
504  }
505  return DEFAULT_CHARACTER
506}
507
508// What the character calls her beam (her theme's `beam`).
509const beamName = async ($: EngineInterface) => {
510  try {
511    const theme = JSON.parse(await $.fs.read(`${$.plugin.root}/frames/${await characterFor($)}/theme.json`))
512    if (typeof theme?.beam === 'string' && theme.beam.trim()) return theme.beam.trim()
513  } catch {
514    // Miku's, below.
515  }
516  return 'Miku Miku Beam'
517}
518
519// Characters with art: the folders under frames/.
520const characters = async ($: EngineInterface) =>
521  ((await quiet($.fs.list(`${$.plugin.root}/frames`))) ?? []).filter(e => e.kind === 'dir').map(e => e.name).sort()
522
523// A program by its full path. Looked up by name, a pythonw under the
524// session's folder is refused as unsafe, and a session started in the home
525// folder holds the Store's (AppData\Local\Microsoft\WindowsApps). `where`
526// searches PATH alone ($PATH:), not the current folder, so a repo still cannot
527// plant one. An .exe before a script (npm's claude.cmd), and by name where
528// there is no `where`.
529const paths = new Map<string, string>()
530const fullPath = async ($: EngineInterface, name: string) => {
531  const known = paths.get(name)
532  if (known) return known
533  const found = await quiet($.process.run(['where', `$PATH:${name}`]))
534  const lines = found?.exitCode === 0 ? found.stdout.split(/\r?\n/).map(l => l.trim()).filter(Boolean) : []
535  const path = lines.find(l => /\.exe$/i.test(l)) ?? lines[0] ?? name
536  paths.set(name, path)
537  return path
538}
539
540const pythonw = ($: EngineInterface) => fullPath($, 'pythonw')
541
542// A command's argv: a script (.cmd, .bat) runs through cmd.exe.
543const commandLine = async ($: EngineInterface, name: string, ...args: string[]) => {
544  const path = await fullPath($, name)
545  return /\.(cmd|bat)$/i.test(path) ? ['cmd.exe', '/d', '/c', path, ...args] : [path, ...args]
546}
547
548// ---- updates
549
550type Route = { route: MascotUpdate['route']; marketplace?: string; repo?: string }
551
552// Versions compare part by part, as numbers: 0.14.10 is newer than 0.14.9.
553const isVersion = (v: unknown): v is string => typeof v === 'string' && /^\d+(\.\d+){1,3}$/.test(v)
554const isNewer = (a: string, b: string) => {
555  const pa = a.split('.').map(Number)
556  const pb = b.split('.').map(Number)
557  for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
558    if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0)
559  }
560  return false
561}
562
563const versionIn = (manifest: string) => {
564  const data = JSON.parse(manifest) as { version?: unknown } | null
565  return isVersion(data?.version) ? data.version : undefined
566}
567
568// How this copy updates: a marketplace install lives in Claude Code's plugin
569// cache (`plugins/cache/<marketplace>/<plugin>/<version>`), a clone in a git
570// work tree (the folder holding this one); else by hand.
571const routeOf = async ($: EngineInterface): Promise<Route> => {
572  const parts = $.plugin.root.split(/[\\/]/).filter(Boolean)
573  const at = parts.lastIndexOf('cache')
574  if (at > 0 && parts[at - 1] === 'plugins' && parts[at + 1]) return { route: 'marketplace', marketplace: parts[at + 1] }
575  const repo = $.plugin.root.replace(/[\\/][^\\/]+[\\/]?$/, '')
576  if (repo && (await quiet($.fs.exists(`${repo}/.git`)))) return { route: 'clone', repo }
577  return { route: 'manual' }
578}
579
580// What's new in each version, shipped with the mod (whatsnew.json:
581// { "0.17.0": ["a line", { "text": "a line", "kind": "fix" }] }): a line is
582// news unless marked a fix. The settings window reads it by the same rules
583// (`notes_between` in overlay/settings_window.py): keep them in step.
584type News = { version: string; text: string; isFix: boolean }
585
586const newsOf = (version: string, line: unknown): News[] => {
587  if (typeof line === 'string') return line.trim() ? [{ version, text: line, isFix: false }] : []
588  if (!line || typeof line !== 'object') return []
589  const { text, kind } = line as { text?: unknown; kind?: unknown }
590  return typeof text === 'string' && text.trim() ? [{ version, text, isFix: kind === 'fix' }] : []
591}
592
593// The lines of the versions after `from` (every one without), up to `to`,
594// newest first.
595const notesBetween = async ($: EngineInterface, from: string | undefined, to: string): Promise<News[]> => {
596  try {
597    const all = JSON.parse(await $.fs.read(`${$.plugin.root}/whatsnew.json`)) as Record<string, unknown>
598    return Object.keys(all)
599      .filter(v => isVersion(v) && (from === undefined || isNewer(v, from)) && !isNewer(v, to))
600      .sort((a, b) => (isNewer(a, b) ? -1 : 1))
601      .flatMap(v => (Array.isArray(all[v]) ? (all[v] as unknown[]) : []).flatMap(line => newsOf(v, line)))
602  } catch {
603    return []
604  }
605}
606
607// The line to lead with: the newest that is not a fix, else the newest.
608const headline = (news: News[]) => news.find(n => !n.isFix) ?? news[0]
609
610// /mascot news: what is new since the version before (else in this one), or
611// in `every` version, by version, newest first.
612const newsText = async ($: EngineInterface, every: boolean) => {
613  if (!release) return 'Mascot: its version is unknown.'
614  const { version, from } = release
615  const all = await notesBetween($, every ? undefined : from, version)
616  const news = every || from ? all : all.filter(n => n.version === version)
617  if (news.length === 0) return `Nothing written down for v${version}. /mascot news all lists every version.`
618  const title = every ? 'Every version, newest first' : from ? `New since v${from}` : `New in v${version}`
619  const lines: string[] = [`${title}:`]
620  for (const [i, n] of news.entries()) {
621    if (n.version !== news[i - 1]?.version) lines.push(`v${n.version}`)
622    lines.push(`  - ${n.isFix ? 'Fix: ' : ''}${n.text}`)
623  }
624  if (!every) lines.push('/mascot news all lists every version.')
625  return lines.join('\n')
626}
627
628const setRelease = async ($: EngineInterface, changes: Partial<MascotUpdate>) => {
629  if (!release) return
630  release = { ...release, ...changes }
631  if (card) card = { ...card, newVersion: release.latest, installed: release.installed }
632  await writeFile($)
633}
634
635// Her version, and whether it is newer than the last one run: then her
636// banner (the overlay plays it once), a happy moment and what's new.
637const startRelease = async ($: EngineInterface) => {
638  try {
639    const version = versionIn(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
640    if (!version) return
641    updater = await routeOf($)
642    const last = await quiet($.store.get(VERSION_KEY))
643    // The version this conversation ran before a reload: newer is new to
644    // this session too, though another session has run it already.
645    ranVersion ??= (await read($, ranState).catch(() => '')) || undefined
646    const ran = ranVersion
647    const seen = (await quiet($.store.get(LATEST_KEY))) as { version?: unknown } | undefined
648    const latest = isVersion(seen?.version) && isNewer(seen.version, version) ? seen.version : undefined
649    release = { version, route: updater.route, latest }
650    const upgrade = (await quiet($.store.get(UPGRADE_KEY))) as { from?: unknown; to?: unknown } | undefined
651    if (upgrade?.to === version && isVersion(upgrade.from)) release.from = upgrade.from
652    if (!isVersion(last) || isNewer(version, last)) await $.store.set(VERSION_KEY, version)
653    ranVersion = version
654    await update($, ranState, () => version).catch(() => {})
655    const isNewEverywhere = isVersion(last) && isNewer(version, last)
656    if (isNewEverywhere || (isVersion(ran) && isNewer(version, ran))) {
657      release.celebrate = await $.clock.now()
658      if (isNewEverywhere) {
659        release.from = last
660        await $.store.set(UPGRADE_KEY, { from: last, to: version })
661      }
662      release.from ??= ran
663      // One line, the one worth telling, and how to read the rest.
664      const news = await notesBetween($, release.from, version)
665      const lead = headline(news)
666      const more = news.length - (lead ? 1 : 0)
667      $.ui.toast(
668        `Mascot updated to v${version}${lead ? `: ${lead.text}` : ''}${more > 0 ? ` (+${more} more: /mascot news)` : ''}`,
669      )
670      const { frame } = await read($, mood)
671      if (frame === 'idle' || frame === 'sleepy') await show($, 'happy', { holdMs: CELEBRATE_MS, after: 'idle' })
672    }
673    await writeFile($)
674    void checkForUpdates($)
675  } catch {
676    // A mascot never gets in the way of the session.
677  }
678}
679
680// The version installed now, which may be newer than the one running: an
681// update run from another session. A marketplace install reads Claude
682// Code's list of installed plugins, beside its cache; a clone or a folder,
683// its manifest on disk.
684const installedVersion = async ($: EngineInterface): Promise<string | undefined> => {
685  if (updater.route !== 'marketplace') {
686    return versionIn(await $.fs.read(`${$.plugin.root}/.claude-plugin/plugin.json`))
687  }
688  const parts = $.plugin.root.split(/[\\/]/)
689  const at = parts.lastIndexOf('cache')
690  const listed = JSON.parse(await $.fs.read(`${parts.slice(0, at).join('/')}/installed_plugins.json`)) as {
691    plugins?: Record<string, { version?: unknown }[]>
692  } | null
693  const entries = listed?.plugins?.[`${$.plugin.name}@${updater.marketplace}`]
694  const versions = (Array.isArray(entries) ? entries : []).map(e => e?.version).filter(isVersion)
695  return versions.sort((a, b) => (isNewer(a, b) ? -1 : 1))[0]
696}
697
698// Every 30 s: a newer version installed meanwhile (an update run from
699// another session) is told here once, and on her card and in the settings
700// window until this session reloads.
701const checkInstalled = async ($: EngineInterface) => {
702  if (!release || isUpdating) return
703  try {
704    const installed = await installedVersion($)
705    const newer = installed && isNewer(installed, release.version) ? installed : undefined
706    if (newer === release.installed) return
707    // The session that updated has said so already.
708    if (newer && release.state !== 'updated') {
709      $.ui.toast(`Mascot v${newer} is installed: type /reload-plugins to meet her (this session runs v${release.version}).`)
710    }
711    await setRelease($, { installed: newer })
712  } catch {
713    // Unreadable now: the next look will do.
714  }
715}
716
717// Reads the newest release's version, once a day while `checkUpdates` is on
718// (`force`: now, asked for), and keeps what it found for every session.
719const checkForUpdates = async ($: EngineInterface, force = false) => {
720  if (!release || isChecking) return
721  isChecking = true
722  try {
723    if (!force && !(await loadSettings($)).checkUpdates) return
724    const now = await $.clock.now()
725    const seen = (await quiet($.store.get(LATEST_KEY))) as { at?: unknown; version?: unknown } | undefined
726    let latest = isVersion(seen?.version) ? seen.version : undefined
727    if (force || typeof seen?.at !== 'number' || now - seen.at >= CHECK_EVERY_MS) {
728      const answer = await $.http.fetch(LATEST_URL)
729      latest = answer.ok ? versionIn(answer.text) : undefined
730      if (latest) await $.store.set(LATEST_KEY, { at: now, version: latest })
731    }
732    const newer = latest && isNewer(latest, release.version) ? latest : undefined
733    if (newer !== release.latest) await setRelease($, { latest: newer })
734  } catch {
735    // Offline, or the release unreadable: try again tomorrow.
736  } finally {
737    isChecking = false
738  }
739}
740
741// Updates this copy as it was installed: `claude plugin update` for a
742// marketplace install, `git pull --ff-only` for a clone. The new version
743// loads with /reload-plugins or the next session (a clone's files changing
744// may reload it at once); her banner greets it. Says how it went.
745const runUpdate = async ($: EngineInterface): Promise<string> => {
746  if (!release) return 'Mascot: its version is unknown, so it cannot update itself.'
747  if (isUpdating) return 'Mascot: already updating.'
748  if (updater.route === 'manual') {
749    return 'Mascot: this copy came from neither the marketplace nor a git clone; update it by hand (github.com/desuqcafe/cc-mascot).'
750  }
751  isUpdating = true
752  await setRelease($, { state: 'updating', message: undefined })
753  try {
754    if (updater.route === 'marketplace') {
755      // Its catalog first, so the update sees the newest release.
756      await quiet($.process.run(await commandLine($, 'claude', 'plugin', 'marketplace', 'update', updater.marketplace!), {
757        timeoutMs: UPDATE_TIMEOUT_MS,
758      }))
759    }
760    const argv =
761      updater.route === 'marketplace'
762        ? await commandLine($, 'claude', 'plugin', 'update', `${$.plugin.name}@${updater.marketplace}`)
763        : await commandLine($, 'git', '-C', updater.repo!, 'pull', '--ff-only')
764    const done = await $.process.run(argv, { timeoutMs: UPDATE_TIMEOUT_MS })
765    const said = (done.stdout + '\n' + done.stderr).trim().split(/\r?\n/).filter(l => l.trim())
766    if (done.exitCode !== 0) throw new Error(said.pop() ?? `exit code ${done.exitCode}`)
767    if (said.some(l => /already (up to date|at the latest)/i.test(l))) {
768      // The latest on disk, maybe not here: another session updated her.
769      const installed = await quiet(installedVersion($))
770      if (installed && isNewer(installed, release.version)) {
771        const message = `v${installed} is installed: type /reload-plugins to meet her.`
772        await setRelease($, { state: 'updated', message, installed, latest: undefined })
773        return `Mascot ${message} This session still runs v${release.version}.`
774      }
775      await setRelease($, { state: undefined, latest: undefined })
776      return `Mascot v${release.version} is the latest.`
777    }
778    const message =
779      updater.route === 'marketplace'
780        ? 'Type /reload-plugins to meet her new version (or start a new session).'
781        : 'Pulled. She reloads by herself; if not, type /reload-plugins.'
782    await setRelease($, { state: 'updated', message })
783    return `Mascot updated. ${message}`
784  } catch (err) {
785    const message = (err instanceof Error ? err.message : String(err)).trim()
786    await setRelease($, { state: 'failed', message })
787    return `Mascot update failed: ${message}`
788  } finally {
789    isUpdating = false
790  }
791}
792
793// Runs an update and says how it went in a toast: the prompt stays free.
794const updateInBackground = ($: EngineInterface) => {
795  void runUpdate($).then(text => $.ui.toast(text)).catch(() => {})
796}
797
798// Starts this session's overlay, which shows or hides itself as `view` says.
799// It reports on stdout what is chosen in its own window: `hidden` (right-click).
800const startOverlay = async ($: EngineInterface): Promise<string | undefined> => {
801  if (overlay || isEnded) return undefined
802  const path = await sessionFile($).catch(() => undefined)
803  if (!path) return 'Mascot overlay: no home folder to keep its files in.'
804  characterNow = await characterFor($)
805  await publish($, (await read($, mood)).frame)
806  const frames = `${$.plugin.root}/frames/${characterNow}`
807  const child = $.process.spawn({ argv: [await pythonw($), `${$.plugin.root}/overlay/mascot_overlay.py`, frames, path] })
808  let isStopping = false
809  let ended: () => void = () => {}
810  const exited = new Promise<void>(resolve => (ended = resolve))
811  const self = {
812    isEnding: false,
813    stop: async () => {
814      isStopping = true
815      if (overlay === self) overlay = undefined
816      void child.return(undefined as never)
817      await Promise.race([exited, new Promise<void>(resolve => $.clock.after(STOP_WAIT_MS, resolve))])
818    },
819  }
820  overlay = self
821  void (async () => {
822    let failure = ''
823    let pending = ''
824    try {
825      for await (const { stream, text } of child) {
826        if (stream === 'stderr') {
827          failure += text
828          continue
829        }
830        pending += text
831        const lines = pending.split('\n')
832        pending = lines.pop() ?? ''
833        for (const line of lines) if (line.trim() === 'hidden') await setVisible($, false)
834      }
835    } catch (err) {
836      failure = String(err)
837    }
838    if (overlay === self) overlay = undefined
839    ended()
840    if (isStopping || self.isEnding) return
841    // It could not start, or it broke; `/mascot show` starts it again.
842    const reason = failure.trim().split('\n').pop()
843    if (reason && view.visible) $.ui.toast(`Mascot overlay stopped: ${reason}`)
844  })()
845  return undefined
846}
847
848// Opens the settings window (overlay/settings_window.py), in the colors of
849// this session's character; one already open for this session comes forward
850// instead (one open for another hands over to this one). It edits
851// settings.json, which every overlay follows, and on its stdout picks this
852// session's character (`character NAME remember|session`), asks for a check
853// for a newer release (`check`, as its toggle turns the check on) or for the
854// update (`update`).
855const openSettingsWindow = async ($: EngineInterface): Promise<string | undefined> => {
856  const dir = await stateDir($)
857  const path = await sessionFile($).catch(() => undefined)
858  if (!dir || !path) return 'Mascot: no home folder to keep its settings in.'
859  const frames = `${$.plugin.root}/frames/${await characterFor($)}`
860  const root = await quiet($.session.root())
861  const project = (root && projectOf(root)) || ''
862  const child = $.process.spawn({
863    argv: [await pythonw($), `${$.plugin.root}/overlay/settings_window.py`, dir, frames, sessionKey!, project],
864  })
865  void (async () => {
866    let failure = ''
867    let pending = ''
868    try {
869      for await (const { stream, text } of child) {
870        if (stream === 'stderr') {
871          failure += text
872          continue
873        }
874        pending += text
875        const lines = pending.split('\n')
876        pending = lines.pop() ?? ''
877        for (const line of lines) {
878          const [verb, name = '', how] = line.trim().split(/\s+/)
879          if (verb === 'character' && (await characters($)).includes(name)) {
880            await setCharacter($, name, how !== 'session').catch(() => {})
881          } else if (verb === 'check') {
882            void checkForUpdates($, true)
883          } else if (verb === 'update') {
884            updateInBackground($)
885          } else if (verb === 'magic') {
886            await sendMagic($, true)
887          }
888        }
889      }
890    } catch (err) {
891      failure = String(err)
892    }
893    const reason = failure.trim().split('\n').pop()
894    if (reason) $.ui.toast(`Mascot settings window: ${reason}`)
895  })()
896  return undefined
897}
898
899// Shows or hides this session's mascot, and makes that the default for the
900// sessions that start next.
901const setVisible = async ($: EngineInterface, visible: boolean) => {
902  view = { visible, at: await $.clock.now() }
903  await update($, viewState, () => view).catch(() => {})
904  await writeFile($)
905  await $.store.set(OVERLAY_KEY, visible).catch(() => {})
906  return visible ? startOverlay($) : undefined
907}
908
909// Shows or hides every session's mascot: each overlay reads all.json beside
910// its own file and follows whichever was chosen last.
911const setAllVisible = async ($: EngineInterface, visible: boolean) => {
912  const dir = await stateDir($)
913  if (!dir) return 'Mascot overlay: no home folder to keep its files in.'
914  const all: MascotVisibility = { visible, at: await $.clock.now() }
915  await $.fs.write(`${dir}/all.json`, JSON.stringify(all))
916  return setVisible($, visible)
917}
918
919// Picks the character for this project's sessions (`remember`), or for this
920// session alone. This one's overlay takes it on where it stands (its outro,
921// then the new character's intro); other sessions of the project take a
922// remembered one when they next start.
923const setCharacter = async ($: EngineInterface, name: string, remember = true) => {
924  const root = await quiet($.session.root())
925  const project = root ? projectOf(root) : undefined
926  if (remember) {
927    if (!project) return 'Mascot: this session has no project folder to pick a character for.'
928    const chosen = ((await quiet($.store.get(CHARACTERS_KEY))) ?? {}) as Record<string, string>
929    await $.store.set(CHARACTERS_KEY, { ...chosen, [project]: name })
930  }
931  sessionCharacter = remember ? undefined : name
932  await update($, characterState, () => sessionCharacter ?? '').catch(() => {})
933  characterNow = name
934  await writeFile($)
935  const problem = await startOverlay($)
936  return problem ?? (remember ? `Mascot: ${project} now shows ${name}.` : `Mascot: this session shows ${name}.`)
937}
938
939// Shows `frame`. `force` replaces whatever is held; otherwise a held frame
940// stays up and `frame` follows when the hold ends. `holdMs` holds the new
941// frame, then moves to `after`.
942const show = async (
943  $: EngineInterface,
944  frame: MascotFrame,
945  opts: { force?: boolean; holdMs?: number; after?: MascotFrame } = {},
946) => {
947  try {
948    const now = await $.clock.now()
949    const next = await update($, mood, cur => {
950      if (opts.holdMs) return { frame, holdUntil: now + opts.holdMs, then: opts.after ?? 'idle' }
951      if (!opts.force && cur.holdUntil > now) return { ...cur, then: frame }
952      return { frame, holdUntil: 0, then: frame }
953    })
954    // Called at every step of the main loop and every tool call (subagents'
955    // too): a frame she already shows is not written again (the card's
956    // refresh keeps the file fresh).
957    if (next.frame !== frameNow) await publish($, next.frame)
958    if (opts.holdMs) {
959      const until = next.holdUntil
960      $.clock.after(opts.holdMs, () => {
961        void update($, mood, cur =>
962          cur.holdUntil === until ? { frame: cur.then, holdUntil: 0, then: cur.then } : cur,
963        )
964          .then(cur => publish($, cur.frame))
965          .catch(() => {})
966      })
967    }
968  } catch {
969    // A mascot never gets in the way of the session.
970  }
971}
972
973// Called whenever a round of work may have ended. Happy (the beam, for a
974// big round) only once the main turn is over and no subagent, background
975// agent or other work it waits on is left: an orchestrator that ends its
976// turn to wait on its agents or a build is not done yet.
977const settle = async ($: EngineInterface) => {
978  try {
979    const w = await read($, work)
980    if (w.inTurn) return
981    if (w.agents.length > 0 || w.hasBackground) {
982      await show($, 'working', { force: true })
983      return
984    }
985    if (w.isSettled) return
986    const now = await $.clock.now()
987    if (w.waitUntil > now) {
988      await show($, 'working', { force: true })
989      $.clock.after(w.waitUntil - now, () => void settle($))
990      return
991    }
992    await update($, work, cur => ({ ...cur, isSettled: true }))
993    if (w.waitUntil > 0) {
994      // What it waited on outlasted WAIT_CAP_MS: done or not, nobody knows.
995      round = newRound()
996      await show($, 'idle', { force: true })
997      return
998    }
999    const { beamAfter, beamForAgents, magicAfter } = await loadSettings($)
1000    const lasted = (minutes: number | false) =>
1001      minutes !== false && round.since !== undefined && now - round.since >= minutes * 60_000
1002    const isBig = (beamForAgents && round.hadAgents) || lasted(beamAfter)
1003    if (lasted(magicAfter)) call = { at: now }
1004    cue = { at: now, moment: isBig ? 'beam' : 'done' }
1005    round = newRound()
1006    await show($, isBig ? 'beam' : 'happy', { holdMs: isBig ? BEAM_MS : HOLD_MS, after: 'idle' })
1007  } catch {
1008    // A mascot never gets in the way of the session.
1009  }
1010}
1011
1012// Sends her call to the pointer now (/mascot magic, the settings window's
1013// button): a test, so the overlay sends it even over a fullscreen game.
1014const sendMagic = async ($: EngineInterface, test = false) => {
1015  call = { at: await $.clock.now(), ...(test ? { test: true as const } : {}) }
1016  await writeFile($)
1017}
1018
1019// The turn died on an error (an API error past its retries, a refusal).
1020// Both turn.complete and StopFailure may say so: one sound for the two.
1021const fail = async ($: EngineInterface) => {
1022  await update($, work, cur => ({ ...cur, inTurn: false, isSettled: true })).catch(() => {})
1023  const now = await quiet($.clock.now())
1024  if (now !== undefined && !(cue?.moment === 'error' && now - cue.at < HOLD_MS)) cue = { at: now, moment: 'error' }
1025  await show($, 'error', { holdMs: HOLD_MS, after: 'idle' })
1026}
1027
1028// 300000 as 300k, 1200000 as 1.2M.
1029const fmtTokens = (n: number) =>
1030  n >= 1_000_000 ? `${+(n / 1_000_000).toFixed(2)}M` : n >= 1000 ? `${+(n / 1000).toFixed(1)}k` : `${n}`
1031
1032// 300k, 1.2m or 300000 as a number of tokens.
1033const parseTokens = (text: string) => {
1034  const match = /^(\d+(?:\.\d+)?)([km]?)$/.exec(text)
1035  return match ? Number(match[1]) * (match[2] === 'm' ? 1_000_000 : match[2] === 'k' ? 1000 : 1) : NaN
1036}
1037
1038const describe = {
1039  size: (s: Settings) => {
1040    const name = Object.keys(SIZES).find(k => SIZES[k] === s.size)
1041    return `Size: ${s.size} px${name ? ` (${name})` : ''}.`
1042  },
1043  calm: (s: Settings) =>
1044    s.calm ? 'Calm mode on: no glitch, particles, flicker or flashes.' : 'Calm mode off.',
1045  smooth: (s: Settings) =>
1046    s.smooth
1047      ? `Smooth sparkles on: her sparkles move as smoothly as her symbols, using more CPU while she works${
1048          s.calm ? ' (calm mode is on, so she has no sparkles to smooth)' : ''
1049        }.`
1050      : 'Smooth sparkles off: her sparkles move in step with her drawn frames.',
1051  aura: (s: Settings) =>
1052    s.aura === false ? 'Aura off.' : `Aura from ${s.aura.map(fmtTokens).join(', ')} tokens of context.`,
1053  beamAfter: (s: Settings) =>
1054    s.beamAfter === false
1055      ? 'Long rounds of work do not end in the beam.'
1056      : `Rounds of work of ${s.beamAfter} min or more end in the beam.`,
1057  beamForAgents: (s: Settings) =>
1058    s.beamForAgents
1059      ? 'Rounds with subagents or background agents end in the beam.'
1060      : 'Rounds with subagents or background agents end in the beam only when long.',
1061  magicAfter: (s: Settings) =>
1062    s.magicAfter === false
1063      ? 'No cursor magic.'
1064      : `Rounds of work of ${s.magicAfter} min or more send magic to your pointer.`,
1065  checkUpdates: (s: Settings) =>
1066    s.checkUpdates ? 'Looks for a new version once a day.' : 'Never goes online to look for a new version.',
1067  sound: (s: Settings) => (s.sound ? 'Sound on.' : 'Sound off.'),
1068  volume: (s: Settings) => `Volume ${s.volume}%.`,
1069  sounds: (s: Settings) => {
1070    const own = MOMENTS.filter(m => s.sounds[m] === true)
1071    const none = MOMENTS.filter(m => s.sounds[m] === false)
1072    const files = MOMENTS.filter(m => typeof s.sounds[m] === 'string').map(m => `${m}: ${s.sounds[m]}`)
1073    const parts = [
1074      own.length ? `${own.join(', ')} her own` : '',
1075      ...files,
1076      none.length ? `${none.join(', ')} none` : '',
1077    ].filter(Boolean)
1078    return `Sounds: ${parts.join('; ')}.`
1079  },
1080  waitingAfter: (s: Settings) => `Her waiting sound after ${s.waitingAfter} s of waiting on you.`,
1081  nudge: (s: Settings) =>
1082    s.nudge
1083      ? `Call me on: after ${s.waitingAfter} s of waiting on you, she calls and her terminal blinks in the taskbar; click her to bring it forward.`
1084      : 'Call me off.',
1085  remote: (s: Settings) =>
1086    s.remote ? 'Remote on: she shows prompts sent from your phone, the web or a chat.' : 'Remote off.',
1087  away: (s: Settings) =>
1088    s.away ? 'Away notes on: back at your PC, she holds a note of what happened.' : 'Away notes off.',
1089}
1090
1091const settingsSummary = (s: Settings) => Object.values(describe).map(line => line(s)).join(' ')
1092
1093// `/mascot <verb> ...` for a setting: its value with nothing after the verb;
1094// undefined when the words are not a settings command at all.
1095const settingsCommand = async ($: EngineInterface, words: string[], raw: string[] = words): Promise<string | undefined> => {
1096  const [verb, ...rest] = words
1097  const set = async <K extends keyof Settings>(key: K, value: Settings[K] | null) => {
1098    await saveSettings($, { [key]: value })
1099    return describe[key](await loadSettings($))
1100  }
1101  const now = async (key: keyof Settings) => describe[key](await loadSettings($))
1102  const [value, extra] = rest
1103  if (verb === 'size' && !extra) {
1104    if (!value) return `${await now('size')} /mascot size small, normal, large or 240 to 640 (px).`
1105    if (value === 'default') return set('size', null)
1106    const px = SIZES[value] ?? Number(value.replace(/px$/, ''))
1107    if (!checks.size(px)) return 'Size takes small, normal, large, default or 240 to 640 (px).'
1108    return set('size', px)
1109  }
1110  if (verb === 'calm' && !extra) {
1111    if (!value) return `${await now('calm')} /mascot calm on|off.`
1112    if (value !== 'on' && value !== 'off') return 'Calm takes on or off.'
1113    return set('calm', value === 'on')
1114  }
1115  if (verb === 'smooth' && !extra) {
1116    if (!value) return `${await now('smooth')} /mascot smooth on|off.`
1117    if (value !== 'on' && value !== 'off') return 'Smooth takes on or off.'
1118    return set('smooth', value === 'on')
1119  }
1120  if (verb === 'aura') {
1121    if (!value) return `${await now('aura')} /mascot aura <3 token counts, as 300k 400k 500k>|off|default.`
1122    if (rest.length === 1 && (value === 'off' || value === 'default')) return set('aura', value === 'off' ? false : null)
1123    const tiers = checks.aura(rest.map(parseTokens))
1124    if (!tiers) return 'Aura takes three token counts going up (as 300k 400k 500k, 10k to 10M), off or default.'
1125    return set('aura', tiers)
1126  }
1127  if (verb === 'beam' && value === 'after' && rest.length <= 2) {
1128    const [, minutes] = rest
1129    if (!minutes) return `${await now('beamAfter')} /mascot beam after <minutes>|never|default.`
1130    if (minutes === 'never' || minutes === 'default') return set('beamAfter', minutes === 'never' ? false : null)
1131    const n = Number(minutes.replace(/m(in)?$/, ''))
1132    if (checks.beamAfter(n) === undefined) return 'Beam after takes minutes (1 to 120), never or default.'
1133    return set('beamAfter', n)
1134  }
1135  if (verb === 'beam' && value === 'agents' && rest.length <= 2) {
1136    const [, on] = rest
1137    if (!on) return `${await now('beamForAgents')} /mascot beam agents on|off.`
1138    if (on !== 'on' && on !== 'off') return 'Beam agents takes on or off.'
1139    return set('beamForAgents', on === 'on')
1140  }
1141  if (verb === 'magic' && value === 'after' && rest.length <= 2) {
1142    const [, minutes] = rest
1143    if (!minutes) return `${await now('magicAfter')} /mascot magic after <minutes>|never|default.`
1144    if (minutes === 'never' || minutes === 'default') return set('magicAfter', null)
1145    const n = Number(minutes.replace(/m(in)?$/, ''))
1146    if (checks.magicAfter(n) === undefined) return 'Magic after takes minutes (0 to 120), never or default.'
1147    return set('magicAfter', n)
1148  }
1149  if (verb === 'updates' && !extra) {
1150    const about = release
1151      ? `Mascot v${release.version}${release.latest ? `; v${release.latest} is out: /mascot update` : ''}.`
1152      : ''
1153    if (!value) return `${about} ${await now('checkUpdates')} /mascot updates on|off.`.trim()
1154    if (value !== 'on' && value !== 'off') return 'Updates takes on or off.'
1155    const answer = await set('checkUpdates', value === 'on')
1156    if (value === 'on') void checkForUpdates($, true)
1157    return answer
1158  }
1159  if (verb === 'sound') return soundCommand($, rest, raw.slice(1), set)
1160  // On or off: /mascot call (the `nudge` setting), remote, away.
1161  const switches: Record<string, 'nudge' | 'remote' | 'away'> = { call: 'nudge', remote: 'remote', away: 'away' }
1162  const key = verb !== undefined && Object.hasOwn(switches, verb) ? switches[verb] : undefined
1163  if (verb !== undefined && key && !extra) {
1164    if (!value) return `${await now(key)} /mascot ${verb} on|off.`
1165    if (value !== 'on' && value !== 'off') return `${verb.charAt(0).toUpperCase()}${verb.slice(1)} takes on or off.`
1166    return set(key, value === 'on')
1167  }
1168  if (verb === 'settings' && !value) {
1169    const problem = await openSettingsWindow($)
1170    return `${problem ?? 'Settings window opened.'} ${settingsSummary(await loadSettings($))}`
1171  }
1172  if (verb === 'reset' && !value) {
1173    await saveSettings($, {
1174      size: null,
1175      calm: null,
1176      smooth: null,
1177      aura: null,
1178      beamAfter: null,
1179      beamForAgents: null,
1180      magicAfter: null,
1181      checkUpdates: null,
1182      sound: null,
1183      volume: null,
1184      sounds: null,
1185      waitingAfter: null,
1186      nudge: null,
1187      remote: null,
1188      away: null,
1189    })
1190    return `Settings back to their defaults. ${settingsSummary(DEFAULTS)}`
1191  }
1192  return undefined
1193}
1194
1195const soundName = (choice: boolean | string) => (choice === false ? 'none' : choice === true ? 'her own sound' : choice)
1196
1197// `/mascot sound ...`: `words` lower-cased, `raw` as typed (a file's name
1198// keeps its case).
1199const soundCommand = async (
1200  $: EngineInterface,
types/index.d.ts 165 lines
1export type MascotFrame = 'idle' | 'thinking' | 'working' | 'happy' | 'error' | 'waiting' | 'worried' | 'sleepy' | 'beam'
2
3/** The frame shown now; while `holdUntil` (clock ms) is ahead, `then` waits for the hold to end. */
4export type MascotMood = { frame: MascotFrame; holdUntil: number; then: MascotFrame }
5
6/**
7 * Whether the session is really done: the main turn running, the subagents
8 * still at work, background agent work the last Stop reported, and whether
9 * the end of this round of work has had its happy moment yet.
10 */
11/**
12 * The main turn, the subagents running, whether background agents were
13 * listed at its end, and until when (clock ms; 0 for none) other background
14 * work Claude started this round holds the round open (a shell, a monitor,
15 * a wakeup).
16 */
17export type MascotWork = {
18  inTurn: boolean
19  agents: string[]
20  hasBackground: boolean
21  waitUntil: number
22  isSettled: boolean
23}
24
25/** Whether a mascot is shown, and when that was chosen (epoch ms): the newest choice wins. */
26export type MascotVisibility = { visible: boolean; at: number }
27
28/**
29 * What the mod writes to `~/.claude/mascot/sessions/<key>.json` for its own
30 * overlay: the frame, the hover card's figures, whether to show, when the
31 * conversation was last cleared (`cleared`, clock ms: the overlay plays a
32 * channel change), the character it shows (`character`, a folder under
33 * frames/: a new one makes the overlay play its outro, take on that art and
34 * look, and play its intro), `update` (the mascot's version and its
35 * updates, for the settings window and the overlay), and `ended` once the
36 * session is over (the overlay then plays its outro, cleans up and exits).
37 * `~/.claude/mascot/all.json` holds a MascotVisibility for every session.
38 */
39export type MascotSessionFile = {
40  frame: MascotFrame
41  info?: Record<string, unknown>
42  visible: boolean
43  visibleAt: number
44  cleared?: number
45  character?: string
46  update?: MascotUpdate
47  call?: MascotCall
48  cue?: MascotCue
49  visit?: MascotVisit
50  ended?: true
51}
52
53/**
54 * A prompt that came from elsewhere (`at`, epoch ms): `from` 'bridge' (Remote
55 * Control: the Claude app on a phone, or the web) or 'channel' (a chat an MCP
56 * server relays, `name` its server). With the `remote` setting on, the
57 * overlay shows her messenger bringing it in, once per new `at`.
58 */
59export type MascotVisit = { at: number; from: 'bridge' | 'channel'; name?: string }
60
61/** The moments she has a sound for (the `sounds` setting). */
62export type MascotMoment = 'waiting' | 'done' | 'beam' | 'error' | 'magic' | 'intro' | 'outro'
63
64/**
65 * A moment only the mod knows (`at`, epoch ms): a round done or ending in
66 * the beam, a turn that died. The overlay plays its sound once per new
67 * `at`, as the settings say; `test` (/mascot sound try) plays it whatever
68 * they say. The overlay finds the other moments itself.
69 */
70export type MascotCue = { at: number; moment: MascotMoment; test?: true }
71
72/**
73 * Her call to the pointer: a round of work ended (`at`, epoch ms) that
74 * lasted the `magicAfter` setting's minutes. The overlay sends magic to the
75 * pointer at once, waiting only while a fullscreen game or a presentation
76 * holds notifications back; `test` (/mascot magic) does not wait even then.
77 */
78export type MascotCall = { at: number; test?: true }
79
80/**
81 * The mascot's version and its updates: `version` the one running; `latest`
82 * a newer release the daily check found (`checkUpdates`); `route` how this
83 * copy updates ('marketplace': `claude plugin update`, 'clone': `git pull`,
84 * 'manual': by hand); `state` an update run from this session ('updating',
85 * 'updated', or 'failed' with `message`); `celebrate` (epoch ms) when a
86 * newer version than the last one run first loaded: the overlay plays its
87 * banner once, while that is fresh; `from` the version before the last
88 * update (`$.store` `upgrade`, in every session while `version` runs): the
89 * settings window's news counts from it; `installed` a newer version
90 * installed since this session loaded (an update run from another
91 * session): this one meets it with /reload-plugins.
92 */
93export type MascotUpdate = {
94  version: string
95  latest?: string
96  route: 'marketplace' | 'clone' | 'manual'
97  state?: 'updating' | 'updated' | 'failed'
98  message?: string
99  celebrate?: number
100  from?: string
101  installed?: string
102}
103
104/**
105 * `~/.claude/mascot/settings.json`, every session's: written by /mascot, the
106 * settings window or by hand, followed live by every overlay. A key left out
107 * (or not valid) is its default: `size` her height in px (240-640, 420);
108 * `calm` no glitch, particles, flicker or flashes (false); `smooth` her
109 * status's particles step with a moving symbol, 36 fps, not 12 (false); `aura` the context
110 * tokens at which her aura's three levels start, ascending, or false for none
111 * ([300000, 400000, 500000]); `beamAfter` the minutes a round of work lasts
112 * before it ends in the beam (1-120), or false for never (2);
113 * `beamForAgents` a round that used agents ends in it too (true);
114 * `magicAfter` the minutes a round of work lasts before its end sends magic
115 * to the pointer (0-120: 0 every round), or
116 * false for never (false); `checkUpdates` look for a newer release on GitHub once a day (false);
117 * `sound` she plays sounds (false); `volume` theirs, 0-100 (60); `sounds`
118 * per moment, false none, true her own or the name of a file of yours in
119 * the mascot folder's sounds/ (.wav or .mp3), a moment left out its default
120 * (waiting, done, beam and error her own, the rest none); `waitingAfter`
121 * the seconds she waits on you before her waiting sound (10-300, 30);
122 * `nudge` once she has waited that long, her messenger calls and her
123 * terminal's taskbar button flashes, and a click on her brings her
124 * terminal forward (false); `remote` her messenger brings in a prompt from
125 * Remote Control or a channel (false); `away` what happened while you were away, on a note
126 * she holds when you are back (false).
127 */
128export type MascotSettings = {
129  size?: number
130  calm?: boolean
131  smooth?: boolean
132  aura?: [number, number, number] | false
133  beamAfter?: number | false
134  beamForAgents?: boolean
135  magicAfter?: number | false
136  checkUpdates?: boolean
137  sound?: boolean
138  volume?: number
139  sounds?: Partial<Record<MascotMoment, boolean | string>>
140  waitingAfter?: number
141  nudge?: boolean
142  remote?: boolean
143  away?: boolean
144}
145
146declare module 'claude-code' {
147  interface PluginState {
148    /**
149     * `view`: this session's show or hide (`at` 0 until chosen); `sessionKey`:
150     * the name of its file; `character`: a character picked for this session
151     * alone ('' for none: the project's); `ran`: the version this
152     * conversation ran last ('' for none: a new session, or after a /clear).
153     * All outlive a reload of the mod.
154     */
155    mascot: {
156      mood: MascotMood
157      work: MascotWork
158      view: MascotVisibility
159      sessionKey: string
160      character: string
161      ran: string
162    }
163  }
164}
165