SLOPSHOPPER

crew

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

newpanebandguardcommandtoast
v0.2.0MITupdated 2026-10-14flaviorg/crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · crew
│ ┃ Crew ✕ › fix the failing auth test and add an audit log call │ ┃ ◆ crew · idle │ ┃ ⏺ Read(src/auth.ts) │ ┃ ╭───────────────╮ ╭───────────────╮ ╭────── ⎿ Read 6 lines │ ┃ │ Session cost │ │ Tokens │ │ Sessi ⏺ Update(src/auth.ts) │ ┃ │ $0.42 │ │ 0 │ │ 30:00 ⎿ Added 2 lines, removed 1 line │ ┃ ╰───────────────╯ ╰───────────────╯ ╰────── ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ ▀ No subagents yet — they appear he │ ┃ ▄▀▀▀▀▀▄ Claude delegates work. ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ ▄▀▀▀▀▀▀▀▄ │ ┃ ▀▀▀▀▀▀▀ ✻ Worked for 42s · done 4:20 PM │ │ › /crew │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Crew
◆ crew · idle ╭───────────────╮ ╭───────────────╮ ╭────────────────╮ │ Session cost │ │ Tokens │ │ Session time │ │ $0.42 │ │ 0 │ │ 30:00 │ ╰───────────────╯ ╰───────────────╯ ╰────────────────╯ ▀ No subagents yet — they appear here when Claude d ▄▀▀▀▀▀▄ work. ▄▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀
README

<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%">

◆ crew

A Claude Code mod with a live panel of the subagents Claude delegates to — each with its own animated pixel mascot.

Claude Code mod Tested with License: MIT

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

In a real session

Install

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.

What you see

  • Stat cards — totals for the whole session, the main loop included, not just the agents below them: Session cost (the session's real cost when Claude Code reports it, else an estimate marked ≈), 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.
  • Running — a card per agent: its mascot, its description and a status dot; then its type, model, effort, step count and last tool; a thin bar for how full its context is (a row of ━ 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.
  • Finished — newest first, with ✓, ✗ 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.
  • The band — while anything runs, one line above the prompt: a dot per live agent in its mascot's color, then the run so far — every agent since the band appeared, the ones already under Finished too: how many run and how many ended, for how long (the clock stops when the last one ends) and their estimated cost. 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.

The mascots

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 typeHead pieceCoat
general-purpose (and forks)an antennacoral #D97757
Explorefeelersteal #35B5AC
Plana capviolet #9B7BEA
any custom typea sproutamber, 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">

PoseWhenWhat it does
idlethe empty paneblinks now and then
workingthe agent runslooks left and right, taps, steps; its tip pulses
doneit finishedthrows its hands up and cheers while sparkles twinkle
failedit failed or was stoppedturns 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).

Commands

CommandWhat it does
/crewShows 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.

Options

Set them with /plugin configure crew@crew:

OptionValuesDefaultWhat it does
autoOpenon, offonOpen the pane when Claude first delegates to a subagent (if the terminal is wide enough)
bandon, offonShow the one-line summary above the prompt while subagents run
mascoton, offonDraw the animated mascots; off draws a dot in the agent's color
pricesJSON(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.

ModelInputOutputCache readCache write
Opus 5.54200.205
Opus 4.5 – 55250.506.25
Opus 4, 4.115751.5018.75
Sonnet 5.52100.102.50
Sonnet 52100.202.50
Sonnet 3.5 – 4.63150.303.75
Haiku 5.50.100.500.010.125
Haiku 5.5, a request past 100k prompt tokens0.502.500.050.625
Haiku 4.5150.101.25
Haiku 3.50.8040.081
Fable 5.1, Mythos 5.110500.2512.50
Fable 5, Mythos 51050112.50

About the numbers

  • The Session cost card is Claude Code's own figure for the whole session, the main loop's share included, when it has one. Only when it does not is it crew's estimate, marked ≈. So it is more than the agents' costs added up; the band's ≈ is theirs alone.
  • An agent's cost is always an estimate (≈): 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.
  • Tokens are counted once, from each model response. The total a turn reports at its end is the same tokens again, so it is not added.
  • Context % is the agent's last request (input plus cache) over its model's window: the session's window when the agent runs the main model, a million for a [1m] model, 200k otherwise. It says how full the agent's context is; it is not progress.
  • Time runs from the spawn and stops when the agent ends. An agent that works again (a teammate's next turn, or a stopped agent resumed) picks up where its clock stopped.
  • Failures and kills that no turn end reports are picked up from Claude Code's agent list every two seconds while an agent runs; so is the end of a turn of a teammate in a terminal pane of its own. Agents started by a workflow are never in that list; they are not marked failed for it. One that the list never shows and that has not been heard from for 30 minutes counts as stopped, so the band does not run for the rest of the session.

Privacy

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.

Compatibility

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.

Development

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

Credits

The mascots are original pixel art. Crew is written from scratch against the public Claude Code mods API.

License

MIT © flaviorg

Source 6 files
hooks/register.ts 597 lines
1// 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}
597
hooks/pricing.ts 243 lines
1// 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}
243
hooks/roster.ts 273 lines
1// 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}
273
hooks/sprites.ts 330 lines
1// 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}
330
hooks/view.ts 324 lines
1// 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}
324
types/index.d.ts 42 lines
1// 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