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

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.
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./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./mascot magic sends one now, to try it (so does the settings window's "Send one now")./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)./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./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.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.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.
| Command | Setting | |||
|---|---|---|---|---|
/mascot settings | Opens 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 MOMENT | Plays 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 reset | Every 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.
| Mood | When |
|---|---|
| idle | Nothing running; also after an interrupted reply. |
| thinking | Claude is working out its answer: after your message, between tool calls, and while the conversation is compacted. |
| working | A 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. |
| waiting | Claude 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). |
| worried | A model request has streamed nothing for 10 s: usually an API error being retried, or a dropped connection. |
| happy | 3 s when everything is done: the reply and all the work it waited on. |
| beam | Instead 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). |
| error | 3 s when a tool fails, or a reply ends on an API error or refusal. Declining a permission prompt is not an error. |
| sleepy | Idle 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:
| Mood | Symbol |
|---|---|
| thinking | a mint thought bubble whose three dots light up in turn, teal to pink |
| working | a little equalizer bouncing to a beat, notes (♪ ♫) rising from it |
| waiting | a speech bubble with a pink "?" that hops, bursting little stars |
| worried | a sweat drop sliding down by her temple, flustered pink lines |
| happy | stage stars bursting over her head, then twinkling; hearts floating up |
| error | a grumpy cloud with a pink scribble, dropping a cracked note |
| sleepy | soft 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:
| When | What she shows |
|---|---|
| context past 300k tokens | a soft teal aura around her, breathing |
| past 400k | the aura turns pink; sparkles and notes drift up off her |
| past 500k | overload: 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% used | a failing stage light: a ring of light on the floor at her feet that hums and sputters |
| weekly limit 90% used | a hologram fading from the stage: scanlines, a bright band rolling down her, bits of her flaking away |
Two characters come with it, each with her own art, colors and effects (/mascot character NAME):
| Yunseul | |
|---|---|
| thinking | a lace thought bubble, gem dots lighting in turn |
| working | a needle sewing cross stitches, bats flapping up |
| waiting | a lace speech bubble with a hopping "?", a bat peeking |
| worried | a sweat drop and a little ghost trembling beside her |
| happy | a burst of bats, roses and stitched hearts |
| error | a pouting cloud with a >< face, dropping cracked hearts |
| sleepy | a 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 |
| glitch | a haunt: silver and crimson afterimages, an ectoplasm ripple |
| coming, going | a 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) |
| sounds | a music box, low bells, an organ and bats; Love Bite is an organ sting with a little nibble |
| messenger | a 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 |
| aura | moonlight; crimson with petals and bats; a blood moon behind her, beating like a heart |
| 5-hour limit | candles guttering at her feet |
| weekly limit | a ghost fade from her feet up |
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.
From this folder:
claude plugin test .
python -m unittest discover -s overlay -p "test_*.py"
python -m unittest discover -s scripts -p "test_*.py"
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).
hooks/register.tsx 1604 lines1import { 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 lines1export 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