SLOPSHOPPER

mize-coworker

Claude as a small coworker in the terminal: an orange pixel-art Claude, two rows tall, in 132 frames. While a turn runs he sits beside the spinner in a scene…

newbandspinnercommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mize-coworker
› fix the failing auth test and add an audit log call ● mize-coworker: sprite on, narration on, animation on ● mize-coworker: Claude is done with the turn (busy for 0s) ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /coworker ● mize-coworker: /coworker demo plays every animation once, about two minutes, in the band above the prompt ▣ ⢼⢽⠿⡯⡧ ✻ Thinking… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Spinner
▣ ⢼⢽⠿⡯⡧ ✻ Thinking…
README

mize-coworker

Claude as a small coworker in the terminal. An orange pixel-art Claude (the Claude Code mascot: wide body, two eyes, arm nubs, four legs; 8 cells by 2 rows, or 4 by 1 with the big option off) sits at the right end of the status line row (the engine's SessionMode site, after its mode labels) and lives there: he breathes, blinks, strolls a few cells, looks around, hops, yawns and sips a coffee; he sweats when the context window is nearly full, eyes an hourglass when a usage window runs hot, sits by a glowing pumpkin in the last week of October, and falls asleep after 10 idle minutes, frosted once the prompt cache has gone cold. While the main session rests and agents or workflows work in the background he minds them: at ease, with a skit every few seconds (a rocket as they start, a score paddle with how many are at work, a radar, a headset, a helper bringing a finished agent's report, juggling, bubble gum, popcorn, a paper plane that comes back, a plant that grows the longer they run). While the main loop's turn runs he moves up beside the thinking spinner, left of its whole line, in a scene of 12 cells by 2 for every spinner word: a thought bubble filling with dots while Thinking, a pen on a notepad while Writing, a book whose pages turn, a magnifying glass sweeping lines of text, the same glass over a turning globe while Searching the web, a globe alone while Browsing, a laptop, a terminal printing output, one to three small helpers for the agents in flight, a speech bubble when he needs you, a gear while Working, a scroll unrolling while Loading a skill, a plug going into its socket while Calling an MCP server. When the turn ends he comes back down with a hop, or a cheer after a long one. The main loop's spinner word also changes from the random verb to what is happening: Reading register.ts, Running npm, Browsing docs.example.com. /coworker demo plays every animation once, about two minutes, in the band above the prompt.

The mod only draws. It runs no process, keeps nothing in $.store, makes no model call and no network call, and adds nothing to the model's context. It never changes a tool call. It writes one small file per session, the cells it needs on the status row, and one machine-wide file, the default of those cells for the next session's start (The status row); it reads that default before writing it, and the session's vitals the status line writes (Vitals).

Tested on Claude Code 2.1.288: claude plugin test (190 tests: the frame table, every loop step by step and the solo frame the footer shows for it, the scene every spinner word and mode puts beside the spinner and the footer that never swaps, the footer and spinner mounted on the terminal surface through the engine, the scenes and scenes off, the done gesture's two forms, the team of one to three, the breath and the three-phase blink on a mock clock, the sleep loop and its cold form, the vitals parser, the idle moves on a mock clock and a seeded generator with each vitals- and date-gated move appearing only when its condition holds, the activity model's agents at work (the stop report by task type, the quiet, the count, the open-turn rest), the skits and their mix, twelve minutes of minding agents through the engine (the send-off, at least eight different skits, the count on the paddle, never a hop), a background shell that keeps nothing busy, the demo tour step by step through the engine and its band, the default reserve, and a crash-safety suite whose sentinel plugin reads next.trace and fails on any hook of this mod the engine skipped, the band's included), tests/test_frames.py (the table against scripts/frames.json and every PNG's size; claude plugin test has no file system, see Frames), claude plugin validate --strict, tsc, a headless claude -p "/coworker" run, and live interactive sessions driven in a pseudo-terminal (0.5.0, at 120 columns: /coworker demo drew the band above the prompt, ⢼⢿⠿⡿⡧ Writing · write3 3 s in and ⢼⢯⠿⡯⡧ Searching the web · webSearch1 9 s in, the label 13 cells in, the width of his seat; /coworker demo again took it away; the status row and the auto mode on row under it stayed adjacent throughout; the session start wrote 22 11 110 to the default reserve; 0.4.0, at 100 columns: the alt beside the spinner, 13 cells in). The pictures were checked by hand in Ghostty 1.3.1 (kitty graphics); the pty harness (scripts/tui-capture.py) has no graphics protocol and shows the alt.

What it shows

focus · reading [Claude]

The engine's own labels come first, dim and joined by & as the engine draws them. After them come a dim caption and Claude. Claude is the terminal's Image element, a PNG frame drawn over a box of 8 cells by 2 rows where the terminal speaks the kitty graphics protocol (Ghostty, kitty). The box spans the status row and the row under it, the engine's permission-mode row (auto mode on), which is short and leaves that space empty: no row is added to the footer, and the status line and the mode row keep their places. The caption, the z and the blank cells to his right sit on the first row, the status row. With the big option off the box is 0.2.0's 4 cells by 1 row. Where the picture cannot be drawn, the engine draws its alt in its place, dim: the 5-cell braille of the frame's pose (⢼⢯⠿⡽⡧). With the picture option off, Claude is that braille as text, in the same orange. Asleep, a z follows him in #7d5a50 (the braille the same color). With no labels the row is reading [Claude]. Right of Claude are the blank cells his wander has put there (below). The row is one line and stays inside the cells the mod reserves (The status row). In a terminal narrower than 110 columns the caption is left out and Claude alone is drawn.

The footer always draws a solo frame (Claude alone, 8 by 2). Beside the spinner (below) a busy activity is a scene, 12 by 2; the footer draws that scene's solo frame in its place, so the same loop reads as Claude alone there (eyes up while thinking, eyes down while reading, a scuttle while running).

activitywhencaptionloop beside the spinner (one step per 250 ms)in the footer
thinkingthe main turn runs and no tool call is in flightthinkingthink1 x2, think2 x2, think3 x4: the thought bubble fills with one, two, three dots. By the spinner's mode, step for step: responding (Writing) write1-3, a pen writing on a notepad; tool-input type1-3, the laptop; tool-use (Working) work1, work2, work1, the gearlookUp, idle
readingRead, NotebookReadreadingread1 x3, read2 x3, read1 x2, read2 x2, read3 x2: the left page, the right page, a page turninglookDown, blink
searchingGrep, Glob, ToolSearch, LSsearchingsearch1 x2, search2 x2, search3 x2, search2 x2: the magnifying glass over the first, middle and last linelookL, idle, lookR
editingEdit, Write, NotebookEdit, MultiEditeditingtype1, type2, type1, type2, type3, type2: at the laptop, the line of code growingarmsIn, idle
runningBash, BashOutput, Monitorrunningrun1 x2, run2 x2, run3 x2: a terminal printing outputstepA, stepB
browsingWebFetch, WebSearchbrowsingweb1 x2, web2 x2, web3 x2: the globe turning. Searching the web (WebSearch): webSearch1-3, the magnifying glass sweeping the turning globelookR, idle
delegatingAgent, Task, Workflow, SendMessagedelegatingteamNa x2, teamNb x2: N small helpers bobbing, one per delegating call in flight, 1 to 3lookR, idle (a look toward the helpers: the scene's own hop2 is never looped without them, on the status row or beside the spinner with scenes off)
askingAskUserQuestion, or a permission dialog, until that loop's call ends or its turn doesneeds youask1 x2, ask2 x2: a yellow speech bubble with an exclamation mark, Claude wavingwave1, wave2
workingany other tool (MCP tools, Skill, TodoWrite, ...)workingwork1 x2, work2 x2: a gear turning. Loading a skill (Skill): skill1-2, a scroll unrolling; Calling <server> (an MCP tool): plug1-2, a plug going into its socketidle, blink
greetinga session opens (2 s)hiwave1 x2, wave2 x2, wave1 x2, wave2 x2the same
donethe main turn ends with an answer; none after an interrupt or an errordonea turn under 2 minutes: hop1, hop2, hop3, idle (1.5 s). Two minutes or more: cheer1, cheer2, cheer3, cheer2, cheer3, idle (2 s), confetti. Each plays once and holds its last framethe same
oopsone of the main loop's own tool calls fails (1.25 s, at most once in 10 s)oopsflinchthe same
supervisingthe main loop rests (no turn, or a turn left open with no spinner drawn) and agents or workflows are at work in the backgroundN agents(footer only) at ease as when idle: the breath, the blink, and a skit every 4 to 10 s (Minding agents)
idleno turn and no agent at work in the backgroundnone(footer only) idle and idleUp in turn every 2 s, a blink every 6 s (blinkHalf 100 ms, blink 200 ms, blinkHalf 100 ms), and the moves (Idle life)
asleep10 minutes idlez after the sprite(footer only) sleep1, sleep2, sleep3, one frame every 3 s; sleepCold1, sleepCold2 while the prompt cache is cold

The done gesture's length comes from turn.complete's durationMs, else from the turn start the mod saw. Delegating counts the delegating calls in flight from any loop (classic.PreToolUse carries no agent id), so a workflow agent's own Agent call adds a helper too.

What it shows, in order. A permission ask comes first. Then, while the main loop is at work (its turn runs and its spinner is drawn): the most recently started tool call still in flight, else a gesture still running, else thinking. While it rests (no turn, or a turn left open with no spinner drawn, as between a /goal's iterations): a gesture still running (the hop at the turn's end plays out), else supervising while agents are at work in the background, else the most recently started call in flight, else thinking while the turn is open, else the main loop's own last tool event if it came in the last 8 s and after its turn ended, else idle, and asleep after 10 idle minutes.

  • Classic tool events fire for subagents and workflow agents too, so while the main turn runs Claude mirrors the session and its agents together. Once the main loop rests he does not mirror them: he minds them.
  • Permission asks. An ask is recorded only when nothing beneath decided it (so a dialog really opens), and it belongs to the loop and tool that raised it: another loop's tool events leave it alone. The API has no event for an answered dialog, so needs you lasts until that loop's call ends (done, failed or denied) or its turn does, which for an approved call means through its run.
  • Background work. When the main loop stops, the engine says what still runs in the background (classic.Stop's background_tasks), and the mod reads each task by its type alone: a shell or a monitor only runs and keeps nothing busy (he idles, and sleeps, with a glance at a small terminal among his idle moves); any other type (a subagent, a workflow) is agent work. Agents are at work while a stop reported some or one was heard from (a tool event with its agent_id, or a call that starts with no main turn running, which can only be an agent's) within the last 10 minutes, or a call of one is in flight; an agent's own end (turn.complete with its agentId) and a main stop that reports none take that back at once. Until 0.6.0 any background task, a shell included, showed as delegating, whose footer frames were a hop and a stand alternating every half second for as long as the task ran: he looked stuck, hopping in the corner.

Frames

There are 132 picture frames, named in scripts/frames.json (the frame contract): 94 solo frames (kind: "solo", 70x40 logical pixels, drawn in 8x2 cells, anywhere) and 38 scenes (kind: "scene", 105x40, 12x2 cells, Claude at the left exactly as in a solo frame and a prop at the right, drawn only beside the spinner and in the demo). Each scene names the solo frame the footer shows in its place; each solo frame names the braille pose that stands in for it. 0.5.0 added blinkHalf (the blink's half-shut lids) and the scenes the spinner words swap in: write1-3, webSearch1-3, skill1-2, plug1-2. 0.6.0 added 59 solo frames, the skits of a Claude minding background agents and the peek at a background shell: tally0-9 and tallyMany, radar1-4, radio1-3, report1-4, launch1-3, perch1-2, conduct1-3, juggle1-3, gum1-3, popcorn1-2, plane1-5, zen1-2, plant1-4, water1-4, lantern1-2, lunch1-2, term1-2. Wherever a scene cannot be drawn (the footer, or beside the spinner with scenes or big off) its stand-in is footerOf(frame): the scene's own solo frame, except for the team scenes (built on the hop), which stand in by a look to the right.

  • The pictures are assets/frames/<name>.png, drawn by scripts/make-frames.py (Pillow): one parametric renderer for Claude and one function per prop, exported at 4x as hard pixels, so the terminal's own scaling keeps the edges crisp. Edit a frame there, run it, look at the sheets it writes with --sheet <dir>, and commit the PNGs.
  • The code names frames by name: hooks/lib/sprite.ts mirrors the table (SOLO_FRAMES, SCENE_FRAMES, one entry per line), and the view in $.state carries the frame's name beside its braille. A frame is never looked up by its braille (several frames share a pose).
  • The braille is the twelve 0.1.0 poses as pixel rows (PIXELS: idle, blink, lookL, lookR, focus, armsIn, stepA, stepB, hop, wave, flinch, sleep), composed when the module loads: the picture's alt, and the picture: false drawing.
  • tests/test_frames.py holds the TS table equal to frames.json kind by kind (its solo frames in order, then its scenes in order; the JSON may interleave the kinds), and checks every PNG exists at its kind's size (width and height times scale). It runs outside the engine because claude plugin test gives a test no file system: python3 -m unittest discover -s plugins/mize-coworker/tests -p 'test_*.py' from the repo root. tests/lib.test.ts checks the table's own sense: 132 distinct names, every scene's solo frame a solo frame, every frame drawn by some loop, swap, move, the breath or the blink.

Beside the spinner

While the main loop's turn runs (between its turn.start and turn.complete), Claude is drawn in the main loop's Spinner site instead of the footer: the frame and a space, then the engine's own spinner line, whole (glyph, word, elapsed time, tokens). The hook places await next(e), a reference to the engine's own drawing, as a child of its own row:

[  Claude + prop  ]
[  Claude + prop  ] ✳ Reading register.ts… (3s · ↓ 11 tokens)

A busy activity there is its scene, an Image of columns={12} rows={2}; a solo frame (the flinch) is columns={8} rows={2}. His seat is a scene's width and a space (13 cells) whatever the frame, so the spinner line keeps its place when a solo frame comes between two scenes. With the scenes option off, every frame there is the solo frame, 8 by 2, in a seat of its own width. With big off, the solo frame in 4 by 1, stepped one row down to the line.

Every spinner word has its own scene. The Spinner hook hands sceneFor(frame, word, mode) (hooks/lib/sprite.ts) the frame the loop is on, the word it narrates and the spinner's mode, and draws what comes back, step for step: the thought bubble becomes the notepad while responding (Writing), the laptop while tool-input (the model writing a tool call's input) and the gear while tool-use (Working: work1, work2, work1); the globe takes the magnifying glass for Searching the web; the gear becomes the scroll for Loading a skill and the plug for Calling <server>. Every other frame, word and mode stays as it is. The swap is the spinner's drawing alone: the state keeps the loop's own frame, the footer draws that frame's solo frame, and the alt is the braille of the drawn scene's solo frame. The word comes from the doing, written whenever Claude is drawn, so with narrate off the scene still follows what is happening while the spinner keeps the engine's word. The redraws stay paced by the loop's own frames, so the longest wait between two is unchanged (the tool-use gear holds work1 six ticks across the loop's wrap, 1.5 s, inside the 2.5 s grace).

The seat is held by the spinner drawing him, not by the turn alone: the spinner redraws him at the frame rate, and the footer, which follows the frames too, takes him back once 2.5 seconds have passed without that (SPINNER_FRESH_MS, longer than any frame a busy loop holds; a /goal keeps the turn open while the engine draws no spinner; seen 2026-10-02), then leaves him again when the spinner draws him. A turn start counts as a drawing, so the spinner gets its grace first.

The engine's spinner drawing opens with a blank row above its line. The two-row picture spans that blank row and the line, the line's text starting on the second row right of him (seen in Ghostty 1.3.1 on 2.1.288). With big off, the one-row picture steps one row down to sit on the line itself (seen in a live 2.1.287 session; without the step it sat on the blank row above the line). Meanwhile the footer draws only the engine's labels, and the status-row reserve stays as it is, so nothing reflows. He comes back to the footer when the turn ends, for the done gesture, the idle, minding background agents and sleep. Only the terminal and only the main loop's spinner: a subagent's spinner, a spinner showing a message of its own (compacting, a retry), another surface, or picture, sprite or besideSpinner off leave the spinner as before (the word narrated, nothing else). While he sits there the spinner line redraws with his frames, at most 4 times a second.

Idle life

Idle on the status row (not asleep, no turn), with animate on:

  • Breath. idle and idleUp (one pixel taller, arms a pixel higher) take turns every 2 seconds, one slow timer.
  • Blink. Every 6 seconds the lids close and open: blinkHalf (half shut) 100 ms, blink (shut) 200 ms, blinkHalf 100 ms, then open again; one after chain. It shows over the breath; a move under way shows its own frames instead.
  • Moves (with gestures on too). Every 12 to 30 seconds he does one of: a stroll to another spot (one cell per 250 ms tick on alternating feet, then idle), a look around (lookL, lookL, idle, lookR, lookR, idle), a hop (hop1, hop2, hop3, idle), a yawn (yawn1 x2, yawn2 x4, yawn1 x2: easing in and out around the widest moment) or a coffee sip (sip1 x2, sip2 x4, sip1 x2). Strolls and looks are the common moves, hops less so; yawns and sips are rarer than either, and after 6 idle minutes, as his nap nears, he yawns more.
  • Moves the session calls for. sweat (sweat1, sweat2, sweat1, sweat2: worried eyes toward the context gauge, a drop at his temple) only when the context window is 80% full or more; clock (clock1 x2, clock2 x2: an hourglass, its sand running) only while the 5h or 7d usage window is over or crit; pumpkin (pumpkin1 x3, pumpkin2 x2, pumpkin1 x3, pumpkin2 x2: a jack-o'-lantern, its glow flickering unevenly) only from 24 to 31 October, local time, with the seasonal option on; peek (term1 x4, term2 x6: a glance at a small terminal, a line of output arriving) only while a shell or a monitor runs in the background. When one or more of them applies, about one move in three is one of them (picked evenly), the everyday mix the rest.

After a move ends the breath goes on. The choices come from a small seeded generator (mulberry32, seeded from the session start time; no Math.random), so the tests see the same walk every time. Where he stands is x, the blank cells to his right (the row is right-aligned, so a larger x is further left): 0 to 12 at full width, and in a terminal narrower than 110 columns 0 to 1 (0 to 2 with big or picture off). With a caption showing he is drawn where the caption leaves room, and his x is kept for when it goes. No move timer runs while asleep, busy, or with gestures or animate off.

Asleep after 10 idle minutes he stays where he was, the z in the first two of his blank cells, and sleeps in a slow loop: sleep1 (settled low, a small z), sleep2 (breathing in, a bigger Z drifting up), sleep3 (breathing out, the Zs fading), one frame every 3 seconds, one timer, cancelled when he wakes, when the session ends and with animate off. While the vitals say the prompt cache is cold, he sleeps frosted instead: sleepCold1, sleepCold2 (frost on his top edge, a snowflake turning). The first sleep frame already reads the vitals.

Minding agents

When the main loop rests and agents or workflows are at work in the background (a workflow launched and left to run, an Agent call in the background, a /goal waiting on either), Claude is supervising: on the status row, at ease exactly as when idle (the breath, the blink), with the caption N agents where there is room for one, and, with gestures and animate on, a skit every 4 to 10 seconds. A skit is a move like the idle ones: solo frames, one per 250 ms tick, 2.5 to 6 seconds long, then the breath again. No skit plays twice running, and none is the hop.

skitwhat he doesframes, in ticks
launcha small rocket lifts off at his side. Played first, once per stretch of agent work, when the agents take over within 10 s of the main turn's endlaunch1 x4, launch2 x2, launch3 x4
reporta helper runs in with a page, he reads it, a green check. Played when an agent finishes (its turn.complete) while the main loop rests: at once when he is between moves, else after the move under way; at most one in 20 sreport1 x2, report2 x2, report3 x4, report4 x4
tallya judge's paddle with how many agents are at work: 1 to 9, then 9+tally0 x2, tallyN x8, tally0 x2
radarhe watches them on a small scope, the sweep going twice roundradar1-4, two ticks each, twice
radiomission control: a headset, a word into the mic, "copy that"radio1 x4, radio2 x2, radio1 x2, radio2 x2, radio3 x4
conducta baton and drifting notes; only while a workflow is among the background tasks (Orchestrating agents)conduct1, 2, 3, 2, two ticks each, twice
percha helper checks in from the top of his headperch1 x3, perch2 x2, perch1 x2, perch2 x2, perch1 x3
jugglethree ballsjuggle1, 2, 3, six times round
guma bubble grows and popsgum1 x3, gum2 x5, gum3 x3
popcornhe watches the showpopcorn1 x3, popcorn2 x2, three times
planea paper plane, thrown off to the right, comes back from the leftplane1 x3, plane2 x2, plane3 x4, plane4 x2, plane5 x4
zenhe meditates, floatingzen1 x4, zen2 x4, three times
gardenhe waters a potted plant, then admires it. The plant grows with the wait: a sprout, leaves after 2 minutes of agent work, a bud after 6, a flower after 15waterN x4, plantN x6
lanternthe night shift, 22:00 to 06:00 local time (seasonal)lantern1 x3, lantern2 x2, twice
luncha sandwich, and a bite out of it, 12:00 to 13:00 local time (seasonal)lunch1 x4, lunch2 x6

The everyday looks, strolls, sips and yawns are in the mix too, and so are the moves the session calls for (the sweat, the pumpkin, the peek; the hourglass also once the agents have run for 5 minutes). One draw of the seeded generator picks the next skit by weight (skitWeights in hooks/lib/wander.ts), the kind last played left out.

Where the caption shows (110 columns or more), it takes cells from his walk: a stroll goes only as far as the row as drawn leaves (three cells beside `

Source 7 files
hooks/register.tsx 2010 lines
1/**
2 * mize-coworker: Claude as a tiny coworker in the terminal.
3 *
4 * - SessionMode (the dim mode labels at the right of the prompt footer): the
5 *   engine's own labels, then a dim caption (`reading`, `needs you`) and an
6 *   orange pixel-art Claude (an `Image` of 8x2 cells that spans the status row
7 *   and the short mode row under it; 4x1 with the `big` option off; the
8 *   colored braille with the `picture` option off) that animates with what
9 *   the session and its agents are doing. Idle he breathes, blinks and lives
10 *   on the reserved cells of the row (strolls, looks, hops, yawns, sips a
11 *   coffee; sweats near a full context, eyes an hourglass when a usage window
12 *   runs hot, sits by a pumpkin in late October, glances at a small terminal
13 *   while a shell runs in the background); after 10 idle minutes he sleeps,
14 *   frosted once the prompt cache has gone cold. While the main loop rests
15 *   and agents or workflows work in the background he minds them
16 *   (`supervising`): at ease as when idle, with a skit every 4 to 10 s (a
17 *   rocket as they start, a tally of them, a radar, a headset, a helper with
18 *   a finished agent's report, juggling, a paper plane, a plant that grows
19 *   with the wait, ...) and never the same one twice running.
20 * - Spinner (the main loop's only): the word becomes what is happening
21 *   (`Reading register.ts`, `Running npm`); the engine keeps its glyph,
22 *   shimmer, time, tokens and effort. While the main loop's turn runs, Claude
23 *   sits left of that line (`next(e)` is the engine's own drawing, placed as a
24 *   child) in a scene of 12x2 cells for every spinner word (a thought bubble, a
25 *   pen on a notepad, a book, a magnifying glass over text or over a globe, a
26 *   laptop, a terminal, a globe, small helpers, a speech bubble, a gear, a
27 *   scroll, a plug), and the footer keeps only the engine's labels.
28 * - AbovePrompt (the band above the prompt): `/coworker demo`'s tour, every
29 *   animation once with its spinner word or move name, about two minutes.
30 * - `/coworker` prints the switches and what Claude is doing; `/coworker off`
31 *   and `/coworker on`, typed at the prompt, switch both for the session, and
32 *   `/coworker demo` starts or stops the tour.
33 *
34 * Sources: turn.start / turn.complete (the main loop's; a subagent's run
35 * raises no turn.start and its turn.complete carries agentId), the classic
36 * tool events (PreToolUse, PostToolUse, PostToolUseFailure, PermissionDenied,
37 * PermissionRequest), which fire for the main loop and its agents alike (an
38 * agent's carry its `agent_id`), and the stop hooks' `background_tasks`
39 * (which agents, workflows and shells still run). No `tool.call` hook
40 * (docs/BUILD-SPEC.md rule 7a); no tool call is ever changed.
41 *
42 * It only draws: no processes, no store, no model calls, no network, nothing
43 * the model reads, and two files: the cells Claude needs on the status row,
44 * reserved on the status bus so the status line script keeps them free
45 * (./lib/statusline-bus), and the machine-wide default of that reserve, which
46 * the script holds for a session that has not reserved yet. It reads the
47 * default before writing it, and the session's vitals the status line script
48 * writes on the same bus, at most once every 10 s, when an idle Claude picks
49 * his next move or a sleeping one turns over. A headless session registers the
50 * command and does nothing else. All mods share one worker, so every hook and
51 * timer body here catches what it calls and logs to the debug sink; nothing is
52 * thrown out.
53 *
54 * Every function that touches `$` is declared at the top level of this file;
55 * the pure logic is in ./lib/activity, ./lib/sprite, ./lib/wander and ./lib/demo.
56 */
57
58import { atom, read } from 'claude-code'
59import type { EngineInterface, Register, RenderElement, Timer } from 'claude-code'
60
61import type { CoworkerActivity, CoworkerDemo, CoworkerDoing, CoworkerSpot } from '../types'
62import { DEMO_ACTS, demoSteps, type DemoStep } from './lib/demo'
63import {
64  agentCount,
65  agentEnded,
66  backgroundOf,
67  backgroundSeen,
68  doingKey,
69  greeted,
70  hasAgentSigns,
71  idleSince,
72  isCheering,
73  narration,
74  newModel,
75  nextChangeIn,
76  parseCommand,
77  permissionAsked,
78  shown,
79  statusText,
80  teamSize,
81  toolEnded,
82  toolFailed,
83  toolStarted,
84  turnEnded,
85  turnStarted,
86  USAGE,
87  type Model,
88  type Switches,
89} from './lib/activity'
90import {
91  ASLEEP_COLOR,
92  BLINK,
93  BLINK_EVERY_MS,
94  boxOf,
95  brailleOf,
96  BREATH_MS,
97  captionOf,
98  CLAUDE_COLOR,
99  footerOf,
100  footerPieces,
101  footerRange,
102  frameFile,
103  isAtEase,
104  isBusy,
105  isClaude,
106  isFrameName,
107  movingView,
108  normalView,
109  PICTURE,
110  poseOf,
111  RESERVE,
112  SCENE_BOX,
113  sceneFor,
114  sizeOf,
115  SLEEP_FRAME_MS,
116  SPINNER_FRESH_MS,
117  spinnerFrame,
118  TICK_MS,
119  viewKey,
120  viewOf,
121  viewOfFrame,
122  type FrameContext,
123  type FrameName,
124  type Piece,
125  type Size,
126} from './lib/sprite'
127import { parseVitals, reserveDefaultPath, reserveLine, reservePath, vitalsPath, type Vitals } from './lib/statusline-bus'
128import {
129  chooseMove,
130  chooseSkit,
131  clampX,
132  DROWSY_MS,
133  gatedMoves,
134  moveSteps,
135  nextWait,
136  seeded,
137  SKIT_SOON_MS,
138  WANDER_RANGE,
139  type GatedMove,
140  type Move,
141  type MoveKind,
142  type Rng,
143  type Step,
144} from './lib/wander'
145
146const COMMAND = 'coworker'
147/**
148 * The main loop's spinner instance. The terminal raises it under the session's
149 * id (seen in a live 2.1.287 session's debug log: `ui.render ... key=<session id>`);
150 * the engine's own fallback is `agentId ?? "main"`. A subagent's carries its agent id.
151 */
152const MAIN_SPINNER = 'main'
153/**
154 * The row of the engine's spinner drawing that holds its line: a live 2.1.287
155 * session drew a blank row first, then `· Running sleep… (3s · ↓ 11 tokens)`.
156 * The one-row picture (`big` off) steps down to it; the two-row picture spans
157 * both rows and needs no step (seen in Ghostty 1.3.1 on 2.1.288).
158 */
159const SPINNER_ROW = 1
160/** The step counter wraps here: a multiple of every loop length (12, 8, 6, 4, 3, 2, 1). */
161const STEP_WRAP = 480
162/** The vitals file is read at most this often; between reads the last answer stands. */
163const VITALS_EVERY_MS = 10_000
164/** The send-off (the rocket) plays only when the agents take over within this long of the main turn's end. */
165const LAUNCH_WINDOW_MS = 10_000
166/** A finished agent's report is brought only this fresh. */
167const REPORT_FRESH_MS = 10_000
168/** At least this long between two reports, so a wave of agents finishing together is one helper, not a queue of them. */
169const REPORT_EVERY_MS = 20_000
170/** While a turn is open, the spinner's silence is checked once in this many ticks (about a second). */
171const REST_CHECK_TICKS = 4
172/**
173 * A wake whose time only moved this much later keeps its timer (every sign of
174 * an agent moves the end of the quiet a little): the wake it brings works out
175 * what is left and waits again.
176 */
177const WAKE_SLACK_MS = 60_000
178
179const DOING_REF = { plugin: 'mize-coworker', key: 'doing' } as const
180const VIEW_REF = { plugin: 'mize-coworker', key: 'view' } as const
181const IS_OFF_REF = { plugin: 'mize-coworker', key: 'isOff' } as const
182const SPOT_REF = { plugin: 'mize-coworker', key: 'spot' } as const
183const DEMO_REF = { plugin: 'mize-coworker', key: 'demo' } as const
184
185/** No tour: the band draws nothing. */
186const NO_DEMO: CoworkerDemo = { frame: '', label: '' }
187
188const DOING = atom(DOING_REF, { activity: 'idle', word: '' })
189const VIEW = atom(VIEW_REF, viewOfFrame('idle'))
190const IS_OFF = atom(IS_OFF_REF, false)
191const SPOT = atom(SPOT_REF, { where: 'footer', x: 0 } as CoworkerSpot)
192const DEMO = atom(DEMO_REF, NO_DEMO)
193
194/** The options beyond the /coworker status line's three. */
195type Options = Switches & {
196  /** Claude as the orange PNG frames (`Image`); off, the colored braille text. */
197  picture: boolean
198  /** While the main loop's turn runs, Claude sits beside its spinner instead of in the footer. */
199  besideSpinner: boolean
200  /** The picture in 8x2 cells (two rows); off, 0.2.0's 4x1. */
201  big: boolean
202  /** Beside the spinner, the 12x2 scenes (Claude and a prop); off, the solo frames there too. */
203  scenes: boolean
204  /** The date-bound idle moves: the pumpkin in the last week of October. */
205  seasonal: boolean
206}
207
208/** The module's own view of the session; a hot reload starts it over from $.state. */
209const S = {
210  isInteractive: false,
211  options: { sprite: true, narrate: true, animate: true, picture: true, besideSpinner: true, big: true, scenes: true, seasonal: true } as Options,
212  /** The `gestures` option: the wave at session start, the hop after a good turn, the flinch after a failed call. */
213  gestures: true,
214  /** `/coworker off`, mirrored from $.state so timers can check it. */
215  isOff: false,
216  model: newModel(0) as Model,
217  /** The activity the frames are drawn for. */
218  activity: 'idle' as CoworkerActivity,
219  step: 0,
220  /** Idle: where the blink is: 0 open, else the phase BLINK[blinkAt - 1] (half shut, shut, half shut). */
221  blinkAt: 0,
222  /** Idle: the top of a breath (idleUp), every other BREATH_MS. */
223  isBreathIn: false,
224  /** Asleep: the prompt cache has gone cold (the vitals say so), so he sleeps frosted. */
225  isCold: false,
226  /** What was last written to $.state, so an unchanged value is never written. */
227  doingKey: '',
228  viewKey: '',
229  wasBusy: false,
230  /** When the current busy stretch began (the idle stretch is idleSince). */
231  busySince: 0,
232  ticker: null as Timer | null,
233  blinker: null as Timer | null,
234  /** Idle: idle and idleUp in turn, every BREATH_MS. */
235  breather: null as Timer | null,
236  /** Asleep: the next frame of the sleep loop, every SLEEP_FRAME_MS. */
237  sleeper: null as Timer | null,
238  waker: null as Timer | null,
239  /** When the waker is due, so an event that does not move it does not reschedule it. */
240  wakeAt: undefined as number | undefined,
241  /** What was last written as the spot, so an unchanged one is never written. */
242  spotKey: '',
243  /** The clock's time when the main loop's spinner last drew him (a turn start counts, so the spinner gets its chance first). */
244  spinnerAt: 0,
245  /** Blank cells to Claude's right on the footer's row: where his wander has taken him. */
246  x: 0,
247  /** The wander's generator, seeded from the session start time. */
248  rng: seeded(0) as Rng,
249  /** Waits 12 to 30 s for the next move while idle. */
250  wanderer: null as Timer | null,
251  /** Steps through a move, one step per tick. */
252  walker: null as Timer | null,
253  /** The move under way and the next step of it. */
254  steps: undefined as Step[] | undefined,
255  stepAt: 0,
256  /** The frame the move shows over the idle frame. */
257  moveFrame: undefined as FrameName | undefined,
258  /** The move last begun: a skit never plays twice running. */
259  lastMove: undefined as MoveKind | undefined,
260  /** How many agents he minds, as last worked out: the supervising caption and the tally's digit. */
261  agents: 1,
262  /** The main loop rests, as last worked out: no turn, or a turn left open whose spinner has gone quiet. */
263  isResting: true,
264  /** The stretch of agent work (the model's agentsSince) whose send-off was decided, so it is decided once. */
265  launchedFor: undefined as number | undefined,
266  /** The send-off is the next skit. */
267  isLaunchDue: false,
268  /** When a report was last brought (REPORT_EVERY_MS apart at least). */
269  reportedAt: Number.NEGATIVE_INFINITY,
270  /** The wait under way is the short one, for a skit that answers something: if nothing is left to answer when it ends, the usual wait follows, not a move. */
271  isWaitShort: false,
272  /** How far the wander may go on the row the footer was last drawn on (narrower in a narrow terminal). */
273  wanderRange: WANDER_RANGE.big.full as number,
274  /** The plugin's directory, which holds assets/frames/; read once at session start. */
275  root: undefined as string | undefined,
276  /** The session's id: what the main loop's spinner is drawn under. A /clear or /resume changes it. */
277  sessionId: undefined as string | undefined,
278  home: undefined as string | undefined,
279  /** The reserve last written to the status bus: its file, its content, and when. */
280  reserve: undefined as { path: string; line: string; at: number } | undefined,
281  /** The vitals last read from the status bus, and when (read at most every VITALS_EVERY_MS). */
282  vitals: undefined as { value: Vitals; at: number } | undefined,
283  /** Set when the session ended for good (not a /clear or /resume): nothing is recorded, drawn or scheduled after it. */
284  isEnded: false,
285  /** `/coworker demo`: the tour's steps and the one on screen; undefined while no tour runs. */
286  demo: undefined as { steps: DemoStep[]; at: number } | undefined,
287  /** Shows the tour's next step: one `after` at a time, the length of the step on screen. */
288  demoTimer: null as Timer | null,
289  /** What was last written as the tour's step: '' for none, '?' when a write failed (the next one always goes). */
290  demoKey: '',
291}
292
293/** How old a reserve may get before a turn start writes it again: the status line's sweep deletes files untouched for 3 days. */
294const RESERVE_REFRESH_MS = 24 * 60 * 60_000
295
296/** True while the session has a screen and has not ended. */
297function isLive(): boolean {
298  return S.isInteractive && !S.isEnded
299}
300
301function messageOf(error: unknown): string {
302  return error instanceof Error ? error.message : String(error)
303}
304
305/** A line in the debug log (led by the plugin's name there); never throws. */
306function debug($: EngineInterface, text: string): void {
307  try {
308    $.ui.log(text, { to: 'debug' })
309  } catch {
310    // nowhere to log it
311  }
312}
313
314function stop(timer: Timer | null): null {
315  try {
316    timer?.cancel()
317  } catch {
318    // a timer that cannot be cancelled is dropped with the module
319  }
320
321  return null
322}
323
324/** Ends the wander: no next move, no move under way. Claude stays where it took him. */
325function stopWander(): void {
326  S.wanderer = stop(S.wanderer)
327  S.isWaitShort = false
328  S.walker = stop(S.walker)
329  S.steps = undefined
330  S.stepAt = 0
331  S.moveFrame = undefined
332}
333
334/**
335 * Cancels every timer, the tour's too (endDemo, where the session goes on,
336 * also takes its last step off the band).
337 */
338function cancelTimers(): void {
339  S.ticker = stop(S.ticker)
340  S.blinker = stop(S.blinker)
341  S.breather = stop(S.breather)
342  S.sleeper = stop(S.sleeper)
343  S.waker = stop(S.waker)
344  S.wakeAt = undefined
345  S.blinkAt = 0
346  S.isBreathIn = false
347  stopWander()
348  S.demoTimer = stop(S.demoTimer)
349  S.demo = undefined
350}
351
352function isDrawing(): boolean {
353  return isLive() && !S.isOff && S.options.sprite
354}
355
356function isNarrating(): boolean {
357  return isLive() && !S.isOff && S.options.narrate
358}
359
360/** True when the options let Claude sit beside the main loop's spinner (the picture only). */
361function isBesideOn(): boolean {
362  return S.options.sprite && S.options.picture && S.options.besideSpinner
363}
364
365/** The size Claude is drawn at: the two-row picture (`big`), or one row (`big` or `picture` off). */
366function sizeNow(): Size {
367  return sizeOf(S.options.picture, S.options.big)
368}
369
370/** True while Claude may wander: drawn, animated, with gestures, and at ease (idle or minding agents; not asleep, not busy). */
371function canWander(): boolean {
372  return isDrawing() && S.options.animate && S.gestures && isAtEase(S.activity)
373}
374
375/**
376 * True while the main loop rests: no turn of its runs, or one is open but its
377 * spinner has not drawn Claude for SPINNER_FRESH_MS (between a /goal's
378 * iterations, or while it waits on background work with the turn left open).
379 * The spinner's silence counts only while his frames animate: with `animate`
380 * off nothing redraws the spinner site between events, so its age says nothing.
381 */
382function isMainResting(now: number): boolean {
383  if (!S.model.isTurnRunning) {
384    return true
385  }
386
387  return isBesideOn() && S.options.animate && now - S.spinnerAt > SPINNER_FRESH_MS
388}
389
390/** Where Claude is drawn now: beside the spinner while the main loop's turn runs, else the footer. */
391function spotNow(): CoworkerSpot {
392  return { where: isBesideOn() && S.model.isTurnRunning ? 'spinner' : 'footer', x: S.x }
393}
394
395function spotKey(spot: CoworkerSpot): string {
396  return `${spot.where}|${spot.x}`
397}
398
399/** The plugin's directory: read at session start, or here the first time a drawing needs it. */
400function rootOf($: EngineInterface): string {
401  if (S.root === undefined) {
402    S.root = $.plugin.root
403  }
404
405  return S.root
406}
407
408/**
409 * Asks for this plugin's drawings again. The footer is first drawn before
410 * `session.start` has said the session is interactive, and a hook that passed
411 * then never read $.state, so no later write would reach it.
412 */
413function redraw($: EngineInterface): void {
414  try {
415    $.ui.invalidate('ui.render')
416  } catch (error) {
417    debug($, `redraw not asked: ${messageOf(error)}`)
418  }
419}
420
421/**
422 * Reserves Claude's cells at the right end of the status row for this session,
423 * or releases them (sprite off, `/coworker off`, the session ending). Written
424 * only when the file or its content changes, or with `isRefresh` to keep the
425 * file young in a session that stays open for days. Never throws.
426 */
427async function reserve($: EngineInterface, isRefresh = false): Promise<void> {
428  try {
429    if (!isLive()) {
430      return
431    }
432
433    if (S.home === undefined) {
434      S.home = await $.env.get('HOME')
435    }
436
437    const path = S.sessionId === undefined ? undefined : reservePath(S.home, S.sessionId)
438
439    if (path === undefined) {
440      return
441    }
442
443    const line = reserveLine(isDrawing() ? RESERVE[sizeNow()] : undefined)
444
445    if (S.reserve?.path === path && S.reserve.line === line && !(isRefresh && line !== '')) {
446      return
447    }
448
449    // the session id changed with no session.end in between (/branch): the old id's cells go back
450    if (S.reserve !== undefined && S.reserve.path !== path && S.reserve.line !== '') {
451      await $.fs.write(S.reserve.path, '').catch(() => undefined)
452    }
453
454    S.reserve = { path, line, at: await $.clock.now() }
455    // written even when empty: the status line holds the machine-wide default's cells for a session
456    // with no file of its own, so a session that reserves nothing (sprite off, off) says so
457    await $.fs.write(path, line)
458  } catch (error) {
459    S.reserve = undefined
460    debug($, `status row not reserved: ${messageOf(error)}`)
461  }
462}
463
464/** Releases the cells of the session that is ending: its file is named by that session's id. */
465async function release($: EngineInterface): Promise<void> {
466  const last = S.reserve
467
468  S.reserve = undefined
469
470  if (last === undefined || last.line === '') {
471    return
472  }
473
474  try {
475    await $.fs.write(last.path, '')
476  } catch (error) {
477    debug($, `status row not released: ${messageOf(error)}`)
478  }
479}
480
481/** Takes the session's id (it names the main loop's spinner and the reserve file): the one given, else read from the engine. */
482async function readSessionId($: EngineInterface, given?: unknown): Promise<void> {
483  const before = S.sessionId
484
485  if (typeof given === 'string' && given !== '') {
486    S.sessionId = given
487  } else {
488    try {
489      S.sessionId = await $.session.id()
490    } catch {
491      S.sessionId = undefined
492    }
493  }
494
495  // the vitals are the session's: another id has its own file
496  if (S.sessionId !== before) {
497    S.vitals = undefined
498  }
499}
500
501/**
502 * The session's vitals as the status line script last wrote them (context
503 * share, the usage windows, the prompt cache): read from the status bus at
504 * most once every VITALS_EVERY_MS, the last answer standing between reads. A
505 * missing or unreadable file, or one it cannot parse, is all unknown. Never throws.
506 */
507async function vitalsOf($: EngineInterface): Promise<Vitals> {
508  let now: number
509
510  try {
511    now = await $.clock.now()
512  } catch (error) {
513    debug($, `vitals not read: ${messageOf(error)}`)
514
515    return {}
516  }
517
518  if (S.vitals !== undefined && now >= S.vitals.at && now - S.vitals.at < VITALS_EVERY_MS) {
519    return S.vitals.value
520  }
521
522  let value: Vitals = {}
523
524  try {
525    if (S.home === undefined && S.sessionId !== undefined) {
526      S.home = await $.env.get('HOME')
527    }
528
529    const path = S.sessionId === undefined ? undefined : vitalsPath(S.home, S.sessionId)
530
531    if (path !== undefined) {
532      // missing until the status line's first run, and after a sweep: unknown, and quietly so
533      value = parseVitals(await $.fs.read(path).catch(() => ''))
534    }
535  } catch (error) {
536    debug($, `vitals not read: ${messageOf(error)}`)
537  }
538
539  // a failed read counts too: the next try waits its turn
540  S.vitals = { value, at: now }
541
542  return value
543}
544
545/** True when the vitals say the prompt cache has gone cold. Never throws. */
546async function isCacheCold($: EngineInterface): Promise<boolean> {
547  return (await vitalsOf($)).cache === 'cold'
548}
549
550/**
551 * Writes the doing the Spinner reads, only when it changed: for its word, and
552 * for the scene beside it, which follows the word whether or not it is shown.
553 */
554async function writeDoing($: EngineInterface, doing: CoworkerDoing): Promise<void> {
555  if (!isNarrating() && !isDrawing()) {
556    return
557  }
558
559  const key = doingKey(doing)
560
561  if (key === S.doingKey) {
562    return
563  }
564
565  S.doingKey = key
566
567  try {
568    await $.state.set(DOING_REF, doing)
569  } catch (error) {
570    S.doingKey = ''
571    debug($, `activity not written: ${messageOf(error)}`)
572  }
573}
574
575/**
576 * Writes the frame the render hooks draw, only when the frame or caption
577 * changed, then where Claude is, only when that changed. A move under way
578 * shows its frame over the idle one.
579 */
580async function draw($: EngineInterface): Promise<void> {
581  if (!isDrawing()) {
582    return
583  }
584
585  const context = frameContext()
586  const view = S.moveFrame !== undefined && isAtEase(S.activity) ? movingView(S.moveFrame, captionOf(S.activity, context)) : viewOf(S.activity, S.step, context)
587  const key = viewKey(view)
588
589  if (key !== S.viewKey) {
590    S.viewKey = key
591
592    try {
593      await $.state.set(VIEW_REF, view)
594    } catch (error) {
595      S.viewKey = ''
596      debug($, `frame not drawn: ${messageOf(error)}`)
597    }
598  }
599
600  await place($)
601}
602
603/** What picks the frame besides the activity and the step: motion, the blink and breath, the team, the cheer, the cold, the agents he minds. */
604function frameContext(): FrameContext {
605  const blinking = S.blinkAt === 0 ? undefined : BLINK[S.blinkAt - 1]?.frame
606
607  return {
608    isAnimated: S.options.animate,
609    isBlinking: blinking === 'blink',
610    isBlinkHalf: blinking === 'blinkHalf',
611    isBreathIn: S.isBreathIn,
612    team: teamSize(S.model),
613    isCheer: isCheering(S.model),
614    isCold: S.isCold,
615    agents: S.agents,
616  }
617}
618
619/** Writes where Claude is drawn, only when the place or x changed. */
620async function place($: EngineInterface): Promise<void> {
621  const spot = spotNow()
622  const key = spotKey(spot)
623
624  if (key === S.spotKey) {
625    return
626  }
627
628  S.spotKey = key
629
630  try {
631    await $.state.set(SPOT_REF, spot)
632  } catch (error) {
633    S.spotKey = ''
634    debug($, `place not written: ${messageOf(error)}`)
635  }
636}
637
638/** One animation step; the 250 ms timer's body. */
639async function tick($: EngineInterface): Promise<void> {
640  try {
641    if (!isDrawing() || !S.options.animate || !isBusy(S.activity)) {
642      S.ticker = stop(S.ticker)
643
644      return
645    }
646
647    S.step = (S.step + 1) % STEP_WRAP
648
649    // a turn left open whose spinner has gone quiet: the main loop rests, and agents at work get his
650    // attention (asked about once a second, and only when something says an agent may be at work)
651    if (S.model.isTurnRunning && !S.isResting && S.step % REST_CHECK_TICKS === 0 && hasAgentSigns(S.model)) {
652      const now = await nowOf($)
653
654      if (now !== undefined && isMainResting(now)) {
655        await refresh($, now)
656
657        return
658      }
659    }
660
661    await draw($)
662  } catch (error) {
663    debug($, `tick failed: ${messageOf(error)}`)
664  }
665}
666
667function scheduleBlink($: EngineInterface, ms: number): void {
668  S.blinker = $.clock.after(ms, () => {
669    void blink($)
670  })
671}
672
673/**
674 * The blink, one phase at a time while at ease: open for BLINK_EVERY_MS,
675 * then half shut, shut and half shut again, each for its BLINK phase's ms.
676 */
677async function blink($: EngineInterface): Promise<void> {
678  try {
679    S.blinker = null
680
681    if (!isDrawing() || !S.options.animate || !isAtEase(S.activity)) {
682      S.blinkAt = 0
683
684      return
685    }
686
687    S.blinkAt = (S.blinkAt + 1) % (BLINK.length + 1)
688    scheduleBlink($, S.blinkAt === 0 ? BLINK_EVERY_MS : (BLINK[S.blinkAt - 1]?.ms ?? BLINK_EVERY_MS))
689    await draw($)
690  } catch (error) {
691    debug($, `blink failed: ${messageOf(error)}`)
692  }
693}
694
695/**
696 * True, with everything brought in line, when the main loop was taken to be
697 * resting with its turn left open and its spinner is drawing Claude again: it
698 * is at work, and he is its own once more. Asked from the at-ease timers (the
699 * breath, a move's steps); the spinner's render hook only notes the time.
700 */
701async function isMainBack($: EngineInterface): Promise<boolean> {
702  if (!S.model.isTurnRunning || !S.isResting) {
703    return false
704  }
705
706  const now = await nowOf($)
707
708  if (now === undefined || isMainResting(now)) {
709    return false
710  }
711
712  await refresh($, now)
713
714  return true
715}
716
717/** The breath: idle and idleUp in turn, every BREATH_MS, while at ease. */
718async function breathe($: EngineInterface): Promise<void> {
719  try {
720    if (!isDrawing() || !S.options.animate || !isAtEase(S.activity)) {
721      S.breather = stop(S.breather)
722      S.isBreathIn = false
723
724      return
725    }
726
727    if (await isMainBack($)) {
728      return
729    }
730
731    S.isBreathIn = !S.isBreathIn
732    await draw($)
733  } catch (error) {
734    debug($, `breath failed: ${messageOf(error)}`)
735  }
736}
737
738/** Asleep: the next frame of the sleep loop, frosted while the vitals say the prompt cache is cold. */
739async function snore($: EngineInterface): Promise<void> {
740  try {
741    if (!isDrawing() || !S.options.animate || S.activity !== 'asleep') {
742      S.sleeper = stop(S.sleeper)
743
744      return
745    }
746
747    S.step = (S.step + 1) % STEP_WRAP
748    S.isCold = await isCacheCold($)
749    await draw($)
750  } catch (error) {
751    debug($, `sleep frame failed: ${messageOf(error)}`)
752  }
753}
754
755/**
756 * Waits for the next move: 12 to 30 s idle, 4 to 10 s while he minds agents,
757 * and only a moment when a skit answers something (the send-off as the
758 * agents start, a finished agent's report).
759 */
760function scheduleWander($: EngineInterface): void {
761  // one wait at a time: a wait scheduled while another was pending (a refresh during a move's awaits) replaces it
762  S.wanderer = stop(S.wanderer)
763  S.isWaitShort = (S.activity === 'supervising' && S.isLaunchDue) || S.model.report !== undefined
764  S.wanderer = $.clock.after(S.isWaitShort ? SKIT_SOON_MS : nextWait(S.rng, S.activity === 'supervising'), () => {
765    void wander($)
766  })
767}
768
769/** True when a skit that answers something is due at `now`: the send-off, or a report still fresh and far enough from the last. */
770function isAnswerDue(now: number): boolean {
771  if (S.activity === 'supervising' && S.isLaunchDue) {
772    return true
773  }
774
775  const report = S.model.report
776
777  return report !== undefined && now - report <= REPORT_FRESH_MS && now - S.reportedAt >= REPORT_EVERY_MS
778}
779
780/**
781 * The next move of a Claude at ease. Minding agents: the send-off when it is
782 * due, else a finished agent's report when one is fresh, else a skit from the
783 * mix. Idle: a fresh report (the last agent just finished), else the idle mix.
784 */
785function nextMove(now: number, hour: number, gated: GatedMove[]): Move {
786  const isSupervising = S.activity === 'supervising'
787  const at = clampX(S.x, S.wanderRange)
788
789  if (isSupervising && S.isLaunchDue) {
790    S.isLaunchDue = false
791
792    return { kind: 'launch', steps: moveSteps('launch', at) }
793  }
794
795  const report = S.model.report
796
797  S.model.report = undefined
798
799  if (report !== undefined && now - report <= REPORT_FRESH_MS && now - S.reportedAt >= REPORT_EVERY_MS) {
800    S.reportedAt = now
801
802    return { kind: 'report', steps: moveSteps('report', at) }
803  }
804
805  if (isSupervising) {
806    return chooseSkit(S.rng, S.x, S.wanderRange, {
807      count: S.agents,
808      forMs: now - (S.model.agentsSince ?? now),
809      isWorkflow: S.model.background.hasWorkflow,
810      hour,
811      isSeasonal: S.options.seasonal,
812      gated,
813      last: S.lastMove,
814    })
815  }
816
817  return chooseMove(S.rng, S.x, S.wanderRange, { gated, isDrowsy: now - idleSince(S.model) >= DROWSY_MS })
818}
819
820/** The wait is over: a Claude at ease starts his next move, its first step now and one per tick after. */
821async function wander($: EngineInterface): Promise<void> {
822  try {
823    S.wanderer = null
824
825    if (!canWander() || S.walker !== null) {
826      return
827    }
828
829    const now = await $.clock.now()
830    const vitals = await vitalsOf($)
831
832    // the read took a moment: the session may have moved on, or another move begun
833    if (!canWander() || S.walker !== null) {
834      return
835    }
836
837    // the short wait was for an answer that is no longer due (a report gone stale behind a move): the
838    // usual wait follows, so two moves never run back to back
839    if (S.isWaitShort && !isAnswerDue(now)) {
840      S.model.report = undefined
841      scheduleWander($)
842
843      return
844    }
845
846    S.isWaitShort = false
847    // where he stands is where the row draws him (a caption may have taken cells since he last walked)
848    S.x = clampX(S.x, S.wanderRange)
849
850    // the count the paddle shows is the caption's, worked out now (it otherwise follows events only)
851    if (S.activity === 'supervising') {
852      S.agents = agentCount(S.model, now)
853    }
854
855    const date = new Date(now)
856    const gated = gatedMoves(vitals, date.getMonth(), date.getDate(), S.options.seasonal, S.model.background.shells)
857    const move = nextMove(now, date.getHours(), gated)
858
859    S.lastMove = move.kind
860    S.steps = move.steps
861    S.stepAt = 0
862    S.walker = $.clock.every(TICK_MS, () => {
863      void walk($)
864    })
865    await walk($)
866  } catch (error) {
867    debug($, `wander failed: ${messageOf(error)}`)
868  }
869}
870
871/** One step of the move under way; past its last, the move ends and the next wait begins. */
872async function walk($: EngineInterface): Promise<void> {
873  try {
874    if (!canWander() || S.steps === undefined) {
875      stopWander()
876
877      return
878    }
879
880    if (await isMainBack($)) {
881      return
882    }
883
884    // the await took a moment: a refresh may have ended the move
885    if (!canWander() || S.steps === undefined) {
886      return
887    }
888
889    const step = S.steps[S.stepAt]
890
891    if (step === undefined) {
892      stopWander()
893      scheduleWander($)
894      await draw($)
895
896      return
897    }
898
899    S.stepAt += 1
900    S.moveFrame = step.frame
901    S.x = step.x
902    await draw($)
903  } catch (error) {
904    debug($, `walk failed: ${messageOf(error)}`)
905  }
906}
907
908/**
909 * A background agent finished: when Claude is at ease and between moves, the
910 * report it left is brought now, not at the end of the wait. One that comes
911 * too soon after the last is dropped (a wave of agents finishing together is
912 * one helper); one that finds him in the middle of a move waits for its end.
913 */
914async function bringReport($: EngineInterface, now: number): Promise<void> {
915  try {
916    if (S.model.report === undefined) {
917      return
918    }
919
920    if (now - S.reportedAt < REPORT_EVERY_MS) {
921      S.model.report = undefined
922
923      return
924    }
925
926    if (!canWander() || S.walker !== null) {
927      return
928    }
929
930    S.wanderer = stop(S.wanderer)
931    S.isWaitShort = false
932    await wander($)
933  } catch (error) {
934    debug($, `report not brought: ${messageOf(error)}`)
935  }
936}
937
938/** Runs the tick while busy; the blink, the breath and the wander while at ease; the sleep loop asleep. */
939function syncAnimation($: EngineInterface): void {
940  const isAnimated = isDrawing() && S.options.animate
941
942  if (isAnimated && isBusy(S.activity)) {
943    if (S.ticker === null) {
944      S.ticker = $.clock.every(TICK_MS, () => {
945        void tick($)
946      })
947    }
948  } else {
949    S.ticker = stop(S.ticker)
950  }
951
952  if (isAnimated && isAtEase(S.activity)) {
953    if (S.blinker === null) {
954      S.blinkAt = 0
955      scheduleBlink($, BLINK_EVERY_MS)
956    }
957
958    if (S.breather === null) {
959      S.isBreathIn = false
960      S.breather = $.clock.every(BREATH_MS, () => {
961        void breathe($)
962      })
963    }
964  } else {
965    S.blinker = stop(S.blinker)
966    S.blinkAt = 0
967    S.breather = stop(S.breather)
968    S.isBreathIn = false
969  }
970
971  if (isAnimated && S.activity === 'asleep') {
972    if (S.sleeper === null) {
973      S.sleeper = $.clock.every(SLEEP_FRAME_MS, () => {
974        void snore($)
975      })
976    }
977  } else {
978    S.sleeper = stop(S.sleeper)
979  }
980
981  if (canWander()) {
982    if (S.wanderer === null && S.walker === null) {
983      scheduleWander($)
984    }
985  } else {
986    stopWander()
987  }
988}
989
990/**
991 * One `after` for the next change no event brings: decay, sleep, a forgotten
992 * call, the agents going quiet. Left alone when an event did not move its
993 * time (tool calls in a turn) or moved it only a little later (WAKE_SLACK_MS).
994 */
995function scheduleWake($: EngineInterface, now: number): void {
996  const ms = nextChangeIn(S.model, now)
997  const at = ms === undefined ? undefined : now + ms
998
999  if (S.waker !== null && at === S.wakeAt) {
1000    return
1001  }
1002
1003  if (S.waker !== null && at !== undefined && S.wakeAt !== undefined && at > S.wakeAt && at - S.wakeAt < WAKE_SLACK_MS) {
1004    return
1005  }
1006
1007  S.waker = stop(S.waker)
1008  S.wakeAt = undefined
1009
1010  if (ms === undefined) {
1011    return
1012  }
1013
1014  S.waker = $.clock.after(ms, () => {
1015    void wake($)
1016  })
1017  S.wakeAt = at
1018}
1019
1020async function wake($: EngineInterface): Promise<void> {
1021  try {
1022    S.waker = null
1023    S.wakeAt = undefined
1024    await refresh($, await $.clock.now())
1025  } catch (error) {
1026    debug($, `wake failed: ${messageOf(error)}`)
1027  }
1028}
1029
1030/**
1031 * Works out what is shown now and brings the timers and $.state in line:
1032 * a new activity starts its loop at its first frame. Never throws.
1033 */
1034async function refresh($: EngineInterface, now: number): Promise<void> {
1035  try {
1036    if (!isLive()) {
1037      return
1038    }
1039
1040    if (!isDrawing() && !isNarrating()) {
1041      cancelTimers()
1042      await endDemo($)
1043
1044      return
1045    }
1046
1047    S.isResting = isMainResting(now)
1048
1049    const doing = shown(S.model, now, S.isResting)
1050    const busy = isBusy(doing.activity)
1051
1052    if (busy !== S.wasBusy) {
1053      S.wasBusy = busy
1054      S.busySince = now
1055    }
1056
1057    S.agents = doing.activity === 'supervising' ? agentCount(S.model, now) : 1
1058
1059    // a report nobody was at ease to be brought has gone stale
1060    if (S.model.report !== undefined && now - S.model.report > REPORT_FRESH_MS) {
1061      S.model.report = undefined
1062    }
1063
1064    if (doing.activity !== S.activity) {
1065      // the wait for a move is the activity's own: idle's long one must not hold up the first skit
1066      if (isAtEase(S.activity) || isAtEase(doing.activity)) {
1067        S.wanderer = stop(S.wanderer)
1068      }
1069
1070      S.activity = doing.activity
1071      S.step = 0
1072      S.blinkAt = 0
1073      S.isBreathIn = false
1074
1075      // the agents take over as the main turn ends: a send-off, decided once per stretch of agent work
1076      const since = S.model.agentsSince
1077
1078      S.isLaunchDue =
1079        S.activity === 'supervising' && since !== undefined && since !== S.launchedFor && S.model.turnEndedAt > 0 && now - S.model.turnEndedAt <= LAUNCH_WINDOW_MS
1080
1081      if (S.activity === 'supervising') {
1082        S.launchedFor = since
1083      }
1084
1085      // falling asleep: frosted from the first frame when the prompt cache has gone cold
1086      if (S.activity === 'asleep' && isDrawing()) {
1087        S.isCold = await isCacheCold($)
1088      }
1089    }
1090
1091    try {
1092      syncAnimation($)
1093      scheduleWake($, now)
1094    } catch (error) {
1095      debug($, `timers not set: ${messageOf(error)}`)
1096    }
1097
1098    await writeDoing($, doing)
1099    await draw($)
1100  } catch (error) {
1101    debug($, `refresh failed: ${messageOf(error)}`)
1102  }
1103}
1104
1105/** The clock's time, or undefined (logged) when it cannot be read. */
1106async function nowOf($: EngineInterface): Promise<number | undefined> {
1107  try {
1108    return await $.clock.now()
1109  } catch (error) {
1110    debug($, `clock not read: ${messageOf(error)}`)
1111
1112    return undefined
1113  }
1114}
1115
1116/** True while the main loop's spinner drew Claude within SPINNER_FRESH_MS: his seat there still holds. */
1117async function isSpinnerFresh($: EngineInterface): Promise<boolean> {
1118  const now = await nowOf($)
1119
1120  return now !== undefined && now - S.spinnerAt <= SPINNER_FRESH_MS
1121}
1122
1123/** Starts the session's coworker; on a hot reload, takes over what $.state holds. */
1124async function start($: EngineInterface): Promise<void> {
1125  try {
1126    cancelTimers()
1127
1128    const now = await $.clock.now()
1129
1130    S.model = newModel(now)
1131    S.activity = 'idle'
1132    S.step = 0
1133    S.blinkAt = 0
1134    S.isBreathIn = false
1135    S.isCold = false
1136    S.vitals = undefined
1137    S.wasBusy = false
1138    S.busySince = now
1139    S.rng = seeded(now)
1140    S.lastMove = undefined
1141    S.agents = 1
1142    S.isResting = true
1143    S.launchedFor = undefined
1144    S.isLaunchDue = false
1145    S.reportedAt = Number.NEGATIVE_INFINITY
1146    S.isWaitShort = false
1147    await readSessionId($)
1148    await adopt($)
1149    await reserve($)
1150    await reserveDefault($)
1151    if (S.gestures) {
1152      greeted(S.model, now)
1153    }
1154
1155    await refresh($, now)
1156    redraw($)
1157  } catch (error) {
1158    debug($, `start failed: ${messageOf(error)}`)
1159  }
1160}
1161
1162/**
1163 * Reads what the host holds, so a value it already has is not written again;
1164 * a tour step the module no longer plays (a reload, a /resume) is taken off
1165 * the band.
1166 */
1167async function adopt($: EngineInterface): Promise<void> {
1168  S.isOff = await read($, IS_OFF)
1169  S.viewKey = viewKey(await read($, VIEW))
1170  S.doingKey = doingKey(await read($, DOING))
1171
1172  const spot = await read($, SPOT)
1173
1174  S.spotKey = spotKey(spot)
1175  S.x = Number.isFinite(spot.x) ? Math.max(0, Math.min(WANDER_RANGE[sizeNow()].full, Math.floor(spot.x))) : 0
1176  S.demoKey = demoKey(await read($, DEMO))
1177
1178  if (S.demo === undefined) {
1179    await writeDemo($, NO_DEMO)
1180  }
1181}
1182
1183/**
1184 * Keeps the machine-wide default reserve (the status bus's .reserve-default)
1185 * equal to the line this session reserved: the status line holds those cells
1186 * for a session with no .reserve of its own yet, so the next session's first
1187 * status line, drawn before this mod starts there, already leaves Claude his
1188 * cells. Written when the file differs, and when it is a day old (the status
1189 * line's daily sweep deletes bus files untouched for three days); a release
1190 * (an empty line) never writes it. Never throws.
1191 */
1192async function reserveDefault($: EngineInterface): Promise<void> {
1193  try {
1194    const line = S.reserve?.line
1195    const path = reserveDefaultPath(S.home)
1196
1197    if (!isLive() || line === undefined || line === '' || path === undefined) {
1198      return
1199    }
1200
hooks/lib/demo.ts 201 lines
1// The `/coworker demo` tour: everything Claude does, once each, as the spinner
2// and the footer would show it, step by step for the band above the prompt.
3// Pure: no `$`.
4//
5// An act lasts about ACT_MS. A busy loop is shown as the spinner shows it (its
6// scene, with the scene a spinner word or mode puts in its place: sceneFor),
7// played whole as often as it takes to fill the act; a gesture plays for its
8// moment (MOMENT_MS); an idle move as the wander plays it; the skits he
9// plays while he minds background agents, each once (the tally for three
10// agents, the plant through its four stages); the breath and the blink
11// together; the sleep loops sped up to one pass in about ACT_MS (asleep, a
12// frame lasts 3 s). The words are the narration's own for a sample call, so
13// the tour says what the spinner would say. The whole tour runs about two
14// minutes.
15
16import { MOMENT_MS, narration, toolWord } from './activity'
17import { BLINK, CHEER_LOOP, COLD_LOOP, LOOPS, sceneFor, TEAM_LOOPS, TICK_MS, type FrameName } from './sprite'
18import { GARDEN_STAGE_MS, moveSteps, type MoveDetail, type MoveKind } from './wander'
19
20/** One step of the tour: the frame, what it shows (a spinner word or a move's name), its act (0-based), how long it shows. */
21export type DemoStep = { frame: FrameName; label: string; act: number; ms: number }
22
23/** About this long per act. */
24export const ACT_MS = 2_000
25
26/** One act: its name, and its frames, each with how long it shows and, where it differs from the act's, its own label. */
27type Act = { label: string; steps: { frame: FrameName; ms: number; label?: string }[] }
28
29/** The spinner words of the tour: the narration of a sample call, or of the spinner's mode with no call. */
30export const DEMO_WORDS = {
31  thinking: narration({ activity: 'thinking', word: '' }, 'thinking'),
32  writing: narration({ activity: 'thinking', word: '' }, 'responding'),
33  reading: toolWord('Read', { file_path: '/work/hooks/register.tsx' }),
34  searching: toolWord('Grep', { pattern: 'demo' }),
35  webSearch: toolWord('WebSearch', { query: 'demo' }),
36  browsing: toolWord('WebFetch', { url: 'https://example.com/', prompt: 'demo' }),
37  editing: toolWord('Edit', { file_path: '/work/hooks/register.tsx' }),
38  running: toolWord('Bash', { command: 'npm test' }),
39  delegating: toolWord('Agent', { description: 'demo', prompt: 'demo' }),
40  orchestrating: toolWord('Workflow', {}),
41  asking: toolWord('AskUserQuestion', {}),
42  working: toolWord('TodoWrite', {}),
43  skill: toolWord('Skill', { skill: 'demo' }),
44  calling: toolWord('mcp__github__search_repositories', {}),
45} as const
46
47/** A busy loop as the spinner shows it under a word and a mode: each step the scene sceneFor puts there. */
48function shownAs(loop: readonly FrameName[], word: string, mode: string): FrameName[] {
49  return loop.map(frame => sceneFor(frame, word, mode))
50}
51
52/** A loop played whole, as many times as it takes to fill ACT_MS, each frame `tickMs` long. */
53function looped(label: string, loop: readonly FrameName[], tickMs = TICK_MS): Act {
54  const passes = Math.max(1, Math.ceil(ACT_MS / Math.max(1, loop.length * tickMs)))
55  const steps: Act['steps'] = []
56
57  for (let pass = 0; pass < passes; pass += 1) {
58    for (const frame of loop) {
59      steps.push({ frame, ms: tickMs })
60    }
61  }
62
63  return { label, steps }
64}
65
66/** A busy activity's act: its loop as the spinner shows it under the word (a tool is running: mode `tool-use`). */
67function busy(word: string, loop: readonly FrameName[], mode = 'tool-use'): Act {
68  return looped(word, shownAs(loop, word, mode))
69}
70
71/** A gesture: its frames once, one per tick, the last held until its moment ends. */
72function gesture(label: string, frames: readonly FrameName[], ms: number): Act {
73  const ticks = Math.max(frames.length, Math.round(ms / TICK_MS))
74
75  return { label, steps: Array.from({ length: ticks }, (_, tick) => ({ frame: frames[Math.min(tick, frames.length - 1)] ?? 'idle', ms: TICK_MS })) }
76}
77
78/** An idle move as the wander plays it (a stroll as its walk, seven cells and the stop), whole, as often as it takes to fill ACT_MS. */
79function move(kind: MoveKind, detail: MoveDetail = {}): Act {
80  const steps = kind === 'stroll' ? moveSteps('stroll', 0, 7) : moveSteps(kind, 0, 0, detail)
81
82  return looped(kind, steps.map(step => step.frame))
83}
84
85/** The garden through its four stages: each watered and admired, two ticks apiece, as the plant grows over a long wait. */
86function garden(): Act {
87  const frames = [0, ...GARDEN_STAGE_MS].flatMap(forMs => {
88    const steps = moveSteps('garden', 0, 0, { forMs }).map(step => step.frame)
89
90    return [...new Set(steps)].flatMap(frame => [frame, frame])
91  })
92
93  return { label: 'garden', steps: frames.map(frame => ({ frame, ms: TICK_MS })) }
94}
95
96/** Idle on the status row: a breath (idle and idleUp, sped up from 2 s a side) and a blink as it runs (BLINK). */
97function idle(): Act {
98  return {
99    label: 'idle',
100    steps: [
101      { frame: 'idle', ms: 500, label: 'breath' },
102      { frame: 'idleUp', ms: 500, label: 'breath' },
103      { frame: 'idle', ms: 300, label: 'breath' },
104      ...BLINK.map(phase => ({ frame: phase.frame, ms: phase.ms, label: 'blink' })),
105      { frame: 'idle', ms: 300, label: 'blink' },
106    ],
107  }
108}
109
110/** A sleep loop sped up so one pass takes about ACT_MS: each frame a whole number of ticks. */
111function asleep(label: string, loop: readonly FrameName[]): Act {
112  const ticks = Math.max(1, Math.round(ACT_MS / TICK_MS / Math.max(1, loop.length)))
113
114  return looped(label, loop, ticks * TICK_MS)
115}
116
117const MOVES: readonly MoveKind[] = ['stroll', 'look', 'hop', 'yawn', 'sip', 'sweat', 'clock', 'pumpkin', 'peek']
118/** The skits he plays while he minds background agents, in the tour's order (the garden has its own act). */
119const SKITS: readonly MoveKind[] = ['launch', 'tally', 'radar', 'radio', 'report', 'perch', 'conduct', 'juggle', 'gum', 'popcorn', 'plane', 'zen']
120const TIMED_SKITS: readonly MoveKind[] = ['lantern', 'lunch']
121/** The tour's tally shows three agents at work. */
122const DEMO_AGENTS = 3
123
124/**
125 * The acts in order: every busy scene family as the spinner shows it (the
126 * word- and mode-swapped ones included, and the team of one, two and three
127 * helpers), then the greeting, the done hop, the cheer and the flinch, then
128 * idle (the breath and the blink) and every idle move, then the skits of a
129 * Claude minding background agents (the send-off first, the plant through
130 * its stages, the night shift and the lunch last), then the warm and the cold
131 * sleep.
132 */
133const ACTS: readonly Act[] = [
134  busy(DEMO_WORDS.thinking, LOOPS.thinking, 'thinking'),
135  busy(DEMO_WORDS.writing, LOOPS.thinking, 'responding'),
136  busy(DEMO_WORDS.reading, LOOPS.reading),
137  busy(DEMO_WORDS.searching, LOOPS.searching),
138  busy(DEMO_WORDS.webSearch, LOOPS.browsing),
139  busy(DEMO_WORDS.browsing, LOOPS.browsing),
140  busy(DEMO_WORDS.editing, LOOPS.editing),
141  busy(DEMO_WORDS.running, LOOPS.running),
142  busy(DEMO_WORDS.delegating, TEAM_LOOPS[1]),
143  busy(DEMO_WORDS.delegating, TEAM_LOOPS[2]),
144  busy(DEMO_WORDS.orchestrating, TEAM_LOOPS[3]),
145  busy(DEMO_WORDS.asking, LOOPS.asking),
146  busy(DEMO_WORDS.working, LOOPS.working),
147  busy(DEMO_WORDS.skill, LOOPS.working),
148  busy(DEMO_WORDS.calling, LOOPS.working),
149  looped('greeting', LOOPS.greeting),
150  gesture('done', LOOPS.done, MOMENT_MS.done),
151  gesture('cheer', CHEER_LOOP, MOMENT_MS.cheer),
152  gesture('oops', LOOPS.oops, MOMENT_MS.oops),
153  idle(),
154  ...MOVES.map(kind => move(kind)),
155  ...SKITS.map(kind => move(kind, { count: DEMO_AGENTS })),
156  garden(),
157  ...TIMED_SKITS.map(kind => move(kind)),
158  asleep('asleep', LOOPS.asleep),
159  asleep('asleep, cache cold', COLD_LOOP),
160]
161
162/** How many acts the tour has. */
163export const DEMO_ACTS: number = ACTS.length
164
165/** Each act's name, in order. */
166export function demoActs(): string[] {
167  return ACTS.map(act => act.label)
168}
169
170/**
171 * The tour as steps: every act's frames in order, a frame held over several
172 * ticks as one step (so a step is a change on screen). With `isAnimated` false
173 * (reduced motion), each act is its first frame alone, held for the act.
174 */
175export function demoSteps(isAnimated = true): DemoStep[] {
176  const steps: DemoStep[] = []
177
178  ACTS.forEach((act, index) => {
179    if (!isAnimated) {
180      const first = act.steps[0]
181
182      steps.push({ frame: first?.frame ?? 'idle', label: first?.label ?? act.label, act: index, ms: act.steps.reduce((sum, step) => sum + step.ms, 0) })
183
184      return
185    }
186
187    for (const step of act.steps) {
188      const label = step.label ?? act.label
189      const last = steps.at(-1)
190
191      if (last !== undefined && last.act === index && last.frame === step.frame && last.label === label) {
192        last.ms += step.ms
193      } else {
194        steps.push({ frame: step.frame, label, act: index, ms: step.ms })
195      }
196    }
197  })
198
199  return steps
200}
201
hooks/lib/activity.ts 1138 lines
1// What Claude is doing: tool -> activity, the safe object a spinner word may
2// name, the activity model (turns, tool calls in flight, decay, sleep), the
3// spinner word, and the /coworker text. Pure: no `$`.
4//
5// Safety: tool inputs are read only to derive a short object. A file is named
6// by its basename, a Bash command by its program name (never an argument, so
7// never a token or a password), a URL by its host. Nothing here logs.
8
9import type { CoworkerActivity, CoworkerDoing } from '../../types'
10
11export type Activity = CoworkerActivity
12
13/** The Spinner's `mode` prop. */
14export type SpinnerMode = 'requesting' | 'responding' | 'thinking' | 'tool-input' | 'tool-use'
15
16/** A tool event this long ago still shows its activity once nothing else runs. */
17export const DECAY_MS = 8_000
18/** Idle this long and Claude falls asleep. */
19export const SLEEP_MS = 10 * 60_000
20/** A tool call (or permission ask) with no end after this long is forgotten. */
21export const FORGET_MS = 30 * 60_000
22/**
23 * No sign of a background agent for this long (no tool event of one, no stop
24 * that reported one) and Claude stops minding them: as long as he takes to
25 * fall asleep, so a quiet ten minutes ends in sleep either way.
26 */
27export const QUIET_MS = SLEEP_MS
28/** An agent last heard from this long ago no longer counts as one at work (its end never came). */
29export const SEEN_MS = 3 * 60_000
30/** At most this many tool calls are held in flight; the oldest is dropped. */
31export const MAX_CALLS = 200
32/** The longest spinner word, in characters. */
33export const MAX_WORD = 40
34/**
35 * How long each gesture shows: the wave at session start, the hop after a
36 * good turn (`done`), the cheer after a long one (`cheer`: its six frames
37 * once, then a breath of idle), the flinch after a failed call.
38 */
39export const MOMENT_MS = { greeting: 2_000, done: 1_500, cheer: 2_000, oops: 1_250 } as const
40/** A main turn at least this long ends in a cheer instead of a hop. */
41export const LONG_TURN_MS = 2 * 60_000
42/** At least this long between two flinches, so a run of failing commands is not a tic. */
43export const OOPS_EVERY_MS = 10_000
44
45const ACTIVITY_OF_TOOL: ReadonlyMap<string, Activity> = new Map<string, Activity>([
46  ['Read', 'reading'],
47  ['NotebookRead', 'reading'],
48  ['Grep', 'searching'],
49  ['Glob', 'searching'],
50  ['ToolSearch', 'searching'],
51  ['LS', 'searching'],
52  ['Edit', 'editing'],
53  ['Write', 'editing'],
54  ['NotebookEdit', 'editing'],
55  ['MultiEdit', 'editing'],
56  ['Bash', 'running'],
57  ['BashOutput', 'running'],
58  ['Monitor', 'running'],
59  ['WebFetch', 'browsing'],
60  ['WebSearch', 'browsing'],
61  ['Agent', 'delegating'],
62  ['Task', 'delegating'],
63  ['Workflow', 'delegating'],
64  ['SendMessage', 'delegating'],
65  ['AskUserQuestion', 'asking'],
66])
67
68/**
69 * The background task types that are not agents: a shell or a monitor runs on
70 * with nobody thinking in it. Every other type (subagent, workflow, a type a
71 * later Claude Code adds) is agent work.
72 */
73const PASSIVE_TASK = /shell|bash|monitor|mcp/i
74/** A task status that says the task is over, should a report ever list one. */
75const ENDED_TASK = /^(completed|failed|killed|stopped|cancelled|canceled)$/i
76
77/**
78 * The spinner words a scene beside the spinner keys on (sprite.ts sceneFor):
79 * a web search's, a skill's, and the lead of an MCP call's (`Calling <server>`).
80 */
81export const WORDS = { webSearch: 'Searching the web', skill: 'Loading a skill', calling: 'Calling ' } as const
82
83/** The spinner word of each tool activity when the tool names no object. */
84const VERB: Readonly<Record<Activity, string>> = {
85  thinking: 'Thinking',
86  reading: 'Reading',
87  searching: 'Searching',
88  editing: 'Editing',
89  running: 'Running',
90  browsing: 'Browsing',
91  delegating: 'Delegating',
92  asking: 'Asking you',
93  working: 'Working',
94  greeting: '',
95  done: '',
96  oops: '',
97  supervising: '',
98  idle: '',
99  asleep: '',
100}
101
102/** The activity a tool call shows; any tool not listed (MCP tools, Skill, TodoWrite) is `working`. */
103export function activityOf(tool: string): Activity {
104  return ACTIVITY_OF_TOOL.get(tool) ?? 'working'
105}
106
107// Control characters, format characters (bidi marks and overrides, zero-width
108// and other invisible ones), line and paragraph separators: none of them may
109// reach a terminal row.
110const UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu
111const PROGRAM = /^[A-Za-z0-9._+-]{1,24}$/
112const HOST = /^[a-z0-9.-]{1,40}$/
113const SERVER = /^[A-Za-z0-9._-]{1,32}$/
114const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*\+?=/
115/** How far into a command the program search reads. */
116const MAX_SCAN = 4_096
117/** How many `cd <dir> &&` (or empty) segments the program search steps over. */
118const MAX_HOPS = 4
119
120function isRecord(value: unknown): value is Record<string, unknown> {
121  return typeof value === 'object' && value !== null && !Array.isArray(value)
122}
123
124/** The last path segment (`/a/b/register.ts` -> `register.ts`), trailing slashes ignored. */
125export function basename(path: string): string {
126  // a loop, not /\/+$/: that regex is quadratic on a long run of slashes
127  let end = path.length
128
129  while (end > 0 && path.charCodeAt(end - 1) === 47) {
130    end -= 1
131  }
132
133  const at = path.lastIndexOf('/', end - 1)
134
135  return path.slice(at + 1, end)
136}
137
138/** At most MAX_WORD characters, an ellipsis marking a cut. */
139export function fit(word: string): string {
140  const chars = [...word]
141
142  return chars.length <= MAX_WORD ? word : `${chars.slice(0, MAX_WORD - 1).join('')}\u2026`
143}
144
145function withObject(verb: string, object: string | undefined): string {
146  const safe = object === undefined ? '' : object.replace(UNSAFE, '').trim()
147
148  return safe === '' ? verb : fit(`${verb} ${safe}`)
149}
150
151function fileOf(value: unknown): string | undefined {
152  return typeof value === 'string' && value !== '' ? basename(value) : undefined
153}
154
155/**
156 * One word: its text with quotes removed, whether it opened with a quote or
157 * escape, whether it expands, and whether it holds an `=` written plainly with
158 * no quote or escape before it (only such a word is a `NAME=value` assignment).
159 */
160type Word = { text: string; isNameQuoted: boolean; isDynamic: boolean; hasPlainEq: boolean }
161type Simple = { words: Word[]; end: number }
162
163/**
164 * The words of one simple command starting at `from`, quotes removed, up to
165 * the next control operator (`&&`, `||`, `;`, `|`, `&`, newline, a paren).
166 * A word holding `$` or a backquote outside single quotes is dynamic.
167 */
168function lexSimple(src: string, from: number): Simple {
169  const words: Word[] = []
170  const limit = Math.min(src.length, MAX_SCAN)
171  let text = ''
172  let isNameQuoted = false
173  let isDynamic = false
174  let isInWord = false
175  let isMarked = false
176  let hasPlainEq = false
177  let at = from
178
179  const close = (): void => {
180    if (isInWord) {
181      words.push({ text, isNameQuoted, isDynamic, hasPlainEq })
182    }
183
184    text = ''
185    isNameQuoted = false
186    isDynamic = false
187    isInWord = false
188    isMarked = false
189    hasPlainEq = false
190  }
191
192  while (at < limit) {
193    const ch = src[at] ?? ''
194
195    if (ch === ' ' || ch === '\t') {
196      close()
197      at += 1
198      continue
199    }
200
201    if (ch === '\n' || ch === ';' || ch === '&' || ch === '|' || ch === '(' || ch === ')') {
202      close()
203      const pair = src.slice(at, at + 2)
204
205      return { words, end: at + (pair === '&&' || pair === '||' ? 2 : 1) }
206    }
207
208    if (!isInWord) {
209      isInWord = true
210      isNameQuoted = ch === '\\' || ch === "'" || ch === '"'
211    }
212
213    if (ch === '\\') {
214      isMarked = true
215      text += src[at + 1] ?? ''
216      at += 2
217      continue
218    }
219
220    if (ch === "'") {
221      isMarked = true
222      const stop = src.indexOf("'", at + 1)
223      const last = stop < 0 ? limit : Math.min(stop, limit)
224
225      text += src.slice(at + 1, last)
226      at = last + 1
227      continue
228    }
229
230    if (ch === '"') {
231      isMarked = true
232      at += 1
233
234      while (at < limit && src[at] !== '"') {
235        const inner = src[at] ?? ''
236
237        if (inner === '\\' && at + 1 < limit) {
238          text += src[at + 1] ?? ''
239          at += 2
240          continue
241        }
242
243        if (inner === '$' || inner === '`') {
244          isDynamic = true
245        }
246
247        text += inner
248        at += 1
249      }
250
251      at += 1
252      continue
253    }
254
255    if (ch === '$' || ch === '`') {
256      isDynamic = true
257    }
258
259    if (ch === '=' && !isMarked) {
260      hasPlainEq = true
261    }
262
263    text += ch
264    at += 1
265  }
266
267  close()
268
269  return { words, end: limit }
270}
271
272/**
273 * The program a Bash command runs first, for the spinner word: the basename of
274 * the first word after any `NAME=value` assignments, stepping over a leading
275 * `cd <dir> &&`. Undefined unless it matches `^[A-Za-z0-9._+-]{1,24}$`, so no
276 * argument, path, expansion or secret can come out of it. The lexer does not
277 * follow shell grammar past words and quotes, so wherever a word boundary
278 * cannot be trusted the answer is undefined: an assignment that expands
279 * (`H=\`cmd secret\``, `N=$((..))`), a command of assignments alone (the array
280 * `NAME=(a b)` reads that way), a redirection in the program's place.
281 */
282export function programOf(command: string): string | undefined {
283  let from = 0
284
285  for (let hop = 0; hop < MAX_HOPS && from < Math.min(command.length, MAX_SCAN); hop += 1) {
286    const { words, end } = lexSimple(command, from)
287    let index = 0
288
289    while (index < words.length && (words[index]?.hasPlainEq ?? false) && ASSIGNMENT.test(words[index]?.text ?? '')) {
290      if (words[index]?.isDynamic ?? false) {
291        return undefined
292      }
293
294      index += 1
295    }
296
297    const first = words[index]
298
299    if (first === undefined) {
300      if (index > 0) {
301        return undefined
302      }
303
304      from = end
305      continue
306    }
307
308    if (first.text === 'cd' && index === 0) {
309      from = end
310      continue
311    }
312
313    if (first.isDynamic || /[<>]/.test(first.text)) {
314      return undefined
315    }
316
317    const name = basename(first.text)
318
319    return PROGRAM.test(name) ? name : undefined
320  }
321
322  return undefined
323}
324
325/** The host of an http(s) or other `scheme://` URL, lowercase, without user info or port. */
326export function hostOf(url: string): string | undefined {
327  const text = url.trim()
328
329  if (!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(text)) {
330    return undefined
331  }
332
333  let host: string
334
335  try {
336    // the URL parser, not a hand split: a backslash or odd userinfo cannot move path text into the host
337    host = new URL(text).hostname.toLowerCase()
338  } catch {
339    return undefined
340  }
341
342  return HOST.test(host) ? host : undefined
343}
344
345/** The server of an MCP tool name `mcp__<server>__<tool>`. */
346export function mcpServerOf(tool: string): string | undefined {
347  if (!tool.startsWith('mcp__')) {
348    return undefined
349  }
350
351  const rest = tool.slice('mcp__'.length)
352  const at = rest.indexOf('__')
353  const server = at > 0 ? rest.slice(0, at) : ''
354
355  return SERVER.test(server) ? server : undefined
356}
357
358/** The spinner word for one tool call: the activity's verb and a safe object. */
359export function toolWord(tool: string, input: unknown): string {
360  const args = isRecord(input) ? input : {}
361
362  switch (tool) {
363    case 'Read':
364      return withObject('Reading', fileOf(args.file_path))
365    case 'NotebookRead':
366      return withObject('Reading', fileOf(args.notebook_path))
367    case 'Edit':
368    case 'Write':
369    case 'MultiEdit':
370      return withObject('Editing', fileOf(args.file_path))
371    case 'NotebookEdit':
372      return withObject('Editing', fileOf(args.notebook_path))
373    case 'Bash':
374      return withObject('Running', typeof args.command === 'string' ? programOf(args.command) : undefined)
375    case 'Grep':
376    case 'Glob':
377      return 'Searching'
378    case 'WebFetch':
379      return withObject('Browsing', typeof args.url === 'string' ? hostOf(args.url) : undefined)
380    case 'WebSearch':
381      return WORDS.webSearch
382    case 'Agent':
383    case 'Task':
384      return 'Delegating'
385    case 'Workflow':
386      return 'Orchestrating agents'
387    case 'Skill':
388      return WORDS.skill
389    case 'AskUserQuestion':
390      return 'Asking you'
391    default:
392      break
393  }
394
395  if (tool.startsWith('mcp__')) {
396    const server = mcpServerOf(tool)
397
398    return server === undefined ? 'Working' : fit(`${WORDS.calling}${server}`)
399  }
400
401  return VERB[activityOf(tool)] || 'Working'
402}
403
404/**
405 * The loop of an agent whose id is not known. `classic.PreToolUse` carries no
406 * agent id (the end events do), so a call that starts while no turn of the
407 * main loop runs is some agent's, and is held under this name until it ends.
408 */
409export const SOME_AGENT = '?'
410
411/** One tool call in flight: `loop` is the agent that made it (SOME_AGENT when only that much is known), empty for the main loop. */
412export type Call = { activity: Activity; word: string; startedAt: number; loop: string }
413
414/**
415 * The session's background work as a stop hook reports it: the tasks that are
416 * agents at work (subagents, workflows), the ones that only run (shells,
417 * monitors), and whether a workflow is among them.
418 */
419export type Background = { agents: number; shells: number; hasWorkflow: boolean }
420
421/** No background work. */
422export const NO_BACKGROUND: Background = { agents: 0, shells: 0, hasWorkflow: false }
423
424/**
425 * A stop hook's `background_tasks` as counts. A task is read by its `type`
426 * alone (never its command or description): shells and monitors only run; any
427 * other type is agent work; one whose status says it is over is not counted.
428 */
429export function backgroundOf(tasks: readonly unknown[]): Background {
430  const out = { agents: 0, shells: 0, hasWorkflow: false }
431
432  for (const task of tasks) {
433    const type = isRecord(task) && typeof task.type === 'string' ? task.type : ''
434    const status = isRecord(task) && typeof task.status === 'string' ? task.status : ''
435
436    if (ENDED_TASK.test(status)) {
437      continue
438    }
439
440    if (PASSIVE_TASK.test(type)) {
441      out.shells += 1
442    } else {
443      out.agents += 1
444      out.hasWorkflow = out.hasWorkflow || /workflow/i.test(type)
445    }
446  }
447
448  return out
449}
450
451/** What the activity model holds; one per session, mutated by the functions below. */
452export type Model = {
453  /** When the model was made (session start or module load). */
454  startedAt: number
455  /** True between the main loop's turn.start and its turn.complete. */
456  isTurnRunning: boolean
457  /** When the main loop's running turn began, if known (a hot reload in mid-turn does not know). */
458  turnStartedAt: number | undefined
459  turnEndedAt: number
460  /** Tool calls in flight by tool_use_id, oldest first. */
461  calls: Map<string, Call>
462  /** The last tool event (start or end), when it came and which loop's it was (empty: the main loop's). */
463  last: { activity: Activity; word: string; at: number; loop: string } | undefined
464  /**
465   * Permission dialogs that may still be open, by `<loop>|<tool>` (the loop is
466   * the agent id, empty for the main loop): when each was raised. The API has
467   * no event for an answered dialog, so an ask lasts until that loop's call
468   * ends (done, failed or denied) or its turn does.
469   */
470  asks: Map<string, number>
471  /** Background work the session reported at the last stop (agents and workflows, shells and monitors), and when. */
472  background: Background & { at: number }
473  /** The agents heard from and not yet ended: agent id -> when its last tool event came. */
474  agents: Map<string, number>
475  /** When an agent was last known to be at work (a tool event of one, a stop that reported some); 0 for never. */
476  agentsAt: number
477  /** When the present stretch of agent work began (the first sign after none); undefined while no agent is at work. */
478  agentsSince: number | undefined
479  /** When a background agent last finished, until the report it brings has been shown or has gone stale. */
480  report: number | undefined
481  /**
482   * A short gesture and when it ends. It shows over thinking, the decay and
483   * idle; a permission ask or a call in flight shows instead. `isLong` marks
484   * the end of a long turn (LONG_TURN_MS): a cheer instead of a hop.
485   */
486  moment: { kind: 'greeting' | 'done' | 'oops'; until: number; isLong?: boolean } | undefined
487  /** When Claude last flinched (OOPS_EVERY_MS apart at least). */
488  oopsAt: number
489}
490
491export function newModel(now: number): Model {
492  return {
493    startedAt: now,
494    isTurnRunning: false,
495    turnStartedAt: undefined,
496    turnEndedAt: 0,
497    calls: new Map(),
498    last: undefined,
499    asks: new Map(),
500    background: { ...NO_BACKGROUND, at: now },
501    agents: new Map(),
502    agentsAt: 0,
503    agentsSince: undefined,
504    report: undefined,
505    moment: undefined,
506    oopsAt: -OOPS_EVERY_MS,
507  }
508}
509
510/** The session opened: a wave. */
511export function greeted(model: Model, now: number): void {
512  model.moment = { kind: 'greeting', until: now + MOMENT_MS.greeting }
513}
514
515/** One of the main loop's own tool calls failed: a flinch, at most one per OOPS_EVERY_MS. */
516export function toolFailed(model: Model, now: number): void {
517  if (now - model.oopsAt < OOPS_EVERY_MS) {
518    return
519  }
520
521  model.oopsAt = now
522  model.moment = { kind: 'oops', until: now + MOMENT_MS.oops }
523}
524
525/** The main loop's turn began at `now` (a subagent's run raises no turn.start). */
526export function turnStarted(model: Model, now?: number): void {
527  model.isTurnRunning = true
528  model.turnStartedAt = now !== undefined && Number.isFinite(now) ? now : undefined
529  dropAsks(model, '')
530  model.moment = undefined
531}
532
533function askKey(loop: string, tool: string): string {
534  return `${loop}|${tool}`
535}
536
537/** Forgets one loop's asks: its turn began or ended, so no dialog of its can be open. */
538function dropAsks(model: Model, loop: string): void {
539  for (const key of model.asks.keys()) {
540    if (key.startsWith(`${loop}|`)) {
541      model.asks.delete(key)
542    }
543  }
544}
545
546/** A sign that an agent is at work now: the stretch of agent work begins with the first. */
547function agentSign(model: Model, now: number): void {
548  model.agentsAt = Math.max(model.agentsAt, now)
549
550  if (model.agentsSince === undefined) {
551    model.agentsSince = now
552  }
553}
554
555/** Drops the calls in flight of one loop (an agent that ended), or of every agent. */
556function dropCalls(model: Model, isOf: (loop: string) => boolean): void {
557  for (const [id, call] of model.calls) {
558    if (isOf(call.loop)) {
559      model.calls.delete(id)
560    }
561  }
562}
563
564/**
565 * How an agent's run ended: when; whether the main loop was resting then (so
566 * the agent was one in the background, not one of a running main turn's
567 * own); and whether it ended with an answer (not stopped, not on an error).
568 */
569export type AgentEnd = { at: number; isResting: boolean; isAnswer: boolean }
570
571/**
572 * A subagent's or workflow agent's run ended: its asks and its calls in
573 * flight go with it and it no longer counts as at work. With `end`, the end
574 * is the last sign of agent work (the idle time runs from it, not from the
575 * agent's last tool event). One that ended while the main loop rested was a
576 * background agent: it is no longer one of the agent tasks last reported
577 * (unless a workflow is among them: a workflow is one task of many agents,
578 * so the count stands until the main loop's next stop), and when it ended
579 * with an answer it leaves a report for Claude to be brought: a helper with a
580 * page, shown while the report is fresh. An agent of a running main turn
581 * touches neither: that turn's own stop reads the count again.
582 */
583export function agentEnded(model: Model, agentId: string, end?: AgentEnd): void {
584  dropAsks(model, agentId)
585
586  if (agentId === '') {
587    return
588  }
589
590  dropCalls(model, loop => loop === agentId)
591  model.agents.delete(agentId)
592
593  if (end === undefined || !Number.isFinite(end.at)) {
594    return
595  }
596
597  model.agentsAt = Math.max(model.agentsAt, end.at)
598
599  if (!end.isResting) {
600    return
601  }
602
603  if (!model.background.hasWorkflow && model.background.agents > 0) {
604    model.background = { ...model.background, agents: model.background.agents - 1 }
605  }
606
607  if (end.isAnswer) {
608    model.report = end.at
609  }
610}
611
612/**
613 * What the session said is still running in the background when a loop
614 * stopped. Shells and monitors keep nothing busy; agents and workflows are
615 * agent work. The main loop's stop (`isMain`) is the word on it, taken when
616 * none of its own calls or agents can still run. Agents there are a sign of
617 * agent work now, and every call still in flight is one of theirs (a call an
618 * agent started while the main turn ran was held as the main loop's, for
619 * want of an agent id): held as some agent's from here on. None there means
620 * no agent is at work: every call in flight and the agents heard from are
621 * dropped (one that was killed leaves no end event). An agent's own stop can
622 * only lower the count (its siblings may be the main turn's own, gone with an
623 * interrupt, so it never raises it).
624 */
625export function backgroundSeen(model: Model, background: Background, now: number, isMain = true): void {
626  const agents = Math.max(0, Math.floor(background.agents))
627  const shells = Math.max(0, Math.floor(background.shells))
628
629  if (!isMain) {
630    const fewer = Math.min(model.background.agents, agents)
631
632    model.background = { agents: fewer, shells, hasWorkflow: model.background.hasWorkflow && fewer > 0, at: model.background.at }
633
634    return
635  }
636
637  model.background = { agents, shells, hasWorkflow: background.hasWorkflow && agents > 0, at: now }
638
639  if (agents > 0) {
640    agentSign(model, now)
641
642    for (const call of model.calls.values()) {
643      if (call.loop === '') {
644        call.loop = SOME_AGENT
645      }
646    }
647  } else {
648    model.agents.clear()
649    model.calls.clear()
650  }
651}
652
653/** True when anything says an agent may be at work (a cheap check, before the clock is read to ask agentsAtWork). */
654export function hasAgentSigns(model: Model): boolean {
655  return model.background.agents > 0 || model.agents.size > 0
656}
657
658/**
659 * True while agents are at work as far as the session has said: a call of
660 * one is in flight (its id known or not), or one was heard from (or a stop
661 * reported some) within QUIET_MS and none of that has been taken back (an
662 * agent's end, a main stop that reported none).
663 */
664export function agentsAtWork(model: Model, now: number): boolean {
665  for (const call of model.calls.values()) {
666    if (call.loop !== '') {
667      return true
668    }
669  }
670
671  return (model.background.agents > 0 || model.agents.size > 0) && now - model.agentsAt < QUIET_MS
672}
673
674/**
675 * How many agents are at work, as near as the events say: the agents with a
676 * call in flight or heard from within SEEN_MS, or the agent tasks the last
677 * stop reported when that is more (a workflow is one task of several agents;
678 * a quiet agent is still a task). At least 1.
679 */
680export function agentCount(model: Model, now: number): number {
681  const ids = new Set<string>()
682
683  for (const [id, at] of model.agents) {
684    if (now - at < SEEN_MS) {
685      ids.add(id)
686    }
687  }
688
689  for (const call of model.calls.values()) {
690    if (call.loop !== '' && call.loop !== SOME_AGENT) {
691      ids.add(call.loop)
692    }
693  }
694
695  return Math.max(1, ids.size, model.background.agents)
696}
697
698/**
699 * True while the last tool event still shows with no turn running: it was
700 * the main loop's own, came after its turn ended and within DECAY_MS. The
701 * main turn's own last call does not linger once the turn is over, and an
702 * agent's events show as `supervising`, not as a decay.
703 */
704function isDecaying(model: Model, now: number): boolean {
705  return model.last !== undefined && model.last.loop === '' && model.last.at > model.turnEndedAt && now - model.last.at < DECAY_MS
706}
707
708/**
709 * The main loop's turn ended. Its own calls cannot still run, and an interrupt
710 * or an API error may leave a call with no end event, so the calls held as
711 * its own are dropped (after a stop that reported agents none is left held
712 * so: backgroundSeen). Calls held as an agent's stay while the session's last
713 * stop reported agents in the background; with none reported, only those of
714 * some agent that started between main turns stay (they cannot be this
715 * turn's), and the agents heard from are forgotten (an interrupt ends the
716 * main loop's own agents with no end event). FORGET_MS bounds what stays.
717 * A turn that ended with an answer (not interrupted, no error) gets a hop, or
718 * a cheer when it ran LONG_TURN_MS or more: `durationMs` as the engine
719 * measured it, else from the turn's start as recorded here.
720 */
721export function turnEnded(model: Model, now: number, isAnswered = false, durationMs?: number): void {
722  const ran =
723    durationMs !== undefined && Number.isFinite(durationMs) && durationMs >= 0
724      ? durationMs
725      : model.isTurnRunning && model.turnStartedAt !== undefined
726        ? now - model.turnStartedAt
727        : 0
728  const isLong = ran >= LONG_TURN_MS
729
730  model.isTurnRunning = false
731  model.turnStartedAt = undefined
732  model.turnEndedAt = now
733  dropAsks(model, '')
734
735  if (model.background.agents > 0) {
736    dropCalls(model, loop => loop === '')
737  } else {
738    dropCalls(model, loop => loop !== SOME_AGENT)
739    model.agents.clear()
740  }
741
742  model.moment = isAnswered ? { kind: 'done', until: now + (isLong ? MOMENT_MS.cheer : MOMENT_MS.done), isLong } : undefined
743}
744
745/** True while the shown moment is the cheer after a long turn. */
746export function isCheering(model: Model): boolean {
747  return model.moment?.kind === 'done' && model.moment.isLong === true
748}
749
750/**
751 * How many delegating calls (Agent, Task, Workflow, SendMessage) are in flight,
752 * 1 to 3: the helpers the delegating scene draws. None in flight (delegating
753 * shown for background work) is one; more than three are three.
754 */
755export function teamSize(model: Model): 1 | 2 | 3 {
756  let count = 0
757
758  for (const call of model.calls.values()) {
759    if (call.activity === 'delegating') {
760      count += 1
761    }
762  }
763
764  return count >= 3 ? 3 : count === 2 ? 2 : 1
765}
766
767/** Drops calls (and an ask) older than FORGET_MS, and agents not heard from for QUIET_MS: their end event never came. */
768function forget(model: Model, now: number): void {
769  for (const [id, at] of model.agents) {
770    if (now - at >= QUIET_MS) {
771      model.agents.delete(id)
772    }
773  }
774
775  for (const [id, call] of model.calls) {
776    if (now - call.startedAt < FORGET_MS) {
777      break
778    }
779
780    model.calls.delete(id)
781  }
782
783  for (const [key, at] of model.asks) {
784    if (now - at >= FORGET_MS) {
785      model.asks.delete(key)
786    }
787  }
788}
789
790/**
791 * classic.PreToolUse let a call through: it is in flight until its
792 * PostToolUse or PostToolUseFailure. `loop` is the agent id of the loop that
793 * made it where the event says (today it never does); with none, a call that
794 * starts while a turn of the main loop runs is taken for the main loop's (or
795 * one of its agents', as before), and one that starts with no such turn is
796 * some agent's (SOME_AGENT): the main loop calls nothing between its turns.
797 * An agent's call is a sign of it at work.
798 */
799export function toolStarted(model: Model, id: string, tool: string, input: unknown, now: number, loop = ''): void {
800  const owner = loop !== '' ? loop : model.isTurnRunning ? '' : SOME_AGENT
801  const call: Call = { activity: activityOf(tool), word: toolWord(tool, input), startedAt: now, loop: owner }
802
803  forget(model, now)
804  model.calls.delete(id)
805
806  while (model.calls.size >= MAX_CALLS) {
807    const oldest = model.calls.keys().next()
808
809    if (oldest.done === true) {
810      break
811    }
812
813    model.calls.delete(oldest.value)
814  }
815
816  model.calls.set(id, call)
817  model.last = { activity: call.activity, word: call.word, at: now, loop: owner }
818  heardFrom(model, owner, now)
819}
820
821/**
822 * An agent's tool event: agents are at work as of `now`, and the agent, when
823 * its id is known, is one of them. The main loop's (an empty `loop`) says
824 * nothing of agents.
825 */
826function heardFrom(model: Model, loop: string, now: number): void {
827  if (loop === '') {
828    return
829  }
830
831  if (loop !== SOME_AGENT) {
832    model.agents.set(loop, now)
833  }
834
835  agentSign(model, now)
836}
837
838/**
839 * A call ended (done, failed or denied); its activity shows for DECAY_MS once
840 * nothing else runs. `loop` is the agent id of the loop that made it (empty
841 * for the main loop): an ask of that loop for that tool is over.
842 */
843export function toolEnded(model: Model, id: string, tool: string, input: unknown, now: number, loop = ''): void {
844  const call = model.calls.get(id)
845  // the end event names the agent; without a name, a call held as some agent's stays one
846  const owner = loop !== '' ? loop : (call?.loop ?? '')
847
848  model.calls.delete(id)
849  model.last = call === undefined
850    ? { activity: activityOf(tool), word: toolWord(tool, input), at: now, loop: owner }
851    : { activity: call.activity, word: call.word, at: now, loop: owner }
852  model.asks.delete(askKey(loop, tool))
853  heardFrom(model, owner, now)
854}
855
856/**
857 * A permission dialog opened for a call of `loop` (empty: the main loop):
858 * `needs you` until that call ends or that loop's turn does. Other loops'
859 * tool events leave it alone.
860 */
861export function permissionAsked(model: Model, now: number, loop = '', tool = ''): void {
862  model.asks.set(askKey(loop, tool), now)
863}
864
865function newestCall(model: Model): Call | undefined {
866  let newest: Call | undefined
867
868  for (const call of model.calls.values()) {
869    newest = call
870  }
871
872  return newest
873}
874
875/**
876 * When Claude went (or goes) idle: the latest of start, turn end, the main
877 * loop's last late tool event plus the decay, and the last sign of an agent
878 * at work.
879 */
880export function idleSince(model: Model): number {
881  const lastShown = model.last !== undefined && model.last.loop === '' && model.last.at > model.turnEndedAt ? model.last.at + DECAY_MS : 0
882
883  return Math.max(model.startedAt, model.turnEndedAt, lastShown, model.agentsAt)
884}
885
886/** The gesture still showing at `now`, if any; one that has run out is cleared. */
887function momentAt(model: Model, now: number): CoworkerDoing | undefined {
888  if (model.moment === undefined) {
889    return undefined
890  }
891
892  if (now < model.moment.until) {
893    return { activity: model.moment.kind, word: '' }
894  }
895
896  model.moment = undefined
897
898  return undefined
899}
900
901/**
902 * The shown activity. A pending permission ask comes first. Then, while the
903 * main loop is at work (`isResting` false: its turn runs and its spinner is
904 * drawn): the most recently started call in flight, else a gesture still
905 * running, else `thinking`. While it rests (no turn, or a turn left open with
906 * no spinner drawn, as between a /goal's iterations): a gesture still running
907 * (the hop at the turn's end plays out first), else `supervising` while
908 * agents are at work in the background, else the most recently started call
909 * in flight, else `thinking` while the turn is open, else the main loop's
910 * last late tool event within DECAY_MS, else idle, asleep after SLEEP_MS.
911 * Background shells keep nothing busy.
912 */
913export function shown(model: Model, now: number, isResting = !model.isTurnRunning): CoworkerDoing {
914  forget(model, now)
915
916  const isAtWork = agentsAtWork(model, now)
917
918  if (!isAtWork) {
919    model.agentsSince = undefined
920  }
921
922  if (model.asks.size > 0) {
923    return { activity: 'asking', word: VERB.asking }
924  }
925
926  const newest = newestCall(model)
927
928  if (!isResting) {
929    if (newest !== undefined) {
930      return { activity: newest.activity, word: newest.word }
931    }
932
933    return momentAt(model, now) ?? { activity: 'thinking', word: '' }
934  }
935
936  const moment = momentAt(model, now)
937
938  if (moment !== undefined) {
939    return moment
940  }
941
942  if (isAtWork) {
943    return { activity: 'supervising', word: '' }
944  }
945
946  if (newest !== undefined) {
947    return { activity: newest.activity, word: newest.word }
948  }
949
950  if (model.isTurnRunning) {
951    return { activity: 'thinking', word: '' }
952  }
953
954  if (model.last !== undefined && isDecaying(model, now)) {
955    return { activity: model.last.activity, word: model.last.word }
956  }
957
958  return { activity: now - idleSince(model) >= SLEEP_MS ? 'asleep' : 'idle', word: '' }
959}
960
961/**
962 * Milliseconds until the shown activity can change with no event: a call or
963 * ask being forgotten, a gesture or the decay ending, the agents going quiet
964 * (QUIET_MS after their last sign, once none of their calls is in flight), or
965 * falling asleep. Undefined when only an event can change it (a turn runs, or
966 * Claude is asleep).
967 */
968export function nextChangeIn(model: Model, now: number): number | undefined {
969  forget(model, now)
970  const due: number[] = []
971  const isAtWork = agentsAtWork(model, now)
972
973  for (const at of model.asks.values()) {
974    due.push(at + FORGET_MS)
975  }
976
977  const oldest = model.calls.values().next()
978
979  if (oldest.done !== true) {
980    due.push(oldest.value.startedAt + FORGET_MS)
981  }
982
983  if (model.moment !== undefined && model.moment.until > now) {
984    due.push(model.moment.until)
985  }
986
987  let hasAgentCall = false
988
989  for (const call of model.calls.values()) {
990    hasAgentCall = hasAgentCall || call.loop !== ''
991  }
992
993  if (isAtWork && !hasAgentCall) {
994    // minding the agents until nothing has been heard of them for QUIET_MS (a call of theirs in
995    // flight keeps them at work for as long as it is held)
996    due.push(model.agentsAt + QUIET_MS)
997  }
998
999  if (model.asks.size === 0 && model.calls.size === 0 && !model.isTurnRunning && !isAtWork) {
1000    if (model.last !== undefined && isDecaying(model, now)) {
1001      due.push(model.last.at + DECAY_MS)
1002    } else {
1003      const sleepAt = idleSince(model) + SLEEP_MS
1004
1005      if (sleepAt > now) {
1006        due.push(sleepAt)
1007      }
1008    }
1009  }
1010
1011  return due.length === 0 ? undefined : Math.max(1, Math.min(...due) - now)
1012}
1013
1014/** The doing's identity: equal keys narrate the same. */
1015export function doingKey(doing: CoworkerDoing): string {
1016  return `${doing.activity}|${doing.word}`
1017}
1018
1019/**
1020 * The main loop's spinner word: the tool activity's word, else from the
1021 * spinner's own mode (`responding` -> Writing, `tool-use` with nothing known
1022 * -> Working, the rest -> Thinking).
1023 */
1024export function narration(doing: CoworkerDoing, mode: SpinnerMode): string {
1025  const isTool = doing.word !== '' && doing.activity !== 'thinking' && doing.activity !== 'idle' && doing.activity !== 'asleep'
1026
1027  if (isTool && doing.word !== '') {
1028    return doing.word
1029  }
1030
1031  switch (mode) {
1032    case 'responding':
1033      return 'Writing'
1034    case 'tool-use':
1035      return 'Working'
1036    default:
1037      return 'Thinking'
1038  }
1039}
1040
1041/** `45s`, `2m 10s`, `1h 5m`. */
1042export function formatDuration(ms: number): string {
1043  const seconds = Math.max(0, Math.floor(ms / 1000))
1044
1045  if (seconds < 60) {
1046    return `${seconds}s`
1047  }
1048
1049  const minutes = Math.floor(seconds / 60)
1050
1051  if (minutes < 60) {
1052    return `${minutes}m ${seconds % 60}s`
1053  }
1054
1055  return `${Math.floor(minutes / 60)}h ${minutes % 60}m`
1056}
1057
1058export type Switches = { sprite: boolean; narrate: boolean; animate: boolean }
1059
1060export type StatusInput = {
1061  isInteractive: boolean
1062  /** `/coworker off` is in force for this session. */
1063  isOff: boolean
1064  options: Switches
1065  doing: CoworkerDoing
1066  /** How long the current busy or idle stretch has lasted. */
1067  forMs: number
1068  /** While he minds background agents: how many are at work. */
1069  agents?: number
1070  /** The `/coworker demo` tour under way: which act (1-based) of how many, and what it shows. */
1071  demo?: { act: number; of: number; label: string }
1072}
1073
1074const PHRASE: Readonly<Record<Activity, string>> = {
1075  thinking: 'thinking',
1076  reading: 'reading',
1077  searching: 'searching',
1078  editing: 'editing',
1079  running: 'running something',
1080  browsing: 'browsing',
1081  delegating: 'delegating to agents',
1082  asking: 'waiting on you',
1083  working: 'working',
1084  greeting: 'saying hi',
1085  done: 'done with the turn',
1086  oops: 'wincing at a failed call',
1087  supervising: 'minding the agents',
1088  idle: 'idle',
1089  asleep: 'asleep',
1090}
1091
1092function onOff(isOn: boolean): string {
1093  return isOn ? 'on' : 'off'
1094}
1095
1096/** The /coworker demo line of the status: the tour under way, or what the command does. */
1097export const DEMO_HINT = '/coworker demo plays every animation once, about two minutes, in the band above the prompt'
1098
1099/**
1100 * The /coworker status: one line of switches, one of what Claude is doing and
1101 * for how long, and one about `/coworker demo` (the act under way while it
1102 * plays).
1103 */
1104export function statusText(input: StatusInput): string {
1105  const switches = `sprite ${onOff(input.options.sprite)}, narration ${onOff(input.options.narrate)}, animation ${onOff(input.options.animate)}`
1106
1107  if (!input.isInteractive) {
1108    return `headless session, nothing is drawn here; in an interactive terminal: ${switches}`
1109  }
1110
1111  const first = input.isOff ? `switched off for this session (/coworker on brings it back); options: ${switches}` : switches
1112  const isIdle = input.doing.activity === 'idle' || input.doing.activity === 'asleep'
1113  const demo = input.demo === undefined ? DEMO_HINT : `demo playing: ${input.demo.act} of ${input.demo.of}, ${input.demo.label}; /coworker demo again stops it`
1114
1115  const phrase =
1116    input.doing.activity === 'supervising' && input.agents !== undefined
1117      ? `minding ${input.agents} agent${input.agents === 1 ? '' : 's'} in the background`
1118      : PHRASE[input.doing.activity]
1119
1120  return `${first}\nClaude is ${phrase} (${isIdle ? 'idle' : 'busy'} for ${formatDuration(input.forMs)})\n${demo}`
1121}
1122
1123export type CommandVerb = 'status' | 'on' | 'off' | 'demo' | 'unknown'
1124
1125/** What `/coworker <args>` asks for. */
1126export function parseCommand(args: string): CommandVerb {
1127  const word = args.trim().toLowerCase()
1128
1129  if (word === '' || word === 'status') {
1130    return 'status'
1131  }
1132
1133  return word === 'on' || word === 'off' || word === 'demo' ? word : 'unknown'
1134}
1135
1136export const USAGE =
1137  'usage: /coworker [on | off | demo]  (on and off switch Claude and the spinner words for this session; demo plays every animation once in the band above the prompt, and again stops it; typed at the prompt only)'
1138
hooks/lib/sprite.ts 785 lines
1// Claude's frames: the 132 picture frames by name (scripts/frames.json,
2// mirrored here), the twelve braille poses that stand in for them where no
3// picture can be drawn, the frame loop of each activity, the scene each
4// spinner word and mode puts beside the spinner, the blink, and the view and
5// row pieces the render hooks draw. Pure: no `$`.
6
7import type { CoworkerActivity, CoworkerView } from '../../types'
8import { WORDS } from './activity'
9import { clampX, WANDER_RANGE } from './wander'
10
11/**
12 * One of the twelve braille poses: 10x4 pixels, five braille cells. Each
13 * picture frame names the pose that stands in for it: the picture's `alt`
14 * where the terminal has no graphics, and the drawing itself with the
15 * `picture` option off.
16 */
17export type Pose = 'idle' | 'blink' | 'lookL' | 'lookR' | 'focus' | 'armsIn' | 'stepA' | 'stepB' | 'hop' | 'wave' | 'flinch' | 'sleep'
18
19/**
20 * The poses as pixel rows, top to bottom, `#` on and `.` off: the Claude Code
21 * mascot (wide body, two wide-set eyes, arm nubs at the sides, four small legs).
22 * Edit a row here and BRAILLE follows.
23 */
24export const PIXELS: Readonly<Record<Pose, readonly string[]>> = {
25  idle: ['.########.', '.#.####.#.', '##########', '.#.#..#.#.'],
26  blink: ['.########.', '.########.', '##########', '.#.#..#.#.'],
27  lookL: ['.########.', '.#.###.##.', '##########', '.#.#..#.#.'],
28  lookR: ['.########.', '.##.###.#.', '##########', '.#.#..#.#.'],
29  focus: ['.########.', '.##.##.##.', '##########', '.#.#..#.#.'],
30  armsIn: ['.########.', '.#.####.#.', '.########.', '.#.#..#.#.'],
31  stepA: ['.########.', '.#.####.#.', '##########', '#.#....#.#'],
32  stepB: ['.########.', '.#.####.#.', '##########', '..##..##..'],
33  hop: ['.#.####.#.', '##########', '.#.#..#.#.', '..........'],
34  // the right arm lifted off the side, up beside the head
35  wave: ['.#########', '.#.####.#.', '#########.', '.#.#..#.#.'],
36  // eyes shut, arms pulled in
37  flinch: ['.########.', '.########.', '.########.', '.#.#..#.#.'],
38  sleep: ['..........', '.########.', '##########', '.#.#..#.#.'],
39}
40
41/** Braille dot bits of a cell's left pixel column, rows top to bottom. */
42const LEFT_BITS = [0x01, 0x02, 0x04, 0x40] as const
43/** Braille dot bits of a cell's right pixel column, rows top to bottom. */
44const RIGHT_BITS = [0x08, 0x10, 0x20, 0x80] as const
45const BRAILLE_BASE = 0x2800
46
47/**
48 * Pixel rows (`#` on, anything else off) as braille: two pixel columns and
49 * four rows per cell. Rows past the fourth are ignored; a short row or an odd
50 * width reads as off pixels.
51 */
52export function braille(rows: readonly string[]): string {
53  const width = rows.slice(0, 4).reduce((most, row) => Math.max(most, row.length), 0)
54  let out = ''
55
56  for (let column = 0; column < width; column += 2) {
57    let bits = 0
58
59    for (let row = 0; row < 4; row += 1) {
60      const line = rows[row] ?? ''
61
62      if (line[column] === '#') {
63        bits |= LEFT_BITS[row] ?? 0
64      }
65
66      if (line[column + 1] === '#') {
67        bits |= RIGHT_BITS[row] ?? 0
68      }
69    }
70
71    out += String.fromCharCode(BRAILLE_BASE + bits)
72  }
73
74  return out
75}
76
77function composeAll(): Readonly<Record<Pose, string>> {
78  const out = {} as Record<Pose, string>
79
80  for (const name of Object.keys(PIXELS) as Pose[]) {
81    out[name] = braille(PIXELS[name])
82  }
83
84  return out
85}
86
87/** Every pose as its braille string, composed once at load. */
88export const BRAILLE: Readonly<Record<Pose, string>> = composeAll()
89
90// The frame table: scripts/frames.json, mirrored. tests/test_frames.py reads
91// both and holds them equal, kind by kind, entry for entry and in order, and
92// checks that each frame's PNG exists at the size its kind gives. Keep one
93// entry per line.
94
95/**
96 * The solo frames: Claude alone, 8 cells by 2 rows (4 by 1 with `big` off),
97 * drawn anywhere. Each names the braille pose that stands in for it. From
98 * tally0 on (0.6.0) they are the skits of a Claude minding background agents,
99 * and the peek at a background shell (term1, term2).
100 */
101export const SOLO_FRAMES = {
102  idle: 'idle',
103  idleUp: 'idle',
104  blink: 'blink',
105  lookL: 'lookL',
106  lookR: 'lookR',
107  lookUp: 'idle',
108  lookDown: 'focus',
109  armsIn: 'armsIn',
110  stepA: 'stepA',
111  stepB: 'stepB',
112  hop1: 'idle',
113  hop2: 'hop',
114  hop3: 'idle',
115  wave1: 'wave',
116  wave2: 'idle',
117  flinch: 'flinch',
118  cheer1: 'hop',
119  cheer2: 'wave',
120  cheer3: 'hop',
121  yawn1: 'blink',
122  yawn2: 'blink',
123  sip1: 'armsIn',
124  sip2: 'blink',
125  sweat1: 'lookL',
126  sweat2: 'lookL',
127  clock1: 'focus',
128  clock2: 'focus',
129  pumpkin1: 'idle',
130  pumpkin2: 'blink',
131  sleep1: 'sleep',
132  sleep2: 'sleep',
133  sleep3: 'sleep',
134  sleepCold1: 'sleep',
135  sleepCold2: 'sleep',
136  blinkHalf: 'blink',
137  tally0: 'idle',
138  tally1: 'wave',
139  tally2: 'wave',
140  tally3: 'wave',
141  tally4: 'wave',
142  tally5: 'wave',
143  tally6: 'wave',
144  tally7: 'wave',
145  tally8: 'wave',
146  tally9: 'wave',
147  tallyMany: 'wave',
148  radar1: 'lookR',
149  radar2: 'lookR',
150  radar3: 'lookR',
151  radar4: 'blink',
152  radio1: 'idle',
153  radio2: 'lookL',
154  radio3: 'blink',
155  report1: 'lookR',
156  report2: 'lookR',
157  report3: 'focus',
158  report4: 'blink',
159  launch1: 'lookR',
160  launch2: 'lookR',
161  launch3: 'idle',
162  perch1: 'idle',
163  perch2: 'blink',
164  conduct1: 'wave',
165  conduct2: 'idle',
166  conduct3: 'wave',
167  juggle1: 'idle',
168  juggle2: 'wave',
169  juggle3: 'idle',
170  gum1: 'idle',
171  gum2: 'idle',
172  gum3: 'flinch',
173  popcorn1: 'lookL',
174  popcorn2: 'blink',
175  plane1: 'lookR',
176  plane2: 'wave',
177  plane3: 'lookR',
178  plane4: 'lookL',
179  plane5: 'flinch',
180  zen1: 'blink',
181  zen2: 'blink',
182  plant1: 'lookR',
183  plant2: 'lookR',
184  plant3: 'lookR',
185  plant4: 'blink',
186  water1: 'lookR',
187  water2: 'lookR',
188  water3: 'lookR',
189  water4: 'lookR',
190  lantern1: 'idle',
191  lantern2: 'blink',
192  lunch1: 'idle',
193  lunch2: 'blink',
194  term1: 'lookR',
195  term2: 'lookR',
196} as const satisfies Record<string, Pose>
197
198export type SoloFrame = keyof typeof SOLO_FRAMES
199
200/**
201 * The scene frames: Claude at the left exactly as in his solo frame, a prop
202 * at the right, 12 cells by 2 rows, drawn only beside the spinner. Each names
203 * the solo frame it is built on, which is what is drawn in its place wherever
204 * a scene cannot be (the footer, `scenes` off), the team scenes excepted:
205 * footerOf.
206 */
207export const SCENE_FRAMES = {
208  think1: 'lookUp',
209  think2: 'lookUp',
210  think3: 'idle',
211  read1: 'lookDown',
212  read2: 'lookDown',
213  read3: 'blink',
214  search1: 'lookL',
215  search2: 'idle',
216  search3: 'lookR',
217  type1: 'armsIn',
218  type2: 'idle',
219  type3: 'armsIn',
220  run1: 'stepA',
221  run2: 'stepB',
222  run3: 'stepA',
223  web1: 'lookR',
224  web2: 'lookR',
225  web3: 'idle',
226  team1a: 'hop2',
227  team1b: 'idle',
228  team2a: 'hop2',
229  team2b: 'idle',
230  team3a: 'hop2',
231  team3b: 'idle',
232  ask1: 'wave1',
233  ask2: 'wave2',
234  work1: 'idle',
235  work2: 'blink',
236  write1: 'lookDown',
237  write2: 'lookDown',
238  write3: 'blink',
239  webSearch1: 'lookR',
240  webSearch2: 'lookR',
241  webSearch3: 'idle',
242  skill1: 'lookDown',
243  skill2: 'idle',
244  plug1: 'armsIn',
245  plug2: 'idle',
246} as const satisfies Record<string, SoloFrame>
247
248export type SceneFrame = keyof typeof SCENE_FRAMES
249
250/** One picture frame by name: assets/frames/<name>.png (scripts/make-frames.py draws them). */
251export type FrameName = SoloFrame | SceneFrame
252
253/** Every frame name: the solo frames, then the scenes, each kind in the order of scripts/frames.json. */
254export const FRAME_NAMES: readonly FrameName[] = [...(Object.keys(SOLO_FRAMES) as SoloFrame[]), ...(Object.keys(SCENE_FRAMES) as SceneFrame[])]
255
256const SOLO_SET: ReadonlySet<string> = new Set(Object.keys(SOLO_FRAMES))
257const SCENE_SET: ReadonlySet<string> = new Set(Object.keys(SCENE_FRAMES))
258
259/** True for a name the table holds (a value read back from $.state may be anything). */
260export function isFrameName(value: unknown): value is FrameName {
261  return typeof value === 'string' && (SOLO_SET.has(value) || SCENE_SET.has(value))
262}
263
264/** True for a scene frame (12 cells wide, beside the spinner only). */
265export function isScene(frame: FrameName): frame is SceneFrame {
266  return SCENE_SET.has(frame)
267}
268
269/** The solo frame a frame shows where a scene cannot be drawn: itself for a solo frame. */
270export function soloOf(frame: FrameName): SoloFrame {
271  return isScene(frame) ? SCENE_FRAMES[frame] : frame
272}
273
274/**
275 * The stand-ins where a scene's own solo frame would not do without its
276 * prop: the team scenes are built on the hop (Claude bobbing with his
277 * helpers), and with no helpers beside him (on the status row, or beside the
278 * spinner with `scenes` off) that loop was a hop every half second for as
279 * long as the work ran. There he looks toward where the helpers would be
280 * instead.
281 */
282const FOOTER_FRAMES: ReadonlyMap<FrameName, SoloFrame> = new Map<FrameName, SoloFrame>([
283  ['team1a', 'lookR'],
284  ['team2a', 'lookR'],
285  ['team3a', 'lookR'],
286])
287
288/** The solo frame drawn for a frame wherever a scene cannot be (the footer, `scenes` off): its stand-in (FOOTER_FRAMES), else the frame's own solo frame. */
289export function footerOf(frame: FrameName): SoloFrame {
290  return FOOTER_FRAMES.get(frame) ?? soloOf(frame)
291}
292
293/** The braille pose that stands in for a frame. */
294export function poseOf(frame: FrameName): Pose {
295  return SOLO_FRAMES[soloOf(frame)]
296}
297
298/** The braille string that stands in for a frame: 5 cells, one row. */
299export function brailleOf(frame: FrameName): string {
300  return BRAILLE[poseOf(frame)]
301}
302
303/** The absolute path of a frame's PNG under the plugin's directory. */
304export function frameFile(root: string, frame: FrameName): string {
305  return `${root.replace(/\/+$/, '')}/assets/frames/${frame}.png`
306}
307
308/** `a` twice, `b` once: a loop written the way its timing reads. */
309function times(frame: FrameName, count: number): FrameName[] {
310  return Array.from({ length: count }, () => frame)
311}
312
313/**
314 * The frame loop of each activity, one step per 250 ms tick. Delegating, done
315 * and asleep have more than one form (TEAM_LOOPS, CHEER_LOOP, COLD_LOOP); the
316 * forms below are the defaults. Beside the spinner a spinner word or mode
317 * can put another scene in a step's place (sceneFor), step for step. Idle and
318 * supervising (at ease) are breathing (idle and idleUp every 2 s, BREATH_MS)
319 * and the blink (BLINK), not a tick loop; what he does between breaths is the
320 * wander's (./wander). Done plays once and holds its last frame (ONCE); every
321 * other loop repeats.
322 */
323export const LOOPS: Readonly<Record<CoworkerActivity, readonly FrameName[]>> = {
324  thinking: [...times('think1', 2), ...times('think2', 2), ...times('think3', 4)],
325  reading: [...times('read1', 3), ...times('read2', 3), ...times('read1', 2), ...times('read2', 2), ...times('read3', 2)],
326  searching: [...times('search1', 2), ...times('search2', 2), ...times('search3', 2), ...times('search2', 2)],
327  editing: ['type1', 'type2', 'type1', 'type2', 'type3', 'type2'],
328  running: [...times('run1', 2), ...times('run2', 2), ...times('run3', 2)],
329  browsing: [...times('web1', 2), ...times('web2', 2), ...times('web3', 2)],
330  delegating: [...times('team1a', 2), ...times('team1b', 2)],
331  asking: [...times('ask1', 2), ...times('ask2', 2)],
332  working: [...times('work1', 2), ...times('work2', 2)],
333  // the wave held two ticks a side: the arm swings at the pace of a real wave, four swings in the 2 s greeting
334  greeting: [...times('wave1', 2), ...times('wave2', 2), ...times('wave1', 2), ...times('wave2', 2)],
335  done: ['hop1', 'hop2', 'hop3', 'idle'],
336  oops: ['flinch'],
337  supervising: ['idle', 'idleUp'],
338  idle: ['idle', 'idleUp'],
339  asleep: ['sleep1', 'sleep2', 'sleep3'],
340}
341
342/** Delegating by how many delegating calls are in flight (1 to 3): that many small helpers bobbing. */
343export const TEAM_LOOPS: Readonly<Record<1 | 2 | 3, readonly FrameName[]>> = {
344  1: [...times('team1a', 2), ...times('team1b', 2)],
345  2: [...times('team2a', 2), ...times('team2b', 2)],
346  3: [...times('team3a', 2), ...times('team3b', 2)],
347}
348
349/** Done after a long turn (LONG_TURN_MS): a cheer with confetti, played once. */
350export const CHEER_LOOP: readonly FrameName[] = ['cheer1', 'cheer2', 'cheer3', 'cheer2', 'cheer3', 'idle']
351
352/** Asleep once the prompt cache has gone cold: frost and a turning snowflake. */
353export const COLD_LOOP: readonly FrameName[] = ['sleepCold1', 'sleepCold2']
354
355/** Each frame of the families a spinner word or mode swaps, by its step in the family (think1 is 0, think3 is 2). */
356const THINK_AT: ReadonlyMap<FrameName, number> = new Map<FrameName, number>([
357  ['think1', 0],
358  ['think2', 1],
359  ['think3', 2],
360])
361const WEB_AT: ReadonlyMap<FrameName, number> = new Map<FrameName, number>([
362  ['web1', 0],
363  ['web2', 1],
364  ['web3', 2],
365])
366const WORK_AT: ReadonlyMap<FrameName, number> = new Map<FrameName, number>([
367  ['work1', 0],
368  ['work2', 1],
369])
370
371/**
372 * Beside the spinner, the thought bubble gives way to what the spinner's mode
373 * says the model is doing, step for step (think1, think2, think3): writing its
374 * answer (`responding`, the word `Writing`: a pen on a notepad), writing a tool
375 * call's input (`tool-input`: the laptop), waiting on a tool (`tool-use`, the
376 * word `Working`: the gear, whose two frames make work1, work2, work1). Any
377 * other mode (`thinking`, `requesting`) keeps the bubble.
378 */
379export const THINK_BY_MODE: ReadonlyMap<string, readonly [SceneFrame, SceneFrame, SceneFrame]> = new Map<string, readonly [SceneFrame, SceneFrame, SceneFrame]>([
380  ['responding', ['write1', 'write2', 'write3']],
381  ['tool-input', ['type1', 'type2', 'type3']],
382  ['tool-use', ['work1', 'work2', 'work1']],
383])
384
385/** `Searching the web`: the magnifying glass over the turning globe, step for step with web1-3 (a page fetched keeps the globe alone). */
386export const WEB_SEARCH_LOOP: readonly [SceneFrame, SceneFrame, SceneFrame] = ['webSearch1', 'webSearch2', 'webSearch3']
387/** `Loading a skill`: a scroll unrolling, step for step with work1-2. */
388export const SKILL_LOOP: readonly [SceneFrame, SceneFrame] = ['skill1', 'skill2']
389/** `Calling <server>` (an MCP tool): a plug going into its socket, step for step with work1-2. */
390export const PLUG_LOOP: readonly [SceneFrame, SceneFrame] = ['plug1', 'plug2']
391
392/**
393 * The scene beside the spinner for a busy loop's frame, the spinner's word and
394 * its mode, so every spinner word has its own: the thought bubble by the mode
395 * (THINK_BY_MODE), the globe under a magnifying glass for `Searching the web`,
396 * and in the gear's place a scroll for `Loading a skill` and a plug for
397 * `Calling <server>`. Every other frame, and any word or mode it does not know,
398 * stays as it is. The footer draws solo frames and never asks.
399 */
400export function sceneFor(frame: FrameName, word: string, mode: string): FrameName {
401  const think = THINK_AT.get(frame)
402
403  if (think !== undefined) {
404    return THINK_BY_MODE.get(mode)?.[think] ?? frame
405  }
406
407  const said = typeof word === 'string' ? word : ''
408  const web = WEB_AT.get(frame)
409
410  if (web !== undefined) {
411    return said === WORDS.webSearch ? (WEB_SEARCH_LOOP[web] ?? frame) : frame
412  }
413
414  const work = WORK_AT.get(frame)
415
416  if (work !== undefined) {
417    if (said.startsWith(WORDS.skill)) {
418      return SKILL_LOOP[work] ?? frame
419    }
420
421    if (said.startsWith(WORDS.calling)) {
422      return PLUG_LOOP[work] ?? frame
423    }
424  }
425
426  return frame
427}
428
429/** The activities whose loop plays once and holds its last frame. */
430const ONCE: ReadonlySet<CoworkerActivity> = new Set<CoworkerActivity>(['done'])
431
432/** The dim word left of the sprite; empty for none. */
433export const CAPTIONS: Readonly<Record<CoworkerActivity, string>> = {
434  thinking: 'thinking',
435  reading: 'reading',
436  searching: 'searching',
437  editing: 'editing',
438  running: 'running',
439  browsing: 'browsing',
440  delegating: 'delegating',
441  asking: 'needs you',
442  working: 'working',
443  greeting: 'hi',
444  done: 'done',
445  oops: 'oops',
446  // supervising says how many agents are at work (agentsCaption), set by the frame's context
447  supervising: '',
448  idle: '',
449  asleep: '',
450}
451
452/** The caption while he minds background agents: how many are at work, `9+` past nine. At most 9 cells. */
453export function agentsCaption(count: number): string {
454  const whole = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1
455
456  return whole === 1 ? '1 agent' : `${whole > 9 ? '9+' : whole} agents`
457}
458
459/** One animation step. */
460export const TICK_MS = 250
461/**
462 * How long the footer leaves Claude to the main loop's spinner after that site last drew him
463 * (a turn start counts). The spinner site redraws him only when his frame changes, so this
464 * must outlast the longest hold of one frame in any busy loop and the longest moment (`oops`):
465 * shorter, and he flickered into the footer for a tick at every long hold (0.3.1). When the
466 * engine shows no spinner although the turn has not ended (between a /goal's iterations), the
467 * footer takes him back after this.
468 */
469export const SPINNER_FRESH_MS = 2_500
470/** From one idle blink to the next: how long the eyes stay open. */
471export const BLINK_EVERY_MS = 6_000
472/**
473 * The idle blink, every BLINK_EVERY_MS: half shut, shut, half shut, each held
474 * this long (400 ms in all), then open again. The half-shut lids are what
475 * make it read as a blink and not a flicker.
476 */
477export const BLINK: readonly { frame: 'blinkHalf' | 'blink'; ms: number }[] = [
478  { frame: 'blinkHalf', ms: 100 },
479  { frame: 'blink', ms: 200 },
480  { frame: 'blinkHalf', ms: 100 },
481]
482/** Idle, Claude breathes: idle and idleUp take turns this often. */
483export const BREATH_MS = 2_000
484/** Asleep, one frame of the sleep loop this often. */
485export const SLEEP_FRAME_MS = 3_000
486/** Claude's orange, the colored braille's and the pictures' (scripts/make-frames.py). */
487export const CLAUDE_COLOR = '#d97757'
488/** Asleep: the sleep frame and its `z`, muted. */
489export const ASLEEP_COLOR = '#7d5a50'
490/**
491 * The two sizes of the drawing: `big` is the picture in a box of 8 cells by 2
492 * rows (the `big` option, on by default), which spans the status row and the
493 * engine's short permission-mode row under it without adding a row; `small`
494 * is 0.2.0's one row: the picture in 4 cells by 1 (`big` off), or the 5-cell
495 * braille (`picture` off).
496 */
497export type Size = 'big' | 'small'
498
499/** The size drawn: the two-row box only for the picture with `big` on. */
500export function sizeOf(isPicture: boolean, isBig: boolean): Size {
501  return isPicture && isBig ? 'big' : 'small'
502}
503
504/** A solo frame's box per size, in terminal cells. */
505export const PICTURE: Readonly<Record<Size, { columns: number; rows: number }>> = {
506  big: { columns: 8, rows: 2 },
507  small: { columns: 4, rows: 1 },
508}
509
510/** A scene frame's box: drawn only beside the spinner, only with `big` on. */
511export const SCENE_BOX = { columns: 12, rows: 2 } as const
512
513/** The box a frame is drawn in: a scene 12x2 with `big` on; otherwise the size's solo box. */
514export function boxOf(frame: FrameName, size: Size): { columns: number; rows: number } {
515  return size === 'big' && isScene(frame) ? SCENE_BOX : PICTURE[size]
516}
517
518/**
519 * The frame drawn beside the spinner: the scene itself where scenes may be
520 * drawn (the `scenes` option, and `big`: a scene needs two rows), else the
521 * solo frame that stands in for it (footerOf: never the team scenes' bare hop).
522 */
523export function spinnerFrame(frame: FrameName, isScenes: boolean, size: Size): FrameName {
524  return isScenes && size === 'big' ? frame : footerOf(frame)
525}
526
527/**
528 * The footer site shares its row with the status line, right-aligned; when the
529 * two do not fit, the engine wraps the site onto a row of its own and the
530 * prompt jumps. So the mod reserves its cells on the status bus
531 * (./statusline-bus) and the status line script keeps them free: FULL for the
532 * widest drawing, COMPACT for Claude alone, which is what is drawn in a
533 * terminal narrower than COMPACT_BELOW columns. `big`: a space, Claude's 8
534 * cells, 12 cells to wander in and one spare (which also holds a space, a
535 * 10-cell caption, a space and Claude); compact a space, Claude, 1 cell to
536 * wander in and one spare. `small` (0.2.0): a space, Claude's 4 cells, 12 to
537 * wander in, one spare; compact a space, Claude, 3 cells to wander in or ` z`,
538 * one spare. The spare is never drawn on: the engine wrapped the site onto a
539 * row of its own when the drawing filled every reserved cell (102 columns,
540 * 2026-10-02). The footer draws solo frames only, so a scene never widens it.
541 */
542export const RESERVE = {
543  big: { full: 22, compact: 11, compactBelow: 110 },
544  small: { full: 18, compact: 9, compactBelow: 110 },
545} as const
546
547/**
548 * True while Claude is at ease: idle, or minding background agents with the
549 * main loop at rest. At ease he breathes, blinks and makes his moves (the
550 * wander); no tick loop runs.
551 */
552export function isAtEase(activity: CoworkerActivity): boolean {
553  return activity === 'idle' || activity === 'supervising'
554}
555
556/** True for the activities that animate on the tick (all but the at-ease ones and asleep). */
557export function isBusy(activity: CoworkerActivity): boolean {
558  return !isAtEase(activity) && activity !== 'asleep'
559}
560
561/**
562 * What else picks a frame: reduced motion (`isAnimated` false holds each
563 * loop's first frame), the at-ease blink (`isBlinking` shut, `isBlinkHalf`
564 * half shut) and breath, how many delegating calls are in flight, whether a
565 * finished turn was long enough for a cheer, whether the prompt cache has
566 * gone cold, and how many agents he minds (the supervising caption).
567 */
568export type FrameContext = {
569  agents?: number
570  isAnimated?: boolean
571  isBlinking?: boolean
572  isBlinkHalf?: boolean
573  isBreathIn?: boolean
574  team?: 1 | 2 | 3
575  isCheer?: boolean
576  isCold?: boolean
577}
578
579/** The loop an activity plays in a context: the team's size, the cheer, the cold sleep. */
580export function loopOf(activity: CoworkerActivity, context: FrameContext = {}): readonly FrameName[] {
581  if (activity === 'delegating') {
582    return TEAM_LOOPS[context.team ?? 1]
583  }
584
585  if (activity === 'done' && context.isCheer === true) {
586    return CHEER_LOOP
587  }
588
589  if (activity === 'asleep' && context.isCold === true) {
590    return COLD_LOOP
591  }
592
593  return LOOPS[activity]
594}
595
596/** The frame of an activity at a step of its loop (at ease: the blink, shut or half shut, else the breath). */
597export function frameAt(activity: CoworkerActivity, step: number, context: FrameContext = {}): FrameName {
598  const isAnimated = context.isAnimated !== false
599
600  if (isAtEase(activity)) {
601    if (!isAnimated) {
602      return 'idle'
603    }
604
605    return context.isBlinking === true ? 'blink' : context.isBlinkHalf === true ? 'blinkHalf' : context.isBreathIn === true ? 'idleUp' : 'idle'
606  }
607
608  const loop = loopOf(activity, context)
609  const whole = Number.isFinite(step) ? Math.max(0, Math.floor(step)) : 0
610  const index = !isAnimated || loop.length === 0 ? 0 : ONCE.has(activity) ? Math.min(whole, loop.length - 1) : whole % loop.length
611
612  return loop[index] ?? 'idle'
613}
614
615/** A view whose frame is known to be one of the table's (the $.state contract says only `string`). */
616export type FrameView = CoworkerView & { frame: FrameName }
617
618/** A view of one frame: its name, the braille that stands in for it, the caption, asleep or not. */
619export function viewOfFrame(frame: FrameName, caption = '', isAsleep = false): FrameView {
620  return { frame, sprite: brailleOf(frame), caption, isAsleep }
621}
622
623/** The caption of an activity in a context: the activity's own, or while supervising how many agents he minds. */
624export function captionOf(activity: CoworkerActivity, context: FrameContext = {}): string {
625  return activity === 'supervising' ? agentsCaption(context.agents ?? 1) : CAPTIONS[activity]
626}
627
628/** What the render hooks draw for an activity at a step of its loop. */
629export function viewOf(activity: CoworkerActivity, step: number, context: FrameContext = {}): FrameView {
630  return viewOfFrame(frameAt(activity, step, context), captionOf(activity, context), activity === 'asleep')
631}
632
633/**
634 * A Claude at ease in the middle of a move (a stroll, a yawn, a skit): that
635 * frame, with the caption his activity has (none idle; the agents' count
636 * while supervising, so it does not come and go with every move).
637 */
638export function movingView(frame: FrameName, caption = ''): FrameView {
639  return viewOfFrame(frame, caption)
640}
641
642/**
643 * A view as read back from $.state, made whole: a value an older version
644 * wrote (0.3.1 kept no frame name) or anything malformed draws as the idle
645 * frame, or the first sleep frame while asleep, never as a missing picture.
646 */
647export function normalView(value: unknown): FrameView {
648  const record = typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
649  const isAsleep = record.isAsleep === true
650  const frame = isFrameName(record.frame) ? record.frame : isAsleep ? 'sleep1' : 'idle'
651  const caption = typeof record.caption === 'string' ? record.caption : ''
652
653  return viewOfFrame(frame, caption, isAsleep)
654}
655
656/** A view's identity: equal keys draw the same thing. */
657export function viewKey(view: CoworkerView): string {
658  return `${view.isAsleep ? 'z' : '-'}|${view.caption}|${String(view.frame)}`
659}
660
661/**
662 * One piece of the footer drawing, left to right: `dim` text (the engine's
663 * labels, the caption), Claude (`claude`, or `sleeping` while asleep; drawn
664 * as the picture, or as the colored braille `text`), the muted ` z` while
665 * asleep (`asleep`), and the blank cells to his right (`blank`).
666 */
667export type Piece = { text: string; tone: 'dim' | 'claude' | 'sleeping' | 'asleep' | 'blank' }
668
669/**
670 * How the footer is drawn: a narrow terminal, Claude's place on the row, the
671 * picture or the braille, and the size (`size`, default `small`: 0.2.0's).
672 */
673export type FooterOptions = { isCompact?: boolean; x?: number; isPicture?: boolean; size?: Size }
674
675/** True for the piece that is Claude himself. */
676export function isClaude(piece: Piece): boolean {
677  return piece.tone === 'claude' || piece.tone === 'sleeping'
678}
679
680/**
681 * The cells a piece takes: any text its characters; Claude's picture its
682 * box's columns where the terminal draws it, but its braille `alt` (5 cells)
683 * where it cannot, so the larger of the two is counted.
684 */
685export function pieceCells(piece: Piece, isPicture = false, size: Size = 'small'): number {
686  const cells = [...piece.text].length
687
688  return isPicture && isClaude(piece) ? Math.max(PICTURE[size].columns, cells) : cells
689}
690
691/**
692 * The footer row as pieces: the engine's own mode labels (dim, joined by
693 * ` & `), then the caption (dim), Claude, and `x` blank cells to his right
694 * (the muted ` z` while asleep takes the first two). It always opens with a
695 * space, so it never runs into a footer item drawn before it. Everything
696 * after the labels fits the size's reserve: `isCompact` (a narrow terminal)
697 * leaves the caption out and walks the size's compact range at most (3
698 * cells; 1 for `big`), a caption shortens the walk, the ` · ` before a
699 * caption becomes a space where it would not fit, and the ` z` becomes `z`
700 * where only one cell is left (`big` in a narrow terminal). Claude's text is
701 * the braille of the view's frame (the footer's solo frame for a scene).
702 */
703export function footerPieces(modes: readonly string[], view: CoworkerView, options: FooterOptions = {}): Piece[] {
704  const labels = modes.join(' & ')
705  const claude: Piece = { text: view.sprite, tone: view.isAsleep ? 'sleeping' : 'claude' }
706  const { lead, room, range } = footerLayout(modes, view, options)
707  const x = clampX(options.x ?? 0, range)
708  const pieces: Piece[] = []
709
710  if (labels !== '') {
711    pieces.push({ text: labels, tone: 'dim' })
712  }
713
714  pieces.push({ text: lead, tone: 'dim' }, claude)
715
716  const z = !view.isAsleep ? '' : room >= 2 ? ' z' : room === 1 ? 'z' : ''
717
718  if (z !== '') {
719    pieces.push({ text: z, tone: 'asleep' })
720  }
721
722  const blanks = x - [...z].length
723
724  if (blanks > 0) {
725    pieces.push({ text: ' '.repeat(blanks), tone: 'blank' })
726  }
727
728  return pieces
729}
730
731/**
732 * The footer row's measures for a view: the lead before Claude (a space, or
733 * the caption between its separators), the blank cells left right of him
734 * with the spare kept free (`room`), and how far he may stand from the right
735 * end there (`range`: the size's wander range, short of the room).
736 */
737function footerLayout(modes: readonly string[], view: CoworkerView, options: FooterOptions): { lead: string; room: number; range: number } {
738  const isCompact = options.isCompact === true
739  const size = options.size ?? 'small'
740  const labels = modes.join(' & ')
741  const reserve = isCompact ? RESERVE[size].compact : RESERVE[size].full
742  const claudeCells = pieceCells({ text: view.sprite, tone: 'claude' }, options.isPicture === true, size)
743  const caption = isCompact ? '' : view.caption
744  let lead = caption === '' ? ' ' : `${labels === '' ? ' ' : ' · '}${caption} `
745
746  if ([...lead].length + claudeCells > reserve) {
747    lead = ` ${caption} `
748  }
749
750  // the spare stays free (see RESERVE)
751  const room = reserve - 1 - [...lead].length - claudeCells
752
753  return { lead, room, range: Math.max(0, Math.min(isCompact ? WANDER_RANGE[size].compact : WANDER_RANGE[size].full, room)) }
754}
755
756/**
757 * How far Claude may walk on the footer row as it is drawn for a view: with
758 * no caption wanderRoom's answer, with one (`3 agents` while he minds them)
759 * only what it leaves, so a stroll never steps where the row cannot show it.
760 */
761export function footerRange(modes: readonly string[], view: CoworkerView, options: FooterOptions = {}): number {
762  return footerLayout(modes, view, options).range
763}
764
765/**
766 * How far an idle Claude may walk on this row: the size's wander range, short
767 * of what the reserve leaves right of his drawing with its spare kept free.
768 * `big`: 0 to 12 at full width and 0 to 1 in a narrow terminal. `small`, with
769 * the picture counted as its 5-cell alt: 0 to 11 and 0 to 2.
770 */
771export function wanderRoom(isCompact: boolean, isPicture: boolean, size: Size = 'small'): number {
772  const cells = pieceCells({ text: BRAILLE.idle, tone: 'claude' }, isPicture, size)
773  const range = isCompact ? WANDER_RANGE[size].compact : WANDER_RANGE[size].full
774
775  return Math.max(0, Math.min(range, (isCompact ? RESERVE[size].compact : RESERVE[size].full) - 2 - cells))
776}
777
778/** The cells the drawing adds after the engine's labels. */
779export function addedCells(pieces: readonly Piece[], modes: readonly string[], isPicture = false, size: Size = 'small'): number {
780  const labels = modes.join(' & ')
781  const all = pieces.reduce((sum, piece) => sum + pieceCells(piece, isPicture, size), 0)
782
783  return all - [...labels].length
784}
785
hooks/lib/statusline-bus.ts 240 lines
1// The status line bus: how a mod hands the status line one short segment for
2// its session. Pure: no imports, no `$`.
3//
4// Canonical copy: shared/statusline-bus.ts. Each publishing mod
5// carries a vendored copy at hooks/lib/statusline-bus.ts (scripts/vendor-shared.sh
6// writes them and scripts/check-all.sh fails on drift). Edit this file, never a
7// vendored copy.
8//
9// ~/.claude/statusline.sh (the statusLine command) reads every file in
10// ~/.claude/state/statusline/bus/<sessionId>/ on each run and appends what it
11// finds to the line. One file per publisher, named after it, written whole:
12//
13//   <level>\t<text>\n            or            <level>\t<text>\t<until>\n
14//
15// `level` is info, warn or fail (the script colors the segment by it) and
16// `text` is plain, at most BUS_TEXT_MAX characters, with no control
17// characters (a character outside ASCII is budgeted two cells by the script).
18// `until`, when given, is an epoch in seconds after which the script stops
19// showing the segment: a publisher that may stop without clearing (a session
20// killed, the plugin unloaded) writes it and rewrites the file before it runs
21// out (BUS_TTL_S, BUS_KEEP_S). An empty file shows nothing: `$.fs` cannot
22// delete, so a publisher clears its segment by writing ''. The script reads the bus in
23// bash, so the format is a contract: change it only together with the script
24// and the suite that pins it (statusline/tests/test-statusline.sh).
25//
26// The script runs on the engine's status line triggers and its refresh timer,
27// so a segment reaches the line up to one refresh interval after it is
28// written. Publish state, not events: a count, a lockout, a queue length.
29//
30// The row has a second tenant. The engine draws its `SessionMode` site (the
31// mode labels, and whatever a mod draws there) right-aligned on the SAME row
32// as the status line, and when the two do not fit the site wraps onto a row of
33// its own, which moves the prompt. So a mod that draws there says how many
34// cells it needs, in <sessionId>/.reserve (RESERVE_FILE), written whole:
35//
36//   <full>\t<compact>\t<compactBelow>\n
37//
38// The script keeps `full` cells free at the row's right end, or `compact` in a
39// terminal narrower than `compactBelow` columns, where the mod must draw its
40// compact form. An empty file reserves nothing. One mod per session reserves.
41//
42// The engine draws a session's first status line before the mod has reserved
43// anything, so the site wrapped onto a row of its own for up to one refresh at
44// every session start. The reserving mod therefore also keeps a machine-wide
45// default beside the session folders, bus/.reserve-default
46// (RESERVE_DEFAULT_FILE), in the same format: the line it writes to its own
47// .reserve at session start. The script holds those cells for a session that
48// has no .reserve of its own yet; once the session's file exists (empty when
49// released) it rules. A release never writes the default, so one session's
50// `off` does not reach the next session's start.
51//
52// The bus also runs the other way. On each run the script writes what it
53// measured about the session to <sessionId>/.vitals (VITALS_FILE), one line,
54// written whole:
55//
56//   <context>\t<5h>\t<7d>\t<cache>\n
57//
58// `context` is the share of the context window in use, a whole or decimal
59// number from 0 to 100, or empty when unknown; `5h` and `7d` are the state of
60// the two usage windows, `calm`, `watch`, `over` or `crit`, or empty; `cache`
61// is the prompt cache, `warm` or `cold`, or empty. A mod reads it to react to
62// the session (mize-coworker's Claude sweats near a full context and sleeps
63// cold once the cache has gone cold). parseVitals is total: a field it does
64// not know reads as unknown, and a line without exactly four fields reads as
65// all unknown, so a reader never trusts a writer it does not understand.
66
67export const BUS_DIR = '.claude/state/statusline/bus'
68export const BUS_TEXT_MAX = 32
69/** How long a segment written with `until` stays valid, in seconds. */
70export const BUS_TTL_S = 180
71/** A publisher rewrites its segment once less than this many seconds of it remain. */
72export const BUS_KEEP_S = 120
73
74/** How the status line colors a segment: quiet, attention, or failure. */
75export type BusLevel = 'info' | 'warn' | 'fail'
76
77/** One segment: what the line shows, how loud, and (epoch seconds) until when. */
78export type BusSegment = { level: BusLevel; text: string; until?: number }
79
80const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/
81
82/**
83 * The file one publisher writes for one session, or undefined when the home
84 * directory, the session id or the publisher's name cannot name a file.
85 */
86export function busPath(home: string | undefined, sessionId: string, publisher: string): string | undefined {
87  if (home === undefined || home === '' || !SAFE_NAME.test(sessionId) || !SAFE_NAME.test(publisher)) {
88    return undefined
89  }
90
91  return `${home}/${BUS_DIR}/${sessionId}/${publisher}`
92}
93
94/** The text as the line may show it: one line, no control characters, at most BUS_TEXT_MAX characters. */
95export function busText(text: string): string {
96  // eslint-disable-next-line no-control-regex
97  return Array.from(text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, ' ').trim())
98    .slice(0, BUS_TEXT_MAX)
99    .join('')
100}
101
102/** The cells a mod drawing at the right end of the status row needs kept free. */
103export type BusReserve = {
104  /** Cells for its widest drawing. */
105  full: number
106  /** Cells for its compact drawing, used below `compactBelow` columns. */
107  compact: number
108  /** The terminal width under which the mod draws compact. */
109  compactBelow: number
110}
111
112export const RESERVE_FILE = '.reserve'
113/** No reservation is honored past this many cells. */
114export const RESERVE_MAX = 40
115
116/** The session's reserve file, or undefined when the home directory or the session id cannot name one. */
117export function reservePath(home: string | undefined, sessionId: string): string | undefined {
118  if (home === undefined || home === '' || !SAFE_NAME.test(sessionId)) {
119    return undefined
120  }
121
122  return `${home}/${BUS_DIR}/${sessionId}/${RESERVE_FILE}`
123}
124
125/** The machine-wide default reserve, beside the session folders: what the status line holds for a session with no .reserve yet. */
126export const RESERVE_DEFAULT_FILE = '.reserve-default'
127
128/** The machine-wide default reserve file (~/.claude/state/statusline/bus/.reserve-default), or undefined when the home directory cannot name one. */
129export function reserveDefaultPath(home: string | undefined): string | undefined {
130  if (home === undefined || home === '') {
131    return undefined
132  }
133
134  return `${home}/${BUS_DIR}/${RESERVE_DEFAULT_FILE}`
135}
136
137/** The reserve file's whole content, or '' to reserve nothing. */
138export function reserveLine(reserve: BusReserve | undefined): string {
139  if (reserve === undefined) {
140    return ''
141  }
142
143  const cells = (value: number): number => (Number.isFinite(value) ? Math.min(RESERVE_MAX, Math.max(0, Math.floor(value))) : 0)
144  const below = Number.isFinite(reserve.compactBelow) ? Math.max(0, Math.floor(reserve.compactBelow)) : 0
145
146  return `${cells(reserve.full)}\t${cells(reserve.compact)}\t${below}\n`
147}
148
149export const VITALS_FILE = '.vitals'
150
151/** A usage window's state as the status line rates it: quiet, worth a look, over its pace, or near its end. */
152export type VitalsWindow = 'calm' | 'watch' | 'over' | 'crit'
153
154/** The prompt cache: still warm, or gone cold (the next turn pays to write it again). */
155export type VitalsCache = 'warm' | 'cold'
156
157/** What the status line measured about the session; a field it did not know is absent. */
158export type Vitals = {
159  /** The share of the context window in use, 0 to 100. */
160  context?: number
161  /** The 5-hour usage window. */
162  fiveHour?: VitalsWindow
163  /** The 7-day usage window. */
164  sevenDay?: VitalsWindow
165  cache?: VitalsCache
166}
167
168const WINDOWS: readonly string[] = ['calm', 'watch', 'over', 'crit']
169const CACHES: readonly string[] = ['warm', 'cold']
170const PERCENT = /^\d{1,3}(?:\.\d{1,6})?$/
171/** How much of the file parseVitals reads: the line is a few dozen characters. */
172const VITALS_SCAN = 256
173
174/** The session's vitals file, or undefined when the home directory or the session id cannot name one. */
175export function vitalsPath(home: string | undefined, sessionId: string): string | undefined {
176  if (home === undefined || home === '' || !SAFE_NAME.test(sessionId)) {
177    return undefined
178  }
179
180  return `${home}/${BUS_DIR}/${sessionId}/${VITALS_FILE}`
181}
182
183/**
184 * The vitals in a file's text. Total: never throws, whatever it is given. Only
185 * the first line counts; a line without exactly four tab-separated fields
186 * reads as all unknown, and within a good line each field that is not one of
187 * its known values (a percent above 100, a state it does not name) is unknown.
188 */
189export function parseVitals(text: unknown): Vitals {
190  if (typeof text !== 'string') {
191    return {}
192  }
193
194  const line = (text.slice(0, VITALS_SCAN).split('\n')[0] ?? '').replace(/\r$/, '')
195  const fields = line.split('\t')
196
197  if (fields.length !== 4) {
198    return {}
199  }
200
201  const [context = '', fiveHour = '', sevenDay = '', cache = ''] = fields
202  const vitals: Vitals = {}
203  const percent = PERCENT.test(context) ? Number(context) : NaN
204
205  if (Number.isFinite(percent) && percent <= 100) {
206    vitals.context = percent
207  }
208
209  if (WINDOWS.includes(fiveHour)) {
210    vitals.fiveHour = fiveHour as VitalsWindow
211  }
212
213  if (WINDOWS.includes(sevenDay)) {
214    vitals.sevenDay = sevenDay as VitalsWindow
215  }
216
217  if (CACHES.includes(cache)) {
218    vitals.cache = cache as VitalsCache
219  }
220
221  return vitals
222}
223
224/** The file's whole content for a segment, or '' to show nothing. */
225export function busLine(segment: BusSegment | undefined): string {
226  if (segment === undefined) {
227    return ''
228  }
229
230  const text = busText(segment.text)
231
232  if (text === '') {
233    return ''
234  }
235
236  const until = segment.until !== undefined && Number.isFinite(segment.until) && segment.until > 0 ? `\t${Math.floor(segment.until)}` : ''
237
238  return `${segment.level}\t${text}${until}\n`
239}
240
hooks/lib/wander.ts 431 lines
1// Claude's life at ease on the status row: a small seeded generator, the
2// choice of the next move while idle (a stroll, a look, a hop, a yawn, a sip,
3// and the moves the session's vitals, its background shells or the date call
4// for), the choice of the next skit while he minds background agents (a
5// tally of them, a radar, a headset, juggling, a paper plane, a plant that
6// grows with the wait, ...), the steps of a move, and the clamp that keeps
7// him inside the cells the mod reserves. Pure: no `$`, no Math.random, so a
8// test that seeds the generator sees the same walk every time.
9
10import type { FrameName } from './sprite'
11
12/**
13 * How far Claude may wander left of the right end, in blank cells to his
14 * right: at full width, and in a terminal narrower than the reserve's
15 * compactBelow; for the two-row picture (`big`, 8 cells wide) and for the
16 * one-row drawing (`small`: 0.2.0's 4-cell picture, or the braille).
17 */
18export const WANDER_RANGE = { big: { full: 12, compact: 1 }, small: { full: 12, compact: 3 } } as const
19/** From the end of one idle move to the start of the next. */
20export const WANDER_EVERY_MS = { min: 12_000, max: 30_000 } as const
21/**
22 * From the end of one skit to the start of the next while he minds background
23 * agents: livelier than idle, and still more standing than performing.
24 */
25export const SKIT_EVERY_MS = { min: 4_000, max: 10_000 } as const
26/** How soon a skit that answers something plays (the send-off as the agents start, a finished agent's report). */
27export const SKIT_SOON_MS = 750
28
29/** The generator's state (mulberry32): one 32-bit word, advanced by every draw. */
30export type Rng = { state: number }
31
32/** A generator seeded from a number (the session start time); any number gives a valid seed. */
33export function seeded(seed: number): Rng {
34  const whole = Number.isFinite(seed) ? Math.floor(Math.abs(seed)) : 0
35
36  return { state: (whole % 0x1_0000_0000) >>> 0 }
37}
38
39/** The next draw in [0, 1). */
40export function nextFloat(rng: Rng): number {
41  rng.state = (rng.state + 0x6d2b79f5) >>> 0
42
43  let t = rng.state
44
45  t = Math.imul(t ^ (t >>> 15), t | 1)
46  t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
47
48  return ((t ^ (t >>> 14)) >>> 0) / 0x1_0000_0000
49}
50
51/** A whole number from `min` to `max`, both included. */
52export function nextInt(rng: Rng, min: number, max: number): number {
53  const low = Math.ceil(Math.min(min, max))
54  const high = Math.floor(Math.max(min, max))
55
56  return low + Math.floor(nextFloat(rng) * (high - low + 1))
57}
58
59/** How long Claude stays put before his next move: 12 to 30 s idle, 4 to 10 s while he minds agents. */
60export function nextWait(rng: Rng, isSupervising = false): number {
61  const every = isSupervising ? SKIT_EVERY_MS : WANDER_EVERY_MS
62
63  return nextInt(rng, every.min, every.max)
64}
65
66/**
67 * What a Claude at ease does next: walk to another spot, look around, hop,
68 * yawn, sip a coffee; one of the moves the session or the date calls for
69 * (GatedMove); or, while he minds background agents, a skit (SkitKind).
70 */
71export type MoveKind = 'stroll' | 'look' | 'hop' | 'yawn' | 'sip' | GatedMove | SkitKind
72/**
73 * The moves that happen only when something holds: `sweat` when the context
74 * window is 80% full or more, `clock` when a usage window is over its pace or
75 * near its end, `pumpkin` in the last week of October (the `seasonal`
76 * option), `peek` (a glance at a small terminal) while a shell runs in the
77 * background.
78 */
79export type GatedMove = 'sweat' | 'clock' | 'pumpkin' | 'peek'
80/**
81 * The skits, played while the main loop rests and agents or workflows work in
82 * the background: `tally` (a score paddle with how many are at work), `radar`
83 * (he watches them on a scope), `radio` (a headset: mission control),
84 * `report` (a helper brings a finished agent's page; played when one
85 * finishes), `launch` (a rocket lifts off; played as the agents start),
86 * `perch` (a helper checks in from the top of his head), `conduct` (a baton
87 * and notes, while a workflow runs), `juggle`, `gum` (a bubble that pops),
88 * `popcorn` (he watches the show), `plane` (a paper plane that comes back),
89 * `zen` (he meditates), `garden` (a potted plant that grows with the wait),
90 * `lantern` (the night shift) and `lunch` (a sandwich at noon).
91 */
92export type SkitKind =
93  | 'tally'
94  | 'radar'
95  | 'radio'
96  | 'report'
97  | 'launch'
98  | 'perch'
99  | 'conduct'
100  | 'juggle'
101  | 'gum'
102  | 'popcorn'
103  | 'plane'
104  | 'zen'
105  | 'garden'
106  | 'lantern'
107  | 'lunch'
108/** One tick of a move: the frame shown and where. */
109export type Step = { frame: FrameName; x: number }
110export type Move = { kind: MoveKind; steps: Step[] }
111
112/** `frame` held for `ticks` ticks: a move written the way its timing reads. */
113function hold(frame: FrameName, ticks: number): FrameName[] {
114  return Array.from({ length: ticks }, () => frame)
115}
116
117/** `frames` in order, the whole run `times` times. */
118function repeat(frames: readonly FrameName[], times: number): FrameName[] {
119  return Array.from({ length: times }, () => frames).flat()
120}
121
122/** The moves whose frames depend on something: the tally's digit, the plant's stage. */
123export type VariedMove = 'tally' | 'garden'
124
125/**
126 * The frames of each move that stays put, one per tick; the breath follows
127 * the last. The yawn and the sip ease in and out (two ticks each way) around
128 * a held middle (four ticks), and the pumpkin's glow flickers unevenly. The
129 * skits run 2.5 to 6 s; the tally and the garden are in stillFrames.
130 */
131export const STILL: Readonly<Record<Exclude<MoveKind, 'stroll' | VariedMove>, readonly FrameName[]>> = {
132  look: ['lookL', 'lookL', 'idle', 'lookR', 'lookR', 'idle'],
133  hop: ['hop1', 'hop2', 'hop3', 'idle'],
134  yawn: [...hold('yawn1', 2), ...hold('yawn2', 4), ...hold('yawn1', 2)],
135  sip: [...hold('sip1', 2), ...hold('sip2', 4), ...hold('sip1', 2)],
136  sweat: ['sweat1', 'sweat2', 'sweat1', 'sweat2'],
137  clock: ['clock1', 'clock1', 'clock2', 'clock2'],
138  pumpkin: [...hold('pumpkin1', 3), ...hold('pumpkin2', 2), ...hold('pumpkin1', 3), ...hold('pumpkin2', 2)],
139  // a glance at the terminal: the cursor, then a line of output more (once: the line does not go away again)
140  peek: [...hold('term1', 4), ...hold('term2', 6)],
141  // the sweep goes twice round the scope
142  radar: repeat([...hold('radar1', 2), ...hold('radar2', 2), ...hold('radar3', 2), ...hold('radar4', 2)], 2),
143  radio: [...hold('radio1', 4), ...hold('radio2', 2), ...hold('radio1', 2), ...hold('radio2', 2), ...hold('radio3', 4)],
144  report: [...hold('report1', 2), ...hold('report2', 2), ...hold('report3', 4), ...hold('report4', 4)],
145  launch: [...hold('launch1', 4), ...hold('launch2', 2), ...hold('launch3', 4)],
146  perch: [...hold('perch1', 3), ...hold('perch2', 2), ...hold('perch1', 2), ...hold('perch2', 2), ...hold('perch1', 3)],
147  conduct: repeat([...hold('conduct1', 2), ...hold('conduct2', 2), ...hold('conduct3', 2), ...hold('conduct2', 2)], 2),
148  juggle: repeat(['juggle1', 'juggle2', 'juggle3'], 6),
149  gum: [...hold('gum1', 3), ...hold('gum2', 5), ...hold('gum3', 3)],
150  popcorn: repeat([...hold('popcorn1', 3), ...hold('popcorn2', 2)], 3),
151  plane: [...hold('plane1', 3), ...hold('plane2', 2), ...hold('plane3', 4), ...hold('plane4', 2), ...hold('plane5', 4)],
152  // a slow bob
153  zen: repeat([...hold('zen1', 4), ...hold('zen2', 4)], 3),
154  lantern: repeat([...hold('lantern1', 3), ...hold('lantern2', 2)], 2),
155  // the sandwich, then the bite (once: a bitten sandwich does not grow back)
156  lunch: [...hold('lunch1', 4), ...hold('lunch2', 6)],
157}
158
159/** The tally's faces by how many agents are at work: 1 to 9, and `9+`. */
160const TALLY: readonly FrameName[] = ['tally1', 'tally2', 'tally3', 'tally4', 'tally5', 'tally6', 'tally7', 'tally8', 'tally9']
161/** The plant by its stage, 1 to 4: watered, then admired. */
162const GARDEN: readonly { water: FrameName; plant: FrameName }[] = [
163  { water: 'water1', plant: 'plant1' },
164  { water: 'water2', plant: 'plant2' },
165  { water: 'water3', plant: 'plant3' },
166  { water: 'water4', plant: 'plant4' },
167]
168/** How long the agents have been at work when the plant reaches its second, third and fourth stage. */
169export const GARDEN_STAGE_MS: readonly [number, number, number] = [2 * 60_000, 6 * 60_000, 15 * 60_000]
170
171/** What a varied move is played with: how many agents are at work (the tally), how long they have been (the plant). */
172export type MoveDetail = { count?: number; forMs?: number }
173
174/** The paddle's face for a count: its digit from 1 to 9, `9+` beyond; anything less than 1 reads as 1. */
175export function tallyFrame(count: number | undefined): FrameName {
176  const whole = count !== undefined && Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1
177
178  return TALLY[whole - 1] ?? 'tallyMany'
179}
180
181/** The plant's stage, 1 to 4, after the agents have been at work for `forMs` (GARDEN_STAGE_MS). */
182export function gardenStage(forMs: number | undefined): 1 | 2 | 3 | 4 {
183  const ms = forMs !== undefined && Number.isFinite(forMs) ? forMs : 0
184
185  return ms >= GARDEN_STAGE_MS[2] ? 4 : ms >= GARDEN_STAGE_MS[1] ? 3 : ms >= GARDEN_STAGE_MS[0] ? 2 : 1
186}
187
188/**
189 * The frames of a move that stays put, one per tick: STILL's, or for the
190 * tally the paddle lifted, shown (its face the count's) and lowered, and for
191 * the garden the plant at its stage, watered and then admired.
192 */
193export function stillFrames(kind: Exclude<MoveKind, 'stroll'>, detail: MoveDetail = {}): readonly FrameName[] {
194  if (kind === 'tally') {
195    return [...hold('tally0', 2), ...hold(tallyFrame(detail.count), 8), ...hold('tally0', 2)]
196  }
197
198  if (kind === 'garden') {
199    const stage = GARDEN[gardenStage(detail.forMs) - 1] ?? { water: 'water1', plant: 'plant1' }
200
201    return [...hold(stage.water, 4), ...hold(stage.plant, 6)]
202  }
203
204  return STILL[kind]
205}
206
207/** When a gated move applies, about this share of moves is one. */
208export const GATED_SHARE = 1 / 3
209/** Idle this long (of the 10 minutes before he sleeps) and he grows drowsy: more yawns. */
210export const DROWSY_MS = 6 * 60_000
211/**
212 * The everyday mix, as upper bounds of one draw in [0, 1): stroll, look, hop,
213 * yawn, then sip. Drowsy, the yawn takes a larger share. Yawns and sips stay
214 * rarer than strolls and looks either way.
215 */
216export const MIX = {
217  awake: { stroll: 0.45, look: 0.75, hop: 0.87, yawn: 0.935 },
218  drowsy: { stroll: 0.4, look: 0.68, hop: 0.76, yawn: 0.92 },
219} as const
220
221/**
222 * The ticks of a move from `x`: a stroll to `to` one cell per tick on
223 * alternating feet then idle; any other move stays at `x` and plays its
224 * frames (stillFrames, with `detail` for the tally and the garden). A stroll
225 * to where he already is has no steps.
226 */
227export function moveSteps(kind: MoveKind, x: number, to = x, detail: MoveDetail = {}): Step[] {
228  if (kind !== 'stroll') {
229    return stillFrames(kind, detail).map(frame => ({ frame, x }))
230  }
231
232  const steps: Step[] = []
233  const way = Math.sign(to - x)
234
235  for (let moved = 1; moved <= Math.abs(to - x); moved += 1) {
236    steps.push({ frame: moved % 2 === 1 ? 'stepA' : 'stepB', x: x + way * moved })
237  }
238
239  if (steps.length > 0) {
240    steps.push({ frame: 'idle', x: to })
241  }
242
243  return steps
244}
245
246/** What else shapes the next move: the gated moves that apply now, and whether he is drowsy. */
247export type MoveContext = { gated?: readonly GatedMove[]; isDrowsy?: boolean }
248
249/**
250 * The next move of a Claude at `x` who may stand anywhere from 0 to `range`.
251 * When a gated move applies, about one move in three (GATED_SHARE) is one of
252 * them, picked evenly; otherwise the everyday mix (MIX): a stroll to another
253 * spot, a look around, a hop, a yawn or a sip. With no room to walk, never a
254 * stroll (its share goes to the look).
255 */
256export function chooseMove(rng: Rng, x: number, range: number, context: MoveContext = {}): Move {
257  const top = Math.max(0, Math.floor(range))
258  const from = clampX(x, top)
259  const gated = context.gated ?? []
260
261  if (gated.length > 0 && nextFloat(rng) < GATED_SHARE) {
262    const kind = gated[nextInt(rng, 0, gated.length - 1)] ?? 'look'
263
264    return { kind, steps: moveSteps(kind, from) }
265  }
266
267  const mix = context.isDrowsy === true ? MIX.drowsy : MIX.awake
268  const roll = nextFloat(rng)
269
270  if (roll < mix.stroll && top > 0) {
271    const pick = nextInt(rng, 0, top - 1)
272    const to = pick >= from ? pick + 1 : pick
273
274    return { kind: 'stroll', steps: moveSteps('stroll', from, to) }
275  }
276
277  const kind: MoveKind = roll < mix.look ? 'look' : roll < mix.hop ? 'hop' : roll < mix.yawn ? 'yawn' : 'sip'
278
279  return { kind, steps: moveSteps(kind, from) }
280}
281
282/**
283 * What shapes the next skit: how many agents are at work and for how long,
284 * whether a workflow is among them, the local hour, whether the time-bound
285 * skits are on (the `seasonal` option), the gated moves that apply now, and
286 * the move last played (never the same twice running).
287 */
288export type SkitContext = {
289  count: number
290  forMs: number
291  isWorkflow: boolean
292  hour: number
293  isSeasonal: boolean
294  gated?: readonly GatedMove[]
295  last?: MoveKind
296}
297
298/** The hours of the night shift, when the lantern comes out: from 22:00 to 05:59, local time. */
299export function isNightShift(hour: number): boolean {
300  return hour >= 22 || hour < 6
301}
302
303/** The lunch hour, when the sandwich comes out: 12:00 to 12:59, local time. */
304export function isLunchHour(hour: number): boolean {
305  return hour === 12
306}
307
308/** The agents have been at work this long when he starts checking the hourglass. */
309export const LONG_WAIT_MS = 5 * 60_000
310
311/**
312 * The mix while he minds agents: each kind and its weight in this context, 0
313 * where it does not apply. The conductor needs a workflow, the lantern the
314 * night, the sandwich the lunch hour, the hourglass a long wait (or a usage
315 * window that runs hot), the stroll room to walk. No hop: a hop every few
316 * seconds is what this replaced. The report and the launch are not drawn
317 * from the mix; they answer events.
318 */
319export function skitWeights(context: SkitContext, hasRoom: boolean): [MoveKind, number][] {
320  const gated = context.gated ?? []
321  const isTimed = context.isSeasonal
322
323  return [
324    ['tally', 3],
325    ['radar', 3],
326    ['radio', 3],
327    ['juggle', 3],
328    ['popcorn', 3],
329    ['garden', 3],
330    ['conduct', context.isWorkflow ? 4 : 0],
331    ['perch', 2],
332    ['gum', 2],
333    ['plane', 2],
334    ['zen', 2],
335    ['look', 3],
336    ['stroll', hasRoom ? 2 : 0],
337    ['sip', 2],
338    ['yawn', 1],
339    ['clock', gated.includes('clock') || context.forMs >= LONG_WAIT_MS ? 2 : 0],
340    ['sweat', gated.includes('sweat') ? 3 : 0],
341    ['pumpkin', gated.includes('pumpkin') ? 3 : 0],
342    ['peek', gated.includes('peek') ? 2 : 0],
343    ['lantern', isTimed && isNightShift(context.hour) ? 4 : 0],
344    ['lunch', isTimed && isLunchHour(context.hour) ? 4 : 0],
345  ]
346}
347
348/**
349 * The next skit of a Claude at `x`, minding agents, who may stand anywhere
350 * from 0 to `range`: one draw over the mix (skitWeights), the kind last
351 * played left out so no skit plays twice running; a stroll goes to another
352 * spot as chooseMove's does.
353 */
354export function chooseSkit(rng: Rng, x: number, range: number, context: SkitContext): Move {
355  const top = Math.max(0, Math.floor(range))
356  const from = clampX(x, top)
357  const mix = skitWeights(context, top > 0).filter(([kind, weight]) => weight > 0 && kind !== context.last)
358  const total = mix.reduce((sum, [, weight]) => sum + weight, 0)
359  let roll = nextFloat(rng) * total
360  let kind: MoveKind = 'look'
361
362  for (const [candidate, weight] of mix) {
363    kind = candidate
364
365    if (roll < weight) {
366      break
367    }
368
369    roll -= weight
370  }
371
372  if (kind === 'stroll') {
373    const pick = nextInt(rng, 0, top - 1)
374    const to = pick >= from ? pick + 1 : pick
375
376    return { kind, steps: moveSteps('stroll', from, to) }
377  }
378
379  return { kind, steps: moveSteps(kind, from, from, { count: context.count, forMs: context.forMs }) }
380}
381
382/** The vitals a gated move reads: the context share and the two usage windows' states. */
383export type MoveVitals = { context?: number; fiveHour?: string; sevenDay?: string }
384
385/** The context share at which Claude starts to sweat. */
386export const SWEAT_AT = 80
387
388/** True from 24 to 31 October: `month` 0-based as a Date gives it, both read in local time. */
389export function isPumpkinTime(month: number, day: number): boolean {
390  return month === 9 && day >= 24 && day <= 31
391}
392
393/**
394 * The gated moves that apply: `sweat` at SWEAT_AT% of context or more,
395 * `clock` while the 5h or 7d window is `over` or `crit`, `pumpkin` in the last
396 * week of October when `isSeasonal`, `peek` while `shells` shells or monitors
397 * run in the background. Unknown vitals apply nothing.
398 */
399export function gatedMoves(vitals: MoveVitals, month: number, day: number, isSeasonal: boolean, shells = 0): GatedMove[] {
400  const moves: GatedMove[] = []
401  const isPressed = (state: string | undefined): boolean => state === 'over' || state === 'crit'
402
403  if (vitals.context !== undefined && Number.isFinite(vitals.context) && vitals.context >= SWEAT_AT) {
404    moves.push('sweat')
405  }
406
407  if (isPressed(vitals.fiveHour) || isPressed(vitals.sevenDay)) {
408    moves.push('clock')
409  }
410
411  if (isSeasonal && isPumpkinTime(month, day)) {
412    moves.push('pumpkin')
413  }
414
415  if (shells > 0) {
416    moves.push('peek')
417  }
418
419  return moves
420}
421
422/**
423 * Where Claude is drawn: `x` as a whole number from 0 to the smallest of the
424 * limits given (the wander range, the room a caption leaves).
425 */
426export function clampX(x: number, ...limits: number[]): number {
427  const most = Math.max(0, Math.min(Infinity, ...limits.filter(limit => Number.isFinite(limit)).map(limit => Math.floor(limit))))
428
429  return Number.isFinite(x) ? Math.min(most, Math.max(0, Math.floor(x))) : 0
430}
431
types/index.d.ts 110 lines
1// The $.state contract of mize-coworker: the values the host keeps for the
2// session (they survive a hot reload of the module; /clear resets them).
3
4/**
5 * What Claude is shown doing: a tool family while a tool call is in flight or
6 * just ended, `thinking` while the main loop's turn runs between tools,
7 * `supervising` while the main loop rests and agents or workflows are at work
8 * in the background (he minds them: at ease, with skits of his own), `idle`
9 * when nothing is going on, `asleep` after 10 idle minutes; and three short
10 * gestures: `greeting` (a wave when the session opens), `done` (a hop when
11 * the main turn ends with an answer, a cheer after a long one) and `oops` (a
12 * flinch when one of the main loop's own tool calls fails).
13 */
14export type CoworkerActivity =
15  | 'thinking'
16  | 'reading'
17  | 'searching'
18  | 'editing'
19  | 'running'
20  | 'browsing'
21  | 'delegating'
22  | 'asking'
23  | 'working'
24  | 'greeting'
25  | 'done'
26  | 'oops'
27  | 'supervising'
28  | 'idle'
29  | 'asleep'
30
31/** The activity and the spinner word for it, as the Spinner hook reads them. */
32export type CoworkerDoing = {
33  activity: CoworkerActivity
34  /**
35   * The narration for a tool activity (`Reading register.ts`, `Running npm`,
36   * `Asking you`); empty for `thinking`, `supervising`, `idle` and `asleep`,
37   * which the spinner words from its own mode.
38   */
39  word: string
40}
41
42/** What the SessionMode hook (and, while a turn runs, the Spinner hook) draws: one frame of Claude and its caption. */
43export type CoworkerView = {
44  /**
45   * The picture frame by name, one of the 132 in scripts/frames.json: its PNG
46   * is assets/frames/<frame>.png. A scene frame (12 cells wide, a prop beside
47   * Claude) is drawn only beside the spinner, where the spinner's word or mode
48   * may put another scene in its place; the footer draws the solo frame the
49   * table names for it.
50   */
51  frame: string
52  /**
53   * The braille that stands in for the frame (one of twelve poses, five
54   * cells): the colored text with the `picture` option off, and the picture's
55   * `alt` with it on. Several frames share a pose, so it names no frame.
56   */
57  sprite: string
58  /** The dim word to the sprite's left (`reading`, `needs you`, `3 agents`); empty for none. */
59  caption: string
60  /** True while asleep: the sprite and a trailing `z` are drawn muted. */
61  isAsleep: boolean
62}
63
64/**
65 * The step of the `/coworker demo` tour on screen, drawn in the band above the
66 * prompt (`AbovePrompt`): the frame and what it shows. An empty `frame` is no
67 * tour, and the band draws nothing.
68 */
69export type CoworkerDemo = {
70  /**
71   * The frame by name, one of scripts/frames.json, as the tour gives it; the
72   * band draws it as the spinner would (a scene's solo frame where scenes are
73   * not drawn). Empty while no tour runs.
74   */
75  frame: string
76  /** What it shows: the spinner word (`Writing`, `Reading register.tsx`) or the move's name (`yawn`). */
77  label: string
78}
79
80/**
81 * Where Claude is drawn: in the footer (`SessionMode`), or beside the main
82 * loop's spinner while its turn runs; and where on the footer's row.
83 */
84export type CoworkerSpot = {
85  where: 'footer' | 'spinner'
86  /**
87   * Blank cells to Claude's right on the footer's row. He is right-aligned,
88   * so a larger x puts him further left: 0 to 12, drawn clamped to what the
89   * terminal's width and a caption leave room for.
90   */
91  x: number
92}
93
94declare module 'claude-code' {
95  interface PluginState {
96    'mize-coworker': {
97      /** Read by the Spinner hook; written only when the activity or its word changes. */
98      doing: CoworkerDoing
99      /** Read by the SessionMode hook; written only when the frame or caption changes. */
100      view: CoworkerView
101      /** Read by both render hooks; written only when the place or x changes. */
102      spot: CoworkerSpot
103      /** The session-only switch `/coworker off` sets; both features pass through while true. */
104      isOff: boolean
105      /** Read by the AbovePrompt hook; written by `/coworker demo`'s one timer, only when the step changes. */
106      demo: CoworkerDemo
107    }
108  }
109}
110