A stepped progress bar of your workflow above the prompt, with pixel-art Clawd scenes while Claude works and while it waits on agents

A stepped progress bar of your development workflow above the Claude Code prompt, with pixel-art cartoons of Clawd below it while Claude works.

claude plugin marketplace add wmaq/wmaq-claude-mods
claude plugin install stage-toons@wmaq-claude-mods
Restart Claude Code. Needs 2.1.288 or later with plugin hook modules (early access); the cartoons draw in the terminal, and the desktop app shows the bar as text.
●━●━●━●━◉─○─○─○─○─○ 5/10 Plan · stage-toons
Writing the implementation plan
| Command | What it does |
|---|---|
/stage help | A guide to every feature, one topic at a time |
/stage demo | Play every scene now (/stage demo waiting, /stage demo dj, ...) |
/stage next, /stage plan, /stage done | Move the bar yourself |
/stage calm on | Hold the cartoons still |
/stage dj on | Clawd DJ while Spotify plays |
/stage log, /stage time | How long each stage took, and what it cost |
The rest of this page is the detail. Inspired by claude-toons by Anshu Chimala, whose Clawd sprite it borrows (MIT; see NOTICE). What changed in each release is in the CHANGELOG; updating and running from a clone are under Install, in full.
| # | Stage | What it means |
|---|---|---|
| 1 | Roadmap | Roadmap discussion and new-feature brainstorming |
| 2 | Brainstorm | Brainstorming one feature |
| 3 | Spec | Writing the spec |
| 4 | Spec audit | A Fable subagent audits the spec |
| 5 | Plan | Writing the implementation plan |
| 6 | Plan audit | A Fable subagent audits the plan |
| 7 | Develop | Building it |
| 8 | Claude testing | Claude verifies the work |
| 9 | Your testing | You test it |
| 10 | Cleanup | Leftovers, chores and docs |
Beside the track are two side stages, which the work can enter from any point and leave again:
| Stage | What it means |
|---|---|
| Debugging | Finding and fixing a bug, at any point (/stage debugging) |
| Other | Anything that fits none of them (/stage 0 or /stage ?) |
On a side stage the bar shows the stage instead of the track, and the track stage the work returns to:
Debugging · login bug ↩ 7/10 Develop
Reproducing the failing login test
Past the last stage is Done: the feature is merged, finished. The whole track lights up, the bar reads ✓ Done · <feature> with "Merged and done" under it, Clawd gets the confetti, and one of Done's scenes plays (fireworks, a rocket, or a victory lap):
●━●━●━●━●━●━●━●━●━● ✓ Done · login
Merged and done
The next move starts a new round: the feature is cleared (Claude names the next one), and the track starts again from the beginning. A move back to the stage Done came from (/stage back) reopens the feature instead, as the undo of a mistaken Done.
This is the default workflow. A project can define its own (below).
Claude moves it. The mod gives Claude a set_stage tool and a short note in the system prompt listing the stages; Claude calls the tool when the work moves on (forwards, backwards or skipping) and writes the message under the bar. Some steps move it by themselves (below). Progress is saved per project directory and survives restarts.
You can move it yourself with /stage:
| Command | Effect | |
|---|---|---|
/stage | Show the stage and the recent moves | |
/stage 5, /stage plan | Move to a stage (0 or ? for Other) | |
/stage debugging | Move to a side stage by its id | |
/stage next, /stage back | One step (next from the last stage is Done; from Done, a new round) | |
/stage done | The feature is finished (merged); the next move starts a new one | |
/stage feature <name> | Name the feature (/stage feature - clears it) | |
/stage message <text> | Set the message | |
/stage hide | Hide or show the bar and the cartoons | |
| `/stage demo [<stage> \ | idle] [seconds]` | Play every scene, one stage's scenes (by id), or the idle routines, 10 s each (2 to 60); a bare number is the seconds |
/stage demo waiting [scene] | Play the waiting toon with pretend agents (about 25 s): the next of its four scenes, or the one named (laptops, building, rowing, conveyor) | |
/stage demo work [seconds] | Play what Clawd shows while Claude works (thinking, each kind of tool, his lines), 3 s a step | |
/stage demo dj [seconds] | Play Clawd DJ (20 s): a long title, a track change, two helpers dancing | |
/stage demo stop | End the demo | |
/stage workflow | Show the workflow, where it came from, and each stage's scenes | |
/stage workflow init | Write the default workflow to .claude/stage-toons.json to edit | |
/stage workflow reload | Read .claude/stage-toons.json again and switch to it | |
| `/stage auto [on \ | off]` | Show the rules that move the stage by itself; turn them on or off |
| `/stage bubbles [on \ | off]` | Whether Clawd's pop-ups show: what Claude is doing over his head, and what he says |
| `/stage calm [on \ | off]` | Whether the cartoons hold still, changing only when something happens (off by default) |
| `/stage dj [on \ | off]` | Clawd DJ: while Spotify plays, Clawd at the decks and the track's name (Windows, macOS, Linux; off by default) |
| `/stage dj party [on \ | off]` | The DJ's full light show: speakers, floor, booth and box lighting up with the beat (off by default) |
| `/stage width [<n> \ | full]` | How wide the cartoons may grow under the bar: up to n columns (120 by default; 48 to 512), or the band's whole width |
/stage log [n] | The last n moves (15; up to 200) with how long each stage lasted | |
/stage time | Time per feature and per stage | |
| `/stage help [<topic> \ | commands]` | A guide to the mod: a line per feature; /stage help <topic> (bar, auto, scenes, popups, waiting, idle, done, calm, width, dj, history) explains one, with its commands and its setting now; /stage help commands lists every command (a /stage it doesn't understand answers one line, with the stage you may have meant) |
Claude is told about your moves.
Some steps of the workflow are plain to see from what Claude does, and the bar moves on them without waiting for set_stage. With the default workflow and the superpowers skills:
| Claude... | The bar moves to |
|---|---|
loads superpowers:brainstorming | Brainstorm |
writes a file under docs/superpowers/specs/ | Spec |
| starts a Fable subagent during Brainstorm or Spec | Spec audit |
loads superpowers:writing-plans, or writes under docs/superpowers/plans/ | Plan |
| starts a Fable subagent during Plan | Plan audit |
loads superpowers:executing-plans or superpowers:subagent-driven-development | Develop |
loads superpowers:finishing-a-development-branch | Cleanup |
pushes to main or master, or runs gh pr merge, during Your testing or Cleanup | Done |
loads superpowers:systematic-debugging | Debugging |
The bar only moves forward this way (or into Done, or a side stage): fixing the spec during its audit, or ticking the plan's boxes while developing, moves nothing. A push counts when Claude Code saw it go through: one that was up to date, a --dry-run, a rejected push or a deleted branch doesn't, and neither does a quiet one (git push -q), which shows nothing; a push of several branches at once counts only the first. gh pr merge matches a command at the start of a line or after ;, && or |, so a commit message that mentions one doesn't; gh pr merge --auto merges nothing yet and doesn't either. A push or merge the rules miss is a /stage done away; a default branch with another name, such as trunk, needs a push rule of its own (below). From Done only a skill you type moves the bar by itself (/superpowers:brainstorming starts the next feature): the chores after a merge, Claude's own, stay in Done. Only the main conversation counts, not subagents or teammates, and only files written with Write or Edit. Claude is told of each such move with the result of the call that made it (for a subagent, the call after it, as the Agent call answers before the subagent starts), and still calls set_stage for a better message or any other move. /stage auto off turns this off, for every project; a workflow file sets its own rules, and may start its project's sessions with them off (below).
Each move is kept with the feature, who made it (Claude, you, or auto and why) and how long Claude worked in it, for the last 200 moves. /stage log lists the recent ones with how long each stage lasted; /stage time totals the time per feature and per stage. Wall-clock time counts the hours nobody worked (a night in Your testing); Claude's working time is the time its turns ran. Done has no wall-clock time, as sitting there is no work on the feature: its line says done, with Claude's time after the merge. Both also show what the work cost, where it was counted: dollars as /cost totals them (compaction and other plugins' model calls included) and the tokens in and out of Claude's turns and its agents'. While the session's cost can't be read, the dollars wait and go to the stage current when it can again.
The area plays the stage's scene, and over Clawd's head it shows what Claude is doing right now:
auth.ts, npm test); it stays a moment after the tool ends, so quick calls in a row don't flicker;At a few moments he says a line in a speech bubble: All green! when tests pass, Red tests. when they fail, Committed!, Pushed!, All done! when Claude's last task is done, Oops. with a failed tool's flinch, Onward! when the stage moves forward, Hi, Fable! when Fable walks in. Every line is fixed: the mod asks no model for them.
While Claude keeps a task list, the bar counts it after the stage: 5/10 Plan · 3 of 8 tasks · stage-toons (with a ✓ when all are done; a finished list goes at the next turn's start). On a narrow bar the feature goes first, then the count.
The count comes from Claude Code's own task tools (TaskCreate, TaskUpdate, TodoWrite), which Claude Code provides by default only on older models (Opus up to 4.7, Sonnet up to 4.6, Haiku 4.5). On newer models, start Claude Code with CLAUDE_CODE_ENABLE_TODO_TOOLS=1 to see it; without the tools there is no count, and nothing else changes.
/stage bubbles off turns the pop-ups off (the icons, labels, puffs and bubbles) for every project; the scenes, the reactions and the tasks count stay. /stage demo work plays them all, 3 s a step, even while they are off.
The area below the bar is where Clawd lives: as wide as the band, up to 120 columns (/stage width changes it, up to 512 columns or the band's whole width), and 9 rows high (8 or 7 in a shorter band). Each scene keeps its picture at the area's left and runs its set on to the right edge (the road, the table, the river), or fills the room with a few quiet props of its own; the rows above the picture are headroom for Clawd's pop-ups. It plays the stage's scene while Claude works, and the demos. When Claude isn't working, a small Clawd stands at its left. He has five routines: the classic one (he blinks, glances at the prompt and the bar, taps a foot), Nyan Clawd (flying through a drifting starfield), coffee (he sips from a steaming mug), reading (he turns the pages of a book) and yo-yo. One is picked each time a turn starts. Whichever it is, after a minute he sits down and dozes off.
He marks a step finished, once: after the work moves forward on the track, the next idle period has him show off the step's prop (a map for the Roadmap, a light bulb for Brainstorm, a scroll for the Spec, a stamped page for its audit, a checklist for the Plan, a clipboard for its audit, a hammer for Develop, a test tube for Claude testing, a gift for Your testing, a broom for Cleanup; a page with a tick for a stage of your own). He lifts it, turns it over, then glances at you. In Done he celebrates under confetti, every idle period, until the work moves on. A move you make while Claude waits shows at once.
When Claude's turn ends while agents still work (background subagents, a Fable audit, teammates), Clawd walks aside with a cup of coffee and a row of small helpers gets to work beside him, one for each agent at work, in its model's colour: Sonnet magenta, Opus orange, Fable violet (with a monocle), Haiku teal, any other model grey. Four seats on a 48-column area, one more each 8 columns past it, eight at most; more agents wait for one to free. A helper hops up and walks off when its agent finishes, and when the last has gone Clawd walks back and his idle routine plays. He doesn't doze while agents work.
Each wait plays one of four scenes, dealt at random like a stage's scenes (each once before any again): the helpers type at laptops, build brick walls that rise behind them (a flag on top when one is done), row a long boat with their oars in time, or mark the boxes riding a conveyor belt past them, each box keeping a mark from every helper it passed. /stage demo waiting plays the next one with pretend agents; /stage demo waiting rowing (or laptops, building, conveyor) plays that one.
The area shows in a terminal at least 35 columns wide (30 for the band, beside Claude Code's own mark) with room for the bar and 7 rows (8 or 9 rows of band; a taller band gives it up to 9 rows), whether or not Claude works; otherwise the bar is text alone. A band you collapse shows no area.
Whatever the area shows next (Claude starting or stopping, a move to another stage, Fable walking in or out, the helpers arriving, the doze, a demo's next step), it dissolves into it over half a second, cell by cell. A scene starts from its beginning each time it comes on, and a scene that builds something up dissolves back to its start when it loops.
/stage demo plays all 41 scenes of the library in turn below the bar, each named with the stage that plays it, so you can see them without waiting for the work to reach each stage. /stage demo plan plays only the scenes of one stage, /stage demo idle the idle routines and /stage demo waiting the waiting toon, /stage demo work what Clawd shows while Claude works and /stage demo dj Clawd DJ. A new prompt ends the demo.
/stage calm on holds the cartoons still, for every project: each picture is one held frame, and the area changes only when something happens. Claude starting or stopping, a move to another stage, Fable coming or going, a helper arriving or leaving, the doze and the next idle period each dissolve into the next still, as in motion. While Claude works, Clawd holds a moment of the stage's scene; between turns he stands still in his routine's pose, sits down to doze after a minute, and shows off a finished step's prop or celebrates Done held still. While agents work, the helpers sit at their seats in the wait's scene, the work held part done.
The pop-ups stay, held still: what Claude is doing over Clawd's head changes when the work does, and his lines show as they come. A failure or a move forward shows only its line, without the hop. The bar and its task count are unchanged. The demos move anyway, and the stills come back when they end. /stage calm off brings the motion back. With it on, the area looks for a change twice a second, and sends the terminal a new frame only when something changed (and the same one every 2 s, to learn that the area still shows).

/stage dj on (in a terminal on Windows, macOS or Linux) has Clawd DJ while Spotify plays, for every project: behind a booth with two turntables, in headphones, bobbing to a 120 BPM beat (the media session gives no tempo, so it follows the song only when the song is near 120), speakers and a mirror ball over a dance floor. It plays on every stage, between turns and while agents work: their helpers come down to the dance floor and dance. Clawd's pop-ups show over his head as usual. From 72 columns a box by the mirror ball names the track, up to 60 characters (a longer title scrolls; calm mode holds it, cut with ~); a new track swaps the record and brightens the box once. When Spotify pauses or closes, the usual cartoons come back a read later. /stage dj says what it last heard; /stage dj off stops it.
The booth is gentle by default, for long coding sessions: the lights hold steady while Clawd, the records, the notes and the dancers move. /stage dj party on lights it up with the beat: the speakers thump, the floor tiles and the booth's LEDs blink, the mirror ball glints and the box's frame pulses. Nothing flashes faster than twice a second.
How it reads Spotify, with no Spotify account, app or token:
powershell.exe, part of Windows). Spotify tells Windows only a track's first artist, so a track by two shows the first.osascript (part of macOS). The first time it finds Spotify running, macOS asks whether your terminal (Ghostty, iTerm, Terminal…) may control Spotify; allow it and the music shows. Through tmux or another multiplexer, it is the terminal that started it that asks. With Spotify closed at /stage dj on, the question comes later, when Spotify first runs. If you said no, /stage dj says where to switch it on: System Settings › Privacy & Security › Automation, under your terminal's name; the music shows at the next read.playerctl (MPRIS), which your package manager has (once it is installed, type /stage dj on again); Spotify from Flatpak works too. Spotify tells it only a track's first artist, as on Windows. Under WSL, Spotify plays on the Windows side, out of reach: run Claude Code in a Windows terminal for it.A read takes about 0.1 to 0.3 s, every 5 s while the area shows and every 30 s while it doesn't or Clawd sleeps with nothing playing. The track stays in memory: it isn't saved, logged or sent to Claude. The box writes Latin letters (with accents, Polish ones too), Greek and Cyrillic; other characters (Japanese, Chinese, Korean, emoji) show as ?.
A project can define its own stages in .claude/stage-toons.json. /stage workflow init writes the default there to start from; edit it, then /stage workflow reload. Without the file, the default applies.
A workflow has track stages, in order, and side stages, which the work can enter from any point and leave again. Each stage has an id (what set_stage and /stage take), a label for the bar, a message for the status line, a when line telling Claude when to move there, and scenes:
{
"track": [
{ "id": "develop", "label": "Develop", "message": "Building it",
"when": "writing the code", "scenes": ["bricklaying", "terminal"] }
],
"side": [
{ "id": "debugging", "label": "Debugging", "message": "Hunting a bug",
"when": "finding and fixing a bug, at any point", "scenes": ["bug-hunt"] }
]
}
A stage may also list auto rules: what, done in the main conversation, moves the work into it (only forward on the track, into Done, or into a side stage). Each rule has one of skill (a skill's name; without a plugin: part it matches any plugin's skill of that name), write (a glob on the file's path from the project directory: ** any run, * within a folder, ? one character), agent (a model family, such as fable, that a subagent runs on), bash (a regular expression on a command) or push (a branch a push went through to), and may add from, the stages it fires from:
"auto": [
{ "skill": "superpowers:writing-plans" },
{ "write": "docs/superpowers/plans/**" },
{ "agent": "fable", "from": ["plan"] },
{ "bash": "git merge" },
{ "push": "trunk", "from": ["cleanup"] }
]
Up to 8 rules a stage. A bash rule may hold at most 4 repeats (*, +, ?, {n,m}) and may not repeat a group that itself repeats or chooses ((a+)*, (a|b)+), which can take too long to match. It reads each command of a line on its own (split at ;, &, | and line ends, so ^ and $ match at each command's ends), the first 60 characters of each, up to the line's first 500: a word past a command's 60th character, such as a late --auto, goes unread. A glob holds at most 8 *. The file's when lines are shown to Claude in the system prompt, as CLAUDE.md is, and its labels, messages and rules in the [stage-toons] notes Claude gets when the stage moves. A workflow file in which no stage has an auto field takes the default's rules for the stages it shares with the default by id; give any stage an auto list, even an empty one, and the file has only its own.
Beside track and side, the file may set how a session on the project starts: "auto": false with the rules off, "hide": true with the bar and the cartoons hidden, "bubbles": false with Clawd's pop-ups off (and true or false the other way), "calm": true with calm mode on (and false with it off), "width": 90 with the cartoons up to 90 columns wide (48 to 512, or "full"). Where the file sets one, it wins over your own /stage auto, /stage hide, /stage bubbles, /stage calm or /stage width setting, at each session's start and at /stage workflow reload; where it says nothing, yours applies. The commands still change the session at once, and say when the file starts it otherwise. /stage workflow init writes neither.
{ "auto": false, "hide": true, "track": [ ... ] }
Every workflow has Done past its track. The file may set its message, scenes and auto rules in a done object (each optional; the rules as a stage's, up to 8); without it, Done has the default message and scene and no rules, as the default's name user-test and cleanup, which your track may not have. The exception is a file in which no stage has an auto field: Done then takes the default's merge rules too, from whichever of user-test and cleanup the file has. /stage workflow init writes the default's. A stage's rule may name done in its from, but from Done only a skill you type moves the work:
"done": {
"message": "Shipped",
"auto": [{ "bash": "(?:^|[;&|\\n])\\s*npm\\s+publish\\b", "from": ["release"] }]
}
A stage lists 1 to 5 scenes, dealt like a shuffled deck: each time the work enters the stage it shows the next one, every scene once before any repeats, never the same twice in a row. A single "scene": "bug-hunt" works too. A workflow file keeps its own lists: scenes added to the mod later reach it only when you add them.
Scenes: road-trip, light-bulbs, writing-desk, proofread-page, stepping-stones, proof
hooks/register.tsx 1448 lines1// stage-toons: wires the workflow progress bar and the toons to Claude
2// Code's events. What it decides lives in the modules it imports.
3//
4// The engine reads this module's source before it loads it: $ may be
5// passed only to functions declared at the top of this file, and every
6// call on it is spelled $.noun.method(...) where it is made. So the helpers
7// below are top-level functions that take $ and the module's state.
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, ToolCallInput } from 'claude-code'
11
12import type { Progress, ScenePick, SessionWorkflow, StageId, TaskList, WidthSetting, Workflow, WorkflowSettings } from '../types'
13import { isAuditorSpawn, isFailureDue, staleAuditors } from './activity'
14import { ended, reconcileRoster, rememberKind, spawned } from './agents'
15import { autoMatch, hasRules, type Heard, isWindowsPath, relativePath, type Signal, typedSkill, viaOf } from './auto'
16import { areaSize, BAR_TONES, bandLayout, bandMode, type BandSite } from './band'
17import { autoText, bubblesText, CALM_DEMO_LINE, calmText, djBlockedText, djPartyText, djText, hideText, moveFor, parseCommand, settingsText, settingsWords, showText, unknownText, WORKFLOW_FILE, workflowSummary } from './command'
18import { demoAt, demoFor, demoLine, demoStep, djDemoLine, idleDemoAt, idleDemoLine, startDemo, waitingDemoLine, workDemoAt, workDemoLine } from './demo'
19import { createDirector, react, restarted, withAuditing, withScene } from './director'
20import { areaCells, type Log } from './frames'
21import { helpText } from './help'
22import { CELEBRATE, pickIdle } from './idle'
23import { idleForMove } from './keepsake'
24import { createMod, djPollState, endDemo, type Mod, queued, reseat, takeWaiting } from './mod'
25import { animate, type Ports } from './paint'
26import { dealCard } from './deck'
27import { addCost, type Cost, costText, creditAt, currentEntry, logText, NO_COST, timeText, tokensOf } from './history'
28import { COMMAND_READ_MS, heard, isNewTrack, nextDelay, NO_MUSIC, type NowPlaying, READ_TIMEOUT_MS, type ReadFailure, SLOW_POLL_MS, trackText } from './nowplaying'
29import { CHECK_TIMEOUT_MS, type Platform, platformOfUname, PLAYERCTL_VERSION_ARGV, problemOf, type Reader, readerFor, UNAME_ARGV } from './readers'
30import { momentOf, phaseOf, whatOf } from './phase'
31import { composed } from './pixels'
32import { type KV, loadProgress, PRUNE_EVERY_MS, saveProgress, staleKeys, storeKey, validate } from './progress'
33import { COMMAND, SECTION_ID, TOOL, TOOL_DESCRIPTION, toolSchema, workflowText } from './prompt'
34import type { SceneId } from './scene'
35import { STAGE_SCENES } from './scenes'
36import { applyMove, describe, freshProgress, isAdvance, isReopen, reconcile, stageLabel } from './stages'
37import { afterTaskCall, atTurnStart, countOf, isAllDone, sameCount, TASK_TOOLS } from './tasks'
38import { isDone } from './track'
39import { DEFAULT_WIDTH, isWidth, WIDTH_MAX, WIDTH_USAGE, widthText } from './width'
40import { createActivity, lineOf, said, toolEnded, toolStarted, withMode } from './work'
41import { DEFAULT_WORKFLOW, parseWorkflow, sameWorkflow, stageIds, stageList, stageOf, UNTRACKED_SCENE, withScenes, workflowJson } from './workflow'
42
43const HIDDEN_KEY = 'hidden'
44const AUTO_KEY = 'auto'
45const BUBBLES_KEY = 'bubbles'
46const CALM_KEY = 'calm'
47const DJ_KEY = 'dj'
48const DJ_PARTY_KEY = 'dj-party'
49const WIDTH_KEY = 'width'
50const PRUNED_KEY = 'pruned-at'
51// Set once the first session's welcome toast was given.
52const WELCOMED_KEY = 'welcomed'
53const WELCOME = "stage-toons: the bar above the prompt follows the work's stage; /stage shows it and its commands, /stage demo plays the cartoons."
54const UNSAVED = "stage-toons: couldn't save progress; it lasts for this session only"
55const NEWER = "stage-toons: a newer version saved this project's progress; this session won't save over it"
56const BAD_FILE = 'stage-toons: .claude/stage-toons.json has errors; using the default workflow. /stage workflow reload lists them.'
57// The engine reads each atom's plugin and key from the source, so they are
58// written out as literals.
59const progressAtom = atom({ plugin: 'stage-toons', key: 'progress' } as const, null)
60const auditingAtom = atom({ plugin: 'stage-toons', key: 'auditing' } as const, false)
61const hiddenAtom = atom({ plugin: 'stage-toons', key: 'hidden' } as const, false)
62const sectionAtom = atom({ plugin: 'stage-toons', key: 'workflowText' } as const, null)
63// The session's workflow: kept across a hot reload, so the bar, the tool and
64// the cached prompt section keep to the same one until /stage workflow reload.
65const workflowAtom = atom({ plugin: 'stage-toons', key: 'workflow' } as const, null)
66// Whether the store warning was given: kept across a reload, so it shows once a session.
67const warnedAtom = atom({ plugin: 'stage-toons', key: 'warned' } as const, false)
68// The load of this module that runs now. A hot reload cancels only the
69// waits pending as it lands, so a paint loop of the old load can live on,
70// painting its own state into the band's Raster: it stops once this names
71// another load.
72const loadAtom = atom({ plugin: 'stage-toons', key: 'loadId' } as const, null)
73const LOAD_ID = Math.random().toString(36).slice(2, 10)
74// The idle routine for the next idle period: picked when a turn starts, so
75// the band's render (which may not write $.state) only reads it; kept across
76// a hot reload.
77const idleAtom = atom({ plugin: 'stage-toons', key: 'idle' } as const, null)
78// The scene the stage shows: kept across a hot reload, so the scene does
79// not change under the person when the module reloads.
80const sceneAtom = atom({ plugin: 'stage-toons', key: 'scene' } as const, null)
81// Whether rules move the stage by itself, for this session: the project's
82// file's setting or the store's at the start, then /stage auto's; kept
83// across a hot reload.
84const autoAtom = atom({ plugin: 'stage-toons', key: 'auto' } as const, true)
85// Each agent's kind, as its spawn told it: kept across reloads, which
86// start the roster empty, so the list's agents keep their colours.
87const kindsAtom = atom({ plugin: 'stage-toons', key: 'agentKinds' } as const, {})
88// The session's cost already credited to the history (null: not known yet):
89// kept across reloads, so dollars are neither lost nor counted twice.
90const usdAtom = atom({ plugin: 'stage-toons', key: 'usdCredited' } as const, null)
91// The subagent tokens waiting for the main turn's end: kept across reloads.
92const turnTokensAtom = atom({ plugin: 'stage-toons', key: 'turnTokens' } as const, NO_COST)
93// Claude's task list, as the bar counts it: kept across reloads.
94const tasksAtom = atom({ plugin: 'stage-toons', key: 'tasks' } as const, {})
95// Whether Clawd's pop-ups show, for this session, as autoAtom is.
96const bubblesAtom = atom({ plugin: 'stage-toons', key: 'bubbles' } as const, true)
97// Whether the cartoons hold still, for this session, as autoAtom is: off
98// unless turned on.
99const calmAtom = atom({ plugin: 'stage-toons', key: 'calm' } as const, false)
100// Whether DJ mode is on (/stage dj): kept across a reload, as calm is.
101const djAtom = atom({ plugin: 'stage-toons', key: 'dj' } as const, false)
102// Whether the DJ picture has its full light show (/stage dj party).
103const djPartyAtom = atom({ plugin: 'stage-toons', key: 'djParty' } as const, false)
104// How wide the cartoons grow, for this session, as autoAtom is.
105const widthAtom = atom({ plugin: 'stage-toons', key: 'width' } as const, DEFAULT_WIDTH as WidthSetting)
106
107// A queued step of the mod's own (an automatic move, a turn's credit) that
108// must not fail the call or event it rides on: a failure goes to the debug log.
109function settle($: EngineInterface, what: string, done: Promise<unknown>): Promise<void> {
110 return done.then(
111 () => undefined,
112 error => $.ui.log(`stage-toons: ${what} failed: ${String(error)}`, { to: 'debug' }),
113 )
114}
115
116// The progress with Claude's working time so far credited to the entry it
117// was spent in: what every move starts from. The count restarts only when
118// the move goes ahead (move).
119function credited(mod: Mod, p: Progress, now: number): Progress {
120 if (mod.workSince === null) return p
121
122 return creditAt(p, mod.workEntry, now - mod.workSince)
123}
124
125// The working time of the turn under way, not yet credited.
126const runningMs = (mod: Mod, now: number): number => (mod.workSince === null ? 0 : Math.max(0, now - mod.workSince))
127
128type ToolArgs = { agentId?: string } & Record<string, unknown>
129
130// A tool call's arguments, read off the tool.call event itself: the engine
131// spreads them onto it, beside tool, tool_use_id and agentId. The event's
132// type is a union of the known tools' inputs, which names no MCP tool's
133// fields, so it is read as a plain record here, once.
134const argsOf = (e: ToolCallInput): ToolArgs => e as unknown as ToolArgs
135
136// The store as progress.ts takes it.
137const storeOf = ($: EngineInterface): KV => ({
138 get: key => $.store.get(key),
139 set: (key, value) => $.store.set(key, value),
140})
141
142// The first session's toast: once the store has saved that it was given,
143// so a store that can't be read or written shows nothing.
144async function welcome($: EngineInterface) {
145 try {
146 if ((await $.store.get(WELCOMED_KEY)) !== undefined) return
147 await $.store.set(WELCOMED_KEY, true)
148 } catch {
149 return
150 }
151 $.ui.toast(WELCOME)
152}
153
154async function warnUnsaved($: EngineInterface) {
155 if (await read($, warnedAtom)) return
156 await update($, warnedAtom, () => true)
157 $.ui.toast(UNSAVED)
158}
159
160async function current($: EngineInterface, now: number): Promise<Progress> {
161 return (await read($, progressAtom)) ?? freshProgress(now)
162}
163
164// The progress every change starts from: this session's, or the store's
165// when another session on the project saved since (adopted, fitted to this
166// session's workflow), so a change never writes over another session's
167// move. raw: the store's copy as saved, unfitted, when it is this
168// session's last save or newer: what a save that is no move of this
169// session's (the turn's credit) builds on, so it never rewrites another
170// session's stage to fit this session's workflow. Runs on the move queue.
171async function latestProgress($: EngineInterface, mod: Mod, now: number): Promise<{ progress: Progress; raw: Progress }> {
172 const mine = await current($, now)
173 let value: unknown
174 try {
175 value = await $.store.get(mod.key)
176 } catch {
177 // The store is unreadable: keep what this session has.
178 return { progress: mine, raw: mine }
179 }
180 const head = typeof value === 'object' && value !== null ? (value as { v?: unknown; updatedAt?: unknown }) : {}
181 // A newer version's progress: this session reads none of it and saves
182 // nothing over it.
183 if (typeof head.v === 'number' && head.v > 3) {
184 if (!mod.isReadOnly) $.ui.toast(NEWER)
185 mod.isReadOnly = true
186
187 return { progress: mine, raw: mine }
188 }
189 // Older than this session's: no need to check it whole.
190 if (typeof head.updatedAt === 'number' && head.updatedAt < mine.updatedAt) return { progress: mine, raw: mine }
191 const stored = validate(value)
192 if (stored === null || stored.updatedAt < mine.updatedAt) return { progress: mine, raw: mine }
193 if (stored.updatedAt === mine.updatedAt) return { progress: mine, raw: stored }
194 const fitted = reconcile(mod.workflow, stored).progress
195 await update($, progressAtom, () => fitted)
196 mod.director = withScene(mod.director, await sceneFor($, mod, fitted.stage), now)
197
198 return { progress: fitted, raw: stored }
199}
200
201// The scene for the stage the work is in: the one already picked while the
202// stage stays and still lists it, else the next card of the stage's deck
203// (hooks/deck.ts: every scene plays once before any repeats, never the one
204// shown last). Untracked shows the Other scene and clears the held pick's
205// stage, so coming back to the same stage deals the next card; the deck
206// stays. Never called while a ui.render hook draws: it writes $.state.
207async function sceneFor($: EngineInterface, mod: Mod, stage: StageId | null): Promise<SceneId> {
208 const held = await read($, sceneAtom)
209 if (stage === null) {
210 if (held && held.stage !== null) await update($, sceneAtom, () => ({ ...held, stage: null }))
211
212 return UNTRACKED_SCENE
213 }
214 const scenes = stageOf(mod.workflow, stage)?.scenes ?? [UNTRACKED_SCENE]
215 if (held && held.stage === stage && scenes.includes(held.scene)) return held.scene
216 const { scene, deck } = dealCard(scenes, held?.decks?.[stage], held?.lastByStage[stage], Math.random)
217 const next: ScenePick = { stage, scene, lastByStage: { ...held?.lastByStage, [stage]: scene }, decks: { ...held?.decks, [stage]: deck } }
218 await update($, sceneAtom, () => next)
219
220 return scene
221}
222
223// The idle routine a move calls for (keepsake.ts), as the next idle
224// period's; while Claude is idle (no turn under way: /stage typed while he
225// waits), shown at once, the idle clock restarted, and a deep-asleep area
226// woken to show it.
227async function idleAfterMove($: EngineInterface, mod: Mod, before: Progress, after: Progress, now: number) {
228 const routine = idleForMove(mod.workflow, before, after, mod.idle, Math.random())
229 if (routine === null) return
230 mod.nextIdle = routine
231 await update($, idleAtom, () => routine)
232 if (mod.workSince !== null || mod.isBandWorking) return
233 mod.idle = routine
234 mod.idleSince = now
235 if (mod.band.isAsleep) {
236 mod.band.isAsleep = false
237 $.ui.invalidate('ui.render')
238 }
239}
240
241async function commit($: EngineInterface, mod: Mod, before: Progress, after: Progress, now: number) {
242 await update($, progressAtom, () => after)
243 // Claude's working time goes to the entry the move left the work in.
244 mod.workEntry = currentEntry(after)
245 mod.director = withScene(mod.director, await sceneFor($, mod, after.stage), now)
246 // Confetti for a step forward, and for the feature finished.
247 if (isAdvance(mod.workflow, before, after) || (isDone(after.stage) && !isDone(before.stage))) mod.director = react(mod.director, 'advance', now)
248 await idleAfterMove($, mod, before, after, now)
249 if (!mod.isReadOnly && !(await saveProgress(storeOf($), mod.key, after))) await warnUnsaved($)
250}
251
252// Reads the store's latest copy, credits the time so far, applies what
253// build makes of it, and commits; null from build moves nothing, and
254// leaves the working time counting as it was.
255async function move<P extends Progress | null>($: EngineInterface, mod: Mod, at: number, build: (from: Progress) => P): Promise<P> {
256 const { progress } = await latestProgress($, mod, at)
257 const before = credited(mod, progress, at)
258 const after = build(before)
259 if (after === null) return after
260 if (mod.workSince !== null) mod.workSince = at
261 await commit($, mod, before, after, at)
262
263 return after
264}
265
266async function setStage($: EngineInterface, mod: Mod, args: ToolArgs) {
267 if (args.agentId !== undefined) return { deny: 'stage-toons: only the main conversation moves the workflow stage.' }
268 const { stage, message, feature } = args
269 if (typeof stage !== 'string' || !stageIds(mod.workflow).includes(stage) || typeof message !== 'string' || message.trim() === '') {
270 return { deny: `stage-toons: set_stage needs "stage" (one of ${stageIds(mod.workflow).join(', ')}) and a non-empty "message".` }
271 }
272 const now = await $.clock.now()
273 const after = await move($, mod, now, from => applyMove(mod.workflow, from, { stage, message, feature: typeof feature === 'string' ? feature : undefined, by: 'claude', at: now }))
274 // Claude's own move supersedes what it has not heard of yet.
275 mod.notes = []
276
277 // Core takes a registered tool's result as a string, an array or nothing.
278 return { result: `Stage ${describe(mod.workflow, after)}` }
279}
280
281// The kind of rule each tool's calls can match.
282const SIGNAL_KINDS: Record<string, Signal['kind']> = { Skill: 'skill', Bash: 'bash', Write: 'write', Edit: 'write' }
283
284// What a successful tool call of the main conversation did, as rules read
285// it: a skill loaded, a file written (relative to the session's directory
286// at the call), a command run (with the branch a push in it updated, as
287// core's gitOperation has it); or nothing a rule reads.
288async function signalOf($: EngineInterface, mod: Mod, args: ToolArgs, result: unknown): Promise<Signal | null> {
289 if (args.tool === 'Skill' && typeof args.skill === 'string') return { kind: 'skill', name: args.skill }
290 if (args.tool === 'Bash' && typeof args.command === 'string') {
291 const pushed = (result as { gitOperation?: { push?: { branch?: unknown } } } | null)?.gitOperation?.push?.branch
292
293 return { kind: 'bash', command: args.command, ...(typeof pushed === 'string' ? { pushed } : {}) }
294 }
295 if ((args.tool === 'Write' || args.tool === 'Edit') && typeof args.file_path === 'string') {
296 let cwd = ''
297 try {
298 cwd = await $.session.cwd()
299 mod.isWindows = isWindowsPath(cwd)
300 } catch {
301 // No directory: the path stays whole and a relative glob misses it.
302 }
303
304 return { kind: 'write', path: relativePath(args.file_path, cwd) }
305 }
306
307 return null
308}
309
310// Moves the work where a rule sends it, if one does, and queues a note for
311// Claude. Runs on the move queue.
312async function autoMove($: EngineInterface, mod: Mod, signal: Signal) {
313 if (!mod.isAuto) return
314 const now = await $.clock.now()
315 // Most calls move nothing: only a match on this session's copy reads the store.
316 if (autoMatch(mod.workflow, await current($, now), signal, mod.isWindows) === null) return
317 // Matched again on the store's copy, which another session may have moved.
318 let target: StageId = ''
319 let via = ''
320 let isNewRound = false
321 const after = await move($, mod, now, from => {
322 const match = autoMatch(mod.workflow, from, signal, mod.isWindows)
323 if (match === null) return null
324 target = match.stage
325 via = viaOf(signal, match.rule)
326 isNewRound = isDone(from.stage) && !isReopen(from, match.stage)
327
328 return applyMove(mod.workflow, from, { stage: target, by: 'auto', via, at: now })
329 })
330 if (after === null) return
331 // Out of Done, a new round: the bar has no feature until Claude names one.
332 const newRound = isNewRound ? ' A new feature starts: pass its name as feature.' : ''
333 mod.notes.push(
334 `[stage-toons] The stage moved by itself to ${stageLabel(mod.workflow, target)} (${via}). Now: ${describe(mod.workflow, after)}. Call ${mod.toolName} if a more specific message or another stage fits.${newRound}`,
335 )
336}
337
338// Where a command the person typed comes from: a terminal, a phone (the
339// bridge) or their own SDK host. A plugin's command is not the person's.
340const PERSON_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
341
342// A skill the person typed: command.run hears the command, skill.prompt the
343// skill expanded, in either order. Whichever comes second makes the match,
344// and the move goes on the queue as the Skill tool's call does.
345async function hearSkill($: EngineInterface, mod: Mod, side: 'command' | 'skill', name: string) {
346 const heard: Heard = { name, at: await $.clock.now() }
347 const skill = side === 'command' ? typedSkill(heard, mod.expandedSkill) : typedSkill(mod.typedCommand, heard)
348 if (skill === null) {
349 if (side === 'command') mod.typedCommand = heard
350 else mod.expandedSkill = heard
351
352 return
353 }
354 mod.typedCommand = null
355 mod.expandedSkill = null
356 if (!mod.isAuto || !hasRules(mod.workflow, 'skill')) return
357 const signal: Signal = { kind: 'skill', name: skill, isTyped: true }
358 await settle($, 'an automatic move', queued(mod, () => autoMove($, mod, signal)))
359}
360
361// The session's cost so far, as /cost totals it; null when the engine
362// answers none (no ledger) or the call fails.
363async function sessionUsd($: EngineInterface): Promise<number | null> {
364 try {
365 const usd = (await $.session.usage()).cost?.usd
366
367 return typeof usd === 'number' && Number.isFinite(usd) && usd >= 0 ? usd : null
368 } catch {
369 return null
370 }
371}
372
373// The dollars spent since the last main turn's end, the baseline moved to
374// the session's cost whatever becomes of them. A cost below the baseline
375// (the ledger restarted) or a first figure only starts it: nothing spent.
376// A session with no figure leaves it as it was, and spent nothing.
377async function dollarsSpent($: EngineInterface): Promise<number> {
378 const usd = await sessionUsd($)
379 if (usd === null) return 0
380 const baseline = await read($, usdAtom)
381 await update($, usdAtom, () => usd)
382
383 return baseline !== null && usd >= baseline ? usd - baseline : 0
384}
385
386// The main turn's end: the rest of its working time, and what its turns
387// cost (tokens: the main turn's and its subagents'; dollars since the last
388// main turn's end), credited and saved, so another session on the project
389// picks it up. A turn whose start was missed credits its duration, at most
390// the time since the entry began.
391async function creditTurn($: EngineInterface, mod: Mod, durationMs: number, tokens: Cost) {
392 const now = await $.clock.now()
393 const since = mod.workSince
394 const counted = mod.workEntry
395 mod.workSince = null
396 mod.workEntry = null
397 // Moved at every main turn's end, credited or not (untracked, no entry),
398 // so dollars never pile onto a later entry.
399 const cost: Cost = { ...tokens, usd: await dollarsSpent($) }
400 if ((await read($, progressAtom)) === null) return
401 const { progress, raw } = await latestProgress($, mod, now)
402 const entry = since !== null ? counted : currentEntry(progress)
403 if (entry === null) return
404 // After a reload mid-turn, from the entry's start or the last save,
405 // whichever is later: a move during the turn credited its time so far.
406 const ms = since !== null ? now - since : Math.min(Math.max(0, durationMs), now - Math.max(entry.at, raw.updatedAt))
407 const after = creditAt(raw, entry, ms, cost)
408 if (after === raw) {
409 // The entry is gone from the copy (an older version's session cut the history).
410 const lost = [ms > 0 ? `${Math.round(ms / 1000)} s of working time` : '', costText(cost)].filter(part => part !== '').join(' and ')
411 if (lost !== '') $.ui.log(`stage-toons: ${lost} had no history entry to go to`, { to: 'debug' })
412
413 return
414 }
415 // Saved as the store had it, credit added; shown fitted to this workflow.
416 const saved = { ...after, updatedAt: now }
417 const shown = reconcile(mod.workflow, saved).progress
418 await update($, progressAtom, () => shown)
419 if (!mod.isReadOnly && !(await saveProgress(storeOf($), mod.key, saved))) await warnUnsaved($)
420}
421
422// Deletes the progress of projects nobody has moved for months, at most
423// once a week, so the store (4 MiB for all of it) never fills.
424async function pruneStore($: EngineInterface, mod: Mod, now: number) {
425 try {
426 const last = await $.store.get(PRUNED_KEY)
427 if (typeof last === 'number' && now - last < PRUNE_EVERY_MS) return
428 await $.store.set(PRUNED_KEY, now)
429 const keys = (await $.store.keys()).filter(key => key.startsWith('progress:') && key !== mod.key)
430 const entries: [string, unknown][] = []
431 for (const key of keys) entries.push([key, await $.store.get(key)])
432 for (const key of staleKeys(entries, now, mod.key)) await $.store.delete(key)
433 } catch (error) {
434 $.ui.log(`stage-toons: pruning old progress failed: ${String(error)}`, { to: 'debug' })
435 }
436}
437
438// A setting the person changed, kept in the store for every project; a
439// store that fails keeps it for this session ($.state, which the engine
440// reads by atom where each setter writes it). The paint loop reads it at
441// its next frame.
442async function keep($: EngineInterface, key: string, value: unknown) {
443 try {
444 await $.store.set(key, value)
445 } catch {
446 // Lasts for this session.
447 }
448}
449
450async function setAuto($: EngineInterface, mod: Mod, isOn: boolean) {
451 await update($, autoAtom, () => (mod.isAuto = isOn))
452 await keep($, AUTO_KEY, isOn)
453}
454
455async function setBubbles($: EngineInterface, mod: Mod, isOn: boolean) {
456 await update($, bubblesAtom, () => (mod.isBubbles = isOn))
457 await keep($, BUBBLES_KEY, isOn)
458}
459
460// The band is drawn again, which wakes a loop the deep sleep stopped.
461async function setCalm($: EngineInterface, mod: Mod, isOn: boolean) {
462 await update($, calmAtom, () => (mod.isCalm = isOn))
463 await keep($, CALM_KEY, isOn)
464 $.ui.invalidate('ui.render')
465}
466
467async function setDjParty($: EngineInterface, mod: Mod, isOn: boolean) {
468 await update($, djPartyAtom, () => (mod.isDjParty = isOn))
469 await keep($, DJ_PARTY_KEY, isOn)
470 $.ui.invalidate('ui.render')
471}
472
473// The band redraws at its new size.
474async function setWidth($: EngineInterface, mod: Mod, width: WidthSetting) {
475 await update($, widthAtom, () => (mod.width = width))
476 await keep($, WIDTH_KEY, width)
477 $.ui.invalidate('ui.render')
478}
479
480async function setHidden($: EngineInterface, mod: Mod, isHidden: boolean) {
481 await update($, hiddenAtom, () => (mod.isHidden = isHidden))
482 await keep($, HIDDEN_KEY, isHidden)
483}
484
485// The platform check, run where the cwd isn't Windows: uname -sr, and on
486// Linux playerctl --version. isKept: false for an answer the next check
487// may change (uname couldn't run in time; playerctl isn't installed yet).
488async function checkPlatform($: EngineInterface): Promise<{ platform: Platform; isKept: boolean }> {
489 let platform: Platform
490 try {
491 platform = platformOfUname(await $.process.run([...UNAME_ARGV], { timeoutMs: CHECK_TIMEOUT_MS }))
492 } catch {
493 return { platform: 'unknown', isKept: false }
494 }
495 if (platform !== 'linux') return { platform, isKept: true }
496 try {
497 const { exitCode } = await $.process.run([...PLAYERCTL_VERSION_ARGV], { timeoutMs: CHECK_TIMEOUT_MS })
498 if (exitCode === 0) return { platform: 'linux', isKept: true }
499 } catch {
500 // Not installed.
501 }
502
503 return { platform: 'linux-missing', isKept: false }
504}
505
506// The platform DJ mode reads on (Clawd DJ everywhere): none off the
507// terminal ($.process is CLI only), Windows from the cwd, elsewhere the
508// check's answer, kept for the load; a check in flight is shared, so a
509// poll and a /stage dj typed together run one.
510async function platformOf($: EngineInterface, mod: Mod): Promise<Platform> {
511 if (mod.surface !== 'terminal') return 'none'
512 if (mod.isWindows) return 'windows'
513 if (mod.platform === null) {
514 mod.platform = checkPlatform($).then(check => {
515 mod.platform = check.isKept ? check.platform : null
516
517 return check.platform
518 })
519 }
520
521 return await mod.platform
522}
523
524// One read of what Spotify plays, with the platform's reader: the track,
525// or null for nothing playing. A failed read counts as nothing playing;
526// each kind is logged once per load, with its exit code but never its
527// output or stderr (they may name what other apps play). It leaves what
528// macOS's permission says (mod.djProblem). timeoutMs: how long it may take
529// (the poll's 10 s; a command's own read less, inside its hook's budget).
530async function readMusic($: EngineInterface, mod: Mod, reader: Reader, timeoutMs = READ_TIMEOUT_MS): Promise<NowPlaying | null> {
531 let track: NowPlaying | null = null
532 let failure: ReadFailure | null = null
533 let detail = ''
534 try {
535 const result = await $.process.run([...reader.argv], { timeoutMs })
536 // DJ mode turned off while it read: what it heard is nobody's.
537 if (!mod.isDj) return null
538 const outcome = reader.outcomeOf(result)
539 // Composed (NFC), as drawText draws it and the box measures it.
540 track = outcome.track && { ...outcome.track, artist: composed(outcome.track.artist), title: composed(outcome.track.title) }
541 failure = outcome.failure
542 if (failure === 'exit') detail = ` ${result.exitCode}`
543 } catch {
544 if (!mod.isDj) return null
545 failure = 'rejected'
546 }
547 mod.djProblem = problemOf(reader, failure, mod.hasDjRead)
548 // Only a read that reached Spotify shows macOS's prompt was answered:
549 // {} comes from running() alone, before any Apple Event.
550 if (track !== null) mod.hasDjRead = true
551 if (failure !== null && !mod.djFailures.has(failure)) {
552 mod.djFailures.add(failure)
553 $.ui.log(`stage-toons: reading Spotify failed (${failure}${detail}); DJ mode takes it as nothing playing`, { to: 'debug' })
554 }
555
556 return track
557}
558
559// A read taken in: what the mod has heard, the moment a new track came on,
560// and the band drawn again when the picture or the track changes.
561async function hearMusic($: EngineInterface, mod: Mod, track: NowPlaying | null) {
562 const before = mod.music
563 const after = heard(before, track)
564 mod.music = after
565 if (isNewTrack(before, after)) mod.djChangedAt = await $.clock.now()
566 const trackOf = (m: typeof after) => (m.track === null ? null : trackText(m.track))
567 if (before.isPlaying !== after.isPlaying || trackOf(before) !== trackOf(after)) $.ui.invalidate('ui.render')
568}
569
570// The poll (Clawd DJ), detached: reads Spotify at nextDelay's pace while
571// DJ mode stays on and this load is the current one; at once first unless
572// the caller has just read. One loop at a time: turning DJ mode off ends
573// it at its next look, and a loop started after that has its own token.
574// It asks the platform first, and ends at once where DJ mode can't read
575// (a check that isn't kept is asked again every SLOW_POLL_MS).
576function startPoll($: EngineInterface, mod: Mod, hasRead: boolean) {
577 if (mod.djLoop !== null) return
578 const token = Math.random().toString(36).slice(2, 10)
579 mod.djLoop = token
580 void (async () => {
581 let reader = readerFor(await platformOf($, mod))
582 // A check that isn't kept (uname too slow, playerctl not yet
583 // installed) is asked again at the slow pace while DJ mode stays on.
584 while (reader === null && mod.platform === null && mod.isDj && mod.djLoop === token) {
585 await $.clock.sleep(SLOW_POLL_MS)
586 const id = await read($, loadAtom)
587 if (id !== null && id !== LOAD_ID) return
588 reader = readerFor(await platformOf($, mod))
589 }
590 let isFirst = !hasRead
591 while (reader !== null && mod.isDj && mod.djLoop === token) {
592 if (!isFirst) {
593 // The area shown again wakes it (the band's hook); the sleep runs
594 // out unheeded.
595 mod.djSleepMs = nextDelay(djPollState(mod))
596 let wake: (() => void) | null = null
597 await Promise.race([$.clock.sleep(mod.djSleepMs), new Promise<void>(resolve => (mod.djWake = wake = resolve))])
598 // A newer loop's (dj off, then on) is its own.
599 if (mod.djWake === wake) mod.djWake = null
600 }
601 isFirst = false
602 if (!mod.isDj || mod.djLoop !== token) break
603 const id = await read($, loadAtom)
604 if (id !== null && id !== LOAD_ID) break
605 const track = await readMusic($, mod, reader)
606 // DJ mode turned off (or on again, with a new loop) while it read.
607 if (!mod.isDj || mod.djLoop !== token) break
608 await hearMusic($, mod, track)
609 }
610 })()
611 .catch(() => {
612 // The environment unloaded mid-read (a reload, the session's end).
613 })
614 .finally(() => {
615 if (mod.djLoop === token) mod.djLoop = null
616 })
617}
618
619// /stage dj on or off, kept for every project; its answer. Turning it on
620// gives a DJ picture that failed another try, and reads Spotify at once
621// (within COMMAND_READ_MS, so the answer comes inside the hook's budget),
622// so the answer names the track, then polls. Where DJ mode can't read (the
623// platform), it answers why and stays off.
624async function setDj($: EngineInterface, mod: Mod, isOn: boolean): Promise<string> {
625 let reader: Reader | null = null
626 if (isOn) {
627 const platform = await platformOf($, mod)
628 reader = readerFor(platform)
629 if (reader === null) return djBlockedText(platform)
630 }
631 mod.isDj = isOn
632 if (isOn && mod.isDjBroken) {
633 mod.isDjBroken = false
634 $.ui.invalidate('ui.render')
635 }
636 await update($, djAtom, () => isOn)
637 try {
638 await $.store.set(DJ_KEY, isOn)
639 } catch {
640 // Lasts for this session.
641 }
642 if (!isOn) {
643 mod.djLoop = null
644 // The poll's sleep ends now: it sees DJ mode off and ends.
645 mod.djWake?.()
646 mod.music = NO_MUSIC
647 mod.djChangedAt = null
648 mod.djProblem = null
649 $.ui.invalidate('ui.render')
650 } else if (mod.djLoop === null && reader !== null) {
651 await hearMusic($, mod, await readMusic($, mod, reader, COMMAND_READ_MS))
652 startPoll($, mod, true)
653 }
654
655 return djText(mod.isDj, mod.music, mod.isDjBroken, mod.djProblem)
656}
657
658// /stage dj's answer with no on or off: off; where DJ mode can't read (a
659// setting kept from another machine), why; else what it last heard.
660async function djStatus($: EngineInterface, mod: Mod): Promise<string> {
661 if (mod.isDj) {
662 const platform = await platformOf($, mod)
663 if (readerFor(platform) === null) return djBlockedText(platform)
664 }
665
666 return djText(mod.isDj, mod.music, mod.isDjBroken, mod.djProblem)
667}
668
669// The columns the band has for the area, null before a band was drawn.
670const roomOf = (mod: Mod): number | null => (mod.lastBand === null ? null : Math.min(mod.lastBand.columns - 1, WIDTH_MAX))
671
672type Setting = 'auto' | 'bubbles' | 'calm' | 'width' | 'hide' | 'dj' | 'dj-party'
673
674// What a setting's command answers: the setting now, and what the
675// project's workflow file starts it with, if it says.
676async function settingText($: EngineInterface, mod: Mod, setting: Setting): Promise<string> {
677 const settings = (await read($, workflowAtom))?.settings
678 if (setting === 'auto') return autoText(mod.workflow, mod.isAuto, settings?.auto)
679 if (setting === 'bubbles') return bubblesText(mod.isBubbles, settings?.bubbles)
680 if (setting === 'calm') return calmText(mod.isCalm, settings?.calm)
681 if (setting === 'width') return widthText(mod.width, roomOf(mod), settings?.width)
682 if (setting === 'hide') return hideText(mod.isHidden, settings?.hide)
683 if (setting === 'dj') return djStatus($, mod)
684
685 return djPartyText(mod.isDjParty)
686}
687
688// What a help topic's page ends with: the answer its feature's own
689// commands give now, for a feature with a setting.
690async function helpNow($: EngineInterface, mod: Mod, topic: string | null, p: Progress, now: number): Promise<string | undefined> {
691 if (topic === 'bar') return `${showText(mod.workflow, p, now)}\n${await settingText($, mod, 'hide')}`
692 if (topic === 'popups') return settingText($, mod, 'bubbles')
693 if (topic === 'dj') return `${await settingText($, mod, 'dj')}\n${await settingText($, mod, 'dj-party')}`
694 if (topic === 'auto' || topic === 'calm' || topic === 'width') return settingText($, mod, topic)
695
696 return undefined
697}
698
699async function setAuditing($: EngineInterface, mod: Mod, isAuditing: boolean) {
700 await update($, auditingAtom, () => isAuditing)
701 mod.director = withAuditing(mod.director, isAuditing, await $.clock.now())
702}
703
704// Where the frames' failures go (a scene or a routine that threw): the
705// debug log. The band's hook hands it to areaCells, as the paint loop's
706// cells do.
707function debugLog($: EngineInterface): Log {
708 return text => $.ui.log(text, { to: 'debug' })
709}
710
711// The engine as the paint loop reaches it: closures over $, built here,
712// since no other file may take $.
713function portsOf($: EngineInterface, mod: Mod): Ports {
714 const log = debugLog($)
715
716 return {
717 now: () => $.clock.now(),
718 sleep: ms => $.clock.sleep(ms),
719 blit: frame => $.ui.blit(frame).catch(() => ({ deny: 'failed' })),
720 invalidate: () => $.ui.invalidate('ui.render'),
721 pollAgents: () => checkAgents($, mod),
722 isCurrent: async () => {
723 const id = await read($, loadAtom)
724
725 return id === null || id === LOAD_ID
726 },
727 cells: (site, now) => areaCells(mod, { columns: site.columns, rows: site.rows }, now, log),
728 }
729}
730
731// The roster as the agent list has it now. A list that fails leaves the
732// roster as it was: the next check tries again.
733async function checkAgents($: EngineInterface, mod: Mod) {
734 try {
735 const list = await $.agent.list()
736 const now = await $.clock.now()
737 mod.roster = reconcileRoster(mod.roster, list, now, await read($, kindsAtom))
738 reseat(mod, now)
739 } catch {
740 // Checked again at the next poll or turn's end.
741 }
742}
743
744// A workflow file is a few KB; anything far bigger is not one.
745const WORKFLOW_FILE_MAX = 64 * 1024
746
747type FileWorkflow = { kind: 'none' } | { kind: 'ok'; workflow: Workflow; settings?: WorkflowSettings } | { kind: 'bad'; errors: string[] }
748
749// The project's workflow file: absent, a workflow, or what is wrong with it.
750async function readWorkflowFile($: EngineInterface): Promise<FileWorkflow> {
751 try {
752 if (!(await $.fs.exists(WORKFLOW_FILE))) return { kind: 'none' }
753 const text = await $.fs.read(WORKFLOW_FILE)
754 if (typeof text === 'string' && text.length > WORKFLOW_FILE_MAX) return { kind: 'bad', errors: [`${text.length} characters; a workflow file holds at most ${WORKFLOW_FILE_MAX}`] }
755 const parsed = parseWorkflow(typeof text === 'string' ? text : '', STAGE_SCENES)
756
757 return 'workflow' in parsed ? { kind: 'ok', workflow: parsed.workflow, ...(parsed.settings ? { settings: parsed.settings } : {}) } : { kind: 'bad', errors: parsed.errors }
758 } catch (error) {
759 return { kind: 'bad', errors: [`could not read it: ${String(error)}`] }
760 }
761}
762
763const sessionWorkflowOf = (file: FileWorkflow): SessionWorkflow =>
764 file.kind === 'ok' ? { workflow: file.workflow, source: 'file', ...(file.settings ? { settings: file.settings } : {}) } : { workflow: DEFAULT_WORKFLOW, source: 'default' }
765
766// The project's starting state, at a fresh session start and at /stage
767// workflow reload: the file's auto, hide, bubbles, calm and width where it sets them, else the
768// person's own (the store's), else as the session has it. Nothing is
769// saved: the store keeps the person's own settings.
770async function applySettings($: EngineInterface, mod: Mod, settings: WorkflowSettings | undefined) {
771 const wasHidden = await read($, hiddenAtom)
772 const wasCalm = await read($, calmAtom)
773 const wasWidth = await read($, widthAtom)
774 mod.isAuto = await startValue($, settings?.auto, AUTO_KEY, kept => kept !== false, await read($, autoAtom))
775 mod.isHidden = await startValue($, settings?.hide, HIDDEN_KEY, kept => kept === true, wasHidden)
776 mod.isBubbles = await startValue($, settings?.bubbles, BUBBLES_KEY, kept => kept !== false, await read($, bubblesAtom))
777 // Off unless the store says on (bubbles is the other way round).
778 mod.isCalm = await startValue($, settings?.calm, CALM_KEY, kept => kept === true, wasCalm)
779 mod.width = await startValue($, settings?.width, WIDTH_KEY, kept => (isWidth(kept) ? kept : DEFAULT_WIDTH), wasWidth)
780 await update($, autoAtom, () => mod.isAuto)
781 await update($, hiddenAtom, () => mod.isHidden)
782 await update($, bubblesAtom, () => mod.isBubbles)
783 await update($, calmAtom, () => mod.isCalm)
784 await update($, widthAtom, () => mod.width)
785 if (mod.isHidden !== wasHidden || mod.width !== wasWidth || mod.isCalm !== wasCalm) $.ui.invalidate('ui.render')
786}
787
788// A setting at the start: the file's where it sets one, else the store's
789// (what kept makes of it), else the session's.
790async function startValue<T>($: EngineInterface, fromFile: T | undefined, key: string, kept: (stored: unknown) => T, session: T): Promise<T> {
791 if (fromFile !== undefined) return fromFile
792 try {
793 return kept(await $.store.get(key))
794 } catch {
795 return session
796 }
797}
798
799// Registers set_stage for the workflow; false when the engine refused.
800async function registerTool($: EngineInterface, mod: Mod): Promise<boolean> {
801 try {
802 mod.toolName = (await $.tool.register({ name: TOOL, description: TOOL_DESCRIPTION, inputSchema: toolSchema(mod.workflow) })).tool
803
804 return true
805 } catch (error) {
806 $.ui.log(`stage-toons: could not register set_stage: ${String(error)}`, { to: 'debug' })
807
808 return false
809 }
810}
811
812async function initWorkflow($: EngineInterface, mod: Mod): Promise<string> {
813 try {
814 if (await $.fs.exists(WORKFLOW_FILE)) return `${WORKFLOW_FILE} exists; edit it, then /stage workflow reload`
815 } catch (error) {
816 return `couldn't check for ${WORKFLOW_FILE}: ${String(error)}`
817 }
818 try {
819 await $.fs.write(WORKFLOW_FILE, workflowJson(DEFAULT_WORKFLOW))
820 } catch (error) {
821 return `couldn't write ${WORKFLOW_FILE}: ${String(error)}`
822 }
823 // The file holds the stages the session already has: it is now their source.
824 if (mod.workflowSource === 'default') {
825 const next: SessionWorkflow = { workflow: mod.workflow, source: 'file' }
826 mod.workflowSource = 'file'
827 await update($, workflowAtom, () => next)
828 }
829
830 return `Wrote the default workflow to ${WORKFLOW_FILE}: edit it, then /stage workflow reload.`
831}
832
833async function reloadWorkflow($: EngineInterface, mod: Mod, now: number): Promise<{ text: string; context?: string[] }> {
834 const file = await readWorkflowFile($)
835 if (file.kind === 'bad') return { text: [`${WORKFLOW_FILE} has errors; kept the current workflow:`, ...file.errors.map(e => ` ${e}`)].join('\n') }
836 const next = sessionWorkflowOf(file)
837 // The file's settings apply whether or not its stages changed.
838 await applySettings($, mod, next.settings)
839 const starts = settingsText(next.settings)
840 const withStarts = (text: string) => (starts ? `${text}\n${starts}` : text)
841 if (sameWorkflow(next.workflow, mod.workflow)) {
842 const wasSource = mod.workflowSource
843 // The settings ride in the session's workflow: kept for a hot reload.
844 mod.workflowSource = next.source
845 await update($, workflowAtom, () => next)
846 const words = settingsWords(next.settings)
847 const applied = words === null ? '.' : `; applied its settings: ${words}.`
848 if (next.source === wasSource) return { text: `workflow unchanged${applied}` }
849
850 // The same stages from another source: nothing for Claude or the cache.
851 return { text: `workflow unchanged (now ${next.source === 'file' ? `from ${WORKFLOW_FILE}` : 'the default'})${applied}` }
852 }
853 mod.workflow = next.workflow
854 mod.workflowSource = next.source
855 await update($, workflowAtom, () => next)
856 // On the move queue, so it reads what the last move left.
857 const { progress, moved } = await queued(mod, async () => {
858 // Read when its turn on the queue comes: a move queued before it may have run.
859 const at = await $.clock.now()
860 const { progress: base } = await latestProgress($, mod, at)
861 // No credit here: the turn's time stays counting for its entry (found
862 // again by its key), and the turn's end saves it.
863 const fitted = reconcile(mod.workflow, base)
864 await update($, progressAtom, () => fitted.progress)
865
866 return fitted
867 })
868 mod.director = withScene(mod.director, await sceneFor($, mod, progress.stage), now)
869 const isRegistered = await registerTool($, mod)
870 await update($, sectionAtom, () => workflowText(mod.workflow, progress, mod.toolName))
871 // The band's lines name stages: draw it again.
872 $.ui.invalidate('ui.render')
873 const from = next.source === 'file' ? `from ${WORKFLOW_FILE}` : `the default (no ${WORKFLOW_FILE})`
874 const lines = [`workflow ${from}: ${mod.workflow.track.length} track stages, ${mod.workflow.side.length} side stages.`]
875 if (moved) lines.push(moved)
876 if (starts) lines.push(starts)
877 if (!isRegistered) lines.push("set_stage could not be registered again (the debug log says why): Claude's tool may still list the old stages.")
878
879 return { text: lines.join('\n'), context: [`[stage-toons] The workflow changed: ${stageList(mod.workflow)}. Now: ${describe(mod.workflow, progress)}`] }
880}
881
882// A call of the main conversation's that ended: the line it says (a test
883// run, a commit, a push), and the task list it moved, the bar redrawn when
884// the count changed and "All done!" said when the last task is done. The
885// list is read and written in one update: a batch's task calls end
886// together, and each must build on the one before.
887async function heardCall($: EngineInterface, mod: Mod, args: ToolArgs, output: unknown, isError: boolean, now: number) {
888 const tool = String(args.tool)
889 const moment = momentOf(tool, args, isError)
890 if (moment !== null) mod.activity = said(mod.activity, lineOf(moment, now), now)
891 if (isError || !TASK_TOOLS.has(tool)) return
892 let before: TaskList = {}
893 let after: TaskList = {}
894 await update($, tasksAtom, list => {
895 before = list
896 after = afterTaskCall(list, tool, args, output)
897
898 return after
899 })
900 if (sameCount(countOf(before), countOf(after))) return
901 $.ui.invalidate('ui.render')
902 if (isAllDone(after) && !isAllDone(before)) mod.activity = said(mod.activity, lineOf('all-done', now), now)
903}
904
905export const register: Register = on => {
906 const mod = createMod()
907
908 on('session.start', async ($, e, next) => {
909 const result = await next(e)
910 await update($, loadAtom, () => LOAD_ID)
911 const now = await $.clock.now()
912 mod.key = storeKey(e.cwd)
913 let held = await read($, workflowAtom)
914 // No session workflow yet: a fresh session, not a hot reload.
915 const isFresh = held === null
916 if (held === null) {
917 const file = await readWorkflowFile($)
918 held = sessionWorkflowOf(file)
919 const first = held
920 await update($, workflowAtom, () => first)
921 if (file.kind === 'bad') $.ui.toast(BAD_FILE)
922 }
923 mod.workflow = withScenes(held.workflow)
924 // A default kept by an older version takes this version's (its rules)
925 // while the stages are the same.
926 if (held.source === 'default' && !sameWorkflow(mod.workflow, DEFAULT_WORKFLOW) && stageIds(mod.workflow).join() === stageIds(DEFAULT_WORKFLOW).join()) {
927 mod.workflow = DEFAULT_WORKFLOW
928 const upgraded: SessionWorkflow = { workflow: DEFAULT_WORKFLOW, source: 'default' }
929 await update($, workflowAtom, () => upgraded)
930 }
931 mod.workflowSource = held.source
932 mod.isWindows = isWindowsPath(e.cwd)
933 const loaded = await loadProgress(storeOf($), mod.key, now)
934 if (!loaded.isAvailable) await warnUnsaved($)
935 const progress = reconcile(mod.workflow, loaded.progress).progress
936 // A fresh session starts as the project's file and the person's own
937 // settings say; a hot reload keeps the session's own (a /stage auto off
938 // typed earlier stands).
939 if (isFresh) await applySettings($, mod, held.settings)
940 else {
941 mod.isAuto = await read($, autoAtom)
942 mod.isHidden = await read($, hiddenAtom)
943 mod.isBubbles = await read($, bubblesAtom)
944 mod.isCalm = await read($, calmAtom)
945 mod.width = await read($, widthAtom)
946 }
947 mod.surface = e.surface
948 // DJ mode is the person's own, never the workflow file's: from the
949 // store on a fresh session, from the session on a reload. On, it reads
950 // Spotify at once (the poll ends at once where it can't read).
951 if (isFresh) {
952 let isDj = false
953 let isParty = false
954 try {
955 isDj = (await $.store.get(DJ_KEY)) === true
956 isParty = (await $.store.get(DJ_PARTY_KEY)) === true
957 } catch {
958 // Off for this session.
959 }
960 await update($, djAtom, () => isDj)
961 await update($, djPartyAtom, () => isParty)
962 }
963 mod.isDj = await read($, djAtom)
964 mod.isDjParty = await read($, djPartyAtom)
965 if (mod.isDj && mod.surface === 'terminal') startPoll($, mod, false)
966 mod.idleSince = now
967 let routine = await read($, idleAtom)
968 if (routine === null) {
969 // A session that starts in Done celebrates; a show-off is not replayed.
970 const first = isDone(progress.stage) ? CELEBRATE : pickIdle(undefined, Math.random())
971 await update($, idleAtom, () => first)
972 routine = first
973 }
974 mod.idle = routine
975 mod.nextIdle = routine
976 // A reload forgets which agents were Fable, so the auditor stands down.
977 mod.auditors.clear()
978 await update($, progressAtom, () => progress)
979 await update($, auditingAtom, () => false)
980 mod.director = createDirector(await sceneFor($, mod, progress.stage), false)
981 await registerTool($, mod)
982 try {
983 mod.commandName = (
984 await $.command.register({
985 name: COMMAND,
986 description:
987 "Show or move the workflow stage; /stage hide hides the bar and the cartoons; /stage demo plays every scene, a stage's scenes, the idle routines, the waiting toon, what Clawd shows while Claude works or Clawd DJ; /stage workflow shows or reloads the workflow; /stage auto, bubbles, calm, dj, width, log and time: the automatic moves, Clawd's pop-ups, whether the cartoons hold still, Clawd DJ while Spotify plays, how wide they grow, the history, the time spent",
988 argumentHint: '[n | id | next | back | feature <name> | message <text> | hide | demo [<stage> | idle | work | dj] [seconds] | demo waiting [scene] | demo stop | workflow [init | reload] | auto [on | off] | bubbles [on | off] | calm [on | off] | dj [on | off | party [on | off]] | width [<n> | full] | log [n] | time]',
989 immediate: true,
990 })
991 ).command
992 } catch (error) {
993 $.ui.log(`stage-toons: could not register /stage: ${String(error)}`, { to: 'debug' })
994 }
995 // Computed once per session and kept across reloads: the section sits
996 // in the cached part of the prompt.
997 if ((await read($, sectionAtom)) === null) await update($, sectionAtom, () => workflowText(mod.workflow, progress, mod.toolName))
998 // Dollars count from the session's cost now: what it cost before (a
999 // resumed session's) is not this history's to claim. A reload keeps the
1000 // baseline it finds, unless the session's cost is below it (the ledger
1001 // restarted).
1002 const usdNow = await sessionUsd($)
1003 const usdBaseline = await read($, usdAtom)
1004 if (usdNow !== null && (usdBaseline === null || usdNow < usdBaseline)) await update($, usdAtom, () => usdNow)
1005 // Agents at work before a reload: the roster starts empty, and the list
1006 // finds them, in the colours their spawns left (grey for one unseen).
1007 await checkAgents($, mod)
1008 // Last, so the tool and the command are there first.
1009 if (loaded.isAvailable) await pruneStore($, mod, now)
1010 await welcome($)
1011
1012 return result
1013 })
1014
1015 on('prompt.submit', async ($, e, next) => {
1016 // A turn starts: the stage's scene takes the area over from the demo.
1017 if (mod.demo) endDemo(mod, () => $.ui.invalidate('ui.render'))
1018 // Another session on this project may have moved the stage. On the move
1019 // queue, so a move still running is not overwritten.
1020 // Fitted to this session's workflow in memory; saved back only with this
1021 // session's next move or turn's credit.
1022 const now = await $.clock.now()
1023 await settle($, "adopting another session's progress", queued(mod, () => latestProgress($, mod, now)))
1024 // The routine for the idle period after this turn, leaving out the one
1025 // just shown; in Done (another session's move adopted first), the
1026 // celebration. A prompt typed while a turn runs (turnId set) starts no
1027 // idle period of its own, so it picks nothing. The box keeps the routine
1028 // on show until the idle clock restarts: switched here, it could paint
1029 // the new one for a frame before the turn hides it.
1030 if (e.turnId === undefined) {
1031 const routine = isDone((await read($, progressAtom))?.stage ?? null) ? CELEBRATE : pickIdle(mod.idle, Math.random())
1032 mod.nextIdle = routine
1033 await update($, idleAtom, () => routine)
1034 }
1035 if (mod.notes.length === 0) return next(e)
1036 const notes = mod.notes
1037 mod.notes = []
1038
1039 return next({ ...e, context: [...(e.context ?? []), ...notes] })
1040 })
1041
1042 // Claude's working time counts from the main turn's start (only the main
1043 // loop raises turn.start).
1044 on('turn.start', async ($, e, next) => {
1045 const now = await $.clock.now()
1046 mod.workSince = now
1047 mod.workEntry = currentEntry(await current($, now))
1048 // A new turn: no tools running, no line said; the spinner's mode stays.
1049 mod.activity = { ...createActivity(), mode: mod.activity.mode }
1050 // A task list all done showed through the turn it finished in.
1051 const tasks = await read($, tasksAtom)
1052 if (atTurnStart(tasks) !== tasks) {
1053 await update($, tasksAtom, atTurnStart)
1054 $.ui.invalidate('ui.render')
1055 }
1056
1057 return next(e)
1058 })
1059
1060 on('tool.call', async ($, e, next) => {
1061 if (e.tool === mod.toolName) return queued(mod, () => setStage($, mod, argsOf(e)))
1062 // The model's own calls in the main conversation show their phase's
1063 // badge while they run.
1064 const isModelCall = e.agentId === undefined && next.origin.plugin === 'engine'
1065 const phase = isModelCall ? phaseOf(e.tool, argsOf(e), mod.toolName) : null
1066 if (phase !== null) mod.activity = toolStarted(mod.activity, e.tool_use_id, phase, whatOf(e.tool, argsOf(e)))
1067 let result: Awaited<ReturnType<typeof next>>
1068 try {
1069 result = await next(e)
1070 } finally {
1071 // The clock fails only as the plugin unloads: the call's own outcome stands.
1072 let end: number | null = null
1073 if (phase !== null) {
1074 try {
1075 end = await $.clock.now()
1076 } catch {
1077 end = null
1078 }
1079 }
1080 if (end !== null) mod.activity = toolEnded(mod.activity, e.tool_use_id, end)
1081 }
1082 if (e.agentId !== undefined || result.deny !== undefined) return result
1083 if (result.isError === true) {
1084 const now = await $.clock.now()
1085 if (isFailureDue(mod.lastFailureAt, now)) {
1086 mod.lastFailureAt = now
1087 mod.director = react(mod.director, 'failure', now)
1088 }
1089 }
1090 // Only the model's own calls (the engine's dispatch) move the stage and
1091 // carry notes: a plugin's $.tool.call result reaches no model.
1092 if (next.origin.plugin !== 'engine') return result
1093 // Never at the cost of the call's result: a failure goes to the debug log.
1094 await settle($, 'hearing a tool call', heardCall($, mod, argsOf(e), result.result, result.isError === true, await $.clock.now()))
1095 const kind = SIGNAL_KINDS[e.tool]
1096 if (result.isError !== true && kind !== undefined && mod.isAuto && hasRules(mod.workflow, kind)) {
1097 const signal = await signalOf($, mod, argsOf(e), result.result)
1098 if (signal) await settle($, 'an automatic move', queued(mod, () => autoMove($, mod, signal)))
1099 }
1100 if (mod.notes.length === 0) return result
1101 // Claude hears of the moves it has not heard of with this result.
1102 const notes = mod.notes
1103 mod.notes = []
1104
1105 return { ...result, context: [...(result.context ?? []), ...notes] }
1106 })
1107
1108 on('command.run', async ($, e, next) => {
1109 if (e.command !== mod.commandName) {
1110 // Heard before it runs: the skill it expands may be heard while it does.
1111 if (PERSON_ORIGINS.includes(e.origin.kind)) await hearSkill($, mod, 'command', e.command)
1112
1113 return next(e)
1114 }
1115 const cmd = parseCommand(mod.workflow, e.args)
1116 const now = await $.clock.now()
1117 // Read on the move queue: a move still running (an automatic one just
1118 // made) shows.
1119 const before = (await queued(mod, () => latestProgress($, mod, now))).progress
1120 if (cmd.kind === 'show') return { text: showText(mod.workflow, before, now) }
1121 if (cmd.kind === 'log') return { text: logText(mod.workflow, before, now, cmd.count, runningMs(mod, now)) }
1122 if (cmd.kind === 'time') return { text: timeText(mod.workflow, before, now, runningMs(mod, now)) }
1123 if (cmd.kind === 'auto') {
1124 if (cmd.isOn !== null) await setAuto($, mod, cmd.isOn)
1125
1126 return { text: await settingText($, mod, 'auto') }
1127 }
1128 if (cmd.kind === 'bubbles') {
1129 if (cmd.isOn !== null) await setBubbles($, mod, cmd.isOn)
1130
1131 return { text: await settingText($, mod, 'bubbles') }
1132 }
1133 if (cmd.kind === 'calm') {
1134 if (cmd.isOn !== null) await setCalm($, mod, cmd.isOn)
1135
1136 return { text: await settingText($, mod, 'calm') }
1137 }
1138 if (cmd.kind === 'dj-party') {
1139 if (cmd.isOn !== null) await setDjParty($, mod, cmd.isOn)
1140
1141 return { text: await settingText($, mod, 'dj-party') }
1142 }
1143 if (cmd.kind === 'dj') return { text: cmd.isOn !== null ? await setDj($, mod, cmd.isOn) : await settingText($, mod, 'dj') }
1144 if (cmd.kind === 'width') {
1145 if (cmd.width !== null) await setWidth($, mod, cmd.width)
1146
1147 return { text: await settingText($, mod, 'width') }
1148 }
1149 if (cmd.kind === 'width-usage') return { text: WIDTH_USAGE }
1150 if (cmd.kind === 'help') return { text: helpText(mod.workflow, cmd.topic, await helpNow($, mod, cmd.topic, before, now)) }
1151 if (cmd.kind === 'usage') return { text: unknownText(mod.workflow, e.args) }
1152 if (cmd.kind === 'demo' || cmd.kind === 'demo-idle' || cmd.kind === 'demo-waiting' || cmd.kind === 'demo-work' || cmd.kind === 'demo-dj') {
1153 // The work demo plays over the stage's own scene; the waiting demo the
1154 // scene asked for, or the next one dealt.
1155 const deal = mod.waiting.deal
1156 const waiting = cmd.kind === 'demo-waiting' ? (cmd.scene ?? takeWaiting(mod, now)) : undefined
1157 // The waiting demo's walks are worked out at the width the area will
1158 // have: the band's last size under the width setting now (a /stage
1159 // width just before it has not been drawn yet).
1160 const columns = mod.lastBand === null ? mod.band.columns : areaSize(mod.lastBand, mod.width, null).columns
1161 const { demo, label } = demoFor(cmd, mod.workflow, now, mod.director.scene, waiting, columns)
1162 // The bar's lines with the demo's line under it, at the band's width.
1163 const barLines = bandLayout(mod.workflow, before, false, mod.lastBand?.columns ?? 100, 'demo', countOf(await read($, tasksAtom))).length
1164 const text = startDemo(mod, demo, label, now, await read($, hiddenAtom), barLines)
1165 // A demo refused takes no scene from the deck.
1166 if (mod.demo !== demo) mod.waiting = { ...mod.waiting, deal }
1167 // The band grows to hold it.
1168 if (mod.demo) $.ui.invalidate('ui.render')
1169 // Asked for, the work demo shows the pop-ups even while they are off,
1170 // and every demo moves while calm mode is on.
1171 const notes = mod.demo !== demo ? [] : [...(demo.kind === 'work' && !mod.isBubbles ? ["Clawd's pop-ups are off: the demo shows them; /stage bubbles on turns them on."] : []), ...(mod.isCalm ? [CALM_DEMO_LINE] : [])]
1172
1173 return { text: [text, ...notes].join('\n') }
1174 }
1175 if (cmd.kind === 'demo-stop') {
1176 if (!mod.demo) return { text: 'No demo is playing.' }
1177 endDemo(mod, () => $.ui.invalidate('ui.render'))
1178
1179 return { text: 'Demo stopped.' }
1180 }
1181 if (cmd.kind === 'workflow') return { text: workflowSummary(mod.workflow, mod.workflowSource, (await read($, workflowAtom))?.settings) }
1182 if (cmd.kind === 'workflow-init') return { text: await initWorkflow($, mod) }
1183 if (cmd.kind === 'workflow-reload') return reloadWorkflow($, mod, now)
1184 if (cmd.kind === 'hide') {
1185 await setHidden($, mod, !(await read($, hiddenAtom)))
1186
1187 return { text: await settingText($, mod, 'hide') }
1188 }
1189 // Untracked, a message would be shown nowhere and lost at the first stage.
1190 if (cmd.kind === 'message' && before.stage === null) return { text: 'a message belongs to a stage; set one first (/stage next, or /stage <id>).' }
1191
1192 return queued(mod, async () => {
1193 // Read when its turn on the queue comes: a move queued before it may have run.
1194 const at = await $.clock.now()
1195 const after = await move($, mod, at, from => moveFor(mod.workflow, from, cmd, at))
1196
1197 return { text: `Stage ${describe(mod.workflow, after)}`, context: [`[stage-toons] The user set the stage: ${describe(mod.workflow, after)}`] }
1198 })
1199 })
1200hooks/activity.ts 26 lines1// What counts as the auditor coming into play, when a tracked auditor has
2// gone, and how often a failure may play.
3
4export const FAILURE_GAP_MS = 3000
5
6export const isFableModel = (model: string | undefined): boolean => model !== undefined && /fable/i.test(model)
7
8// A spawn that resolved to Fable is the auditor, unless it only inherited
9// Fable from a parent that runs on Fable itself (an Explore agent or a fork
10// of a Fable session); naming Fable explicitly always counts.
11export const isAuditorSpawn = (spawn: { model?: string; parentModel: string }, resolved: string): boolean =>
12 isFableModel(resolved) && (!isFableModel(spawn.parentModel) || isFableModel(spawn.model))
13
14// An agent's statuses once its loop has ended; pending, running, waiting
15// (held) and idle (between turns) are all still at work.
16const ENDED = new Set(['completed', 'failed', 'killed'])
17
18// Tracked auditors the agent list shows as ended, or no longer shows.
19export function staleAuditors(tracked: Iterable<string>, agents: readonly { id: string; status: string }[]): string[] {
20 const live = new Set(agents.filter(a => !ENDED.has(a.status)).map(a => a.id))
21
22 return [...tracked].filter(id => !live.has(id))
23}
24
25export const isFailureDue = (lastAt: number | null, now: number): boolean => lastAt === null || now - lastAt >= FAILURE_GAP_MS
26hooks/agents.ts 97 lines1// The roster: the agents the mod knows of, the model each runs on, and
2// whether it is at work. Fed by agent.spawn (which knows the model), by an
3// agent's own turn ending, and by $.agent.list() (which knows the status).
4
5import type { ModelKind } from '../types'
6import { isFableModel } from './activity'
7
8export type { ModelKind }
9// spawnedAt: when agent.spawn reported it (null for one the list brought);
10// endedAt: when its own turn last ended (null while it works).
11export type RosterAgent = { id: string; kind: ModelKind; isAtWork: boolean; spawnedAt: number | null; endedAt: number | null }
12export type Roster = ReadonlyMap<string, RosterAgent>
13// An agent as $.agent.list() gives it, as far as the roster reads it.
14export type ListedAgent = { id: string; status: string }
15
16export const EMPTY_ROSTER: Roster = new Map()
17// How far the list may lag behind the events: for this long after a spawn
18// an agent the list does not show yet is kept, and after an agent's own
19// turn ended the list's word that it still runs is not taken as new work.
20export const LIST_LAG_MS = 3000
21
22// pending, running and waiting (held) are at work; idle (a teammate
23// between turns) is not, nor are completed, failed and killed.
24const AT_WORK = new Set(['pending', 'running', 'waiting'])
25const IDLE = 'idle'
26
27// Fable first, then Opus, Sonnet and Haiku: the first match wins.
28export function modelKind(model: string | undefined): ModelKind {
29 if (model === undefined) return 'unknown'
30 if (isFableModel(model)) return 'fable'
31 const lower = model.toLowerCase()
32 for (const kind of ['opus', 'sonnet', 'haiku'] as const) if (lower.includes(kind)) return kind
33
34 return 'unknown'
35}
36
37// A spawned agent, at work; one already known keeps its kind when the
38// spawn names no model the roster recognises.
39export function spawned(roster: Roster, id: string, model: string | undefined, now: number): Roster {
40 const kind = modelKind(model)
41 const known = roster.get(id)
42
43 return new Map(roster).set(id, { id, kind: kind === 'unknown' && known ? known.kind : kind, isAtWork: true, spawnedAt: now, endedAt: null })
44}
45
46// How many agents' kinds the session's state keeps, the newest.
47export const KINDS_KEPT = 32
48
49// The kinds by agent id, with this one's added last; an unknown kind adds
50// nothing (the same object comes back, so nothing need be written).
51export function rememberKind(kinds: Readonly<Record<string, ModelKind>>, id: string, kind: ModelKind): Readonly<Record<string, ModelKind>> {
52 if (kind === 'unknown') return kinds
53 const { [id]: _, ...rest } = kinds
54
55 return Object.fromEntries(Object.entries({ ...rest, [id]: kind }).slice(-KINDS_KEPT))
56}
57
58// The agent's own turn ended: it finished, or (a teammate) went idle.
59export function ended(roster: Roster, id: string, now: number): Roster {
60 const known = roster.get(id)
61 if (!known) return roster
62
63 return new Map(roster).set(id, { ...known, isAtWork: false, endedAt: now })
64}
65
66const isFresh = (at: number | null, now: number) => at !== null && now - at < LIST_LAG_MS
67
68// The roster as the list has it: at work by its status; an idle teammate
69// kept, not at work, so it keeps its kind; ended and unlisted agents
70// dropped, but for one spawned too lately for the list to show it; an
71// agent at work or an idle teammate the roster lacks (from before a
72// reload) joined with the kind its spawn left (kinds), else as unknown, so
73// the list is polled while it lasts. Roster order first, then the list's.
74export function reconcileRoster(roster: Roster, list: readonly ListedAgent[], now: number, kinds: Readonly<Record<string, ModelKind>> = {}): Roster {
75 const listed = new Map(list.map(a => [a.id, a.status]))
76 const next = new Map<string, RosterAgent>()
77 for (const agent of roster.values()) {
78 const status = listed.get(agent.id)
79 if (status === undefined) {
80 if (agent.isAtWork && isFresh(agent.spawnedAt, now)) next.set(agent.id, agent)
81 continue
82 }
83 if (!AT_WORK.has(status) && status !== IDLE) continue
84 const isAtWork = AT_WORK.has(status) && !isFresh(agent.endedAt, now)
85 next.set(agent.id, { ...agent, isAtWork, endedAt: isAtWork ? null : agent.endedAt })
86 }
87 for (const { id, status } of list) {
88 if (next.has(id) || roster.has(id) || (!AT_WORK.has(status) && status !== IDLE)) continue
89 next.set(id, { id, kind: kinds[id] ?? 'unknown', isAtWork: AT_WORK.has(status), spawnedAt: null, endedAt: null })
90 }
91
92 return next
93}
94
95// The agents at work, in the order they joined.
96export const atWork = (roster: Roster): RosterAgent[] => [...roster.values()].filter(a => a.isAtWork)
97hooks/auto.ts 341 lines1// Automatic moves: a workflow's rules (a stage's "auto" list), what the
2// main conversation did that they match, and the stage a match moves the
3// work to. Rules only ever move the work forward on the track, or into a
4// side stage.
5
6import type { AutoRule, Progress, StageId, Workflow } from '../types'
7import { allStages, isDone, rank, standing } from './track'
8
9export type { AutoRule }
10
11// What the main conversation did: a skill loaded (isTyped: the person
12// typed it), a file written (its path relative to the project, /
13// separators), a subagent spawned (the model it runs on, the one the call
14// asked for, the parent's), a command run (pushed: the branch core's
15// gitOperation says a push in it updated, only when one went through).
16export type Signal =
17 | { kind: 'skill'; name: string; isTyped?: boolean }
18 | { kind: 'write'; path: string }
19 | { kind: 'agent'; model: string; asked: string | undefined; parentModel: string }
20 | { kind: 'bash'; command: string; pushed?: string }
21
22export const KINDS = ['skill', 'write', 'agent', 'bash', 'push'] as const
23type Kind = (typeof KINDS)[number]
24const FIELDS = [...KINDS, 'from'] as const
25export const RULES_MAX = 8
26// How much of a line a bash rule reads, how much of each command in it,
27// how many repeats a rule may hold, and how many * a glob: a rule from a
28// project's file runs on every call, so none may take long. A pattern's
29// time on a near miss grows with the text's length to the power of its
30// repeats (.*.*.*x).
31export const COMMAND_MAX = 500
32export const SEGMENT_MAX = 60
33export const REPEATS_MAX = 4
34export const GLOB_STARS_MAX = 8
35// The longest reason for a move the history keeps.
36export const VIA_MAX = 60
37export const FROM_MAX = 20
38const LIMITS: Record<Kind, number> = { skill: 60, agent: 60, write: 120, bash: 120, push: 60 }
39
40const kindOf = (rule: AutoRule): Kind => KINDS.find(k => k in rule)!
41const valueOf = (rule: AutoRule): string => (rule as Record<Kind, string>)[kindOf(rule)]
42
43// A text's length in code points, as the limits count it.
44export const chars = (text: string): number => Array.from(text).length
45
46// Control characters, a terminal's escapes among them: a file's text
47// reaches the bar, the answers and the system prompt, so none may hold one.
48export const hasControl = (text: string): boolean => /[\u0000-\u001f\u007f-\u009f]/.test(text)
49
50// A file's value quoted in an error: its escapes written out, cut to 40
51// characters.
52export const quoted = (text: string): string => JSON.stringify(chars(text) > 40 ? `${Array.from(text).slice(0, 39).join('')}…` : text)
53// A key of the file in an error: bare when it is a plain word, else quoted.
54export const keyName = (key: string): string => (/^[\w-]{1,40}$/.test(key) ? key : quoted(key))
55
56// One rule: exactly one kind, and perhaps from. The ids in from are checked
57// against the workflow once all its stages are read (unknownFrom).
58function ruleAt(item: unknown, path: string, errors: string[]): AutoRule | null {
59 if (typeof item !== 'object' || item === null || Array.isArray(item)) {
60 errors.push(`${path}: not a rule (an object with one of ${KINDS.join(', ')}, and perhaps from)`)
61
62 return null
63 }
64 const raw = item as Record<string, unknown>
65 const before = errors.length
66 for (const key of Object.keys(raw)) if (!(FIELDS as readonly string[]).includes(key)) errors.push(`${path}.${keyName(key)}: not a field (${FIELDS.join(', ')})`)
67 const kinds = KINDS.filter(k => raw[k] !== undefined)
68 if (kinds.length !== 1) {
69 errors.push(`${path}: ${kinds.length === 0 ? 'needs one of' : 'has more than one of'} ${KINDS.join(', ')}`)
70
71 return null
72 }
73 const kind = kinds[0]!
74 const value = raw[kind]
75 let text = ''
76 if (typeof value !== 'string') errors.push(`${path}.${kind}: must be text`)
77 else if (value.trim() === '') errors.push(`${path}.${kind}: empty`)
78 else if (chars(value.trim()) > LIMITS[kind]) errors.push(`${path}.${kind}: ${chars(value.trim())} characters, at most ${LIMITS[kind]}`)
79 else if (hasControl(value)) errors.push(`${path}.${kind}: control characters`)
80 else text = kind === 'write' ? value.trim().replace(/^\.\//, '') : value.trim()
81 if (text !== '' && kind === 'bash') {
82 try {
83 new RegExp(text)
84 const problem = patternProblem(text)
85 if (problem) errors.push(`${path}.bash: ${problem}; simplify it`)
86 } catch (error) {
87 errors.push(`${path}.bash: not a regular expression: ${error instanceof Error ? error.message : String(error)}`)
88 }
89 }
90 if (text !== '' && kind === 'write' && (text.match(/\*/g)?.length ?? 0) > GLOB_STARS_MAX) errors.push(`${path}.write: at most ${GLOB_STARS_MAX} *`)
91 if (text !== '' && kind === 'agent' && /\s/.test(text)) errors.push(`${path}.agent: one word, a model family such as fable`)
92 if (text !== '' && kind === 'push' && /\s/.test(text)) errors.push(`${path}.push: one word, a branch such as main`)
93 let from: string[] | undefined
94 if (raw.from !== undefined) {
95 if (!Array.isArray(raw.from) || !raw.from.every(id => typeof id === 'string')) errors.push(`${path}.from: must be a list of stage ids`)
96 else if (raw.from.length === 0 || raw.from.length > FROM_MAX) errors.push(`${path}.from: ${raw.from.length} stage ids; 1 to ${FROM_MAX}`)
97 else from = (raw.from as string[]).map(id => id.trim())
98 }
99 if (errors.length !== before) return null
100
101 return { [kind]: text, ...(from ? { from } : {}) } as AutoRule
102}
103
104// A stage's "auto" list: its rules, or null with its mistakes listed.
105export function rulesAt(value: unknown, path: string, errors: string[]): AutoRule[] | null {
106 if (!Array.isArray(value)) {
107 errors.push(`${path}: must be a list of rules`)
108
109 return null
110 }
111 if (value.length > RULES_MAX) {
112 errors.push(`${path}: ${value.length} rules; at most ${RULES_MAX}`)
113
114 return null
115 }
116 const before = errors.length
117 const rules = value.map((item, i) => ruleAt(item, `${path}[${i}]`, errors))
118
119 return errors.length === before ? (rules as AutoRule[]) : null
120}
121
122// The from ids a stage's rules name that the workflow has no stage for.
123export function unknownFrom(rules: readonly AutoRule[], path: string, ids: readonly string[]): string[] {
124 const errors: string[] = []
125 rules.forEach((rule, i) =>
126 rule.from?.forEach((id, j) => {
127 if (!ids.includes(id)) errors.push(`${path}[${i}].from[${j}]: ${quoted(id)} is not a stage of this workflow`)
128 }),
129 )
130
131 return errors
132}
133
134const BACKSLASH = String.fromCharCode(92)
135
136// What makes a pattern slow on a near miss, or null: a repeated group
137// holding another repeat or a choice, as in (a+)* or (a|b)+, or more than
138// REPEATS_MAX repeats in all (*, +, ?, {n,m}; a lazy ? and the ? of (?:
139// are none). Escapes and character classes are skipped.
140export function patternProblem(source: string): string | null {
141 type Group = { hasRepeat: boolean; hasChoice: boolean }
142 type Token = 'repeat' | 'open' | 'other'
143 const stack: Group[] = [{ hasRepeat: false, hasChoice: false }]
144 const isRepeat = (c: string | undefined) => c === '*' || c === '+' || c === '{'
145 let repeats = 0
146 // What the last token was: a ? after a repeat makes it lazy, after ( it
147 // starts (?: or (?=; anywhere else it is a repeat of its own.
148 let last: Token = 'other'
149 for (let i = 0; i < source.length; i++) {
150 const c = source[i]!
151 const top = stack[stack.length - 1]!
152 const bounds = c === '{' ? /^\{\d+(,\d*)?\}/.exec(source.slice(i)) : null
153 let token: Token = 'other'
154 if (c === BACKSLASH) i += 1
155 else if (c === '[') {
156 for (i += 1; i < source.length && source[i] !== ']'; i++) if (source[i] === BACKSLASH) i += 1
157 } else if (c === '(') {
158 stack.push({ hasRepeat: false, hasChoice: false })
159 token = 'open'
160 } else if (c === '|') top.hasChoice = true
161 else if (c === '?' && last !== 'other') token = last === 'repeat' ? 'other' : 'open'
162 else if (c === '*' || c === '+' || c === '?' || bounds) {
163 repeats += 1
164 if (c !== '?') top.hasRepeat = true
165 if (bounds) i += bounds[0].length - 1
166 token = 'repeat'
167 } else if (c === ')' && stack.length > 1) {
168 const group = stack.pop()!
169 const isRepeated = isRepeat(source[i + 1])
170 if (isRepeated && (group.hasRepeat || group.hasChoice)) return 'a repeated group holding another repeat or a choice, as in (a+)* or (a|b)+, can take too long'
171 if (group.hasRepeat || isRepeated) stack[stack.length - 1]!.hasRepeat = true
172 }
173 last = token
174 }
175
176 return repeats > REPEATS_MAX ? `${repeats} repeats (*, +, ?, {n,m}) can take too long; at most ${REPEATS_MAX}` : null
177}
178
179// The commands of a line a bash rule reads, split at ; & | and line ends,
180// each without its separators and spaces around, so ^ and $ match at its
181// ends.
182const commandsOf = (command: string): string[] => command.slice(0, COMMAND_MAX).split(/[;&|\n]+/).map(part => part.trim().slice(0, SEGMENT_MAX).trimEnd())
183
184// Rules' regular expressions, compiled once each.
185const compiled = new Map<string, RegExp>()
186function regExpOf(key: string, make: () => RegExp): RegExp {
187 let found = compiled.get(key)
188 if (!found) {
189 found = make()
190 compiled.set(key, found)
191 }
192
193 return found
194}
195
196// A glob as a regular expression on a whole path: ** any run of characters
197// (a **/ also none), * any run without /, ? one character but /.
198export function globRegExp(glob: string, ignoreCase: boolean): RegExp {
199 let source = ''
200 for (let i = 0; i < glob.length; i++) {
201 const c = glob[i]!
202 if (c === '*' && glob[i + 1] === '*') {
203 // Three stars or more read as two.
204 while (glob[i + 1] === '*') i += 1
205 if (glob[i + 1] === '/') {
206 i += 1
207 source += '(?:.*/)?'
208 } else {
209 source += '.*'
210 }
211 } else if (c === '*') source += '[^/]*'
212 else if (c === '?') source += '[^/]'
213 else source += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
214 }
215
216 return new RegExp(`^${source}$`, ignoreCase ? 'i' : '')
217}
218
219export const isWindowsPath = (path: string): boolean => /^[A-Za-z]:/.test(path)
220
221// A file's path relative to the project directory, with / separators; a
222// path outside it stays whole. A Windows path compares without case.
223export function relativePath(file: string, cwd: string): string {
224 const path = file.replace(/\\/g, '/')
225 let dir = cwd.replace(/\\/g, '/')
226 while (dir.length > 1 && dir.endsWith('/')) dir = dir.slice(0, -1)
227 if (dir === '/' && path.startsWith('/')) return path.slice(1)
228 const head = path.slice(0, dir.length)
229 const isSame = isWindowsPath(dir) ? head.toLowerCase() === dir.toLowerCase() : head === dir
230 if (dir !== '' && isSame && path[dir.length] === '/') return path.slice(dir.length + 1)
231
232 return path.replace(/^\.\//, '')
233}
234
235const hasFamily = (model: string | undefined, family: string): boolean => model !== undefined && model.toLowerCase().includes(family.toLowerCase())
236
237// Whether a rule matches what was done. A spawn that only inherited the
238// family from a parent on it does not count, unless the call named it. A
239// push rule listens to commands, and reads what core saw them push.
240export function ruleMatches(rule: AutoRule, signal: Signal, isWindows: boolean): boolean {
241 const kind = kindOf(rule)
242 if ((kind === 'push' ? 'bash' : kind) !== signal.kind) return false
243 const value = valueOf(rule)
244 switch (signal.kind) {
245 case 'skill': {
246 const name = signal.name.toLowerCase()
247 const want = value.toLowerCase()
248
249 // A bare name on either side matches the other's part after the
250 // plugin's name: skill.prompt may name a skill bare.
251 return name === want || (!want.includes(':') && name.endsWith(`:${want}`)) || (!name.includes(':') && want.endsWith(`:${name}`))
252 }
253 case 'write':
254 return regExpOf(`write ${isWindows} ${value}`, () => globRegExp(value, isWindows)).test(signal.path)
255 case 'agent':
256 return hasFamily(signal.model, value) && (!hasFamily(signal.parentModel, value) || hasFamily(signal.asked, value))
257 case 'bash':
258 if (kind === 'push') return signal.pushed === value
259 try {
260 const pattern = regExpOf(`bash ${value}`, () => new RegExp(value))
261
262 return commandsOf(signal.command).some(part => pattern.test(part))
263 } catch {
264 return false
265 }
266 }
267}
268
269// How long a command the person typed and a skill's expansion wait for
270// each other: the engine does not say which of the two comes first.
271export const TYPED_SKILL_MS = 10_000
272
273// A name heard, and when: the command typed, or the skill expanded.
274export type Heard = { name: string; at: number }
275
276// The skill the person just typed, when the command and the skill expanded
277// name the same one (exactly, or one bare and the other with its plugin's
278// name) within TYPED_SKILL_MS of each other: the more qualified of the two
279// names. Otherwise null: a skill expanded with no command around it (a
280// preload into a subagent, or the Skill tool's) is not one the person typed.
281export function typedSkill(typed: Heard | null, expanded: Heard | null): string | null {
282 if (typed === null || expanded === null || Math.abs(typed.at - expanded.at) > TYPED_SKILL_MS) return null
283 const a = typed.name.toLowerCase()
284 const b = expanded.name.toLowerCase()
285 if (a === b) return typed.name
286 if (!b.includes(':') && a.endsWith(`:${b}`)) return typed.name
287 if (!a.includes(':') && b.endsWith(`:${a}`)) return expanded.name
288
289 return null
290}
291
292// The stage what was done moves the work to, or null: the first stage, in
293// workflow order, with a rule that matches and may move the work there. A
294// rule moves the work into a side stage, into Done, or forward on the
295// track; never to the stage it is in, never back. From Done only a skill
296// the person typed moves it (a new round): post-merge chores stay in Done.
297export function autoMatch(w: Workflow, p: Progress, signal: Signal, isWindows: boolean): { stage: StageId; rule: AutoRule } | null {
298 if (isDone(p.stage) && !(signal.kind === 'skill' && signal.isTyped === true)) return null
299 const at = standing(w, p)
300 for (const stage of allStages(w)) {
301 if (stage.id === p.stage) continue
302 if (rank(w, stage.id) >= 0 && rank(w, stage.id) <= at) continue
303 const rule = (stage.auto ?? []).find(r => (r.from === undefined || (p.stage !== null && r.from.includes(p.stage))) && ruleMatches(r, signal, isWindows))
304 if (rule) return { stage: stage.id, rule }
305 }
306
307 return null
308}
309
310const short = (text: string, max: number): string => {
311 const clean = text.replace(/\s+/g, ' ').trim()
312 const list = Array.from(clean)
313
314 return list.length <= max ? clean : `${list.slice(0, max - 1).join('')}…`
315}
316
317// Why a move happened, as the history and the note say it. A command is
318// named by the rule it matched, not by its text, which may hold a secret.
319export function viaOf(signal: Signal, rule?: AutoRule): string {
320 switch (signal.kind) {
321 case 'skill':
322 return short(`skill ${signal.name}`, VIA_MAX)
323 case 'write':
324 return short(`wrote ${signal.path}`, VIA_MAX)
325 case 'agent':
326 return short(`agent on ${signal.model}`, VIA_MAX)
327 case 'bash':
328 if (rule && 'push' in rule) return short(`pushed to ${rule.push}`, VIA_MAX)
329
330 return short(rule && 'bash' in rule ? `bash rule ${rule.bash}` : 'ran a command', VIA_MAX)
331 }
332}
333
334// Whether any stage of the workflow has rules, or rules that listen to
335// one kind of signal (push rules to commands).
336export const hasRules = (w: Workflow, kind?: Signal['kind']): boolean =>
337 allStages(w).some(s => (s.auto ?? []).some(rule => kind === undefined || kind in rule || (kind === 'bash' && 'push' in rule)))
338
339// A rule as /stage workflow and /stage auto list it.
340export const ruleText = (rule: AutoRule): string => `${kindOf(rule)} ${valueOf(rule)}${rule.from ? ` from ${rule.from.join(', ')}` : ''}`
341hooks/band.ts 169 lines1// The progress bar above the prompt, as lines of styled spans: the stepped
2// track, where the work stands and the feature, the Fable tag while an
3// auditor runs, and the message. Sized to the band in terminal cells, so
4// nothing wraps.
5
6import type { Progress, WidthSetting, Workflow } from '../types'
7import type { AreaSize } from './scene'
8import { cellWidth, fitCells, NOT_TRACKED, position, stageLabel } from './stages'
9import { type TaskCount, countText } from './tasks'
10import { TOON_ROWS } from './toon'
11import { isDone } from './track'
12import { WIDTH_MAX } from './width'
13import { isTrack, rank, stageOf } from './workflow'
14
15export type Tone = 'done' | 'current' | 'future' | 'label' | 'tag' | 'message' | 'note'
16export type Span = { text: string; tone: Tone }
17
18// How each tone is drawn: the props of its Text (the band's hook), and the
19// frame tool's colours.
20export const BAR_TONES: Record<Tone, { color?: string; bold?: boolean; dimColor?: boolean }> = {
21 done: { color: '#D97757' },
22 current: { color: '#D97757', bold: true },
23 future: { dimColor: true },
24 label: {},
25 tag: { color: '#8B7AE8' },
26 message: { dimColor: true },
27 note: { dimColor: true },
28}
29
30// Below this many columns the band is one line.
31export const WIDE_MIN = 50
32export const TAG = '⌕ Fable reviewing'
33// The fewest band columns at which the area under the bar shows (drawn 29
34// wide there, cropped).
35export const TOON_MIN = 30
36
37// The most rows the area takes, where the band has them (TOON_ROWS, 7, is
38// the fewest).
39export const AREA_ROWS = 9
40
41export type BandMode = 'none' | 'text' | 'area'
42// barLines: how many lines the bar takes, as bandLayout laid it out.
43export type BandSite = {
44 surface: string
45 hasSurvey: boolean
46 isAgentView: boolean
47 isHidden: boolean
48 columns: number
49 maxRows: number
50 barLines: number
51}
52
53// What the band draws: nothing of stage-toons' (yielding), the text bar,
54// or the bar over the area, which plays a demo, the stage's scene while
55// Claude works, the waiting toon or the idle Clawd. The area is terminal
56// only, and shows only where the band has the rows to hold it whole under
57// the bar's lines.
58export function bandMode(s: BandSite): BandMode {
59 if (s.hasSurvey || s.isAgentView || s.isHidden) return 'none'
60 if (s.surface !== 'terminal') return 'text'
61
62 return s.columns >= TOON_MIN && s.maxRows >= s.barLines + TOON_ROWS ? 'area' : 'text'
63}
64
65// The area's size in a band: as wide as the band (one column spare, as the
66// bar keeps) up to the width setting and the widest Raster; 9 rows where
67// the band has them under the bar, else 8, else 7. previous: the rows the
68// area last showed with, null when it has not shown since the session or
69// the last band without it. It shrinks at once, and grows only with a row
70// to spare, so a line that comes and goes under it (the spinner's, at a
71// turn's start) does not make it flicker.
72export function areaSize(s: BandSite, limit: WidthSetting, previous: number | null): AreaSize {
73 const columns = Math.min(limit === 'full' ? WIDTH_MAX : limit, s.columns - 1)
74 const room = s.maxRows - s.barLines
75 const fit = Math.min(AREA_ROWS, room)
76 const rows = previous === null || fit <= previous ? fit : Math.max(previous, Math.min(AREA_ROWS, room - 1))
77
78 return { columns, rows }
79}
80
81// The track as it stands: done steps and the links between them, the
82// current step, and the steps to come. In Other, the track as it stood at
83// the last track stage, all dim. In Done, every step done, none current.
84export function trackSpans(w: Workflow, p: Progress): Span[] {
85 const isOff = !isTrack(w, p.stage) && !isDone(p.stage)
86 const at = isDone(p.stage) ? w.track.length : isTrack(w, p.stage) ? rank(w, p.stage) : rank(w, p.lastTrackStage)
87 const lit = (tone: Tone): Tone => (isOff ? 'future' : tone)
88 const spans: Span[] = []
89 w.track.forEach((_, i) => {
90 if (i > 0) spans.push(i <= at ? { text: '━', tone: lit('done') } : { text: '─', tone: 'future' })
91 spans.push(i < at ? { text: '●', tone: lit('done') } : i === at ? { text: '◉', tone: lit('current') } : { text: '○', tone: 'future' })
92 })
93
94 return spans
95}
96
97const lengthOf = (spans: Span[]) => spans.reduce((n, s) => n + cellWidth(s.text), 0)
98
99// The label after the track: the position, then the tasks' count and the
100// feature, each dropped in turn (the feature first) while it doesn't fit.
101function trackLabel(head: string, p: Progress, tasks: TaskCount | null, room: number): string {
102 const count = tasks ? ` · ${countText(tasks)}` : ''
103 const feature = p.feature ? ` · ${p.feature}` : ''
104 const fits = [` ${head}${count}${feature}`, ` ${head}${count}`].find(label => cellWidth(label) <= room)
105
106 return fits ?? ` ${head}`
107}
108
109// message, when given, stands in for the progress's own (the demo's line);
110// tasks, Claude's task list's count, shows after the position.
111export function bandLayout(w: Workflow, p: Progress, isAuditing: boolean, columns: number, message?: string, tasks: TaskCount | null = null): Span[][] {
112 // One column spare, so a line never touches the edge.
113 const width = Math.max(1, columns - 1)
114 if (p.stage === null) return untrackedLayout(w, p, isAuditing, columns, width, message, tasks)
115 // Done draws as a track stage does: the track, then its label.
116 if (!isTrack(w, p.stage) && !isDone(p.stage)) return sideLayout(w, p, isAuditing, columns, width, message ?? p.message)
117 const line = message ?? p.message
118 if (columns < WIDE_MIN) return [[{ text: fitCells(`${position(w, p)} — ${line}`, width), tone: 'label' }]]
119 const track: Span[] = [{ text: ' ', tone: 'label' }, ...trackSpans(w, p)]
120 const room = width - lengthOf(track)
121 const tag = isAuditing ? ` ${TAG}` : ''
122 // The tag goes first, then the feature, then the tasks; what still
123 // overflows is cut.
124 const label = trackLabel(position(w, p), p, tasks, room)
125 const line1: Span[] = [...track, { text: fitCells(label, room), tone: 'label' }]
126 if (tag && cellWidth(label) + cellWidth(tag) <= room) line1.push({ text: tag, tone: 'tag' })
127
128 return [line1, [{ text: fitCells(` ${line}`, width), tone: 'message' }]]
129}
130
131// Untracked: the dim track, Not tracked yet and the Fable tag when it fits,
132// on one line, and the demo's line under it while one plays.
133function untrackedLayout(w: Workflow, p: Progress, isAuditing: boolean, columns: number, width: number, message: string | undefined, tasks: TaskCount | null): Span[][] {
134 const label = `${NOT_TRACKED}${p.feature ? ` · ${p.feature}` : ''}`
135 const demo: Span[][] = message === undefined ? [] : [[{ text: fitCells(` ${message}`, width), tone: 'message' }]]
136 if (columns < WIDE_MIN) return [[{ text: fitCells(label, width), tone: 'label' }], ...demo]
137 const track: Span[] = [{ text: ' ', tone: 'label' }, ...trackSpans(w, p)]
138 const room = width - lengthOf(track)
139 const full = tasks ? trackLabel(NOT_TRACKED, p, tasks, room) : ` ${label}`
140 const line1: Span[] = [...track, { text: fitCells(full, room), tone: 'label' }]
141 const tag = ` ${TAG}`
142 if (isAuditing && cellWidth(full) + cellWidth(tag) <= room) line1.push({ text: tag, tone: 'tag' })
143
144 return [line1, ...demo]
145}
146
147// A side stage: no track; the stage, the feature, the Fable tag and a dim
148// note of the track stage the work returns to. Room runs out: the tag goes,
149// then the note, then the feature, then the label is cut.
150function sideLayout(w: Workflow, p: Progress, isAuditing: boolean, columns: number, width: number, message: string): Span[][] {
151 const head = `${stageOf(w, p.stage)?.label ?? p.stage}`
152 if (columns < WIDE_MIN) return [[{ text: fitCells(`${head} — ${message}`, width), tone: 'label' }]]
153 const feature = p.feature ? ` · ${p.feature}` : ''
154 const note = isTrack(w, p.lastTrackStage) ? ` ↩ ${stageLabel(w, p.lastTrackStage!)}` : ''
155 const tag = isAuditing ? ` ${TAG}` : ''
156 const full = ` ${head}${feature}`
157 const label = cellWidth(full) <= width ? full : ` ${head}`
158 const line1: Span[] = [{ text: fitCells(label, width), tone: 'label' }]
159 let used = cellWidth(label)
160 // The note goes before the feature does: with the feature dropped, no note.
161 if (note && label === full && used + cellWidth(note) <= width) {
162 line1.push({ text: note, tone: 'note' })
163 used += cellWidth(note)
164 }
165 if (tag && used + cellWidth(tag) <= width) line1.push({ text: tag, tone: 'tag' })
166
167 return [line1, [{ text: fitCells(` ${message}`, width), tone: 'message' }]]
168}
169hooks/command.ts 290 lines1// /stage: what its arguments ask for, the move it makes, and what it shows.
2
3import type { Progress, StageDef, StageId, WidthSetting, Workflow, WorkflowSettings } from '../types'
4import { ruleText } from './auto'
5import { DEMO_MAX, DEMO_MIN, DEMO_SECONDS, DJ_DEMO_SECONDS, WORK_DEMO_SECONDS } from './demo'
6import { ago, LOG_DEFAULT } from './history'
7import { type Music, trackLine } from './nowplaying'
8import type { DjProblem, Platform } from './readers'
9import { applyMove, backStage, describe, HISTORY_MAX, nextStage, parseStage, stageLabel } from './stages'
10import { isWaitingScene, type WaitingSceneId } from './waiting/deal'
11import { parseWidth, widthSettingWords } from './width'
12import { allStages, DONE_ID, doneOf, RESERVED_IDS, stageIds, zeroStage } from './workflow'
13
14export type Command =
15 | { kind: 'show' }
16 | { kind: 'move'; stage: StageId }
17 | { kind: 'next' }
18 | { kind: 'back' }
19 | { kind: 'feature'; name: string }
20 | { kind: 'message'; text: string }
21 | { kind: 'hide' }
22 | { kind: 'demo'; seconds: number; stage: StageId | null }
23 | { kind: 'demo-idle'; seconds: number }
24 | { kind: 'demo-work'; seconds: number }
25 | { kind: 'demo-waiting'; scene: WaitingSceneId | null }
26 | { kind: 'demo-dj'; seconds: number }
27 | { kind: 'demo-stop' }
28 | { kind: 'workflow' }
29 | { kind: 'workflow-init' }
30 | { kind: 'workflow-reload' }
31 | { kind: 'auto'; isOn: boolean | null }
32 | { kind: 'bubbles'; isOn: boolean | null }
33 | { kind: 'calm'; isOn: boolean | null }
34 | { kind: 'dj'; isOn: boolean | null }
35 | { kind: 'dj-party'; isOn: boolean | null }
36 | { kind: 'width'; width: WidthSetting | null }
37 | { kind: 'width-usage' }
38 | { kind: 'log'; count: number }
39 | { kind: 'time' }
40 | { kind: 'help'; topic: string | null }
41 | { kind: 'usage' }
42
43export const WORKFLOW_FILE = '.claude/stage-toons.json'
44
45export function parseCommand(w: Workflow, args: string): Command {
46 const text = args.trim()
47 if (text === '') return { kind: 'show' }
48 const head = text.split(/\s+/)[0] ?? ''
49 const tail = text.slice(head.length).trim()
50 const word = head.toLowerCase()
51 if (word === 'feature') return tail ? { kind: 'feature', name: tail } : { kind: 'usage' }
52 if (word === 'message') return tail ? { kind: 'message', text: tail } : { kind: 'usage' }
53 if (word === 'demo') return parseDemo(w, tail.toLowerCase())
54 if (word === 'auto' || word === 'bubbles' || word === 'calm' || word === 'dj') {
55 const sub = tail.toLowerCase()
56 if (word === 'dj' && sub.split(/\s+/)[0] === 'party') {
57 const party = sub.slice('party'.length).trim()
58 if (party === '') return { kind: 'dj-party', isOn: null }
59
60 return party === 'on' || party === 'off' ? { kind: 'dj-party', isOn: party === 'on' } : { kind: 'usage' }
61 }
62 if (sub === '') return { kind: word, isOn: null }
63
64 return sub === 'on' || sub === 'off' ? { kind: word, isOn: sub === 'on' } : { kind: 'usage' }
65 }
66 if (word === 'width') {
67 if (tail === '') return { kind: 'width', width: null }
68 const width = parseWidth(tail.toLowerCase())
69
70 return width === null ? { kind: 'width-usage' } : { kind: 'width', width }
71 }
72 if (word === 'log') {
73 if (tail === '') return { kind: 'log', count: LOG_DEFAULT }
74 const count = /^\d+$/.test(tail) ? Number(tail) : NaN
75
76 return count >= 1 && count <= HISTORY_MAX ? { kind: 'log', count } : { kind: 'usage' }
77 }
78 if (word === 'workflow') {
79 const sub = tail.toLowerCase()
80 if (sub === '') return { kind: 'workflow' }
81 if (sub === 'init') return { kind: 'workflow-init' }
82 if (sub === 'reload') return { kind: 'workflow-reload' }
83
84 return { kind: 'usage' }
85 }
86 if (word === 'help') return { kind: 'help', topic: tail === '' ? null : tail.toLowerCase().split(/\s+/).join(' ') }
87 if (tail !== '') return { kind: 'usage' }
88 if (word === 'next' || word === 'back' || word === 'hide') return { kind: word }
89 if (word === 'time') return { kind: 'time' }
90 const stage = parseStage(w, word)
91
92 return stage ? { kind: 'move', stage } : { kind: 'usage' }
93}
94
95// A /stage it doesn't understand: one line, with the stage it may have
96// meant when a word names one (/stage set plan), unless the first word is
97// a command of its own whose argument was wrong (/stage demo plan 99).
98export function unknownText(w: Workflow, args: string): string {
99 const words = args.trim().split(/\s+/)
100 // done is reserved, but it is a stage: /stage done now may mean it.
101 const first = words[0]!.toLowerCase()
102 const isCommand = first !== DONE_ID && (RESERVED_IDS as readonly string[]).includes(first)
103 const meant = isCommand ? undefined : words.map(word => parseStage(w, word)).find(id => id !== undefined)
104
105 return `Not a /stage command: ${words.join(' ')}.${meant ? ` Did you mean /stage ${meant}?` : ''} /stage help commands lists them all.`
106}
107
108// Seconds as /stage demo takes them: none for the default, else 2 to 60.
109function secondsOf(word: string | undefined, fallback = DEMO_SECONDS): number | null {
110 if (word === undefined) return fallback
111 const seconds = /^\d+$/.test(word) ? Number(word) : NaN
112
113 return seconds >= DEMO_MIN && seconds <= DEMO_MAX ? seconds : null
114}
115
116// demo [<stage id> | idle | work | dj] [seconds], demo waiting [scene], or demo stop. A bare
117// number is the seconds: stages are named by id only.
118function parseDemo(w: Workflow, arg: string): Command {
119 const [first, second, ...rest] = arg === '' ? [] : arg.split(/\s+/)
120 if (rest.length > 0) return { kind: 'usage' }
121 if (first === 'stop') return second === undefined ? { kind: 'demo-stop' } : { kind: 'usage' }
122 if (first === 'waiting') {
123 if (second === undefined) return { kind: 'demo-waiting', scene: null }
124
125 return isWaitingScene(second) ? { kind: 'demo-waiting', scene: second } : { kind: 'usage' }
126 }
127 if (first === undefined || /^\d+$/.test(first)) {
128 const seconds = second === undefined ? secondsOf(first) : null
129
130 return seconds === null ? { kind: 'usage' } : { kind: 'demo', seconds, stage: null }
131 }
132 const seconds = secondsOf(second, first === 'work' ? WORK_DEMO_SECONDS : first === 'dj' ? DJ_DEMO_SECONDS : DEMO_SECONDS)
133 if (seconds === null) return { kind: 'usage' }
134 if (first === 'idle') return { kind: 'demo-idle', seconds }
135 if (first === 'dj') return { kind: 'demo-dj', seconds }
136 if (first === 'work') return { kind: 'demo-work', seconds }
137
138 return stageIds(w).includes(first) ? { kind: 'demo', seconds, stage: first } : { kind: 'usage' }
139}
140
141export function moveFor(w: Workflow, p: Progress, cmd: Command, now: number): Progress {
142 const by = 'user'
143 switch (cmd.kind) {
144 case 'move':
145 return applyMove(w, p, { stage: cmd.stage, by, at: now })
146 case 'next':
147 return applyMove(w, p, { stage: nextStage(w, p), by, at: now })
148 case 'back': {
149 const stage = backStage(w, p)
150
151 return stage === null ? p : applyMove(w, p, { stage, by, at: now })
152 }
153 case 'feature':
154 return applyMove(w, p, { stage: p.stage, feature: cmd.name === '-' ? '' : cmd.name, by, at: now })
155 case 'message':
156 return applyMove(w, p, { stage: p.stage, message: cmd.text, by, at: now })
157 default:
158 return p
159 }
160}
161
162export function showText(w: Workflow, p: Progress, now: number): string {
163 const lines = [`Stage ${describe(w, p)}`]
164 const recent = p.history.slice(-5).reverse()
165 if (recent.length > 0) lines.push('Recent moves:', ...recent.map(h => ` ${stageLabel(w, h.stage)} by ${h.by}${h.via ? ` (${h.via})` : ''}, ${ago(now - h.at)}`))
166
167 return lines.join('\n')
168}
169
170// What the project's workflow file starts the session with, or null when
171// it sets nothing.
172// What a workflow file's settings start, as words: 'automatic moves off and
173// the bar hidden'; null when it sets none.
174export function settingsWords(settings: WorkflowSettings | undefined): string | null {
175 const parts: string[] = []
176 if (settings?.auto !== undefined) parts.push(`automatic moves ${settings.auto ? 'on' : 'off'}`)
177 if (settings?.hide !== undefined) parts.push(`the bar ${settings.hide ? 'hidden' : 'shown'}`)
178 if (settings?.bubbles !== undefined) parts.push(`Clawd's pop-ups ${settings.bubbles ? 'on' : 'off'}`)
179 if (settings?.calm !== undefined) parts.push(`calm mode ${settings.calm ? 'on' : 'off'}`)
180 if (settings?.width !== undefined) parts.push(widthSettingWords(settings.width))
181
182 return parts.length === 0 ? null : parts.length === 1 ? parts[0]! : `${parts.slice(0, -1).join(', ')} and ${parts.at(-1)}`
183}
184
185export function settingsText(settings: WorkflowSettings | undefined): string | null {
186 const words = settingsWords(settings)
187
188 return words === null ? null : `This project's workflow file starts ${words}.`
189}
190
191export function workflowSummary(w: Workflow, source: 'file' | 'default', settings?: WorkflowSettings): string {
192 const zero = zeroStage(w)
193 const starts = settingsText(settings)
194 const lines = [
195 source === 'file' ? `Workflow: from ${WORKFLOW_FILE}` : `Workflow: the default (no ${WORKFLOW_FILE}; /stage workflow init writes it there to edit)`,
196 ...(starts ? [starts] : []),
197 'Track:',
198 ...w.track.map((s, i) => ` ${i + 1} ${s.id}: ${s.label} (${s.scenes.join(', ')})${rulesOf(s)}`),
199 'Past the track:',
200 ` ${DONE_ID}: ${doneOf(w).label} (${doneOf(w).scenes.join(', ')})${rulesOf(doneOf(w))}`,
201 ]
202 if (w.side.length > 0) lines.push('Side:', ...w.side.map(s => ` ${s === zero ? '0 ' : ''}${s.id}: ${s.label} (${s.scenes.join(', ')})${rulesOf(s)}`))
203
204 return lines.join('\n')
205}
206
207const rulesOf = (s: StageDef): string => (s.auto && s.auto.length > 0 ? ` · auto: ${s.auto.map(ruleText).join('; ')}` : '')
208
209// /stage auto: whether the stage moves by itself, and on what; fileAuto,
210// what the project's workflow file starts the session with, if it says.
211export function autoText(w: Workflow, isOn: boolean, fileAuto?: boolean): string {
212 const stages = allStages(w).filter(s => s.auto && s.auto.length > 0)
213 const state = isOn ? 'Automatic moves are on: /stage auto off turns them off.' : 'Automatic moves are off: /stage auto on turns them on.'
214 const head = fileAuto === undefined ? [state] : [state, `This project's workflow file starts it ${fileAuto ? 'on' : 'off'}.`]
215 if (stages.length === 0) return [...head, `This workflow has no rules: give a stage an "auto" list in ${WORKFLOW_FILE}.`].join('\n')
216 const rows = stages.map(s => ` ${s.id}: ${s.auto!.map(ruleText).join('; ')}`)
217
218 return [...head, 'Rules, by the stage they move the work to (only forward on the track, into Done, or into a side stage; from Done only a skill you type):', ...rows].join('\n')
219}
220
221// /stage bubbles' answer; fileBubbles, what the project's workflow file
222// starts the session with, if it says.
223export function bubblesText(isOn: boolean, fileBubbles?: boolean): string {
224 const state = isOn
225 ? "Clawd's pop-ups are on: what Claude is doing over his head, and what he says. /stage bubbles off turns them off."
226 : "Clawd's pop-ups are off. /stage bubbles on turns them on."
227
228 return fileBubbles === undefined ? state : `${state} This project's workflow file starts them ${fileBubbles ? 'on' : 'off'}.`
229}
230
231// /stage calm's answer; fileCalm, what the project's workflow file starts
232// the session with, if it says.
233export function calmText(isOn: boolean, fileCalm?: boolean): string {
234 const state = isOn
235 ? 'Calm mode is on: the cartoons hold still and change only when something happens. /stage calm off brings the motion back.'
236 : 'Calm mode is off: the cartoons move. /stage calm on holds them still.'
237
238 return fileCalm === undefined ? state : `${state} This project's workflow file starts it ${fileCalm ? 'on' : 'off'}.`
239}
240
241// Where DJ mode can't read, and macOS's Automation permission (Clawd DJ
242// everywhere).
243export const DJ_NEEDS_TERMINAL = 'DJ mode needs Claude Code in a terminal on Windows, macOS or Linux.'
244export const DJ_NEEDS_PLAYERCTL = 'DJ mode on Linux needs playerctl (from your package manager): it reads Spotify through MPRIS. Once it is installed, type /stage dj on again.'
245export const DJ_UNKNOWN = "DJ mode couldn't tell which system this is (uname didn't answer in time): /stage dj on tries again."
246export const DJ_NO_WSL = "DJ mode can't reach Spotify on Windows from WSL: run Claude Code in a Windows terminal for it."
247export const DJ_ASKING = 'DJ mode on: reading Spotify timed out. If macOS is asking whether your terminal may control Spotify, allow it, and the music shows.'
248export const DJ_DENIED = "DJ mode on, but macOS doesn't let your terminal control Spotify: switch it on in System Settings › Privacy & Security › Automation, under your terminal's name."
249
250// Why DJ mode can't read on a platform readerFor has no reader for.
251export function djBlockedText(platform: Platform): string {
252 if (platform === 'linux-missing') return DJ_NEEDS_PLAYERCTL
253 if (platform === 'wsl') return DJ_NO_WSL
254 if (platform === 'unknown') return DJ_UNKNOWN
255
256 return DJ_NEEDS_TERMINAL
257}
258
259// /stage dj's answer: whether DJ mode is on, and what the last read
260// heard. The track goes to the person only (the answer's text, never
261// Claude's context). isBroken: the DJ picture failed this load, and the
262// usual cartoons show. problem: what macOS's Automation permission left
263// (asking, denied).
264export function djText(isOn: boolean, music: Music, isBroken = false, problem: DjProblem = null): string {
265 if (!isOn) return "DJ mode is off. /stage dj on: while Spotify plays, Clawd DJs and the track's name shows."
266 if (isBroken) return 'DJ mode on, but the DJ picture failed this session (the debug log says why): the usual cartoons show. /stage dj on tries again.'
267 if (problem === 'asking') return DJ_ASKING
268 if (problem === 'denied') return DJ_DENIED
269 if (music.isPlaying && music.track) return `DJ mode on: ${trackLine(music.track)}`
270
271 return 'DJ mode on: waiting for Spotify to play. /stage dj off turns it off.'
272}
273
274// /stage dj party's answer.
275export const djPartyText = (isOn: boolean): string =>
276 isOn
277 ? 'DJ party lights on: the speakers, the floor, the booth and the box light up with the beat. /stage dj party off calms them.'
278 : 'DJ party lights off: the booth glows steady while Clawd bobs to the beat. /stage dj party on lights it up.'
279
280// What a demo's answer adds while calm mode is on.
281export const CALM_DEMO_LINE = 'Calm mode is on: the demo moves anyway, and the stills come back when it ends.'
282
283// /stage hide's answer; fileHide, what the project's workflow file starts
284// the session with, if it says.
285export function hideText(isHidden: boolean, fileHide?: boolean): string {
286 const state = isHidden ? 'stage-toons is hidden. /stage hide shows it again.' : 'stage-toons is shown.'
287
288 return fileHide === undefined ? state : `${state} This project's workflow file starts it ${fileHide ? 'hidden' : 'shown'}.`
289}
290hooks/demo.ts 243 lines1// /stage demo: the library's scenes, one stage's scenes, or the idle
2// routines, each for so many seconds, or the waiting toon's script, or the
3// work layer's steps over the stage's scene, or Clawd DJ's script, so the
4// cartoons can be seen
5// while Claude is idle. What shows at a moment is
6// worked out from the demo and the time alone (the area dissolves from one
7// step into the next); startDemo is the one function that sets mod.demo.
8
9import type { IdleId, Workflow } from '../types'
10import { bandMode, type BandSite } from './band'
11import type { Command } from './command'
12import { createDirector, type Director, react, type Shot, tick } from './director'
13import { CELEBRATE, IDLE_IDS, SHOW_OFF_DEMO, SHOW_OFF_PREFIX } from './idle'
14import type { Mod } from './mod'
15import type { Phase } from './phase'
16import type { NowPlaying } from './nowplaying'
17import { FABLE_SCENE, FALLBACK_SCENE, type SceneId, STAGE_W } from './scene'
18import { DEMO_ORDER } from './scenes'
19import { djSpots } from './scenes/dj'
20import { TOON_ROWS } from './toon'
21import { FALLBACK_WAITING, WAITING_SCENE_IDS, type WaitingSceneId } from './waiting/deal'
22import { waitingDemoMs } from './waiting/demo'
23import { AREA_W, type SeatEntry, type SeatLog } from './waiting/seats'
24import { type Activity, createActivity, type LineMoment, lineOf, said, settleActivity, type SpinnerMode, toolStarted, withMode } from './work'
25import { allStages, stageOf } from './workflow'
26
27export const DEMO_SECONDS = 10
28export const DEMO_MIN = 2
29export const DEMO_MAX = 60
30export const WORK_DEMO_SECONDS = 3
31export const DJ_DEMO_SECONDS = 20
32
33export type Demo =
34 | { kind: 'scenes'; at: number; seconds: number; scenes: readonly SceneId[] }
35 | { kind: 'idle'; at: number; seconds: number }
36 // columns: the area's width when it started, which sets its length.
37 | { kind: 'waiting'; at: number; scene: WaitingSceneId; columns: number }
38 | { kind: 'work'; at: number; seconds: number; scene: SceneId }
39 | { kind: 'dj'; at: number; seconds: number }
40// The scene showing, as a shot, and how long it has shown.
41export type DemoFrame = { index: number; shot: Shot; sceneMs: number }
42// The idle demo's step: a routine, or the doze (null) after them all.
43export type IdleDemoFrame = { index: number; routine: IdleId | null; ms: number }
44
45// The idle demo plays every routine, the celebration, the show-off (each
46// prop for SHOW_OFF_DEMO_MS in turn: one loop of the routine, through the
47// turn-over and the glance), then the doze.
48export const IDLE_DEMO_LENGTH = IDLE_IDS.length + 3
49export const SHOW_OFF_DEMO_MS = 6000
50// The work demo's script: what Claude does in each step, as the layer
51// shows it.
52export type WorkStep = { name: string; mode?: SpinnerMode; phase?: Phase; what?: string; say?: LineMoment; isFailure?: boolean }
53export const WORK_STEPS: readonly WorkStep[] = [
54 { name: 'thinking', mode: 'thinking' },
55 { name: 'searching', phase: 'search', what: 'useAuth' },
56 { name: 'reading', phase: 'read', what: 'auth.ts' },
57 { name: 'editing', phase: 'edit', what: 'auth.ts' },
58 { name: 'testing', phase: 'test', what: 'npm test' },
59 { name: 'building', phase: 'build', what: 'npm run' },
60 { name: 'git', phase: 'git', what: 'git status' },
61 { name: 'running a command', phase: 'run', what: 'ls src' },
62 { name: 'on the web', phase: 'web', what: 'docs.rs' },
63 { name: 'agents', phase: 'agents', what: 'Audit spec' },
64 { name: 'a test fails', phase: 'test', what: 'npm test', say: 'test-fail', isFailure: true },
65 { name: 'the tests pass', phase: 'test', what: 'npm test', say: 'test-pass' },
66 { name: 'a commit', phase: 'git', what: 'git commit', say: 'commit' },
67 { name: 'writing the answer', mode: 'responding' },
68]
69
70export const demoLength = (demo: Demo): number =>
71 demo.kind === 'scenes' ? demo.scenes.length : demo.kind === 'idle' ? IDLE_DEMO_LENGTH : demo.kind === 'work' ? WORK_STEPS.length : 1
72
73// Which item plays at a moment and for how long it has, or null once the
74// demo is over.
75export function demoStep(demo: Demo, now: number): { index: number; ms: number } | null {
76 const length = demo.kind === 'waiting' ? waitingDemoMs(demo.columns) : demo.seconds * 1000
77 const elapsed = Math.max(0, now - demo.at)
78 const index = Math.floor(elapsed / length)
79
80 return index < demoLength(demo) ? { index, ms: elapsed - index * length } : null
81}
82
83export function demoAt(demo: Demo, now: number): DemoFrame | null {
84 if (demo.kind !== 'scenes') return null
85 const step = demoStep(demo, now)
86 if (!step) return null
87 return { index: step.index, shot: { base: demo.scenes[step.index]!, time: step.ms / 1000, reaction: null }, sceneMs: step.ms }
88}
89
90export function idleDemoAt(demo: Demo, now: number): IdleDemoFrame | null {
91 if (demo.kind !== 'idle') return null
92 const step = demoStep(demo, now)
93
94 if (!step) return null
95 const n = IDLE_IDS.length
96 if (step.index < n) return { index: step.index, routine: IDLE_IDS[step.index]!, ms: step.ms }
97 if (step.index === n) return { index: step.index, routine: CELEBRATE, ms: step.ms }
98 if (step.index === n + 1) {
99 const prop = Math.floor(step.ms / SHOW_OFF_DEMO_MS)
100
101 return { index: step.index, routine: SHOW_OFF_DEMO[prop % SHOW_OFF_DEMO.length]!, ms: step.ms - prop * SHOW_OFF_DEMO_MS }
102 }
103
104 return { index: step.index, routine: null, ms: step.ms }
105}
106
107// The work demo at a moment: its step (the last one past the end, which an
108// outgoing picture keeps drawing through its dissolve), when the step
109// started, the activity and the director as the step makes them: the
110// stage's scene from the demo's start, the failure from the step's.
111export type WorkDemoFrame = { index: number; step: WorkStep; activity: Activity; director: Director }
112
113export function workDemoAt(demo: Extract<Demo, { kind: 'work' }>, now: number): WorkDemoFrame {
114 const stepMs = demo.seconds * 1000
115 const index = Math.max(0, Math.min(WORK_STEPS.length - 1, Math.floor((now - demo.at) / stepMs)))
116 const step = WORK_STEPS[index]!
117 const start = demo.at + index * stepMs
118 let activity = createActivity()
119 if (step.mode) activity = withMode(activity, step.mode)
120 if (step.phase) activity = toolStarted(activity, 'demo', step.phase, step.what ?? '')
121 if (step.say) activity = said(activity, lineOf(step.say, start), start)
122 let director: Director = { ...createDirector(demo.scene, false), baseAt: demo.at }
123 if (step.isFailure) director = react(director, 'failure', start)
124
125 return { index, step, activity: settleActivity(activity, start), director: tick(director, now) }
126}
127
128export const workDemoLine = (f: WorkDemoFrame, isBubbles: boolean): string =>
129 `Work demo ${f.index + 1}/${WORK_STEPS.length}: ${f.step.name}${isBubbles ? '' : " (Clawd's pop-ups are off)"} · /stage demo stop ends it`
130
131// A scene as the demo names it: its own name, and the first stage (track,
132// then side) that lists it.
133function labelOf(id: SceneId, w: Workflow): string {
134 if (id === FABLE_SCENE) return `${id} (Fable reviewing)`
135 const stage = allStages(w).find(s => s.scenes.includes(id))
136
137 return stage ? `${id} (${stage.label})` : id
138}
139
140export const demoLine = (f: DemoFrame, demo: Demo, w: Workflow): string =>
141 `Demo ${f.index + 1}/${demoLength(demo)}: ${labelOf(f.shot.base, w)} · /stage demo stop ends it`
142
143// A routine as the idle demo's line names it: a show-off by its prop's stage.
144function routineName(routine: IdleId | null): string {
145 if (routine === null) return 'doze'
146 if (!routine.startsWith(SHOW_OFF_PREFIX)) return routine
147 const stage = routine.slice(SHOW_OFF_PREFIX.length)
148
149 return `show-off (${stage === '*' ? 'any other stage' : stage})`
150}
151
152export const idleDemoLine = (f: IdleDemoFrame): string => `Idle demo ${f.index + 1}/${IDLE_DEMO_LENGTH}: ${routineName(f.routine)} · /stage demo stop ends it`
153
154export const waitingDemoLine = (scene: WaitingSceneId): string => `Waiting demo: ${scene} · /stage demo stop ends it`
155
156// /stage demo dj: Clawd at the decks whatever Spotify says. A long track
157// (its title scrolls in the box), another from DJ_DEMO_CHANGE_MS (the
158// record swap; letters beyond ASCII), and two helpers dancing, walking in
159// half a second apart.
160export const DJ_DEMO_TRACKS: readonly NowPlaying[] = [
161 { artist: 'Daft Punk', title: 'Harder, Better, Faster, Stronger', isPlaying: true },
162 { artist: 'Clawd & the Toons', title: 'Łąka, Кино, Café', isPlaying: true },
163]
164export const DJ_DEMO_CHANGE_MS = 8000
165const DJ_DEMO_CAST: readonly Pick<SeatEntry, 'agentId' | 'kind'>[] = [
166 { agentId: 'demo-opus', kind: 'opus' },
167 { agentId: 'demo-fable', kind: 'fable' },
168]
169
170export type DjDemoFrame = { track: NowPlaying; changedAt: number; dancers: SeatLog }
171
172// The DJ demo at a moment, on an area this wide: the track, when it
173// changed, and the dancers (as many as the floor has spots for).
174export function djDemoAt(demo: Extract<Demo, { kind: 'dj' }>, now: number, columns: number): DjDemoFrame {
175 const changedAt = demo.at + DJ_DEMO_CHANGE_MS
176 const spots = djSpots(columns)
177 const edge = Math.max(AREA_W, columns)
178 const entries: SeatEntry[] = DJ_DEMO_CAST.slice(0, spots.count).map((c, seat) => ({ ...c, seat, arrivedAt: demo.at + seat * 500, leftAt: null, edge, x: spots.x(seat) }))
179
180 return {
181 track: now < changedAt ? DJ_DEMO_TRACKS[0]! : DJ_DEMO_TRACKS[1]!,
182 changedAt,
183 dancers: { entries, queue: [], since: demo.at, until: null, walk: null, seats: spots.count },
184 }
185}
186
187export const djDemoLine = (): string => 'DJ demo: Clawd at the decks · /stage demo stop ends it'
188
189// The demo a /stage demo command asks for, and what its answer calls it.
190// scene: the stage's, which the work demo plays under its layer; waiting:
191// the waiting scene the waiting demo plays.
192export function demoFor(
193 cmd: Extract<Command, { kind: 'demo' | 'demo-idle' | 'demo-waiting' | 'demo-work' | 'demo-dj' }>,
194 w: Workflow,
195 now: number,
196 scene: SceneId = FALLBACK_SCENE,
197 waiting: WaitingSceneId = FALLBACK_WAITING,
198 columns = STAGE_W,
199): { demo: Demo; label: string } {
200 if (cmd.kind === 'demo-work') return { demo: { kind: 'work', at: now, seconds: cmd.seconds, scene }, label: `what Clawd shows while Claude works, over ${scene}, in ${WORK_STEPS.length} steps` }
201 if (cmd.kind === 'demo-dj') return { demo: { kind: 'dj', at: now, seconds: cmd.seconds }, label: 'Clawd DJ' }
202 const stage = cmd.kind === 'demo' && cmd.stage !== null ? stageOf(w, cmd.stage) : undefined
203 const demo: Demo =
204 cmd.kind === 'demo-waiting'
205 ? { kind: 'waiting', at: now, scene: waiting, columns }
206 : cmd.kind === 'demo-idle'
207 ? { kind: 'idle', at: now, seconds: cmd.seconds }
208 : { kind: 'scenes', at: now, seconds: cmd.seconds, scenes: stage?.scenes ?? DEMO_ORDER }
209 const n = demoLength(demo)
210 const label =
211 cmd.kind === 'demo-waiting'
212 ? `the waiting toon: ${waiting}`
213 : cmd.kind === 'demo-idle'
214 ? `${IDLE_IDS.length} idle routines, the celebration, the show-off and the doze`
215 : stage
216 ? `${n} scene${n === 1 ? '' : 's'} of ${stage.label}`
217 : `${n} scenes`
218
219 return { demo, label }
220}
221
222// Whether the band last drawn has room for the area under a bar of
223// barLines lines (the bar with the demo's line).
224export const hasRoom = (lastBand: BandSite | null, barLines: number): boolean =>
225 !lastBand || bandMode({ ...lastBand, hasSurvey: false, isAgentView: false, isHidden: false, barLines }) === 'area'
226
227// Starts the demo, or says why not. label names what plays; barLines is
228// how many lines the bar takes with the demo's line.
229export function startDemo(mod: Mod, demo: Demo, label: string, now: number, isHidden: boolean, barLines: number): string {
230 if (isHidden) return 'stage-toons is hidden; /stage hide shows it.'
231 if (mod.surface !== 'terminal') return 'The demo plays in the terminal only.'
232 if (!hasRoom(mod.lastBand, barLines)) return `The band has no room for the demo: it needs 30 columns and ${barLines + TOON_ROWS} rows.`
233 const isRestart = mod.demo !== null && demoStep(mod.demo, now) !== null
234 mod.demo = demo
235 const playing = `${isRestart ? 'Restarting the demo: p' : 'P'}laying`
236 if (demo.kind === 'waiting') return `${playing} ${label} (about ${Math.round(waitingDemoMs(demo.columns) / 1000)} s). /stage demo waiting <${WAITING_SCENE_IDS.join(' | ')}> plays one. /stage demo stop ends it.`
237 if (demo.kind === 'dj') return `${playing} ${label} for ${demo.seconds} s: a long title, a track change, two helpers dancing. /stage demo stop ends it.`
238 const total = demoLength(demo) * demo.seconds
239 const length = total < 60 ? 'under a minute' : `about ${Math.round(total / 60)} min`
240
241 return `${playing} ${label}, ${demo.seconds} s each (${length}). /stage demo stop ends it.`
242}
243hooks/director.ts 96 lines1// Which scene to show, since when, and with which reaction over it. Pure:
2// every change takes the time it happens at, and tick settles what has run
3// its course. The area dissolves from one scene into the next (frames.ts).
4
5import { FABLE_SCENE, type Mood, type ReactionId, type SceneId } from './scene'
6
7export const REACTION_MS: Record<ReactionId, number> = { failure: 2500, advance: 3000, 'fable-arrives': 2000, 'fable-leaves': 2000 }
8
9export type Director = {
10 // The scene of the stage the work is in.
11 scene: SceneId
12 auditing: boolean
13 // The scene on show, and when it came on: each scene runs on its own
14 // clock from there.
15 base: SceneId
16 baseAt: number
17 reaction: { id: ReactionId; at: number } | null
18}
19
20// time: seconds on the base scene's own clock. mood: the hero's look when
21// no reaction gives one (eyes up while Claude thinks).
22export type Shot = {
23 base: SceneId
24 time: number
25 reaction: { id: ReactionId; t: number } | null
26 mood?: Mood
27}
28
29const isFable = (id: ReactionId) => id === 'fable-arrives' || id === 'fable-leaves'
30
31export const createDirector = (scene: SceneId, auditing: boolean): Director => ({
32 scene,
33 auditing,
34 base: auditing ? FABLE_SCENE : scene,
35 baseAt: 0,
36 reaction: null,
37})
38
39// Ends what has run its course, then brings Fable in or out to match
40// whether an auditor runs. Fable's walks are never cut short: the auditor
41// scene takes over when the walk-in ends, and the stage's scene returns at
42// once when Fable starts to walk out (over the auditor scene, which holds
43// Fable already, there would be two).
44export function tick(d: Director, now: number): Director {
45 let n = d
46 if (n.reaction && now - n.reaction.at >= REACTION_MS[n.reaction.id]) {
47 const ended = n.reaction.id
48 n = { ...n, reaction: null }
49 if (ended === 'fable-arrives') n = { ...n, base: FABLE_SCENE, baseAt: now }
50 }
51 const isWalking = n.reaction !== null && isFable(n.reaction.id)
52 if (!isWalking) {
53 if (n.auditing && n.base !== FABLE_SCENE) n = { ...n, reaction: { id: 'fable-arrives', at: now } }
54 else if (!n.auditing && n.base === FABLE_SCENE) n = { ...n, base: n.scene, baseAt: now, reaction: { id: 'fable-leaves', at: now } }
55 }
56
57 return n
58}
59
60// The stage moved and its scene is `scene`: it comes on from its start,
61// unless the auditor scene shows, which plays on.
62export function withScene(d: Director, scene: SceneId, now: number): Director {
63 const n = tick(d, now)
64 if (scene === n.scene) return n
65 if (n.base === FABLE_SCENE) return { ...n, scene }
66
67 return { ...n, scene, base: scene, baseAt: now }
68}
69
70// The area shows the scene again after showing something else: it starts
71// over.
72export const restarted = (d: Director, now: number): Director => ({ ...d, baseAt: now })
73
74export const withAuditing = (d: Director, auditing: boolean, now: number): Director => tick({ ...d, auditing }, now)
75
76export function react(d: Director, id: 'failure' | 'advance', now: number): Director {
77 const n = tick(d, now)
78 if (n.reaction && isFable(n.reaction.id)) return n
79
80 return { ...n, reaction: { id, at: now } }
81}
82
83export const shot = (d: Director, now: number): Shot => ({
84 base: d.base,
85 time: (now - d.baseAt) / 1000,
86 reaction: d.reaction ? { id: d.reaction.id, t: (now - d.reaction.at) / 1000 } : null,
87})
88
89// The shot of a director no longer ticked (a scene dissolving out): its
90// reaction holds its last moment once over.
91export const heldShot = (d: Director, now: number): Shot => ({
92 base: d.base,
93 time: (now - d.baseAt) / 1000,
94 reaction: d.reaction ? { id: d.reaction.id, t: Math.min(now - d.reaction.at, REACTION_MS[d.reaction.id]) / 1000 } : null,
95})
96hooks/frames.ts 340 lines1// Which frame the area shows at a moment, from the mod's state and the time
2// alone: no engine. Composing moves the mod on as the loop goes (the seat
3// log, the director's clock, the dissolve), and failures go to the log
4// passed in.
5
6import type { IdleId } from '../types'
7import { cropLeft, placeIdle } from './area'
8import { demoAt, demoStep, djDemoAt, idleDemoAt, workDemoAt } from './demo'
9import { type Director, heldShot, restarted, type Shot, tick } from './director'
10import { drawLayer } from './layer'
11import { DEEP_SLEEP_MS, DOZE_MS, dozeFrame, idleBackground, routineFrame, routineStill } from './idle'
12import { isDjOn, isFading, type Mod, type Picture, redance, reseat } from './mod'
13import { trackText } from './nowplaying'
14import { encodeCells, type PixelBuffer, type Point } from './pixels'
15import { type AreaSize, dissolve, FADE_MS, stillOf } from './scene'
16import { REACTIONS, SCENES } from './scenes'
17import { djFrame, djHead, type DjInput } from './scenes/dj'
18import { type Cast, composeFrame, heroOnScreen, sceneIn } from './toon'
19import { FALLBACK_WAITING, waitingFrame, type WaitingSceneId } from './waiting'
20import { calmView, isPresent } from './waiting/calm'
21import { waitingDemoAt } from './waiting/demo'
22import type { SeatLog } from './waiting/seats'
23import { type Activity, type Layer, layerAt, settleActivity } from './work'
24
25export type Log = (text: string) => void
26
27// The highest the hero's head may be for the whole layer to show, and for
28// the bubble alone (it takes the top two pixel rows and its tail the
29// third): above them (a climb, a seat up high) it would cover his face.
30export const LAYER_CLEAR_Y = 4
31export const BUBBLE_CLEAR_Y = 2
32
33// The part of the layer that fits over a head at row y: all of it, the
34// bubble alone, or nothing.
35export function layerAbove(layer: Layer, y: number): Layer | null {
36 if (y >= LAYER_CLEAR_Y) return layer
37 if (y >= BUBBLE_CLEAR_Y && layer.bubble !== null) return { ...layer, badge: null, what: '' }
38
39 return null
40}
41
42// A shot of the scene with the activity layer over it: the badge, label
43// and bubble above the hero's head, and his eyes up while Claude thinks.
44// The layer is as it was worked out for its frame: an outgoing picture
45// keeps it through its dissolve.
46export function sceneWithLayer(shot: Shot, size: AreaSize, cast: Cast, layer: Layer | null): PixelBuffer {
47 if (layer === null) return composeFrame(shot, size, cast)
48 const head = heroOnScreen(shot, size, cast)
49 const shown = layerAbove(layer, head.y)
50 const frame = composeFrame(shown?.badge === 'thinking' ? { ...shot, mood: 'think' } : shot, size, cast)
51 if (shown) drawLayer(frame, shown, head.x, head.y > BUBBLE_CLEAR_Y)
52
53 return frame
54}
55
56// A frame with the layer over a head at this point, as much of it as fits
57// (layerAbove); no mood: the head's own look stays.
58function withLayer(frame: PixelBuffer, head: Point, layer: Layer | null): PixelBuffer {
59 const shown = layer === null ? null : layerAbove(layer, head.y)
60 if (shown) drawLayer(frame, shown, head.x, head.y > BUBBLE_CLEAR_Y)
61
62 return frame
63}
64
65// The layer for a frame of the stage's scene, with the activity settled;
66// none while /stage bubbles is off; held still in calm mode.
67export function layerFor(activity: Activity, director: Director, isBubbles: boolean, now: number, isCalm = false): { activity: Activity; layer: Layer | null } {
68 const settled = settleActivity(activity, now)
69
70 return { activity: settled, layer: isBubbles ? layerAt(settled, director, now, isCalm) : null }
71}
72
73// The shot calm mode holds a director's scene at: the scene's still, with
74// no reaction (no jump, no flinch; its line still shows in the layer). A
75// broken scene holds the fallback's still, as the fallback is drawn.
76export function calmShot(d: Director, cast: Cast): Shot {
77 const scene = sceneIn(cast, d.base)
78
79 return { base: d.base, time: stillOf(scene), reaction: null }
80}
81
82// The mod's one Cast, built on the first frame. It keeps the first log it
83// was given: the hooks hand every frame the same one (register.tsx's
84// debugLog($)), and the frame tool makes a fresh mod per run.
85export function castOf(mod: Mod, log: Log): Cast {
86 mod.cast ??= {
87 scenes: SCENES,
88 reactions: REACTIONS,
89 broken: mod.broken,
90 onBreak: (id, error) => log(`stage-toons: scene ${id} failed and was replaced by the standing scene: ${String(error)}`),
91 }
92
93 return mod.cast
94}
95
96// An idle routine started at `since`, on the area: its box over its
97// background. It plays on past DOZE_MS (the doze is a picture of its own).
98// Calm, it holds the routine's still.
99function routinePicture(mod: Mod, name: string, routine: IdleId, since: number, log: Log, isCalm = false): Picture {
100 const onBreak = (id: string, error: unknown) => log(`stage-toons: idle routine ${id} failed and was replaced by classic: ${String(error)}`)
101 const idleMs = (now: number) => (isCalm ? routineStill(routine, mod.brokenIdle) * 1000 : now - since)
102
103 return {
104 name: isCalm ? `${name}:calm` : name,
105 draw: (now, size) => {
106 const box = routineFrame(routine, idleMs(now), mod.brokenIdle, onBreak)
107
108 return cropLeft(placeIdle(box, idleBackground(routine, idleMs(now), mod.brokenIdle, onBreak, size), size), size)
109 },
110 }
111}
112
113// The doze of an idle period started at `since` (its idle time, from 0);
114// calm, its still from the start.
115const dozePicture = (name: string, since: number, isCalm = false): Picture =>
116 isCalm
117 ? { name: `${name}:calm`, draw: (_now, size) => cropLeft(placeIdle(dozeFrame(DOZE_MS + DEEP_SLEEP_MS), null, size), size) }
118 : { name, draw: (now, size) => cropLeft(placeIdle(dozeFrame(now - since), null, size), size) }
119
120const waitingPicture = (name: string, log: SeatLog, scene: WaitingSceneId): Picture => ({ name, draw: (now, size) => cropLeft(waitingFrame(log, now, scene, size), size) })
121
122// The DJ picture (Clawd DJ): the dj scene drawn from what the mod knows,
123// on its own clock since it came on. While Claude works the director moves
124// on as the stage's scene would have it (Fable's audit, the reactions'
125// lines) and Clawd's pop-ups show over his head; he stays at the decks (no
126// jump, flinch or eyes up). Moving, it is one picture, `dj`, through track
127// changes (the scene draws its own); calm, its still is named by the track
128// and the dancers present, so it is drawn again when either changes.
129function djPicture(mod: Mod, now: number): Picture {
130 if (!mod.shown?.name.startsWith('dj')) mod.djSince = now
131 let layer: Layer | null = null
132 if (mod.isBandWorking) {
133 mod.director = tick(mod.director, now)
134 const settled = layerFor(mod.activity, mod.director, mod.isBubbles, now, mod.isCalm)
135 mod.activity = settled.activity
136 layer = settled.layer
137 }
138 const { djSince: since, djChangedAt: changedAt, dancers, isCalm, isDjParty: isParty } = mod
139 const np = mod.music.track
140 // Inside each frame's drawing, so a track that can't be written fails
141 // there, where areaCells takes the picture down.
142 const input = (t: number): DjInput => ({
143 track: np ? trackText(np) : null,
144 time: (t - since) / 1000,
145 now: t,
146 trackTime: (t - Math.max(since, changedAt ?? -Infinity)) / 1000,
147 sinceChange: changedAt === null ? null : (t - changedAt) / 1000,
148 dancers,
149 isCalm,
150 isParty,
151 })
152 let track: string | null = null
153 try {
154 track = np ? trackText(np) : null
155 } catch {
156 // Named without it: drawing it fails, where areaCells takes it down.
157 }
158 const present = dancers.entries.filter(e => isPresent(e, now)).map(e => e.agentId)
159 const name = isCalm ? `dj:calm:${track ?? ''}:${present.sort().join(',')}` : 'dj'
160
161 return { name, draw: (t, size) => withLayer(djFrame(input(t), size), djHead(input(t), size), layer) }
162}
163
164// /stage demo dj's picture: the demo's script on the DJ picture, from the
165// demo's start, with the party lights as /stage dj party has them.
166function djDemoPicture(demo: { at: number; seconds: number; kind: 'dj' }, isParty: boolean): Picture {
167 return {
168 name: `demo:${demo.at}:dj`,
169 draw: (t, size) => {
170 const f = djDemoAt(demo, t, size.columns)
171 const isChanged = t >= f.changedAt
172
173 return djFrame(
174 {
175 track: trackText(f.track),
176 time: (t - demo.at) / 1000,
177 now: t,
178 trackTime: (t - (isChanged ? f.changedAt : demo.at)) / 1000,
179 sinceChange: isChanged ? (t - f.changedAt) / 1000 : null,
180 dancers: f.dancers,
181 isCalm: false,
182 isParty,
183 },
184 size,
185 )
186 },
187 }
188}
189
190// What the area shows now, or null once the demo it plays is over: a demo,
191// else the stage's scene while Claude works, else the waiting toon while a
192// waiting period lasts, else the idle routine, else the doze. Each picture
193// is named (the name changes exactly when the picture changes to another)
194// and drawn from this frame's state. In calm mode (never for a demo) each
195// picture but a demo holds one frame, its name ending in :calm.
196function pictureOf(mod: Mod, now: number, log: Log): Picture | null {
197 const demo = mod.demo
198 if (demo?.kind === 'scenes') {
199 const frame = demoAt(demo, now)
200 if (!frame) return null
201 const start = now - frame.sceneMs
202 const cast = castOf(mod, log)
203
204 return { name: `demo:${demo.at}:${frame.index}`, draw: (t, size) => composeFrame({ ...frame.shot, time: (t - start) / 1000 }, size, cast) }
205 }
206 if (demo?.kind === 'idle') {
207 const f = idleDemoAt(demo, now)
208 if (!f) return null
209 const name = `demo:${demo.at}:${f.index}:${f.routine ?? 'doze'}`
210
211 return f.routine === null ? dozePicture(name, now - f.ms - DOZE_MS) : routinePicture(mod, name, f.routine, now - f.ms, log)
212 }
213 // The work demo: one picture, the stage's scene playing on from the
214 // demo's start under the layer of each step in turn.
215 if (demo?.kind === 'work') {
216 if (!demoStep(demo, now)) return null
217 const cast = castOf(mod, log)
218
219 return {
220 name: `demo:${demo.at}:work`,
221 draw: (t, size) => {
222 const f = workDemoAt(demo, t)
223
224 return sceneWithLayer(heldShot(f.director, t), size, cast, layerAt(f.activity, f.director, t))
225 },
226 }
227 }
228 if (demo?.kind === 'dj') return demoStep(demo, now) && !mod.isDjBroken ? djDemoPicture(demo, mod.isDjParty) : null
229 if (demo?.kind === 'waiting') {
230 if (!demoStep(demo, now)) return null
231 const at = waitingDemoAt(demo.at, now, demo.columns)
232
233 return 'log' in at ? waitingPicture(`demo:${demo.at}:waiting`, at.log, demo.scene) : routinePicture(mod, `demo:${demo.at}:idle`, mod.idle, now - at.idleMs, log)
234 }
235
236 // While Spotify plays in DJ mode, the DJ picture, whatever else would show.
237 if (isDjOn(mod)) return djPicture(mod, now)
238 // The stage's scene, with Fable's walk and the reactions, as a scene demo
239 // is drawn: cropped by the scene's camera. Coming back to it from another
240 // picture, it starts over.
241 const isCalm = mod.isCalm
242 if (mod.isBandWorking) {
243 mod.director = tick(mod.director, now)
244 if (!mod.shown?.name.startsWith('scene:')) mod.director = restarted(mod.director, now)
245 const d = mod.director
246 const cast = castOf(mod, log)
247 const { activity, layer } = layerFor(mod.activity, d, mod.isBubbles, now, isCalm)
248 mod.activity = activity
249 const name = `scene:${d.base}:${d.baseAt}`
250 if (isCalm) {
251 const held = calmShot(d, cast)
252
253 return { name: `${name}:calm`, draw: (_t, size) => sceneWithLayer(held, size, cast, layer) }
254 }
255
256 return { name, draw: (t, size) => sceneWithLayer(heldShot(d, t), size, cast, layer) }
257 }
258 if (mod.seats.since !== null && !mod.isWaitingBroken) {
259 const scene = mod.waiting.scene
260 // Calm: the frozen log, built here so that a failure in it is not
261 // taken for the waiting scene's (areaCells' fallback).
262 if (isCalm) {
263 const view = calmView(mod.seats, now)
264
265 return { name: `waiting:${mod.seats.since}:${scene}:calm:${view.present.join(',')}`, draw: (_t, size) => cropLeft(waitingFrame(view.log, view.now, scene, size), size) }
266 }
267 // With the helpers that left still in it, for the conveyor's marks.
268 const seats = { ...mod.seats, entries: [...mod.waiting.past, ...mod.seats.entries] }
269
270 return waitingPicture(`waiting:${mod.seats.since}:${scene}`, seats, scene)
271 }
272 if (now - mod.idleSince >= DOZE_MS) return dozePicture(`doze:${mod.idleSince}`, mod.idleSince, isCalm)
273
274 return routinePicture(mod, `idle:${mod.idle}:${mod.idleSince}`, mod.idle, mod.idleSince, log, isCalm)
275}
276
277// The area's next frame, at its size, or null once the demo it plays is
278// over. The seat log moves on first, whichever plays. When the picture
279// changes, the old one (as it was in its last frame, on its own clock)
280// dissolves into the new over FADE_MS; a change while a dissolve runs keeps
281// dissolving the same old picture.
282export function areaCells(mod: Mod, size: AreaSize, now: number, log: Log): string | null {
283 reseat(mod, now)
284 redance(mod, now, isDjOn(mod) && mod.demo === null)
285 let picture = pictureOf(mod, now, log)
286 // The demo is over: what was shown stays, to dissolve from next frame.
287 if (picture === null) return null
288 let current: PixelBuffer
289 // A waiting scene that throws gives way to the laptops, and the laptops
290 // to the idle routine: a cut, as the scene that threw can't be trusted to
291 // draw the dissolve's old picture.
292 let isCut = false
293 for (;;) {
294 try {
295 current = picture.draw(now, size)
296 break
297 } catch (error) {
298 // The DJ picture (or its demo) that throws gives way to the usual
299 // pictures for the rest of the load, its demo ending: a cut.
300 if (picture.name === 'dj' || picture.name.startsWith('dj:') || picture.name.endsWith(':dj')) {
301 mod.isDjBroken = true
302 log(`stage-toons: the DJ picture failed and was replaced by the usual pictures: ${String(error)}`)
303 const next = pictureOf(mod, now, log)
304 if (next === null) return null
305 picture = next
306 isCut = true
307 continue
308 }
309 if (!picture.name.startsWith('waiting:')) throw error
310 const { scene } = mod.waiting
311 if (scene !== FALLBACK_WAITING) {
312 // The laptops show for the rest of the period: the next is dealt another.
313 const deal = { ...mod.waiting.deal, last: FALLBACK_WAITING }
314 mod.waiting = { ...mod.waiting, scene: FALLBACK_WAITING, deal, broken: new Set([...mod.waiting.broken, scene]) }
315 log(`stage-toons: the waiting scene ${scene} failed and was replaced by the laptops: ${String(error)}`)
316 } else {
317 mod.isWaitingBroken = true
318 log(`stage-toons: the waiting toon failed and was replaced by the idle routine: ${String(error)}`)
319 }
320 picture = pictureOf(mod, now, log)!
321 isCut = true
322 }
323 }
324 if (isCut || (mod.fade !== null && !isFading(mod, now))) mod.fade = null
325 if (!isCut && mod.shown !== null && mod.shown.name !== picture.name && mod.fade === null) mod.fade = { from: mod.shown, at: now }
326 mod.shown = picture
327 if (mod.fade === null) return encodeCells(current)
328 let from: PixelBuffer
329 try {
330 from = mod.fade.from.draw(now, size)
331 } catch {
332 // Its failure was reported while it showed.
333 mod.fade = null
334
335 return encodeCells(current)
336 }
337
338 return encodeCells(dissolve(from, current, (now - mod.fade.at) / FADE_MS, mod.fade.at))
339}
340hooks/help.ts 141 lines1// /stage help: a guide to the mod, a page per feature, and the full list
2// of commands. Each command line is written once, with the topics it
3// belongs to; the list keeps the order /stage help always had.
4
5import type { Workflow } from '../types'
6import { DEMO_MAX, DEMO_MIN, DEMO_SECONDS, DJ_DEMO_SECONDS, WORK_DEMO_SECONDS } from './demo'
7import { LOG_DEFAULT } from './history'
8import { HISTORY_MAX } from './stages'
9import { STAGE_W } from './scene'
10import { WAITING_SCENE_IDS } from './waiting/deal'
11import { DEFAULT_WIDTH, WIDTH_MAX } from './width'
12import { DONE_ID, zeroStage } from './workflow'
13
14export const HELP_TOPICS = ['bar', 'auto', 'scenes', 'popups', 'waiting', 'idle', 'done', 'calm', 'width', 'dj', 'history'] as const
15export type HelpTopic = (typeof HELP_TOPICS)[number]
16
17export const isHelpTopic = (word: string | null): word is HelpTopic => (HELP_TOPICS as readonly (string | null)[]).includes(word)
18
19const TOPICS: Record<HelpTopic, { summary: string; about: string }> = {
20 bar: {
21 summary: 'the stages of your workflow, above the prompt',
22 about:
23 'The bar above the prompt shows where the work is in your workflow: the stage, the feature, a status message under it, and a count of Claude\'s tasks while it keeps a list. Claude moves it with its set_stage tool as the work moves on, and you can move it yourself. Side stages (Debugging, Other) can be entered from any point and left again. Progress is saved per project directory; a project can define its own stages in .claude/stage-toons.json.',
24 },
25 auto: {
26 summary: 'rules that move the bar by themselves',
27 about:
28 "Some steps are plain to see from what Claude does: loading a skill, writing a spec or a plan, starting a Fable subagent, a push to main that went through. The workflow's rules move the bar on them, only forward on the track, into Done or into a side stage, and only for the main conversation; Claude is told of each move. /stage workflow init writes the rules to .claude/stage-toons.json to edit.",
29 },
30 scenes: {
31 summary: "Clawd's cartoon of the stage while Claude works",
32 about:
33 'While Claude works, the area below the bar plays the stage\'s scene: 41 pixel-art scenes, a few per stage, dealt like a shuffled deck so each shows before any repeats. When a Fable subagent audits, a violet Clawd with a monocle walks on to review the work. Every change of picture dissolves into the next. The workflow picks each stage\'s scenes; /stage workflow lists them.',
34 },
35 popups: {
36 summary: "what Claude is doing, over Clawd's head",
37 about:
38 "Over Clawd's head the area shows what Claude is doing: thought puffs while it thinks, an icon and a label for the tool at work (auth.ts, npm test), a speech bubble while it writes the answer. At a few moments he says a line: All green!, Red tests., Committed!, Pushed!, Onward!. The lines are fixed: no model is asked for them.",
39 },
40 waiting: {
41 summary: 'helpers at work beside Clawd while agents run',
42 about: `When Claude's turn ends while agents still work (background subagents, a Fable audit, teammates), Clawd steps aside with a coffee and a helper works for each agent, in its model's colour: Sonnet magenta, Opus orange, Fable violet, Haiku teal. Each wait plays one of ${WAITING_SCENE_IDS.length} scenes (${WAITING_SCENE_IDS.join(', ')}); a helper walks off when its agent finishes.`,
43 },
44 idle: {
45 summary: 'Clawd between turns',
46 about:
47 "When Claude isn't working, Clawd stands at the area's left in one of five routines (classic, Nyan Clawd, coffee, reading, yo-yo), picked each turn; after a minute he sits down and dozes. After the work moves forward on the track, he shows off the finished step's prop once: a map, a light bulb, a scroll, a hammer...",
48 },
49 done: {
50 summary: 'the feature merged and finished',
51 about:
52 "Past the track's last stage is Done: the track lights up, the bar reads ✓ Done, one of Done's scenes plays (fireworks, a rocket, a victory lap) and Clawd celebrates under confetti between turns. A push to main or gh pr merge moves there by itself. The next move starts a new feature; a move back to the stage Done came from reopens it.",
53 },
54 calm: {
55 summary: 'cartoons that hold still',
56 about:
57 'Calm mode holds the cartoons still: each picture is one held frame, and the area changes only when something happens (Claude starting or stopping, a move, a helper arriving), dissolving into the next still. The pop-ups stay, held still, and the demos move anyway. For a quieter screen or a slow terminal.',
58 },
59 width: {
60 summary: 'how wide the area under the bar grows',
61 about: `The area under the bar is as wide as the band, up to ${DEFAULT_WIDTH} columns by default (${STAGE_W} to ${WIDTH_MAX}, or the band's whole width), and 7 to 9 rows high. Scenes keep their picture at the left and spread their set to the right edge. In a terminal under 35 columns, or too short, the bar shows as text alone.`,
62 },
63 dj: {
64 summary: 'Clawd at the decks while Spotify plays',
65 about:
66 "Clawd DJ: while Spotify plays, Clawd bobs behind a booth with two turntables, the helpers of agents at work dance on the floor, and from 72 columns a box names the track. It reads Spotify on this computer (Windows' media session, osascript on macOS, playerctl on Linux), with no account or token; the track isn't saved or sent to Claude. The party lights are off by default, for long sessions.",
67 },
68 history: {
69 summary: 'moves, time and cost per stage',
70 about: `Each move is kept with its feature, who made it (Claude, you, or auto and why) and how long Claude worked, for the last ${HISTORY_MAX} moves. /stage log lists the recent ones with how long each stage lasted; /stage time totals wall-clock and Claude's working time per feature and per stage, with what the work cost in dollars and tokens.`,
71 },
72}
73
74export type CommandLine = { topics: HelpTopic[]; text: (w: Workflow) => string }
75
76const line = (topics: HelpTopic[], text: string | ((w: Workflow) => string)): CommandLine => ({ topics, text: typeof text === 'string' ? () => text : text })
77
78export const COMMAND_LINES: CommandLine[] = [
79 line(['bar'], ' /stage show the current stage and the recent moves'),
80 line(['bar'], w => {
81 const zero = zeroStage(w)
82
83 return ` /stage 5, /stage plan move to a stage${zero ? ` (0 or ? for ${zero.label})` : ''}`
84 }),
85 line(['bar', 'done'], ' /stage next, /stage back'),
86 line(['done'], ' /stage done the feature is finished (merged); the next move starts a new one'),
87 line(['bar'], ' /stage feature <name> name the feature (/stage feature - clears it)'),
88 line(['bar'], ' /stage message <text> set the status message'),
89 line(['bar'], ' /stage hide hide or show the bar and the cartoons'),
90 line(['scenes', 'idle'], ` /stage demo [<stage> | idle] [seconds] play every scene, one stage's scenes, or the idle routines, ${DEMO_SECONDS} s each (${DEMO_MIN} to ${DEMO_MAX}); /stage demo stop ends it`),
91 line(['waiting'], ` /stage demo waiting [scene] play the toon shown while Claude waits on agents, about 25 s, in one of its scenes (${WAITING_SCENE_IDS.join(', ')}), or the next one dealt`),
92 line(['popups'], ` /stage demo work [seconds] play what Clawd shows while Claude works: thinking, each kind of tool, his lines, ${WORK_DEMO_SECONDS} s each`),
93 line(['dj'], ` /stage demo dj [seconds] play Clawd DJ: a long title, a track change, two helpers dancing, ${DJ_DEMO_SECONDS} s`),
94 line(['bar', 'auto'], ' /stage workflow [init | reload] show the workflow; write the default to .claude/stage-toons.json; read the file again'),
95 line(['auto'], " /stage auto [on | off] show the workflow's rules for moving the stage by itself; turn them on or off"),
96 line(['popups'], " /stage bubbles [on | off] whether Clawd's pop-ups show: what Claude is doing over his head, and what he says"),
97 line(['calm'], ' /stage calm [on | off] whether the cartoons hold still, changing only when something happens'),
98 line(['dj'], " /stage dj [on | off] Clawd DJ: while Spotify plays, Clawd at the decks and the track's name (Windows, macOS, Linux)"),
99 line(['dj'], " /stage dj party [on | off] the DJ's full light show: speakers, floor, booth and box lighting up with the beat (off by default)"),
100 line(['width'], ` /stage width [<n> | full] how wide the cartoons may grow under the bar (${DEFAULT_WIDTH} columns by default, ${WIDTH_MAX} at most)`),
101 line(['history'], ` /stage log [n] the last n moves (${LOG_DEFAULT}; up to ${HISTORY_MAX}) with how long each stage lasted`),
102 line(['history'], ' /stage time time per feature and per stage, wall-clock and Claude working'),
103 line([], ' /stage help the guide: /stage help <topic> for one feature, /stage help commands for this list'),
104]
105
106// /stage help commands: every command, and the workflow's stages.
107export function usage(w: Workflow): string {
108 const zero = zeroStage(w)
109 const stages = [...w.track.map((s, i) => `${i + 1} ${s.id}`), DONE_ID, ...w.side.map(s => (s === zero ? `0 ${s.id}` : s.id))]
110
111 return [
112 'Usage: /stage [n | id | next | back | feature <name> | message <text> | hide | demo [<stage> | idle | work | dj] [seconds] | demo waiting [scene] | demo stop | workflow [init | reload] | auto [on | off] | bubbles [on | off] | calm [on | off] | dj [on | off | party [on | off]] | width [<n> | full] | log [n] | time | help [<topic> | commands]]',
113 ...COMMAND_LINES.map(l => l.text(w)),
114 `Stages: ${stages.join(', ')}`,
115 ].join('\n')
116}
117
118// /stage help [topic]: the overview, a topic's page (with now, what the
119// feature's own command answers, when it has a setting), the commands, or
120// the topics for a word that names none.
121export function helpText(w: Workflow, topic: string | null, now?: string): string {
122 if (topic === 'commands') return usage(w)
123 if (topic === null) {
124 const pad = Math.max(...HELP_TOPICS.map(id => id.length)) + 2
125
126 return [
127 // The engine puts the plugin's name before the answer.
128 'A progress bar of your workflow above the prompt, and Clawd acting out the work in the area below it.',
129 '',
130 ...HELP_TOPICS.map(id => ` ${id.padEnd(pad)}${TOPICS[id].summary}`),
131 '',
132 '/stage help <topic> for more on one; /stage help commands for every command.',
133 ].join('\n')
134 }
135 if (!isHelpTopic(topic)) return `No help topic "${topic}". Topics: ${HELP_TOPICS.join(', ')}, commands.`
136 const commands = COMMAND_LINES.filter(l => l.topics.includes(topic)).map(l => l.text(w))
137 const state = now === undefined ? [] : ['', 'Now:', ...now.split('\n').map(l => ` ${l}`)]
138
139 return [TOPICS[topic].about, '', ...commands, ...state].join('\n')
140}
141hooks/idle/index.ts 131 lines1// The idle Clawd at the bottom left of the area below the bar while Claude
2// waits for you: 26 columns by 4 rows (26 x 8 pixels). Awake, he plays one routine, picked each time a
3// turn starts for the idle period after it; after a minute every routine
4// turns into the same doze.
5
6import type { IdleId } from '../../types'
7import { pickOne } from '../pick'
8import { createBuffer, type PixelBuffer } from '../pixels'
9import { type AreaSize, areaBuffer, STAGE_SIZE } from '../scene'
10import { CELEBRATE, celebrate } from './celebrate'
11import { classic } from './classic'
12import { coffee } from './coffee'
13import { DEEP_SLEEP_MS, DOZE_MS, drawDoze, STILL_T } from './doze'
14import { nyan } from './nyan'
15import { reading } from './reading'
16import { PROPS } from './props'
17import type { IdleRoutine } from './routine'
18import { SHOW_OFF_PREFIX, showOff, showOffId } from './show-off'
19import { yoYo } from './yo-yo'
20
21export { CLAWD_X, DEEP_SLEEP_MS, DOZE_MS, Z_COLOR } from './doze'
22export type { IdleRoutine } from './routine'
23export { CELEBRATE } from './celebrate'
24export { SHOW_OFF_PREFIX, showOffId } from './show-off'
25
26export const IDLE_COLS = 26
27export const IDLE_ROWS = 4
28// The routines as written: `satisfies` checks the table but keeps its own
29// type, so its exact keys make RoutineName.
30const LIBRARY = { classic, nyan, coffee, reading, 'yo-yo': yoYo } satisfies Record<IdleId, IdleRoutine>
31
32// A routine's name in the library, checked by tsc. Names read from state
33// stay IdleId (any string).
34export type RoutineName = keyof typeof LIBRARY
35
36export const FALLBACK_ROUTINE: RoutineName = 'classic'
37// The library, indexed by any string (a saved name may be unknown).
38export const ROUTINES: Record<IdleId, IdleRoutine> = LIBRARY
39export const IDLE_IDS: readonly IdleId[] = Object.keys(ROUTINES)
40
41export const pickIdle = (previous: IdleId | undefined, random: number): IdleId => pickOne(IDLE_IDS, previous, random)
42
43// The routine an id names: the library's, the celebration, or the show-off
44// of a stage's prop; undefined for an unknown id. The last two are never
45// picked at random: a move sets them (register.tsx).
46export function routineOf(id: IdleId): IdleRoutine | undefined {
47 if (Object.hasOwn(ROUTINES, id)) return ROUTINES[id]
48 if (id === CELEBRATE) return celebrate
49 if (id.startsWith(SHOW_OFF_PREFIX)) return showOff(id.slice(SHOW_OFF_PREFIX.length))
50
51 return undefined
52}
53
54// The show-offs /stage demo idle cycles through: each prop of the default
55// track's stages, then the generic one (a stage id no prop has).
56export const SHOW_OFF_DEMO: readonly IdleId[] = [...Object.keys(PROPS), '*'].map(showOffId)
57
58const drawn = (r: IdleRoutine, idleMs: number): PixelBuffer => {
59 const b = createBuffer(IDLE_COLS, IDLE_ROWS * 2)
60 r.draw(b, (idleMs / 1000) % r.loop)
61
62 return b
63}
64
65// The box idleMs into an idle period: the routine, or the doze after
66// DOZE_MS. A routine that throws is replaced by classic from then on
67// (broken), and reported once (onBreak). For the tests: the mod places
68// the routine in the area (frames.ts).
69export const idleFrame = (routine: IdleId, idleMs: number, broken: Set<IdleId> = new Set(), onBreak?: (id: IdleId, error: unknown) => void): PixelBuffer =>
70 idleMs >= DOZE_MS ? dozeFrame(idleMs) : routineFrame(routine, idleMs, broken, onBreak)
71
72// The doze's box idleMs into an idle period (DOZE_MS or more): still once
73// DEEP_SLEEP_MS has passed.
74export function dozeFrame(idleMs: number): PixelBuffer {
75 const b = createBuffer(IDLE_COLS, IDLE_ROWS * 2)
76 drawDoze(b, idleMs >= DOZE_MS + DEEP_SLEEP_MS ? STILL_T : Math.max(0, idleMs - DOZE_MS) / 1000)
77
78 return b
79}
80
81// The routine's box idleMs into an idle period, however long: a routine
82// dissolving into the doze plays on.
83export function routineFrame(routine: IdleId, idleMs: number, broken: Set<IdleId> = new Set(), onBreak?: (id: IdleId, error: unknown) => void): PixelBuffer {
84 const found = broken.has(routine) ? undefined : routineOf(routine)
85 const id = found === undefined ? FALLBACK_ROUTINE : routine
86 try {
87 return drawn(found ?? ROUTINES[FALLBACK_ROUTINE]!, idleMs)
88 } catch (error) {
89 broken.add(id)
90 onBreak?.(id, error)
91
92 return drawn(ROUTINES[FALLBACK_ROUTINE]!, idleMs)
93 }
94}
95
96// The moment calm mode holds a routine at, in seconds: its still, else half
97// its loop; classic's for an unknown or broken one (what routineFrame
98// draws in its place).
99export function routineStill(routine: IdleId, broken: Set<IdleId> = new Set()): number {
100 const r = (broken.has(routine) ? undefined : routineOf(routine)) ?? ROUTINES[FALLBACK_ROUTINE]!
101
102 return r.still ?? r.loop / 2
103}
104
105// The routine's background on the whole area idleMs into an idle period,
106// drawn in the core's coordinates (its origin at the area's bottom left),
107// or null: a routine with none, an unknown or broken one. A background
108// that throws breaks its routine like its drawing would.
109export function idleBackground(
110 routine: IdleId,
111 idleMs: number,
112 broken: Set<IdleId> = new Set(),
113 onBreak?: (id: IdleId, error: unknown) => void,
114 size: AreaSize = STAGE_SIZE,
115): PixelBuffer | null {
116 if (broken.has(routine)) return null
117 const background = routineOf(routine)?.background
118 if (background === undefined) return null
119 const b = areaBuffer(size)
120 try {
121 background(b, idleMs / 1000)
122 } catch (error) {
123 broken.add(routine)
124 onBreak?.(routine, error)
125
126 return null
127 }
128
129 return b
130}
131hooks/keepsake.ts 23 lines1// The idle routine a move calls for: Clawd marks the step just finished in
2// the next idle period. Into Done he celebrates; out of Done (a new round,
3// or a reopen) a random routine takes over, so the celebration ends with
4// Done; forward on the track he shows off the prop of the step finished.
5
6import type { IdleId, Progress, Workflow } from '../types'
7import { CELEBRATE, pickIdle, showOffId } from './idle'
8import { isAdvance } from './stages'
9import { isDone, isTrack } from './track'
10
11// The routine for the next idle period, or null to leave the pick as it
12// is. The step finished is the track stage left, or, from a side stage, the
13// track stage it was entered from. previous: the routine on show, which a
14// random pick leaves out.
15export function idleForMove(w: Workflow, before: Progress, after: Progress, previous: IdleId | undefined, random: number): IdleId | null {
16 if (isDone(after.stage)) return isDone(before.stage) ? null : CELEBRATE
17 if (isDone(before.stage)) return pickIdle(previous, random)
18 if (!isAdvance(w, before, after)) return null
19 const left = isTrack(w, before.stage) ? before.stage : isTrack(w, before.lastTrackStage) ? before.lastTrackStage : null
20
21 return left === null ? null : showOffId(left)
22}
23