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…

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.
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).
| activity | when | caption | loop beside the spinner (one step per 250 ms) | in the footer |
|---|---|---|---|---|
| thinking | the main turn runs and no tool call is in flight | thinking | think1 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 gear | lookUp, idle |
| reading | Read, NotebookRead | reading | read1 x3, read2 x3, read1 x2, read2 x2, read3 x2: the left page, the right page, a page turning | lookDown, blink |
| searching | Grep, Glob, ToolSearch, LS | searching | search1 x2, search2 x2, search3 x2, search2 x2: the magnifying glass over the first, middle and last line | lookL, idle, lookR |
| editing | Edit, Write, NotebookEdit, MultiEdit | editing | type1, type2, type1, type2, type3, type2: at the laptop, the line of code growing | armsIn, idle |
| running | Bash, BashOutput, Monitor | running | run1 x2, run2 x2, run3 x2: a terminal printing output | stepA, stepB |
| browsing | WebFetch, WebSearch | browsing | web1 x2, web2 x2, web3 x2: the globe turning. Searching the web (WebSearch): webSearch1-3, the magnifying glass sweeping the turning globe | lookR, idle |
| delegating | Agent, Task, Workflow, SendMessage | delegating | teamNa x2, teamNb x2: N small helpers bobbing, one per delegating call in flight, 1 to 3 | lookR, 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) |
| asking | AskUserQuestion, or a permission dialog, until that loop's call ends or its turn does | needs you | ask1 x2, ask2 x2: a yellow speech bubble with an exclamation mark, Claude waving | wave1, wave2 |
| working | any other tool (MCP tools, Skill, TodoWrite, ...) | working | work1 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 socket | idle, blink |
| greeting | a session opens (2 s) | hi | wave1 x2, wave2 x2, wave1 x2, wave2 x2 | the same |
| done | the main turn ends with an answer; none after an interrupt or an error | done | a 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 frame | the same |
| oops | one of the main loop's own tool calls fails (1.25 s, at most once in 10 s) | oops | flinch | the same |
| supervising | the main loop rests (no turn, or a turn left open with no spinner drawn) and agents or workflows are at work in the background | N agents | (footer only) at ease as when idle: the breath, the blink, and a skit every 4 to 10 s (Minding agents) | |
| idle | no turn and no agent at work in the background | none | (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) | |
| asleep | 10 minutes idle | z 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.
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.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.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.
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.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).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.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 on the status row (not asleep, no turn), with animate on:
after chain. It shows over the breath; a move under way shows its own frames instead.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.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.
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.
| skit | what he does | frames, in ticks |
|---|---|---|
launch | a 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 end | launch1 x4, launch2 x2, launch3 x4 |
report | a 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 s | report1 x2, report2 x2, report3 x4, report4 x4 |
tally | a judge's paddle with how many agents are at work: 1 to 9, then 9+ | tally0 x2, tallyN x8, tally0 x2 |
radar | he watches them on a small scope, the sweep going twice round | radar1-4, two ticks each, twice |
radio | mission control: a headset, a word into the mic, "copy that" | radio1 x4, radio2 x2, radio1 x2, radio2 x2, radio3 x4 |
conduct | a baton and drifting notes; only while a workflow is among the background tasks (Orchestrating agents) | conduct1, 2, 3, 2, two ticks each, twice |
perch | a helper checks in from the top of his head | perch1 x3, perch2 x2, perch1 x2, perch2 x2, perch1 x3 |
juggle | three balls | juggle1, 2, 3, six times round |
gum | a bubble grows and pops | gum1 x3, gum2 x5, gum3 x3 |
popcorn | he watches the show | popcorn1 x3, popcorn2 x2, three times |
plane | a paper plane, thrown off to the right, comes back from the left | plane1 x3, plane2 x2, plane3 x4, plane4 x2, plane5 x4 |
zen | he meditates, floating | zen1 x4, zen2 x4, three times |
garden | he 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 15 | waterN x4, plantN x6 |
lantern | the night shift, 22:00 to 06:00 local time (seasonal) | lantern1 x3, lantern2 x2, twice |
lunch | a 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 `
hooks/register.tsx 2010 lines1/**
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 }
1200hooks/lib/demo.ts 201 lines1// 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}
201hooks/lib/activity.ts 1138 lines1// 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)'
1138hooks/lib/sprite.ts 785 lines1// 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}
785hooks/lib/statusline-bus.ts 240 lines1// 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}
240hooks/lib/wander.ts 431 lines1// 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}
431types/index.d.ts 110 lines1// 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