A live panel of the subagents Claude delegates to: model, context, tokens, cost and time, each with an animated mascot.

<img src="docs/media/banner.png" alt="crew: a Claude Code mod with a live panel of the subagents Claude delegates to, and its four pixel mascots" width="100%">
A Claude Code mod with a live panel of the subagents Claude delegates to — each with its own animated pixel mascot.
<img src="docs/media/mascots.png" alt="The four mascots: general-purpose (coral, with an antenna), Explore (teal, with feelers), Plan (violet, with a cap) and a custom agent type (amber, with a sprout)" width="560">
<img src="docs/media/crew.gif" alt="Claude delegates to four subagents; the crew pane shows each one with its animated mascot, model, context bar, tokens, estimated cost and time, and files them under Finished as they end" width="900">
<sub>An animation of the pane, drawn with crew's own sprites and layout; the clock runs 20× faster than a real session.</sub>
When Claude hands work to subagents, the transcript tells you little while they run: which ones are still going, on which model, how full their context is, what they have cost. Crew puts all of it in one pane — stat cards for the session, a card per running agent, a list of the ones that finished — and a one-line band above the prompt while anything runs.
◆ crew · 1 running · 3 done · 1 failed
╭──────────────────╮ ╭──────────────────╮ ╭────────────────────╮
│ Session cost │ │ Session tokens │ │ Session time │
│ ≈$1.15 │ │ 948.7k │ │ 12:18 │
╰──────────────────╯ ╰──────────────────╯ ╰────────────────────╯
RUNNING · 1
▀▄ ▄▀ Scan the repo for TODO markers ●
▄█████▄ Explore · haiku 4.5 · high · step 7 · Bash
▀███████▄ ━━━━━━━━━━──────────────────────────────────── ctx 21%
█▀▀▀▀█▀ 207.5k tok · 12 tools · ≈$0.05 · 5:35
FINISHED · 4 c: Collapse
✓ Review the auth module sonnet 4.5 · 61.7k · ≈$0.08 · 1:48
✗ Run the flaky tests sonnet 4.5 · 118.4k · ≈$0.12 · 4:51
✓ Draft the migration plan opus 5.5 · 88.1k · ≈$0.29 · 3:02
✓ Map the API routes haiku 4.5 · 52.0k · ≈$0.02 · 2:10
(In the terminal the mascot is drawn in full color, two pixels per cell. The Session cost card here is crew's estimate, marked ≈: the main loop's share plus each agent's.)
Inside Claude Code:
/plugin marketplace add flaviorg/crew
/plugin install crew@crew
Then start a new session, or run /reload-plugins. Crew is also listed in my claude-mods marketplace with my other mods.
≈), Session tokens (everything the session's model responses reported: main loop and subagents, input, output and cache) and Session time (since the session started). A narrow card says just Cost, Tokens and Time; on a narrower pane they fold into one line.━ in the terminal; in the Desktop app, a bar three pixels tall that fills as the context does); and its tokens, tool calls, estimated cost and time. An agent that finishes stays here for three seconds to celebrate (✓) or droop (✗ failed, ■ stopped), counted under the title as just finished (RUNNING · 1 · JUST FINISHED · 2), then moves on. Past 30 cards, one line says how many more run. When nothing runs, the idle mascot is back with a line saying so.✓, ✗ or ■, the model, tokens, cost and time, set out as a table: the columns line up from row to row. Its c: Collapse button folds it and e: Expand unfolds it (c: Hide and e: Show in a narrow pane); the choice sticks across sessions. Past 50 rows, one line says how many more.o: Open opens the pane, or brings it to the front. ◆ crew ●● 2 running · 1 done · 0:42 · ≈$0.18 o: Open
The pane opens by itself the first time Claude delegates, if the terminal is wide enough for a pane nobody asked for (144 columns); otherwise a toast says to run /crew.
Each built-in agent type has its own head piece and coat. Custom types share the sprout and take one of four coats by name, so a glance tells most of them apart.
| Agent type | Head piece | Coat |
|---|---|---|
general-purpose (and forks) | an antenna | coral #D97757 |
Explore | feelers | teal #35B5AC |
Plan | a cap | violet #9B7BEA |
| any custom type | a sprout | amber, pink, blue or green — always the same one for the same type |
<img src="docs/media/poses.png" alt="The general-purpose mascot in its four poses: idle, working, done (hands up, sparkles) and failed (grey, drooping, a tear)" width="560">
| Pose | When | What it does |
|---|---|---|
| idle | the empty pane | blinks now and then |
| working | the agent runs | looks left and right, taps, steps; its tip pulses |
| done | it finished | throws its hands up and cheers while sparkles twinkle |
| failed | it failed or was stopped | turns grey and droops; a tear rolls down |
Every pose plays at four frames a second, each agent from its own point in the loop. The pieces that stand on the terminal's background (head pieces, feet, tips, sparkles) keep to middle tones, so they show on dark and light themes alike. A mascot is 9 x 8 pixels: in the terminal, 9 columns by 4 rows, one row per line of its card; in the Desktop app, about two lines of text tall. The pixel art is original: hooks/sprites.ts draws every frame from one table of parts (body, head pieces, eyes, mouth, hands, feet).
| Command | What it does |
|---|---|
/crew | Shows the pane, or hides it when it is showing |
Claude Code shows one mod pane at a time, docked or above the prompt, with a tab for each; /crew (or the band's o: Open) brings crew's to the front.
Set them with /plugin configure crew@crew:
| Option | Values | Default | What it does |
|---|---|---|---|
autoOpen | on, off | on | Open the pane when Claude first delegates to a subagent (if the terminal is wide enough) |
band | on, off | on | Show the one-line summary above the prompt while subagents run |
mascot | on, off | on | Draw the animated mascots; off draws a dot in the agent's color |
prices | JSON | (empty) | Override the price table, in $ per million tokens |
prices keys are model families (opus, sonnet, haiku, fable, mythos) or any part of a model id; the longest match wins, and beats the built-in table. An override's rates apply to every request, long ones too. For example:
{ "opus": { "input": 4, "output": 20, "cacheRead": 0.2, "cacheWrite": 5 },
"opus-4-1": { "input": 15, "output": 75, "cacheRead": 1.5, "cacheWrite": 18.75 } }
The built-in table is Claude Code's own list prices (the model catalog it ships and prices its session cost from), in $ per million tokens. A release it does not list is priced as its family's newest. Haiku 5.5 has a second row: a request whose prompt (input and cache) is past 100k tokens costs 5× as much, all of it, and crew prices each such request at that row, as Claude Code does.
| Model | Input | Output | Cache read | Cache write |
|---|---|---|---|---|
| Opus 5.5 | 4 | 20 | 0.20 | 5 |
| Opus 4.5 – 5 | 5 | 25 | 0.50 | 6.25 |
| Opus 4, 4.1 | 15 | 75 | 1.50 | 18.75 |
| Sonnet 5.5 | 2 | 10 | 0.10 | 2.50 |
| Sonnet 5 | 2 | 10 | 0.20 | 2.50 |
| Sonnet 3.5 – 4.6 | 3 | 15 | 0.30 | 3.75 |
| Haiku 5.5 | 0.10 | 0.50 | 0.01 | 0.125 |
| Haiku 5.5, a request past 100k prompt tokens | 0.50 | 2.50 | 0.05 | 0.625 |
| Haiku 4.5 | 1 | 5 | 0.10 | 1.25 |
| Haiku 3.5 | 0.80 | 4 | 0.08 | 1 |
| Fable 5.1, Mythos 5.1 | 10 | 50 | 0.25 | 12.50 |
| Fable 5, Mythos 5 | 10 | 50 | 1 | 12.50 |
≈. So it is more than the agents' costs added up; the band's ≈ is theirs alone.≈): its tokens times the price table. Claude Code reports the session's cost, not each subagent's. A model with no price shows —. A cache write is priced as the 5-minute kind (1.25× input), which is what a subagent writes unless its settings ask for an hour; hooks are not told an hour's writes (2× input), fast mode or US-only inference apart, so those cost more than shown.[1m] model, 200k otherwise. It says how full the agent's context is; it is not progress.Crew reads only what Claude Code tells its hooks — subagent spawns, tool calls, model requests and turn ends — and asks Claude Code for its list of agents, the session's usage and the main model. It runs no commands, reads no files of yours and makes no network requests. It stores one thing, whether Finished is collapsed, in its own plugin store. You can confirm this before installing:
claude plugin validate ./crew
The calls: line lists every mods API call it makes.
Tested with Claude Code 2.1.299 in the terminal and the Desktop app's Code tab. The terminal draws the mascots as pixel art in half blocks; the Desktop app draws the same pixels as an animated SVG, and each running card's context bar as a picture three pixels tall (a row of ━ there comes from a wider font than the cell, and wraps). A pane Claude Code opens without being asked needs a terminal 144 columns wide (110 once you have opened it yourself); /crew opens it at any width. A drawing has a size limit, so with many agents at once only the first ones get a mascot (about thirty in the terminal, eight in the Desktop app, where the bars take some of the room) and the rest get a colored dot.
claude plugin validate --strict . # manifest, hooks and calls
claude plugin test . # 134 tests, no session or sign-in needed
claude --plugin-dir . # try it in a session; edits hot-reload
hooks/register.ts is the only file that touches the mods API. The rest is pure and tested on its own: sprites.ts (the mascots and their encoders), pricing.ts (prices, model names, number formats), roster.ts (the agents' lifecycle) and view.ts (the text of every line).
The mascots are original pixel art. Crew is written from scratch against the public Claude Code mods API.
MIT © flaviorg
hooks/register.ts 597 lines1// crew — a live panel of the subagents Claude delegates to.
2//
3// This is the only file that touches the mods API ($). It observes the agents' events,
4// keeps the roster in one $.state atom, runs one animation clock, and arranges the pure
5// modules' output into elements: sprites.ts (the mascots), pricing.ts (prices and
6// formats), roster.ts (the agents' lifecycle) and view.ts (the text of every line).
7
8import { atom, read, update } from 'claude-code'
9import { contextWindow, parsePrices, type Price } from './pricing.ts'
10import {
11 anyLive, completed, counts, EMPTY, estimate, finished, live, poseOf, reconciled, spawned, stepped,
12 toolUsed, totals, type AgentRow, type Roster,
13} from './roster.ts'
14import {
15 encodeCells, FRAME_MS, frameAt, kindOf, paletteFor, phaseOf, spriteSvg, SPRITE_COLUMNS, SPRITE_H, SPRITE_ROWS, SPRITE_W,
16 SVG_PIXEL, type Pose,
17} from './sprites.ts'
18import {
19 ACCENT, bandFacts, bar, cards, cardsLine, ctxLabel, ctxPercent, DOTS, EMPTY_TEXT, FINISHED_CAP, finishedLines, foldLabel,
20 GAUGE_PX, gaugeSvg, glyphOf, headerFacts, metaLine, oneLine, QUIET_TEXT, runningTitle, statsLine, truncate, widthOf, type Card,
21} from './view.ts'
22
23const PANE = 'crew'
24const OPEN = { id: PANE, title: 'Crew', columns: 60 } as const
25
26// A drawn tree is bounded at 100,000 characters: past it, what comes last is cut. Mascot
27// art may take this much of it: thirty terminal mascots, or about eight SVG ones on a
28// remote surface, whose running cards carry their gauges as pictures too (up to
29// RUNNING_CAP of them, some 300 characters each, which the smaller budget leaves room
30// for); past it, mascots are dots.
31const ART_BUDGET = 32_000
32const ART_BUDGET_REMOTE = 24_000
33/** Running cards drawn; past this, one line says how many more. */
34const RUNNING_CAP = 30
35/** Dots in the band, one per live agent. */
36const BAND_DOTS = 8
37/** Narrower than this, a card leaves its text too little room for a mascot: it gets a dot. */
38const ART_MIN_WIDTH = SPRITE_COLUMNS + 1 + 16
39/** Ticks between two looks at $.agent.list(): every 2 s while an agent runs. */
40const RECONCILE_EVERY = 8
41/** With no period for this long, the interval is gone: a refused period ends it silently. */
42const STALL_MS = 4 * FRAME_MS
43
44type Options = { autoOpen: 'on' | 'off'; band: 'on' | 'off'; mascot: 'on' | 'off'; prices: string }
45type View = { width: number; window: number; mainModel: string; now: number; art: { left: number } }
46
47const rosterAtom = atom({ plugin: 'crew', key: 'roster' } as const, EMPTY)
48
49let settings: Options = { autoOpen: 'on', band: 'on', mascot: 'on', prices: '' }
50let overrides: Record<string, Price> = {}
51
52// Throwaway values: nothing here needs to survive a reload.
53let frame = 0 // the animation frame, one per FRAME_MS
54let ticker: { cancel(): void } | null = null
55let beat = { at: 0, frame: 0 } // the last time ensureTicker saw the frame counter, and its value
56let busy = false // a tick still waiting on the host makes the next one skip its work
57let wasLive = false // so the tick after the last agent files away redraws once more
58let paneShown = true // as last seen by the ticker
59let opened = false // the pane opens by itself once per session
60let collapsed = false // the Finished section, folded; kept in $.store so the choice sticks
61
62// Turn ends for agents with no row yet: one that fails on its first request can end before
63// its spawn hook has written the row. The spawn applies its own; the oldest go past EARLY_MAX.
64type End = { at: number; reason?: string; isAborted?: boolean; turnId?: string }
65const early = new Map<string, End>()
66const EARLY_MAX = 20
67
68function keepEarly(id: string, end: End) {
69 early.delete(id)
70 early.set(id, end)
71 if (early.size > EARLY_MAX) early.delete(early.keys().next().value!)
72}
73
74// ── the clock ──────────────────────────────────────────────────────────────────────────
75
76// One clock drives everything that moves: every frame while an agent is live (4 a second,
77// under the redraw cap), once a second for the Time card while only the pane is shown,
78// and nothing at all otherwise.
79//
80// A refused period ends an interval and its handle does not say so. Whoever calls this
81// passes the time: when no period has come for STALL_MS, the old interval is replaced.
82function ensureTicker($: any, now: number) {
83 if (ticker && now - beat.at >= STALL_MS) {
84 if (frame === beat.frame) stopTicker()
85 beat = { at: now, frame }
86 }
87 if (ticker) return
88 paneShown = true
89 beat = { at: now, frame }
90 try {
91 ticker = $.clock.every(FRAME_MS, () => onTick($))
92 } catch {
93 ticker = null
94 }
95}
96
97function stopTicker() {
98 try {
99 ticker?.cancel()
100 } catch {}
101 ticker = null
102}
103
104async function clockNow($: any): Promise<number> {
105 try {
106 return await $.clock.now()
107 } catch {
108 return beat.at
109 }
110}
111
112type PaneState = 'shown' | 'behind' | 'closed'
113
114// Crew's pane: shown, open but not shown (a tab behind another pane, docked or inline, or
115// waiting undrawn), or not open; null when the host cannot say.
116async function paneState($: any): Promise<PaneState | null> {
117 try {
118 const pane = (await $.ui.panes()).find((p: any) => p.id === PANE)
119 return pane === undefined ? 'closed' : pane.isShown ? 'shown' : 'behind'
120 } catch {
121 return null
122 }
123}
124
125async function onTick($: any) {
126 frame++
127 if (busy) return
128 busy = true
129 try {
130 if (frame % RECONCILE_EVERY === 0) await reconcile($)
131 const r = await read($, rosterAtom)
132 const isLive = anyLive(r, await $.clock.now())
133 if (isLive || wasLive) $.ui.invalidate('ui.render')
134 wasLive = isLive
135 if (isLive) return
136 if (frame % 4 === 0) {
137 // A pane list the host cannot give counts as no pane: the clock stops, not spins.
138 paneShown = (await paneState($)) === 'shown'
139 if (!paneShown) return stopTicker()
140 }
141 // With nothing live the pane shows its idle mascot, which blinks: draw just the frames
142 // where it changes.
143 const blinked = settings.mascot === 'on' && frameAt('general', 'idle', frame) !== frameAt('general', 'idle', frame - 1)
144 if (paneShown && (frame % 4 === 0 || blinked)) $.ui.invalidate('ui.render')
145 } catch {
146 // A failed tick is skipped; the next one tries again.
147 } finally {
148 busy = false
149 }
150}
151
152// $.agent.list() is the only word on a failure or a kill that no turn.complete reports.
153// Workflow agents are never listed, so an unlisted agent is left alone.
154async function reconcile($: any) {
155 const r = await read($, rosterAtom)
156 if (!r.agents.some((a) => a.status === 'running')) return
157 let list: any[]
158 try {
159 list = await $.agent.list()
160 } catch {
161 return
162 }
163 const at = await $.clock.now()
164 if (reconciled(r, list, at) !== r) await update($, rosterAtom, (x) => reconciled(x, list, at))
165}
166
167// ── the pane ───────────────────────────────────────────────────────────────────────────
168
169async function openPane($: any) {
170 try {
171 await $.ui.open(OPEN)
172 } catch {}
173 ensureTicker($, await clockNow($))
174}
175
176// Shows the pane at the person's word. Claude Code shows one pane at a time, docked or above
177// the prompt, the rest tabs behind it, and an open of an open id only retitles it: a pane
178// behind is closed and opened again, which brings it to the front. A refused close rejects.
179async function showPane($: any, state: PaneState | null) {
180 if (state === 'behind') await $.ui.close({ id: PANE })
181 await openPane($)
182}
183
184// The band's o: Open never hides the pane; a refused close leaves it where it is.
185async function openFromBand($: any) {
186 try {
187 await showPane($, await paneState($))
188 } catch {}
189}
190
191// The first spawn opens the pane by itself. Unasked, a pane is seated only on a wide
192// terminal; when it is not, say once how to see it.
193async function autoOpen($: any) {
194 if (settings.autoOpen !== 'on' || opened) return
195 opened = true
196 try {
197 const placed = await $.ui.open(OPEN)
198 if (placed?.isPlaced === false) $.ui.toast('crew: run /crew to watch your subagents')
199 } catch {}
200}
201
202async function toggleCollapsed($: any) {
203 collapsed = !collapsed
204 try {
205 await $.store.set('collapsed', collapsed)
206 } catch {}
207 $.ui.invalidate('ui.render')
208}
209
210// Takes `size` characters from the tree's art budget, if they are left.
211function spend(art: { left: number }, size: number): boolean {
212 if (size > art.left) return false
213 art.left -= size
214 return true
215}
216
217// One agent's mascot for the surface drawing it: half-block pixels in the terminal, a
218// self-animating SVG where Svg exists, a dot in its coat's color otherwise (and on a pane
219// too narrow for it, or once the art budget is spent). `phase` offsets its loop.
220function mascot(
221 $: any, e: any, id: string, type: string, pose: Pose, alt: string, art: { left: number }, roomy: boolean, phase = 0,
222) {
223 const palette = paletteFor(type, pose)
224 if (settings.mascot === 'on' && roomy) {
225 if (e.surface === 'terminal') {
226 const { Raster } = $.ui.resolve(e)
227 const cells = encodeCells(frameAt(kindOf(type), pose, frame + phase), palette)
228 if (Raster && spend(art, cells.length)) return Raster({ key: 'm:' + id, columns: SPRITE_COLUMNS, rows: SPRITE_ROWS, cells })
229 } else if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
230 const { Svg } = $.ui.resolve(e)
231 const source = spriteSvg(kindOf(type), pose, palette)
232 if (Svg && spend(art, source.length + alt.length)) {
233 return Svg({ source, alt, width: SPRITE_W * SVG_PIXEL, height: SPRITE_H * SVG_PIXEL, isInteractive: true })
234 }
235 }
236 }
237 const { Text } = $.ui.resolve(e)
238 return Text({ color: palette.b!, children: ['●'] })
239}
240
241// The family mark, then the facts cut to what the pane leaves them: never a second line.
242function header($: any, e: any, r: Roster, width: number) {
243 const { Box, Text } = $.ui.resolve(e)
244 const facts = truncate(' · ' + headerFacts(counts(r)), width - 6)
245 return Box({
246 flexDirection: 'row',
247 children: [Text({ bold: true, children: ['◆ crew'] }), facts !== '' && Text({ dimColor: true, children: [facts] })],
248 })
249}
250
251// Three round cards of one width each (the last takes the odd cells), or one dim line when
252// three would not fit. Fixed widths keep the borders still while the values change length.
253// A card too narrow for its title (`Session cost`) shows its label (`Cost`).
254function statCards($: any, e: any, list: Card[], width: number) {
255 const { Box, Text } = $.ui.resolve(e)
256 if (width < 36) return Text({ dimColor: true, children: [cardsLine(list, width)] })
257 const each = Math.floor((width - 2) / 3)
258 const card = (c: Card, i: number) => {
259 const w = i === list.length - 1 ? width - 2 - each * (list.length - 1) : each
260 const title = widthOf(c.title) <= w - 4 ? c.title : c.label
261 return Box({
262 key: 'card:' + c.label.toLowerCase(),
263 borderStyle: 'round',
264 borderColor: 'subtle',
265 flexDirection: 'column',
266 paddingX: 1,
267 width: w,
268 children: [
269 Text({ dimColor: true, children: [truncate(title, w - 4)] }),
270 Text({ bold: true, ...(c.label === 'Cost' ? { color: ACCENT } : {}), children: [truncate(c.value, w - 4)] }),
271 ],
272 })
273 }
274 return Box({ flexDirection: 'row', gap: 1, marginTop: 1, children: list.map(card) })
275}
276
277// The context gauge and its label, ending under the status glyph. In the terminal, a row
278// of `━` and `─` in cells. On the surfaces that draw `Svg`, a thin picture that takes the
279// room the label leaves: in the Desktop app a `━` comes from a wider font than the cell
280// the width counts, and a row of them wraps. The label's box never shrinks, so it is never
281// cut or wrapped; the picture's does, and is clipped if a surface draws it too wide. (The
282// table names `Svg` on every surface, a fragment where there is none: so the surface decides.)
283// A column too narrow for a picture and its label draws the label alone, cut to fit, as
284// the terminal does.
285function gaugeRow($: any, e: any, a: AgentRow, pct: number, width: number) {
286 const { Box, Text, Svg } = $.ui.resolve(e)
287 const label = ctxLabel(pct)
288 const running = a.status === 'running'
289 const room = width - label.length - 1
290 if (Svg && room >= 3 && (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile')) {
291 return Box({
292 flexDirection: 'row',
293 alignItems: 'center',
294 children: [
295 Box({
296 flexGrow: 1,
297 flexShrink: 1,
298 minWidth: 0,
299 overflow: 'hidden',
300 children: [Svg({ source: gaugeSvg(pct, running), alt: `context ${ctxPercent(pct)}% full`, height: GAUGE_PX })],
301 }),
302 Box({ flexShrink: 0, children: [Text({ dimColor: true, children: [' ' + label] })] }),
303 ],
304 })
305 }
306 const gauge = room >= 3 ? bar(pct, room) : { filled: '', track: '' }
307 const lit = running ? ACCENT : 'subtle'
308 return Box({
309 flexDirection: 'row',
310 children: [
311 gauge.filled !== '' && Text({ color: lit, children: [gauge.filled] }),
312 gauge.track !== '' && Text({ color: 'subtle', children: [gauge.track] }),
313 Text({ dimColor: true, children: [room >= 3 ? ' ' + label : truncate(label, width)] }),
314 ],
315 })
316}
317
318// Mascot on the left; description and status, meta, context bar and stats on the right.
319// The first card sits right under its section's title, as the first Finished row does.
320function agentCard($: any, e: any, a: AgentRow, view: View, first: boolean) {
321 const { Box, Text } = $.ui.resolve(e)
322 const pose = poseOf(a)
323 const phase = phaseOf(a.id)
324 const glyph = glyphOf(a.status, frame + phase)
325 const roomy = view.width >= ART_MIN_WIDTH
326 // The text column: what the mascot (or its dot) and the margin leave.
327 const width = Math.max(1, view.width - (settings.mascot === 'on' && roomy ? SPRITE_COLUMNS : 1) - 1)
328 const pct = (100 * a.context) / contextWindow(a.model, view.window, view.mainModel)
329 return Box({
330 key: 'agent:' + a.id,
331 flexDirection: 'row',
332 marginTop: first ? 0 : 1,
333 children: [
334 mascot($, e, a.id, a.type, pose, `${truncate(a.description || a.type, 80)} — ${pose}`, view.art, roomy, phase),
335 Box({
336 flexDirection: 'column',
337 marginLeft: 1,
338 flexGrow: 1,
339 children: [
340 Box({
341 flexDirection: 'row',
342 children: [
343 Text({ bold: true, children: [truncate(a.description || a.type, width - 2)] }),
344 Box({ flexGrow: 1 }),
345 Text({ color: glyph.color, children: [glyph.char] }),
346 ],
347 }),
348 Text({ dimColor: true, children: [metaLine(a, width)] }),
349 gaugeRow($, e, a, pct, width),
350 Text({ dimColor: true, children: [statsLine(a, view.now, estimate(a, overrides), width)] }),
351 ],
352 }),
353 ],
354 })
355}
356
357function runningSection($: any, e: any, agents: AgentRow[], view: View) {
358 const { Box, Text } = $.ui.resolve(e)
359 const more = agents.length > RUNNING_CAP ? [Text({ dimColor: true, children: [`… ${agents.length - RUNNING_CAP} more running`] })] : []
360 const running = agents.filter((a) => a.status === 'running').length
361 return [
362 // Every card is counted: the running ones, and apart, those celebrating their end.
363 Box({ marginTop: 1, children: [Text({ dimColor: true, bold: true, children: [runningTitle(running, agents.length - running, view.width)] })] }),
364 ...agents.slice(0, RUNNING_CAP).map((a, i) => agentCard($, e, a, view, i === 0)),
365 ...more,
366 ]
367}
368
369function finishedRow($: any, e: any, a: AgentRow, { title, right }: { title: string; right: string }) {
370 const { Box, Text } = $.ui.resolve(e)
371 const glyph = glyphOf(a.status, 0)
372 return Box({
373 key: 'done:' + a.id,
374 flexDirection: 'row',
375 children: [
376 Text({ color: glyph.color, children: [glyph.char] }),
377 Text({ children: [' ' + title] }),
378 Box({ flexGrow: 1 }),
379 Text({ dimColor: true, children: [' ' + right] }),
380 ],
381 })
382}
383
384function finishedSection($: any, e: any, done: AgentRow[], width: number) {
385 const { Box, Text, Button } = $.ui.resolve(e)
386 const head = Box({
387 flexDirection: 'row',
388 marginTop: 1,
389 children: [
390 Text({ dimColor: true, bold: true, children: [`FINISHED · ${done.length}`] }),
391 Box({ flexGrow: 1 }),
392 Button({
393 key: 'collapse',
394 plain: true,
395 hotkey: collapsed ? 'e' : 'c',
396 label: foldLabel(done.length, collapsed, width),
397 onPress: () => toggleCollapsed($),
398 }),
399 ],
400 })
401 if (collapsed) return [head]
402 // The rows are laid out together, so the columns of their right sides line up.
403 const shown = done.slice(0, FINISHED_CAP)
404 const lines = finishedLines(shown.map((a) => ({ a, usd: estimate(a, overrides) })), width)
405 const rows = shown.map((a, i) => finishedRow($, e, a, lines[i]!))
406 const more = done.length > FINISHED_CAP ? [Text({ dimColor: true, children: [`… ${done.length - FINISHED_CAP} more`] })] : []
407 return [head, ...rows, ...more]
408}
409
410// Never a blank pane, and never one without its mascot: before the first agent and again
411// once every agent has finished, an idle mascot and what will happen.
412function idleRow($: any, e: any, art: { left: number }, width: number, text: string) {
413 const { Box, Text } = $.ui.resolve(e)
414 return Box({
415 flexDirection: 'row',
416 marginTop: 1,
417 children: [
418 mascot($, e, 'idle', 'general-purpose', 'idle', 'crew mascot, waiting', art, width >= ART_MIN_WIDTH),
419 Box({
420 flexDirection: 'column',
421 justifyContent: 'center',
422 marginLeft: 1,
423 flexShrink: 1,
424 children: [Text({ dimColor: true, children: [text] })],
425 }),
426 ],
427 })
428}
429
430async function sessionUsage($: any): Promise<any> {
431 try {
432 return await $.session.usage()
433 } catch {
434 return null
435 }
436}
437
438async function sessionModel($: any): Promise<string> {
439 try {
440 return (await $.session.model()) ?? ''
441 } catch {
442 return ''
443 }
444}
445
446async function drawPane($: any, e: any) {
447 const { Box } = $.ui.resolve(e)
448 const r = await read($, rosterAtom)
449 const now = await $.clock.now()
450 const usage = await sessionUsage($)
451 const mainModel = await sessionModel($)
452 // A dock draws the body flush against its borders: crew keeps a cell clear on each side,
453 // as an inline frame already does, and lays out to what is left.
454 const docked = e.props?.placement === 'dock'
455 const width = Math.max(1, (e.props?.bodyColumns ?? 60) - (docked ? 2 : 0))
456 const art = { left: e.surface === 'terminal' ? ART_BUDGET : ART_BUDGET_REMOTE }
457 const view: View = { width, now, mainModel, window: usage?.context?.window ?? 200_000, art }
458 const sum = totals(r, overrides, mainModel)
459 const startedAt = typeof usage?.startedAt === 'number' ? usage.startedAt : now
460 const values = cards({ realUsd: usage?.cost?.usd ?? null, estUsd: sum.estUsd, tokens: sum.tokens, sessionMs: now - startedAt })
461 const running = live(r, now)
462 const done = finished(r, now)
463 ensureTicker($, now)
464 const root = Box({
465 flexDirection: 'column',
466 children: [
467 header($, e, r, width),
468 statCards($, e, values, width),
469 ...(running.length === 0 ? [idleRow($, e, view.art, width, r.agents.length === 0 ? EMPTY_TEXT : QUIET_TEXT)] : []),
470 ...(running.length > 0 ? runningSection($, e, running, view) : []),
471 ...(done.length > 0 ? finishedSection($, e, done, width) : []),
472 ],
473 })
474 return docked ? Box({ flexDirection: 'column', paddingLeft: 1, paddingRight: 1, children: [root] }) : root
475}
476
477// ── the band ───────────────────────────────────────────────────────────────────────────
478
479// One line while anything is live: a dot per agent, the facts, and o: Open. Whatever the
480// mods after this one draw stays beneath it.
481async function drawBand($: any, e: any, next: any) {
482 if (settings.band === 'off' || e.props?.hasSurvey) return next(e)
483 const r = await read($, rosterAtom)
484 const now = await $.clock.now()
485 const agents = live(r, now)
486 if (agents.length === 0) return next(e)
487 ensureTicker($, now)
488 const { Box, Text, Button } = $.ui.resolve(e)
489 const dots = agents.slice(0, BAND_DOTS).map((a) =>
490 Text({ color: paletteFor(a.type, poseOf(a)).b!, children: [a.status === 'running' ? DOTS[frame % DOTS.length]! : '●'] }),
491 )
492 const room = (e.props?.bodyColumns ?? 80) - 7 - dots.length - 10
493 const row = Box({
494 flexDirection: 'row',
495 children: [
496 Text({ bold: true, children: ['◆ crew '] }),
497 ...dots,
498 Text({ dimColor: true, children: [' ' + bandFacts(r, now, overrides, room) + ' '] }),
499 Button({ key: 'band-open', plain: true, hotkey: 'o', label: 'Open', onPress: () => openFromBand($) }),
500 ],
501 })
502 return Box({ flexDirection: 'column', children: [row, await next(e)] })
503}
504
505// ── wiring ─────────────────────────────────────────────────────────────────────────────
506
507export function register(on: any, options: any) {
508 settings = { ...settings, ...options }
509 overrides = parsePrices(String(settings.prices ?? ''))
510
511 on('session.start', async ($: any, e: any, next: any) => {
512 try {
513 await $.command.register({ name: 'crew', description: 'Show or hide the subagent panel', immediate: true })
514 } catch {}
515 try {
516 collapsed = (await $.store.get('collapsed')) === true
517 } catch {}
518 return next(e)
519 }).catch(($: any, e: any, next: any) => next(e))
520
521 // The spawn resolves once the agent has started, with the id its own events carry.
522 // A refused spawn, or one without an id, adds nothing. The result passes untouched.
523 // Its text comes from the model, a workflow script or another plugin: it is kept as one
524 // clean line, since a surface refuses a whole tree whose text holds a control character.
525 on('agent.spawn', async ($: any, e: any, next: any) => {
526 const r = await next(e)
527 if (r && !r.deny && r.agentId) {
528 const at = await $.clock.now()
529 const row = {
530 id: r.agentId,
531 description: oneLine(e.description ?? ''),
532 type: oneLine(e.subagentType ?? '', 60) || 'general-purpose',
533 model: oneLine(r.model ?? e.model ?? e.parentModel ?? '', 80),
534 background: e.background === true,
535 workflow: !!e.workflow,
536 at,
537 }
538 await update($, rosterAtom, (x) => {
539 const end = early.get(row.id)
540 return end ? completed(spawned(x, row), row.id, end) : spawned(x, row)
541 })
542 early.delete(row.id)
543 ensureTicker($, at)
544 await autoOpen($)
545 }
546 return r
547 }).catch(($: any, e: any, next: any) => next(e))
548
549 on('tool.call', async ($: any, e: any, next: any) => {
550 if (e.agentId) await update($, rosterAtom, (x) => toolUsed(x, e.agentId, oneLine(String(e.tool ?? ''), 60)))
551 return next(e)
552 }).catch(($: any, e: any, next: any) => next(e))
553
554 // Tokens come from the steps alone; turn.complete repeats their sum, so it adds none.
555 // No .catch here: a stream that breaks off fails this hook after its chunks went out,
556 // and a catch's next(e) would replay them. Without one the engine passes the rest on;
557 // the counting after the stream is wrapped so the hook never fails on its own.
558 on('turn.step', async function* ($: any, e: any, next: any) {
559 const r = yield* next(e)
560 try {
561 const at = await $.clock.now()
562 await update($, rosterAtom, (x) => stepped(x, e.agentId, r?.usage, e.effort, at, e.turnId))
563 } catch {
564 // A step left uncounted; the response itself is untouched.
565 }
566 return r
567 })
568
569 on('turn.complete', async ($: any, e: any, next: any) => {
570 if (e.agentId) {
571 const at = await $.clock.now()
572 const end: End = { at, reason: e.reason, isAborted: e.isAborted, turnId: e.turnId }
573 await update($, rosterAtom, (x) => {
574 if (!x.agents.some((a) => a.id === e.agentId)) keepEarly(e.agentId, end)
575 return completed(x, e.agentId, end)
576 })
577 ensureTicker($, at) // the celebration needs frames
578 }
579 return next(e)
580 }).catch(($: any, e: any, next: any) => next(e))
581
582 // Hides the pane when it is shown; otherwise shows it, bringing one open behind another
583 // pane's tab to the front. When the pane list cannot be read, opening is the useful guess.
584 // A registered command has no core beneath it, so a failure answers in words instead of
585 // passing on.
586 on('command.run', { command: 'crew' }, async ($: any) => {
587 const state = await paneState($)
588 if (state === 'shown') await $.ui.close({ id: PANE })
589 else await showPane($, state)
590 return {}
591 }).catch(($: any, e: any, next: any) => (next.called ? next(e) : { text: 'crew: could not show or hide the panel' }))
592
593 on('ui.render', { component: 'Pane', requestId: PANE }, async ($: any, e: any) => drawPane($, e))
594
595 on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => drawBand($, e, next))
596}
597hooks/pricing.ts 243 lines1// Prices, model names and number formats. Pure, no mods API.
2//
3// Per-agent cost is an estimate: Claude Code reports the session's real cost, but not each
4// subagent's, so crew multiplies an agent's tokens by this table and labels the result ≈.
5
6export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
7/** $ per million tokens; `longPrompt`, when a release has them, its rates for a long request. */
8export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number; longPrompt?: Price }
9export type Family = 'opus' | 'sonnet' | 'haiku' | 'fable' | 'mythos'
10
11/** The usage fields a model response reports (TurnUsage), all optional here. */
12export type UsageLike = {
13 input_tokens?: number
14 output_tokens?: number
15 cache_read_input_tokens?: number
16 cache_creation_input_tokens?: number
17}
18
19export const ZERO: Tokens = Object.freeze({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
20
21const rate = (input: number, output: number, cacheRead: number, cacheWrite: number): Price =>
22 Object.freeze({ input, output, cacheRead, cacheWrite })
23const T15_75 = rate(15, 75, 1.5, 18.75)
24const T10_50 = rate(10, 50, 1, 12.5)
25const T10_50_READ_025 = rate(10, 50, 0.25, 12.5)
26const T5_25 = rate(5, 25, 0.5, 6.25)
27const T3_15 = rate(3, 15, 0.3, 3.75)
28
29/**
30 * A request whose prompt (input and cache, read or written) is past this many tokens is a
31 * long one: Claude Code prices each such request at its release's long-prompt rates.
32 */
33export const LONG_PROMPT = 100_000
34
35/**
36 * List prices by release (`opus 5.5`, as release() names it): the model catalog Claude
37 * Code 2.1.296 ships, which its own session cost is priced from. Prices change between
38 * releases of one family: Opus 5.5 costs less than Opus 5, Opus 4.1 three times more.
39 *
40 * A cache write is the 5-minute kind, 1.25× input: what a subagent writes unless its
41 * settings ask for an hour (2× input). Hooks get a response's four token counts and no
42 * more, so an hour's writes, fast mode and US-only inference (1.1×) cost more than this
43 * says. Haiku 5.5 alone has long-prompt rates, 5× its own: crew counts a long request's
44 * tokens apart, so they are priced at those.
45 */
46export const PRICES: Readonly<Record<string, Price>> = Object.freeze({
47 'opus 5.5': rate(4, 20, 0.2, 5),
48 'opus 5': T5_25,
49 'opus 4.8': T5_25,
50 'opus 4.7': T5_25,
51 'opus 4.6': T5_25,
52 'opus 4.5': T5_25,
53 'opus 4.1': T15_75,
54 'opus 4': T15_75,
55 'sonnet 5.5': rate(2, 10, 0.1, 2.5),
56 'sonnet 5': rate(2, 10, 0.2, 2.5),
57 'sonnet 4.6': T3_15,
58 'sonnet 4.5': T3_15,
59 'sonnet 4': T3_15,
60 'sonnet 3.7': T3_15,
61 'sonnet 3.5': T3_15,
62 'haiku 5.5': Object.freeze({ ...rate(0.1, 0.5, 0.01, 0.125), longPrompt: rate(0.5, 2.5, 0.05, 0.625) }),
63 'haiku 4.5': rate(1, 5, 0.1, 1.25),
64 'haiku 3.5': rate(0.8, 4, 0.08, 1),
65 'fable 5.1': T10_50_READ_025,
66 'fable 5': T10_50,
67 'mythos 5.1': T10_50_READ_025,
68 'mythos 5': T10_50,
69})
70
71/** Each family's newest release: the price of one the table does not know yet. */
72const NEWEST: Readonly<Record<Family, string>> = Object.freeze({
73 opus: 'opus 5.5', sonnet: 'sonnet 5.5', haiku: 'haiku 5.5', fable: 'fable 5.1', mythos: 'mythos 5.1',
74})
75
76const FIELDS = ['input', 'output', 'cacheRead', 'cacheWrite'] as const
77
78export function family(model: string): Family | null {
79 const m = model.toLowerCase()
80 if (m.includes('opus')) return 'opus'
81 if (m.includes('sonnet')) return 'sonnet'
82 if (m.includes('haiku')) return 'haiku'
83 if (m.includes('fable')) return 'fable'
84 if (m.includes('mythos')) return 'mythos'
85 return null
86}
87
88/**
89 * The release a model id names, in any provider's spelling: `claude-opus-4-5-20251101`,
90 * `us.anthropic.claude-opus-4-5-20251101-v1:0` and `claude-opus-4-5@20251101` are all
91 * `opus 4.5`; `claude-opus-4-20250514` is `opus 4`, `claude-3-5-haiku-…` is `haiku 3.5`.
92 * Null when the id carries no version.
93 */
94export function release(model: string): string | null {
95 const m = model.toLowerCase()
96 // claude-<name>-<major>[-<minor>], then a date or a suffix: a minor is at most two digits.
97 const modern = /claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)/.exec(m)
98 // claude-<major>[-<minor>]-<name>, the older order
99 const older = modern ? null : /claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)/.exec(m)
100 const [name, major, minor] = modern ? [modern[1], modern[2], modern[3]] : older ? [older[3], older[1], older[2]] : []
101 if (name === undefined || major === undefined) return null
102 // `claude-opus-4-0` is Opus 4.
103 return `${name} ${Number(major)}${minor !== undefined && Number(minor) > 0 ? '.' + Number(minor) : ''}`
104}
105
106function isPrice(v: unknown): v is Price {
107 if (typeof v !== 'object' || v === null) return false
108 const o = v as Record<string, unknown>
109 return FIELDS.every((f) => typeof o[f] === 'number' && Number.isFinite(o[f]) && (o[f] as number) >= 0)
110}
111
112/**
113 * The `prices` option: `{ "<family or model-id substring>": Price }`. Invalid JSON or a
114 * non-object gives `{}`; an entry with a missing, non-number or negative field is dropped.
115 */
116export function parsePrices(json: string): Record<string, Price> {
117 let parsed: unknown
118 try {
119 parsed = JSON.parse(json)
120 } catch {
121 return {}
122 }
123 if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return {}
124 const out: Record<string, Price> = {}
125 for (const [key, value] of Object.entries(parsed)) {
126 if (key.trim() === '' || !isPrice(value)) continue
127 out[key.toLowerCase()] = { input: value.input, output: value.output, cacheRead: value.cacheRead, cacheWrite: value.cacheWrite }
128 }
129 return out
130}
131
132/**
133 * The price of a model: the longest override key the id contains (so `opus-5-5` beats
134 * `opus`, and a family name works as a key), then the table's row for its release, then
135 * its family's newest release, else null.
136 */
137export function priceFor(model: string, overrides: Record<string, Price>): Price | null {
138 const m = model.toLowerCase()
139 let best: string | null = null
140 for (const key of Object.keys(overrides)) {
141 if (m.includes(key) && (best === null || key.length > best.length)) best = key
142 }
143 if (best !== null) return overrides[best] ?? null
144 const r = release(m)
145 const listed = r === null ? undefined : PRICES[r]
146 if (listed !== undefined) return listed
147 const f = family(m)
148 return f ? PRICES[NEWEST[f]] ?? null : null
149}
150
151const rated = (t: Tokens, p: Price): number =>
152 (t.input * p.input + t.output * p.output + t.cacheRead * p.cacheRead + t.cacheWrite * p.cacheWrite) / 1e6
153
154/** What `t` costs at `p`; `long`, the share of `t` that long requests sent, at `p`'s long-prompt rates. */
155export function cost(t: Tokens, p: Price, long: Tokens = ZERO): number {
156 if (!p.longPrompt) return rated(t, p)
157 const short = { input: t.input - long.input, output: t.output - long.output, cacheRead: t.cacheRead - long.cacheRead, cacheWrite: t.cacheWrite - long.cacheWrite }
158 return rated(short, p) + rated(long, p.longPrompt)
159}
160
161export function addTokens(a: Tokens, b: Tokens): Tokens {
162 return { input: a.input + b.input, output: a.output + b.output, cacheRead: a.cacheRead + b.cacheRead, cacheWrite: a.cacheWrite + b.cacheWrite }
163}
164
165export function totalTokens(t: Tokens): number {
166 return t.input + t.output + t.cacheRead + t.cacheWrite
167}
168
169const count = (n: unknown): number => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? n : 0)
170
171/** A response's usage as Tokens; anything missing, null or not a number counts 0. */
172export function fromUsage(u: UsageLike | null | undefined): Tokens {
173 if (!u) return { ...ZERO }
174 return {
175 input: count(u.input_tokens),
176 output: count(u.output_tokens),
177 cacheRead: count(u.cache_read_input_tokens),
178 cacheWrite: count(u.cache_creation_input_tokens),
179 }
180}
181
182/** How much of the window one request filled: everything it sent, cached or not. */
183export function contextOf(u: UsageLike | null | undefined): number {
184 const t = fromUsage(u)
185 return t.input + t.cacheRead + t.cacheWrite
186}
187
188/** The tokens of a long request (its prompt past LONG_PROMPT), added to `long`; else `long`. */
189export function addLong(long: Tokens | undefined, u: UsageLike | null | undefined): Tokens | undefined {
190 return contextOf(u) > LONG_PROMPT ? addTokens(long ?? ZERO, fromUsage(u)) : long
191}
192
193/**
194 * An agent's context window. Claude Code only reports the main model's, so an agent on the
195 * same model shares it; a `[1m]` id has a million; anything else gets the standard 200k.
196 */
197export function contextWindow(model: string, sessionWindow: number, mainModel: string): number {
198 if (model !== '' && model === mainModel && sessionWindow > 0) return sessionWindow
199 if (model.toLowerCase().includes('[1m]')) return 1_000_000
200 return 200_000
201}
202
203/** `claude-haiku-4-5-20251001` → `haiku 4.5`, `claude-sonnet-4-5[1m]` → `sonnet 4.5 1M`. */
204export function shortModel(model: string): string {
205 const oneM = /\[1m\]/i.test(model)
206 const bare = model.replace(/\[[^\]]*\]/g, '').trim()
207 const suffix = oneM ? ' 1M' : ''
208 // claude-<name>-<major>[-<minor>][-<yyyymmdd>]
209 const modern = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?$/i.exec(bare)
210 if (modern) return `${modern[1]!.toLowerCase()} ${modern[2]}${modern[3] ? '.' + modern[3] : ''}${suffix}`
211 // claude-<major>[-<minor>]-<name>[-<yyyymmdd>], the older order
212 const older = /^claude-(\d+)(?:-(\d{1,2}))?-([a-z]+)(?:-\d{8})?$/i.exec(bare)
213 if (older) return `${older[3]!.toLowerCase()} ${older[1]}${older[2] ? '.' + older[2] : ''}${suffix}`
214 return bare + suffix
215}
216
217/** `999`, `1.5k`, `206.5k`, `2.0M`. */
218export function formatTokens(n: number): string {
219 if (!Number.isFinite(n) || n <= 0) return '0'
220 if (n < 1000) return String(Math.round(n))
221 // 999,950 would print as 1000.0k; say 1.0M instead.
222 if (n < 999_950) return (n / 1000).toFixed(1) + 'k'
223 return (n / 1e6).toFixed(1) + 'M'
224}
225
226/** `$0.41`; `≈` marks an estimate; a non-zero amount under a cent is `<$0.01`. */
227export function formatUsd(usd: number, approx: boolean): string {
228 const prefix = approx ? '≈' : ''
229 const v = Number.isFinite(usd) && usd > 0 ? usd : 0
230 if (v > 0 && v < 0.005) return prefix + '<$0.01'
231 return prefix + '$' + v.toFixed(2)
232}
233
234/** `0:42`, `5:35`, `1:02:03`. */
235export function formatClock(ms: number): string {
236 const total = Number.isFinite(ms) && ms > 0 ? Math.floor(ms / 1000) : 0
237 const h = Math.floor(total / 3600)
238 const m = Math.floor((total % 3600) / 60)
239 const s = total % 60
240 const ss = String(s).padStart(2, '0')
241 return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
242}
243hooks/roster.ts 273 lines1// The subagents' lifecycle as immutable updates. Pure, no mods API.
2//
3// Every function takes a roster and returns a new one (or the same one when nothing
4// changed), so register.ts can hand them straight to `update($, atom, fn)`. Rows are plain
5// JSON: they live in $.state.
6
7import {
8 addLong, addTokens, contextOf, cost, fromUsage, priceFor, totalTokens, ZERO,
9 type Price, type Tokens, type UsageLike,
10} from './pricing.ts'
11import type { Pose } from './sprites.ts'
12
13export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
14
15export type AgentRow = {
16 id: string
17 description: string
18 type: string
19 model: string
20 effort?: string
21 background: boolean
22 workflow: boolean
23 startedAt: number
24 endedAt?: number
25 /** The turn whose end ended it: a late result of that turn does not revive it. */
26 endedTurn?: string
27 /** When a step of it last finished: what an agent nothing else reports on is judged by. */
28 seenAt?: number
29 status: AgentStatus
30 steps: number
31 tools: number
32 lastTool?: string
33 tokens: Tokens
34 /** The share of `tokens` its long requests sent (each past LONG_PROMPT); absent while none. */
35 long?: Tokens
36 context: number
37}
38
39export type Roster = { agents: AgentRow[]; main: Tokens; mainSteps: number; mainLong?: Tokens }
40
41/** A turn.step result's usage: token counts plus the model that answered. */
42export type TurnUsageLike = UsageLike & { model?: string }
43
44export type Spawn = {
45 id: string
46 description: string
47 type: string
48 model: string
49 background: boolean
50 workflow: boolean
51 at: number
52}
53
54export const EMPTY: Roster = Object.freeze({ agents: Object.freeze([]) as unknown as AgentRow[], main: ZERO, mainSteps: 0 })
55
56/** How long a finished agent stays in Running, in its done or failed pose. */
57export const CELEBRATE_MS = 3_000
58
59/** Rows kept per session; past this the oldest finished ones go. */
60export const MAX_AGENTS = 500
61
62/**
63 * How long a running agent that `$.agent.list()` does not list may go unheard before it
64 * counts as stopped: nothing else would ever end it (a workflow's remote agent, or a stalled
65 * one retried under a new id), and the band and the clock would run for the session.
66 */
67export const QUIET_MS = 30 * 60_000
68
69const endOf = (a: AgentRow): number => a.endedAt ?? a.startedAt
70
71function patch(r: Roster, id: string, change: (a: AgentRow) => AgentRow): Roster {
72 const i = r.agents.findIndex((a) => a.id === id)
73 if (i < 0) return r
74 const agents = r.agents.slice()
75 agents[i] = change(r.agents[i]!)
76 return { ...r, agents }
77}
78
79function capped(agents: AgentRow[]): AgentRow[] {
80 let excess = agents.length - MAX_AGENTS
81 if (excess <= 0) return agents
82 // Rows are in spawn order, so the first finished ones are the oldest. Running rows stay.
83 return agents.filter((a) => {
84 if (excess > 0 && a.status !== 'running') {
85 excess--
86 return false
87 }
88 return true
89 })
90}
91
92export function spawned(r: Roster, a: Spawn): Roster {
93 if (r.agents.some((x) => x.id === a.id)) return r
94 const row: AgentRow = {
95 id: a.id,
96 description: a.description,
97 type: a.type,
98 model: a.model,
99 background: a.background,
100 workflow: a.workflow,
101 startedAt: a.at,
102 status: 'running',
103 steps: 0,
104 tools: 0,
105 tokens: { ...ZERO },
106 context: 0,
107 }
108 return { ...r, agents: capped([...r.agents, row]) }
109}
110
111/**
112 * One model request finished. Without an agent id it was the main loop's. A null usage
113 * (no response arrived) still counts the step but adds no tokens.
114 *
115 * An ended agent that steps in a new turn is running again: a teammate runs one turn per
116 * message it receives, and a message may resume a stopped or failed agent under the same
117 * id. Its clock resumes where it stopped, so the time shown is time spent working. A late
118 * result of the very turn that ended it changes its numbers but not its status.
119 */
120export function stepped(
121 r: Roster,
122 agentId: string | undefined,
123 usage: TurnUsageLike | null | undefined,
124 effort: string | number | undefined,
125 at: number,
126 turnId?: string,
127): Roster {
128 if (agentId === undefined) {
129 const mainLong = addLong(r.mainLong, usage)
130 return { ...r, main: usage ? addTokens(r.main, fromUsage(usage)) : r.main, mainSteps: r.mainSteps + 1, ...(mainLong ? { mainLong } : {}) }
131 }
132 return patch(r, agentId, (a) => {
133 let next: AgentRow = { ...a, steps: a.steps + 1, seenAt: at }
134 if (usage) {
135 next.tokens = addTokens(a.tokens, fromUsage(usage))
136 const long = addLong(a.long, usage)
137 if (long) next.long = long
138 next.context = contextOf(usage)
139 if (typeof usage.model === 'string' && usage.model !== '') next.model = usage.model
140 }
141 if (effort !== undefined && String(effort) !== '') next.effort = String(effort)
142 const sameTurn = turnId !== undefined && turnId === a.endedTurn
143 if (a.status !== 'running' && !sameTurn) {
144 const { endedAt: _ended, endedTurn: _turn, ...open } = next
145 next = { ...open, status: 'running', startedAt: at - (endOf(a) - a.startedAt) }
146 }
147 return next
148 })
149}
150
151export function toolUsed(r: Roster, agentId: string, tool: string): Roster {
152 return patch(r, agentId, (a) => ({ ...a, tools: a.tools + 1, lastTool: tool }))
153}
154
155/**
156 * The agent's turn ended. Its usage is not added: turn.complete repeats the sum of the
157 * steps already counted.
158 */
159export function completed(
160 r: Roster,
161 agentId: string,
162 e: { at: number; reason?: string; isAborted?: boolean; turnId?: string },
163): Roster {
164 const status: AgentStatus =
165 e.isAborted === true || e.reason === 'aborted' ? 'stopped'
166 : e.reason === 'error' || e.reason === 'refusal' ? 'failed'
167 : 'done'
168 return patch(r, agentId, (a) => ({ ...a, status, endedAt: e.at, ...(e.turnId === undefined ? {} : { endedTurn: e.turnId }) }))
169}
170
171/**
172 * Applies `$.agent.list()`: it is the only place a failure or a kill shows up when no
173 * turn.complete comes, and the only word on a teammate in a terminal pane of its own, which
174 * runs no loop here (`idle` is its turn's end). An agent it does not list (workflow agents
175 * never are) is left alone until it has gone QUIET_MS unheard; then it counts as stopped,
176 * from when it was last heard. Returns the same roster when nothing changed.
177 */
178export function reconciled(r: Roster, list: ReadonlyArray<{ id: string; status: string }>, at: number): Roster {
179 const byId = new Map(list.map((x) => [x.id, x.status]))
180 let changed = false
181 const agents = r.agents.map((a) => {
182 const status = byId.get(a.id)
183 let next: AgentStatus | null = null
184 let end = a.endedAt ?? at
185 if (status === 'failed' && (a.status === 'running' || a.status === 'done')) next = 'failed'
186 if (status === 'killed' && (a.status === 'running' || a.status === 'done')) next = 'stopped'
187 if ((status === 'completed' || status === 'idle') && a.status === 'running') next = 'done'
188 if (status === undefined && a.status === 'running' && at - (a.seenAt ?? a.startedAt) >= QUIET_MS) {
189 next = 'stopped'
190 end = a.seenAt ?? a.startedAt
191 }
192 if (next === null) return a
193 changed = true
194 return { ...a, status: next, endedAt: end }
195 })
196 return changed ? { ...r, agents } : r
197}
198
199/** Running, or finished less than CELEBRATE_MS ago; oldest first. */
200export function live(r: Roster, now: number): AgentRow[] {
201 return r.agents
202 .filter((a) => a.status === 'running' || now - endOf(a) < CELEBRATE_MS)
203 .sort((a, b) => a.startedAt - b.startedAt)
204}
205
206/** Finished at least CELEBRATE_MS ago; newest first. */
207export function finished(r: Roster, now: number): AgentRow[] {
208 return r.agents
209 .filter((a) => a.status !== 'running' && now - endOf(a) >= CELEBRATE_MS)
210 .sort((a, b) => endOf(b) - endOf(a))
211}
212
213export function anyLive(r: Roster, now: number): boolean {
214 return r.agents.some((a) => a.status === 'running' || now - endOf(a) < CELEBRATE_MS)
215}
216
217/**
218 * The run the live agents belong to: every agent since the band last appeared. An agent is
219 * live from its spawn to CELEBRATE_MS after its end; the run is the unbroken stretch of
220 * live time that reaches now, so one already filed under Finished still counts while the
221 * others run. Empty when nothing is live.
222 */
223export function run(r: Roster, now: number): AgentRow[] {
224 const shown = live(r, now)
225 if (shown.length === 0) return []
226 let start = Math.min(...shown.map((a) => a.startedAt))
227 const ids = new Set(shown.map((a) => a.id))
228 // Newest end first: each one still live when the run began joins it, and may move its
229 // start earlier. The first that is not ends the search: every one after it ended sooner.
230 const rest = r.agents.filter((a) => !ids.has(a.id)).sort((a, b) => endOf(b) - endOf(a))
231 const joined: AgentRow[] = []
232 for (const a of rest) {
233 if (endOf(a) + CELEBRATE_MS <= start) break
234 joined.push(a)
235 start = Math.min(start, a.startedAt)
236 }
237 return [...joined, ...shown].sort((a, b) => a.startedAt - b.startedAt)
238}
239
240export function poseOf(a: AgentRow): Pose {
241 if (a.status === 'running') return 'working'
242 if (a.status === 'done') return 'done'
243 return 'failed'
244}
245
246export function counts(r: Roster): { running: number; done: number; failed: number; stopped: number } {
247 const c = { running: 0, done: 0, failed: 0, stopped: 0 }
248 for (const a of r.agents) c[a.status]++
249 return c
250}
251
252/** The agent's cost from its tokens and the price table; null when its model has no price. */
253export function estimate(a: AgentRow, overrides: Record<string, Price>): number | null {
254 const price = priceFor(a.model, overrides)
255 return price ? cost(a.tokens, price, a.long) : null
256}
257
258/** All tokens the session reported, and their estimated cost (null when nothing had a price). */
259export function totals(r: Roster, overrides: Record<string, Price>, mainModel: string): { tokens: number; estUsd: number | null } {
260 let tokens = totalTokens(r.main)
261 let estUsd: number | null = null
262 const add = (usd: number | null) => {
263 if (usd !== null) estUsd = (estUsd ?? 0) + usd
264 }
265 const mainPrice = priceFor(mainModel, overrides)
266 add(mainPrice ? cost(r.main, mainPrice, r.mainLong) : null)
267 for (const a of r.agents) {
268 tokens += totalTokens(a.tokens)
269 add(estimate(a, overrides))
270 }
271 return { tokens, estUsd }
272}
273hooks/sprites.ts 330 lines1// crew's mascots: an original dome-shaped critter, one head piece per agent kind.
2// Pure data and encoders, no mods API. The art is one table, ART: 9 x 8 pixels, drawn as
3// 9 columns x 4 rows of half blocks in the terminal (Raster), one row per line of an agent's
4// card, and as an animated SVG in the Desktop app.
5//
6// Pixel keys: . clear · b body · h highlight · d shade · e eye · w eye shine · m mouth
7// c cheek · a lit tip · A dim tip · s stalk/hat · f feet · x hands · k sparkle · t tear
8
9export type Pose = 'idle' | 'working' | 'done' | 'failed'
10export type Kind = 'general' | 'explore' | 'plan' | 'custom'
11export type Frame = readonly string[]
12export type Palette = Readonly<Record<string, string>>
13
14type Head = 'antenna' | 'feelers' | 'cap' | 'sprout'
15type Pixels = ReadonlyArray<readonly [number, number]>
16
17/**
18 * The drawing at one size, part by part. Rows run from the top and may be shorter than the
19 * grid; a '.' in them leaves the pixel below as it was.
20 */
21type Art = {
22 w: number
23 h: number
24 /** The dome with its highlight and shade, the whole grid. */
25 body: readonly string[]
26 /** Each head piece over the body, T its tip (lit or dim); `droop` when failed. */
27 heads: Record<Head, { up: readonly string[]; droop: readonly string[] }>
28 /**
29 * The eyes' top row, each eye's [left column, width] by where the face looks (turned, the
30 * eye nearer the edge narrows), and the eye as rows, cut to that width.
31 */
32 eyes: {
33 y: number
34 at: Record<'left' | 'ahead' | 'right', ReadonlyArray<readonly [number, number]>>
35 open: readonly string[]
36 blink: readonly string[]
37 tired: readonly string[]
38 }
39 /** Cheeks follow the look, on the body only: a face turned aside hides the far one. */
40 cheeks: { x: readonly number[]; y: number }
41 /** The mouth's middle with the face ahead, and each mouth as offsets from it. */
42 mouth: { x: number; y: number; dot: Pixels; smile: Pixels; flat: Pixels }
43 hands: Record<'rest' | 'tapL' | 'tapR' | 'up', Pixels>
44 feet: Record<'in' | 'L' | 'R' | 'none', Pixels>
45 /** Where sparkles twinkle when done, one set per frame; they take clear pixels only. */
46 sparks: readonly [Pixels, Pixels]
47 /** The column a tear rolls down when failed, and the three rows it passes. */
48 tear: { x: number; rows: readonly [number, number, number] }
49}
50
51// The one place the size lives: every pixel the mascot places, at 9 x 8.
52const ART: Art = {
53 w: 9,
54 h: 8,
55 body: [
56 '.........',
57 '.........',
58 '..hhbbb..',
59 '.hbbbbbb.',
60 '.bbbbbbd.',
61 '.bbbbbbd.',
62 '.ddddddd.',
63 '.........',
64 ],
65 heads: {
66 antenna: { up: ['....T', '....s'], droop: ['.....T', '....s'] },
67 feelers: { up: ['.T.....T', '..s...s'], droop: ['', '.Ts...sT'] },
68 cap: { up: ['...sss', '.sssssss', '.......T'], droop: ['...sss', '.sssssss', '.......T'] },
69 sprout: { up: ['..TT.T', '....s'], droop: ['..TT.T', '....s'] },
70 },
71 eyes: {
72 y: 3,
73 at: { left: [[2, 1], [4, 2]], ahead: [[2, 2], [5, 2]], right: [[3, 2], [6, 1]] },
74 open: ['we', 'ee'],
75 blink: ['', 'ee'],
76 tired: ['dd', 'ee'],
77 },
78 cheeks: { x: [2, 6], y: 5 },
79 // A smile is a small V; the frown the same shape turned over.
80 mouth: { x: 4, y: 5, dot: [[0, 0]], smile: [[-1, 0], [0, 1], [1, 0]], flat: [[-1, 1], [0, 0], [1, 1]] },
81 hands: { rest: [[0, 5], [8, 5]], tapL: [[0, 4], [8, 5]], tapR: [[0, 5], [8, 4]], up: [[0, 2], [0, 3], [8, 2], [8, 3]] },
82 feet: { in: [[2, 7], [6, 7]], L: [[1, 7], [6, 7]], R: [[2, 7], [7, 7]], none: [] },
83 sparks: [[[0, 0], [8, 0], [5, 0]], [[0, 2], [8, 1], [3, 0]]],
84 tear: { x: 7, rows: [4, 5, 6] },
85}
86
87export const SPRITE_W = ART.w
88export const SPRITE_H = ART.h
89export const SPRITE_COLUMNS = SPRITE_W
90export const SPRITE_ROWS = SPRITE_H / 2
91/** CSS pixels per mascot pixel in the Desktop app: 9 x 8 is 36 x 32, about two lines of text. */
92export const SVG_PIXEL = 4
93/** Every pose plays at this rate; poses repeat frames to go slower. */
94export const FRAME_MS = 250
95
96type FrameSpec = {
97 look?: -1 | 0 | 1
98 eyes?: 'open' | 'blink' | 'tired'
99 hands?: keyof Art['hands']
100 feet?: keyof Art['feet']
101 tip?: 'on' | 'off'
102 mouth?: 'dot' | 'smile' | 'flat' | 'none'
103 /** Which set of sparkles shows, if any. */
104 spark?: 0 | 1
105 droop?: boolean
106 /** How far the tear has rolled, if one shows. */
107 tear?: 0 | 1 | 2
108}
109
110const isBody = (c: string | undefined) => c === 'b' || c === 'h' || c === 'd'
111const SIDES: Pixels = [[1, 0], [-1, 0], [0, 1], [0, -1]]
112
113function draw(head: Head, spec: FrameSpec): Frame {
114 const { look = 0, eyes = 'open', hands = 'rest', feet = 'in', tip = 'on', mouth = 'dot', spark, droop = false, tear } = spec
115 const g: string[][] = Array.from({ length: ART.h }, (_, y) => [...(ART.body[y] ?? '').padEnd(ART.w, '.')].slice(0, ART.w))
116 const set = (x: number, y: number, c: string) => {
117 if (x >= 0 && x < ART.w && y >= 0 && y < ART.h) g[y]![x] = c
118 }
119 const stamp = (rows: readonly string[], x0: number, y0: number) =>
120 rows.forEach((row, dy) => [...row].forEach((c, dx) => c !== '.' && set(x0 + dx, y0 + dy, c === 'T' ? (tip === 'on' ? 'a' : 'A') : c)))
121 stamp(ART.heads[head][droop ? 'droop' : 'up'], 0, 0)
122 // the face turns with `look`
123 for (const [x, w] of ART.eyes.at[look < 0 ? 'left' : look > 0 ? 'right' : 'ahead']) {
124 stamp(ART.eyes[eyes].map((row) => row.slice(0, w)), x, ART.eyes.y)
125 }
126 for (const x of ART.cheeks.x) if (isBody(g[ART.cheeks.y]?.[x + look])) set(x + look, ART.cheeks.y, 'c')
127 if (mouth !== 'none') for (const [dx, dy] of ART.mouth[mouth]) set(ART.mouth.x + look + dx, ART.mouth.y + dy, 'm')
128 for (const [x, y] of ART.hands[hands]) set(x, y, 'x')
129 for (const [x, y] of ART.feet[feet]) set(x, y, 'f')
130 // A sparkle beside a tip, the same gold, would read as a bigger tip: it skips that spot.
131 const byTip = (x: number, y: number) => SIDES.some(([dx, dy]) => /[aA]/.test(g[y + dy]?.[x + dx] ?? ''))
132 if (spark !== undefined) for (const [x, y] of ART.sparks[spark]) if (g[y]?.[x] === '.' && !byTip(x, y)) set(x, y, 'k')
133 if (tear !== undefined) set(ART.tear.x, ART.tear.rows[tear], 't')
134 return g.map((row) => row.join(''))
135}
136
137const HEADS: Record<Kind, Head> = { general: 'antenna', explore: 'feelers', plan: 'cap', custom: 'sprout' }
138
139function posesOf(head: Head): Record<Pose, Frame[]> {
140 const f = (spec: FrameSpec = {}) => draw(head, spec)
141 const rest = f()
142 const done1 = f({ hands: 'up', mouth: 'smile', feet: 'none', spark: 0 })
143 const done2 = f({ hands: 'rest', mouth: 'smile', spark: 1 })
144 const sad = (tear?: 0 | 1 | 2) => f({ eyes: 'tired', mouth: 'flat', droop: true, tip: 'off', tear })
145 const [sad0, sad1, sad2, dry] = [sad(0), sad(1), sad(2), sad()]
146 return {
147 // 2 s loop with one blink
148 idle: [rest, rest, rest, rest, rest, f({ eyes: 'blink' }), rest, rest],
149 // looks left and right, taps, steps, the tip pulses
150 working: [
151 f({ look: -1, hands: 'tapL', feet: 'L', tip: 'on' }),
152 f({ look: -1, hands: 'tapR', feet: 'R', tip: 'off', mouth: 'none' }),
153 f({ look: 1, hands: 'tapL', feet: 'L', tip: 'on' }),
154 f({ look: 1, hands: 'tapR', feet: 'R', tip: 'off', mouth: 'none' }),
155 ],
156 // throws its hands up and cheers, sparkles twinkle
157 done: [done1, done1, done2, done2],
158 // grey and drooping; a tear rolls down, then another
159 failed: [sad0, sad0, sad1, sad1, sad2, sad2, dry, dry],
160 }
161}
162
163const POSES: Record<Kind, Record<Pose, Frame[]>> = {
164 general: posesOf(HEADS.general),
165 explore: posesOf(HEADS.explore),
166 plan: posesOf(HEADS.plan),
167 custom: posesOf(HEADS.custom),
168}
169
170
171/** The frames of one kind in one pose, each SPRITE_H rows of SPRITE_W characters. */
172export function framesFor(kind: Kind, pose: Pose): readonly Frame[] {
173 return POSES[kind][pose]
174}
175
176/** Which mascot an agent type gets: built-ins by name, anything else is custom. */
177export function kindOf(agentType: string | undefined): Kind {
178 const t = (agentType ?? '').toLowerCase()
179 if (t === '' || t === 'general-purpose' || t === 'general' || t === 'fork') return 'general'
180 if (t === 'explore') return 'explore'
181 if (t === 'plan') return 'plan'
182 return 'custom'
183}
184
185// The pieces that stand on the terminal's own background (stalks, caps, tips, feet, sparkles,
186// the tear) keep to middle tones, so they show on dark and light themes alike: a Raster takes
187// fixed colors and cannot follow the theme.
188const SHARED: Palette = { e: '#1E1B2E', w: '#FFFFFF', m: '#1E1B2E', k: '#E0A100', t: '#4FA3D9', c: '#F58FA0' }
189
190const BODIES: Record<Exclude<Kind, 'custom'>, Palette> = {
191 general: { b: '#D97757', h: '#F2A98C', d: '#B05A3E', s: '#A0522D', a: '#E0A100', A: '#9C6B3A', f: '#A0522D', x: '#D97757' },
192 explore: { b: '#35B5AC', h: '#8FE6DE', d: '#23847D', s: '#1F8A80', a: '#E0A100', A: '#5E8F75', f: '#1F8A80', x: '#35B5AC' },
193 plan: { b: '#9B7BEA', h: '#CDBBF8', d: '#7155C0', s: '#6B4FD8', a: '#E0A100', A: '#8D7A3D', f: '#6B4FD8', x: '#9B7BEA' },
194}
195
196// Custom agent types share the sprout and take one of these coats by name.
197const CUSTOM_COATS: readonly Palette[] = [
198 { b: '#E9B248', h: '#F8D993', d: '#BA862C', s: '#4E7A2A', a: '#66B83A', A: '#4E7A2A', f: '#7A561A', x: '#E9B248' },
199 { b: '#E77FA8', h: '#F6B6CF', d: '#B9577F', s: '#4E7A2A', a: '#66B83A', A: '#4E7A2A', f: '#A0507A', x: '#E77FA8' },
200 { b: '#5B9BEA', h: '#A6C9F6', d: '#3D73B8', s: '#4E7A2A', a: '#66B83A', A: '#4E7A2A', f: '#2F6DB0', x: '#5B9BEA' },
201 { b: '#7DC57A', h: '#B9E5B6', d: '#559A52', s: '#3E6B2A', a: '#E0A100', A: '#8D8A4A', f: '#3F7A3A', x: '#7DC57A' },
202]
203
204const GREY: Palette = { b: '#8E8C96', h: '#B9B7C0', d: '#66646E', s: '#76747E', a: '#6B6973', A: '#6B6973', f: '#76747E', x: '#8E8C96', c: '#8E8C96' }
205
206function hash(s: string): number {
207 let h = 2166136261
208 for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619) >>> 0
209 return h
210}
211
212/** The colors for one agent: by kind (custom coats by type name), grey when failed. */
213export function paletteFor(agentType: string | undefined, pose: Pose): Palette {
214 const kind = kindOf(agentType)
215 const coat = kind === 'custom' ? CUSTOM_COATS[hash(agentType ?? '') % CUSTOM_COATS.length]! : BODIES[kind]
216 return pose === 'failed' ? { ...SHARED, ...coat, ...GREY } : { ...SHARED, ...coat }
217}
218
219/**
220 * Where in its loop an agent's animation starts, from its id: cards side by side then look,
221 * tap and blink each in their own time instead of in step.
222 */
223export function phaseOf(id: string): number {
224 return hash(id) % 8
225}
226
227/** The frame shown at a tick of the shared FRAME_MS clock. */
228export function frameAt(kind: Kind, pose: Pose, tick: number): Frame {
229 const frames = framesFor(kind, pose)
230 return frames[((tick % frames.length) + frames.length) % frames.length]!
231}
232
233const DEFAULT = 0x01000000
234const UPPER = 0x2580 // ▀
235const LOWER = 0x2584 // ▄
236const SPACE = 0x20
237
238function rgb(hex: string | undefined): number {
239 if (!hex) return DEFAULT
240 return parseInt(hex.slice(1), 16) & 0xffffff
241}
242
243const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
244
245/** Standard padded base64 of bytes; no reliance on btoa or Uint8Array.toBase64. */
246export function base64(bytes: Uint8Array): string {
247 let out = ''
248 for (let i = 0; i < bytes.length; i += 3) {
249 const a = bytes[i]!, b = bytes[i + 1], c = bytes[i + 2]
250 const n = (a << 16) | ((b ?? 0) << 8) | (c ?? 0)
251 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
252 out += b === undefined ? '=' : B64[(n >> 6) & 63]!
253 out += c === undefined ? '=' : B64[n & 63]!
254 }
255 return out
256}
257
258/**
259 * A frame as Raster `cells`: SPRITE_COLUMNS x SPRITE_ROWS cells, each two stacked pixels as
260 * a half block (top pixel the foreground of ▀, bottom pixel its background), clear pixels
261 * left to the terminal's default color.
262 */
263export function encodeCells(frame: Frame, palette: Palette): string {
264 const words = new Uint32Array(SPRITE_COLUMNS * SPRITE_ROWS * 3)
265 let i = 0
266 for (let row = 0; row < SPRITE_ROWS; row++) {
267 for (let x = 0; x < SPRITE_COLUMNS; x++) {
268 const top = frame[row * 2]?.[x] ?? '.'
269 const bottom = frame[row * 2 + 1]?.[x] ?? '.'
270 const t = top === '.' ? null : rgb(palette[top])
271 const b = bottom === '.' ? null : rgb(palette[bottom])
272 let glyph = SPACE, fg = DEFAULT, bg = DEFAULT
273 if (t !== null && b !== null) { glyph = UPPER; fg = t; bg = b }
274 else if (t !== null) { glyph = UPPER; fg = t }
275 else if (b !== null) { glyph = LOWER; fg = b }
276 words[i++] = glyph; words[i++] = fg; words[i++] = bg
277 }
278 }
279 // Uint32Array is little-endian on every platform Claude Code runs on; pin it anyway.
280 const bytes = new Uint8Array(words.length * 4)
281 const view = new DataView(bytes.buffer)
282 words.forEach((w, k) => view.setUint32(k * 4, w, true))
283 return base64(bytes)
284}
285
286// One <path> per color with a subpath per horizontal run: a fraction of the size of a <rect>
287// per run, which matters because a drawn tree is bounded at 100,000 characters.
288function paths(frame: Frame, palette: Palette): string {
289 const byFill = new Map<string, string>()
290 const fillAt = (row: string, x: number) => (row[x] === '.' ? undefined : palette[row[x]!])
291 frame.forEach((row, y) => {
292 let x = 0
293 while (x < row.length) {
294 const fill = fillAt(row, x)
295 let end = x + 1
296 while (end < row.length && fillAt(row, end) === fill) end++
297 if (fill) byFill.set(fill, (byFill.get(fill) ?? '') + `M${x} ${y}h${end - x}v1H${x}z`)
298 x = end
299 }
300 })
301 return [...byFill].map(([fill, d]) => `<path fill="${fill}" d="${d}"/>`).join('')
302}
303
304/**
305 * The pose as one self-animating SVG (SMIL, discrete frames at FRAME_MS), for surfaces that
306 * draw `Svg` with `isInteractive: true`. A single frame when the pose has one distinct frame.
307 */
308export function spriteSvg(kind: Kind, pose: Pose, palette: Palette, pixel = SVG_PIXEL): string {
309 const frames = framesFor(kind, pose)
310 const n = frames.length
311 const w = SPRITE_W * pixel, h = SPRITE_H * pixel
312 const head = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${SPRITE_W} ${SPRITE_H}" width="${w}" height="${h}" shape-rendering="crispEdges">`
313 // Poses hold a frame by repeating it; each distinct frame is drawn once, shown in all its slots.
314 const slotsOf = new Map<string, number[]>()
315 frames.forEach((frame, i) => {
316 const k = frame.join('/')
317 slotsOf.set(k, [...(slotsOf.get(k) ?? []), i])
318 })
319 if (slotsOf.size === 1) return head + paths(frames[0]!, palette) + '</svg>'
320 const dur = (n * FRAME_MS) / 1000
321 const keyTimes = frames.map((_, i) => (i / n).toFixed(4)).join(';')
322 const body = [...slotsOf.values()]
323 .map((slots) => {
324 const values = frames.map((_, j) => (slots.includes(j) ? 1 : 0)).join(';')
325 return `<g opacity="${slots.includes(0) ? 1 : 0}"><animate attributeName="opacity" values="${values}" keyTimes="${keyTimes}" dur="${dur}s" calcMode="discrete" repeatCount="indefinite"/>${paths(frames[slots[0]!]!, palette)}</g>`
326 })
327 .join('')
328 return head + body + '</svg>'
329}
330hooks/view.ts 324 lines1// The text of every line crew draws. Pure, no mods API: register.ts only arranges these
2// strings into elements.
3
4import { formatClock, formatTokens, formatUsd, shortModel, totalTokens, type Price } from './pricing.ts'
5import { counts, estimate, run, type AgentRow, type AgentStatus, type Roster } from './roster.ts'
6
7/** crew's one accent, used only for what is live. */
8export const ACCENT = '#9B7BEA'
9
10/** The running dot's pulse, one step per animation frame. `○` means idle, so it is not in it. */
11export const DOTS: readonly string[] = ['●', '●', '◉', '◉']
12
13export const FINISHED_CAP = 50
14
15export const EMPTY_TEXT = 'No subagents yet — they appear here when Claude delegates work.'
16
17/** The idle line once every agent so far has finished. */
18export const QUIET_TEXT = 'All quiet — new subagents appear here when Claude delegates work.'
19
20const SEP = ' · '
21
22// C0 and C1 controls, DEL and the Unicode line and paragraph separators. A surface refuses a
23// whole tree whose text holds a control character, so none is ever drawn.
24const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g
25
26// Marks, joiners and variation selectors take no cell of their own.
27const ZERO_WIDTH = /^[\p{Mn}\p{Me}\u200b-\u200f\ufe00-\ufe0f]$/u
28// East Asian wide and fullwidth ranges: two cells each in a terminal.
29const WIDE =
30 /^[\u1100-\u115f\u2329\u232a\u2e80-\u303e\u3041-\u33ff\u3400-\u4dbf\u4e00-\u9fff\ua000-\ua4cf\ua960-\ua97f\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6\u{20000}-\u{3fffd}]$/u
31const PICTOGRAPH = /^\p{Extended_Pictographic}$/u
32const EMOJI = /^\p{Emoji_Presentation}$/u
33const EMOJI_VS16 = /^\p{Emoji}\ufe0f$/u
34
35// What widths are counted by: a character, with the VS16 (U+FE0F) right after it if any.
36const UNITS = /[^\ufe0f]\ufe0f?|\ufe0f/gu
37const unitsOf = (s: string): string[] => s.match(UNITS) ?? []
38
39function cellsOf(unit: string): number {
40 // A VS16 asks for the symbol before it to be drawn as an emoji (⚠️, ✔️): two cells.
41 if (EMOJI_VS16.test(unit)) return 2
42 const ch = String.fromCodePoint(unit.codePointAt(0)!)
43 if (ZERO_WIDTH.test(ch)) return 0
44 // Emoji drawn as emoji by default are wide (✅ ⚡ 🐛), and the rest past U+1F000; older
45 // symbols (©, ✓, ●) are one cell unless asked otherwise.
46 if (EMOJI.test(ch) || (ch.codePointAt(0)! >= 0x1f000 && PICTOGRAPH.test(ch))) return 2
47 return WIDE.test(ch) ? 2 : 1
48}
49
50/** How many terminal cells `s` takes: two for wide characters and emoji, none for marks. */
51export function widthOf(s: string): number {
52 let w = 0
53 for (const u of unitsOf(s)) w += cellsOf(u)
54 return w
55}
56
57/**
58 * Cuts to `width` cells with a trailing `…`, never splitting a character (or a symbol from
59 * its VS16). Control characters become spaces, so whatever it returns is safe to draw.
60 */
61export function truncate(s: string, width: number): string {
62 if (width <= 0) return ''
63 const clean = s.replace(CONTROL, ' ')
64 if (widthOf(clean) <= width) return clean
65 let out = ''
66 let w = 0
67 for (const u of unitsOf(clean)) {
68 const c = cellsOf(u)
69 if (w + c > width - 1) break
70 out += u
71 w += c
72 }
73 return out + '…'
74}
75
76/**
77 * Text from outside crew (a description, an agent type, a tool name) made fit to keep:
78 * one line, runs of whitespace and control characters as one space, at most `max` cells.
79 */
80export function oneLine(s: string, max = 200): string {
81 return truncate(String(s).replace(CONTROL, ' ').replace(/\s+/g, ' ').trim(), max)
82}
83
84/** One part of a ` · ` line; parts with a `drop` rank go first, lowest first, when room is short. */
85export type Part = { text: string; drop?: number }
86
87/**
88 * Joins `parts` with ` · ` in at most `width` cells. Whole parts go before anything is cut:
89 * droppable ones, lowest `drop` first; only a line still too wide is truncated.
90 */
91export function fit(parts: readonly Part[], width: number): string {
92 let kept = parts.slice()
93 const line = () => kept.map((p) => p.text).join(SEP)
94 while (widthOf(line()) > width) {
95 let at = -1
96 kept.forEach((p, i) => {
97 if (p.drop !== undefined && (at < 0 || p.drop < kept[at]!.drop!)) at = i
98 })
99 if (at < 0) break
100 kept = kept.filter((_, i) => i !== at)
101 }
102 return truncate(line(), width)
103}
104
105/** `1 running · 15 done · 2 failed`, zero parts left out; `idle` when all are zero. */
106export function headerFacts(c: { running: number; done: number; failed: number; stopped?: number }): string {
107 const parts: string[] = []
108 if (c.running > 0) parts.push(`${c.running} running`)
109 if (c.done > 0) parts.push(`${c.done} done`)
110 if (c.failed > 0) parts.push(`${c.failed} failed`)
111 if ((c.stopped ?? 0) > 0) parts.push(`${c.stopped} stopped`)
112 return parts.length > 0 ? parts.join(SEP) : 'idle'
113}
114
115/**
116 * `Explore · haiku 4.5 · high · step 7 · Bash`: the model, effort and last tool when known.
117 * Short of room, the last tool goes first, then the effort.
118 */
119export function metaLine(a: AgentRow, width: number): string {
120 const parts: Part[] = [{ text: a.type }]
121 if (a.model !== '') parts.push({ text: shortModel(a.model) })
122 if (a.effort) parts.push({ text: a.effort, drop: 2 })
123 parts.push({ text: a.steps > 0 ? `step ${a.steps}` : 'starting' })
124 if (a.lastTool) parts.push({ text: a.lastTool, drop: 1 })
125 return fit(parts, width)
126}
127
128const clamp = (p: number): number => (Number.isFinite(p) ? Math.min(100, Math.max(0, p)) : 0)
129
130/** A thin gauge: `━` for the filled part, `─` for the track. */
131export function bar(percent: number, width: number): { filled: string; track: string } {
132 const w = Math.max(0, Math.floor(width))
133 const n = Math.round((clamp(percent) / 100) * w)
134 return { filled: '━'.repeat(n), track: '─'.repeat(w - n) }
135}
136
137/** The context percentage as shown: whole, held to 0–100. */
138export function ctxPercent(percent: number): number {
139 return Math.round(clamp(percent))
140}
141
142export function ctxLabel(percent: number): string {
143 return `ctx ${ctxPercent(percent)}%`
144}
145
146/** The gauge's height where it is a picture, in CSS pixels. */
147export const GAUGE_PX = 3
148
149/** The gauge's track, and its filled part once the agent has ended: a middle grey, for dark and light themes alike. */
150export const TRACK = '#8A877F'
151
152/**
153 * The gauge as a picture, for the surfaces that draw `Svg` (the Desktop app, VS Code,
154 * mobile), where a row of `━` comes from a wider fallback font and wraps: a track
155 * GAUGE_PX tall with round ends, and the filled part over it, the accent while the agent
156 * runs. The markup is wider than any pane, so the picture takes its whole slot; its parts
157 * are in percent of it, so they stretch with the slot and the ends stay round.
158 */
159export function gaugeSvg(percent: number, lit: boolean): string {
160 const p = Math.round(clamp(percent) * 10) / 10
161 const rect = (w: string, fill: string, more = '') => `<rect width="${w}" height="${GAUGE_PX}" rx="${GAUGE_PX / 2}" fill="${fill}"${more}/>`
162 return (
163 `<svg xmlns="http://www.w3.org/2000/svg" width="4000" height="${GAUGE_PX}">` +
164 rect('100%', TRACK, ' fill-opacity="0.3"') +
165 (p > 0 ? rect(`${p}%`, lit ? ACCENT : TRACK) : '') +
166 '</svg>'
167 )
168}
169
170const usdOrDash = (usd: number | null): string => (usd === null ? '—' : formatUsd(usd, true))
171const elapsed = (a: AgentRow, now: number): string => formatClock((a.endedAt ?? now) - a.startedAt)
172
173/**
174 * `209.0k tok · 3 tools · ≈$0.04 · 5:35`; the clock stops when the agent ends. Short of
175 * room, the tool count goes first, then the tokens: the cost and the time stay.
176 */
177export function statsLine(a: AgentRow, now: number, usd: number | null, width = Infinity): string {
178 const parts: Part[] = [{ text: `${formatTokens(totalTokens(a.tokens))} tok`, drop: 2 }]
179 if (a.tools > 0) parts.push({ text: `${a.tools} ${a.tools === 1 ? 'tool' : 'tools'}`, drop: 1 })
180 parts.push({ text: usdOrDash(usd) }, { text: elapsed(a, now) })
181 return fit(parts, width)
182}
183
184// The cells of a Finished row's right side, in column order: model, tokens, cost, time. The
185// model's is empty when it is not known.
186function finishedCells(a: AgentRow, usd: number | null): string[] {
187 return [
188 a.model !== '' ? shortModel(a.model) : '',
189 formatTokens(totalTokens(a.tokens)),
190 usdOrDash(usd),
191 elapsed(a, a.endedAt ?? a.startedAt),
192 ]
193}
194
195/** The right side of a Finished row on its own: `haiku 4.5 · 52.0k · ≈$0.31 · 2:10`. */
196export function finishedRight(a: AgentRow, usd: number | null): string {
197 return finishedCells(a, usd).filter((c) => c !== '').join(SEP)
198}
199
200// The columns of that right side: the model flush left, the numbers flush right. Short of
201// room they go lowest `drop` first (the model, then the tokens, then the time); the cost stays.
202const FINISHED_COLUMNS: readonly { flushRight: boolean; drop?: number }[] = [
203 { flushRight: false, drop: 0 },
204 { flushRight: true, drop: 1 },
205 { flushRight: true },
206 { flushRight: true, drop: 2 },
207]
208
209/** `s` padded with spaces to `w` cells, in front when `flushRight`. */
210function pad(s: string, w: number, flushRight: boolean): string {
211 const gap = ' '.repeat(Math.max(0, w - widthOf(s)))
212 return flushRight ? gap + s : s + gap
213}
214
215/**
216 * The Finished rows as a table, each `✓ <title> <right>` in `width` cells. A column of the
217 * right side is as wide as its widest cell, so the `·` line up from row to row. The longest
218 * title keeps up to 16 cells (all of a shorter one): while it would get fewer, the columns
219 * drop for every row alike, the model first, then the tokens, then the time. The cost stays.
220 */
221export function finishedLines(rows: readonly { a: AgentRow; usd: number | null }[], width: number): { title: string; right: string }[] {
222 const cells = rows.map(({ a, usd }) => finishedCells(a, usd))
223 // A column nothing fills (no row knows its model) takes no room, not even its separator.
224 let cols = FINISHED_COLUMNS.map((c, i) => ({ ...c, i, w: Math.max(...cells.map((r) => widthOf(r[i]!))) })).filter((c) => c.w > 0)
225 const taken = () => cols.reduce((n, c) => n + c.w, 0) + widthOf(SEP) * (cols.length - 1)
226 const want = Math.max(...rows.map(({ a }) => Math.min(widthOf(a.description || a.type), 16)))
227 // The cost has no rank and is never empty: with more than one column left, one can go.
228 while (cols.length > 1 && width - 3 - taken() < want) {
229 const gone = cols.filter((c) => c.drop !== undefined).reduce((lo, c) => (c.drop! < lo.drop! ? c : lo))
230 cols = cols.filter((c) => c !== gone)
231 }
232 const room = Math.max(0, width - 3 - Math.min(want, 6))
233 return rows.map(({ a }, r) => {
234 const right = truncate(cols.map((c) => pad(cells[r]![c.i]!, c.w, c.flushRight)).join(SEP), room)
235 return { title: truncate(a.description || a.type, Math.max(0, width - 3 - widthOf(right))), right }
236 })
237}
238
239/**
240 * The title over the agent cards, counting every card under it: `RUNNING · 1 · JUST
241 * FINISHED · 2` while some celebrate their end, `JUST FINISHED · 1` when none runs. Short
242 * of room, the celebrating count goes first.
243 */
244export function runningTitle(running: number, ended: number, width = Infinity): string {
245 const parts: Part[] = []
246 if (running > 0 || ended === 0) parts.push({ text: `RUNNING · ${running}` })
247 if (ended > 0) parts.push({ text: `JUST FINISHED · ${ended}`, drop: running > 0 ? 1 : undefined })
248 return fit(parts, width)
249}
250
251/** A stat card: `label` names it; `title` is what it shows when the card has room for it. */
252export type Card = { label: string; title: string; value: string }
253
254/**
255 * The three stat cards, totals of the whole session: the main loop with its subagents.
256 * Claude Code's real session cost wins; else crew's estimate, marked ≈.
257 */
258export function cards(input: { realUsd: number | null; estUsd: number | null; tokens: number; sessionMs: number }): Card[] {
259 const costValue = input.realUsd !== null ? formatUsd(input.realUsd, false) : input.estUsd !== null ? formatUsd(input.estUsd, true) : '—'
260 return [
261 { label: 'Cost', title: 'Session cost', value: costValue },
262 { label: 'Tokens', title: 'Session tokens', value: formatTokens(input.tokens) },
263 { label: 'Time', title: 'Session time', value: formatClock(input.sessionMs) },
264 ]
265}
266
267/**
268 * The cards on one line, for panes too narrow for three boxes: `Cost $0.41 · Tokens 206.5k ·
269 * Time 12:18`, or `$0.41 · 206.5k tok · 12:18` when that does not fit either.
270 */
271export function cardsLine(list: { label: string; value: string }[], width = Infinity): string {
272 const full = list.map((c) => `${c.label} ${c.value}`).join(SEP)
273 if (widthOf(full) <= width) return full
274 return truncate(list.map((c) => (c.label === 'Tokens' ? `${c.value} tok` : c.value)).join(SEP), width)
275}
276
277/**
278 * The band: `2 running · 1 done · 0:42 · ≈$0.18`, over every agent of the run (since the
279 * band appeared), the ones already filed under Finished too. The clock runs from the run's
280 * first spawn and stops at its last end once nothing runs. Short of room, the counts of
281 * agents that ended go first (while any still runs), then the clock.
282 */
283export function bandFacts(r: Roster, now: number, overrides: Record<string, Price>, width = Infinity): string {
284 const agents = run(r, now)
285 const c = counts({ ...r, agents })
286 const ended = c.running > 0 ? 1 : undefined
287 const parts: Part[] = []
288 if (c.running > 0) parts.push({ text: `${c.running} running` })
289 if (c.done > 0) parts.push({ text: `${c.done} done`, drop: ended })
290 if (c.failed > 0) parts.push({ text: `${c.failed} failed`, drop: ended })
291 if (c.stopped > 0) parts.push({ text: `${c.stopped} stopped`, drop: ended })
292 if (parts.length === 0) parts.push({ text: 'idle' })
293 if (agents.length > 0) {
294 const start = Math.min(...agents.map((a) => a.startedAt))
295 const end = c.running > 0 ? now : Math.max(...agents.map((a) => a.endedAt ?? now))
296 parts.push({ text: formatClock(end - start), drop: 2 })
297 }
298 let usd: number | null = null
299 for (const a of agents) {
300 const e = estimate(a, overrides)
301 if (e !== null) usd = (usd ?? 0) + e
302 }
303 if (usd !== null) parts.push({ text: formatUsd(usd, true) })
304 return fit(parts, width)
305}
306
307/** An agent's status glyph and its color: the accent while running, theme keys after. */
308export function glyphOf(status: AgentStatus, frame: number): { char: string; color: string } {
309 if (status === 'done') return { char: '✓', color: 'success' }
310 if (status === 'failed') return { char: '✗', color: 'error' }
311 if (status === 'stopped') return { char: '■', color: 'warning' }
312 return { char: DOTS[frame % DOTS.length]!, color: ACCENT }
313}
314
315/**
316 * The Finished section's fold button: `Collapse` or `Expand`, or `Hide` or `Show` when the
317 * section's title and the full label don't fit the pane's width together.
318 */
319export function foldLabel(count: number, collapsed: boolean, width: number): string {
320 const long = collapsed ? 'Expand' : 'Collapse'
321 if (widthOf(`FINISHED · ${count}`) + 1 + widthOf(`c: ${long}`) <= width) return long
322 return collapsed ? 'Show' : 'Hide'
323}
324types/index.d.ts 42 lines1// crew's state contract: the one value its drawings read from $.state.
2// Same shapes as hooks/pricing.ts (Tokens) and hooks/roster.ts (AgentRow, Roster).
3
4/** Token counts, summed over the model responses they came from. */
5export type Tokens = { input: number; output: number; cacheRead: number; cacheWrite: number }
6
7export type AgentStatus = 'running' | 'done' | 'failed' | 'stopped'
8
9/** One subagent, from its spawn to its end. Times are ms epoch from $.clock.now(). */
10export type AgentRow = {
11 id: string
12 description: string
13 type: string
14 model: string
15 effort?: string
16 background: boolean
17 workflow: boolean
18 startedAt: number
19 endedAt?: number
20 /** The turn whose end ended it. */
21 endedTurn?: string
22 /** When a step of it last finished. */
23 seenAt?: number
24 status: AgentStatus
25 steps: number
26 tools: number
27 lastTool?: string
28 tokens: Tokens
29 /** The share of `tokens` its long requests sent (each past 100k prompt tokens). */
30 long?: Tokens
31 context: number
32}
33
34/** Every subagent of the session, plus the main loop's own tokens and steps. */
35export type Roster = { agents: AgentRow[]; main: Tokens; mainSteps: number; mainLong?: Tokens }
36
37declare module 'claude-code' {
38 interface PluginState {
39 crew: { roster: Roster }
40 }
41}
42