A side pane where a slime hops along while Claude works and naps while idle, colored by the current model

Stop watching the logs, watch slimes!
A pixel-art slime that lives in Claude Code's side pane. It crawls along while Claude works and naps while Claude waits, with your usage limits, context, subagents and recent events all around it, at a glance.
<img src="docs/top.gif" width="420" alt="The pane at work: HP, MP and CP bars over a red slime crawling past trees, rocks and goo, two little subagent slimes hopping behind it">
At a Claude Code prompt in a terminal:
/plugin install slime-dashboard --marketplace egan-wu/slime-dashboard
Answer y to add the marketplace, then pick a scope. The pane opens by itself when the terminal is 144 columns wide or more; in a narrower terminal, or after closing it, type /slime-dashboard to open it. It docks on the right only in Claude Code's fullscreen layout (CLAUDE_CODE_NO_FLICKER=1, 110 columns or more); otherwise it sits above the prompt.
To update later, press [Update] under Setting.
<img src="docs/session-sign.png" width="340" alt="The session's name in cream letters on a brown wooden sign with rounded corners, [≡] at its left, and under it three recent sessions with how long ago each was used">
At the top, the session's name on a wooden sign: the one /rename gave it, else the one Claude Code made up. A new session with neither shows ! Mystic Journey ! , the !s in red. It follows each prompt, so a rename typed at the prompt shows after the next one.
[≡] at the sign's left lists this project's recent sessions under it, newest first: each by its name (the /rename one, else Claude Code's, else when it was last used) with how long ago (5m, 3h, 2d). Pressing one resumes it (/resume); [≡] again closes the list. The session you are in, and any never typed into, are left out.
The sign is also a button. Pressed, it opens a field under it; type a new name and press Enter (or the sign again) to run /rename with it. A field left empty or unchanged closes without renaming, and [x] beside it closes it too. A rename is logged in Event Message as Renamed: old → new, so a wrong one can be undone by renaming back.
<img src="docs/top.png" width="340" alt="HP 23%, MP 70%, CP 62%; the red Opus slime walks past an orange tree, a yellow and a blue little slime behind it; the model buttons below">
[Color] picked others.> <), at 70% a # shows beside its head, at 90% it is in tears (T T). Out of HP or MP it stops with x eyes until a limit resets and thinks of the potion it needs: a small dot rises beside its head, then a bigger one, then a thought bubble at its upper left holding blue mana for MP or red health for HP, and around again.[Unload], /compact, or automatically) it wakes up if asleep and sets its load down until the compaction ends: every few beats its body flashes white and it spits the goo it ate back out of its back in a spray of colored blocks, and green ↓ arrows fall beside it.[Weather] to [On] reads the real weather where you are from wttr.in every hour instead, with day turning to night at sunset in between (wttr.in places you by your IP address, which is why it is off unless you choose it). Moon, stars and birds hide under an overcast sky.■:Haiku ■:Sonnet ■:Opus ■:Fable switch the session's model (/model haiku, …): each family's newest model.| Asleep | Waiting on you |
|---|---|
| <img src="docs/asleep.png" width="340" alt="Night: the slime asleep under the moon and stars, zZ above it"> | <img src="docs/waiting.png" width="340" alt="The troop stopped and a speech bubble with a bold question mark over the main slime"> |
| Once the turn ends it falls asleep while the clouds and birds drift on. | A permission prompt or a question is open: the troop stops and a bubble with a bold ? flashes over the slime until you answer. |
<img src="docs/passive.png" width="340" alt="Passive open: [ Cache Warming ] turned on, lit on an amber ground, 74.3k warm after it">
Cache Warming keeps the prompt cache alive while you are away. It is off until you turn it on, since its pings spend tokens of yours. Claude Code caches the conversation for a while after each request (an hour on a Pro or Max subscription, five minutes on an API key); come back after that and the whole context is written to the cache again, at 1.25× the input price for the five-minute cache or 2× for the hour one, where reading it costs 0.1×. While the session sits idle, Cache Warming sends a one-word request forked from the conversation a little before the cache would lapse: it reads the cache, which starts its time over, and nothing joins the conversation.
It sends only while that pays. Each ping reads the context at 0.1×; once the pings since your last prompt would cost more than coming back to a lapsed cache, it rests until your next prompt. A 74k context is kept about seven pings; one under about 28k (little beyond the system prompt and tools, which stay cached anyway) is not kept at all. It also rests when the cache has lapsed already, the model changes, a new session starts, or HP or MP runs out. Pings count toward your usage limits like any request.
| Button | What it does |
|---|---|
[ Cache Warming ] | Off (the default) or on, kept across sessions: lit on an amber ground while on, dim while off. The dim word after it says what it is doing: 74.3k warm (keeping that much), resting (not worth it now), waiting (for a prompt), or off. |
| Warming | Resting |
|---|---|
| <img src="docs/campfire.png" width="340" alt="Night: a campfire burning beside the red slime, awake"> | <img src="docs/embers.png" width="340" alt="Night: the fire burnt down to glowing logs, a puff of smoke above, the slime asleep beside it"> |
| When a turn ends and warming is keeping the cache, the slime spits a log, then a flame, and stays awake by the fire. | Once more pings would not pay, the fire burns down to embers and smoke, and the slime falls asleep beside them. |
<img src="docs/property.png" width="340" alt="Property open: Model [Opus 5.5], Effort [High] with its row of levels, then cache hit rate, tokens, iterations and the latest turn's time">
| Button | What it does |
|---|---|
Model [Opus 5.5] | Opens a row [Haiku][Sonnet][Opus][Fable], the current one bright; picking one runs /model. |
Effort [High] | Opens the row of levels [Low][Mid][High][xHigh][Max]; picking one runs /effort with it and closes the row. |
Below them: the cache hit rate, the cache's time to live (1h or 5m: (env) when CLAUDE_CODE_PROMPT_CACHE_TTL sets it, none when a settings file's promptCacheTtl does, (auto) otherwise), the tokens used, the iterations, and the latest turn's time.
<img src="docs/skills.png" width="340" alt="Skill Box open: a blue frame's top line holds Prompt for skill and [Clear]; two framed pieces, only slow tests and 30, each with ▲ ▼ x, above the empty ›: field, the frame's bottom line under it; General open in a rounded box with [▼][▲] at its right and [Unload], [Respawn] and [timer] each after a [▲]; Code open with five skills, then a line and [◀][▶] 1/2; Party Combo open with two combos">
| Button | What it does |
|---|---|
| Prompt for skill | Its title sits in the top line of a blue frame, [Clear] at the line's right; the frame's bottom line closes it under the field, with no sides, because the field is as wide as the pane so a long line wraps in full. Type, then Enter: the text becomes a piece in a frame of its own above the field, ▲ ▼ at its right moving it up or down, x taking it out, and [Clear] taking them all out. After Enter the field keeps the keys for the next piece. Type as many pieces as you like; the next skill you press is sent with them all, in order (and anything still in the field), and they clear. |
▼ General / ▸ Code (1) | Opens or closes a category of skills; open, it is a rounded box with its skills inside, five rows a page; past five, [◀][▶] turn the page, the page over the pages (1/3) beside them. |
[▼] [▲] (right of a category) | Moves the category a place down or up, kept across sessions. |
[▲] (before a skill) | Trades places with the skill above it, kept across sessions; the page follows it over onto the one before. |
| Party Combo | The combos saved in the Party Combo section, each a button; pressed, it runs with the prompt as its input. |
[Unload], [timer], … | Runs the skill: /timer "30" with the prompt, /timer alone without. [Unload] is built in and runs /compact. |
[Respawn] | Built in: starts a new session (/clear), once confirmed. Pressed, its row asks [Respawn]: [N]/[Y]; [N] (grey under the pointer) backs out, [Y] (red under the pointer) clears. The old slime hops twice and leaps off to the right, turning half over; a great beam of light comes down in the middle of the scene, a ring of light runs out along the ground, glowing motes scatter, and a new slime takes shape in the beam, glints, pauses a second, then crawls to its place. |
Skills are added with /slime-dashboard add (see Usage).
<img src="docs/tree.png" width="340" alt="Party Combo open: tabs Run-Test and Nightly*, then Run-Test under a purple banner with [Rename]; three wave boxes, each with ▲ ▼ ✕ and its skills (a model swatch, the skill, its model and subagent type), a condition and an arrow between them, then + Wave and [Save] [Delete]">
Party Combos: your Skill Box skills chained into waves, each skill run by a subagent with the model and subagent type you pick, and a leader subagent of its own (on Sonnet) leading the whole run in the background, so your conversation is never the one paying for it. The waves run in order; the skills of one wave all start at once, and the next wave waits for them all. For example, build, then unit-test, then check the result on Sonnet while Haiku archives the logs, at the same time.
| Button | What it does |
|---|---|
[Combo 1] … [+New] | One tab per combo; [+New] starts one. A tab with * has unsaved edits. |
▾ Name (the purple banner) | Folds the combo to its name, and opens it again. [Rename] beside it renames it. |
Wave 1 … ▲ ▼ ✕ | Each wave is a rounded box; ▲ ▼ move it, ✕ removes it. |
■ /skill Haiku general ✕ | A skill in the wave, its swatch in its model's color. Press the model to cycle Haiku → Sonnet → Opus → Fable, the subagent type to cycle general → explore → plan (then your own agents from .claude/agents/), ✕ to take it out. In a narrow pane the model and type go on a second line. |
+ skill | Lists the Skill Box's skills not yet in the wave, side by side; each press adds one. |
◆ + condition | Between a wave and the next: what the leader should do with what came back, in your own words (if Pass, report done; if Fail, run the debug wave). Pressed, it opens a field. |
+ Wave | Adds a wave at the end. |
[Save] / [Delete] | Edits stay a draft, so trying things never breaks a combo that works, until [Save] (green under the pointer) keeps them; empty waves are dropped then. [Delete] (red under the pointer) asks [Delete [Y]/[N]]. |
A saved combo shows in the Skill Box under Party Combo; pressed, it runs with the Skill Box's prompt as its input, and Dungeon opens on it. Explore and plan subagents only read, so a skill that writes files (a build, an archive) wants general. Combos pressed together run at the same time: two that write the same files (a test's results, say) can read each other's, so run those one after the other.
<img src="docs/dungeon.png" width="420" alt="Dungeon: the scene on top, a board reading CLEARED 12, the slime swinging its sword under a bubble of a fight, ×2, and the keep with its portcullis raised; [Clear All]; RUN_TEST #21 running, a half-turned gold coin and 96K, its Leader card leading with [Recall], its waves open: /demo-build and /demo-test done, /demo-check running with [X] and a helper it sent out, /demo-archive done, then its condition; below it FAIL_PATH #20 done with [↻][x], its waves closed to 4/4 done · 1m 05s; last SCOUT #19 recalled, folded to its name, number, time, 1/3 and 68K">
A tab of its own that opens when a Party Combo is pressed, and follows each press as a mission until its leader has led it to the end. The tab's title counts the missions not yet done or stopped: Dungeon (2) (and ? after it while a subagent waits on you). At a glance a mission is two lines, its name and how much it has spent; open it for its leader's card and one line for its waves, then the waves, then a step's card, for the detail. Each press gets a number, #1, #2, …; its prompt and each step's tag carry it ([Run-Test #3 2.1]), so combos pressed close together, or one combo pressed twice, keep their subagents apart.
At rest, with no mission going on:
<img src="docs/dungeon-guard.png" width="420" alt="Dungeon's scene at rest: the board reading CLEARED 12, the slime on guard with its sword planted beside it and its eyes on the keep, whose portcullis is down">
| What | Shows |
|---|---|
| The scene (top of the tab) | A board on the left counting the missions cleared this session, in two pixel digits (12; 99 at most) under CLEARED. In the middle the main slime with its sword in its right hand; on the right the dungeon's keep, a battlemented wall with a skull over its arched door, a banner and a torch each side, and a portcullis. With nothing going on the slime rests on guard, its sword planted beside it and its eyes on the keep, whose portcullis is down. While missions run the slime fights, hopping and swinging, the portcullis is up and red eyes glow in the dark, and a bubble at the slime's upper left shows a little fight and how many missions are going (×2). |
▸ Name (the purple banner) | The combo's name. A mission shows only its first two lines until its name is pressed open (▾); pressed again, it folds back. At its right how the mission stands (◐ running, ? asking, ✔ done, ■ stopped, ■ recalled in red, ✖ error); [x] takes the mission off the tab, and once it has ended [↻] before it sends the same combo with the same prompt again as a mission of its own (this one stays as it is). |
#3 · 2026-10-09 15:21 ● 124K | The mission's number and when it was sent (folded, how many of its steps are done: · 1/3); at the right, after a gold coin that turns while the mission runs, the tokens the whole combo has used (its leader's and every subagent's requests: taken in, cache reads included, and given out). |
◆ Leader leading [Recall] | The subagent leading the mission, leading until the mission ends (between its turns as well), then its report of each step. The mission ends with it: once its turn is over and nothing the mission sent out still runs, after ten quiet seconds (a leader waiting on a wave's reports ends its turn between waves, and is woken to send the next, so the mission is not called done in between); a mission you stop or that fails ends at once. [Recall] (red under the pointer) calls the whole mission back: the leader first, then every subagent it sent out that still runs (TaskStop, so Claude Code may ask your permission first). |
▸ Waves · 2 waves · 2/3 done · 1m 05s | The waves, closed to how far their steps have got until pressed open (▾): how many are done, running, failed or stopped, queued before its leader starts, and once the mission ends how long it took (and stopped or error if so). |
Wave 1 … | The waves as they stood when pressed, with their conditions. Each step is a rounded card, closed to one line (its skill and how it stands) until ▸ opens it: then its model and subagent type, and how it stands (waiting, running with its model requests, last tool and time, done, failed, stopped, or not run when the combo ended before it) and its tokens, then the first lines of its answer. What the step's skill sent out (a subagent, or a command left running in the background) is listed in its card, ↳, and keeps the step running until it ends: a step is done when everything it sent out is. A running step's [X] (red under the pointer) calls that subagent and its helpers back; its leader takes it as failed and does not run it again. |
Other subagents | Subagents whose tag names no step: one a step's skill sent out itself, or one the leader added. |
[Clear All] | Takes every mission off the tab. |
Where no subagent can be started, the press asks your conversation to lead the combo instead, as a prompt of its own. Missions are kept in this session's memory only. /slime-dashboard dungeon opens the tab.
Asking you. The plugin registers a tool, mcp__slime-dashboard__ask_person, and a combo's prompt tells each step to ask you with it where its skill would ask something, and to wait for the answer. A step's card then shows the question, one button per option and a field for the answer, the tab opens, and the mission reads ? asking until you answer; [X] or [x] let the question go unanswered. Known limit: a combo's steps run beneath its leader, a subagent the plugin starts itself, and Claude Code does not run the plugin's tool hooks there, so the call comes back as an error and the step says it could not ask rather than guess. The card works where the conversation leads the combo.
<img src="docs/party.png" width="340" alt="Party open: Haiku 5.5 and Sonnet 5.5, each with a red [x] and its task below">
The subagents at work, each its model in its color and the few words its task was given. The red [x] beside one stops that subagent (TaskStop, so Claude Code may ask your permission first); its little slime drops out of line, and Event Message logs it as Stopped.
<img src="docs/events.png" width="340" alt="Event Message open: three framed events, a subagent finished, one stopped and one started">
The newest three events, each framed, with this computer's local time (YYYYMMDD-hhmm) above a summary: a subagent started, finished or stopped, a question waiting or answered, out of HP/MP or back, the model or effort switched, a skill sent, the context compacted, a respawn, a rename, an update, and Cache Warming's doings (♨ Cache warmed, rescued, cold, rests). Twenty are kept.
<img src="docs/journal.png" width="340" alt="Journal open: the Cache Warming block with this session's cache read and write, when it expires, and thirty days' hit rate, cold starts, pings, rescues and tokens saved">
What the cache did, kept on this computer for thirty days across sessions. ▼ Cache Warming opens or closes its block:
· for a day with no record).<img src="docs/setting.png" width="340" alt="Setting open: Update, Display, Color, Order, Width, Performance, Weather (Off) and Reload, then the version">
| Button | What it does |
|---|---|
[Update] | Fetches the latest version from GitHub, updates the installed plugin, and reloads plugins in this session; how it went shows in a toast and in Event Message. A red ! before it means GitHub has a newer version than the one running (checked each time the dashboard loads: at session start and at each reload). |
[Display] | Opens a rounded box of checkboxes, one per section (Session, HP / MP / CP, Slime, Model buttons, Passive, Property, Skill Box, Party Combo, Party, Event Message, Journal); unticking one hides it, and the choice is kept across sessions. Pressed again, it closes the box. Setting always shows. |
[Color] | Opens a rounded box with one row per model family; pressing a family's color button moves it to the next of six (Purple, Red, Blue, Yellow, Green, Pink). Families may share a color. The choice colors the slimes, the model buttons and Party, and is kept across sessions; [Default] puts the original four back. Pressed again, it closes the box. |
[Order] | Opens a rounded box listing the sections top to bottom; each row's [▲] / [▼] moves that section a place up or down in the pane. A hidden section keeps its place (dim in the list). The order is kept across sessions; [Default] puts the original order back. Setting always stays last. Pressed again, it closes the box. |
[Width] [-] 33 [+] | Makes the docked pane a column narrower or wider (24–80), kept across sessions. A width you dragged the dock to by hand wins over it. |
[Performance] | How much the scene draws, kept across sessions. Pressed, a rounded box opens with [High][Mid][Low], the current one bright; picking one closes it. High draws everything. Mid draws about 30% fewer things along the way (trees, rocks, goo, clouds, birds) and half of Unload's spray and Respawn's motes; Low about half the things and 30% of the spray and motes. |
[Weather] [Off] | Off by default; pressed, it turns [On], and pressed again back [Off]. On, the sky shows the real weather where you are, read from wttr.in every hour (it places you by your IP address); Off, day and night follow this computer's clock and the weather is drawn at random each hour. Kept across sessions. |
[Reload] | Reloads the dashboard (/reload-plugins), as saving its files would. |
The plugin's version shows at the pane's foot.
Each section above can be hidden or moved with Setting's [Display] and [Order]. Every button also works from the keyboard: ctrl+x tab, or a click, gives the pane the keys, then Tab walks the buttons and Enter presses one. Each section's title button ([ ▼ ] while open, [ ▲ ] while closed) opens or closes it; closed, Party and Event Message count what they hold in their titles.
/slime-dashboard open the pane
/slime-dashboard add <skill> [--category <name>] [--desc <words>]
/slime-dashboard remove <skill>
/slime-dashboard list the Skill Box's skillshooks/register.tsx 3004 lines1import { atom, read, update } from 'claude-code'
2import type { AgentSpawnResult, AgentStatus, EngineInterface, Register } from 'claude-code'
3
4import { ASK, bubbleCell, CAMPFIRE_W, bubbleFill, colorsFrom, DEFAULT_COLORS, EMERGE_TICKS, FAMILIES, frame, hex, homeCx, modelInfo, PALETTE_IDS, PALETTES, partyLength, partyScrolling, PERF_LEVELS, perfFrom, respawnLength, ROWS, setColors, setPerformance, slotOf, step } from './scene'
5import type { Family, Perf, SlimeColors } from './scene'
6import type { Offsets } from './scene'
7import { addEvent, offsetOf, SHOWN_EVENTS, stamp } from './events'
8import type { SlimeEvent } from './events'
9import { addUsage, cacheEndText, cacheHitRate, cacheTtlOf, clockText, compact, NO_TALLY, secondsText, totalTokens, ttlMs } from './props'
10import type { CacheTtl, Tally } from './props'
11import { arranged as inOrder, grouped, namesFrom, orderMapFrom, parseAdd, skillsFrom, swapNames } from './skills'
12import type { Skill } from './skills'
13import { addLayer, addStep, agentLabel, ASK_TOOL, ASK_TOOL_NAME, COMBO_CATEGORY, COMBO_MODELS, comboPrompt, comboSummary, combosFrom, cycleAgent, cycleModel, DEFAULT_AGENTS, forSteps, LEADER_MODEL, leaderTag, moveLayer, newComboName, removeLayer, removeStep, renameOk, setCondition } from './combos'
14import type { Combo } from './combos'
15import { agentsToFollow, anyLive, askingRun, attachRun, clearedOf, missionCombo, leaderOf, mapRuns, missionState, stepRuns, stepStatus, beginMission, combosOf, dateTimeText, elapsed, endLed, endMissions, reviveRun, SETTLE_MS, settleLed, watching, newMission, nextMissionId, resultLines, runsOf, runTokens, statusOfAgent, tokensOf, tokensText, coinText, updateRun, usageTokens } from './squad'
16import type { Mission, MissionState, RunStatus, SquadRun } from './squad'
17import { DUNGEON_ROWS, dungeonFrame } from './dungeon'
18import { agoText, cleanSummary, recentFrom, titleFrom, wrapSummary } from './summary'
19import { installedSha, manifestVersion, remoteSha, REMOTE_MANIFEST_URL, REMOTE_SHA_URL } from './freshness'
20import { faceOf, filledOf, FULL, isDown, vitalsOf } from './vitals'
21import type { Vitals } from './vitals'
22import { DEFAULT_ORDER, moveSection, orderFrom, SECTIONS } from './layout'
23import type { SectionId } from './layout'
24import { VERSION } from './version'
25import { cacheKept, returnVerdict, WARM_PROMPT, warmStep } from './warming'
26import { addToDay, dayKey, journalFrom, pingPays, READ_COST, rewriteCost, summaryOf } from './journal'
27import type { Day } from './journal'
28import { DEFAULT_WEATHER, localWeather, parseWeather, WEATHER_URL, weatherNow } from './weather'
29import type { SlimeMinion, SlimeRecentSession, SlimeWeather } from '../types'
30
31const PANE = 'slime-dashboard'
32// Dungeon: a tab of its own, opened by a Party Combo's press.
33const SQUAD = 'dungeon'
34const SQUAD_TITLE = 'Dungeon'
35const SCENE = 'scene'
36const TICK_MS = 100
37// The sky is read every hour; day turns to night at sunset in between, from
38// the sunrise and sunset the last read gave. A read that fails is tried
39// again two minutes later.
40const WEATHER_MS = 60 * 60 * 1000
41const WEATHER_RETRY_MS = 2 * 60 * 1000
42// Docked beside the fullscreen transcript, the sidebar asks for this width.
43const OPEN = { id: PANE, title: 'Slime', columns: 33 }
44// Setting's Width moves the docked pane's width a column at a time, between these.
45const MIN_COLUMNS = 24
46const MAX_COLUMNS = 80
47// The model buttons under the scene: each switches the session with /model
48// and the family's alias, which always resolves to that family's newest model.
49// A Button's label takes no color of its own, so a square in the model's
50// color stands before each.
51const PICKS = [
52 { label: 'Haiku', id: 'haiku' },
53 { label: 'Sonnet', id: 'sonnet' },
54 { label: 'Opus', id: 'opus' },
55 { label: 'Fable', id: 'fable' },
56]
57
58// The red of Party's [x], which stops a subagent. A Button's label takes no
59// color at rest, so the brackets around it carry the red, and the x turns red
60// under the pointer.
61const STOP_RED = 0xe63946
62
63const busyAtom = atom({ plugin: 'slime-dashboard', key: 'busy' } as const, false)
64const modelAtom = atom({ plugin: 'slime-dashboard', key: 'model' } as const, '')
65const minionsAtom = atom({ plugin: 'slime-dashboard', key: 'minions' } as const, [] as SlimeMinion[])
66const weatherAtom = atom({ plugin: 'slime-dashboard', key: 'weather' } as const, DEFAULT_WEATHER as SlimeWeather)
67// HP, MP and CP: what is left of the usage limits, and the context window's fill.
68// The session's name as /resume lists it: the one /rename gave, else the one
69// Claude Code made up.
70const SIGN = { edge: '#5c3a1e', board: '#8b5a2b', text: '#f5e6c8' }
71// The red ! either side of a session not yet named, bright on the board.
72const UNNAMED_MARK = '#ff5a4f'
73const sessionTitleAtom = atom({ plugin: 'slime-dashboard', key: 'sessionTitle' } as const, '')
74// Pressing the sign opens a field under it for a new name (renameDraft).
75const renameOpenAtom = atom({ plugin: 'slime-dashboard', key: 'renameOpen' } as const, false)
76const renameDraftAtom = atom({ plugin: 'slime-dashboard', key: 'renameDraft' } as const, '')
77// The sign's [≡] opens a list of the project's recent sessions, to resume one.
78const sessionsOpenAtom = atom({ plugin: 'slime-dashboard', key: 'sessionsOpen' } as const, false)
79const recentSessionsAtom = atom({ plugin: 'slime-dashboard', key: 'recentSessions' } as const, [] as SlimeRecentSession[])
80const RECENT_SESSIONS = 8
81// The Skill Box's Respawn asks first: its row turns into [Y]/[N].
82const respawnConfirmAtom = atom({ plugin: 'slime-dashboard', key: 'respawnConfirm' } as const, false)
83// The tick Respawn cleared the session on, while its scene plays.
84let respawnAt: number | undefined
85const respawnTick = () => (respawnAt === undefined ? undefined : tick - respawnAt)
86const RESPAWN_RED = '#d62828'
87// The Party Combo section's combo: its name on a purple banner (and the box's edge),
88// and the green of [Save].
89const COMBO = { banner: '#5a189a', text: '#ffffff', save: '#38b000' }
90// How Dungeon marks a step: waiting (dim), running, done, failed,
91// stopped, or never reached once its mission ended.
92const BORDER_DIM = '#5a5a66'
93const RUN_LOOK: Record<RunStatus | 'waiting' | 'skipped', { mark: string; word: string; color?: string }> = {
94 waiting: { mark: '○', word: 'waiting' },
95 running: { mark: '◐', word: 'running', color: '#ffd23f' },
96 completed: { mark: '✔', word: 'done', color: '#38b000' },
97 failed: { mark: '✖', word: 'failed', color: '#ff5c5c' },
98 stopped: { mark: '■', word: 'stopped', color: '#ff8c42' },
99 skipped: { mark: '–', word: 'not run' },
100}
101// The coin before a mission's tokens: gold, turning while the mission runs.
102const COIN = { color: '#ffd23f', frames: ['●', '◐', '○', '◑'], ms: 250 }
103
104// A leader, while its mission goes on (between its turns as well).
105const LEADING_LOOK = { mark: '◆', word: 'leading', color: '#c77dff' }
106// A leader whose mission the person called back with [Recall].
107const RECALLED_LOOK = { mark: '■', word: 'recalled', color: hex(STOP_RED) }
108// A subagent waiting on the person's answer.
109const ASKING_LOOK = { mark: '?', word: 'asking', color: '#4cc9f0' }
110// How a mission stands, on its card's banner (folded or not).
111const MISSION_LOOK: Record<MissionState, { mark: string; word: string; color?: string }> = {
112 queued: RUN_LOOK.waiting,
113 running: RUN_LOOK.running,
114 asking: ASKING_LOOK,
115 done: RUN_LOOK.completed,
116 stopped: RUN_LOOK.stopped,
117 recalled: RECALLED_LOOK,
118 error: { ...RUN_LOOK.failed, word: 'error' },
119}
120// A step's model as Dungeon names it: `sonnet` to `Sonnet`.
121const modelName = (m: string) => m.charAt(0).toUpperCase() + m.slice(1)
122const vitalsAtom = atom({ plugin: 'slime-dashboard', key: 'vitals' } as const, FULL as Vitals)
123// True while the session waits on the person: a permission prompt or a question.
124const waitingAtom = atom({ plugin: 'slime-dashboard', key: 'waiting' } as const, false)
125// The Skill Box: the skills registered with /slime-dashboard add (kept
126// in the store across sessions), whether it is open, the prompt typed for
127// them, and the first of the five rows shown.
128const skillsAtom = atom({ plugin: 'slime-dashboard', key: 'skills' } as const, [] as Skill[])
129const skillsOpenAtom = atom({ plugin: 'slime-dashboard', key: 'skillsOpen' } as const, false)
130const skillPromptAtom = atom({ plugin: 'slime-dashboard', key: 'skillPrompt' } as const, '')
131// Each Enter in the prompt field keeps what it held as a piece of its own, in
132// a frame with an [x]; a skill pressed sends them all, in order, as its prompt.
133const skillPiecesAtom = atom({ plugin: 'slime-dashboard', key: 'skillPieces' } as const, [] as string[])
134// Per category: the first of its five rows shown, and whether it is closed
135// (a category is open until closed).
136const skillTopsAtom = atom({ plugin: 'slime-dashboard', key: 'skillTops' } as const, {} as Record<string, number>)
137const skillCatsClosedAtom = atom({ plugin: 'slime-dashboard', key: 'skillCatsClosed' } as const, [] as string[])
138// The person's arrangement of the Skill Box (kept in the store): the skills of
139// each category in order, and the categories in order.
140const skillOrderAtom = atom({ plugin: 'slime-dashboard', key: 'skillOrder' } as const, {} as Record<string, string[]>)
141const catOrderAtom = atom({ plugin: 'slime-dashboard', key: 'catOrder' } as const, [] as string[])
142// The Party Combo section: the combos as saved (kept in the store across
143// sessions), whether it is open, the combo being edited (its tab: the name it
144// was saved under, or a new one's), its box folded to its name, the wave
145// whose + skill lists the skills to add (-1: none), the wave whose condition
146// field is open (-1: none), the rename field, and the subagent types the
147// session offers.
148const combosAtom = atom({ plugin: 'slime-dashboard', key: 'combos' } as const, [] as Combo[])
149const treeOpenAtom = atom({ plugin: 'slime-dashboard', key: 'treeOpen' } as const, false)
150const comboSelAtom = atom({ plugin: 'slime-dashboard', key: 'comboSel' } as const, '')
151const comboFoldAtom = atom({ plugin: 'slime-dashboard', key: 'comboFold' } as const, false)
152const comboPickAtom = atom({ plugin: 'slime-dashboard', key: 'comboPick' } as const, -1)
153const comboCondAtom = atom({ plugin: 'slime-dashboard', key: 'comboCond' } as const, -1)
154const comboRenameAtom = atom({ plugin: 'slime-dashboard', key: 'comboRename' } as const, false)
155const comboNameAtom = atom({ plugin: 'slime-dashboard', key: 'comboName' } as const, '')
156// Edits wait in a draft per tab until [Save] keeps them, so trying something
157// out never spoils a combo that works; a new combo is a tab of its own (fresh)
158// until its first save.
159const comboEditsAtom = atom({ plugin: 'slime-dashboard', key: 'comboEdits' } as const, {} as Record<string, Combo>)
160const comboFreshAtom = atom({ plugin: 'slime-dashboard', key: 'comboFresh' } as const, [] as string[])
161// Delete asks first: [Delete [Y]/[N]].
162const comboDeleteAtom = atom({ plugin: 'slime-dashboard', key: 'comboDelete' } as const, false)
163const agentsAtom = atom({ plugin: 'slime-dashboard', key: 'agents' } as const, [] as string[])
164// The Property block: open or not, and the session's figures it shows.
165const propsOpenAtom = atom({ plugin: 'slime-dashboard', key: 'propsOpen' } as const, false)
166// The Passive block (Cache Warming's switch): open or not.
167const passiveOpenAtom = atom({ plugin: 'slime-dashboard', key: 'passiveOpen' } as const, false)
168// The Setting block: open or not.
169const settingsOpenAtom = atom({ plugin: 'slime-dashboard', key: 'settingsOpen' } as const, false)
170// Setting's Display: whether its box is open, and the sections hidden from the
171// pane (kept in the store across sessions).
172const displayOpenAtom = atom({ plugin: 'slime-dashboard', key: 'displayOpen' } as const, false)
173const hiddenAtom = atom({ plugin: 'slime-dashboard', key: 'hidden' } as const, [] as string[])
174// Setting's Color: whether its box is open, and the slime color each model
175// family wears (kept in the store across sessions).
176const colorOpenAtom = atom({ plugin: 'slime-dashboard', key: 'colorOpen' } as const, false)
177const colorsAtom = atom({ plugin: 'slime-dashboard', key: 'colors' } as const, DEFAULT_COLORS as SlimeColors)
178// True while the main conversation's context compacts (Unload, /compact, auto).
179// Setting's Order: whether its box is open, and the sections' order from the
180// top (kept in the store across sessions).
181const orderOpenAtom = atom({ plugin: 'slime-dashboard', key: 'orderOpen' } as const, false)
182const orderAtom = atom({ plugin: 'slime-dashboard', key: 'order' } as const, DEFAULT_ORDER as SectionId[])
183// Setting's Width: the columns the docked pane asks for (kept in the store).
184const widthAtom = atom({ plugin: 'slime-dashboard', key: 'width' } as const, OPEN.columns)
185const unloadingAtom = atom({ plugin: 'slime-dashboard', key: 'unloading' } as const, false)
186// True once GitHub's main is ahead of what this copy runs: a red ! before [Update].
187const behindAtom = atom({ plugin: 'slime-dashboard', key: 'behind' } as const, false)
188const tallyAtom = atom({ plugin: 'slime-dashboard', key: 'tally' } as const, NO_TALLY as Tally)
189// The prompt cache's TTL, and when the main thread last sent a request (its
190// cache then runs until cacheAt + TTL).
191const cacheTtlAtom = atom({ plugin: 'slime-dashboard', key: 'cacheTtl' } as const, { ttl: '1h', from: 'auto' } as CacheTtl)
192const cacheAtAtom = atom({ plugin: 'slime-dashboard', key: 'cacheAt' } as const, 0)
193// The Journal: thirty days of records (kept in the store), and whether it is open.
194const journalAtom = atom({ plugin: 'slime-dashboard', key: 'journal' } as const, [] as Day[])
195const journalOpenAtom = atom({ plugin: 'slime-dashboard', key: 'journalOpen' } as const, false)
196// The Journal's Cache Warming block, opened and closed on its own.
197const journalWarmOpenAtom = atom({ plugin: 'slime-dashboard', key: 'journalWarmOpen' } as const, false)
198const JOURNAL_KEY = 'journal'
199// Cache Warming's switch while on: a deep amber ground, which the
200// terminal's light text reads well on; bold and brighter under the pointer.
201const WARM_LIT = { ground: '#9c5d12', text: '#ffe7b0' } as const
202// Cache Warming: on or off. On, it rests (sends nothing) once keeping
203// the cache stops paying, until the next turn starts a new idle stretch.
204// Off until the person turns it on: its pings spend their tokens, which no
205// one should find out about afterwards.
206const warmAutoAtom = atom({ plugin: 'slime-dashboard', key: 'warmAuto' } as const, false)
207// What a cold return still read from the cache (the system prompt and tools,
208// kept warm apart), which a rescue does not save; the last one seen.
209const WARM_SHARED_KEY = 'warmShared'
210let warmAuto = false
211let warmRest = false
212let warmBusy = false
213let warmShared = 0
214let warmTtl: '5m' | '1h' = '5m'
215// The main thread's last prefix, kept through a reload of the plugin so the
216// fire is lit again without waiting for another turn.
217const warmPrefixAtom = atom({ plugin: 'slime-dashboard', key: 'warmPrefix' } as const, 0)
218// For the Journal and warming's break-even: when the main thread's last
219// request ended, its prefix's size, and the pings sent since (what they read).
220let lastRealAt = 0
221let lastPrefix = 0
222let streakPings = 0
223let streakSpent = 0
224// Whether warming is keeping the cache now: on Auto, not resting, and worth a
225// ping for what the last turn left (the campfire burns while it is).
226const warmingNow = () => warmAuto && !warmRest && lastPrefix > 0 && pingPays(streakSpent, lastPrefix, warmTtl, warmShared)
227// Where the campfire stands, in the ground's own coordinates (off.rock, which
228// goo and rocks scroll by): lit beside the slime once the troop rests with warming
229// on; it stays while they rest, slides off to the left when they set out,
230// and is lit anew beside them when they rest again.
231let campAt: number | undefined
232// The column the last animation frame drew the fire at, so a redraw of the
233// whole pane between frames draws it in the same place.
234let campShown: number | undefined
235// The tick the fire was set on, for the lighting (a log, then a flame).
236let campLit = 0
237const CAMP_GAP = 2
238// Once warming rests (keeping the cache no longer pays), a fire already lit
239// burns down to embers, still on Auto; none is lit for it.
240const embersNow = () => warmAuto && warmRest && campAt !== undefined
241function campColumn(w: number, resting: boolean): number | undefined {
242 if (!warmingNow() && !embersNow()) return (campAt = undefined)
243 // Lit just in front of the resting slime: two pixels clear of its left side.
244 // A fire left behind (the troop traveled on, or the pane changed width) is
245 // lit anew there once it rests; embers are only moved.
246 const spot = Math.floor(off.rock) + homeCx(w) - 3 - CAMP_GAP - CAMPFIRE_W
247 if (resting && campAt !== spot) {
248 if (warmingNow()) {
249 campAt = spot
250 campLit = tick
251 } else if (campAt !== undefined) {
252 campAt = spot
253 }
254 }
255 if (campAt === undefined) return undefined
256 const x = campAt - Math.floor(off.rock)
257 if (x + CAMPFIRE_W <= 0) return (campAt = undefined)
258 return x
259}
260// The warming loop's own timer, a beat every 15 seconds while it is on.
261let warmTimer: { cancel: () => void } | undefined
262const WARM_BEAT_MS = 15_000
263const startWarmTimer = ($: EngineInterface) => {
264 warmTimer?.cancel()
265 warmTimer = $.clock.every(WARM_BEAT_MS, () => void warmBeat($))
266}
267// The model requests of the main loop's current (or last) turn.
268const iterationAtom = atom({ plugin: 'slime-dashboard', key: 'iteration' } as const, 0)
269// The main loop's reasoning effort as its last model request was sent ('' before one).
270const effortAtom = atom({ plugin: 'slime-dashboard', key: 'effort' } as const, '')
271// The Event Message block's events, newest first.
272const eventsAtom = atom({ plugin: 'slime-dashboard', key: 'events' } as const, [] as SlimeEvent[])
273// Whether Party and Event Message are open; like every block, both start closed.
274const monitorOpenAtom = atom({ plugin: 'slime-dashboard', key: 'monitorOpen' } as const, false)
275const eventsOpenAtom = atom({ plugin: 'slime-dashboard', key: 'eventsOpen' } as const, false)
276// Whether the row of effort levels under Property's Effort is open.
277const modelOpenAtom = atom({ plugin: 'slime-dashboard', key: 'modelOpen' } as const, false)
278const effortOpenAtom = atom({ plugin: 'slime-dashboard', key: 'effortOpen' } as const, false)
279// The last turn's length, and when the running one started (0: none runs).
280const lastTurnMsAtom = atom({ plugin: 'slime-dashboard', key: 'lastTurnMs' } as const, 0)
281const turnStartedAtAtom = atom({ plugin: 'slime-dashboard', key: 'turnStartedAt' } as const, 0)
282
283const SKILL_ROWS = 5
284const SKILLS_KEY = 'skills'
285const COMBOS_KEY = 'combos'
286const SKILL_ORDER_KEY = 'skillOrder'
287const CAT_ORDER_KEY = 'catOrder'
288const HIDDEN_KEY = 'hidden'
289const COLORS_KEY = 'colors'
290const ORDER_KEY = 'order'
291const WIDTH_KEY = 'width'
292// Dungeon's missions, one per Party Combo pressed, newest last; this
293// session's only.
294const squadAtom = atom({ plugin: 'slime-dashboard', key: 'squad' } as const, [] as Mission[])
295// The number the last press's mission took: never handed out twice.
296const squadSeqAtom = atom({ plugin: 'slime-dashboard', key: 'squadSeq' } as const, 0)
297
298// Dungeon's clock: moved once a second while a mission is going, so its
299// times and states are drawn afresh.
300const squadNowAtom = atom({ plugin: 'slime-dashboard', key: 'squadNow' } as const, 0)
301// The step cards opened in Dungeon (each closed until pressed), by key.
302const squadOpenAtom = atom({ plugin: 'slime-dashboard', key: 'squadOpen' } as const, [] as string[])
303// The missions cleared (ended done) this session, by number: the board's
304// count, kept when their cards are taken off.
305const squadClearedAtom = atom({ plugin: 'slime-dashboard', key: 'squadCleared' } as const, [] as number[])
306// What the person has typed so far to answer a subagent's question, by its id.
307const squadDraftsAtom = atom({ plugin: 'slime-dashboard', key: 'squadDrafts' } as const, {} as Record<string, string>)
308
309// A change to the missions; one that finds nothing to change keeps them.
310async function setSquad($: EngineInterface, fn: (missions: Mission[]) => Mission[] | undefined) {
311 await update($, squadAtom, missions => fn(missions) ?? missions)
312 const cleared = clearedOf(await read($, squadAtom), await read($, squadClearedAtom))
313 if (cleared) await update($, squadClearedAtom, () => cleared)
314 await retitleSquad($).catch(() => {})
315}
316
317// Dungeon's tab counts the missions not yet ended: `Dungeon (2)`; and
318// says so when one of them asks the person something: `Dungeon (2) ?`.
319const squadTitle = (missions: readonly Mission[]) => {
320 const going = missions.filter(m => m.endedAt === undefined)
321 const asks = going.some(m => missionState(m) === 'asking') ? ' ?' : ''
322 return going.length > 0 ? `${SQUAD_TITLE} (${going.length})${asks}` : SQUAD_TITLE
323}
324let shownSquadTitle: string | undefined
325
326// The tab's title anew when its count moved, and only while the tab is open
327// (an open of a closed id would open it).
328async function retitleSquad($: EngineInterface) {
329 const title = squadTitle(await read($, squadAtom))
330 if (title === shownSquadTitle) return
331 if (!(await $.ui.panes()).some(p => p.id === SQUAD)) return
332 shownSquadTitle = title
333 await $.ui.open({ id: SQUAD, title, columns: await read($, widthAtom) })
334}
335
336// A subagent of a mission moved on: one that is no mission's is left alone.
337const editRun = ($: EngineInterface, id: string, fn: (r: SquadRun) => SquadRun) => setSquad($, missions => updateRun(missions, id, fn))
338
339// The engine's agent list settles the runs still running (a background
340// subagent's end comes no other way).
341async function checkSquad($: EngineInterface) {
342 const missions = await read($, squadAtom)
343 const running = runsOf(missions).filter(r => r.status === 'running')
344 const at = await $.clock.now()
345 if (running.length === 0 && !watching(missions, at, LEADER_WAKE_MS)) return
346 const agents = await $.agent.list()
347 // The subagents a leader (or a step) spawns run under this plugin's own
348 // spawn, whose agent.spawn hooks skip this one: the agent list names them.
349 for (const a of agentsToFollow(missions, agents, LEADER_MODEL)) await started($, a.id, a.model, a.description, a.parentId)
350 // A run awaiting its background work has ended its turn: the list says so,
351 // but it goes on until that work is done (or its mission ends).
352 for (const r of running) {
353 const status = statusOfAgent(agents.find(a => a.id === r.id)?.status ?? '')
354 if (!status || (r.awaiting && status === 'completed')) continue
355 await editRun($, r.id, x => ({ ...x, status, endedAt: x.endedAt ?? at }))
356 // A leader's end is its mission's, once nothing it sent out still runs.
357 const busy = agents.some(a => a.parentId === r.id && !statusOfAgent(a.status))
358 await setSquad($, missions => endLed(missions, r.id, status === 'completed' ? 'done' : status === 'stopped' ? 'stopped' : 'error', at, busy))
359 }
360 // A run the engine no longer lists has long ended, its end unseen.
361 for (const r of running) {
362 if (!r.awaiting && !agents.some(a => a.id === r.id) && at - r.startedAt > GONE_MS) await editRun($, r.id, x => (x.status === 'running' ? { ...x, status: 'completed', endedAt: x.endedAt ?? at } : x))
363 }
364 // A mission whose leader ended before what it waited on did ends now.
365 await setSquad($, missions => settleLed(missions, at))
366}
367
368// How long a run may go unlisted by the engine before Dungeon takes it as ended.
369const GONE_MS = 10_000
370
371// How long after its mission ended a leader may still be woken by a report.
372const LEADER_WAKE_MS = 120_000
373
374// Dungeon's tab, at the width Setting's Width chose.
375async function openSquad($: EngineInterface) {
376 shownSquadTitle = squadTitle(await read($, squadAtom))
377 return $.ui.open({ id: SQUAD, title: shownSquadTitle, columns: await read($, widthAtom) })
378}
379
380// Setting's Weather: whether the sky is read from wttr.in, which places the
381// machine by its IP address. Off unless chosen, kept across sessions; off, the
382// sky follows this computer's clock and draws its weather by the hour.
383const LIVE_WEATHER_KEY = 'liveWeather'
384const liveWeatherAtom = atom({ plugin: 'slime-dashboard', key: 'liveWeather' } as const, false)
385let liveWeather = false
386
387// Setting's Performance, kept across sessions.
388const PERF_KEY = 'performance'
389const perfAtom = atom({ plugin: 'slime-dashboard', key: 'performance' } as const, 'high' as Perf)
390// Whether Performance's row of levels is open; picking one closes it.
391const perfOpenAtom = atom({ plugin: 'slime-dashboard', key: 'perfOpen' } as const, false)
392// Tools whose call itself waits on the person.
393const ASKING_TOOLS = new Set(['AskUserQuestion', 'ExitPlanMode'])
394// Notifications that mean the person is asked: a permission dialog, an MCP elicitation.
395const ASKING_NOTICES = new Set(['permission_prompt', 'elicitation_dialog'])
396
397// Animation lives in the module: a reload restarts the walk, the state stays.
398let busy = false
399let model = ''
400let tick = 0
401let columns = 0
402let minions: SlimeMinion[] = []
403let weather: SlimeWeather = DEFAULT_WEATHER
404let vitals: Vitals = FULL
405let waiting = false
406let unloading = false
407// This computer's UTC offset in minutes, read from `date +%z` when the
408// session starts; until then (or where it cannot run) event stamps use the
409// plugin environment's own zone.
410let tzOffset: number | undefined
411
412// The name from the session's transcript, for a load that has not seen a
413// prompt carry it yet: the last /rename, else the last name made up for it.
414async function readSessionTitle($: EngineInterface) {
415 const file = `${await projectDir($)}/${await $.session.id()}.jsonl`
416 const run = await $.process.run(['grep', '-o', '"\\(customTitle\\|aiTitle\\)":"[^"]*"', file]).catch(() => undefined)
417 if (run?.exitCode !== 0) return
418 const named = titleFrom(run.stdout)
419 if (named) await setSessionTitle($, named)
420}
421
422// The cache TTL as Claude Code settles it: the environment, then the
423// settings files nearest first (local, project, user), then the plan.
424async function refreshCacheTtl($: EngineInterface) {
425 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
426 const cwd = await $.session.cwd()
427 const files = [`${cwd}/.claude/settings.local.json`, `${cwd}/.claude/settings.json`, `${configDir}/settings.json`]
428 const settings = await Promise.all(
429 files.map(async f => {
430 try {
431 return JSON.parse(await $.fs.read(f)) as unknown
432 } catch {
433 return undefined
434 }
435 }),
436 )
437 const v = await read($, vitalsAtom)
438 const ttl = cacheTtlOf(await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'), settings, v.plan, isDown(v))
439 await update($, cacheTtlAtom, () => ttl)
440 warmTtl = ttl.ttl
441}
442
443// Today's Journal record with `delta` added, made to what the store holds
444// now so windows open at once each add their own.
445async function addJournal($: EngineInterface, delta: Partial<Omit<Day, 'day'>>) {
446 const day = dayKey(await $.clock.now(), tzOffset ?? 0)
447 const list = addToDay(journalFrom(await $.store.get(JOURNAL_KEY)), day, delta)
448 await $.store.set(JOURNAL_KEY, list)
449 await update($, journalAtom, () => list)
450}
451
452// The first request of a main-thread turn: a return after the cache would
453// have lapsed is a rescue when warming kept it (a large read), else a cold
454// start (it wrote the prefix afresh). Either way the warming streak ends.
455async function firstStep($: EngineInterface, usage: { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) {
456 const now = await $.clock.now()
457 const ttl = (await read($, cacheTtlAtom)).ttl
458 if (lastRealAt > 0) {
459 const gap = now - lastRealAt
460 const readTokens = usage.cache_read_input_tokens
461 const verdict = returnVerdict(gap, ttlMs(ttl), readTokens, lastPrefix, streakPings)
462 const idle = `${Math.round(gap / 60_000)}m idle`
463 if (verdict === 'rescued') {
464 await addJournal($, { rescues: 1, rescued: readTokens, saved: readTokens * (rewriteCost(ttl) - READ_COST) })
465 await logEvent($, `♨ Cache rescued: ${compact(readTokens)} still warm after ${idle}`)
466 } else if (gap > ttlMs(ttl) && !cacheKept(readTokens, lastPrefix)) {
467 warmShared = readTokens
468 await $.store.set(WARM_SHARED_KEY, readTokens)
469 await addJournal($, { cold: 1, coldTokens: usage.cache_creation_input_tokens })
470 const why = verdict === 'lapsed' ? `lapsed despite ${streakPings} ping${streakPings === 1 ? '' : 's'}` : 'cold'
471 await logEvent($, `♨ Cache ${why}: rewrote ${compact(usage.cache_creation_input_tokens)} after ${idle}`)
472 }
473 }
474 streakPings = 0
475 streakSpent = 0
476 warmRest = false
477}
478
479// Cache Warming's switch: on or off.
480async function toggleWarm($: EngineInterface) {
481 warmAuto = !warmAuto
482 await update($, warmAutoAtom, () => warmAuto)
483 if (warmAuto) {
484 warmRest = false
485 startWarmTimer($)
486 } else {
487 warmTimer?.cancel()
488 warmTimer = undefined
489 }
490 await logEvent($, `♨ Cache Warming ${warmAuto ? 'on' : 'off'}`)
491}
492
493// Warming rests until the next turn: keeping this cache no longer pays, or
494// there is none to keep.
495async function restWarm($: EngineInterface, why: string) {
496 if (warmRest) return
497 warmRest = true
498 if (streakPings > 0) await logEvent($, `♨ Cache Warming rests: ${why}`)
499}
500
501// Each beat of the warming loop: while on and idle (no turn running, no
502// subagent at work, no question waiting), a fork goes out a little before the
503// cache would lapse. Its prefix is read from the cache, which starts the
504// cache's time over; nothing joins the conversation. It stops once its span
505// is over, when a limit runs out, and when the cache turns out gone already.
506async function warmBeat($: EngineInterface) {
507 if (!warmAuto || warmRest || warmBusy) return
508 if (isDown(vitals)) return restWarm($, 'out of HP/MP')
509 const now = await $.clock.now()
510 const idle = !moving(busy, minions) && !waiting && !unloading
511 const ttl = ttlMs((await read($, cacheTtlAtom)).ttl)
512 const next = warmStep(now, idle, await read($, cacheAtAtom), ttl)
513 if (next.kind !== 'warm') return
514 // Too little to keep, or past break-even: another ping would cost more
515 // than coming back could save.
516 if (!pingPays(streakSpent, lastPrefix, warmTtl, warmShared)) {
517 return restWarm($, streakPings === 0 ? 'too little to keep warm' : 'more pings would cost more than they save')
518 }
519 warmBusy = true
520 try {
521 const r = await $.model.fork({ prompt: WARM_PROMPT })
522 if (!r.isAnswered && r.reason === 'nothing-to-fork') return await restWarm($, 'no conversation to keep warm')
523 const readTokens = r.usage?.cache_read_input_tokens ?? 0
524 const writeTokens = r.usage?.cache_creation_input_tokens ?? 0
525 const at = await $.clock.now()
526 if (!r.isAnswered && readTokens === 0) {
527 await logEvent($, `♨ Cache Warming: no reply (${r.reason}), trying again`)
528 return
529 }
530 if (!cacheKept(readTokens, lastPrefix)) return await restWarm($, 'the cache had already lapsed')
531 await update($, cacheAtAtom, () => at)
532 await update($, tallyAtom, t => addUsage(t, r.usage!))
533 streakPings++
534 streakSpent += readTokens
535 lastPrefix = Math.max(lastPrefix, readTokens)
536 const w = rewriteCost((await read($, cacheTtlAtom)).ttl)
537 await addJournal($, { pings: 1, pingRead: readTokens, saved: -(readTokens * READ_COST + writeTokens * w) })
538 const nextAt = clockText(at + ttl, tzOffset ?? 0)
539 await logEvent($, `♨ Cache warmed: ${compact(readTokens)} read, next by ${nextAt}`)
540 } catch (error) {
541 const message = error instanceof Error ? error.message : String(error)
542 await logEvent($, `♨ Cache Warming: ${message}`)
543 } finally {
544 warmBusy = false
545 }
546}
547
548// The project's transcripts, where readSessionTitle reads this session's.
549async function projectDir($: EngineInterface) {
550 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${await $.env.get('HOME')}/.claude`
551 return `${configDir}/projects/${(await $.session.cwd()).replace(/[^A-Za-z0-9]/g, '-')}`
552}
553
554// [≡] pressed: closed, it lists the recent sessions (read afresh each time:
555// the newest transcripts' names, grep'd as readSessionTitle does); open, it
556// closes.
557async function pressSessions($: EngineInterface) {
558 if (await read($, sessionsOpenAtom)) {
559 await update($, sessionsOpenAtom, () => false)
560 return
561 }
562 await update($, sessionsOpenAtom, () => true)
563 const dir = await projectDir($)
564 const entries = await $.fs.list(dir).catch(() => [])
565 // A few more than are shown, as some turn out never typed into.
566 const newest = entries
567 .filter(f => f.kind === 'file' && f.name.endsWith('.jsonl'))
568 .sort((a, b) => b.mtimeMs - a.mtimeMs)
569 .slice(0, RECENT_SESSIONS * 2 + 1)
570 const found = await Promise.all(
571 newest.map(async f => {
572 const run = await $.process.run(['grep', '-o', '"\\(customTitle\\|aiTitle\\)":"[^"]*"\\|"type":"user"', `${dir}/${f.name}`]).catch(() => undefined)
573 return { id: f.name.slice(0, -'.jsonl'.length), at: f.mtimeMs, lines: run?.stdout ?? '' }
574 }),
575 )
576 const offset = tzOffset ?? 0
577 const untitled = (at: number) => {
578 const d = new Date(at + offset)
579 const two = (n: number) => String(n).padStart(2, '0')
580 return `${two(d.getUTCMonth() + 1)}/${two(d.getUTCDate())} ${two(d.getUTCHours())}:${two(d.getUTCMinutes())}`
581 }
582 const list = recentFrom(found, await $.session.id(), RECENT_SESSIONS, untitled)
583 await update($, recentSessionsAtom, () => list)
584}
585
586// A recent session picked: /resume takes the panel to it.
587async function resumeSession($: EngineInterface, s: SlimeRecentSession) {
588 await update($, sessionsOpenAtom, () => false)
589 await logEvent($, `Resume: ${s.title}`)
590 try {
591 await $.command.run({ command: 'resume', args: s.id })
592 } catch (error) {
593 const why = error instanceof Error ? error.message : String(error)
594 $.ui.toast(`Session: /resume did not run: ${why}`)
595 }
596}
597
598// The sign pressed: closed, it opens the field holding the current name;
599// open, it renames with what the field holds, as Enter in the field does.
600async function pressSign($: EngineInterface) {
601 if (!(await read($, renameOpenAtom))) {
602 await update($, renameDraftAtom, () => '')
603 await update($, renameOpenAtom, () => true)
604 return
605 }
606 await renameTo($, await read($, renameDraftAtom))
607}
608
609// Renames the session with /rename and closes the field. A name left empty
610// or unchanged just closes, renaming nothing, and Event Message keeps the old
611// name, should a rename need undoing.
612async function renameTo($: EngineInterface, value: string) {
613 const old = await read($, sessionTitleAtom)
614 const next = value.trim()
615 await update($, renameOpenAtom, () => false)
616 if (!next || next === old) return
617 await $.command.run({ command: 'rename', args: next })
618 await update($, sessionTitleAtom, () => next)
619 await logEvent($, `Renamed: ${old || '—'} → ${next}`)
620}
621
622// Respawn confirmed: /clear starts a new session, and the scene plays the old
623// slime leaping away and a new one coming down in a beam of light.
624async function respawn($: EngineInterface) {
625 await update($, respawnConfirmAtom, () => false)
626 await restWarm($, 'a new session')
627 $.ui.toast('Skill Box: sending /clear')
628 try {
629 await $.command.run({ command: 'clear', args: '' })
630 } catch (error) {
631 const why = error instanceof Error ? error.message : String(error)
632 $.ui.toast(`Skill Box: /clear did not run: ${why}`)
633 await logEvent($, 'Skill did not run: /clear')
634 return
635 }
636 respawnAt = tick
637 await update($, sessionTitleAtom, () => '')
638 await logEvent($, 'Respawned: a new session')
639}
640
641async function setSessionTitle($: EngineInterface, title: string | undefined) {
642 const next = title?.trim()
643 if (next) await update($, sessionTitleAtom, () => next)
644}
645
646async function readLocalZone($: EngineInterface) {
647 const run = await $.process.run(['date', '+%z']).catch(() => undefined)
648 if (run?.exitCode === 0) tzOffset = offsetOf(run.stdout) ?? tzOffset
649}
650// The tick the troop found its treasure chest on, while that scene plays.
651let partyAt: number | undefined
652// When each new little slime starts budding off the main one. They come out
653// one at a time, EMERGE_GAP ticks apart, however many subagents start at once.
654// Module-only: after a reload the troop is simply all out already.
655const EMERGE_GAP = 8
656const budAt = new Map<string, number>()
657let lastBud = -Infinity
658// Each little slime's place in line as drawn, easing toward its real place
659// when one ahead of it drops out, so the gap closes instead of jumping.
660const CLOSE_SPEED = 0.15
661const pos = new Map<string, number>()
662// When each finished slime dropped out of line, and the x it stood at.
663// Long enough to fall back off any pane, then it is gone for good.
664const LEAVE_TICKS = 70
665const leftAt = new Map<string, { tick: number; x: number }>()
666// The last slime home, which stays for the treasure chest and then merges
667// into the main slime instead of falling back.
668let stayer: string | undefined
669const off: Offsets = { cloud: 0, bird: 0, tree: 0, rock: 0, ground: 0 }
670
671async function setBusy($: EngineInterface, value: boolean) {
672 busy = value
673 await update($, busyAtom, () => value)
674}
675
676async function setMinions($: EngineInterface, fn: (list: SlimeMinion[]) => SlimeMinion[]) {
677 minions = fn(minions)
678 await update($, minionsAtom, () => minions)
679}
680
681const followersOf = (list: SlimeMinion[]) =>
682 list.map(m => {
683 const left = leftAt.get(m.id)
684 if (left) return { model: m.model, leave: { age: tick - left.tick, x: left.x } }
685 if (m.id === stayer) return { model: m.model, pos: pos.get(m.id), stay: true as const }
686 const start = budAt.get(m.id)
687 const age = start === undefined ? undefined : tick - start
688 return { model: m.model, age: age !== undefined && age < EMERGE_TICKS ? age : undefined, pos: pos.get(m.id) }
689 })
690
691// Ease every slime still in line one step toward its place.
692function closeRanks() {
693 minions
694 .filter(m => !m.done || m.id === stayer)
695 .forEach((m, i) => {
696 const at = pos.get(m.id) ?? i
697 pos.set(m.id, Math.abs(i - at) <= CLOSE_SPEED ? i : at + Math.sign(i - at) * CLOSE_SPEED)
698 })
699}
700// The troop keeps travelling while any subagent is still out working.
701const moving = (main: boolean, list: SlimeMinion[]) => main || list.some(m => !m.done)
702const partyTick = () => (partyAt === undefined ? undefined : tick - partyAt)
703
704// A subagent still owes its answer while it runs, waits on background work of
705// its own, or (a teammate) waits for a message. Once it has handed back,
706// failed or been stopped, or the engine has dropped it, its slime drops out
707// of line and falls behind; the last one stays, the main slime finds a
708// treasure chest, and the two open it together before the little one merges
709// back into the main slime.
710const OUT: ReadonlySet<AgentStatus> = new Set(['pending', 'running', 'waiting', 'idle'])
711
712// Writing down an event never gets in the way of what it describes.
713async function toggleSection($: EngineInterface, id: string) {
714 const hidden = await read($, hiddenAtom)
715 const next = hidden.includes(id) ? hidden.filter(h => h !== id) : [...hidden, id]
716 await update($, hiddenAtom, () => next)
717 await $.store.set(HIDDEN_KEY, next)
718}
719
720// Open the pane, asking for the width Setting's Width chose.
721async function openPane($: EngineInterface) {
722 return $.ui.open({ ...OPEN, columns: await read($, widthAtom) })
723}
724
725// Setting's Performance: how much the scene draws, kept.
726async function setPerf($: EngineInterface, level: Perf) {
727 setPerformance(level)
728 await update($, perfAtom, () => level)
729 await update($, perfOpenAtom, () => false)
730 await $.store.set(PERF_KEY, level)
731}
732
733// Setting's Width: a column narrower or wider, kept, and the pane reopened at it.
734async function nudgeWidth($: EngineInterface, by: -1 | 1) {
735 const next = Math.max(MIN_COLUMNS, Math.min(MAX_COLUMNS, (await read($, widthAtom)) + by))
736 await update($, widthAtom, () => next)
737 await $.store.set(WIDTH_KEY, next)
738 await openPane($)
739}
740
741// Setting's Order: the section a place up or down (from the order as it is
742// when pressed), or the default order.
743async function moveOrder($: EngineInterface, id: SectionId, by: -1 | 1) {
744 await setOrder($, moveSection(await read($, orderAtom), id, by))
745}
746
747async function setOrder($: EngineInterface, order: SectionId[]) {
748 await update($, orderAtom, () => order)
749 await $.store.set(ORDER_KEY, order)
750}
751
752// Setting's Color: the family's next color, round the palettes.
753async function cycleColor($: EngineInterface, family: Family) {
754 const colors = await read($, colorsAtom)
755 const at = PALETTE_IDS.indexOf(colors[family])
756 await setSlimeColors($, { ...colors, [family]: PALETTE_IDS[(at + 1) % PALETTE_IDS.length]! })
757}
758
759async function setSlimeColors($: EngineInterface, colors: SlimeColors) {
760 setColors(colors)
761 await update($, colorsAtom, () => colors)
762 await $.store.set(COLORS_KEY, colors)
763}
764
765async function setUnloading($: EngineInterface, value: boolean) {
766 unloading = value
767 await update($, unloadingAtom, () => value)
768 $.ui.invalidate('ui.render')
769}
770
771// Setting's Reload: load the dashboard afresh, as a save to its files would.
772async function reloadDashboard($: EngineInterface) {
773 await logEvent($, 'Reloaded the dashboard')
774 await $.command.run({ command: 'reload-plugins' })
775}
776
777async function logEvent($: EngineInterface, text: string) {
778 try {
779 const at = await $.clock.now()
780 await update($, eventsAtom, list => addEvent(list, { at, text }))
781 } catch {
782 // The event goes unrecorded.
783 }
784}
785
786const agentLine = (m: { model: string; description?: string }) =>
787 `${modelInfo(m.model).name}: ${cleanSummary(m.description || 'subagent')}`
788
789async function checkMinions($: EngineInterface) {
790 if (!minions.some(m => !m.done)) return
791 const agents = await $.agent.list()
792 const out = new Set(agents.filter(a => OUT.has(a.status)).map(a => a.id))
793 const killed = new Set(agents.filter(a => a.status === 'killed').map(a => a.id))
794 const finished = minions.filter(m => !m.done && !out.has(m.id))
795 if (finished.length === 0) return
796 const cx = homeCx(columns || OPEN.columns)
797 const lastHome = finished.length === minions.filter(m => !m.done).length && partyAt === undefined
798 if (lastHome) stayer = finished[finished.length - 1]!.id
799 for (const m of finished) {
800 if (m.id === stayer) continue
801 leftAt.set(m.id, { tick, x: slotOf(cx, pos.get(m.id) ?? minions.filter(o => !o.done).indexOf(m)) })
802 pos.delete(m.id)
803 }
804 await setMinions($, list => list.map(m => (finished.includes(m) ? { ...m, done: true } : m)))
805 for (const m of finished) await logEvent($, `${killed.has(m.id) ? '✖ Stopped' : '✔ Finished'} ${agentLine(m)}`)
806 if (!minions.some(m => !m.done) && partyAt === undefined) partyAt = tick
807}
808
809// Party's [x]: stop that subagent with TaskStop, under the same permission
810// check as the model's own call; its slime then drops out as any finished one.
811async function stopMinion($: EngineInterface, id: string) {
812 try {
813 await $.tool.call({ tool: 'TaskStop', task_id: id })
814 } catch {
815 // Already gone, or the stop was refused: nothing more to do.
816 }
817 await checkMinions($).catch(() => {})
818}
819
820// Dungeon's [X] on a step's card: that subagent, and the helpers it sent out
821// that still run, are called back (TaskStop, as Party's [x]); its leader hears
822// it was stopped. A question one of them put to the person goes unanswered.
823async function recallRuns($: EngineInterface, ids: readonly string[]) {
824 for (const id of ids) answerAsk(id, ASK_STOPPED)
825 for (const id of ids) await stopMinion($, id)
826 const at = await $.clock.now()
827 for (const id of ids) await editRun($, id, r => (r.status === 'running' ? { ...r, status: 'stopped', endedAt: at, awaiting: false } : r))
828 await checkSquad($).catch(() => {})
829}
830
831// Dungeon's [Recall] on a leader's card: the whole mission is called back, its
832// leader first (so it sends out no more), then all it sent out that runs.
833async function recallMission($: EngineInterface, missionId: number) {
834 const mission = (await read($, squadAtom)).find(m => m.id === missionId)
835 if (!mission) return
836 const mine = new Set(runsOf([mission]).map(r => r.id))
837 const agents = await $.agent.list().catch(() => [])
838 // What it sent out that Dungeon has not seen yet, at any depth.
839 for (let grew = true; grew; ) {
840 grew = false
841 for (const a of agents) if (a.parentId !== undefined && mine.has(a.parentId) && !mine.has(a.id)) (mine.add(a.id), (grew = true))
842 }
843 const live = (id: string) => {
844 const status = agents.find(a => a.id === id)?.status
845 return status === undefined ? runsOf([mission]).some(r => r.id === id && r.status === 'running') : !statusOfAgent(status)
846 }
847 const order = [...(mission.leader ? [mission.leader.id] : []), ...[...mine].filter(id => id !== mission.leader?.id)]
848 for (const id of mine) answerAsk(id, ASK_STOPPED)
849 for (const id of order) if (live(id)) await stopMinion($, id)
850 const at = await $.clock.now()
851 const stop = (r: SquadRun): SquadRun => (r.status === 'running' ? { ...r, status: 'stopped', endedAt: at, awaiting: false } : r)
852 await setSquad($, missions => missions.map(m => (m.id !== missionId ? m : { ...mapRuns(m, stop), endedAt: m.endedAt ?? at, outcome: 'stopped', recalled: true })))
853}
854
855// An ended mission's [↻]: its combo, as it was pressed, runs again with the
856// same prompt, as a mission of its own (this one stays as it is).
857async function rerunMission($: EngineInterface, missionId: number) {
858 const m = (await read($, squadAtom)).find(x => x.id === missionId)
859 if (m && m.endedAt !== undefined) await runCombo($, missionCombo(m), { input: m.input ?? '' })
860}
861
862// Missions taken off Dungeon (its [x], [Clear All]): a question one of their
863// subagents still waits on is let go unanswered.
864async function dropMissions($: EngineInterface, keep: (m: Mission) => boolean) {
865 for (const m of await read($, squadAtom)) if (!keep(m)) for (const r of runsOf([m])) answerAsk(r.id, ASK_DROPPED)
866 await setSquad($, missions => missions.filter(keep))
867}
868
869// A combo's subagent asking the person (ASK_TOOL): its question shows on its
870// card in Dungeon, which opens on it, and the call waits there for the answer.
871const asks = new Map<string, { answer?: string }>()
872// How long a question waits for the person before the subagent goes on alone.
873const ASK_WAIT_MS = 30 * 60_000
874const ASK_STOPPED = 'The person stopped you before answering. Stop here.'
875const ASK_DROPPED = 'The person did not answer. Go on with your best judgment, and say what you assumed.'
876const ASK_TIMEOUT = 'The person did not answer in time. Go on with your best judgment, and say what you assumed.'
877
878// The person's answer to the question `id` asked; nothing when none waits.
879function answerAsk(id: string, answer: string) {
880 const pending = asks.get(id)
881 if (pending && pending.answer === undefined) pending.answer = answer
882}
883
884async function sendAnswer($: EngineInterface, id: string, answer: string) {
885 const text = answer.trim()
886 if (!text) return
887 answerAsk(id, text)
888 await update($, squadDraftsAtom, ({ [id]: _, ...rest }) => rest)
889}
890
891// The cards a run's question shows on, opened: its mission, its waves, and
892// its step's (or other's) card.
893function askKeys(missions: readonly Mission[], id: string): string[] {
894 for (const m of missions) {
895 const base = [`mission-${m.id}-fold`]
896 if (m.leader?.id === id) return base
897 for (const w of m.waves) {
898 const si = w.steps.findIndex(st => stepRuns(st).some(r => r.id === id))
899 if (si >= 0) return [...base, `mission-${m.id}-waves`, `mission-${m.id}-${w.n}-${si}`]
900 }
901 const oi = m.others.findIndex(r => r.id === id)
902 if (oi >= 0) return [...base, `mission-${m.id}-waves`, `mission-${m.id}-o${oi}`]
903 }
904 return []
905}
906
907// The chest is behind them and the little one has merged: it is gone.
908async function endParty($: EngineInterface) {
909 partyAt = undefined
910 const merged = stayer
911 stayer = undefined
912 if (merged === undefined) return
913 pos.delete(merged)
914 budAt.delete(merged)
915 await setMinions($, list => list.filter(m => m.id !== merged))
916}
917
918// Forget the slimes that have fallen back out of sight.
919async function dropGone($: EngineInterface) {
920 const gone = minions.filter(m => {
921 const left = leftAt.get(m.id)
922 return left !== undefined && tick - left.tick >= LEAVE_TICKS
923 })
924 if (gone.length === 0) return
925 for (const m of gone) {
926 leftAt.delete(m.id)
927 budAt.delete(m.id)
928 }
929 await setMinions($, list => list.filter(m => !gone.includes(m)))
930}
931
932// Is it day or night where this machine is, and what is the sky doing. A
933// failed or unreadable reply keeps the sky as it was and tries again soon.
934// Answers what it read, or why it read nothing, for `/slime-dashboard weather`.
935let retryPending = false
936async function refreshWeather($: EngineInterface): Promise<string> {
937 if (!liveWeather) {
938 const next = await localSky($)
939 return `Weather: ${next.day ? 'day' : 'night'}, ${next.sky} (from this computer's clock; Setting's Weather is Off).`
940 }
941 let why: string
942 try {
943 // wttr.in answers a browser with a page; asked as curl, with the line alone.
944 const reply = await $.http.fetch(WEATHER_URL, { headers: { 'User-Agent': 'curl/8' } })
945 const read = reply.ok ? parseWeather(reply.text) : undefined
946 const next = read && { ...read, readAt: await $.clock.now() }
947 if (next) {
948 weather = next
949 await update($, weatherAtom, () => next)
950 return `Weather: ${next.day ? 'day' : 'night'}, ${next.sky} (wttr.in said "${reply.text.trim()}").`
951 }
952 why = reply.ok ? `an unreadable reply "${reply.text.trim().slice(0, 60)}"` : `HTTP ${reply.status}`
953 } catch (error) {
954 why = error instanceof Error ? error.message : String(error)
955 }
956 if (!retryPending) {
957 retryPending = true
958 $.clock.after(WEATHER_RETRY_MS, () => {
959 retryPending = false
960 void refreshWeather($)
961 })
962 }
963 return `Weather: could not read wttr.in (${why}); the sky stays as it was and tries again in two minutes.`
964}
965
966// The sky without wttr.in, kept when it changed: day or night by this
967// computer's clock, the hour's weather drawn.
968async function localSky($: EngineInterface) {
969 const next = localWeather(await $.clock.now(), tzOffset ?? 0)
970 if (next.day !== weather.day || next.sky !== weather.sky || weather.rise !== undefined) {
971 weather = next
972 await update($, weatherAtom, () => next)
973 }
974 return weather
975}
976
977// Setting's Weather: on, the sky is read from it at once; off, the clock's.
978async function setLiveWeather($: EngineInterface, on: boolean) {
979 liveWeather = on
980 await update($, liveWeatherAtom, () => on)
981 await $.store.set(LIVE_WEATHER_KEY, on)
982 await refreshWeather($)
983}
984
985// A session between models (as /clear passes) names none for a moment: the
986// slime keeps the last one rather than flash the unknown model's green, and
987// keeps it through Respawn's scene.
988async function refreshModel($: EngineInterface) {
989 if (respawnAt !== undefined) return
990 const current = await $.session.model()
991 if (!current || current === model) return
992 if (model !== '') await logEvent($, `Model: ${modelInfo(model).name} → ${modelInfo(current).name}`)
993 // Another model has a cache of its own: nothing left to keep warm.
994 if (model !== '') await restWarm($, 'the model changed')
995 model = current
996 await update($, modelAtom, () => current)
997}
998
999async function setVitals($: EngineInterface, next: Vitals) {
1000 if (next.plan === vitals.plan && next.hp === vitals.hp && next.mp === vitals.mp && next.cp === vitals.cp) return
1001 const wasDown = isDown(vitals)
1002 vitals = next
1003 await update($, vitalsAtom, () => next)
1004 if (!wasDown && isDown(next)) await logEvent($, `x Out of ${next.hp <= 0 ? 'HP' : 'MP'}: resting until the limit resets`)
1005 if (wasDown && !isDown(next)) await logEvent($, 'Back on its feet: a limit has reset')
1006}
1007
1008// The status line's figures. A failed read keeps the bars as they were.
1009async function refreshVitals($: EngineInterface) {
1010 const usage = await $.session.usage().catch(() => undefined)
1011 if (usage) await setVitals($, vitalsOf(usage.rateLimits, usage.context.percent))
1012}
1013
1014// The bars' colors: HP a bright red, MP a bright blue; CP shades from a
1015// lighter to a darker grey along its length.
1016const BAR = { hp: '#ff5c5c', mp: '#4db8ff' }
1017// The Skill Box prompt's title banner: the MP blue deepened, so white reads on it.
1018const CP_FROM = 0x9e9e9e
1019const CP_TO = 0x4a4a4a
1020function cpColor(i: number, cells: number) {
1021 const mix = (shift: number) => {
1022 const a = (CP_FROM >> shift) & 0xff
1023 const b = (CP_TO >> shift) & 0xff
1024 return Math.round(a + ((b - a) * i) / Math.max(1, cells - 1)) << shift
1025 }
1026 return hex(mix(16) | mix(8) | mix(0))
1027}
1028
1029async function setWaiting($: EngineInterface, value: boolean) {
1030 if (value === waiting) return
1031 waiting = value
1032 await update($, waitingAtom, () => value)
1033 await logEvent($, value ? '? Waiting for your answer' : 'Answered: the troop moves on')
1034}
1035
1036// The Skill Box's skills, their order and the Party Combos are one store
1037// for every window open at once: each change is made to what the store holds
1038// now, not to this window's copy, so one window never writes another's
1039// changes away; and the copy is read afresh as the Skill Box or Party Combo section
1040// opens.
1041async function readShared($: EngineInterface) {
1042 const skills = skillsFrom(await $.store.get(SKILLS_KEY))
1043 const skillOrder = orderMapFrom(await $.store.get(SKILL_ORDER_KEY))
1044 const catOrder = namesFrom(await $.store.get(CAT_ORDER_KEY))
1045 const combos = combosFrom(await $.store.get(COMBOS_KEY))
1046 await update($, skillsAtom, () => skills)
1047 await update($, skillOrderAtom, () => skillOrder)
1048 await update($, catOrderAtom, () => catOrder)
1049 await update($, combosAtom, () => combos)
1050}
1051
1052async function setSkills($: EngineInterface, fn: (list: Skill[]) => Skill[]) {
1053 const list = fn(skillsFrom(await $.store.get(SKILLS_KEY)))
1054 await $.store.set(SKILLS_KEY, list)
1055 await update($, skillsAtom, () => list)
1056 return list
1057}
1058
1059// A skill's [▲]: it trades places with the one above it, in its category's
1060// order as the store now holds it; the arrangement is kept.
1061async function skillUp($: EngineInterface, category: string, names: string[], at: number) {
1062 const name = names[at]
1063 const kept = orderMapFrom(await $.store.get(SKILL_ORDER_KEY))
1064 const now = inOrder(names, kept[category], n => n)
1065 const next = { ...kept, [category]: name === undefined ? now : swapNames(now, now.indexOf(name), -1) }
1066 await $.store.set(SKILL_ORDER_KEY, next)
1067 await update($, skillOrderAtom, () => next)
1068}
1069
1070// A category's [▼] or [▲]: it trades places with the next or the one before,
1071// in the order the store now holds.
1072async function moveCategory($: EngineInterface, categories: string[], at: number, by: -1 | 1) {
1073 const name = categories[at]
1074 const now = inOrder(categories, namesFrom(await $.store.get(CAT_ORDER_KEY)), n => n)
1075 const next = name === undefined ? now : swapNames(now, now.indexOf(name), by)
1076 await $.store.set(CAT_ORDER_KEY, next)
1077 await update($, catOrderAtom, () => next)
1078}
1079
1080// The Skill Box's prompt: the pieces kept with Enter, then anything still in
1081// the field, a blank line between.
1082async function skillInput($: EngineInterface): Promise<string> {
1083 const parts = [...(await read($, skillPiecesAtom)), await read($, skillPromptAtom)]
1084 return parts.map(p => p.trim()).filter(p => p !== '').join('\n\n')
1085}
1086
1087// Sent: the pieces go and the field clears.
1088async function clearSkillInput($: EngineInterface) {
1089 await update($, skillPiecesAtom, () => [])
1090 await update($, skillPromptAtom, () => '')
1091}
1092
1093// The prompt field's key, new with each piece: the focus ring holds a place
1094// in the pane, not an element, so the piece's [x] drawn before the field would
1095// take it. Under a new key, the focus below waits for the field drawn after.
1096const skillPromptKey = (pieces: number) => `skill-prompt-${pieces}`
1097
1098// Enter in the prompt field: what it holds becomes a piece, the field clears
1099// and keeps the keys, ready for the next piece.
1100async function keepPiece($: EngineInterface, value: string) {
1101 if (value.trim() === '') return
1102 await update($, skillPiecesAtom, pieces => [...pieces, value.trim()])
1103 await update($, skillPromptAtom, () => '')
1104 const pieces = await read($, skillPiecesAtom)
1105 await $.ui.focus({ requestId: PANE, key: skillPromptKey(pieces.length) }).catch(() => undefined)
1106}
1107
1108// A piece's ▲ or ▼: it trades places with the one before or after (none past
1109// either end), and the focus goes along with it, so it can be pressed again.
1110async function movePiece($: EngineInterface, at: number, by: -1 | 1) {
1111 const pieces = await read($, skillPiecesAtom)
1112 const to = at + by
1113 if (to < 0 || to >= pieces.length) return
1114 await update($, skillPiecesAtom, ps => {
1115 const next = [...ps]
1116 ;[next[at], next[to]] = [next[to]!, next[at]!]
1117 return next
1118 })
1119 await $.ui.focus({ requestId: PANE, key: `skill-piece-${by < 0 ? 'up' : 'down'}-${to}` }).catch(() => undefined)
1120}
1121
1122// A skill's button: run it as the person would type it, with the Skill Box's
1123// prompt after it in quotes when one is typed; the pieces then clear.
1124async function runSkill($: EngineInterface, skill: Skill) {
1125 const name = skill.command ?? skill.name
1126 const prompt = await skillInput($)
1127 const args = prompt === '' ? '' : `"${prompt.replace(/"/g, '\\"')}"`
1128 const typed = `/${name}${args === '' ? '' : ` ${args}`}`
1129 // Said at once, since the run itself waits until the session is idle.
1130 $.ui.toast(`Skill Box: sending ${typed}`)
1131 try {
1132 await $.command.run({ command: name, args })
1133 await clearSkillInput($)
1134 await logEvent($, `Skill sent: ${typed}`)
1135 } catch (error) {
1136 // An unknown skill (not in this session's slash commands) lands here; the
1137 // prompt stays in the field to try again.
1138 const why = error instanceof Error ? error.message : String(error)
1139 $.ui.toast(`Skill Box: ${typed} did not run: ${why}`)
1140 await logEvent($, `Skill did not run: ${typed}`)
1141 }
1142}
1143
1144async function setCombos($: EngineInterface, fn: (list: Combo[]) => Combo[]) {
1145 const list = fn(combosFrom(await $.store.get(COMBOS_KEY)))
1146 await $.store.set(COMBOS_KEY, list)
1147 await update($, combosAtom, () => list)
1148 return list
1149}
1150
1151// The combo a tab shows: its draft, else as saved.
1152async function comboOf($: EngineInterface, tab: string): Promise<Combo | undefined> {
1153 return (await read($, comboEditsAtom))[tab] ?? (await read($, combosAtom)).find(c => c.name === tab)
1154}
1155
1156// An edit to a tab's combo, into its draft.
1157async function editCombo($: EngineInterface, tab: string, fn: (c: Combo) => Combo) {
1158 const combo = await comboOf($, tab)
1159 if (combo) await update($, comboEditsAtom, d => ({ ...d, [tab]: fn(combo) }))
1160}
1161
1162// Every name taken, saved or new, but the tab's own.
1163async function namesBesides($: EngineInterface, tab: string) {
1164 const saved = (await read($, combosAtom)).map(c => c.name)
1165 const drafts = Object.entries(await read($, comboEditsAtom)).filter(([t]) => t !== tab).map(([, c]) => c.name)
1166 return [...new Set([...saved, ...(await read($, comboFreshAtom)), ...drafts])].filter(n => n !== tab).map(name => ({ name, layers: [] }))
1167}
1168
1169// Party Combo's [+New]: a new combo with one empty wave, kept once saved.
1170async function newCombo($: EngineInterface) {
1171 const name = newComboName(await namesBesides($, ''))
1172 await update($, comboFreshAtom, f => [...f, name])
1173 await update($, comboEditsAtom, d => ({ ...d, [name]: addLayer({ name, layers: [] }) }))
1174 await selectCombo($, name)
1175 await update($, comboFoldAtom, () => false)
1176}
1177
1178async function selectCombo($: EngineInterface, tab: string) {
1179 await update($, comboSelAtom, () => tab)
1180 await update($, comboPickAtom, () => -1)
1181 await update($, comboCondAtom, () => -1)
1182 await update($, comboRenameAtom, () => false)
1183 await update($, comboDeleteAtom, () => false)
1184}
1185
1186// [Rename] pressed: closed, it opens a field holding the name; open, the
1187// draft takes what it holds, unless that is empty or another's.
1188async function pressRename($: EngineInterface, tab: string) {
1189 const combo = await comboOf($, tab)
1190 if (!combo) return
1191 if (!(await read($, comboRenameAtom))) {
1192 await update($, comboNameAtom, () => combo.name)
1193 await update($, comboRenameAtom, () => true)
1194 return
1195 }
1196 await update($, comboRenameAtom, () => false)
1197 const to = renameOk(await namesBesides($, tab), combo.name, await read($, comboNameAtom))
1198 if (to && to !== combo.name) await editCombo($, tab, c => ({ ...c, name: to }))
1199}
1200hooks/scene.ts 1053 lines1// The slime's world: tiny flat pixel sprites, parallax layers, and the
2// packing of a frame into Raster cells (two pixels per cell with half blocks).
3
4export const ROWS = 6
5const PX_H = ROWS * 2
6const GROUND_Y = PX_H - 1
7export const SKY = 0x01000000
8
9import { DEFAULT_WEATHER } from './weather'
10import type { Weather } from './weather'
11import type { Face } from './vitals'
12
13export type Layer = 'cloud' | 'bird' | 'tree' | 'rock' | 'ground'
14export type Offsets = Record<Layer, number>
15
16// Pixels per 100 ms tick. Clouds and birds drift even while the slime
17// sleeps; trees, rocks and the ground only move while it travels.
18const GROUND_SPEED = 0.8
19const SPEED: Record<'idle' | 'busy', Offsets> = {
20 idle: { cloud: 0.1, bird: 0.3, tree: 0, rock: 0, ground: 0 },
21 // Trees and rocks stand on the ground, so they pass with it; only the sky drifts at its own pace.
22 busy: { cloud: 0.2, bird: 0.5, tree: GROUND_SPEED, rock: GROUND_SPEED, ground: GROUND_SPEED },
23}
24
25export function step(off: Offsets, busy: boolean): void {
26 const speed = SPEED[busy ? 'busy' : 'idle']
27 for (const layer of Object.keys(off) as Layer[]) off[layer] += speed[layer]
28}
29
30export type ModelInfo = { name: string; body: number; light: number; dark: number }
31
32// The slime colors Setting's Color can give each model family; any two
33// families may share one.
34export const PALETTES = {
35 purple: { name: 'Purple', body: 0x9b5de5, light: 0xe0cdfb, dark: 0x6a3fb0 },
36 red: { name: 'Red', body: 0xe5383b, light: 0xffc2c3, dark: 0xa4161a },
37 blue: { name: 'Blue', body: 0x3a86ff, light: 0xc6ddff, dark: 0x1f4fb3 },
38 yellow: { name: 'Yellow', body: 0xffc300, light: 0xfff3c2, dark: 0xb38600 },
39 green: { name: 'Green', body: 0x2dc653, light: 0xc3f5cf, dark: 0x1a7f35 },
40 pink: { name: 'Pink', body: 0xf15bb5, light: 0xffd0ec, dark: 0xb0307f },
41} as const
42export type PaletteId = keyof typeof PALETTES
43export const PALETTE_IDS = Object.keys(PALETTES) as PaletteId[]
44
45export const FAMILIES = ['Fable', 'Opus', 'Sonnet', 'Haiku'] as const
46export type Family = (typeof FAMILIES)[number]
47export type SlimeColors = Record<Family, PaletteId>
48export const DEFAULT_COLORS: SlimeColors = { Fable: 'purple', Opus: 'red', Sonnet: 'blue', Haiku: 'yellow' }
49
50const PATTERNS: Record<Family, RegExp> = { Fable: /fable/i, Opus: /opus/i, Sonnet: /sonnet/i, Haiku: /haiku/i }
51const UNKNOWN = { body: 0x52b788, light: 0xc7ebd6, dark: 0x2d6a4f }
52
53// The colors in use: the defaults, with whatever the person picked over them.
54let chosen: SlimeColors = { ...DEFAULT_COLORS }
55
56// Keeps the families and palettes it knows and drops anything else, so a
57// stored pick from another version cannot break the scene.
58export function colorsFrom(value: unknown): SlimeColors {
59 const picked = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
60 const colors = { ...DEFAULT_COLORS }
61 for (const family of FAMILIES) {
62 const id = picked[family]
63 if (typeof id === 'string' && id in PALETTES) colors[family] = id as PaletteId
64 }
65 return colors
66}
67
68export function setColors(colors: SlimeColors): void {
69 chosen = { ...colors }
70}
71
72export function modelInfo(model: string): ModelInfo {
73 for (const family of FAMILIES) {
74 if (!PATTERNS[family].test(model)) continue
75 const version = model.match(new RegExp(`${family}[-\\s]?(\\d+)(?:[-.](\\d{1,2})(?!\\d))?`, 'i'))
76 const name = version ? `${family} ${version[1]}${version[2] ? `.${version[2]}` : ''}` : family
77 const { body, light, dark } = PALETTES[chosen[family]]
78 return { name, body, light, dark }
79 }
80 return { name: model || '…', ...UNKNOWN }
81}
82
83export const hex = (color: number) => `#${color.toString(16).padStart(6, '0')}`
84
85type Sprite = { rows: string[]; colors: Record<string, number> }
86
87// H is the glossy highlight, E the eyes, D the shaded rim. Asleep, the eyes
88// are drawn over the body as '-' characters (see SLEEP_EYES).
89// M, to the right of, below and below-right of the highlight, is a softer
90// gloss between the highlight and the body, so the shine spreads.
91const SLIME = {
92 awake: ['.BHMB.', 'DEMMED', 'DBBBBD'],
93 // The crawl's other frame: a 2px tuft pops up on the crown, so the head
94 // flickers as it goes.
95 crawl: ['..BB..', '.BHMB.', 'DEMMED', 'DBBBBD'],
96 // Off the ground the tuft stays up and the underside rounds out; both
97 // settle again on landing.
98 air: ['..BB..', '.BHMB.', 'DEMMED', 'DBBBBD', '.DDDD.'],
99 sleep: ['.BHMB.', 'DBMMBD', 'DBBBBD'],
100 // Jaw dropped wide over a blob of goo just ahead, stretching it taller:
101 // K the open mouth, R the tongue.
102 gape: ['.BHMB.', 'DEMMED', 'DKKKKD', 'DKKKKD', 'DKRRKD', 'DBBBBD'],
103 // Unloading, it opens a round mouth to spit each spray out of it.
104 spit: ['.BHMB.', 'DEMMED', 'DBKKBD', 'DKKKKD', 'DBKKBD', 'DBBBBD'],
105}
106export const MOUTH = { K: 0xffffff, R: 0xff7a9c }
107// A subagent's little slime, trailing the main one: a 2x2 ball in the air
108// that flattens on landing and springs back.
109const MINI = { ball: ['BB', 'BB'], flat: ['BBBB'] }
110const MINI_HOP = [
111 { rows: MINI.flat, lift: 0 },
112 { rows: MINI.ball, lift: 0 },
113 { rows: MINI.ball, lift: 1 },
114 { rows: MINI.ball, lift: 2 },
115 { rows: MINI.ball, lift: 3 },
116 { rows: MINI.ball, lift: 2 },
117 { rows: MINI.ball, lift: 1 },
118 { rows: MINI.ball, lift: 0 },
119]
120const MINI_GAP = 6
121// Ticks each follower lags the one ahead of it, so a hop rolls down the line
122// as a wave instead of alternating.
123const WAVE_LAG = 1
124
125
126const TREE: Sprite = {
127 rows: ['.GGG.', 'GGGGG', 'GGgGG', 'GgGGG', '.GGG.', '..T..', '..T..'],
128 colors: { G: 0x2d9a4e, g: 0x1f6f38, T: 0x7a5230 },
129}
130// Two more kinds of tree, all as wide as the first: a dark pine in tiers and
131// a round autumn tree in orange.
132const PINE: Sprite = {
133 rows: ['..P..', '.PPP.', 'PPpPP', '.PPP.', 'PPPPp', '..T..', '..T..'],
134 colors: { P: 0x1b6b40, p: 0x124d2d, T: 0x6b4426 },
135}
136const AUTUMN: Sprite = {
137 rows: ['.OOO.', 'OOoOO', 'OOOOo', '.OoO.', '..T..', '..T..'],
138 colors: { O: 0xe07a2e, o: 0xb8481f, T: 0x7a5230 },
139}
140const TREES: Sprite[] = [TREE, PINE, AUTUMN]
141// A tall oval tree in a lighter green, taller than the rest, that stands only
142// where the road starts: behind the autumn tree in the opening scene, which
143// every load begins at.
144const TALL: Sprite = {
145 rows: ['.LLL.', 'LLLLL', 'LLlLL', 'LLLLL', 'LlLLL', 'LLLLL', '.LLL.', '..T..', '..T..'],
146 colors: { L: 0x4caf50, l: 0x2e7d32, T: 0x7a5230 },
147}
148// It stands half behind the autumn tree, which overlaps its right edge.
149const LANDMARKS = [{ x: 13, sprite: TALL }]
150const ROCK: Sprite = { rows: ['.rR.', 'RRRR'], colors: { R: 0x8d8d99, r: 0xb8b8c4 } }
151const CLOUD: Sprite = { rows: ['..CCC...', 'CCCCCCCC'], colors: { C: 0xd9dce3 } }
152const BIRDS: Sprite[] = [
153 { rows: ['K.K', '.K.'], colors: { K: 0x8a8a8a } },
154 { rows: ['.K.', 'K.K'], colors: { K: 0x8a8a8a } },
155]
156
157// `sparse` items stay away on a clear day; `overcast` ones only come with
158// cloud, rain or snow.
159type Item = { x: number; y?: number; sparse?: true; overcast?: true }
160type Band = { layer: Layer; span: number; items: Item[]; sprite: Sprite }
161
162// Trees and rocks are scattered rather than repeating: the ground is cut into
163// `span`-pixel stretches, and each stretch holds one at a spot of its own
164// (`jitter` pixels of play), or, one time in `skip`, none at all. The same
165// stretch always comes out the same, so a scene scrolls back and forth stably.
166type Scatter = { layer: 'tree' | 'rock'; sprite: Sprite; span: number; jitter: number; skip: number; seed: number }
167const SCATTER: Scatter[] = [
168 { layer: 'tree', sprite: TREE, span: 50, jitter: 38, skip: 4, seed: 0x7ee },
169 { layer: 'rock', sprite: ROCK, span: 60, jitter: 43, skip: 3, seed: 0x60c },
170]
171const unit = (n: number, seed: number) => (Math.imul((n ^ seed) | 0, 2654435761) >>> 0) / 2 ** 32
172
173// Which kind of tree stands at world x: picked at random, but the same spot
174// always grows the same one.
175export const treeKind = (worldX: number) => Math.floor(unit(worldX, 0x3a7) * TREES.length)
176
177// World x of every tree or rock whose left edge lies in [from, to).
178// For the README's looping picture only: the road repeats every `period`
179// pixels (a multiple of the grass stripe's 8), so the last frame of a walk
180// one period long leads back into the first. Undefined, as in the pane, it
181// never repeats. Things too near a period's end to fit are left out, so the
182// seam never splits one.
183let loopPeriod: number | undefined
184export function setLoop(period: number | undefined): void {
185 loopPeriod = period
186}
187function looped(from: number, to: number, width: number, inPeriod: (from: number, to: number) => number[]): number[] {
188 const P = loopPeriod!
189 const base = inPeriod(0, P).filter(x => x + width + 2 <= P)
190 const found: number[] = []
191 for (let n = Math.floor(from / P) - 1; n * P < to; n++) {
192 for (const x of base) if (x + n * P >= from && x + n * P < to) found.push(x + n * P)
193 }
194 return found
195}
196
197export function scattered(layer: 'tree' | 'rock', from: number, to: number): number[] {
198 if (loopPeriod === undefined) return scatteredAlong(layer, from, to)
199 const width = SCATTER.find(s => s.layer === layer)!.sprite.rows[0]!.length
200 return looped(from, to, width, (a, b) => scatteredAlong(layer, a, b))
201}
202function scatteredAlong(layer: 'tree' | 'rock', from: number, to: number): number[] {
203 const sc = SCATTER.find(s => s.layer === layer)!
204 const found: number[] = []
205 for (let k = Math.floor(from / sc.span) - 1; k * sc.span < to; k++) {
206 if (Math.floor(unit(k, sc.seed) * sc.skip) === 0) continue
207 if (!thingKept(k, sc.seed)) continue
208 const x = k * sc.span + Math.floor(unit(k, sc.seed * 3 + 1) * sc.jitter)
209 if (x >= from && x < to) found.push(x)
210 }
211 return found
212}
213
214// Blobs of goo in every color lie along the road, as rocks do but closer
215// together and never on a rock. The slime eats each one it reaches (its
216// context filling up), so none is ever drawn behind it; unloading, it spits
217// them back out (UNLOAD_COLORS.pixels).
218export const GOO_COLORS = [0xff595e, 0xff924c, 0xffca3a, 0x8ac926, 0x1982c4, 0x6a4c93, 0xff70a6] as const
219const GOO_SCATTER = { span: 20, jitter: 13, skip: 3, seed: 0x900 }
220// A blob is a plain 2x2 block, spat out or lying on the road.
221const GOO = ['GG', 'GG']
222const GOO_W = 2
223// Columns ahead of the slime at which it opens wide for the next blob.
224const GAPE_REACH = 3
225
226// World x (in the rocks' frame) of every blob whose left edge lies in [from, to).
227export function goos(from: number, to: number): number[] {
228 if (loopPeriod !== undefined) return looped(from, to, GOO_W, goosAlong)
229 return goosAlong(from, to)
230}
231function goosAlong(from: number, to: number): number[] {
232 const sc = GOO_SCATTER
233 const found: number[] = []
234 for (let k = Math.floor(from / sc.span) - 1; k * sc.span < to; k++) {
235 if (Math.floor(unit(k, sc.seed) * sc.skip) === 0) continue
236 if (!thingKept(k, sc.seed)) continue
237 const x = k * sc.span + Math.floor(unit(k, sc.seed * 3 + 1) * sc.jitter)
238 if (x < from || x >= to) continue
239 // Clear of rocks ahead, and far enough past one behind that the slime has
240 // landed by the time it comes within reach to gape.
241 const reachLeft = x - GAPE_REACH - SLIME.awake[0]!.length + 1
242 if (scattered('rock', reachLeft - ROCK_CLEAR - ROCK_W + 1, x + GOO_W + 1).length > 0) continue
243 found.push(x)
244 }
245 return found
246}
247export const gooColor = (worldX: number) => GOO_COLORS[Math.floor(unit(worldX, 0x6c0) * GOO_COLORS.length)]!
248
249// Each band repeats every `span` pixels so scrolling wraps seamlessly.
250const BANDS: Band[] = [
251 { layer: 'cloud', span: 50, sprite: CLOUD, items: [{ x: 4, y: 0 }, { x: 28, y: 2, sparse: true }, { x: 16, y: 1, overcast: true }] },
252 { layer: 'bird', span: 75, sprite: BIRDS[0]!, items: [{ x: 18, y: 5 }, { x: 55, y: 6 }] },
253]
254
255// Rocks are obstacles: a slime whose columns a rock is passing under clears
256// it with one empty pixel between them, rising a pixel per column on the way
257// up and down. Zero means no rock is near and the slime hops as usual.
258const ROCK_W = ROCK.rows[0]!.length
259const ROCK_CLEAR = ROCK.rows.length + 1
260
261export function rockLift(left: number, width: number, rocks: number[]): number {
262 let lift = 0
263 for (const rx of rocks) {
264 const gap = rx > left + width - 1 ? rx - (left + width - 1) : rx + ROCK_W - 1 < left ? left - (rx + ROCK_W - 1) : 0
265 lift = Math.max(lift, ROCK_CLEAR - gap)
266 }
267 return lift
268}
269
270const SUN: Sprite = { rows: ['.Y.', 'YYY', '.Y.'], colors: { Y: 0xffd166 } }
271const MOON: Sprite = { rows: ['MM.', 'M..', 'MM.'], colors: { M: 0xf1f1d0 } }
272// Stars are yellow '*' characters in the top two cell rows, each blinking on
273// its own beat; a partly cloudy night shows every other one.
274const STARS = [
275 { col: 3, row: 0 },
276 { col: 11, row: 1 },
277 { col: 18, row: 0 },
278 { col: 24, row: 1 },
279]
280const STAR = { bright: 0xffd166, dim: 0x9a7d2e }
281const RAIN = 0x6fa8dc
282const SNOWFLAKE = 0xf5f5f5
283
284// Cloud color by time of day and how heavy the sky is.
285const CLOUD_TONE = {
286 day: { light: 0xd9dce3, heavy: 0x9ea3ad },
287 night: { light: 0x5c6170, heavy: 0x464a55 },
288}
289
290const hash = (n: number) => (Math.imul(n | 0, 2654435761) >>> 0) % 97
291
292// The treasure chest the troop finds once every subagent has come home.
293const WOOD = { L: 0xb07b4f, B: 0x8b5a2b, G: 0xd4a017, Y: 0xffe066 }
294const CHEST = {
295 shut: { rows: ['.LLL.', 'LLLLL', 'GGYGG', 'BBBBB'], colors: WOOD },
296 // Lid thrown back, treasure heaped up and glowing inside.
297 open: { rows: ['LLLLL', '.YYY.', 'GGYGG', 'BBBBB'], colors: WOOD },
298}
299const FLASH = { white: 0xffffff, gold: 0xffe066 }
300// The burst as the lid flies open: rays shoot out from the chest's mouth
301// one pixel a tick, white at the tip, gold behind it.
302const RAYS = [
303 [0, -1],
304 [-1, -1],
305 [1, -1],
306 [-1, 0],
307 [1, 0],
308] as const
309const RAY_TICKS = 6
310const CHEST_W = 5
311
312// The find, tick by tick: the chest rolls in from the right while the troop
313// slides left until the main slime stands mid-pane; the chest shakes and pops
314// open; everyone hops for joy; then the troop slides back to its place on the
315// right as the chest scrolls away behind it.
316const PARTY_OPEN = 12
317const PARTY_CHEER = 24
318const PARTY_RETURN = 15
319const CHEER_LIFT = [0, 1, 2, 3, 3, 2, 1, 0]
320
321type PartyFrame = {
322 cx: number
323 chestX: number
324 chestOpen: boolean
325 scrolling: boolean
326 // Set while cheering: how high the main slime is off the ground.
327 cheer?: number
328 // Ticks since the lid opened, once it has.
329 sinceOpen?: number
330 // Ticks since the staying slime set off to hop into the main one.
331 mergeAge?: number
332}
333
334// `troop` is how many little slimes follow; the main slime stops mid-pane, or
335// further right when that would push the last of them off the left edge.
336// With a little slime along, the main one waits for it to hop in before it
337// sets off home: the merge, then a beat.
338const mergePhase = (troop: number) => (troop > 0 ? MERGE_TICKS + 3 : 0)
339
340function partyTimes(w: number, troop: number) {
341 const home = Math.max(6, w - 8)
342 const room = troop > 0 ? 9 + (troop - 1) * MINI_GAP : 6
343 const center = Math.min(home, Math.max(w >> 1, room))
344 const target = center + 5
345 const approach = Math.max(1, Math.ceil((w + 1 - target) / GROUND_SPEED))
346 const leave = Math.ceil((target + CHEST_W + 1) / GROUND_SPEED)
347 return { home, center, target, approach, leave }
348}
349
350export function partyLength(columns: number, troop = 0): number {
351 const { approach, leave } = partyTimes(Math.max(1, Math.min(512, columns)), troop)
352 return approach + PARTY_OPEN + PARTY_CHEER + mergePhase(troop) + leave
353}
354
355export function partyFrame(columns: number, t: number, troop = 0): PartyFrame {
356 const w = Math.max(1, Math.min(512, columns))
357 const { home, center, target, approach, leave } = partyTimes(w, troop)
358 const toward = (from: number, to: number, k: number) => Math.round(from + (to - from) * Math.min(1, k))
359 if (t < approach) {
360 return { cx: toward(home, center, t / approach), chestX: Math.round(w + 1 - GROUND_SPEED * t), chestOpen: false, scrolling: true }
361 }
362 t -= approach
363 if (t < PARTY_OPEN) {
364 const opened = t >= PARTY_OPEN / 2
365 return {
366 cx: center,
367 chestX: target + (!opened && t % 2 === 1 ? 1 : 0),
368 chestOpen: opened,
369 scrolling: false,
370 sinceOpen: opened ? t - PARTY_OPEN / 2 : undefined,
371 }
372 }
373 t -= PARTY_OPEN
374 if (t < PARTY_CHEER) {
375 return {
376 cx: center,
377 chestX: target,
378 chestOpen: true,
379 scrolling: false,
380 cheer: CHEER_LIFT[t % CHEER_LIFT.length]!,
381 sinceOpen: PARTY_OPEN / 2 + t,
382 }
383 }
384 t -= PARTY_CHEER
385 const merge = mergePhase(troop)
386 if (t < merge) {
387 return { cx: center, chestX: target, chestOpen: true, scrolling: false, sinceOpen: PARTY_OPEN / 2 + PARTY_CHEER + t, mergeAge: t }
388 }
389 t -= merge
390 return {
391 cx: toward(center, home, t / Math.min(PARTY_RETURN, leave)),
392 chestX: Math.round(target - GROUND_SPEED * t),
393 chestOpen: true,
394 scrolling: true,
395 sinceOpen: PARTY_OPEN / 2 + PARTY_CHEER + merge + t,
396 mergeAge: merge + t,
397 }
398}
399
400export const partyScrolling = (columns: number, t: number, troop = 0) => partyFrame(columns, t, troop).scrolling
401
402export type Overlay = { col: number; row: number; ch: string; fg: number; bg?: number }
403
404// Columns of the eyes from the sleeping sprite's left edge. Each closed eye
405// is a character on a body-colored cell holding its middle row: '-' when that
406// row is the cell's top half, '_' (drawn at the cell's foot) when it is the
407// bottom, so the line always sits just under the middle row.
408const SLEEP_EYES = [1, 4]
409// The awake face's two eye characters, left then right, and the vein's color.
410const FACE_EYES = { x: ['x', 'x'], '><': ['>', '<'], TT: ['T', 'T'] } as const
411const VEIN = 0xff6b6b
412// Waiting on the person: a small speech bubble over the main slime, its
413// tail on the slime's head. The bold question mark inside is not drawn here
414// (a Raster cell has no bold): the pane lays it over the bubble's middle cell,
415// BUBBLE_CELL, as text. The bubble flashes yellow and white every half second.
416const BUBBLE = [
417 '.BBB.',
418 'BBBBB',
419 'BBBBB',
420 '.BBB.',
421 '..B..',
422]
423// Where the question mark goes: the cell row and column of the bubble's
424// middle, for a main slime standing at home (where it waits) in a scene
425// `columns` wide. The bubble's rows 1 and 2 are the scene's pixel rows 4 and 5.
426export const bubbleCell = (columns: number) => ({ row: 2, col: homeCx(columns) })
427export const bubbleFill = (tick: number) => (Math.floor(tick / 5) % 2 === 0 ? ASK.yellow : ASK.white)
428export const ASK = { yellow: 0xffd23f, white: 0xffffff, mark: 0x101018 }
429// Out of MP or HP: the slime thinks of the potion it needs, blue mana or red
430// health, in a thought bubble at its upper left. A small dot rises beside its
431// head, then a bigger one, then the cloud with the potion in it, and around
432// again (THINK ticks). With both dry the MP bubble is nearest the slime, its
433// dots leading to it, and the HP bubble beyond it.
434const THOUGHT = [
435 '.OOOOO..',
436 'OOOkOOO.',
437 'OOOpOOO.',
438 'OOpPpOO.',
439 'OOpppOO.',
440 '.OOOOO..',
441]
442// The trail under the nearest bubble, down to the head: the big dot first.
443const TRAIL = [
444 { dx: 5, dy: 1, w: 2 },
445 { dx: 7, dy: 2, w: 1 },
446]
447const THINK = 16
448// Ticks into the cycle each part shows from: small dot, big dot, cloud.
449const THINK_FROM = { small: 0, big: 4, cloud: 8 }
450export const POTION_COLORS = {
451 mp: { O: 0xe8e8f0, k: 0x9c6644, p: 0x3a86ff, P: 0x8fbcff },
452 hp: { O: 0xe8e8f0, k: 0x9c6644, p: 0xe5383b, P: 0xff8a8c },
453} as const
454// Respawn's beats, in ticks: two hops then the leap off to the right (until
455// `beam`), the beam coming down, the ring along the ground (`wave` ticks),
456// the motes (`motes` ticks from the strike), the new slime taking shape in
457// the middle (`born`), the beam narrowing away from `fade`, the new slime
458// setting off for its place (`walk`: a second after its glow has faded).
459// Setting's Performance: High draws everything; Mid and Low thin the
460// effects (Unload's spray, Respawn's motes) and the things along the way
461// (trees, rocks, goo, clouds, birds).
462export const PERF_LEVELS = ['high', 'mid', 'low'] as const
463export type Perf = (typeof PERF_LEVELS)[number]
464export const PERF = { high: { fx: 1, things: 1 }, mid: { fx: 0.5, things: 0.7 }, low: { fx: 0.3, things: 0.5 } } as const
465let perf: Perf = 'high'
466export function setPerformance(level: Perf): void {
467 perf = level
468}
469export const perfFrom = (value: unknown): Perf => (PERF_LEVELS.includes(value as Perf) ? (value as Perf) : 'high')
470// Whether the i-th of a run is kept when `keep` of them are, spread evenly.
471const kept = (i: number, keep: number) => Math.floor((i + 1) * keep) > Math.floor(i * keep)
472// Whether the thing at index k of a scattered run is kept, at random but steady.
473const thingKept = (k: number, seed: number) => unit(k, seed * 7 + 3) < PERF[perf].things
474
475export const RESPAWN = { leap: 12, beam: 26, wave: 8, motes: 30, born: 33, fade: 46, walk: 53, moteCount: 26 }
476const WALK_SPEED = 0.8
477// How long Respawn plays in a scene `columns` wide: until the new slime,
478// born in the middle, has crawled to its place, and a beat more.
479export function respawnLength(columns: number): number {
480 const w = Math.max(1, Math.min(512, columns))
481 const walk = Math.max(0, Math.max(6, w - 8) - (w >> 1))
482 return Math.max(RESPAWN.beam + 4 + RESPAWN.motes, RESPAWN.walk + Math.ceil(walk / WALK_SPEED) + 4)
483}
484// A sprite turned a quarter (clockwise) and a half over, for the leap.
485const quarterTurn = (rows: string[]) => [...rows[0]!].map((_, x) => rows.map(r => r[x]!).reverse().join(''))
486const halfTurn = (rows: string[]) => rows.map(r => [...r].reverse().join('')).reverse()
487export const RESPAWN_COLORS = { white: 0xfffbe8, gold: 0xffd23f }
488// Unloading (the context compacting): the slime sets its load down. Every
489// few ticks its body flashes white, its mouth opens and it spits a spray of
490// the goo it ate, which arc off to the left, land and vanish; green arrows fall
491// beside it, until the compaction ends.
492export const UNLOAD_COLORS = {
493 glow: { B: 0xffffff, H: 0xffffff, M: 0xe8e8e8, D: 0xb8b8b8 },
494 pixels: GOO_COLORS,
495 arrow: 0x38b000,
496 // The spitting mouth, dark so it shows on the white flash.
497 mouth: 0x3a0d1e,
498} as const
499// Ticks between sprays, sprays in the air at once, and ticks each one flies.
500const SPIT_EVERY = 4
501const SPIT_SPRAYS = 3
502const SPIT_FLIGHT = 9
503// Each pixel of a spray: how far left it lands and how high it arcs.
504const SPRAY = [
505 { far: 5, high: 5 },
506 { far: 8, high: 3 },
507 { far: 11, high: 6 },
508]
509const ZZZ = [
510 { dx: 2, ch: 'z' },
511 { dx: 3, ch: 'Z' },
512 { dx: 4, ch: 'z' },
513]
514
515// Halfway from one color to another, channel by channel.
516const between = (a: number, b: number) =>
517 [16, 8, 0].reduce((c, shift) => c | (Math.round((((a >> shift) & 0xff) + ((b >> shift) & 0xff)) / 2) << shift), 0)
518
519const tintOf = (model: string) => {
520 const info = modelInfo(model)
521 return { B: info.body, H: info.light, M: between(info.light, info.body), D: info.dark, E: 0x101018 }
522}
523
524// A little slime in the troop: its subagent's model; while it is still
525// budding off the main slime, the ticks since it began (negative: its turn
526// to come out has not arrived yet); its place in line, which may be between
527// two places while it closes a gap; and, once its subagent has handed back,
528// how it is dropping out: the ticks since, and the x it stood at then.
529export type Follower =
530 | string
531 | { model: string; age?: number; pos?: number; leave?: { age: number; x: number }; stay?: true }
532
533// The last little slime home stays (`stay`) to open the chest with the main
534// slime, then hops into its body and is gone: MERGE_TICKS from leaving its
535// place to vanishing inside.
536const MERGE_TICKS = 10
537
538// Pixels a tick a little slime falls back once it has dropped out of line.
539const LEAVE_SPEED = GROUND_SPEED
540
541// Where a little slime stands in line `pos` behind a main slime centred at `cx`.
542export const slotOf = (cx: number, pos: number) => Math.round(cx - 9 - pos * MINI_GAP)
543export const homeCx = (columns: number) => Math.max(6, Math.max(1, Math.min(512, columns)) - 8)
544
545// Ticks a little slime takes to bud off the main slime's back and roll to its place.
546export const EMERGE_TICKS = 12
547
548// `minions` holds each subagent's slime, oldest first.
549export function frame(
550 columns: number,
551 off: Offsets,
552 tick: number,
553 busy: boolean,
554 model: string,
555 minions: readonly Follower[] = [],
556 weather: Weather = DEFAULT_WEATHER,
557 // Ticks since the troop found its treasure chest; undefined on a normal day.
558 party?: number,
559 // How the main slime is holding up (vitals.ts): down, or straining under a full context.
560 face: Face = {},
561): string {
562 const w = Math.max(1, Math.min(512, columns))
563 const inLine = minions.filter(f => typeof f === 'string' || f.leave === undefined).length
564 const fete = party === undefined ? undefined : partyFrame(w, party, inLine)
565 // The main slime keeps to the right so its followers have room behind it.
566 const cx = fete?.cx ?? Math.max(6, w - 8)
567 // During the find the troop is wide awake whatever the session is doing.
568 // Down, or waiting on the person, it stands still and wide awake: nothing
569 // passes until a limit resets or they answer. The little slimes keep hopping.
570 const halted = face.down === true || face.ask === true
571 // Unloading wakes it too, though it only travels if the session is busy.
572 const awake = busy || fete !== undefined || halted || face.unloading === true || face.warming === true
573 const travelling = fete ? fete.scrolling : busy && !halted
574 const px = new Uint32Array(w * PX_H).fill(SKY)
575 const put = (x: number, y: number, color: number) => {
576 if (x >= 0 && x < w && y >= 0 && y < PX_H) px[y * w + x] = color
577 }
578 const draw = (sprite: Sprite, x: number, bottom: number) => {
579 const top = bottom - sprite.rows.length + 1
580 sprite.rows.forEach((row, dy) => {
581 for (let dx = 0; dx < row.length; dx++) {
582 const color = sprite.colors[row[dx]!]
583 if (color !== undefined) put(x + dx, top + dy, color)
584 }
585 })
586 }
587
588 const overcast = weather.sky === 'cloudy' || weather.sky === 'rain' || weather.sky === 'snow'
589
590 // Sun by day, moon and twinkling stars by night, unless cloud hides them.
591 if (!overcast) {
592 if (weather.day) draw(SUN, w - 5, 2)
593 else {
594 draw(MOON, w - 5, 2)
595 }
596 }
597
598 const tone = CLOUD_TONE[weather.day ? 'day' : 'night'][overcast ? 'heavy' : 'light']
599 const cloud: Sprite = { rows: CLOUD.rows, colors: { C: tone } }
600 // Birds are out only on a dry day.
601 const birds = weather.day && weather.sky !== 'rain' && weather.sky !== 'snow'
602
603 const rocks: number[] = []
604 for (const band of BANDS) {
605 if (band.layer === 'bird' && !birds) continue
606 const shift = Math.floor(off[band.layer]) % band.span
607 const shown = perf === 'high' ? band.items : band.items.slice(0, Math.max(1, Math.floor(band.items.length * PERF[perf].things)))
608 for (const item of shown) {
609 if (item.sparse && weather.sky === 'clear') continue
610 if (item.overcast && !overcast) continue
611 for (let t = 0; item.x + t * band.span - shift < w; t++) {
612 const x = item.x + t * band.span - shift
613 if (band.layer === 'bird') draw(BIRDS[Math.floor(tick / 4 + item.x) % 2]!, x, item.y ?? 0)
614 else if (band.layer === 'cloud') draw(cloud, x, (item.y ?? 0) + 1)
615 else draw(band.sprite, x, GROUND_Y - 1)
616 }
617 }
618 }
619 // Landmarks first, so the trees scattered along the road stand in front.
620 const treeAt = Math.floor(off.tree)
621 if (loopPeriod === undefined) for (const mark of LANDMARKS) draw(mark.sprite, mark.x - treeAt, GROUND_Y - 1)
622 for (const sc of SCATTER) {
623 const at = Math.floor(off[sc.layer])
624 const width = sc.sprite.rows[0]!.length
625 for (const worldX of scattered(sc.layer, at - width + 1, at + w)) {
626 draw(sc.layer === 'tree' ? TREES[treeKind(worldX)]! : sc.sprite, worldX - at, GROUND_Y - 1)
627 if (sc.layer === 'rock') rocks.push(worldX - at)
628 }
629 }
630
631 // Rain falls straight and fast; snow drifts down slowly, swaying a pixel.
632 if (weather.sky === 'rain' || weather.sky === 'snow') {
633 const snow = weather.sky === 'snow'
634 for (let x = 0; x < w; x++) {
635 if (hash(x + 11) % (snow ? 5 : 4) !== 0) continue
636 const fall = (snow ? Math.floor(tick / 3) : tick) + hash(x + 7)
637 const sway = snow && Math.floor(fall / 4) % 2 === 1 ? 1 : 0
638 put(x + sway, fall % GROUND_Y, snow ? SNOWFLAKE : RAIN)
639 }
640 }
641
642 // A strip of grass; every eighth pixel is darker so motion shows.
643 const ground = Math.floor(off.ground)
644 for (let x = 0; x < w; x++) put(x, GROUND_Y, (x + ground) % 8 === 0 ? 0x4f772d : 0x6a994e)
645
646 const overlays: Overlay[] = []
647 if (fete) {
648 const since = fete.sinceOpen
649 if (since === undefined) draw(CHEST.shut, fete.chestX, GROUND_Y - 1)
650 else {
651 // The heap of treasure glints white every other beat while the troop is near.
652 const glint = !fete.scrolling && Math.floor(since / 2) % 2 === 0
653 draw({ rows: CHEST.open.rows, colors: { ...WOOD, Y: glint ? FLASH.white : WOOD.Y } }, fete.chestX, GROUND_Y - 1)
654 if (since < RAY_TICKS) {
655 const mouthX = fete.chestX + 2
656 const mouthY = GROUND_Y - 3
657 for (const [dx, dy] of RAYS) {
658 const reach = since + 2
659 put(mouthX + dx * reach, mouthY + dy * reach, FLASH.white)
660 put(mouthX + dx * (reach - 1), mouthY + dy * (reach - 1), FLASH.gold)
661 }
662 }
663 }
664 // Sparkles over the open chest while the troop cheers.
665 if (fete.cheer !== undefined) {
666 const beat = Math.floor((party ?? 0) / 3) % 2
667 const sparkles = beat === 0 ? [{ dx: 1, row: 2 }, { dx: 4, row: 1 }] : [{ dx: 3, row: 2 }, { dx: 0, row: 1 }]
668 for (const sp of sparkles) overlays.push({ col: fete.chestX + sp.dx, row: sp.row, ch: '*', fg: STAR.bright })
669 }
670 }
671
672 const mainLeft = cx - (SLIME.awake[0]!.length >> 1)
673 // Goo lies only ahead of the slime: whatever it has reached, it has eaten.
674 // The nearest blob's distance from its front decides whether it gapes.
675 const mainFront = mainLeft + SLIME.awake[0]!.length - 1
676 let nextGoo = Infinity
677 if (!fete) {
678 const at = Math.floor(off.rock)
679 for (const worldX of goos(at - GOO_W + 1, at + w)) {
680 const x = worldX - at
681 if (x <= mainFront) continue
682 nextGoo = Math.min(nextGoo, x - mainFront)
683 draw({ rows: GOO, colors: { G: gooColor(worldX) } }, x, GROUND_Y - 1)
684 }
685 }
686 if (face.camp !== undefined && !fete) campfire(draw, put, face.camp, tick, face.campAge ?? LIGHT.grown, mainLeft + (SLIME.awake[0]!.length >> 1), face.embers === true)
687 let line = 0
688 minions.forEach(f => {
689 const m = typeof f === 'string' ? f : f.model
690 const leave = typeof f === 'string' ? undefined : f.leave
691 if (leave) {
692 // Dropped out: it stops following and falls back off the left edge,
693 // still bobbing, while the troop goes on without it.
694 const x = Math.round(leave.x - LEAVE_SPEED * leave.age)
695 if (x + 4 <= 0) return
696 const hop = MINI_HOP[leave.age % MINI_HOP.length]!
697 draw({ rows: hop.rows, colors: tintOf(m) }, x + ((4 - hop.rows[0]!.length) >> 1), GROUND_Y - 1 - hop.lift)
698 return
699 }
700 const i = line++
701 const age = typeof f === 'string' ? undefined : f.age
702 const slot = slotOf(cx, (typeof f === 'string' ? undefined : f.pos) ?? i)
703 const merge = typeof f !== 'string' && f.stay ? fete?.mergeAge : undefined
704 if (merge !== undefined) {
705 // Hopping home: a high arc from its place into the main slime's back,
706 // shrinking as it sinks in (drawn under the main slime, so it vanishes).
707 if (merge >= MERGE_TICKS) return
708 const k = merge / MERGE_TICKS
709 const rows = merge >= MERGE_TICKS - 2 ? ['B'] : merge >= MERGE_TICKS - 4 ? ['BB'] : MINI.ball
710 const fromX = slot + 1
711 const toX = mainLeft + 2
712 const lift = Math.round(Math.sin(k * Math.PI) * 4)
713 draw({ rows, colors: tintOf(m) }, Math.round(fromX + (toX - fromX) * k), GROUND_Y - 1 - lift)
714 return
715 }
716 if (age !== undefined && age < EMERGE_TICKS) {
717 // Budding: a speck inside the main slime's body (drawn under it) swells
718 // to a ball as it rolls out of its back in a little arc to its place.
719 if (age < 0) return
720 const k = age / EMERGE_TICKS
721 const rows = age < 3 ? ['B'] : age < 5 ? ['BB'] : MINI.ball
722 const fromX = mainLeft + 2
723 const toX = slot + ((4 - rows[0]!.length) >> 1)
724 const lift = Math.round(Math.sin(k * Math.PI) * 2)
725 draw({ rows, colors: tintOf(m) }, Math.round(fromX + (toX - fromX) * k), GROUND_Y - 1 - lift)
726 return
727 }
728 if (slot + 4 <= 0) return
729 const phase = (((tick - (i + 1) * WAVE_LAG) % MINI_HOP.length) + MINI_HOP.length) % MINI_HOP.length
730 let hop = awake ? MINI_HOP[phase]! : { rows: MINI.ball, lift: 0 }
731 const over = travelling ? rockLift(slot + 1, MINI.ball[0]!.length, rocks) : 0
732 if (over > 0) hop = { rows: MINI.ball, lift: over }
733 const left = slot + ((4 - hop.rows[0]!.length) >> 1)
734 draw({ rows: hop.rows, colors: tintOf(m) }, left, GROUND_Y - 1 - hop.lift)
735 })
736
737 const info = modelInfo(model)
738 const tint = tintOf(model)
739 if (face.respawn !== undefined) {
740 // Respawn: the old slime hops twice on the spot, then leaps off to the
741 // right turning half over; a great beam of light comes down where it
742 // stood, a ring of light runs out along the ground, glowing motes
743 // scatter, and a new slime takes shape in the beam, top row first,
744 // glinting as the light fades.
745 const t = face.respawn
746 const R = RESPAWN
747 const home = cx - (SLIME.awake[0]!.length >> 1)
748 if (t < R.leap) {
749 const hop = R.leap / 2
750 const lift = Math.round(2 * Math.sin((Math.PI * (t % hop)) / hop))
751 draw({ rows: lift > 0 ? SLIME.air : SLIME.awake, colors: tint }, home, GROUND_Y - 1 - lift)
752 } else if (t < R.beam) {
753 const k = (t - R.leap) / (R.beam - R.leap)
754 const lift = Math.round(6 * Math.sin(Math.PI * Math.min(1, k * 1.4)))
755 const rows = k < 1 / 3 ? SLIME.air : k < 2 / 3 ? quarterTurn(SLIME.air) : halfTurn(SLIME.air)
756 draw({ rows, colors: tint }, home + Math.round((t - R.leap) * 1.8), GROUND_Y - 1 - lift)
757 }
758 // The light comes down in the middle of the scene.
759 const mid = w >> 1
760 const born = mid - (SLIME.awake[0]!.length >> 1)
761 const landed = R.beam + Math.ceil(GROUND_Y / 3)
762 if (t >= R.beam && t < R.fade + 6) {
763 const reach = Math.min(GROUND_Y, (t - R.beam) * 3)
764 const narrowing = Math.max(0, t - R.fade)
765 const half = Math.max(0, 2 - narrowing)
766 for (let y = 0; y <= reach; y++) {
767 for (let dx = -half - 1; dx <= half + 1; dx++) {
768 const edge = Math.abs(dx) > half
769 if (edge && narrowing > 2) continue
770 put(mid + dx, y, edge ? RESPAWN_COLORS.gold : RESPAWN_COLORS.white)
771 }
772 }
773 }
774 // Where the beam strikes, a ring of light runs out both ways along the ground.
775 if (t >= landed && t < landed + R.wave) {
776 const r = 3 + (t - landed) * 2
777 for (const side of [-1, 1]) {
778 put(mid + side * r, GROUND_Y - 1, RESPAWN_COLORS.white)
779 put(mid + side * (r - 1), GROUND_Y - 1, RESPAWN_COLORS.gold)
780 put(mid + side * (r - 1), GROUND_Y - 2, RESPAWN_COLORS.gold)
781 }
782 }
783 if (t >= landed && t < landed + R.motes) {
784 const age = t - landed
785 for (let i = 0; i < R.moteCount; i++) {
786 if (!kept(i, PERF[perf].fx)) continue
787 if ((i + t) % 5 === 0) continue
788 const vx = (unit(i, 0x5eed) - 0.5) * 1.8
789 const vy = 0.3 + unit(i, 0xbea7) * 0.8
790 const x = Math.round(mid + vx * age)
791 const y = Math.round(GROUND_Y - 3 - vy * age + 0.04 * age * age)
792 if (y < GROUND_Y) put(x, y, i % 2 === 0 ? RESPAWN_COLORS.white : RESPAWN_COLORS.gold)
793 }
794 }
795 if (t >= R.born) {
796 // Taking shape top row first, then a white glow as it settles; once
797 // whole, it crawls right to its place and stops there.
798 const shapedAt = R.born + SLIME.awake.length * 2
799 const shown = Math.min(SLIME.awake.length, Math.floor((t - R.born) / 2) + 1)
800 const walked = Math.min(home - born, Math.max(0, t - R.walk) * WALK_SPEED)
801 const x = born + Math.round(walked)
802 const walking = t >= R.walk && x < home
803 const base = walking && Math.floor(t / 2) % 2 === 0 ? SLIME.crawl : SLIME.awake
804 const rows = base.map((row, i) => (i < shown + base.length - SLIME.awake.length ? row : '.'.repeat(row.length)))
805 const glow = t < shapedAt + 4 ? { ...tint, ...UNLOAD_COLORS.glow } : tint
806 draw({ rows, colors: glow }, x, GROUND_Y - 1)
807 // Glints around it, one at a time, until it sets off.
808 if (t >= shapedAt && t < R.walk) {
809 const top = (GROUND_Y - SLIME.awake.length) >> 1
810 const spots = [{ dx: -2, row: top - 1 }, { dx: 7, row: top }, { dx: 3, row: top - 2 }, { dx: -1, row: top + 1 }]
811 const spot = spots[Math.floor(t / 3) % spots.length]!
812 if (t % 3 !== 2) overlays.push({ col: x + spot.dx, row: spot.row, ch: '✦', fg: RESPAWN_COLORS.gold })
813 }
814 }
815 } else if (fete && !fete.scrolling) {
816 // Standing by the chest: still while it opens, then hopping for joy; the
817 // tuft pops up for a gulp as the little one sinks in.
818 const lift = fete.cheer ?? 0
819 const gulp = fete.mergeAge !== undefined && Math.abs(fete.mergeAge - MERGE_TICKS) <= 1
820 const rows = lift > 0 ? SLIME.air : gulp ? SLIME.crawl : SLIME.awake
821 draw({ rows, colors: tint }, cx - (rows[0]!.length >> 1), GROUND_Y - 1 - lift)
822 } else if (awake) {
823 // The main slime crawls along the ground and only leaves it to clear a rock.
824 const width = SLIME.awake[0]!.length
825 const left = cx - (width >> 1)
826 // Standing still, nothing passes beneath it: it stays on the ground.
827 const lift = travelling ? rockLift(left, width, rocks) : 0
828 const unloading = face.unloading === true
829 const gaping = travelling && lift === 0 && nextGoo <= GAPE_REACH
830 let rows = gaping
831 ? SLIME.gape
832 : !travelling
833 ? face.camp !== undefined && lightingGape(face.campAge ?? LIGHT.grown)
834 ? SLIME.gape
835 : SLIME.awake
836 : lift > 0
837 ? SLIME.air
838 : Math.floor(tick / 2) % 2 === 0
839 ? SLIME.awake
840 : SLIME.crawl
841 const bottom = GROUND_Y - 1 - lift
842 // It flashes white and opens its mouth as each spray leaves it.
843 const spitting = unloading && tick % SPIT_EVERY < 2
844 if (spitting) rows = SLIME.spit
845 const body = { ...(spitting ? { ...tint, ...UNLOAD_COLORS.glow } : tint), ...MOUTH, ...(spitting ? { K: UNLOAD_COLORS.mouth } : {}) }
846 if (unloading) {
847 // Drawn under the slime, so each pixel comes out of its open mouth (its
848 // middle, a row above the chin); a spray keeps its colors all the way
849 // down.
850 const mouthX = left + (width >> 1) - (GOO_W >> 1)
851 const mouthY = bottom - 1
852 const cycle = SPIT_EVERY * SPIT_SPRAYS
853 for (let j = 0; j < SPIT_SPRAYS; j++) {
854 const age = (tick + j * SPIT_EVERY) % cycle
855 if (age > SPIT_FLIGHT) continue
856 const spray = Math.round((tick - age) / SPIT_EVERY)
857 const k = age / SPIT_FLIGHT
858 SPRAY.forEach(({ far, high }, p) => {
859 if (!kept(p, PERF[perf].fx)) return
860 const n = UNLOAD_COLORS.pixels.length
861 const color = UNLOAD_COLORS.pixels[(((spray * SPRAY.length + p) % n) + n) % n]!
862 const x = Math.round(mouthX - far * k)
863 draw({ rows: GOO, colors: { G: color } }, x, mouthY - Math.round(Math.sin(k * Math.PI) * high))
864 })
865 }
866 }
867 draw({ rows, colors: body }, left, bottom)
868 // The face goes over the eyes as characters, as the sleeping eyes do: a
869 // cell holds the eye pixel and the body pixel beside it, so the
870 // character sits on the body's color.
871 const top = bottom - rows.length + 1
872 if (face.eyes) {
873 const eyeY = top + rows.findIndex(row => row.includes('E'))
874 const eyeRow = rows.find(row => row.includes('E'))!
875 const [l, r] = FACE_EYES[face.eyes]
876 const cols = [...eyeRow].flatMap((ch, dx) => (ch === 'E' ? [dx] : []))
877 cols.forEach((dx, i) =>
878 overlays.push({ col: left + dx, row: eyeY >> 1, ch: i === 0 ? l : r, fg: 0x101018, bg: body.B }),
879 )
880 }
881 if (face.vein) overlays.push({ col: left - 1, row: top >> 1, ch: '#', fg: VEIN })
882 const potions = face.potions ?? []
883 if (potions.length > 0) {
884 const beat = tick % THINK
885 const wide = THOUGHT[0]!.length
886 // The nearest cloud's bottom sits a row above the head, its dots
887 // stepping down from it to beside the head.
888 const x0 = left - wide
889 const cloudBottom = top - 1
890 const white = POTION_COLORS.hp.O
891 const [big, small] = TRAIL
892 if (beat >= THINK_FROM.small) put(x0 + small!.dx, cloudBottom + small!.dy, white)
893 if (beat >= THINK_FROM.big) for (let d = 0; d < big!.w; d++) put(x0 + big!.dx + d, cloudBottom + big!.dy, white)
894 if (beat >= THINK_FROM.cloud) {
895 potions.forEach((kind, i) => draw({ rows: THOUGHT, colors: POTION_COLORS[kind] }, x0 - wide * i, cloudBottom))
896 }
897 }
898 // Two arrows fall beside it, over and over.
899 if (unloading) {
900 for (const k of [0, 2]) {
901 overlays.push({ col: left + width, row: 1 + ((Math.floor(tick / 2) + k) % 4), ch: '↓', fg: UNLOAD_COLORS.arrow })
902 }
903 }
904 // The bubble's tail rests on the head.
905 if (face.ask) draw({ rows: BUBBLE, colors: { B: bubbleFill(tick) } }, cx - (BUBBLE[0]!.length >> 1), top - 1)
906 } else {
907 const rows = SLIME.sleep
908 const left = cx - (rows[0]!.length >> 1)
909 draw({ rows, colors: tint }, left, GROUND_Y - 1)
910 const eyeY = GROUND_Y - 2
911 const eyeRow = eyeY >> 1
912 const eye = eyeY % 2 === 0 ? '-' : '_'
913 for (const dx of SLEEP_EYES) overlays.push({ col: left + dx, row: eyeRow, ch: eye, fg: 0x101018, bg: info.body })
914 // z, zZ, zZz, then nothing, and around again.
915 const shown = (Math.floor(tick / 5) + 1) % 4
916 for (const z of ZZZ.slice(0, shown)) overlays.push({ col: cx + z.dx, row: eyeRow - 1, ch: z.ch, fg: 0x9a9ab0 })
917 }
918
919 // Stars go last, and only into cells of open sky, so cloud and moon hide them.
920 if (!weather.day && !overcast) {
921 STARS.forEach((star, i) => {
922 if (weather.sky === 'partly' && i % 2 === 1) return
923 if (star.col >= w - 6) return
924 const open = px[2 * star.row * w + star.col] === SKY && px[(2 * star.row + 1) * w + star.col] === SKY
925 const beat = (Math.floor(tick / 5) + i * 3) % 4
926 if (!open || beat === 0) return
927 overlays.push({ ...star, ch: '*', fg: beat === 1 ? STAR.dim : STAR.bright })
928 })
929 }
930
931 return pack(px, w, overlays)
932}
933
934// Cache Warming's campfire: a row of logs on the ground and a fire on them
935// that flickers through three frames, a spark rising from it now and then.
936// About twenty-five pixels. It stands in the world, so it stays put while the
937// troop rests and slides away with the ground once it travels on.
938export const WARM_COLORS = [0xff4d00, 0xff8a1c, 0xffb627, 0xffe28a] as const
939export const CAMPFIRE_W = 5
940// A single row of logs, as wide as the fire on it.
941const LOGS = { rows: ['LDLDL'], colors: { L: 0x8b5a2b, D: 0x5c3a1e } }
942const FLAMES = [
943 ['..W..', '.WYY.', '.YOY.', 'RORRO'],
944 ['...W.', '..YW.', '.YOYY', 'ORROR'],
945 ['.W...', '.WY..', 'YOOY.', 'RORRO'],
946]
947const FLAME_COLORS = { R: WARM_COLORS[0], O: WARM_COLORS[1], Y: WARM_COLORS[2], W: WARM_COLORS[3] }
948// Lighting it, in ticks from when the troop came to rest: the slime gapes and
949// spits a log, which arcs onto the spot; a pause; it gapes again and spits a
950// flame onto the log, which catches and grows into the fire.
951export const LIGHT = { logFly: 2, logLand: 8, flameOpen: 12, flameFly: 14, flameLand: 19, grown: 23 } as const
952// Whether the slime's mouth is open at this point of the lighting.
953export const lightingGape = (age: number) => (age >= 0 && age < 4) || (age >= LIGHT.flameOpen && age < LIGHT.flameFly + 2)
954const SPAT_FLAME = ['OY', 'RO']
955// Burnt down to embers: the logs glow here and there, slowly, dark red to
956// orange, smoke rising from them in puffs; no flame and no spark.
957export const EMBER_COLORS = [0x6e1400, 0xa82a00, 0xe0520f] as const
958const EMBERS = [
959 [[1, 0], [3, 2]],
960 [[1, 1], [2, 0], [3, 1]],
961 [[1, 2], [3, 0]],
962 [[2, 1], [3, 1], [1, 0]],
963] as const
964const SMOKE_COLORS = [0x9a948e, 0x7d7873, 0x605c58] as const
965const SMOKE_EVERY = 8
966const SMOKE_LIFE = 12
967function campfire(
968 draw: (sprite: Sprite, x: number, bottom: number) => void,
969 put: (x: number, y: number, c: number) => void,
970 x: number,
971 tick: number,
972 age: number,
973 mouthX: number,
974 embers = false,
975) {
976 if (embers) {
977 draw(LOGS, x, GROUND_Y - 1)
978 for (const [dx, glow] of EMBERS[Math.floor(tick / 6) % EMBERS.length]!) put(x + dx, GROUND_Y - 1, EMBER_COLORS[glow])
979 // Smoke: two-pixel puffs, one rising as the last thins, each drifting
980 // a step aside as it climbs and fading from grey into the air.
981 for (const k of [0, 1]) {
982 const at = tick + k * SMOKE_EVERY
983 const age = at % (2 * SMOKE_EVERY)
984 if (age >= SMOKE_LIFE) continue
985 const sway = (Math.floor(at / (2 * SMOKE_EVERY)) + (age >> 2)) % 2
986 const shade = SMOKE_COLORS[Math.min(SMOKE_COLORS.length - 1, age >> 2)]!
987 const y = GROUND_Y - 2 - (age >> 1)
988 put(x + 1 + sway, y, shade)
989 put(x + 2 + sway, y, shade)
990 }
991 return
992 }
993 const mouthY = GROUND_Y - 3
994 const arc = (from: number, to: number, k: number, high: number) => ({
995 x: Math.round(from + (to - from) * k),
996 y: Math.round(mouthY + (GROUND_Y - 1 - mouthY) * k - Math.sin(k * Math.PI) * high),
997 })
998 if (age < LIGHT.logLand) {
999 if (age >= LIGHT.logFly) {
1000 const p = arc(mouthX - 2, x, (age - LIGHT.logFly) / (LIGHT.logLand - LIGHT.logFly), 3)
1001 draw(LOGS, p.x, p.y)
1002 }
1003 return
1004 }
1005 draw(LOGS, x, GROUND_Y - 1)
1006 if (age < LIGHT.flameLand) {
1007 if (age >= LIGHT.flameFly) {
1008 const p = arc(mouthX - 1, x + 2, (age - LIGHT.flameFly) / (LIGHT.flameLand - LIGHT.flameFly), 3)
1009 draw({ rows: SPAT_FLAME, colors: FLAME_COLORS }, p.x, p.y)
1010 }
1011 return
1012 }
1013 // Caught: the fire's lower rows first, then all of it.
1014 const flame = FLAMES[Math.floor(tick / 2) % FLAMES.length]!
1015 draw({ rows: age < LIGHT.grown ? flame.slice(2) : flame, colors: FLAME_COLORS }, x, GROUND_Y - 2)
1016 // A spark climbs from the flames' tip and goes out, one every so often.
1017 const sparkAge = tick % 12
1018 if (age >= LIGHT.grown && sparkAge < 6 && kept(Math.floor(tick / 12), PERF[perf].fx)) {
1019 const drift = unit(Math.floor(tick / 12), 0x5a4c) < 0.5 ? 0 : 1
1020 put(x + 2 + (sparkAge > 2 ? drift : 0), GROUND_Y - 6 - sparkAge, sparkAge < 3 ? WARM_COLORS[3] : WARM_COLORS[2])
1021 }
1022}
1023
1024// `rows` cells high: the slime's world is ROWS, Dungeon's scene its own.
1025export function pack(px: Uint32Array, w: number, overlays: Overlay[], rows = ROWS): string {
1026 const words = new Uint32Array(w * rows * 3)
1027 for (let row = 0; row < rows; row++) {
1028 for (let x = 0; x < w; x++) {
1029 const top = px[2 * row * w + x]!
1030 const bottom = px[(2 * row + 1) * w + x]!
1031 const i = (row * w + x) * 3
1032 if (top === SKY && bottom === SKY) words.set([0x20, SKY, SKY], i)
1033 else if (top === SKY) words.set([0x2584, bottom, SKY], i)
1034 else words.set([0x2580, top, bottom], i)
1035 }
1036 }
1037 for (const o of overlays) {
1038 if (o.col < 0 || o.col >= w || o.row < 0 || o.row >= rows) continue
1039 words.set([o.ch.charCodeAt(0), o.fg, o.bg ?? SKY], (o.row * w + o.col) * 3)
1040 }
1041 return base64(new Uint8Array(words.buffer))
1042}
1043
1044function base64(bytes: Uint8Array): string {
1045 const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
1046 if (native) return native.call(bytes)
1047 let binary = ''
1048 for (let i = 0; i < bytes.length; i += 0x8000) {
1049 binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
1050 }
1051 return btoa(binary)
1052}
1053hooks/events.ts 36 lines1// The Event Message block: what the dashboard saw happen (a subagent started
2// or finished, a question asked or answered, the slime down or back up, the
3// model or effort switched, a skill sent, the context compacted), newest first.
4
5export type SlimeEvent = { at: number; text: string }
6
7// How many the session keeps; the block shows the newest three.
8export const KEEP_EVENTS = 20
9export const SHOWN_EVENTS = 3
10
11export const addEvent = (list: readonly SlimeEvent[], event: SlimeEvent): SlimeEvent[] =>
12 [event, ...list].slice(0, KEEP_EVENTS)
13
14// The UTC offset in minutes at the end of `+0800` (what `date +%z` prints),
15// so stamps read in this computer's own time whatever zone the plugin runs in.
16export function offsetOf(time: string): number | undefined {
17 const m = time.trim().match(/([+-])(\d{2})(\d{2})$/)
18 if (!m) return undefined
19 const minutes = Number(m[2]) * 60 + Number(m[3])
20 return m[1] === '-' ? -minutes : minutes
21}
22
23// YYYYMMDD-hhmm: at `offset` minutes from UTC when known, else the
24// environment's own zone.
25export function stamp(at: number, offset?: number): string {
26 const d = new Date(offset === undefined ? at : at + offset * 60_000)
27 const utc = offset !== undefined
28 const pad = (n: number) => String(n).padStart(2, '0')
29 const year = utc ? d.getUTCFullYear() : d.getFullYear()
30 const month = (utc ? d.getUTCMonth() : d.getMonth()) + 1
31 const day = utc ? d.getUTCDate() : d.getDate()
32 const hour = utc ? d.getUTCHours() : d.getHours()
33 const minute = utc ? d.getUTCMinutes() : d.getMinutes()
34 return `${year}${pad(month)}${pad(day)}-${pad(hour)}${pad(minute)}`
35}
36hooks/props.ts 77 lines1// The session's figures the Property block shows.
2
3export type Tally = {
4 // Input tokens: fresh, written to the cache, and read from it.
5 input: number
6 cacheWrite: number
7 cacheRead: number
8 output: number
9}
10
11export const NO_TALLY: Tally = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 }
12
13type Usage = {
14 input_tokens: number
15 output_tokens: number
16 cache_creation_input_tokens: number
17 cache_read_input_tokens: number
18}
19
20// One turn's usage (the main loop's or a subagent's) added to the session's.
21export const addUsage = (t: Tally, u: Usage): Tally => ({
22 input: t.input + u.input_tokens,
23 cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
24 cacheRead: t.cacheRead + u.cache_read_input_tokens,
25 output: t.output + u.output_tokens,
26})
27
28// The share of input tokens served from the prompt cache; undefined before any.
29export function cacheHitRate(t: Tally): number | undefined {
30 const all = t.input + t.cacheWrite + t.cacheRead
31 return all === 0 ? undefined : (t.cacheRead / all) * 100
32}
33
34export const totalTokens = (t: Tally) => t.input + t.cacheWrite + t.cacheRead + t.output
35
36// 950, 12.3k, 4.56M.
37export function compact(n: number): string {
38 if (n < 1000) return String(n)
39 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 2 : 1)}k`
40 return `${(n / 1_000_000).toFixed(2)}M`
41}
42
43export const secondsText = (ms: number) => `${(ms / 1000).toFixed(1)}s`
44
45// The prompt cache's time to live, and where it came from: Claude Code's
46// CLAUDE_CODE_PROMPT_CACHE_TTL first, then the settings' `promptCacheTtl`
47// (the nearest file that sets it), else automatic: an hour on a subscription
48// within its limits, five minutes on an API key or once a limit runs out.
49export type CacheTtl = { ttl: '5m' | '1h'; from: 'env' | 'setting' | 'auto' }
50const isTtl = (v: unknown): v is '5m' | '1h' => v === '5m' || v === '1h'
51export function cacheTtlOf(env: string | undefined, settings: readonly unknown[], plan: 'subscription' | 'api', isOut: boolean): CacheTtl {
52 if (isTtl(env)) return { ttl: env, from: 'env' }
53 for (const s of settings) {
54 const ttl = s && typeof s === 'object' ? (s as { promptCacheTtl?: unknown }).promptCacheTtl : undefined
55 if (isTtl(ttl)) return { ttl, from: 'setting' }
56 }
57 return { ttl: plan === 'subscription' && !isOut ? '1h' : '5m', from: 'auto' }
58}
59
60// A moment as this computer's clock reads it: `14:32`.
61export function clockText(at: number, offset: number): string {
62 const d = new Date(at + offset)
63 return `${String(d.getUTCHours()).padStart(2, '0')}:${String(d.getUTCMinutes()).padStart(2, '0')}`
64}
65
66export const ttlMs = (ttl: '5m' | '1h') => (ttl === '1h' ? 3_600_000 : 300_000)
67
68// When the cache the last request left runs out, in this computer's time,
69// and how long until then: `14:32 · 52m`, `14:32 · 40s`, `expired`.
70export function cacheEndText(lastAt: number, ttl: '5m' | '1h', now: number, offset: number): string {
71 if (lastAt <= 0) return '—'
72 const end = lastAt + ttlMs(ttl)
73 const left = end - now
74 if (left <= 0) return 'expired'
75 return `${clockText(end, offset)} · ${left >= 60_000 ? `${Math.floor(left / 60_000)}m` : `${Math.ceil(left / 1000)}s`}`
76}
77hooks/skills.ts 82 lines1// The Skill Box's skills: each a slash command, a category to file it under,
2// and a few words drawn dim after its name.
3
4export type Skill = {
5 name: string
6 category: string
7 description?: string
8 // The slash command it runs, when not its own name (Unload runs /compact).
9 command?: string
10}
11
12export const DEFAULT_CATEGORY = 'General'
13
14// Always in the box, ahead of the person's own: Unload compacts the context;
15// Respawn starts a new session (/clear), once confirmed.
16export const BUILT_IN: readonly Skill[] = [
17 { name: 'Unload', category: DEFAULT_CATEGORY, description: 'compact context window', command: 'compact' },
18 { name: 'Respawn', category: DEFAULT_CATEGORY, description: 'create new session', command: 'clear' },
19]
20
21// What the store held before categories (plain names) reads as General skills.
22export function skillsFrom(kept: unknown): Skill[] {
23 if (!Array.isArray(kept)) return []
24 return kept.flatMap((s): Skill[] => {
25 if (typeof s === 'string') return [{ name: s, category: DEFAULT_CATEGORY }]
26 if (s && typeof s === 'object' && typeof (s as Skill).name === 'string') {
27 const { name, category, description } = s as Skill
28 return [{ name, category: typeof category === 'string' && category ? category : DEFAULT_CATEGORY, ...(description ? { description } : {}) }]
29 }
30 return []
31 })
32}
33
34// The box's categories in order (General first, then as first registered),
35// each with its skills: the built-ins, then the person's.
36export function grouped(own: readonly Skill[]): { category: string; skills: Skill[] }[] {
37 const all = [...BUILT_IN, ...own.filter(s => !BUILT_IN.some(b => b.name === s.name))]
38 const order = [DEFAULT_CATEGORY, ...new Set(all.map(s => s.category).filter(c => c !== DEFAULT_CATEGORY))]
39 return order.map(category => ({ category, skills: all.filter(s => s.category === category) }))
40}
41
42// `add <skill> [--category <name>] [--desc <words…>]`: the description runs to
43// the end of the line, so it comes last when both are given.
44export function parseAdd(args: string): Skill | undefined {
45 const m = args.trim().match(/^\/?(\S+)(?:\s+--category\s+(\S+))?(?:\s+--desc\s+(.+))?\s*$/)
46 if (!m) return undefined
47 const [, name, category, description] = m
48 return { name: name!, category: category ?? DEFAULT_CATEGORY, ...(description ? { description: description.trim() } : {}) }
49}
50
51// The person's own arrangement: `names` in the kept order, those it does not
52// name yet after them in their own order.
53export function arranged<T>(items: readonly T[], order: readonly string[] | undefined, nameOf: (item: T) => string): T[] {
54 const at = (item: T) => {
55 const i = order?.indexOf(nameOf(item)) ?? -1
56 return i < 0 ? Infinity : i
57 }
58 return items
59 .map((item, i) => ({ item, i }))
60 .sort((a, b) => at(a.item) - at(b.item) || a.i - b.i)
61 .map(x => x.item)
62}
63
64// The names with the one at `at` swapped with the next (or the one before,
65// `by` -1); at either end they stay as they are.
66export function swapNames(names: readonly string[], at: number, by: -1 | 1 = 1): string[] {
67 const to = at + by
68 if (at < 0 || to < 0 || to >= names.length) return [...names]
69 const next = [...names]
70 next[at] = names[to]!
71 next[to] = names[at]!
72 return next
73}
74
75// A kept arrangement read back: per category, its names; or the categories.
76export const namesFrom = (kept: unknown): string[] =>
77 Array.isArray(kept) ? kept.filter((n): n is string => typeof n === 'string') : []
78export function orderMapFrom(kept: unknown): Record<string, string[]> {
79 if (!kept || typeof kept !== 'object' || Array.isArray(kept)) return {}
80 return Object.fromEntries(Object.entries(kept as Record<string, unknown>).map(([k, v]) => [k, namesFrom(v)]))
81}
82hooks/combos.ts 167 lines1// Party Combos: a chain of Skill Box skills, each run by a subagent, which the
2// main model leads. A combo is waves (`layers`) run one after another; every
3// step of a wave is fanned out at once, and the next wave waits for them all.
4// After a wave, an optional condition tells the leader what to do with what came
5// back (report and stop, go on, …), in the person's own words.
6
7export const COMBO_MODELS = ['haiku', 'sonnet', 'opus', 'fable'] as const
8export type ComboModel = (typeof COMBO_MODELS)[number]
9
10// The built-in subagent types a step can take, in the order a press cycles
11// them; the person's own agents follow.
12export const DEFAULT_AGENTS = ['general-purpose', 'Explore', 'Plan'] as const
13
14// The engine's other agents each do one job of their own (Claude Code's docs,
15// the status line) or repeat general-purpose, and run no skill: never offered.
16const NOT_FOR_STEPS = ['claude', 'claude-code-guide', 'statusline-setup']
17
18// Whether a subagent type the session offers can take a step: the person's own
19// and plugins' agents can; of the built-ins, those above already are.
20export const forSteps = (agent: string, source: string): boolean =>
21 source !== 'built-in' && !NOT_FOR_STEPS.includes(agent) && !(DEFAULT_AGENTS as readonly string[]).includes(agent)
22
23// How a step shows its subagent type: the built-ins short and lower case.
24export const agentLabel = (agent: string): string =>
25 agent === 'general-purpose' ? 'general' : (DEFAULT_AGENTS as readonly string[]).includes(agent) ? agent.toLowerCase() : agent
26
27export type ComboStep = { skill: string; model: ComboModel; agent: string }
28export type ComboLayer = { steps: ComboStep[]; condition?: string }
29export type Combo = { name: string; layers: ComboLayer[] }
30
31export const COMBO_CATEGORY = 'Party Combo'
32
33const isModel = (v: unknown): v is ComboModel => COMBO_MODELS.includes(v as ComboModel)
34
35// What the store kept, with anything unreadable dropped and names made unique.
36export function combosFrom(kept: unknown): Combo[] {
37 if (!Array.isArray(kept)) return []
38 const out: Combo[] = []
39 for (const c of kept) {
40 if (!c || typeof c !== 'object' || typeof c.name !== 'string' || !c.name.trim()) continue
41 if (out.some(o => o.name === c.name)) continue
42 const layers: ComboLayer[] = (Array.isArray(c.layers) ? c.layers : []).flatMap((l: unknown): ComboLayer[] => {
43 if (!l || typeof l !== 'object') return []
44 const { steps, condition } = l as ComboLayer
45 const kept: ComboStep[] = (Array.isArray(steps) ? steps : []).flatMap((s: unknown): ComboStep[] => {
46 if (!s || typeof s !== 'object') return []
47 const { skill, model, agent } = s as ComboStep
48 if (typeof skill !== 'string' || !skill) return []
49 return [{ skill, model: isModel(model) ? model : 'haiku', agent: typeof agent === 'string' && agent ? agent : DEFAULT_AGENTS[0] }]
50 })
51 return [{ steps: kept, ...(typeof condition === 'string' && condition.trim() ? { condition } : {}) }]
52 })
53 out.push({ name: c.name, layers })
54 }
55 return out
56}
57
58// A fresh combo's name: `Combo 1`, `Combo 2`, … the first not taken.
59export function newComboName(combos: readonly Combo[]): string {
60 for (let n = 1; ; n++) if (!combos.some(c => c.name === `Combo ${n}`)) return `Combo ${n}`
61}
62
63// A name for a rename: trimmed, and refused when empty or another's.
64export function renameOk(combos: readonly Combo[], from: string, to: string): string | undefined {
65 const name = to.trim().replace(/\s+/g, ' ')
66 if (!name || (name !== from && combos.some(c => c.name === name))) return undefined
67 return name
68}
69
70const swap = <T>(list: readonly T[], at: number, by: -1 | 1): T[] => {
71 const to = at + by
72 if (at < 0 || at >= list.length || to < 0 || to >= list.length) return [...list]
73 const next = [...list]
74 next[at] = list[to]!
75 next[to] = list[at]!
76 return next
77}
78
79// The edits the Party Combo section makes, each giving a new combo.
80export const addLayer = (c: Combo): Combo => ({ ...c, layers: [...c.layers, { steps: [] }] })
81export const moveLayer = (c: Combo, at: number, by: -1 | 1): Combo => ({ ...c, layers: swap(c.layers, at, by) })
82export const removeLayer = (c: Combo, at: number): Combo => ({ ...c, layers: c.layers.filter((_, i) => i !== at) })
83
84const editLayer = (c: Combo, at: number, fn: (l: ComboLayer) => ComboLayer): Combo => ({
85 ...c,
86 layers: c.layers.map((l, i) => (i === at ? fn(l) : l)),
87})
88
89export const addStep = (c: Combo, layer: number, skill: string): Combo =>
90 editLayer(c, layer, l => ({ ...l, steps: [...l.steps, { skill, model: 'haiku', agent: DEFAULT_AGENTS[0] }] }))
91
92export const removeStep = (c: Combo, layer: number, step: number): Combo =>
93 editLayer(c, layer, l => ({ ...l, steps: l.steps.filter((_, i) => i !== step) }))
94
95const editStep = (c: Combo, layer: number, step: number, fn: (s: ComboStep) => ComboStep): Combo =>
96 editLayer(c, layer, l => ({ ...l, steps: l.steps.map((s, i) => (i === step ? fn(s) : s)) }))
97
98// The step's next model, round the families.
99export const cycleModel = (c: Combo, layer: number, step: number): Combo =>
100 editStep(c, layer, step, s => ({ ...s, model: COMBO_MODELS[(COMBO_MODELS.indexOf(s.model) + 1) % COMBO_MODELS.length]! }))
101
102// The step's next subagent type, round those offered (from the first, when
103// its own is no longer one of them).
104export const cycleAgent = (c: Combo, layer: number, step: number, agents: readonly string[]): Combo =>
105 editStep(c, layer, step, s => ({ ...s, agent: agents[(agents.indexOf(s.agent) + 1) % agents.length] ?? s.agent }))
106
107export const setCondition = (c: Combo, layer: number, text: string): Combo =>
108 editLayer(c, layer, l => {
109 const { condition: _, ...rest } = l
110 return text.trim() ? { ...rest, condition: text } : rest
111 })
112
113// What a combo holds, dim after its button in the Skill Box: `2 layers · 3 skills`.
114export function comboSummary(c: Combo): string {
115 const steps = c.layers.reduce((n, l) => n + l.steps.length, 0)
116 const layers = c.layers.filter(l => l.steps.length > 0).length
117 return `${layers} wave${layers === 1 ? '' : 's'} · ${steps} skill${steps === 1 ? '' : 's'}`
118}
119
120// The tag each Agent call's description starts with, so a step can be told
121// apart: `[Run-Test #3 2.1]`, `#3` the press (Dungeon's mission) it is of,
122// when the press has one.
123export const stepTag = (combo: string, layer: number, step: number, mission?: number) =>
124 `[${combo}${mission === undefined ? '' : ` #${mission}`} ${layer + 1}.${step + 1}]`
125
126// The model a combo's leader runs on: a subagent of its own, so the person's
127// conversation is never the one that leads it.
128export const LEADER_MODEL: ComboModel = 'sonnet'
129
130// The tool a combo's subagents ask the person with (served by register.tsx):
131// the question shows on the step's card in Dungeon, and the answer comes back
132// as the tool's result.
133export const ASK_TOOL_NAME = 'ask_person'
134export const ASK_TOOL = `mcp__slime-dashboard__${ASK_TOOL_NAME}`
135
136// The description a leader's Agent call starts with: `[Run-Test #3] lead`.
137export const leaderTag = (combo: string, mission: number) => `[${combo} #${mission}] lead`
138
139// The prompt the leader gets to lead the combo: every layer in order, its
140// steps fanned out at once with the model and subagent type each names, and
141// the conditions to judge after a layer. Empty layers are skipped; a combo
142// with no step at all has nothing to run. `mission` numbers this press;
143// `subagent` says the leader is one, which waits on each step in the
144// foreground and answers with the report.
145export function comboPrompt(combo: Combo, input: string, mission?: number, subagent = false): string | undefined {
146 const layers = combo.layers.filter(l => l.steps.length > 0)
147 if (layers.length === 0) return undefined
148 const lines = [
149 `Run the Party Combo "${combo.name}"${mission === undefined ? '' : ` (mission #${mission})`}. You lead it: hand each step below to a subagent with the Agent tool, with exactly the model and subagent_type it names, and do not do a step's work yourself.`,
150 'Run the waves in order. Dispatch every step of a wave at once, as parallel Agent calls in one message, and wait for all of them to report before you go on. Begin each Agent call\'s description with the step\'s tag. Tell each subagent to run its skill (the Skill tool, or the slash command) and report what came of it, and pass on the input and whatever earlier steps found that it needs.' +
151 (subagent
152 ? ' However the Agent tool runs them, wait for every report of a wave before you go on. A step the person stopped is not run again: take it as failed, and judge the condition on that.' +
153 ` Tell each subagent too: whatever it sends out (a subagent, a background command), it waits for before it reports; and where its skill would ask the person something, it asks with the ${ASK_TOOL} tool and waits for the answer, never guessing it. Ask the person that way yourself, if you must.`
154 : ''),
155 '',
156 `Input: ${input.trim() || '(none)'}`,
157 ]
158 combo.layers.forEach((layer, i) => {
159 if (layer.steps.length === 0) return
160 lines.push('', `Wave ${i + 1}${layer.steps.length > 1 ? ' (all at once)' : ''}:`)
161 layer.steps.forEach((s, j) => lines.push(`- ${stepTag(combo.name, i, j, mission)} skill /${s.skill}, model ${s.model}, subagent_type ${s.agent}`))
162 if (layer.condition?.trim()) lines.push(`Then, before anything else, judge what came back: ${layer.condition.trim()}`)
163 })
164 lines.push('', subagent ? 'When the combo ends, answer with each step\'s result in a line or two, and which waves did not run and why.' : 'When the combo ends, report each step\'s result in a line or two.')
165 return lines.join('\n')
166}
167hooks/squad.ts 425 lines1// Dungeon: what each Party Combo sent has done. A mission is one press of
2// a combo: its waves and steps as they stood then, and for each step the
3// subagent that took it, known by the tag its Agent call's description starts
4// with (combos.ts' stepTag). A subagent of the combo's turn whose tag names no
5// step (the leader left it out, or added a task of its own) is kept apart.
6// Kept in this session's memory only.
7
8import type { Combo, ComboModel } from './combos'
9
10export type RunStatus = 'running' | 'completed' | 'failed' | 'stopped'
11
12// One subagent: what it was asked, and how far it has got.
13export type SquadRun = {
14 id: string
15 description: string
16 model?: string
17 status: RunStatus
18 // Its model requests so far, and the tool it called last.
19 steps: number
20 tool?: string
21 // The tokens its model requests have taken in and given out so far; and as
22 // its turns' ends reported them, where its requests carried none.
23 tokens?: number
24 turnTokens?: number
25 // Its final answer, once it gave one.
26 result?: string
27 startedAt: number
28 endedAt?: number
29 // It sent work to the background this turn (a shell, a monitor): its turn's
30 // end then leaves it `awaiting` that work, still running, not done.
31 background?: boolean
32 awaiting?: boolean
33 // A question it put to the person (the ask_person tool), until answered.
34 asking?: Ask
35}
36
37export type Ask = { question: string; options?: string[] }
38
39// `helpers`: the subagents the step's own sent out (its skill dispatching
40// them), or that took its tag again: the same step's work.
41export type SquadStep = { skill: string; model: ComboModel; agent: string; run?: SquadRun; helpers?: SquadRun[] }
42// `n` is the wave's number in the combo, as its steps' tags count it.
43export type SquadWave = { n: number; condition?: string; steps: SquadStep[] }
44
45export type Mission = {
46 id: number
47 combo: string
48 sentAt: number
49 // When the leader's turn took up the combo, and when that turn ended (and how).
50 begunAt?: number
51 endedAt?: number
52 outcome?: 'done' | 'stopped' | 'error'
53 // Called back whole by the person (Dungeon's [Recall]).
54 recalled?: boolean
55 // The Skill Box's prompt it was sent with, for a run again.
56 input?: string
57 waves: SquadWave[]
58 others: SquadRun[]
59 // The subagent leading it, when one does (else the person's own model did).
60 leader?: SquadRun
61}
62
63// A press of `combo` (with `input`): its waves with a step in them, none of
64// them run yet.
65export function newMission(id: number, combo: Combo, at: number, input?: string): Mission {
66 const waves = combo.layers.flatMap((layer, i): SquadWave[] =>
67 layer.steps.length === 0
68 ? []
69 : [{ n: i + 1, ...(layer.condition?.trim() ? { condition: layer.condition.trim() } : {}), steps: layer.steps.map(s => ({ ...s })) }],
70 )
71 return { id, combo: combo.name, sentAt: at, waves, others: [], ...(input?.trim() ? { input } : {}) }
72}
73
74// The combo a mission was a press of, as it stood then: its waves at their
75// numbers (the empty ones between them empty again), for a run again.
76export function missionCombo(m: Mission): Combo {
77 const last = Math.max(0, ...m.waves.map(w => w.n))
78 const layers = Array.from({ length: last }, (_, i) => {
79 const w = m.waves.find(x => x.n === i + 1)
80 return w ? { steps: w.steps.map(({ skill, model, agent }) => ({ skill, model, agent })), ...(w.condition ? { condition: w.condition } : {}) } : { steps: [] }
81 })
82 return { name: m.combo, layers }
83}
84
85export type Tag = { combo: string; mission?: number; wave: number; step: number }
86
87// `[Run-Test #3 2.1] run the tests` to the combo, mission, wave and step it
88// names (`[Run-Test 2.1]`, a tag without the mission, as well).
89export function tagOf(description: string): Tag | undefined {
90 const m = description.match(/^\s*\[(.+?)(?: #(\d+))? (\d+)\.(\d+)\]/)
91 return m ? { combo: m[1]!, ...(m[2] ? { mission: Number(m[2]) } : {}), wave: Number(m[3]), step: Number(m[4]) } : undefined
92}
93
94// The prompt a combo sends starts with its name and its mission's number
95// (combos.ts' comboPrompt). Prompts sent while the leader was busy reach it as
96// one, each on a line of its own: every one named so.
97export function combosOf(prompt: string): { combo: string; mission?: number }[] {
98 return [...prompt.matchAll(/^Run the Party Combo "(.+?)"(?: \(mission #(\d+)\))?\./gm)].map(m => ({
99 combo: m[1]!,
100 ...(m[2] ? { mission: Number(m[2]) } : {}),
101 }))
102}
103
104// The number the next press's mission takes: past every one so far, and past
105// `last`, the last one handed out (a mission taken off keeps its number used).
106export const nextMissionId = (missions: readonly Mission[], last: number): number =>
107 Math.max(last, ...missions.map(m => m.id)) + 1
108
109// `[Run-Test #3] lead` to the combo and mission it leads (combos.ts' leaderTag).
110export function leaderOf(description: string): { combo: string; mission: number } | undefined {
111 const m = description.match(/^\s*\[(.+?) #(\d+)\] lead\b/)
112 return m ? { combo: m[1]!, mission: Number(m[2]) } : undefined
113}
114
115const isLive = (m: Mission) => m.begunAt !== undefined && m.endedAt === undefined
116
117// The leader's turn took up `combo`: the mission its prompt numbers, else its
118// oldest mission not yet begun, begins.
119export function beginMission(missions: readonly Mission[], combo: string, at: number, mission?: number): Mission[] {
120 const i = missions.findIndex(m => m.combo === combo && m.begunAt === undefined && (mission === undefined || m.id === mission))
121 return i < 0 ? [...missions] : missions.map((m, k) => (k === i ? { ...m, begunAt: at } : m))
122}
123
124// The leader's turn ended: its missions end with it, but for one with a
125// subagent still running. A step run in the background lets the leader's turn
126// end at once, and the leader takes the mission up again when it reports.
127// A mission with a leader of its own is not the person's turn's: it ends with
128// its leader (endLed).
129export function endMissions(missions: readonly Mission[], outcome: Mission['outcome'], at: number): Mission[] {
130 return missions.map(m => (isLive(m) && !m.leader && !runsOf([m]).some(r => r.status === 'running') ? { ...m, endedAt: at, outcome } : m))
131}
132
133// Whether any mission is still going: Dungeon's clock then keeps ticking.
134export const anyLive = (missions: readonly Mission[]): boolean => missions.some(isLive)
135
136// A step's run to its mission: to the step if still free, else among its
137// others. The mission goes on (begun, or taken up again) while it runs.
138function attachTo(missions: readonly Mission[], i: number, tag: Tag, run: SquadRun): Mission[] {
139 return missions.map((m, k) => {
140 if (k !== i) return m
141 const { endedAt: _, outcome: __, ...rest } = m
142 const going = { ...rest, begunAt: m.begunAt ?? run.startedAt }
143 const wave = m.waves.find(w => w.n === tag.wave)
144 const step = wave?.steps[tag.step - 1]
145 if (!wave || !step) return { ...going, others: [...m.others, run] }
146 const took = (s: SquadStep): SquadStep => (s.run ? { ...s, helpers: [...(s.helpers ?? []), run] } : { ...s, run })
147 return { ...going, waves: m.waves.map(w => (w !== wave ? w : { ...w, steps: w.steps.map(s => (s !== step ? s : took(s))) })) }
148 })
149}
150
151// A subagent started. A leader's goes to the mission it leads, which begins.
152// One a step's subagent (or its helper) spawned (`parent`) is that step's
153// helper. A tag with a mission's number goes to that mission, and only there
154// (a step already taken: its helper). A subagent one of a mission's own
155// spawned otherwise is among its others.
156// Else, a tag without a number: to the step it names in the newest live
157// mission of that combo whose step is still free (a press never seen taken up
158// begins first); else, while a mission is live, among the newest one's others.
159// Undefined when it is no mission's: not the squad's.
160export function attachRun(missions: readonly Mission[], run: SquadRun, parent?: string): Mission[] | undefined {
161 const leads = leaderOf(run.description)
162 if (leads) {
163 const i = missions.findIndex(m => m.id === leads.mission && m.combo === leads.combo)
164 if (i >= 0) {
165 return missions.map((m, k) => {
166 if (k !== i) return m
167 const { endedAt: _, outcome: __, ...rest } = m
168 return { ...rest, begunAt: m.begunAt ?? run.startedAt, leader: run }
169 })
170 }
171 }
172 if (parent !== undefined) {
173 for (let i = 0; i < missions.length; i++) {
174 const m = missions[i]!
175 const step = m.waves.flatMap(w => w.steps).find(s => [...(s.run ? [s.run] : []), ...(s.helpers ?? [])].some(r => r.id === parent))
176 if (!step) continue
177 const { endedAt: _, outcome: __, ...rest } = m
178 return missions.map((x, k) =>
179 k !== i ? x : { ...rest, waves: m.waves.map(w => ({ ...w, steps: w.steps.map(s => (s !== step ? s : { ...s, helpers: [...(s.helpers ?? []), run] })) })) },
180 )
181 }
182 }
183 const tag = tagOf(run.description)
184 if (tag?.mission !== undefined) {
185 const i = missions.findIndex(m => m.id === tag.mission && m.combo === tag.combo)
186 if (i >= 0) return attachTo(missions, i, tag, run)
187 }
188 if (parent !== undefined) {
189 const i = missions.findIndex(m => runsOf([m]).some(r => r.id === parent))
190 if (i >= 0) {
191 return missions.map((m, k) => {
192 if (k !== i) return m
193 const { endedAt: _, outcome: __, ...rest } = m
194 return { ...rest, others: [...m.others, run] }
195 })
196 }
197 }
198 if (tag && !missions.some(m => isLive(m) && m.combo === tag.combo) && missions.some(m => m.combo === tag.combo && m.begunAt === undefined))
199 missions = beginMission(missions, tag.combo, run.startedAt)
200 for (let i = missions.length - 1; tag && i >= 0; i--) {
201 const m = missions[i]!
202 if (!isLive(m) || m.combo !== tag.combo) continue
203 const wave = m.waves.find(w => w.n === tag.wave)
204 const step = wave?.steps[tag.step - 1]
205 if (!wave || !step || step.run) continue
206 return missions.map((x, k) =>
207 k !== i ? x : { ...x, waves: x.waves.map(w => (w !== wave ? w : { ...w, steps: w.steps.map(s => (s !== step ? s : { ...s, run })) })) },
208 )
209 }
210 for (let i = missions.length - 1; i >= 0; i--) {
211 if (isLive(missions[i]!)) return missions.map((x, k) => (k === i ? { ...x, others: [...x.others, run] } : x))
212 }
213 return undefined
214}
215
216// The run under `id` changed; undefined when no mission holds it.
217export function updateRun(missions: readonly Mission[], id: string, fn: (r: SquadRun) => SquadRun): Mission[] | undefined {
218 let found = false
219 const edit = (r: SquadRun) => {
220 if (r.id !== id) return r
221 found = true
222 return fn(r)
223 }
224 const next = missions.map(m => mapRuns(m, edit))
225 return found ? next : undefined
226}
227
228// Every run in the missions: leaders, steps, their helpers and others alike.
229export const runsOf = (missions: readonly Mission[]): SquadRun[] =>
230 missions.flatMap(m => [...(m.leader ? [m.leader] : []), ...m.waves.flatMap(w => w.steps.flatMap(stepRuns)), ...m.others])
231
232// A step's runs: its own subagent's, then its helpers'.
233export const stepRuns = (s: SquadStep): SquadRun[] => [...(s.run ? [s.run] : []), ...(s.helpers ?? [])]
234
235// How a step stands: running while its subagent or any helper it sent out
236// still runs (or awaits its background work); else as its own subagent ended.
237// Undefined until a subagent took it.
238export function stepStatus(s: SquadStep): RunStatus | undefined {
239 if (!s.run) return undefined
240 return stepRuns(s).some(r => r.status === 'running') ? 'running' : s.run.status
241}
242
243// The run of a step (or any run) that waits on the person's answer.
244export const askingRun = (runs: readonly SquadRun[]): SquadRun | undefined => runs.find(r => r.asking !== undefined && r.status === 'running')
245
246export type MissionState = 'queued' | 'running' | 'asking' | 'done' | 'stopped' | 'recalled' | 'error'
247
248// How a mission stands, as its folded card says it.
249export function missionState(m: Mission): MissionState {
250 if (m.endedAt !== undefined) return m.recalled ? 'recalled' : (m.outcome ?? 'done')
251 if (askingRun(runsOf([m]))) return 'asking'
252 return m.begunAt === undefined ? 'queued' : 'running'
253}
254
255// The missions cleared (ended done) this session, by number: `cleared` with
256// any newly done added; undefined when none is new.
257export function clearedOf(missions: readonly Mission[], cleared: readonly number[]): number[] | undefined {
258 const fresh = missions.filter(m => m.endedAt !== undefined && m.outcome === 'done' && !cleared.includes(m.id)).map(m => m.id)
259 return fresh.length > 0 ? [...cleared, ...fresh] : undefined
260}
261
262// The agents a mission's subagents spawned that Dungeon has not seen start
263// (`agents` as the engine lists them), and the model each runs on as far as
264// its step says, else `fallback`.
265export function agentsToFollow(missions: readonly Mission[], agents: readonly { id: string; description: string; parentId?: string }[], fallback: string) {
266 const known = new Set(runsOf(missions).map(r => r.id))
267 return agents.flatMap(a => {
268 if (known.has(a.id) || a.parentId === undefined || !known.has(a.parentId)) return []
269 const tag = tagOf(a.description)
270 const m = missions.find(x => runsOf([x]).some(r => r.id === a.parentId))
271 const step = tag && m?.combo === tag.combo ? m.waves.find(w => w.n === tag.wave)?.steps[tag.step - 1] : undefined
272 const parent = step ? undefined : runsOf(missions).find(r => r.id === a.parentId)
273 return [{ id: a.id, description: a.description, parentId: a.parentId, model: step?.model ?? (parent && !leaderOf(parent.description) ? parent.model : undefined) ?? fallback }]
274 })
275}
276
277// How long a mission whose leader ended its turn stays quiet (nothing running)
278// before it counts as done: a leader waiting on its steps' reports ends its
279// turn between waves, and is woken to send the next.
280export const SETTLE_MS = 10_000
281
282// When the last run of a mission ended.
283const lastEnd = (m: Mission) => Math.max(0, ...runsOf([m]).map(r => r.endedAt ?? 0))
284
285// Whether a mission that finished done has been quiet long enough to be done:
286// a stop or a failure is not waited on.
287const calm = (m: Mission, outcome: Mission['outcome'], at: number) => outcome !== 'done' || at - lastEnd(m) >= SETTLE_MS
288
289// When a mission that ended is said to have: a done one, when its last run
290// ended (the quiet it waited out is no work); else now.
291const endOf = (m: Mission, outcome: Mission['outcome'], at: number) => (outcome === 'done' && lastEnd(m) > 0 ? lastEnd(m) : at)
292
293// The subagent under `id` ended its turn: a mission it led ends with it, but
294// not while a subagent of the mission still runs (`busy`: one the engine
295// lists that Dungeon may not have seen yet), nor, if done, before it has been
296// quiet for SETTLE_MS (settleLed ends it then). A leader whose steps run in
297// the background ends its turn as it sends them, and is woken by their reports.
298export function endLed(missions: readonly Mission[], id: string, outcome: Mission['outcome'], at: number, busy = false): Mission[] | undefined {
299 const m = missions.find(x => x.leader?.id === id)
300 if (!m || m.endedAt !== undefined || busy || runsOf([m]).some(r => r.id !== id && r.status === 'running' && !r.awaiting) || !calm(m, outcome, at)) return undefined
301 const end = endOf(m, outcome, at)
302 return missions.map(x => (x === m ? settled({ ...x, endedAt: end, outcome }, end) : x))
303}
304
305// Missions whose leader has ended its turn and that nothing else of runs any
306// more end now, as their leader did, once quiet for SETTLE_MS: what kept one
307// going (a step, a step's own helper) ended after the leader's last turn.
308export function settleLed(missions: readonly Mission[], at: number): Mission[] | undefined {
309 const outcome = (r: SquadRun): Mission['outcome'] => (r.status === 'completed' ? 'done' : r.status === 'stopped' ? 'stopped' : 'error')
310 const due = (m: Mission) =>
311 m.leader !== undefined &&
312 m.endedAt === undefined &&
313 m.leader.status !== 'running' &&
314 !runsOf([m]).some(r => r.status === 'running' && !r.awaiting) &&
315 calm(m, outcome(m.leader), at)
316 if (!missions.some(due)) return undefined
317 return missions.map(m => {
318 if (!due(m)) return m
319 const how = outcome(m.leader!)
320 const end = endOf(m, how, at)
321 return settled({ ...m, endedAt: end, outcome: how }, end)
322 })
323}
324
325// A mission that ended: a run still awaiting its background work is done
326// with it (its leader had its report and went on).
327const settled = (m: Mission, at: number): Mission =>
328 mapRuns(m, r => (r.awaiting && r.status === 'running' ? { ...r, status: 'completed', awaiting: false, endedAt: r.endedAt ?? at } : r))
329
330// Each run of a mission (its leader's, its steps', their helpers', its
331// others') through `fn`.
332export function mapRuns(m: Mission, fn: (r: SquadRun) => SquadRun): Mission {
333 return {
334 ...m,
335 waves: m.waves.map(w => ({ ...w, steps: w.steps.map(s => ({ ...s, ...(s.run ? { run: fn(s.run) } : {}), ...(s.helpers ? { helpers: s.helpers.map(fn) } : {}) })) })),
336 others: m.others.map(fn),
337 ...(m.leader ? { leader: fn(m.leader) } : {}),
338 }
339}
340
341// The subagent under `id` is at work again (a leader woken by its steps'
342// reports): running once more, and its mission with it.
343export function reviveRun(missions: readonly Mission[], id: string): Mission[] | undefined {
344 const i = missions.findIndex(m => runsOf([m]).some(r => r.id === id))
345 if (i < 0) return undefined
346 const m = missions[i]!
347 const r = runsOf([m]).find(x => x.id === id)
348 if (m.endedAt === undefined && r?.status === 'running' && !r.awaiting) return undefined
349 const { endedAt: _, outcome: __, ...rest } = m
350 const back = (r: SquadRun): SquadRun => {
351 if (r.id !== id) return r
352 const { endedAt: _e, awaiting: _a, ...live } = r
353 return { ...live, status: 'running' }
354 }
355 return missions.map((x, k) => (k !== i ? x : mapRuns(rest as Mission, back)))
356}
357
358// Whether Dungeon should still look for subagents in the engine's list: a
359// mission with a leader is going, or ended within `within` ms (its leader may
360// yet be woken).
361export const watching = (missions: readonly Mission[], now: number, within: number): boolean =>
362 missions.some(m => m.leader !== undefined && (m.endedAt === undefined || now - m.endedAt < within))
363
364// What the engine's agent list says of a run: undefined while it still runs.
365export function statusOfAgent(status: string): RunStatus | undefined {
366 if (status === 'completed') return 'completed'
367 if (status === 'failed') return 'failed'
368 if (status === 'killed') return 'stopped'
369 return undefined
370}
371
372// `1m 05s`, `42s`, `1h 03m`.
373export function elapsed(ms: number): string {
374 const s = Math.max(0, Math.floor(ms / 1000))
375 if (s < 60) return `${s}s`
376 const m = Math.floor(s / 60)
377 if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
378 return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
379}
380
381// What a model request's usage counts toward a run's tokens: all it took in,
382// cache read and written included, and all it gave out.
383export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
384export const usageTokens = (u: Usage): number =>
385 u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
386
387// Every run's tokens in the missions, summed.
388export const tokensOf = (missions: readonly Mission[]): number => runsOf(missions).reduce((n, r) => n + runTokens(r), 0)
389
390// A run's tokens: its requests', else its turns'.
391export const runTokens = (r: SquadRun): number => r.tokens || r.turnTokens || 0
392
393// `812 tok`, `9.4k tok`, `31k tok`, `1.2M tok`.
394export function tokensText(n: number): string {
395 if (n < 1000) return `${n} tok`
396 if (n < 10_000) return `${(n / 1000).toFixed(1)}k tok`
397 if (n < 1_000_000) return `${Math.round(n / 1000)}k tok`
398 return `${(n / 1_000_000).toFixed(1)}M tok`
399}
400
401// A mission's tokens beside its coin: `812`, `9.4K`, `124K`, `1.2M`.
402export function coinText(n: number): string {
403 if (n < 1000) return `${n}`
404 if (n < 10_000) return `${(n / 1000).toFixed(1)}K`
405 if (n < 1_000_000) return `${Math.round(n / 1000)}K`
406 return `${(n / 1_000_000).toFixed(1)}M`
407}
408
409// A run's answer in a few lines: blank lines and markdown marks gone.
410export function resultLines(text: string, lines: number): string[] {
411 const kept = text
412 .split('\n')
413 .map(l => l.replace(/^\s*(#+|[-*>]|\d+\.)\s+/, '').replace(/[*`_]{1,3}/g, '').trim())
414 .filter(l => l !== '')
415 return kept.length > lines ? [...kept.slice(0, lines - 1), `${kept[lines - 1]} …`] : kept
416}
417
418// When a mission was sent, as this computer's clock read it: `2026-10-09 14:54`
419// (`offset` minutes from UTC).
420export function dateTimeText(at: number, offset: number): string {
421 const d = new Date(at + offset * 60_000)
422 const pad = (n: number) => String(n).padStart(2, '0')
423 return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`
424}
425hooks/dungeon.ts 194 lines1// Dungeon's scene, over its missions: on the left a wooden board keeping how
2// many missions this session cleared; in the middle the main slime with its
3// sword; on the right the dungeon's keep. While missions go on, the slime
4// fights (a bubble at its upper left shows the fight, and how many are going)
5// and the keep's portcullis is up, eyes glowing in the dark; with none, the
6// slime rests on guard, its sword planted beside it and its eyes on the keep,
7// whose portcullis is down.
8
9import { modelInfo, pack, SKY } from './scene'
10import type { Overlay } from './scene'
11
12export const DUNGEON_ROWS = 8
13const H = DUNGEON_ROWS * 2
14const FLOOR_Y = H - 1
15
16const FLOOR = { base: 0x3d3d4a, speck: 0x4a4a58 }
17const BOARD = { edge: 0x6b4226, fill: 0x9c6b3f, text: 0xffe8c2, count: 0xffd23f }
18const BUBBLE = { fill: 0xf5f5f5, text: 0x101018 }
19const MONSTER = { body: 0x7b2cbf, eye: 0xff4d6d, hit: 0xff4d6d }
20const SPARK = 0xffd23f
21const SWORD = { blade: 0xdfe6ee, tip: 0xffffff, guard: 0xd4a017, grip: 0x6b4226 }
22const KEEP = {
23 stone: 0x6c757d,
24 mortar: 0x495057,
25 dark: 0x0b0b14,
26 bars: 0x8d8d99,
27 eyes: 0xff4d6d,
28 bone: 0xe9e4d4,
29 banner: 0xa4161a,
30 trim: 0xd4a017,
31 handle: 0x6b4226,
32 flames: [0xff9f1c, 0xffd23f],
33}
34const EYE = 0x101018
35
36// The main slime, larger than the world's: B body, H its gloss, D its rim, E
37// eyes; on guard, settled a little lower, its eyes turned to the keep.
38const SLIME = {
39 awake: ['..BBBB..', '.BHHBBB.', 'BHBEBBEB', 'DBBBBBBD', 'DBBBBBBD', '.DDDDDD.'],
40 guard: ['..BBBB..', '.BHHBBB.', 'BHBBEBEB', 'DBBBBBBD', '.DDDDDD.'],
41}
42// The board's count, in pixels: 3x5 digits.
43const DIGITS = [
44 ['###', '#.#', '#.#', '#.#', '###'],
45 ['.#.', '##.', '.#.', '.#.', '###'],
46 ['###', '..#', '###', '#..', '###'],
47 ['###', '..#', '###', '..#', '###'],
48 ['#.#', '#.#', '###', '..#', '..#'],
49 ['###', '#..', '###', '..#', '###'],
50 ['###', '#..', '###', '#.#', '###'],
51 ['###', '..#', '.#.', '.#.', '.#.'],
52 ['###', '#.#', '###', '#.#', '###'],
53 ['###', '#.#', '###', '..#', '###'],
54]
55
56// Where the board stands, and the widths of the board, the keep and the
57// bubble; the slime with its sword.
58const BOARD_X = 1
59const BOARD_W = 11
60const KEEP_W = 11
61const BUBBLE_W = 8
62const HERO_W = SLIME.awake[0]!.length + 2
63
64// The scene's frame of the fight: one of four, turning every few ticks.
65export const fightFrame = (tick: number) => Math.floor(tick / 3) % 4
66
67// `going`: the missions not yet ended (0: none, the slime rests on guard); `cleared`:
68// those done this session; `model`: the main slime's, for its color.
69export function dungeonFrame(columns: number, tick: number, model: string, going: number, cleared: number): string {
70 const w = Math.max(1, Math.min(512, columns))
71 const px = new Uint32Array(w * H).fill(SKY)
72 const overlays: Overlay[] = []
73 const put = (x: number, y: number, color: number) => {
74 if (x >= 0 && x < w && y >= 0 && y < H) px[y * w + x] = color
75 }
76 const text = (x: number, row: number, s: string, fg: number, bg: number) => [...s].forEach((ch, i) => overlays.push({ col: x + i, row, ch, fg, bg }))
77 const fighting = going > 0
78 const f = fighting ? fightFrame(tick) : 0
79 const hit = f % 2 === 1
80 // The board at the left, the keep at the right edge (pushed past it when
81 // the pane is too narrow for all), the slime midway between them.
82 const boardR = BOARD_X + BOARD_W - 1
83 const g = Math.max(w - KEEP_W, boardR + HERO_W)
84 const sx = boardR + 1 + Math.max(0, Math.floor((g - boardR - 1 - HERO_W) / 2))
85 const sr = sx + SLIME.awake[0]!.length - 1
86
87 // The floor, all the pane's width.
88 for (let x = 0; x < w; x++) put(x, FLOOR_Y, x % 5 === 0 ? FLOOR.speck : FLOOR.base)
89
90 // The board: a plank on two posts, CLEARED over the count.
91 for (let y = 1; y <= 11; y++)
92 for (let x = BOARD_X; x <= boardR; x++) put(x, y, y === 1 || y === 11 || x === BOARD_X || x === boardR ? BOARD.edge : BOARD.fill)
93 for (let y = 12; y < FLOOR_Y; y++) (put(BOARD_X + 2, y, BOARD.edge), put(boardR - 2, y, BOARD.edge))
94 text(BOARD_X + 2, 1, 'CLEARED', BOARD.text, BOARD.fill)
95 // The count under it in two pixel digits (`03`; 99 past that), a pixel
96 // clear of the words above and of the board's edge all round.
97 const count = String(Math.min(cleared, 99)).padStart(2, '0')
98 const cx = BOARD_X + 2
99 ;[...count].forEach((d, i) =>
100 DIGITS[Number(d)]!.forEach((row, dy) => [...row].forEach((c, dx) => c === '#' && put(cx + i * 4 + dx, 5 + dy, BOARD.count))),
101 )
102
103 // The keep: a battlemented wall of bricks, a skull over its arched door, a
104 // banner and a torch each side, and a portcullis, up while missions go on.
105 const door = (x: number, y: number) => y >= 8 && x >= g + 3 && x <= g + 7 && !(y === 8 && (x === g + 3 || x === g + 7))
106 for (let y = 1; y < FLOOR_Y; y++) {
107 for (let x = g; x < g + KEEP_W; x++) {
108 const dx = x - g
109 if (y === 1 && (dx === 2 || dx === 3 || dx === 7 || dx === 8)) continue
110 const mortar = y === 2 || y % 3 === 0 || (dx + (Math.floor(y / 3) % 2) * 2) % 4 === 0
111 put(x, y, door(x, y) ? KEEP.dark : mortar ? KEEP.mortar : KEEP.stone)
112 }
113 }
114 // The portcullis: its bars down to the floor, or drawn up to the arch.
115 for (const bx of [g + 4, g + 6]) for (let y = 9; y < (fighting ? 11 : FLOOR_Y); y++) put(bx, y, KEEP.bars)
116 if (!fighting) for (let x = g + 3; x <= g + 7; x++) put(x, 11, KEEP.bars)
117 // Something waits in the dark while missions go on.
118 if (fighting && f !== 3) (put(g + 4, 12, KEEP.eyes), put(g + 6, 12, KEEP.eyes))
119 // The skull over the door.
120 for (let x = g + 4; x <= g + 6; x++) put(x, 4, KEEP.bone)
121 put(g + 4, 5, KEEP.dark)
122 put(g + 5, 5, KEEP.bone)
123 put(g + 6, 5, KEEP.dark)
124 put(g + 4, 6, KEEP.bone)
125 put(g + 6, 6, KEEP.bone)
126 // Banners, swallow-tailed, and torches under them.
127 for (const bx of [g + 1, g + 8]) {
128 put(bx, 3, KEEP.trim)
129 put(bx + 1, 3, KEEP.trim)
130 for (let y = 4; y <= 6; y++) (put(bx, y, KEEP.banner), put(bx + 1, y, KEEP.banner))
131 put(bx, 7, KEEP.banner)
132 }
133 for (const tx of [g + 1, g + 9]) {
134 put(tx, 11, KEEP.handle)
135 put(tx, 10, KEEP.flames[(Math.floor(tick / 2) + tx) % 2]!)
136 if (fighting && f !== 3) put(tx, 9, KEEP.flames[1]!)
137 }
138
139 // The slime: hopping as it fights, else resting on guard.
140 const info = modelInfo(model)
141 const colors: Record<string, number> = { B: info.body, H: info.light, D: info.dark, E: EYE }
142 const lift = hit ? 1 : 0
143 const sprite = fighting ? SLIME.awake : SLIME.guard
144 const top = FLOOR_Y - sprite.length - lift
145 sprite.forEach((row, dy) => [...row].forEach((c, dx) => colors[c] !== undefined && put(sx + dx, top + dy, colors[c]!)))
146
147 // The sword: in its right hand, upright or swung toward the keep; at rest,
148 // planted in the ground at its side, its hilt to hand.
149 const hx = sr + 1
150 if (!fighting) {
151 put(hx, FLOOR_Y - 8, SWORD.guard)
152 put(hx, FLOOR_Y - 7, SWORD.grip)
153 for (let x = hx - 1; x <= hx + 1; x++) put(x, FLOOR_Y - 6, SWORD.guard)
154 for (let y = FLOOR_Y - 5; y < FLOOR_Y; y++) put(hx, y, SWORD.blade)
155 } else {
156 const hand = FLOOR_Y - 3 - lift
157 put(hx, hand, SWORD.grip)
158 if (hit) {
159 put(hx - 1, hand - 1, SWORD.guard)
160 put(hx + 1, hand + 1, SWORD.guard)
161 for (let i = 1; i <= 5; i++) put(hx + i, hand - i, i === 5 ? SWORD.tip : SWORD.blade)
162 } else {
163 put(hx, hand + 1, SWORD.guard)
164 for (let x = hx - 1; x <= hx + 1; x++) put(x, hand - 1, SWORD.guard)
165 for (let y = hand - 7; y < hand - 1; y++) put(hx, y, y === hand - 7 ? SWORD.tip : SWORD.blade)
166 }
167 }
168
169 // The bubble at its upper left: the fight, and how many are going.
170 if (fighting) {
171 const bx = Math.max(boardR + 2, sx - BUBBLE_W + 3)
172 // It starts a row down from the pane's top, so that the tab above does
173 // not run into it.
174 for (let y = 1; y <= 7; y++) {
175 for (let x = bx; x < bx + BUBBLE_W; x++) {
176 if ((y === 1 || y === 7) && (x === bx || x === bx + BUBBLE_W - 1)) continue
177 put(x, y, BUBBLE.fill)
178 }
179 }
180 put(bx + BUBBLE_W - 2, 8, BUBBLE.fill)
181 // A little slime lunges at a little monster; a spark where they meet.
182 const ms = bx + 1 + (hit ? 1 : 0)
183 for (const [dx, dy] of [[0, 0], [1, 0], [0, 1], [1, 1]] as const) put(ms + dx, 4 + dy, info.body)
184 const mx = bx + 4
185 for (let y = 3; y <= 5; y++) for (let x = mx; x <= mx + 2; x++) put(x, y, hit ? MONSTER.hit : MONSTER.body)
186 put(mx + 1, 4, hit ? BUBBLE.fill : MONSTER.eye)
187 if (hit) for (const [x, y] of [[mx, 3], [mx - 1, 4], [mx, 4], [mx + 1, 4], [mx, 5]] as const) put(x, y, SPARK)
188 const n = `×${Math.min(going, 99)}`
189 text(bx + Math.floor((BUBBLE_W - n.length) / 2), 3, n, BUBBLE.text, BUBBLE.fill)
190 }
191
192 return pack(px, w, overlays, DUNGEON_ROWS)
193}
194hooks/summary.ts 113 lines1// A subagent's task summary laid out for the Party: wrapped to
2// the pane's width so every line after the first hangs under the first's text.
3
4// The model has its own line above, so a family name in the task's words
5// (`Haiku test 1`, `Sonnet: review`) is left out of the summary.
6export function cleanSummary(text: string): string {
7 return text.replace(/\b(fable|opus|sonnet|haiku)\b:?/gi, '').replace(/\s+/g, ' ').trim() || 'subagent'
8}
9
10// Terminal cells a character takes: CJK and full-width forms take two.
11const WIDE = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/
12const cells = (text: string) => [...text].reduce((n, ch) => n + (WIDE.test(ch) ? 2 : 1), 0)
13
14// Words break at spaces; a wide character is a word of its own, so CJK text
15// wraps anywhere; a word longer than a line is split where the line ends.
16function units(text: string): string[] {
17 return text.match(/[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]|[^\sᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]+|\s+/g) ?? []
18}
19
20export function wrapSummary(text: string, width: number, maxLines = 3): string[] {
21 const lines: string[] = []
22 let line = ''
23 const push = () => {
24 lines.push(line.trimEnd())
25 line = ''
26 }
27 for (const unit of units(text)) {
28 if (/^\s+$/.test(unit)) {
29 if (line !== '') line += ' '
30 continue
31 }
32 let rest = unit
33 while (rest !== '') {
34 const room = width - cells(line)
35 if (cells(rest) <= room) {
36 line += rest
37 rest = ''
38 } else if (line.trim() !== '' && cells(rest) <= width) {
39 push()
40 } else {
41 // Split what does not fit even on a line of its own.
42 let head = ''
43 for (const ch of rest) {
44 if (cells(head + ch) > room) break
45 head += ch
46 }
47 if (head === '') {
48 push()
49 continue
50 }
51 line += head
52 rest = rest.slice(head.length)
53 push()
54 }
55 }
56 }
57 if (line.trim() !== '') push()
58
59 if (lines.length <= maxLines) return lines
60 const kept = lines.slice(0, maxLines)
61 let last = kept[maxLines - 1]!
62 while (last !== '' && cells(last) + 1 > width) last = [...last].slice(0, -1).join('')
63 kept[maxLines - 1] = `${last.trimEnd()}…`
64 return kept
65}
66
67// The session's name from `grep -o` over its transcript, one
68// `"customTitle":"…"` or `"aiTitle":"…"` a line, oldest first: the last name
69// /rename gave wins over any Claude Code made up.
70export function titleFrom(lines: string): string | undefined {
71 let custom: string | undefined
72 let made: string | undefined
73 for (const line of lines.split('\n')) {
74 const m = line.match(/^"(customTitle|aiTitle)":(".*")$/)
75 if (!m) continue
76 let value: string
77 try {
78 value = JSON.parse(m[2]!) as string
79 } catch {
80 continue
81 }
82 if (m[1] === 'customTitle') custom = value
83 else made = value
84 }
85 return custom ?? made
86}
87
88// The sign's [≡] list: the project's other sessions, newest first, that the
89// person has typed into (a `"type":"user"` line among what grep found), each
90// by the name titleFrom reads, else when it was last written.
91export type RecentSession = { id: string; title: string; at: number }
92export function recentFrom(
93 found: readonly { id: string; at: number; lines: string }[],
94 current: string,
95 max: number,
96 untitled: (at: number) => string,
97): RecentSession[] {
98 return found
99 .filter(f => f.id !== current && f.lines.includes('"type":"user"'))
100 .sort((a, b) => b.at - a.at)
101 .slice(0, max)
102 .map(f => ({ id: f.id, title: titleFrom(f.lines) ?? untitled(f.at), at: f.at }))
103}
104
105// How long ago, as a list row says it: `now`, `5m`, `3h`, `2d`.
106export function agoText(at: number, now: number): string {
107 const s = Math.max(0, Math.floor((now - at) / 1000))
108 if (s < 60) return 'now'
109 if (s < 3600) return `${Math.floor(s / 60)}m`
110 if (s < 86400) return `${Math.floor(s / 3600)}h`
111 return `${Math.floor(s / 86400)}d`
112}
113hooks/freshness.ts 37 lines1// Is this dashboard behind GitHub? An install (`/plugin install`) records the
2// commit it came from; that is compared with the newest commit on main. A copy
3// loaded from a folder has no such record, so its version is compared with
4// the version main's manifest declares instead.
5
6export const REMOTE_SHA_URL = 'https://api.github.com/repos/egan-wu/slime-dashboard/commits/main'
7export const REMOTE_MANIFEST_URL = 'https://raw.githubusercontent.com/egan-wu/slime-dashboard/main/.claude-plugin/plugin.json'
8
9type InstallRecord = { gitCommitSha?: string; lastUpdated?: string }
10
11// The commit of the newest install of `pluginId` in installed_plugins.json's
12// text; undefined when it is not installed or the text does not parse.
13export function installedSha(recordText: string, pluginId: string): string | undefined {
14 try {
15 const records = (JSON.parse(recordText) as { plugins?: Record<string, InstallRecord[]> }).plugins?.[pluginId] ?? []
16 const newest = [...records].sort((a, b) => (b.lastUpdated ?? '').localeCompare(a.lastUpdated ?? ''))[0]
17 return newest?.gitCommitSha
18 } catch {
19 return undefined
20 }
21}
22
23// GitHub answers a bare commit hash when asked for `application/vnd.github.sha`.
24export function remoteSha(text: string): string | undefined {
25 const sha = text.trim()
26 return /^[0-9a-f]{40}$/.test(sha) ? sha : undefined
27}
28
29export function manifestVersion(text: string): string | undefined {
30 try {
31 const version = (JSON.parse(text) as { version?: unknown }).version
32 return typeof version === 'string' ? version : undefined
33 } catch {
34 return undefined
35 }
36}
37hooks/vitals.ts 70 lines1// The slime's vitals, read from the session's usage figures.
2//
3// On a Pro or Max subscription HP is what is left of the seven-day limit and
4// MP what is left of the five-hour one. Anywhere else (an API key, an
5// enterprise seat) there is no such window to read, and HP stays full. CP is
6// how full the context window is, its capacity.
7
8export type Plan = 'subscription' | 'api'
9export type Vitals = {
10 plan: Plan
11 // Whole percentages left, 0 to 100.
12 hp: number
13 mp: number
14 // The context window's fill as a whole percentage; -1 before the first reading.
15 cp: number
16}
17
18export const FULL: Vitals = { plan: 'api', hp: 100, mp: 100, cp: -1 }
19
20type Window = { kind: string; percentUsed: number }
21
22const left = (used: number | undefined) =>
23 used === undefined ? 100 : Math.max(0, Math.min(100, Math.round(100 - used)))
24
25// A subscription reports its five-hour and seven-day windows; an API key or
26// an enterprise seat reports neither.
27export function vitalsOf(rateLimits: readonly Window[], contextPercent: number | undefined): Vitals {
28 const used = (kind: string) => rateLimits.find(w => w.kind === kind)?.percentUsed
29 const isSubscription = used('five_hour') !== undefined || used('seven_day') !== undefined
30 const cp = contextPercent === undefined ? -1 : Math.max(0, Math.min(100, Math.round(contextPercent)))
31 return isSubscription
32 ? { plan: 'subscription', hp: left(used('seven_day')), mp: left(used('five_hour')), cp }
33 : { plan: 'api', hp: 100, mp: 100, cp }
34}
35
36// Out of HP, or on a subscription out of MP: the slime is down until a limit resets.
37export const isDown = (v: Vitals) => v.hp <= 0 || (v.plan === 'subscription' && v.mp <= 0)
38
39export type Eyes = 'x' | '><' | 'TT'
40// `ask`: the session waits on the person (a permission prompt, a question),
41// so the troop holds still with a blinking question mark over the main slime.
42// `potions`: the pools that ran dry, each shown as a flashing potion in a
43// ring at the main slime's upper left: blue for MP, red for HP.
44export type Potion = 'hp' | 'mp'
45// `unloading`: the context is compacting; the slime wakes and a flashing
46// ring with a sack and a green down arrow stands where the potions do.
47// `respawn`: ticks since the Skill Box's Respawn cleared the session.
48// `warming`: Cache Warming is on, so the slime stays awake (standing still
49// while idle) instead of falling asleep; `camp`: where its campfire burns
50// (the screen column of its left edge), absent when there is none;
51// `campAge`: ticks since it was set, while the slime spits a log and a flame.
52export type Face = { eyes?: Eyes; vein?: boolean; down?: boolean; ask?: boolean; potions?: Potion[]; unloading?: boolean; respawn?: number; warming?: boolean; embers?: boolean; camp?: number; campAge?: number }
53
54// Down: it stops with crossed-out eyes. Walking with a full head: squinting
55// from 50%, a vein beside its head from 70%, in tears from 90%.
56export function faceOf(v: Vitals, walking: boolean): Face {
57 if (isDown(v)) {
58 const potions: Potion[] = []
59 if (v.plan === 'subscription' && v.mp <= 0) potions.push('mp')
60 if (v.hp <= 0) potions.push('hp')
61 return { eyes: 'x', down: true, potions }
62 }
63 if (!walking || v.cp < 50) return {}
64 return { eyes: v.cp >= 90 ? 'TT' : '><', vein: v.cp >= 70 }
65}
66
67// How many of `cells` a bar fills for a percentage.
68export const filledOf = (percent: number, cells: number) =>
69 percent < 0 ? 0 : Math.max(0, Math.min(cells, Math.round((percent / 100) * cells)))
70hooks/layout.ts 45 lines1// The pane's sections, which Setting's Display can hide and its Order can
2// arrange; Setting itself always stays last and shown, so anything hidden or
3// moved can always be brought back.
4
5export const SECTIONS = [
6 { id: 'session', label: 'Session' },
7 { id: 'stats', label: 'HP / MP / CP' },
8 { id: 'scene', label: 'Slime' },
9 { id: 'models', label: 'Model buttons' },
10 { id: 'passive', label: 'Passive' },
11 { id: 'property', label: 'Property' },
12 { id: 'skills', label: 'Skill Box' },
13 { id: 'tree', label: 'Party Combo' },
14 { id: 'monitor', label: 'Party' },
15 { id: 'events', label: 'Event Message' },
16 { id: 'journal', label: 'Journal' },
17] as const
18
19export type SectionId = (typeof SECTIONS)[number]['id']
20export const DEFAULT_ORDER: SectionId[] = SECTIONS.map(s => s.id)
21
22// A kept order, with anything unknown or repeated dropped and any section it
23// lacks (one added since it was kept) put in at its default place.
24export function orderFrom(value: unknown): SectionId[] {
25 const known = new Set<string>(DEFAULT_ORDER)
26 const kept = Array.isArray(value) ? value.filter((v): v is SectionId => typeof v === 'string' && known.has(v)) : []
27 const order = [...new Set(kept)]
28 DEFAULT_ORDER.forEach((id, i) => {
29 if (!order.includes(id)) order.splice(Math.min(i, order.length), 0, id)
30 })
31 return order
32}
33
34// The order with `id` swapped with its neighbor `by` (-1 up, 1 down); at
35// either end it stays put.
36export function moveSection(order: SectionId[], id: SectionId, by: -1 | 1): SectionId[] {
37 const at = order.indexOf(id)
38 const to = at + by
39 if (at < 0 || to < 0 || to >= order.length) return order
40 const next = [...order]
41 next[at] = order[to]!
42 next[to] = id
43 return next
44}
45