SLOPSHOPPER

stage-toons

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

new
v1.0.0MITupdated 2026-10-10wmaq/wmaq-claude-mods/stage-toons
A shopper browsing a rack in a slop shop
Preview could not run: harness produced no result (11 | import { blinking, C, lerp, reach, hash as seededHash } from './kit' ^ error: No matching export in "slop-empty:/home/runner/wo
README

stage-toons

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

stage-toons: the bar above the prompt, and Clawd at work below it

TL;DR

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
  • Claude moves the bar through your workflow (roadmap → brainstorm → spec → plan → develop → testing → cleanup → Done, or your own), and it moves by itself on plain steps: a skill loaded, a spec written, a merge pushed.
  • Clawd lives below it: 41 scenes while Claude works, with what Claude is doing over his head; an idle Clawd between turns; a row of helpers while agents work; a violet Fable Clawd for audits; Clawd DJ while Spotify plays.
  • Local and cheap: no model requests, about 270 tokens of system prompt.
CommandWhat it does
/stage helpA guide to every feature, one topic at a time
/stage demoPlay every scene now (/stage demo waiting, /stage demo dj, ...)
/stage next, /stage plan, /stage doneMove the bar yourself
/stage calm onHold the cartoons still
/stage dj onClawd DJ while Spotify plays
/stage log, /stage timeHow 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.

The stages

#StageWhat it means
1RoadmapRoadmap discussion and new-feature brainstorming
2BrainstormBrainstorming one feature
3SpecWriting the spec
4Spec auditA Fable subagent audits the spec
5PlanWriting the implementation plan
6Plan auditA Fable subagent audits the plan
7DevelopBuilding it
8Claude testingClaude verifies the work
9Your testingYou test it
10CleanupLeftovers, chores and docs

Beside the track are two side stages, which the work can enter from any point and leave again:

StageWhat it means
DebuggingFinding and fixing a bug, at any point (/stage debugging)
OtherAnything 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).

How the bar moves

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:

CommandEffect
/stageShow the stage and the recent moves
/stage 5, /stage planMove to a stage (0 or ? for Other)
/stage debuggingMove to a side stage by its id
/stage next, /stage backOne step (next from the last stage is Done; from Done, a new round)
/stage doneThe 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 hideHide 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 stopEnd the demo
/stage workflowShow the workflow, where it came from, and each stage's scenes
/stage workflow initWrite the default workflow to .claude/stage-toons.json to edit
/stage workflow reloadRead .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 timeTime 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.

By itself

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:brainstormingBrainstorm
writes a file under docs/superpowers/specs/Spec
starts a Fable subagent during Brainstorm or SpecSpec audit
loads superpowers:writing-plans, or writes under docs/superpowers/plans/Plan
starts a Fable subagent during PlanPlan audit
loads superpowers:executing-plans or superpowers:subagent-driven-developmentDevelop
loads superpowers:finishing-a-development-branchCleanup
pushes to main or master, or runs gh pr merge, during Your testing or CleanupDone
loads superpowers:systematic-debuggingDebugging

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

History

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.

While Claude works

The area plays the stage's scene, and over Clawd's head it shows what Claude is doing right now:

  • thinking: thought puffs rise over his head, and he looks up;
  • a tool at work: a small icon for the kind of work (searching, reading, editing, testing, building, git, running a command, the web, agents) with a label naming what it touches (auth.ts, npm test); it stays a moment after the tool ends, so quick calls in a row don't flicker;
  • writing the answer: a speech bubble filling with dots.

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.

While Claude waits for you

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.

While Claude waits on agents

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.

Calm mode

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

Clawd DJ

Clawd DJ: Clawd at the decks, the track's name by the mirror ball, the speakers and the dance floor

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

  • Windows: through Windows' media session with Windows PowerShell (powershell.exe, part of Windows). Spotify tells Windows only a track's first artist, so a track by two shows the first.
  • macOS: through 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.
  • Linux: through 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 ?.

Your own workflow

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

Source 91 files
hooks/register.tsx 1448 lines
1// 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  })
1200
hooks/activity.ts 26 lines
1// 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
26
hooks/agents.ts 97 lines
1// 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)
97
hooks/auto.ts 341 lines
1// 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(', ')}` : ''}`
341
hooks/band.ts 169 lines
1// 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}
169
hooks/command.ts 290 lines
1// /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}
290
hooks/demo.ts 243 lines
1// /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}
243
hooks/director.ts 96 lines
1// 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})
96
hooks/frames.ts 340 lines
1// 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}
340
hooks/help.ts 141 lines
1// /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}
141
hooks/idle/index.ts 131 lines
1// 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}
131
hooks/keepsake.ts 23 lines
1// 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