SLOPSHOPPER

slime-dashboard

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

newpaneguardcommandtoolmodel
v0.5.1no licenseupdated 2026-10-09egan-wu/slime-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slime-dashboard
│ ┃ Slime ✕ › fix the failing auth test and add an audit log call │ ┃ ╭──────────────────────────────╮ │ ┃ │ [≡] ! Mystic Journey ! │ ⏺ Read(src/auth.ts) │ ┃ ╰──────────────────────────────╯ ⎿ Read 6 lines │ ┃ HP [█████] 100% MP [███░░] 69% ⏺ Update(src/auth.ts) │ ┃ CP [██████████░░░░░░░░░░░] 49% ⎿ Added 2 lines, removed 1 line │ ┃ ▄▄▀▀▀▄▄▄ ⏺ Bash(bun test) │ ┃ ▄▀▀▀▄ ⎿ 3 pass, 1 fail │ ┃ ▀▀▀▀▀▀▄▀ │ ┃ ▀▀▀▀▀▀▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▀▀▀▀▀▀ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ ■:Haiku ■:Sonnet ■:Opus ■:Fable │ ┃ ──────────────────────────────── › /slime-dashboard │ ┃ Passive [ ▲ ] ⎿ slime-dashboard: Slime dashboard opened. │ ┃ ──────────────────────────────── │ ┃ Property [ ▲ ] │ ┃ ──────────────────────────────── │ ┃ Skill Box [ ▲ ] │ ┃ ──────────────────────────────── │ ┃ Party Combo (0) [ ▲ ] │ ┃ ──────────────────────────────── │ ┃ Party (0) [ ▲ ] │ ┃ ──────────────────────────────── │ ┃ Event Message (0) [ ▲ ] │ ┃ ──────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Slime
╭──────────────────────────────╮ │ [≡] ! Mystic Journey ! │ ╰──────────────────────────────╯ HP [█████] 100% MP [███░░] 69% CP [██████████░░░░░░░░░░░] 49% ▄▄▀▀▀▄▄▄ ▄▀▄▄▄ ▄▀▀▀▄ ▀ ▀▀▀▀▀▀▄▀ ▀▀▀▀▀▀▀▀ z ▀ ▀▀▀▀▀▀ ■:Haiku ■:Sonnet ■:Opus ■:Fable ──────────────────────────────── Passive [ ▲ ] ──────────────────────────────── Property [ ▲ ] ──────────────────────────────── Skill Box [ ▲ ] ──────────────────────────────── Party Combo (0) [ ▲ ] ──────────────────────────────── Party (0) [ ▲ ] ──────────────────────────────── Event Message (0) [ ▲ ] ──────────────────────────────── Journal [ ▲ ] ──────────────────────────────── Setting [ ▲ ] v0.5.1
Pane · dungeon
▄▄▄▄▄▄▄▄▄▄▄ ▄▄ ▄▄▄ ▄▄ ▀▀CLEARED▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀ ▄ ▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀
README

slime-dashboard

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

Install

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.

Session

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

HP / MP / CP, the slime and the model buttons

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

  • HP / MP / CP: on a Pro or Max subscription HP is what is left of the seven-day limit and MP of the five-hour one; on an API key or enterprise seat HP stays full. CP (capacity) is how full the context window is, shading from light to dark grey.
  • The slime travels while Claude works, past clouds, birds, trees (round green, pine or autumn orange, at random) and rocks at their own speeds; every load opens on the same stretch of road, where a tall oval tree stands half behind an autumn one. Along the road lie 2x2 blobs of goo in every color: it stretches taller with its white mouth wide open as it reaches each one and eats it, its context filling up. Its color follows the model: Fable purple, Opus red, Sonnet blue, Haiku yellow, unless Setting's [Color] picked others.
  • Its face: walking with CP at 50% it squints (> <), 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.
  • Unloading: while the context compacts ([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.
  • Subagents: each one buds off a little slime in its model's color that follows it. When the work is done the troop finds a treasure chest.
  • Weather: by default the sky follows this computer's clock, day from 06:00 to 18:00, and each hour draws its weather at random: clear or partly cloudy most often, cloudy less, rain now and then, snow rarely. Nothing leaves the machine for it. Turning Setting's [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.
  • Model buttons ■:Haiku ■:Sonnet ■:Opus ■:Fable switch the session's model (/model haiku, …): each family's newest model.
AsleepWaiting 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.

Passive

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

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

Property

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

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

Skill Box

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

ButtonWhat it does
Prompt for skillIts 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 ComboThe 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).

Party Combo

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

ButtonWhat 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.
+ skillLists the Skill Box's skills not yet in the wave, side by side; each press adds one.
◆ + conditionBetween 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.
+ WaveAdds 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.

Dungeon

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

WhatShows
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 ● 124KThe 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 05sThe 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 subagentsSubagents 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.

Party

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

Event Message

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

Journal

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

  • Cache Read / Write: this session's tokens read from and written to the cache.
  • Cache Expires: when the cache the last request left runs out, in this computer's time, and how long until then.
  • Cache Hit Rate over the days, then a bar a day (as many as the box has room for, today last; · for a day with no record).
  • Cold Starts: prompts that came back to a lapsed cache and wrote the context afresh, and how much.
  • Pings · Rescues: the requests Cache Warming sent, and the prompts that found the cache still warm thanks to them.
  • Saved: what rescues saved less what pings cost, in input tokens at full price.

Setting

<img src="docs/setting.png" width="340" alt="Setting open: Update, Display, Color, Order, Width, Performance, Weather (Off) and Reload, then the version">

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

Getting around

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.

Usage

/slime-dashboard                                   open the pane
/slime-dashboard add <skill> [--category <name>] [--desc <words>]
/slime-dashboard remove <skill>
/slime-dashboard list                              the Skill Box's skills
Source 17 files
hooks/register.tsx 3004 lines
1import { 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}
1200
hooks/scene.ts 1053 lines
1// 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}
1053
hooks/events.ts 36 lines
1// 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}
36
hooks/props.ts 77 lines
1// 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}
77
hooks/skills.ts 82 lines
1// 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}
82
hooks/combos.ts 167 lines
1// 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}
167
hooks/squad.ts 425 lines
1// 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}
425
hooks/dungeon.ts 194 lines
1// 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}
194
hooks/summary.ts 113 lines
1// 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}
113
hooks/freshness.ts 37 lines
1// 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}
37
hooks/vitals.ts 70 lines
1// 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)))
70
hooks/layout.ts 45 lines
1// 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