SLOPSHOPPER

agent-crew

Shows your subagents above the prompt as pixel-art Claude crew members: what each one is doing, how far along it is and how many tokens it used.

newpanebandguardcommandstatus
★ 10v0.4.0MITupdated 2026-10-23bekir1184/agent-crew
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-crew
│ ┃ crew ✕ › fix the failing auth test and add an audit log call │ ┃ No agents yet. y: ⇤ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crew │ ⎿ agent-crew: Agent Crew hidden. Run /crew to show it again. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · crew
No agents yet. y: ⇤
README

<h1 align="center">Agent Crew</h1>

See who is doing what, how far along they are, and how long is left.</p>

<img src="docs/demo.gif" alt="Agent Crew in the Claude desktop app: Claude starts alone on the AGENT line, then a Plan, an Explore and a General agent arrive one by one with a helper; they search, read, think and ask for approval, their bars fill up, and each raises a checkered flag when done" width="100%">

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT license"></a> <img src="https://img.shields.io/badge/Claude%20Code-%E2%89%A5%202.1.293-d97757" alt="Claude Code 2.1.293 or newer"> <a href="https://mods.aidojo.si/mods/bekir1184--agent-crew--agent-crew/"><img src="https://raw.githubusercontent.com/karanb192/awesome-claude-code-mods/main/badges/bekir1184--agent-crew--agent-crew-reach.svg" alt="reach"></a> <a href="https://mods.aidojo.si/mods/bekir1184--agent-crew--agent-crew/"><img src="https://raw.githubusercontent.com/karanb192/awesome-claude-code-mods/main/badges/bekir1184--agent-crew--agent-crew-validates.svg" alt="validates"></a>

Install

/plugin install agent-crew --marketplace bekir1184/agent-crew

That's it. The crew shows up by itself whenever Claude is at work. Want a look right now? Type /crew demo.

Costs nothing. Agent Crew never calls a model and never touches your prompts: it only watches.

Claude, live

While Claude works, the framed line shows what it's doing this very second. When the turn ends it shrinks to one quiet line with the totals. Press the arrow to see what happened.

<img src="docs/agent-line.svg" alt="The AGENT line: searching, reading, thinking, writing, running tests, then a thin line: done, last turn 0:37, 5.6k tokens, 9 tools" width="100%">

The crew

When Claude hands work to subagents, they line up below, one row each. Each row names the model it runs on, so you can tell Opus from Haiku at a glance. Helpers started by a subagent tuck in under their parent. Cancelled agents leave the stage right away.

Too busy? The arrow on the title folds the rows away, and – shrinks the whole band to one plain line (▸ brings it back). Prefer it out of the way? ⇥ moves the crew into a narrow pane beside the conversation, one card per agent, and ⇤ brings it back above the prompt. Both choices stick for later sessions.

Each critter holds the tool it's using:

<img src="docs/crew-states.svg" alt="Searching with a magnifier, reading a page, writing in a code window, running a command on a laptop, browsing a globe, getting ready in a hard hat, done with a checkered flag, failed with dizzy eyes, cancelled and asleep" width="100%">

Thinking...

Thinking is where agents spend most of their time, so the critters keep it lively: an hourglass, a thought cloud with a question mark, a lightbulb that lights up. Each one at its own pace.

<img src="docs/thinking.svg" alt="Three critters thinking: one turns an hourglass, one fills a thought cloud with a question mark, one lights a bulb and hops" width="100%">

It needs you

When an agent stops to ask for permission, it says so: a yellow question bubble, and the title tells you how many are waiting.

<img src="docs/approval.svg" alt="A row turns yellow with Needs approval: Bash rm -r build, then carries on once approved" width="100%">

The details

Every row has an arrow. It slides open everything the row had no room for: the full task, its to-do list, the files it touched, and its tokens. A finished agent also gets a ✕ to send it off the stage.

<img src="docs/details.svg" alt="A row opens into a panel: task, current step, checklist, changed files, token breakdown and run summary" width="100%">

How long is left?

No model is asked and no tokens are spent: Agent Crew simply remembers how long this kind of agent took before.

<img src="docs/estimate.svg" alt="Past run times, the middle one picked, a live countdown, and a re-estimate when the run goes long" width="100%">

Honest tokens

A row shows what the agent's own work took: what it read in and what it wrote. Nothing else.

Claude also keeps the whole conversation in a cache and re-reads it on every request. And after a break the cache expires, so the next request writes the whole conversation into it again, which on a long conversation can be hundreds of thousands of tokens at once. Neither is the agent's work, so neither inflates the row: both sit in the details panel, on their own line.

Turn on Show estimated cost in the settings and each row also gets a ≈ dollar figure.

Commands

/crewHide or show the crew
/crew demoA 30-second show: Claude starts alone, the crew arrives, thinks, asks you, and ends with all flags up
/crew clearClear the stage

Settings live in /plugin configure agent-crew@agent-crew: show Claude's own line (on), show estimated cost (off).

In the terminal there's no pixel art, just a colored dot per row. Press ctrl+x tab to reach the crew, then the letter on an arrow: h for the title, z to minimise, y to move to the side pane, a, b, c for the rows.

Development

Run it from a clone, then check and test it (Claude Code 2.1.293 or newer):

claude --plugin-dir .
claude plugin validate .
claude plugin test .

The animated images in docs/ and site/ are drawn from the mod's own pixel art: node scripts/docs.mjs (Node 25+) redraws them.


Source 3 files
hooks/register.tsx 1353 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { Activity, Agent, AgentStatus, RecentTool, Step, Tokens, TouchedFile } from '../types'
5import {
6  QUIET_TOOLS, SPRITE_CELLS, SPRITE_H, SPRITE_W, STALE_MS, activityOf, addUsage, barSvg, capAgents, clip, costOf, formatCost,
7  crewSummary, doingText, emptyTokens, statusLabel, fileOf, filesSummary, withFiles, estimateText, formatDuration, formatTokens, workTokens, tokenText, sumTokens, cacheIsNotable, headerSvg, headline, layout,
8  learn, modelShort, modelLabel, redact, sanitizeHistory, statusText, shownProgress, spriteSvg, statusColor, stepsFromTodos, targetOf, textBar, DOT, dotColor, labelSvg, LABEL_W, LABEL_H,
9  tree, typeColor, typeLabel, withActivity, withStepCreated, withStepUpdated,
10} from './draw'
11import type { History, Layout } from './draw'
12
13// ── State ────────────────────────────────────────────────────
14// Everything a drawing reads lives in $.state: it survives a hot reload, and a write redraws
15// whatever read it. The module variables below are caches and buffers; session.start fires
16// again after every reload and rebuilds them.
17const agents = atom({ plugin: 'agent-crew', key: 'agents' } as const, [])
18const main = atom({ plugin: 'agent-crew', key: 'main' } as const, null)
19const hidden = atom({ plugin: 'agent-crew', key: 'hidden' } as const, false)
20const expanded = atom({ plugin: 'agent-crew', key: 'expanded' } as const, [])
21/** The title row's arrow: hides the agent rows below it, leaving the title as the whole view. */
22const collapsed = atom({ plugin: 'agent-crew', key: 'collapsed' } as const, false)
23/** The title's minimise button: the whole band shrinks to one plain line. Kept across sessions in $.store. */
24const minimized = atom({ plugin: 'agent-crew', key: 'minimized' } as const, false)
25/** The crew lives in a narrow pane beside the transcript instead of above the prompt. Kept in $.store. */
26const side = atom({ plugin: 'agent-crew', key: 'side' } as const, false)
27/** Whether the side pane is placed now: until it is, the band keeps showing. */
28const paneUp = atom({ plugin: 'agent-crew', key: 'paneUp' } as const, false)
29/** Bumped on each frame of a sliding details panel: the drawings that read it redraw. */
30const foldFrame = atom({ plugin: 'agent-crew', key: 'foldFrame' } as const, 0)
31/** The side pane's id, and the width it asks for: about 300 px of the desktop's text. */
32const PANE = 'crew'
33const SIDE_COLUMNS = 38
34
35/** Settings from the /config menu (userConfig); a change there reloads the module. */
36let showCost = false
37let showMain = true
38/** The main conversation's key in the hot-path buffer; it is drawn on the title row, never as a row. */
39const MAIN_ID = 'main'
40/** The main turn running now, or null between turns: main-loop events count only while it runs. */
41let mainTurn: string | null = null
42/** What this mod last put on the status line, so it only writes on a change. */
43let lastStatus: string | undefined
44/** The project folder: files under it are shown relative to it. */
45let cwd = ''
46/** How many recent tool calls an agent keeps for its details panel. */
47const RECENT_TOOLS = 5
48
49/** Past run durations per (type, model), mirrored from $.store. */
50let history: History = {}
51
52/** Ids of the agents on stage. Engine-internal loops (compaction, forks) are never touched. */
53const known = new Set<string>()
54
55/**
56 * Hot-path buffer. tool.call and turn.step run for every tool and model request of every
57 * subagent, so they never read or write state: they note what happened here, and the ticker
58 * writes it to $.state once a second. Their only `$` call is a synchronous $.clock.every that
59 * restarts a stopped ticker. A subagent's work is never held up by this mod.
60 */
61type StepOp = { kind: 'create'; id: string; label: string } | { kind: 'update'; change: Record<string, unknown> }
62type Pending = {
63  tokens?: Tokens
64  requests: number
65  tools: number
66  activity?: Activity
67  target?: string
68  /** A whole new to-do list (TodoWrite), and task changes queued after it (TaskCreate / TaskUpdate). */
69  steps?: Step[]
70  stepOps?: StepOp[]
71  /** The tool the agent asked approval for (a string), or null once that was answered. */
72  approval?: string | null
73  /** Tool calls finished since the last flush, for the details panel. */
74  recent?: RecentTool[]
75  files?: TouchedFile[]
76  /** The model a request named: the main conversation learns its model from its requests. */
77  model?: string
78  /** Set by a model request: the agent is running (again, if it was resumed). */
79  revive?: boolean
80  turnId?: string
81}
82const pending = new Map<string, Pending>()
83const pendingFor = (id: string): Pending => {
84  let p = pending.get(id)
85  if (!p) pending.set(id, (p = { requests: 0, tools: 0 }))
86  return p
87}
88
89/** The last turn each agent finished: its buffered events can't bring it back to life. */
90const closedTurn = new Map<string, string>()
91/** Whether $.agent.list() answers here. While it does, silence alone never retires an agent. */
92let listWorks = false
93
94let ticker: { cancel: () => void } | null = null
95let tickBusy = false
96let ticks = 0
97/**
98 * Model requests since the last tick. A clock period another plugin refuses ends the interval
99 * without telling us; this many requests with no tick means the ticker died and is rebuilt.
100 */
101let stepsSinceTick = 0
102const DEAD_TICKER_STEPS = 30
103let demoTick: { cancel: () => void } | null = null
104/**
105 * A demo is on stage. Made for a screen recording, so meanwhile the prompt box shows the demo's
106 * own English hint instead of Claude Code's guess at your next message.
107 */
108let demoOnStage = false
109const DEMO_HINT = '/crew clear'
110/**
111 * On the desktop the band's text column is about twice as roomy as its reported width suggests:
112 * the task box grows to fill the row, so its text may use twice the planned cells before it is cut.
113 */
114const DESKTOP_TEXT_ROOM = 2
115
116/**
117 * Terminal hotkeys, live once the band has the focus (ctrl+x tab): the title's arrow, then one
118 * letter per row in order. Letters only: a digit typed into an empty prompt would also press a
119 * band button, so a message starting with "1." would open a row.
120 */
121const TITLE_KEY = 'h'
122/** The terminal's key for the minimise button. */
123const MIN_KEY = 'z'
124/** The terminal's key for moving the crew to the side pane and back. */
125const SIDE_KEY = 'y'
126const ROW_KEYS = [...'abcdefgijklm']
127
128/** A desktop row is exactly this many text rows tall, with the 34 px sprite centered in it. */
129const ROW_ROWS = 2
130/** Silent this long and missing from the engine's agent list: the agent is gone. */
131const GONE_MS = 30_000
132
133export const register: Register = (on, options) => {
134  showCost = options?.showCost === true
135  showMain = options?.showMain !== false
136
137  // Every hook only observes and ends in `.catch(...)`. tool.call and turn.step, which run for
138  // every tool call and model request, never read or write state; so an error in this mod
139  // never changes what Claude Code does: the event goes on as if the mod were not installed.
140
141  on('session.start', async ($, e, next) => {
142    await $.command.register({
143      name: 'crew',
144      description: 'Agent Crew: show/hide, run a demo, or clear',
145      argumentHint: '[demo | clear]',
146    })
147    cwd = e.cwd ?? ''
148    history = sanitizeHistory(await $.store.get('history').catch(() => null))
149    const wasMinimized = (await $.store.get('minimized').catch(() => null)) === true
150    if (wasMinimized !== (await read($, minimized))) await update($, minimized, () => wasMinimized)
151    // The crew lives above the prompt unless the person moved it to the side pane (⇥). The pane
152    // opens when an agent starts; where the engine doesn't place it (a narrow window), the band shows
153    const inSide = (await $.store.get('side').catch(() => null)) === true
154    if (inSide !== (await read($, side))) await update($, side, () => inSide)
155    // After a reload mid-crew the pane comes back (an unload closed it)
156    if ((await read($, agents)).some(a => a.status === 'running')) await openPane($).catch(() => undefined)
157    const now = await $.clock.now()
158    const listed = await listAgents($)
159    // A demo's timer died with the previous module; an agent that ended unreported is closed
160    await update($, agents, list =>
161      list.map(a => {
162        if (a.status !== 'running') return a
163        const ended = a.demo ? 'cancelled' : verdict(a, now, listed)
164        return ended ? { ...a, status: ended, endedAt: now, retired: true as const } : a
165      }),
166    )
167    const current = await read($, agents)
168    known.clear()
169    for (const a of current) known.add(a.id)
170    // After a reload the main turn is read back from the state: its turn.complete still comes.
171    // A demo's main line died with its timer.
172    const m = await read($, main)
173    if (m?.demo) await update($, main, () => null)
174    mainTurn = m && !m.demo ? (m.turnId ?? null) : null
175    if (current.some(a => a.status === 'running') || mainTurn) startTicker($)
176    return next(e)
177  }).catch(($, e, next) => next(e))
178
179  // 0) The main conversation starts a turn: the title row shows Claude's own work
180  on('turn.start', async ($, e, next) => {
181    if (!showMain) return next(e)
182    // Tracked before any await: the turn's first request may arrive during the next one
183    mainTurn = e.turnId
184    pending.delete(MAIN_ID)
185    const now = await $.clock.now()
186    const record: Agent = {
187      id: MAIN_ID,
188      type: 'main',
189      description: clip(redact(e.text.replace(/\s+/g, ' ').trim()), 80),
190      model: '',
191      status: 'running',
192      activity: 'thinking',
193      target: '',
194      activityAt: now,
195      seenAt: now,
196      startedAt: now,
197      tokens: emptyTokens(),
198      tools: 0,
199      requests: 0,
200      turnId: e.turnId,
201    }
202    await update($, main, () => record)
203    startTicker($)
204    return next(e)
205  }).catch(($, e, next) => next(e))
206
207  // 1) A subagent started
208  on('agent.spawn', async ($, e, next) => {
209    const result = await next(e)
210    if (result.agentId === undefined) return result
211    // Tracked before any await: the subagent's first request may arrive during the next one
212    known.add(result.agentId)
213    const now = await $.clock.now()
214    const agent: Agent = {
215      id: result.agentId,
216      type: e.subagentType,
217      description: clip(redact((e.description || e.prompt).replace(/\s+/g, ' ').trim()), 80),
218      model: result.model,
219      status: 'running',
220      activity: 'starting',
221      target: '',
222      activityAt: now,
223      seenAt: now,
224      startedAt: now,
225      tokens: emptyTokens(),
226      tools: 0,
227      requests: 0,
228      parentId: e.parentAgentId,
229    }
230    const before = await read($, agents)
231    // "Hide" hides this crew: when a new crew starts with nobody running, the stage comes back
232    // A new crew starts open: the rows show until you fold them away with the title's arrow
233    if (!before.some(a => a.status === 'running')) {
234      await update($, hidden, () => false)
235  await update($, collapsed, () => false)
236      const folded = await read($, collapsed)
237      if (folded) await update($, collapsed, () => false)
238    }
239    await update($, agents, list => capAgents([...list.filter(a => a.id !== agent.id), agent]))
240    // Agents the cap removed are no longer tracked
241    const kept = await read($, agents)
242    known.clear()
243    for (const a of kept) known.add(a.id)
244    for (const id of closedTurn.keys()) if (!known.has(id)) closedTurn.delete(id)
245    startTicker($)
246    await openPane($).catch(() => undefined)
247    return result
248  }).catch(($, e, next) => next(e))
249
250  // 2) A subagent's tool call: buffered, never awaited
251  on('tool.call', async ($, e, next) => {
252    // A main-loop call (no agentId) belongs to the title row while the main turn runs
253    const id = e.agentId ?? (mainTurn !== null ? MAIN_ID : undefined)
254    if (id === undefined || (id !== MAIN_ID && !known.has(id))) return next(e)
255    const input = e as unknown as Record<string, unknown>
256    const p = pendingFor(id)
257    if (!QUIET_TOOLS.has(e.tool)) {
258      const target = targetOf(e.tool, input)
259      p.activity = activityOf(e.tool)
260      p.target = target
261      p.tools += 1
262      // Awaited only to see the outcome: no `$` call, so the tool runs exactly as it would
263      const result = await next(e)
264      const q = pendingFor(id)
265      q.approval = null // the call went ahead, so any approval it waited for was answered
266      ;(q.recent ??= []).push({ label: clip(`${e.tool} ${target}`.trim(), 80), ...(failed(result) ? { failed: true } : {}) })
267      const file = failed(result) ? null : fileOf(e.tool, input, cwd)
268      if (file) (q.files ??= []).push(file)
269      return result
270    }
271    // The agent's own to-do list: a whole list replaces what came before; task changes are
272    // queued and applied by the ticker on top of the latest list
273    if (e.tool === 'TodoWrite') {
274      p.steps = stepsFromTodos(input.todos)
275      p.stepOps = []
276    }
277    const result = await next(e)
278    // The ticker may have flushed the buffer meanwhile: take it again
279    const q = pendingFor(id)
280    if (e.tool === 'TaskCreate') {
281      const task = (result as { result?: { task?: { id?: string; subject?: string } } }).result?.task
282      if (task?.id) (q.stepOps ??= []).push({ kind: 'create', id: task.id, label: task.subject ?? String(input.subject ?? '') })
283    } else if (e.tool === 'TaskUpdate' && !failed(result)) {
284      const { taskId, status, activeForm, subject } = input
285      ;(q.stepOps ??= []).push({ kind: 'update', change: { taskId, status, activeForm, subject } })
286    }
287    return result
288  }).catch(($, e, next) => next(e))
289
290  // 3) A subagent's model request: counted in memory. No `$` call, so nothing here can delay
291  // or break the request itself.
292  on('turn.step', async function* ($, e, next) {
293    const isMain = e.agentId === undefined && mainTurn !== null && e.turnId === mainTurn
294    const id = isMain ? MAIN_ID : e.agentId
295    const tracked = id !== undefined && (isMain || known.has(id))
296    if (tracked) {
297      const p = pendingFor(id)
298      p.requests += 1
299      if (isMain) p.model = e.model
300      // A subagent resumed with SendMessage runs again under the same id, in a new turn
301      p.revive = true
302      p.turnId = e.turnId
303      if (p.approval) p.approval = null
304      if (e.index > 0) {
305        p.activity = 'thinking'
306        p.target = ''
307      }
308      // Everyone had finished, so the ticker stopped: this agent is back, so it starts again.
309      // A ticker whose interval was ended by a refused period is rebuilt the same way.
310      if (++stepsSinceTick > DEAD_TICKER_STEPS) {
311        ticker?.cancel()
312        ticker = null
313      }
314      if (!ticker) startTicker($)
315    }
316    const answer = yield* next(e)
317    const usage = answer.usage
318    if (tracked && usage) {
319      const p = pendingFor(id)
320      p.tokens = addUsage(p.tokens ?? emptyTokens(), usage)
321    }
322    return answer
323  }).catch(async function* ($, e, next) {
324    return yield* next(e)
325  })
326
327  // A subagent asks the person to approve a tool call: its row says so until the call goes on.
328  // Observed only: the decision is the person's and Claude Code's, never this mod's.
329  on('classic.PermissionRequest', ($, e, next) => {
330    const id = e.agent_id ?? (mainTurn !== null ? MAIN_ID : undefined)
331    if (id !== undefined && (id === MAIN_ID || known.has(id))) pendingFor(id).approval = clip(`${e.tool_name} ${targetOf(e.tool_name, (e.tool_input ?? {}) as Record<string, unknown>)}`.trim(), 80)
332    return next(e)
333  }).catch(($, e, next) => next(e))
334  on('classic.PermissionDenied', ($, e, next) => {
335    const id = e.agent_id ?? (mainTurn !== null ? MAIN_ID : undefined)
336    if (id !== undefined && (id === MAIN_ID || known.has(id))) pendingFor(id).approval = null
337    return next(e)
338  }).catch(($, e, next) => next(e))
339
340  // /clear starts a fresh conversation: the finished crew leaves with the old one
341  on('classic.SessionStart', async ($, e, next) => {
342    if (e.source === 'clear') {
343      const m = await read($, main)
344      if (m && m.status !== 'running') await update($, main, () => null)
345      const list = await read($, agents)
346      if (list.some(a => a.status !== 'running')) await update($, agents, l => l.filter(a => a.status === 'running'))
347      const kept = await read($, agents)
348      known.clear()
349      for (const a of kept) known.add(a.id)
350      const open = await read($, expanded)
351      if (open.length) await update($, expanded, () => [])
352      if (!kept.length) setStatus($, undefined)
353    }
354    return next(e)
355  }).catch(($, e, next) => next(e))
356
357  // 4) A subagent finished: done, failed or cancelled (rare, so written right away)
358  on('turn.complete', async ($, e, next) => {
359    // The main turn ended: its record keeps the turn's final numbers, which the thin line shows
360    if (e.agentId === undefined && mainTurn !== null && e.turnId === mainTurn) {
361      mainTurn = null
362      const buffered = pending.get(MAIN_ID)
363      pending.delete(MAIN_ID)
364      const now = await $.clock.now()
365      const status: AgentStatus = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'cancelled' : 'failed'
366      await update($, main, m => (m && m.status === 'running' ? { ...applyPending(m, buffered, now), status, endedAt: now, waitingFor: undefined } : m))
367      return next(e)
368    }
369    const id = e.agentId
370    if (id === undefined || !known.has(id)) return next(e)
371    const now = await $.clock.now()
372    const status: AgentStatus = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'cancelled' : 'failed'
373    const buffered = pending.get(id)
374    pending.delete(id)
375    let finished: Agent | undefined
376    await update($, agents, list => {
377      finished = undefined // update() may run this again on a version miss
378      return list.map(a => {
379        if (a.id !== id) return a
380        const caughtUp = applyPending(a, buffered, now)
381        // A running agent ends here; so does one the mod had closed, which now gets its real outcome
382        if (caughtUp.status !== 'running' && !caughtUp.retired) return caughtUp
383        finished = { ...caughtUp, status, endedAt: now, retired: undefined, waitingFor: undefined }
384        return finished
385      })
386    })
387    // Closed only now: this turn's own buffer (above) may revive a resumed agent, while
388    // events of this turn that arrive later must not
389    closedTurn.set(id, e.turnId)
390    if (finished && status === 'done' && !finished.demo) await remember($, finished, now - finished.startedAt)
391    return next(e)
392  }).catch(($, e, next) => next(e))
393
394  // 5) A new message: the finished crew leaves the stage
395  on('prompt.submit', async ($, e, next) => {
396    if (e.origin.kind !== 'composer') return next(e)
397    const list = await read($, agents)
398    if (list.length && !list.some(a => a.status === 'running')) {
399      demoOnStage = false
400      await update($, agents, () => [])
401      known.clear()
402      pending.clear()
403      closedTurn.clear()
404      setStatus($, undefined)
405      await closePane($).catch(() => undefined)
406    }
407    const open = await read($, expanded)
408    if (open.length) await update($, expanded, () => [])
409    return next(e)
410  }).catch(($, e, next) => next(e))
411
412  // While a demo is on stage, Claude Code's own prompt suggestion stays out of the recording;
413  // any other time, suggestions pass untouched
414  on('prompt.suggest', ($, e, next) => {
415    if (demoOnStage && e.origin?.kind === 'suggestion') return { isShown: false }
416    return next(e)
417  }).catch(($, e, next) => next(e))
418
419  // 6) /crew
420  on('command.run', { command: 'crew' }, async ($, e) => {
421    const arg = e.args.trim().toLowerCase()
422    if (arg === 'demo') {
423      await startDemo($)
424      return { text: 'Demo crew on stage: Claude starts alone, then 3 agents and a helper join, for about 30 seconds.' }
425    }
426    if (arg === 'clear') {
427      await clearStage($)
428      return { text: 'Stage cleared.' }
429    }
430    const wasHidden = await read($, hidden)
431    await update($, hidden, () => !wasHidden)
432    await refreshStatus($)
433    return { text: wasHidden ? 'Agent Crew is visible.' : 'Agent Crew hidden. Run /crew to show it again.' }
434  }).catch(() => ({ text: 'Agent Crew ran into an error. Run claude --debug for details.' }))
435
436  // 7) Drawing: one aligned row per agent, helpers indented below their parent
437  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
438    // Cheap checks first: while hidden, the drawing doesn't subscribe to the agent list
439    const isHidden = await read($, hidden)
440    if (isHidden || e.props.hasSurvey) return next(e)
441    // Moved to the side pane: the band stays empty while the pane is up
442    if ((await read($, side)) && (await read($, paneUp))) return next(e)
443    const list = shown(await read($, agents))
444    const m = await read($, main)
445    const working = m?.status === 'running' ? m : null
446    if (list.length === 0 && !m) return next(e)
447    const open = await read($, expanded)
448    await read($, foldFrame)
449
450    const els = $.ui.resolve(e)
451    const { Box, Text, Button } = els
452    // The terminal's table has an Svg key too, but draws it as an empty box: decide by surface
453    const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
454    const now = await $.clock.now()
455    // bodyColumns leaves out the engine's own marks and a docked pane: the room the band really has
456    const L = layout(e.props.bodyColumns || e.viewport?.columns || 120, !!Svg, showCost)
457    const barPx = L.barCells * 8
458
459    const runningHelpers = (id: string) => list.filter(a => a.parentId === id && a.status === 'running').length
460    const { anyRunning, hasCrew, right, own, said, ownModel } = titleFacts(list, m, now)
461    const isCollapsed = await read($, collapsed)
462    const isMinimized = await read($, minimized)
463    const room = Math.max(12, Math.floor(((e.props.bodyColumns || e.viewport?.columns || 120) - right.length - 26 - ownModel.length) * (Svg ? 0.75 * DESKTOP_TEXT_ROOM : 1)))
464
465    const toggleMinimized = () => setMinimized($, !isMinimized)
466    if (isMinimized) {
467      // One plain line, no frame and no sprite: the title, what it says now, and its numbers
468      return (
469        <Box flexDirection="row" justifyContent="space-between" alignItems="center" paddingX={1}>
470          <Text wrap="truncate-end">
471            <Text bold color="claude">{hasCrew ? 'AGENT CREW' : 'AGENT'}</Text>
472            <Text color={own?.alert ? 'warning' : 'subtle'}>{`  ${clip(working || hasCrew ? said : statusLabel(m!), room)}`}</Text>
473          </Text>
474          <Box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
475            <Text color="subtle">{right}</Text>
476            {Svg ? (
477              <Button key="minimize" label="▸" onPress={toggleMinimized} />
478            ) : (
479              <Button key="minimize" label="▸" hotkey={MIN_KEY} plain onPress={toggleMinimized} />
480            )}
481          </Box>
482        </Box>
483      )
484    }
485
486    // The title row alone is framed, in Claude's own color; hiding and clearing live in /crew
487    const header = (
488      <Box flexDirection="row" justifyContent="space-between" alignItems="center" borderStyle="round" borderColor="claude" paddingX={1}>
489        <Box flexDirection="row" gap={1} alignItems="center" flexShrink={1}>
490          {Svg ? (
491            <Svg source={working ? spriteSvg(working, now) : headerSvg(!anyRunning)} alt={hasCrew ? 'Agent Crew' : 'Agent'} width={SPRITE_W} height={SPRITE_H} />
492          ) : (
493            <Text color={dotColor(working ?? { status: anyRunning ? 'running' : 'done' })}>{DOT}</Text>
494          )}
495          <Box flexShrink={0}>
496            <Text bold color="claude">{hasCrew ? 'AGENT CREW' : 'AGENT'}</Text>
497          </Box>
498          {ownModel ? (
499            <Box flexShrink={0}>
500              <Text color="subtle">{ownModel}</Text>
501            </Box>
502          ) : null}
503          <Text wrap="truncate-end" color={own?.alert ? 'warning' : undefined}>{clip(said, room)}</Text>
504        </Box>
505        <Box flexDirection="row" gap={2} alignItems="center" flexShrink={0}>
506          <Text color="subtle">{right}</Text>
507          {Svg ? (
508            <Button key="side" label="⇥" onPress={() => setSide($, true)} />
509          ) : (
510            <Button key="side" label="⇥" hotkey={SIDE_KEY} plain onPress={() => setSide($, true)} />
511          )}
512          {Svg ? (
513            <Button key="minimize" label="–" onPress={toggleMinimized} />
514          ) : (
515            <Button key="minimize" label="–" hotkey={MIN_KEY} plain onPress={toggleMinimized} />
516          )}
517          {/* The title's arrow shows or hides the subagent rows; alone, it opens Claude's own details */}
518          {!hasCrew ? (
519            Svg ? (
520              <Button key="open-main" label={open.includes(MAIN_ID) ? '▾' : '▸'} onPress={() => setOpen($, MAIN_ID, !open.includes(MAIN_ID))} />
521            ) : (
522              <Button key="open-main" label={open.includes(MAIN_ID) ? '▾' : '▸'} hotkey={TITLE_KEY} plain onPress={() => setOpen($, MAIN_ID, !open.includes(MAIN_ID))} />
523            )
524          ) : Svg ? (
525            <Button key="collapse" label={isCollapsed ? '▸' : '▾'} onPress={() => update($, collapsed, c => !c)} />
526          ) : (
527            <Button key="collapse" label={isCollapsed ? '▸' : '▾'} hotkey={TITLE_KEY} plain onPress={() => update($, collapsed, c => !c)} />
528          )}
529        </Box>
530      </Box>
531    )
532    if (isCollapsed) return header
533    if (!hasCrew) {
534      // Claude alone: the arrow opens the details of its turn under the title
535      const isOpen = open.includes(MAIN_ID)
536      const details = showsDetails(MAIN_ID, isOpen) ? unfold({ Box }, MAIN_ID, detailsPanel({ Box, Text }, { a: m!, depth: 0, isOpen, share: 0, est: { main: '', extra: '' }, doing: own ?? doingText(m!, now, 0), color: statusColor(m!) }, now, 2), now) : null
537      if (working) return details ? <Box flexDirection="column">{header}{details}</Box> : header
538      return thinLine({ Box, Text, Button }, m!, now, isOpen, () => setOpen($, MAIN_ID, !isOpen), !!Svg, details)
539    }
540
541    const rows = tree(list).map(({ a, depth }, index) => {
542      const isOpen = open.includes(a.id)
543      const row: RowData = {
544        a,
545        depth,
546        isOpen,
547        share: shownProgress(a, now, history),
548        est: estimateText(a, now, history),
549        doing: doingText(a, now, runningHelpers(a.id)),
550        color: statusColor(a),
551      }
552      const toggle = () => setOpen($, a.id, !isOpen)
553      const line = Svg
554        ? desktopRow({ Box, Text, Svg, Button }, row, L, barPx, now, toggle, () => dismissAgent($, a.id))
555        : terminalRow({ Box, Text, Button }, row, L, toggle, ROW_KEYS[index])
556      // Every row lives in the same wrapper, open or not, so a toggle never rebuilds the row.
557      // Open, the wrapper frames the row and its details in dashed yellow.
558      return (
559        <Box key={`${a.id}-wrap`} flexDirection="column" borderStyle={isOpen ? 'dashed' : undefined} borderColor={isOpen ? 'warning' : undefined}>
560          {line}
561          {showsDetails(a.id, isOpen) ? unfold({ Box }, a.id, detailsPanel({ Box, Text }, row, now, depth * 3 + (Svg ? SPRITE_CELLS : 6) + 1), now) : null}
562        </Box>
563      )
564    })
565
566    return (
567      <Box flexDirection="column" gap={Svg ? 1 : 0}>
568        {header}
569        <Box flexDirection="column">{rows}</Box>
570      </Box>
571    )
572  }).catch(($, e, next) => next(e))
573
574  // 8) The side pane: the same crew as a narrow column, one stacked card per agent
575  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
576    const list = shown(await read($, agents))
577    const m = await read($, main)
578    const open = await read($, expanded)
579    await read($, foldFrame)
580    const els = $.ui.resolve(e)
581    const { Box, Text, Button } = els
582    const Svg = e.surface !== 'terminal' && 'Svg' in els ? els.Svg : undefined
583    const now = await $.clock.now()
584    const cols = e.props.bodyColumns || SIDE_COLUMNS
585    const back = Svg ? (
586      <Button key="unside" label="⇤" dimColor hover={{ dimColor: false }} onPress={() => setSide($, false)} />
587    ) : (
588      <Button key="unside" label="⇤" hotkey={SIDE_KEY} plain onPress={() => setSide($, false)} />
589    )
590    if (list.length === 0 && !m) {
591      return (
592        <Box key="title" flexDirection="row" justifyContent="space-between" alignItems="center">
593          <Text color="subtle">No agents yet.</Text>
594          {back}
595        </Box>
596      )
597    }
598    const { working, hasCrew, right, own, said, ownModel } = titleFacts(list, m, now)
599    const runningHelpers = (id: string) => list.filter(a => a.parentId === id && a.status === 'running').length
600    const title = (
601      <Box key="title" flexDirection="column">
602        <Box flexDirection="row" justifyContent="space-between" alignItems="center">
603          <Text wrap="truncate-end">
604            <Text bold color="claude">{hasCrew ? 'AGENT CREW' : 'AGENT'}</Text>
605            <Text color="subtle">{ownModel ? `  ${ownModel}` : ''}</Text>
606          </Text>
607          {back}
608        </Box>
609        <Text wrap="truncate-end" color={own?.alert ? 'warning' : undefined}>{clip(working || hasCrew ? said : statusLabel(m!), cols)}</Text>
610        <Text wrap="truncate-end" color="subtle">{right}</Text>
611      </Box>
612    )
613    const cards = tree(list).map(({ a, depth }, index) => {
614      const isOpen = open.includes(a.id)
615      const row: RowData = {
616        a,
617        depth,
618        isOpen,
619        share: shownProgress(a, now, history),
620        est: estimateText(a, now, history),
621        doing: doingText(a, now, runningHelpers(a.id)),
622        color: statusColor(a),
623      }
624      return sideCard({ Box, Text, Svg, Button }, row, cols, now, () => setOpen($, a.id, !isOpen), () => dismissAgent($, a.id), ROW_KEYS[index])
625    })
626    return (
627      <Box flexDirection="column" gap={1}>
628        {title}
629        {cards}
630      </Box>
631    )
632  }).catch(($, e, next) => next(e))
633
634  // The person closed the side pane: the crew goes back above the prompt
635  on('ui.close', async ($, e, next) => {
636    if (e.id === PANE) {
637      await update($, paneUp, () => false)
638      if (e.origin.kind === 'person') {
639        await update($, side, () => false)
640        await $.store.set('side', false).catch(() => undefined)
641      }
642    }
643    return next(e)
644  }).catch(($, e, next) => next(e))
645}
646
647/** One agent in the side pane: the sprite beside four short lines, its details below when open. */
648function sideCard({ Box, Text, Svg, Button }: Els, r: RowData, cols: number, now: number, onToggle: () => void, onDismiss: () => void, hotkey: string | undefined) {
649  const { a, depth, isOpen, share, est, doing, color } = r
650  const running = a.status === 'running'
651  // The sprite (or the terminal's dot) and the gap beside it take this many cells
652  const lead = (Svg ? SPRITE_CELLS : 2) + 1 + depth * 2
653  const textCells = Math.max(10, cols - lead - (isOpen ? 2 : 0))
654  const barCells = Math.max(6, Math.min(16, textCells - 12))
655  const arrow = Svg ? (
656    <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} dimColor hover={{ dimColor: false }} onPress={onToggle} />
657  ) : (
658    <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} hotkey={hotkey} plain onPress={onToggle} />
659  )
660  // A finished agent can be sent off the stage
661  const close = running ? null : Svg ? (
662    <Button key={`close-${a.id}`} label="✕" role="dismiss" dimColor hover={{ dimColor: false }} onPress={onDismiss} />
663  ) : (
664    <Button key={`close-${a.id}`} label="✕" plain dimColor onPress={onDismiss} />
665  )
666  return (
667    <Box key={`${a.id}-wrap`} flexDirection="column" borderStyle={isOpen ? 'dashed' : undefined} borderColor={isOpen ? 'warning' : undefined}>
668      <Box key={a.id} flexDirection="row" gap={1} paddingLeft={depth * 2}>
669        {Svg ? (
670          <Svg source={spriteSvg(a, now)} alt={`${typeLabel(a.type)}: ${doing.main}`} width={SPRITE_W} height={SPRITE_H} />
671        ) : (
672          <Text color={dotColor(a)}>{DOT}</Text>
673        )}
674        <Box flexDirection="column" flexGrow={1} flexShrink={1}>
675          <Box flexDirection="row" justifyContent="space-between">
676            <Text wrap="truncate-end">
677              <Text bold color={typeColor(a.type)}>{typeLabel(a.type)}</Text>
678              <Text color="subtle">{modelLabel(a.model) ? `  ${modelLabel(a.model)}` : ''}</Text>
679            </Text>
680            <Box flexDirection="row" gap={1} flexShrink={0}>
681              {arrow}
682              {close}
683            </Box>
684          </Box>
685          <Text bold={depth === 0} dimColor={depth > 0} wrap="truncate-end">{clip(a.description, textCells * (Svg ? DESKTOP_TEXT_ROOM : 1))}</Text>
686          <Text wrap="truncate-end">
687            <Text bold color={doing.alert ? 'warning' : color.theme}>{doing.main}</Text>
688            <Text color="subtle">{doing.extra ? `  ${oneLine(doing.extra)}` : ''}</Text>
689          </Text>
690          <Box flexDirection="row" gap={1} alignItems="center">
691            {Svg ? (
692              <Svg source={barSvg(share, color.raw, running, barCells * 8, barCells, 7)} alt={`${Math.round(share * 100)} percent`} width={barCells * 8} height={7} />
693            ) : (
694              <Text color={color.theme}>{textBar(share, barCells, running)}</Text>
695            )}
696            <Text bold>{`${Math.round(share * 100)}%`}</Text>
697            <Text color={running ? 'text' : 'subtle'}>{est.main}</Text>
698          </Box>
699        </Box>
700      </Box>
701      {showsDetails(a.id, isOpen) ? unfold({ Box }, a.id, detailsPanel({ Box, Text }, r, now, 0), now) : null}
702    </Box>
703  )
704}
705
706// ── Rows ─────────────────────────────────────────────────────
707// The element constructors come from $.ui.resolve(e). Each surface's table is its own type and
708// these helpers serve two of them, so they take the constructors loosely typed.
709// eslint-disable-next-line @typescript-eslint/no-explicit-any
710type Els = Record<string, any>
711type RowData = {
712  a: Agent
713  depth: number
714  isOpen: boolean
715  share: number
716  est: { main: string; extra: string }
717  doing: { main: string; extra: string; alert?: boolean }
718  color: { theme: string; raw: string }
719}
720
721const oneLine = (s: string) => s.replace(/\s+/g, ' ')
722
723function desktopRow({ Box, Text, Svg, Button }: Els, r: RowData, L: Layout, barPx: number, now: number, onToggle: () => void, onDismiss: () => void) {
724  const { a, depth, isOpen, share, est, doing, color } = r
725  const helper = depth > 0
726  const indent = depth * 3
727  // An open row sits inside a dashed frame, which takes a cell on each side
728  const taskCells = Math.max(8, L.task - (helper ? indent + 1 : 0) - (isOpen ? 2 : 0))
729  // The desktop's proportional (and bold) font runs wider than its cells: the "doing" text plans with
730  // 75% of the column, so it never wraps to a second line. The bold part comes first; the target only
731  // shows when at least four characters of it fit.
732  const doingChars = Math.floor(L.doing * 0.75)
733  const mainText = clip(doing.main, doingChars)
734  const extraRoom = doingChars - mainText.length - 2
735  const extraText = doing.extra && extraRoom >= 4 ? `  ${clip(oneLine(doing.extra), extraRoom)}` : ''
736  const running = a.status === 'running'
737  return (
738    <Box key={a.id} flexDirection="row" gap={1} alignItems="center" height={ROW_ROWS} paddingRight={1}>
739      {helper ? (
740        <Box width={indent} justifyContent="flex-end" flexShrink={0}>
741          <Text color="subtle">└</Text>
742        </Box>
743      ) : null}
744      <Svg source={spriteSvg(a, now)} alt={`${typeLabel(a.type)}: ${doing.main}`} width={SPRITE_W} height={SPRITE_H} />
745      {/* The type, and under it the model the agent runs on, drawn small */}
746      <Box width={L.type} flexShrink={0}>
747        <Svg source={labelSvg(a.type, a.model)} alt={[typeLabel(a.type), modelLabel(a.model)].filter(Boolean).join(', ')} width={LABEL_W} height={LABEL_H} />
748      </Box>
749      <Box width={taskCells} flexGrow={1} flexShrink={0}>
750        <Text bold={!helper} dimColor={helper} wrap="truncate-end">{clip(a.description, (taskCells - 1) * DESKTOP_TEXT_ROOM)}</Text>
751      </Box>
752      <Box width={L.doing} flexShrink={0}>
753        <Text wrap="truncate-end">
754          <Text bold color={doing.alert ? 'warning' : color.theme}>{mainText}</Text>
755          <Text color="subtle">{extraText}</Text>
756        </Text>
757      </Box>
758      <Svg source={barSvg(share, color.raw, running, barPx, L.barCells, 7)} alt={`${Math.round(share * 100)} percent`} width={barPx} height={7} />
759      <Box width={L.pct} flexShrink={0}>
760        <Text bold>{Math.round(share * 100)}%</Text>
761      </Box>
762      <Box width={L.eta} flexShrink={0}>
763        <Text bold={running} color={running ? 'text' : 'subtle'}>{est.main}</Text>
764      </Box>
765      {L.showTokens ? (
766        <Box width={L.tokens} flexShrink={0}>
767          <Text color="subtle">{tokenCell(a)}</Text>
768        </Box>
769      ) : null}
770      {/* The disclosure arrow ends the row: a button, so it works by click and by keyboard */}
771      <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} dimColor hover={{ dimColor: false }} onPress={onToggle} />
772      {running ? null : <Button key={`close-${a.id}`} label="✕" role="dismiss" dimColor hover={{ dimColor: false }} onPress={onDismiss} />}
773    </Box>
774  )
775}
776
777function terminalRow({ Box, Text, Button }: Els, r: RowData, L: Layout, onToggle: () => void, hotkey: string | undefined) {
778  const { a, depth, isOpen, share, est, doing, color } = r
779  const helper = depth > 0
780  const taskCells = Math.max(8, L.task - (helper ? depth * 3 + 1 : 0) - (isOpen ? 2 : 0))
781  return (
782    <Box key={a.id} flexDirection="row">
783      <Text>
784        <Text color="subtle">{helper ? `${'   '.repeat(depth - 1)} └ ` : ''}</Text>
785        <Text color={dotColor(a)}>{`${DOT} `}</Text>
786        <Text bold color={typeColor(a.type)}>{typeLabel(a.type).padEnd(L.type + 1)}</Text>
787        <Text color="subtle">{L.model ? (modelLabel(a.model) ? modelShort(a.model) : '').padEnd(L.model) : ''}</Text>
788      </Text>
789      <Box width={taskCells + 1} flexShrink={0}>
790        <Text bold={!helper} dimColor={helper} wrap="truncate-end">{clip(a.description, taskCells - 1)}</Text>
791      </Box>
792      <Text wrap="truncate-end">
793        <Text color={doing.alert ? 'warning' : color.theme}>{clip(`${doing.main}${doing.extra ? ` ${oneLine(doing.extra)}` : ''}`, L.doing - 1).padEnd(L.doing)}</Text>
794        <Text color={color.theme}>{textBar(share, L.barCells - 2, a.status === 'running')} </Text>
795        <Text bold>{`${Math.round(share * 100)}%`.padEnd(L.pct)}</Text>
796        <Text>{est.main.padEnd(L.eta)}</Text>
797        <Text color="subtle">{L.showTokens ? tokenCell(a).padEnd(L.tokens) : ''}</Text>
798      </Text>
799      {hotkey ? (
800        <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} hotkey={hotkey} plain onPress={onToggle} />
801      ) : (
802        <Button key={`open-${a.id}`} label={isOpen ? '▾' : '▸'} plain onPress={onToggle} />
803      )}
804    </Box>
805  )
806}
807
808/**
809 * Claude's last turn, once it ended and no subagents are on stage: one thin line, no critter and
810 * no frame, with the turn's final numbers. Its arrow opens the turn's details.
811 */
812function thinLine({ Box, Text, Button }: Els, m: Agent, now: number, isOpen: boolean, onToggle: () => void, desktop: boolean, details: unknown) {
813  const took = formatDuration((m.endedAt ?? now) - m.startedAt)
814  const cost = showCost ? costOf(m.model, m.tokens) : null
815  const facts = [modelLabel(m.model), `last turn ${took}`, tokenText(m.tokens, 'tokens'), cost ? formatCost(cost.total) : '', `${m.tools} tool${m.tools === 1 ? '' : 's'}`].filter(Boolean).join('  ·  ')
816  const line = (
817    <Box key="main-line" flexDirection="row" justifyContent="space-between" alignItems="center" paddingX={1}>
818      <Text wrap="truncate-end">
819        <Text bold color="claude">AGENT</Text>
820        <Text color={statusColor(m).theme}>{`  ${statusLabel(m)}`}</Text>
821        <Text color="subtle">{`  ·  ${facts}`}</Text>
822      </Text>
823      {desktop ? <Button key="open-main" label={isOpen ? '▾' : '▸'} onPress={onToggle} /> : <Button key="open-main" label={isOpen ? '▾' : '▸'} hotkey={TITLE_KEY} plain onPress={onToggle} />}
824    </Box>
825  )
826  return details ? (
827    <Box flexDirection="column" borderStyle="dashed" borderColor="warning">
828      {line}
829      {details}
830    </Box>
831  ) : (
832    line
833  )
834}
835
836/** The panel under an open row: everything the row had to cut. */
837function detailsPanel({ Box, Text }: Els, r: RowData, now: number, indent: number) {
838  const { a, est, doing } = r
839  const t = a.tokens
840  const elapsed = (a.endedAt ?? now) - a.startedAt
841  return (
842    <Box key={`${a.id}-details`} flexDirection="column" paddingLeft={indent}>
843      <Text>
844        <Text color="subtle">Task    </Text>
845        <Text>{a.description}</Text>
846      </Text>
847      {a.status === 'running' && a.target ? (
848        <Text>
849          <Text color="subtle">Now     </Text>
850          <Text>{`${doing.main}: ${a.target}`}</Text>
851        </Text>
852      ) : null}
853      {(a.steps ?? []).map(s => (
854        <Text key={`${a.id}-${s.id}`}>
855          <Text color="subtle">{s.status === 'completed' ? '  [x] ' : s.status === 'in_progress' ? '  [>] ' : '  [ ] '}</Text>
856          <Text bold={s.status === 'in_progress'} dimColor={s.status === 'completed'}>{s.label}</Text>
857        </Text>
858      ))}
859      {a.recent?.length ? (
860        <Text wrap="truncate-end">
861          <Text color="subtle">Recent  </Text>
862          <Text>{a.recent.map(t => `${t.label}${t.failed ? ' (failed)' : ''}`).join('  ·  ')}</Text>
863        </Text>
864      ) : null}
865      {fileLines({ Text }, a)}
866      {showCost ? costLine({ Text }, a) : null}
867      <Text>
868        <Text color="subtle">Tokens  </Text>
869        <Text>{formatTokens(workTokens(t))}</Text>
870        <Text color="subtle">{`  (input ${formatTokens(t.input)} · output ${formatTokens(t.output)})`}</Text>
871      </Text>
872      <Text>
873        <Text color="subtle">Cache   </Text>
874        <Text>{`${formatTokens(t.cacheWrite)} written · ${formatTokens(t.cacheRead)} re-read`}</Text>
875        <Text color="subtle">
876          {cacheIsNotable(t)
877            ? '  ·  after a pause the cache expires, and the next request writes the conversation to it again'
878            : '  ·  the conversation, kept by Claude between requests'}
879        </Text>
880      </Text>
881      <Text>
882        <Text color="subtle">Run     </Text>
883        <Text>{`${modelShort(a.model)} · ${a.tools} tools · ${a.requests} requests · ${formatDuration(elapsed)} elapsed`}</Text>
884        <Text color="subtle">{a.status === 'running' && est.main ? `  ·  ${est.main}${est.extra ? ` (${est.extra})` : ''}` : ''}</Text>
885      </Text>
886    </Box>
887  )
888}
889
890/** The files the agent changed and read, newest first; each list cut to the panel's width. */
891function fileLines({ Text }: Els, a: Agent) {
892  if (!a.files?.length) return null
893  const { changed, read: seen } = filesSummary(a.files)
894  const line = (key: string, label: string, paths: string[]) =>
895    paths.length ? (
896      <Text key={`${a.id}-${key}`} wrap="truncate-end">
897        <Text color="subtle">{label}</Text>
898        <Text>{paths.join('  ·  ')}</Text>
899      </Text>
900    ) : null
901  return [line('changed', `Changed ${changed.length}  `.padEnd(8), changed), line('read', `Read ${seen.length}  `.padEnd(8), seen)]
902}
903
904/** The token column: new tokens, plus the ≈ cost when costs are on. */
905function tokenCell(a: Agent): string {
906  const tokens = tokenText(a.tokens)
907  if (!showCost) return tokens
908  const cost = costOf(a.model, a.tokens)
909  return cost ? `${tokens} ${formatCost(cost.total)}` : tokens
910}
911
912/** The details panel's cost breakdown; nothing for a model without a known price. */
913function costLine({ Text }: Els, a: Agent) {
914  const c = costOf(a.model, a.tokens)
915  if (!c) return null
916  const part = (usd: number) => formatCost(usd).replace('≈', '')
917  return (
918    <Text>
919      <Text color="subtle">Cost    </Text>
920      <Text>{formatCost(c.total)}</Text>
921      <Text color="subtle">{`  (input ${part(c.input)} · output ${part(c.output)} · cache write ${part(c.cacheWrite)} · cache read ${part(c.cacheRead)})  ·  list prices, an estimate`}</Text>
922    </Text>
923  )
924}
925
926/** The crew's ≈ cost over the agents whose model price is known; null when none is. */
927function sumCost(list: readonly Agent[]): number | null {
928  const known = list.map(a => costOf(a.model, a.tokens)).filter((c): c is NonNullable<typeof c> => c !== null)
929  return known.length ? known.reduce((t, c) => t + c.total, 0) : null
930}
931
932// ── Pure helpers ─────────────────────────────────────────────
933/** Whether a tool call failed: an error result, or a task update that reports no success. */
934function failed(result: unknown): boolean {
935  const r = result as { isError?: boolean; deny?: string; result?: { success?: boolean } }
936  return r.isError === true || r.deny !== undefined || r.result?.success === false
937}
938
939/** Folds an agent's buffered events into it; any event also counts as a sign of life. */
940function applyPending(a: Agent, p: Pending | undefined, now: number): Agent {
941  if (!p) return a
942  let next: Agent = { ...a, seenAt: now, requests: a.requests + p.requests, tools: a.tools + p.tools }
943  // A model request means the agent runs: resumed after it finished, unless that request
944  // belongs to the very turn that already ended (its events arrived after the end)
945  const lateEvents = p.turnId !== undefined && p.turnId === closedTurn.get(a.id)
946  if (p.revive && !lateEvents && a.status !== 'running') next = { ...next, status: 'running', endedAt: undefined, shownShare: 0, retired: undefined }
947  if (p.model) next = { ...next, model: p.model }
948  if (p.tokens) {
949    const t = p.tokens
950    next = { ...next, tokens: addUsage(next.tokens, { input_tokens: t.input, output_tokens: t.output, cache_creation_input_tokens: t.cacheWrite, cache_read_input_tokens: t.cacheRead }) }
951  }
952  if (p.steps || p.stepOps?.length) {
953    let steps = p.steps ?? next.steps ?? []
954    for (const op of p.stepOps ?? []) steps = op.kind === 'create' ? withStepCreated(steps, op.id, op.label) : withStepUpdated(steps, op.change)
955    next = { ...next, steps }
956  }
957  if (p.activity !== undefined && next.status === 'running') next = withActivity(next, p.activity, p.target ?? '', now)
958  if (p.approval !== undefined) next = { ...next, waitingFor: p.approval ?? undefined }
959  if (p.recent?.length) next = { ...next, recent: [...(next.recent ?? []), ...p.recent].slice(-RECENT_TOOLS) }
960  if (p.files?.length) next = { ...next, files: withFiles(next.files ?? [], p.files) }
961  return next
962}
963
964/**
965 * How a running agent ended when its turn.complete never came, or null while it still runs.
966 * The engine's agent list decides: an agent it reports ended is closed with that outcome, one it
967 * no longer lists is closed after GONE_MS of silence, and one it lists as running is left alone
968 * however long it is quiet (a 20-minute build is not a dead agent). Only without that list does
969 * silence alone (STALE_MS) retire an agent.
970 */
971function verdict(a: Agent, now: number, listed: Map<string, string> | null): AgentStatus | null {
972  if (a.demo) return null
973  if (listed) {
974    const status = listed.get(a.id)
975    if (status === undefined) return now - a.seenAt > GONE_MS ? 'cancelled' : null
976    if (status === 'completed') return 'done'
977    if (status === 'failed') return 'failed'
978    if (status === 'killed') return 'cancelled'
979    return null
980  }
981  if (listWorks) return null
982  return now - a.seenAt > STALE_MS ? 'cancelled' : null
983}
984
985// ── Helpers that use $ (they must stay in the hooks file) ────
986/** The engine's agents by id and status, or null where the list isn't available. */
987async function listAgents($: EngineInterface): Promise<Map<string, string> | null> {
988  const listed = await $.agent.list().catch(() => null)
989  if (!listed) return null
990  listWorks = true
991  return new Map(listed.map(x => [x.id, x.status]))
992}
993
994/** Writes the status line only when its text changes. */
995function setStatus($: EngineInterface, text: string | undefined) {
996  if (text === lastStatus) return
997  lastStatus = text
998  $.ui.status(text)
999}
1000
1001/** While the crew is hidden, its one-line summary sits on the status line instead. */
1002async function refreshStatus($: EngineInterface) {
1003  const isHidden = await read($, hidden)
1004  const list = shown(await read($, agents))
1005  const now = await $.clock.now()
1006  setStatus($, isHidden ? statusText(list, now, history, showCost ? sumCost(list) : null) : undefined)
1007}
1008
1009/** What the title says and its numbers, shared by the band and the side pane. `m` or `list` is non-empty. */
1010function titleFacts(list: readonly Agent[], m: Agent | null, now: number) {
1011  const working = m?.status === 'running' ? m : null
1012  const summary = crewSummary(list, now, history)
1013  const anyRunning = list.some(a => a.status === 'running')
1014  const crewCost = showCost ? sumCost(list) : null
1015  const hasCrew = list.length > 0
1016  // Alone, the title is Claude's own line: how long it has worked and what it spent
1017  const mainCost = m && showCost ? costOf(m.model, m.tokens)?.total ?? null : null
1018  const right = hasCrew
1019    ? [
1020        tokenText(sumTokens([...list.map(a => a.tokens), ...(working ? [working.tokens] : [])]), 'tokens'),
1021        crewCost !== null ? formatCost(crewCost + (mainCost ?? 0)) : '',
1022        anyRunning ? `crew ~${formatDuration(summary.leftMs)} left` : '',
1023        `${Math.round(summary.share * 100)}%`,
1024      ].filter(Boolean).join('  ·  ')
1025    : [
1026        formatDuration((m!.endedAt ?? now) - m!.startedAt),
1027        tokenText(m!.tokens, 'tokens'),
1028        mainCost !== null ? formatCost(mainCost) : '',
1029      ].filter(Boolean).join('  ·  ')
1030  // What the title says: an approval a subagent waits on first, then Claude's own work, then the crew's state
1031  const crewAsks = list.some(a => a.status === 'running' && a.waitingFor)
1032  // Claude waiting on the subagents it started (the Agent tool shows no activity of its own)
1033  const waitsOn = list.filter(a => !a.parentId && a.status === 'running').length
1034  const idle = working && (working.activity === 'thinking' || working.activity === 'starting') && !working.waitingFor
1035  const own = !working || crewAsks ? null : idle && waitsOn ? { main: 'Waiting', extra: `on ${waitsOn} agent${waitsOn > 1 ? 's' : ''}`, alert: false } : doingText(working, now, 0)
1036  // Claude's own model sits beside the title while it works
1037  const ownModel = working ? modelLabel(working.model) : ''
1038  const said = own ? `${own.main}${own.extra ? `  ${oneLine(own.extra)}` : ''}` : headline(list, now)
1039  return { working, anyRunning, hasCrew, right, own, said, ownModel }
1040}
1041
1042/** Moves the crew into the side pane or back above the prompt, and remembers the choice. */
1043async function setSide($: EngineInterface, value: boolean) {
1044  await update($, side, () => value)
1045  await $.store.set('side', value).catch(() => undefined)
1046  await (value ? openPane($) : closePane($))
1047}
1048
1049/** Opens the side pane where the crew lives there and it isn't up yet. */
1050async function openPane($: EngineInterface) {
1051  if (!(await read($, side)) || (await read($, paneUp))) return
1052  const opened = await $.ui.open({ id: PANE, title: 'Agent Crew', columns: SIDE_COLUMNS })
1053  await update($, paneUp, () => opened.isPlaced)
1054}
1055
1056/** Closes the side pane, keeping the choice to use it: the next crew opens it again. */
1057async function closePane($: EngineInterface) {
1058  if (!(await read($, paneUp))) return
1059  await update($, paneUp, () => false)
1060  await $.ui.close({ id: PANE }).catch(() => undefined)
1061}
1062
1063/** The agents the band shows: cancelled ones drop out of view (they stay in the state). */
1064const shown = (list: readonly Agent[]) => list.filter(a => a.status !== 'cancelled')
1065
1066/** Minimises or restores the band, and remembers the choice for later sessions. */
1067async function setMinimized($: EngineInterface, value: boolean) {
1068  await update($, minimized, () => value)
1069  await $.store.set('minimized', value).catch(() => undefined)
1070}
1071
1072/** One row open at a time: opening a row closes the others. Writes only on a real change. */
1073async function setOpen($: EngineInterface, id: string, open: boolean) {
1074  const ids = await read($, expanded)
1075  const already = open ? ids.length === 1 && ids[0] === id : !ids.includes(id)
1076  if (already) return
1077  // The details slide open, and whatever closes slides shut
1078  const now = await $.clock.now()
1079  for (const x of open ? ids.filter(x => x !== id) : [id]) startFold($, x, false, now)
1080  if (open) startFold($, id, true, now)
1081  await update($, expanded, current => (open ? [id] : current.filter(x => x !== id)))
1082}
1083
1084// ── Unfolding ────────────────────────────────────────────────
1085// The surfaces lay a plugin out in text rows and run no transitions of their own, so a details
1086// panel slides open by its height in rows: a short run of redraws, eased, then the timer stops.
1087const UNFOLD_MS = 160
1088const FRAME_MS = 20
1089/** Panels sliding now, by row id: which way, and since when. */
1090const folds = new Map<string, { open: boolean; at: number }>()
1091let foldTimer: { cancel: () => void } | null = null
1092
1093function startFold($: EngineInterface, id: string, open: boolean, now: number) {
1094  folds.set(id, { open, at: now })
1095  if (foldTimer) return
1096  foldTimer = $.clock.every(FRAME_MS, () => {
1097    void (async () => {
1098      const t = await $.clock.now()
1099      for (const [k, f] of folds) if (t - f.at >= UNFOLD_MS) folds.delete(k)
1100      await update($, foldFrame, n => n + 1)
1101      if (folds.size === 0) {
1102        foldTimer?.cancel()
1103        foldTimer = null
1104      }
1105    })().catch(() => undefined)
1106  })
1107}
1108
1109/** Whether a row's details are drawn: open, or still sliding shut. */
1110const showsDetails = (id: string, isOpen: boolean) => isOpen || folds.get(id)?.open === false
1111
1112/** The details as far as they have slid: clipped to a growing (or shrinking) number of rows. */
1113function unfold({ Box }: Els, id: string, details: RenderChildren, now: number): RenderChildren {
1114  const f = folds.get(id)
1115  if (!f) return details
1116  const p = Math.min(1, Math.max(0, (now - f.at) / UNFOLD_MS))
1117  const eased = 1 - (1 - p) ** 3
1118  const rows = rowsOf(details)
1119  const shownRows = Math.round(rows * (f.open ? eased : 1 - eased))
1120  if (shownRows >= rows) return details
1121  if (shownRows <= 0 && !f.open) return null
1122  return (
1123    <Box key={`${id}-fold`} flexDirection="column" height={Math.max(1, shownRows)} overflow="hidden" flexShrink={0}>
1124      {details}
1125    </Box>
1126  )
1127}
1128
1129/** The rows a details panel takes: one per line it holds (a long line that wraps counts once). */
1130function rowsOf(el: unknown): number {
1131  const node = el as { children?: unknown; props?: { children?: unknown } } | null
1132  const kids = node?.children ?? node?.props?.children
1133  const flat = (Array.isArray(kids) ? kids : [kids]).flat(4).filter(k => k !== null && k !== undefined && k !== false)
1134  return Math.max(1, flat.length)
1135}
1136
1137/** The ✕ on a finished agent: it leaves the stage, and the pane closes once nobody is left. */
1138async function dismissAgent($: EngineInterface, id: string) {
1139  folds.delete(id)
1140  await update($, agents, list => list.filter(a => a.id !== id))
1141  await update($, expanded, ids => ids.filter(x => x !== id))
1142  known.delete(id)
1143  const left = shown(await read($, agents))
1144  if (left.length === 0) await closePane($).catch(() => undefined)
1145}
1146
1147/**
1148 * The once-a-second heartbeat: flushes the hot-path buffer, records each bar's high-water
1149 * mark, retires agents whose end was never reported, and redraws the clocks. At most one
1150 * write and one redraw per tick, and ticks never overlap.
1151 */
1152function startTicker($: EngineInterface) {
1153  if (ticker) return
1154  ticker = $.clock.every(1000, () => {
1155    if (tickBusy) return
1156    tickBusy = true
1157    void tick($)
1158      .catch(() => undefined)
1159      .finally(() => {
1160        tickBusy = false
1161      })
1162  })
1163}
1164
1165async function tick($: EngineInterface) {
1166  stepsSinceTick = 0
1167  const now = await $.clock.now()
1168  ticks += 1
1169  // Every 10 s, ask the engine where each agent stands: a missing turn.complete would
1170  // otherwise leave an agent "running" forever
1171  const listed = ticks % 10 === 0 ? await listAgents($) : null
1172  const buffered = new Map(pending)
1173  pending.clear()
1174  // The main conversation's events go to the title row's record
1175  const own = buffered.get(MAIN_ID)
1176  buffered.delete(MAIN_ID)
1177  if (own) await update($, main, m => (m && m.status === 'running' ? applyPending(m, own, now) : m))
1178
1179  const before = await read($, agents)
1180  // A just-spawned agent may not be in the state yet: its buffer waits for the next tick
1181  for (const [id, p] of buffered) {
1182    if (!before.some(a => a.id === id) && known.has(id)) {
1183      pending.set(id, p)
1184      buffered.delete(id)
1185    }
1186  }
1187  const needsWrite =
1188    buffered.size > 0 ||
1189    before.some(a => {
1190      if (a.status !== 'running') return false
1191      if (verdict(a, now, listed)) return true
1192      // The bar's high-water mark, written when it reaches a new segment (1/14 of the bar)
1193      return Math.floor(shownProgress(a, now, history) * 14) > Math.floor((a.shownShare ?? 0) * 14)
1194    })
1195  if (needsWrite) {
1196    await update($, agents, list =>
1197      list.map(a => {
1198        let next = applyPending(a, buffered.get(a.id), now)
1199        if (next.status !== 'running') return next
1200        const ended = verdict(next, now, listed)
hooks/draw.ts 951 lines
1// Agent Crew's pure logic and pixel-art drawings. No `$` here, so everything is testable.
2// Text is not drawn here: the surface's own Text elements draw it, full size and in
3// your theme. This file only produces the crew member sprites and the progress bar.
4
5import type { Activity, Agent, AgentStatus, Step, Tokens, TouchedFile } from '../types'
6
7// ── Palette (inside SVGs; colors that read on light and dark themes) ──
8const C = {
9  body: '#D97757',
10  bodyAsleep: '#C9A08F',
11  eye: '#2B211C',
12  white: '#FFFDF7',
13  green: '#2FB67C',
14  red: '#E0533F',
15  yellow: '#F4C542',
16  yellowDark: '#D9A21E',
17  blue: '#3E6FE0',
18  blueDark: '#2C47A8',
19  blueLight: '#9CC4FF',
20  pink: '#FF7AC6',
21  code: '#4BE08A',
22  screen: '#1F2A24',
23  screenEdge: '#3A4A40',
24  grey: '#8A8F98',
25  greyDark: '#5E636C',
26  brown: '#6B4F3A',
27  paperEdge: '#DCD0B4',
28  ink: '#1E1A18',
29  track: '#8882', // bar background: translucent, fits either theme
30}
31
32/** The accent color of an agent type. */
33export function typeColor(type: string): string {
34  if (type === 'Explore') return '#4C7DF0'
35  if (type === 'Plan') return '#9466F7'
36  if (type === 'fork') return '#2FB67C'
37  return '#E08A2E'
38}
39
40/** The status color: a theme key for text, a raw color for SVGs. */
41export function statusColor(a: Agent): { theme: string; raw: string } {
42  if (a.status === 'done') return { theme: 'success', raw: C.green }
43  if (a.status === 'failed') return { theme: 'error', raw: C.red }
44  if (a.status === 'cancelled') return { theme: 'inactive', raw: C.grey }
45  return { theme: typeColor(a.type), raw: typeColor(a.type) }
46}
47
48/** The short label shown in the type column. */
49export function typeLabel(type: string): string {
50  if (type === 'general-purpose') return 'GENERAL'
51  return type.toUpperCase().slice(0, 8)
52}
53
54// ── Text helpers ─────────────────────────────────────────────
55export const clip = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
56
57export function formatTokens(n: number): string {
58  // From 999,950 up, "1000.0k" would show: switch to millions there
59  if (n >= 999_950) return `${(n / 1_000_000).toFixed(1)}M`
60  if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
61  return String(n)
62}
63
64export function formatDuration(ms: number): string {
65  const s = Math.max(0, Math.round(ms / 1000))
66  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
67}
68
69/**
70 * The tokens a row shows: what the agent's own work took, its input and its output. The cache
71 * lives in the details panel: every request re-reads the whole conversation from it, and after a
72 * pause the whole conversation is written to it again, so either would swamp the row.
73 */
74export const workTokens = (t: Tokens) => t.input + t.output
75
76/** The token figure on a row, the title and the thin line: "3.1k tok". */
77export function tokenText(t: Tokens, unit = 'tok'): string {
78  return `${formatTokens(workTokens(t))} ${unit}`
79}
80
81/** A big cache write (a long conversation picked up after its cache expired) gets a word of explanation. */
82export const CACHE_NOTE_MIN = 10_000
83export function cacheIsNotable(t: Tokens): boolean {
84  return t.cacheWrite >= CACHE_NOTE_MIN && t.cacheWrite >= t.input + t.output
85}
86
87/** Several agents' tokens added up. */
88export function sumTokens(list: readonly Tokens[]): Tokens {
89  return list.reduce((s, t) => ({ input: s.input + t.input, output: s.output + t.output, cacheWrite: s.cacheWrite + t.cacheWrite, cacheRead: s.cacheRead + t.cacheRead }), emptyTokens())
90}
91
92export const emptyTokens = (): Tokens => ({ input: 0, output: 0, cacheWrite: 0, cacheRead: 0 })
93
94export function addUsage(t: Tokens, u: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): Tokens {
95  return {
96    input: t.input + u.input_tokens,
97    output: t.output + u.output_tokens,
98    cacheWrite: t.cacheWrite + (u.cache_creation_input_tokens ?? 0),
99    cacheRead: t.cacheRead + (u.cache_read_input_tokens ?? 0),
100  }
101}
102
103// ── Cost (optional; off by default) ──────────────────────────
104// Anthropic's first-party list prices in USD per million tokens (October 2026). Cache writes are
105// priced as 5-minute writes (1.25× input); the API's usage doesn't say which TTL each write used,
106// so every figure built on this is an estimate and is shown with "≈".
107type Price = { input: number; output: number; cacheRead: number }
108const PRICES: [string, Price][] = [
109  // Longest names first: "claude-fable-5-1" must win over "claude-fable-5"
110  ['fable-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
111  ['mythos-5-1', { input: 10, output: 50, cacheRead: 0.25 }],
112  ['fable-5', { input: 10, output: 50, cacheRead: 1 }],
113  ['mythos-5', { input: 10, output: 50, cacheRead: 1 }],
114  ['opus-5-5', { input: 4, output: 20, cacheRead: 0.2 }],
115  ['opus-5', { input: 5, output: 25, cacheRead: 0.5 }],
116  ['opus-4', { input: 5, output: 25, cacheRead: 0.5 }],
117  ['sonnet-5-5', { input: 2, output: 10, cacheRead: 0.2 }],
118  ['sonnet-5', { input: 2, output: 10, cacheRead: 0.2 }],
119  ['sonnet-4', { input: 3, output: 15, cacheRead: 0.3 }],
120  ['haiku-5-5', { input: 0.1, output: 0.5, cacheRead: 0.01 }],
121  ['haiku-4', { input: 1, output: 5, cacheRead: 0.1 }],
122]
123
124/** The list price for a model id, or null for a model the table doesn't know. */
125export function priceOf(model: string): Price | null {
126  const id = model.toLowerCase()
127  return PRICES.find(([key]) => id.includes(key))?.[1] ?? null
128}
129
130export type Cost = { total: number; input: number; output: number; cacheWrite: number; cacheRead: number }
131
132/** The estimated cost of an agent's tokens in USD, or null when the model's price is unknown. */
133export function costOf(model: string, t: Tokens): Cost | null {
134  const p = priceOf(model)
135  if (!p) return null
136  const m = 1_000_000
137  const input = (t.input * p.input) / m
138  const output = (t.output * p.output) / m
139  const cacheWrite = (t.cacheWrite * p.input * 1.25) / m
140  const cacheRead = (t.cacheRead * p.cacheRead) / m
141  return { total: input + output + cacheWrite + cacheRead, input, output, cacheWrite, cacheRead }
142}
143
144export function formatCost(usd: number): string {
145  if (usd < 0.005) return '≈$0.00'
146  if (usd < 10) return `≈$${usd.toFixed(2)}`
147  return `≈$${usd.toFixed(1)}`
148}
149
150// ── Secrets ──────────────────────────────────────────────────
151// A command or URL an agent runs can carry a key. Targets and descriptions are masked before
152// they are stored or drawn, so a token never sits in state or on screen.
153const SECRET_PATTERNS: RegExp[] = [
154  /\b(sk-(?:ant-)?[A-Za-z0-9_-]{8,})/g, // Anthropic / OpenAI style keys
155  /\b(gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})/g, // GitHub tokens
156  /\b(xox[abprs]-[A-Za-z0-9-]{10,})/g, // Slack tokens
157  /\b(AKIA[0-9A-Z]{16})\b/g, // AWS access key ids
158  /\b(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,})/g, // JWTs
159]
160// A key word followed by "=" or ":" (password=…, Authorization: Bearer …), or a bare "Bearer …".
161// A key word alone is not a secret: `grep -rn password src/` stays as it is.
162const SECRET_ASSIGNMENT = /((?:api[_-]?key|access[_-]?token|token|secret|password|passwd|pwd|authorization)["']?\s*[=:]\s*(?:bearer\s+)?["']?|\bbearer\s+)([^\s"'&;,]{4,})/gi
163
164export function redact(text: string): string {
165  let out = text.replace(SECRET_ASSIGNMENT, (_m, lead: string) => `${lead}•••`)
166  for (const re of SECRET_PATTERNS) out = out.replace(re, '•••')
167  return out
168}
169
170const ACTIVITY_LABEL: Record<Activity, string> = {
171  starting: 'Getting ready',
172  thinking: 'Thinking',
173  searching: 'Searching',
174  reading: 'Reading',
175  writing: 'Writing',
176  running: 'Running',
177  web: 'Browsing',
178}
179
180export function statusLabel(a: Agent): string {
181  if (a.status === 'done') return 'Done'
182  if (a.status === 'failed') return 'Failed'
183  if (a.status === 'cancelled') return 'Cancelled'
184  return ACTIVITY_LABEL[a.activity]
185}
186
187/** Tools that keep the agent's to-do list or start helpers: they don't change the accessory. */
188export const QUIET_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet', 'Agent', 'Task'])
189
190export function activityOf(tool: string): Activity {
191  if (tool === 'Grep' || tool === 'Glob' || tool === 'ToolSearch') return 'searching'
192  if (tool === 'Read' || tool === 'NotebookRead') return 'reading'
193  if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') return 'writing'
194  if (tool === 'WebFetch' || tool === 'WebSearch' || tool.startsWith('mcp__')) return 'web'
195  return 'running'
196}
197
198/** What a tool call is about: the pattern, the file name, the command, the address. */
199export function targetOf(tool: string, input: Record<string, unknown>): string {
200  const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
201  const file = (p: string) => p.split('/').filter(Boolean).pop() ?? p
202  const arg =
203    tool === 'Bash' ? str('command')
204    : tool === 'Read' || tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' ? file(str('file_path'))
205    : tool === 'NotebookEdit' ? file(str('notebook_path'))
206    : tool === 'Grep' || tool === 'Glob' ? str('pattern')
207    : tool === 'WebFetch' ? str('url').replace(/^https?:\/\/(www\.)?/, '')
208    : tool === 'WebSearch' ? str('query')
209    : tool.startsWith('mcp__') ? (tool.split('__').pop() ?? tool)
210    : tool
211  // Bounded: a one-line `python -c` can be kilobytes, and this string is stored and redrawn
212  return clip(redact(arg.split('\n')[0].trim()), 160)
213}
214
215// ── Files ────────────────────────────────────────────────────
216/** How many files an agent remembers; past that the oldest go first. */
217export const MAX_FILES = 40
218const READS = new Set(['Read'])
219const CHANGES = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
220
221/** The file a tool call read or changed, or null for every other call. */
222export function fileOf(tool: string, input: Record<string, unknown>, cwd = ''): TouchedFile | null {
223  if (!READS.has(tool) && !CHANGES.has(tool)) return null
224  const raw = input[tool === 'NotebookEdit' ? 'notebook_path' : 'file_path']
225  if (typeof raw !== 'string' || !raw.trim()) return null
226  const path = clip(redact(relativePath(raw.trim(), cwd)), 160)
227  return CHANGES.has(tool) ? { path, changed: true } : { path }
228}
229
230/** A path under the project, shown from the project folder; anything else, as it is. */
231export function relativePath(path: string, cwd: string): string {
232  const root = cwd.replace(/\/+$/, '')
233  return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
234}
235
236/** Adds touched files: one entry per path, moved to the newest place, changed once stays changed. */
237export function withFiles(list: readonly TouchedFile[], touched: readonly TouchedFile[]): TouchedFile[] {
238  let out = [...list]
239  for (const f of touched) {
240    const old = out.find(o => o.path === f.path)
241    out = out.filter(o => o.path !== f.path)
242    out.push(old?.changed || f.changed ? { path: f.path, changed: true } : { path: f.path })
243  }
244  return out.slice(-MAX_FILES)
245}
246
247/** The details panel's file lists, newest first. */
248export function filesSummary(files: readonly TouchedFile[]): { changed: string[]; read: string[] } {
249  const newest = [...files].reverse()
250  return { changed: newest.filter(f => f.changed).map(f => f.path), read: newest.filter(f => !f.changed).map(f => f.path) }
251}
252
253// ── Estimates ────────────────────────────────────────────────
254// For each (type, model) the last 20 finished runs are kept: duration and request count.
255// The median, not the mean: one very long run doesn't skew the estimate.
256export type Sample = { ms: number; requests: number }
257export type History = Record<string, Sample[]>
258const DEFAULT_MS = 60_000
259
260const FAMILIES = ['haiku', 'sonnet', 'opus', 'fable', 'mythos']
261
262/**
263 * A model id as people say it: "claude-opus-5-5" is "Opus 5.5", "claude-sonnet-4-5-20250929"
264 * is "Sonnet 4.5", the older "claude-3-5-haiku" is "Haiku 3.5". Empty for an unknown id.
265 */
266export function modelLabel(model: string): string {
267  const id = (model || '').toLowerCase()
268  const family = FAMILIES.find(f => id.includes(f))
269  if (!family) return ''
270  // Version numbers: single digits around the family name, leaving dates (8 digits) out
271  const nums = id.split(/[^0-9]+/).filter(n => n && n.length <= 2)
272  const name = family[0].toUpperCase() + family.slice(1)
273  return nums.length ? `${name} ${nums.slice(0, 2).join('.')}` : name
274}
275
276export function modelShort(model: string): string {
277  for (const m of ['haiku', 'sonnet', 'opus', 'fable']) if (model.includes(m)) return m
278  return model || '?'
279}
280const historyKey = (a: Pick<Agent, 'type' | 'model'>) => `${a.type}|${modelShort(a.model)}`
281
282function percentile(sorted: number[], p: number): number {
283  const i = (sorted.length - 1) * p
284  const lo = Math.floor(i)
285  return sorted[lo] + (sorted[Math.ceil(i)] - sorted[lo]) * (i - lo)
286}
287
288export type Expectation = { ms: number; requests?: number; low?: number; high?: number; learned: boolean; durations?: number[] }
289
290export function expectation(a: Agent, h: History): Expectation {
291  if (a.expectedMs) return { ms: a.expectedMs, learned: true }
292  // The same type and model first; otherwise every model of the same type
293  let samples = h[historyKey(a)] ?? []
294  if (samples.length === 0) samples = Object.entries(h).filter(([k]) => k.startsWith(`${a.type}|`)).flatMap(([, v]) => v)
295  if (samples.length === 0) return { ms: DEFAULT_MS, learned: false }
296  const durations = samples.map(s => s.ms).sort((x, y) => x - y)
297  const requests = samples.map(s => s.requests).filter(n => n > 0).sort((x, y) => x - y)
298  return {
299    ms: percentile(durations, 0.5),
300    requests: requests.length ? percentile(requests, 0.5) : undefined,
301    low: samples.length >= 3 ? percentile(durations, 0.25) : undefined,
302    high: samples.length >= 3 ? percentile(durations, 0.75) : undefined,
303    learned: true,
304    durations,
305  }
306}
307
308/** Keeps only well-formed samples from whatever the store held: a bad value never breaks drawing. */
309export function sanitizeHistory(raw: unknown): History {
310  const out: History = {}
311  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out
312  for (const [key, samples] of Object.entries(raw as Record<string, unknown>)) {
313    if (!Array.isArray(samples)) continue
314    const ok = samples
315      .filter((x): x is Sample => !!x && typeof x === 'object' && Number.isFinite((x as Sample).ms) && (x as Sample).ms > 0)
316      .map(x => ({ ms: x.ms, requests: Number.isFinite(x.requests) ? x.requests : 0 }))
317      .slice(-20)
318    if (ok.length) out[key] = ok
319  }
320  return out
321}
322
323export function learn(h: History, a: Agent, sample: Sample): History {
324  const k = historyKey(a)
325  return { ...h, [k]: [...(h[k] ?? []), sample].slice(-20) }
326}
327
328/**
329 * How long an agent still needs, and on what that guess rests. All of it is local arithmetic
330 * over the clock and the stored history: no model is asked, so it costs no tokens.
331 *
332 * - steps:   the agent keeps a to-do list, so the time per finished step predicts the rest.
333 * - history: past the typical run time, look only at past runs that lasted at least this long
334 *            and take their median: "runs that got this far usually finished at…".
335 * - stretch: no run in the history lasted this long; extend by a quarter of the time so far.
336 * Within the typical time, the model request count refines the time-based guess.
337 */
338export type Remaining = { ms: number; basis: 'expected' | 'steps' | 'history' | 'stretch'; revised: boolean }
339
340export function remaining(a: Agent, now: number, h: History): Remaining {
341  const elapsed = Math.max(0, now - a.startedAt)
342  const steps = stepSummary(a)
343  if (steps) {
344    const doneUnits = steps.done + (steps.current ? 0.5 : 0)
345    if (doneUnits > 0) return { ms: Math.max(1000, (elapsed / doneUnits) * (steps.total - doneUnits)), basis: 'steps', revised: false }
346  }
347  const e = expectation(a, h)
348  let timeLeft: number
349  let basis: Remaining['basis'] = 'expected'
350  if (elapsed <= e.ms) {
351    timeLeft = e.ms - elapsed
352  } else {
353    const longer = (e.durations ?? []).filter(d => d > elapsed)
354    if (longer.length) {
355      timeLeft = percentile(longer, 0.5) - elapsed
356      basis = 'history'
357    } else {
358      timeLeft = Math.max(10_000, elapsed * 0.25)
359      basis = 'stretch'
360    }
361  }
362  // Requests are a second clock: an agent that works fast gets there sooner than the time says
363  if (basis === 'expected' && e.requests && a.requests > 0 && a.requests < e.requests) {
364    const byRequests = (elapsed * (e.requests - a.requests)) / a.requests
365    timeLeft = 0.6 * timeLeft + 0.4 * byRequests
366  }
367  return { ms: Math.max(1000, timeLeft), basis, revised: basis === 'history' || basis === 'stretch' }
368}
369
370/** 0..1 from the time left: elapsed / (elapsed + remaining). Never 100% before done. */
371export function progress(a: Agent, now: number, h: History): number {
372  if (a.status === 'done') return 1
373  const end = a.endedAt ?? now
374  const elapsed = Math.max(0, end - a.startedAt)
375  const r = remaining(a, end, h)
376  return Math.min(0.99, elapsed / (elapsed + r.ms))
377}
378
379/**
380 * What the bar shows: the progress, but never less than it has already shown. When the time
381 * left is revised upwards, the bar holds still until real progress catches up.
382 */
383export function shownProgress(a: Agent, now: number, h: History): number {
384  return Math.max(a.shownShare ?? 0, progress(a, now, h))
385}
386
387/** The estimate column: the time left (re-estimated when an agent runs long), or the outcome. */
388export function estimateText(a: Agent, now: number, h: History): { main: string; extra: string } {
389  const elapsed = (a.endedAt ?? now) - a.startedAt
390  if (a.status === 'done') return { main: `took ${formatDuration(elapsed)}`, extra: '' }
391  if (a.status === 'failed') return { main: 'failed', extra: '' }
392  if (a.status === 'cancelled') return { main: 'cancelled', extra: '' }
393  const r = remaining(a, now, h)
394  const e = expectation(a, h)
395  const extra =
396    r.basis === 'steps' ? 'from steps'
397    : r.revised ? 're-estimated'
398    : !e.learned ? 'first estimate'
399    : e.low !== undefined && e.high !== undefined ? `usually ${formatDuration(e.low)}–${formatDuration(e.high)}`
400    : ''
401  return { main: `~${formatDuration(r.ms)} left`, extra }
402}
403
404/** For the header: the longest time left among running agents, and the crew's share of work done. */
405export function crewSummary(list: readonly Agent[], now: number, h: History): { leftMs: number; share: number } {
406  let total = 0
407  let done = 0
408  let leftMs = 0
409  for (const a of list.filter(x => x.status === 'running' || x.status === 'done')) {
410    const elapsed = Math.max(0, (a.endedAt ?? now) - a.startedAt)
411    const left = a.status === 'running' ? remaining(a, now, h).ms : 0
412    const share = a.status === 'done' ? 1 : shownProgress(a, now, h)
413    done += share * (elapsed + left)
414    total += elapsed + left
415    leftMs = Math.max(leftMs, left)
416  }
417  return { leftMs, share: total ? done / total : 0 }
418}
419
420/** A running agent with no activity for this long is treated as gone (its end was never reported). */
421export const STALE_MS = 15 * 60_000
422
423/** Past the cap, finished agents leave the stage first; a running one is never dropped. */
424export function capAgents(list: Agent[], max = 12): Agent[] {
425  const extra = list.length - max
426  if (extra <= 0) return list
427  const drop = new Set(list.filter(a => a.status !== 'running').slice(0, extra).map(a => a.id))
428  return list.filter(a => !drop.has(a.id))
429}
430
431// ── Row layout ───────────────────────────────────────────────
432// Column widths come from the surface's real width, so a row never runs past the right edge.
433// Fixed columns take their room first; the task and the "doing" text share what is left.
434export type Layout = { type: number; task: number; doing: number; pct: number; eta: number; tokens: number; barCells: number; showTokens: boolean; model: number }
435
436/** Cells a row spends besides its columns: the sprite, the gaps between columns, the arrow button and its margin. */
437export const FIXED_EXTRAS = 6 + 9 + 4 + 1
438
439/**
440 * The desktop reports its width in code-font cells but lays boxes out a little wider, so a
441 * desktop row plans with 94% of the reported width (bodyColumns already leaves out the
442 * engine's marks and a docked pane). The terminal's cells are exact.
443 */
444const DESKTOP_BUDGET = 0.94
445
446export function layout(columns: number, desktop = true, showCost = false): Layout {
447  // The terminal's dot and its space take two cells where the desktop sprite takes six, and its
448  // arrow carries a hotkey ("a: ▸"), three cells more
449  const budget = Math.floor(columns * (desktop ? DESKTOP_BUDGET : 1)) + (desktop ? 0 : 6 - 2 - 3)
450  // With costs on, the token column also holds "≈$0.04"
451  // The type column also holds the model: under the type on the desktop ("Sonnet 5.5" needs ten
452  // cells), beside it on the terminal when there is room
453  const base = { type: desktop ? 9 : 8, pct: 5, eta: 11, tokens: showCost ? 17 : 10 }
454  let barCells = 14
455  let showTokens = true
456  const flexible = () => budget - FIXED_EXTRAS - base.type - base.pct - base.eta - barCells - (showTokens ? base.tokens : 0)
457  // Too narrow: first a shorter bar, then shorter text columns; the token column is the last
458  // thing to go (it moves into the details panel only on very narrow surfaces)
459  if (flexible() < 30) barCells = 8
460  if (flexible() < 18) showTokens = false
461  const model = !desktop && flexible() >= 46 ? 7 : 0
462  const room = Math.max(18, flexible() - model)
463  const task = Math.min(36, Math.max(8, Math.round(room * 0.5)))
464  const doing = Math.min(46, Math.max(10, room - task))
465  return { ...base, task, doing, barCells, showTokens, model }
466}
467
468// ── Step tracking: the agent's own to-do list ─────────────────
469type TodoInput = { content?: string; activeForm?: string; status?: string }
470const stepStatus = (s: unknown): Step['status'] => (s === 'completed' || s === 'in_progress' ? s : 'pending')
471
472/** TodoWrite sends the whole list: steps are rebuilt from it. */
473export const MAX_STEPS = 50
474export function stepsFromTodos(todos: unknown): Step[] {
475  if (!Array.isArray(todos)) return []
476  return (todos as TodoInput[]).slice(0, MAX_STEPS).map((t, i) => ({
477    id: `t${i}`,
478    label: clip((t.status === 'in_progress' ? t.activeForm : t.content) || t.content || '', 120),
479    status: stepStatus(t.status),
480  }))
481}
482
483export function withStepCreated(steps: readonly Step[], id: string, label: string): Step[] {
484  return [...steps.filter(s => s.id !== id), { id, label: clip(label, 120), status: 'pending' as const }].slice(-MAX_STEPS)
485}
486
487export function withStepUpdated(steps: readonly Step[], u: { taskId?: unknown; status?: unknown; activeForm?: unknown; subject?: unknown }): Step[] {
488  if (u.status === 'deleted') return steps.filter(s => s.id !== u.taskId)
489  return steps.map(s =>
490    s.id !== u.taskId
491      ? s
492      : {
493          ...s,
494          status: u.status !== undefined ? stepStatus(u.status) : s.status,
495          label: (u.status === 'in_progress' && typeof u.activeForm === 'string' ? u.activeForm : typeof u.subject === 'string' ? u.subject : s.label) || s.label,
496        },
497  )
498}
499
500/** With a list of at least two steps: total, done and the current step. */
501export function stepSummary(a: Agent): { total: number; done: number; current?: string } | null {
502  const l = a.steps ?? []
503  if (l.length < 2) return null
504  return { total: l.length, done: l.filter(s => s.status === 'completed').length, current: l.find(s => s.status === 'in_progress')?.label }
505}
506
507// ── Calm transitions ─────────────────────────────────────────
508// The short thinking pauses between tools don't flicker the accessory: "thinking" shows
509// only once it has lasted 1.5 s.
510const CALM_MS = 1500
511
512export function withActivity(a: Agent, activity: Activity, target: string, now: number): Agent {
513  if (a.activity === activity && a.target === target) return a
514  return { ...a, activity, target, activityAt: now, prevActivity: a.activity, prevTarget: a.target }
515}
516
517export function shownActivity(a: Agent, now: number): { activity: Activity; target: string } {
518  if (a.activity === 'thinking' && a.prevActivity && a.prevActivity !== 'thinking' && now - a.activityAt < CALM_MS) {
519    return { activity: a.prevActivity, target: a.prevTarget ?? '' }
520  }
521  return { activity: a.activity, target: a.target }
522}
523
524/** A tool call running this long gets its time shown next to it. */
525export const SLOW_TOOL_MS = 30_000
526/** An agent with no event for this long is marked quiet. */
527export const QUIET_MS = 120_000
528
529/**
530 * The row's "doing" column: an approval the agent waits on comes first; then a step when there
531 * is a list, otherwise the activity and its target. A slow tool call shows its time, and a long
532 * silence says so: health signals that don't depend on the time estimate.
533 * `alert` asks the row to draw the main part in the warning color.
534 */
535export function doingText(a: Agent, now: number, runningHelpers: number): { main: string; extra: string; alert?: boolean } {
536  if (a.status !== 'running') return { main: statusLabel(a), extra: '' }
537  if (a.waitingFor) return { main: 'Needs approval', extra: a.waitingFor, alert: true }
538  const steps = stepSummary(a)
539  const shown = shownActivity(a, now)
540  const helpers = runningHelpers > 0 ? `waiting on ${runningHelpers} helper${runningHelpers > 1 ? 's' : ''}` : ''
541  const quietFor = now - a.seenAt
542  if (!helpers && quietFor > QUIET_MS) return { main: 'Quiet', extra: `no activity for ${formatDuration(quietFor)}`, alert: true }
543  const busyFor = now - a.activityAt
544  const slow = shown.activity !== 'thinking' && shown.activity !== 'starting' && busyFor > SLOW_TOOL_MS ? `${formatDuration(busyFor)} · ` : ''
545  if (steps) return { main: `Step ${Math.min(steps.total, steps.done + 1)}/${steps.total}`, extra: helpers || steps.current || ACTIVITY_LABEL[shown.activity] }
546  if (helpers) return { main: 'Waiting', extra: helpers.replace('waiting ', '') }
547  return { main: ACTIVITY_LABEL[shown.activity], extra: `${slow}${shown.target}` }
548}
549
550/** The header sentence: the crew's state at a glance. */
551export function headline(list: readonly Agent[], now: number): string {
552  const running = list.filter(a => a.status === 'running')
553  const isHelper = (a: Agent) => !!a.parentId && list.some(b => b.id === a.parentId)
554  const helpers = running.filter(isHelper).length
555  if (running.length === 0) {
556    const trouble = list.filter(a => a.status === 'failed' || a.status === 'cancelled').length
557    return trouble ? `Finished, ${trouble} didn't complete` : 'All done'
558  }
559  const top = running.filter(a => !isHelper(a))
560  const helperText = helpers ? `, ${helpers} helper${helpers > 1 ? 's' : ''} working` : ''
561  const approvals = running.filter(a => a.waitingFor).length
562  if (approvals) return `${approvals} agent${approvals > 1 ? 's need' : ' needs'} your approval`
563  if (top.length === 1) {
564    const a = top[0]
565    const d = doingText(a, now, 0)
566    return `${a.description}: ${d.main.toLowerCase()}${helperText}`
567  }
568  return `${top.length} agents working${helperText}`
569}
570
571/** The one-line summary for Claude Code's status line, shown while the crew is hidden. */
572export function statusText(list: readonly Agent[], now: number, h: History, cost: number | null): string | undefined {
573  if (list.length === 0) return undefined
574  const running = list.filter(a => a.status === 'running')
575  const approvals = running.filter(a => a.waitingFor).length
576  const parts = [running.length ? `${running.length} working` : `${list.length} done`]
577  if (approvals) parts.push(`${approvals} need${approvals > 1 ? '' : 's'} approval`)
578  if (running.length) parts.push(`~${formatDuration(crewSummary(list, now, h).leftMs)} left`)
579  if (cost !== null) parts.push(formatCost(cost))
580  return `Agent Crew: ${parts.join(' · ')}`
581}
582
583/** Deeper helpers are drawn at this depth: the indent stops growing, the rows stay. */
584export const MAX_DEPTH = 4
585
586/**
587 * Rows in order: each agent directly followed by its helpers, with their depth. Every agent
588 * gets exactly one row: a parent cycle or a missing parent makes it a root instead of hiding it.
589 */
590export function tree(list: readonly Agent[]): { a: Agent; depth: number }[] {
591  const ids = new Set(list.map(a => a.id))
592  const seen = new Set<string>()
593  const out: { a: Agent; depth: number }[] = []
594  const walk = (parent: string | undefined, depth: number) => {
595    for (const a of list) {
596      if (seen.has(a.id)) continue
597      const isRoot = !a.parentId || !ids.has(a.parentId)
598      if ((parent === undefined && isRoot) || (parent !== undefined && a.parentId === parent)) {
599        seen.add(a.id)
600        out.push({ a, depth: Math.min(depth, MAX_DEPTH) })
601        walk(a.id, depth + 1)
602      }
603    }
604  }
605  walk(undefined, 0)
606  // Agents caught in a parent cycle were never reached: show them as roots
607  for (const a of list) {
608    if (seen.has(a.id)) continue
609    seen.add(a.id)
610    out.push({ a, depth: 0 })
611    walk(a.id, 1)
612  }
613  return out
614}
615
616// ── Pixel drawing core ───────────────────────────────────────
617const px = (u: number, x: number, y: number, w: number, h: number, fill: string) =>
618  `<rect x="${x * u}" y="${y * u}" width="${w * u}" height="${h * u}" fill="${fill}"/>`
619
620/** Pixels from a row map like "..##..": each letter maps to a color. */
621function pixels(u: number, ox: number, oy: number, rows: string[], colors: Record<string, string>): string {
622  let out = ''
623  rows.forEach((row, y) => {
624    for (let x = 0; x < row.length; x++) {
625      const c = colors[row[x]]
626      if (c) out += px(u, ox + x, oy + y, 1, 1, c)
627    }
628  })
629  return out
630}
631
632// The sources don't depend on time: a crew member in the same state yields the same SVG
633// every second, so the surface doesn't reload the image and the animation keeps running.
634// Each SVG carries only the animations its own elements use (see styleFor).
635const KEYFRAMES: Record<string, string> = {
636  hop: '0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}',
637  cheer: '0%,100%{transform:translateY(0)}50%{transform:translateY(-3px)}',
638  on: '0%,49.9%{opacity:1}50%,100%{opacity:0}',
639  off: '0%,49.9%{opacity:0}50%,100%{opacity:1}',
640  blink: '0%,92%{opacity:1}92.1%,97%{opacity:0}97.1%,100%{opacity:1}',
641  blinkShut: '0%,92%{opacity:0}92.1%,97%{opacity:1}97.1%,100%{opacity:0}',
642  scan: '0%,100%{transform:translateX(0)}50%{transform:translateX(-4px)}',
643  l1: '0%,15%{opacity:0}15.1%,100%{opacity:1}',
644  l2: '0%,40%{opacity:0}40.1%,100%{opacity:1}',
645  l3: '0%,65%{opacity:0}65.1%,100%{opacity:1}',
646  read: '0%{transform:translateY(0)}33%{transform:translateY(4px)}66%{transform:translateY(8px)}100%{transform:translateY(0)}',
647  spin: '0%,49.9%{transform:translateX(0)}50%,100%{transform:translateX(2px)}',
648  stars: '0%,33%{transform:translateX(0)}33.1%,66%{transform:translateX(6px)}66.1%,100%{transform:translateX(12px)}',
649  zzz: '0%{transform:translate(0,0);opacity:0}20%{opacity:1}100%{transform:translate(4px,-6px);opacity:0}',
650}
651/** class → [keyframes name, animation rule]. */
652const CLASSES: Record<string, [string, string]> = {
653  hop: ['hop', 'hop .7s steps(1,end) infinite'],
654  cheer: ['cheer', 'cheer .6s steps(1,end) infinite'],
655  legA: ['on', 'on .5s steps(1,end) infinite'],
656  legB: ['off', 'off .5s steps(1,end) infinite'],
657  eye: ['blink', 'blink 3.2s steps(1,end) infinite'],
658  eyeShut: ['blinkShut', 'blinkShut 3.2s steps(1,end) infinite'],
659  scan: ['scan', 'scan 1.2s steps(1,end) infinite'],
660  cursor: ['on', 'on .8s steps(1,end) infinite'],
661  l1: ['l1', 'l1 2.4s steps(1,end) infinite'],
662  l2: ['l2', 'l2 2.4s steps(1,end) infinite'],
663  l3: ['l3', 'l3 2.4s steps(1,end) infinite'],
664  read: ['read', 'read 1.8s steps(1,end) infinite'],
665  sparkA: ['on', 'on 1.4s steps(1,end) infinite'],
666  sparkB: ['off', 'off 1.4s steps(1,end) infinite'],
667  spin: ['spin', 'spin 1.6s steps(1,end) infinite'],
668  stars: ['stars', 'stars 1s steps(1,end) infinite'],
669  zzz: ['zzz', 'zzz 2.4s linear infinite'],
670  zzz2: ['zzz', 'zzz 2.4s linear 1.2s infinite;opacity:0'],
671  waveA: ['on', 'on .7s steps(1,end) infinite'],
672  waveB: ['off', 'off .7s steps(1,end) infinite'],
673  seg: ['on', 'on .9s steps(1,end) infinite'],
674}
675
676/** The <style> for an SVG body: crisp pixels plus exactly the animations its classes use. */
677function styleFor(body: string): string {
678  const used = new Set<string>()
679  for (const m of body.matchAll(/class="(\w+)"/g)) used.add(m[1])
680  const frames = new Set<string>()
681  let css = 'rect{shape-rendering:crispEdges}'
682  for (const c of used) {
683    const def = CLASSES[c]
684    if (!def) continue
685    frames.add(def[0])
686    css += `.${c}{animation:${def[1]}}`
687  }
688  for (const f of frames) css += `@keyframes ${f}{${KEYFRAMES[f]}}`
689  return `<style>${css}</style>`
690}
691
692/** Small memo for SVG sources: the same state always draws the same string. */
693function memo<K>(cache: Map<K, string>, key: K, make: () => string): string {
694  let v = cache.get(key)
695  if (v === undefined) {
696    v = make()
697    if (cache.size > 256) cache.clear()
698    cache.set(key, v)
699  }
700  return v
701}
702
703// ── Crew member (Clawd) ──────────────────────────────────────
704// Stage: 22×18 cells. Body x3..12 (10×7), y5..11; claws on the sides; four thin legs.
705// Accessories sit on the right (x14..21) or above the head.
706const U = 2
707
708/** How a thinking crew member holds itself in a scene's frame. */
709type Pose = { lookUp?: boolean }
710
711function creature(a: Agent, pose: Pose = {}): string {
712  const u = U
713  const running = a.status === 'running'
714  const done = a.status === 'done'
715  const body = a.status === 'cancelled' ? C.bodyAsleep : C.body
716
717  let s = px(u, 3, 14, 10, 1, '#00000018')
718  const leg = (x: number, h: number) => px(u, x, 12, 1, h, body)
719  const still = leg(4, 2) + leg(6, 2) + leg(9, 2) + leg(11, 2)
720  const stride = leg(4, 1) + leg(6, 2) + leg(9, 1) + leg(11, 2)
721  s += running ? `<g class="legA">${still}</g><g class="legB">${stride}</g>` : still
722
723  // Body and claws; when done, the right claw is up holding the flag
724  let torso = px(u, 3, 5, 10, 7, body) + px(u, 2, 7, 1, 2, body)
725  if (done) torso += px(u, 13, 3, 1, 3, body)
726  else torso += px(u, 13, 7, 1, 2, body)
727
728  let face = ''
729  if (done) {
730    face = pixels(u, 4, 7, ['.#....#.', '#.#..#.#'], { '#': C.eye })
731  } else if (a.status === 'failed') {
732    face = pixels(u, 4, 6, ['#.#..#.#', '.#....#.', '#.#..#.#'], { '#': C.eye })
733  } else if (a.status === 'cancelled') {
734    face = px(u, 4, 8, 2, 1, C.eye) + px(u, 9, 8, 2, 1, C.eye) // asleep: - -
735  } else {
736    const side = a.activity !== 'thinking' && a.activity !== 'starting' && !a.waitingFor ? 1 : 0
737    const ey = a.activity === 'thinking' || a.waitingFor || pose.lookUp ? 6 : 7
738    const open = px(u, 5 + side, ey, 1, 2, C.eye) + px(u, 10 + side, ey, 1, 2, C.eye)
739    const shut = px(u, 5 + side, ey + 1, 1, 1, C.eye) + px(u, 10 + side, ey + 1, 1, 1, C.eye)
740    face = `<g class="eye">${open}</g><g class="eyeShut">${shut}</g>`
741  }
742
743  const hat =
744    a.activity === 'starting' && running
745      ? pixels(u, 3, 2, ['..######..', '.########.', '##########'], { '#': C.yellow }) + px(u, 4, 4, 8, 1, C.yellowDark) + px(u, 5, 2, 1, 1, C.white)
746      : ''
747
748  const motion = running ? ' class="hop"' : done ? ' class="cheer"' : ''
749  return s + `<g${motion}>${torso}${face}${hat}</g>`
750}
751
752// ── Thinking scenes ──────────────────────────────────────────
753// Thinking is the most common state, so it isn't always the hourglass: a crew member keeps
754// taking turns between the hourglass, a thought cloud and a lightbulb, each three frames long.
755export const THINKING_SCENES = ['hourglass', 'cloud', 'idea'] as const
756export type ThinkingScene = (typeof THINKING_SCENES)[number]
757/** How long one scene plays before the next: one loop of its three frames, so they keep taking turns. */
758export const SCENE_MS = 3000
759
760/** One frame of a scene lasts a second. */
761export const FRAME_MS = 1000
762
763/**
764 * The scene a thinking crew member plays now, and which of its three frames. Each agent starts
765 * at its own scene, so a crew isn't in step. The frame comes from the clock, not from a CSS
766 * animation: the band redraws every second, and a redraw restarts an SVG's animations, so a
767 * three-second CSS sequence would never get past its first frame.
768 */
769export function thinkingScene(a: Pick<Agent, 'id' | 'activityAt'>, now: number): ThinkingScene {
770  return THINKING_SCENES[(seedOf(a.id) + Math.floor(sinceThinking(a, now) / SCENE_MS)) % THINKING_SCENES.length]
771}
772export function sceneFrame(a: Pick<Agent, 'activityAt'>, now: number): 0 | 1 | 2 {
773  return (Math.floor(sinceThinking(a, now) / FRAME_MS) % 3) as 0 | 1 | 2
774}
775const sinceThinking = (a: Pick<Agent, 'activityAt'>, now: number) => Math.max(0, now - (a.activityAt ?? 0))
776function seedOf(id: string | undefined): number {
777  let seed = 0
778  for (const ch of id ?? '') seed = (seed * 31 + ch.charCodeAt(0)) >>> 0
779  return seed
780}
781
782const framed = (body: string, transform = '') => (transform ? `<g transform="${transform}">${body}</g>` : body)
783const dots = (pts: [number, number][], color: string = C.white) => pts.map(([x, y]) => px(U, x, y, 1, 1, color)).join('')
784
785/** One frame of a scene, the crew member drawn in it. */
786function sceneSvg(a: Agent, scene: ThinkingScene, n: 0 | 1 | 2): string {
787  const u = U
788  switch (scene) {
789    case 'cloud': {
790      // A thought cloud grows over the head and a question mark appears in it
791      const rising = dots([[14, 3], [16, 2]])
792      const cloud = pixels(u, 16, -2, ['.####.', '######', '.####.'], { '#': C.white })
793      const big = pixels(u, 14, -2, ['..#####..', '.#######.', '#########', '.#######.', '..#####..'], { '#': C.white })
794      const mark = pixels(u, 17, -2, ['.##.', '...#', '..#.', '....', '..#.'], { '#': C.yellowDark })
795      return [creature(a, { lookUp: true }) + rising, creature(a, { lookUp: true }) + rising + cloud, creature(a, { lookUp: true }) + px(u, 13, 4, 1, 1, C.white) + big + mark][n]
796    }
797    case 'idea': {
798      // A bulb appears over the head, lights up, and the crew member hops
799      const outline = pixels(u, 6, -1, ['.##.', '#..#', '#..#', '.##.', '.##.'], { '#': C.white })
800      const lit = pixels(u, 6, -1, ['.##.', '####', '####', '.##.'], { '#': C.yellow }) + px(u, 7, 3, 2, 1, C.greyDark)
801      const rays = dots([[4, 0], [11, 0], [3, 2], [12, 2]], C.yellow)
802      return [creature(a), creature(a, { lookUp: true }) + outline, framed(creature(a, { lookUp: true }) + lit + rays, 'translate(0 -2)')][n]
803    }
804    default:
805      return accessory(a) + creature(a)
806  }
807}
808
809/** The checkered finish flag, held up by the right claw, waving in two frames. */
810function finishFlag(): string {
811  const u = U
812  const pole = px(u, 13, -2, 1, 6, C.ink)
813  const check = (shift: number) =>
814    pixels(u, 14, -2 + shift, ['#.#.#.', '.#.#.#', '#.#.#.'], { '#': C.ink, '.': C.white })
815  return `<g class="cheer">${pole}<g class="waveA">${check(0)}</g><g class="waveB">${check(1)}</g></g>`
816}
817
818function accessory(a: Agent, flipped = false): string {
819  const u = U
820  if (a.status === 'done') return finishFlag()
821  if (a.waitingFor && a.status === 'running') {
822    // A yellow question bubble over the head: it waits on the person
823    const bubble = pixels(u, 14, 0, ['.#####.', '#######', '###.###', '####.##', '###.###', '#######', '.#####.', '..#....'], { '#': C.yellow, '.': '' })
824    const mark = pixels(u, 14, 0, ['.......', '..###..', '.#...#.', '....#..', '...#...', '.......', '...#...'], { '#': C.ink })
825    return `<g class="sparkA">${bubble}${mark}</g><g class="sparkB">${bubble}${mark}</g>`
826  }
827  if (a.status === 'failed') {
828    const stars = `<g class="stars">${px(u, 3, 2, 1, 1, C.yellow)}${px(u, 6, 1, 1, 1, C.yellowDark)}</g>`
829    return stars + px(u, 16, 3, 5, 6, C.red) + px(u, 18, 4, 1, 2, C.white) + px(u, 18, 7, 1, 1, C.white) + px(u, 18, 9, 1, 3, C.greyDark)
830  }
831  if (a.status === 'cancelled') {
832    const z = (x: number, y: number) => pixels(u, x, y, ['###', '.#.', '###'], { '#': C.grey })
833    return `<g class="zzz">${z(14, 2)}</g><g class="zzz2">${z(17, 0)}</g>`
834  }
835  switch (a.activity) {
836    case 'searching':
837      return `<g class="scan">${pixels(u, 16, 3, ['.###.', '#ooo#', '#ooo#', '#ooo#', '.###.'], { '#': C.blueDark, o: C.blueLight })}${px(u, 17, 4, 1, 1, C.white)}${px(u, 15, 8, 1, 1, C.brown)}${px(u, 14, 9, 1, 1, C.brown)}</g>`
838    case 'reading':
839      return px(u, 16, 2, 5, 9, C.white) + px(u, 16, 2, 5, 1, C.paperEdge) + px(u, 20, 2, 1, 9, C.paperEdge) +
840        px(u, 17, 4, 3, 1, C.paperEdge) + px(u, 17, 6, 2, 1, C.paperEdge) + px(u, 17, 8, 3, 1, C.paperEdge) +
841        `<g class="read">${px(u, 17, 4, 3, 1, C.yellow)}</g>`
842    case 'writing':
843      return px(u, 15, 1, 7, 10, C.blueDark) + px(u, 15, 1, 7, 1, C.blue) + px(u, 16, 1, 1, 1, C.pink) + px(u, 18, 1, 1, 1, C.pink) +
844        `<g class="l1">${px(u, 16, 3, 4, 1, C.code)}</g><g class="l2">${px(u, 17, 5, 4, 1, C.code)}</g><g class="l3">${px(u, 17, 7, 2, 1, C.code)}</g>` +
845        `<g class="cursor">${px(u, 20, 7, 1, 1, C.white)}</g>` + px(u, 16, 9, 1, 1, C.code) + px(u, 20, 9, 1, 1, C.code)
846    case 'running':
847      return px(u, 15, 2, 7, 7, C.screenEdge) + px(u, 16, 3, 5, 5, C.screen) +
848        px(u, 16, 4, 1, 1, C.code) + px(u, 17, 5, 1, 1, C.code) + px(u, 16, 6, 1, 1, C.code) +
849        `<g class="l2">${px(u, 18, 4, 2, 1, '#2C7A50')}</g><g class="cursor">${px(u, 18, 6, 2, 1, C.code)}</g>` +
850        px(u, 14, 9, 9, 1, C.grey) + px(u, 15, 10, 7, 1, C.greyDark)
851    case 'web':
852      return pixels(u, 16, 3, ['.###.', '#####', '#####', '#####', '.###.'], { '#': C.blue }) +
853        `<g class="spin">${px(u, 16, 4, 2, 1, C.code)}${px(u, 17, 5, 1, 2, C.code)}${px(u, 19, 6, 1, 1, C.code)}</g>` +
854        px(u, 15, 8, 7, 1, C.blueLight)
855    case 'thinking':
856      // Turned over every second by the clock (a redraw would restart a CSS flip before it shows)
857      return `<g${flipped ? ' transform="rotate(180 37 11)"' : ''}>${pixels(u, 16, 2, ['#####', 'coooc', '.coc.', '..o..', '.c.c.', 'coooc', '#####'], { '#': C.blue, o: C.yellow, c: C.blueLight })}</g>` +
858        `<g class="sparkA">${px(u, 15, 1, 1, 1, C.yellow)}${px(u, 21, 5, 1, 1, C.yellow)}</g><g class="sparkB">${px(u, 21, 1, 1, 1, C.yellow)}${px(u, 15, 7, 1, 1, C.yellow)}</g>`
859    case 'starting':
860      return pixels(u, 16, 3, ['#.#', '###', '.#.', '.#.', '.#.', '.#.'], { '#': C.greyDark })
861  }
862  return ''
863}
864
865/** A row's sprite: the crew member with its accessory, 44×34 px, transparent background. */
866export const SPRITE_W = 44
867export const SPRITE_H = 34
868/** The sprite's width in character cells, rounded up: for indenting text under it. */
869export const SPRITE_CELLS = 6
870
871const spriteCache = new Map<string, string>()
872
873/**
874 * The sprite follows the calm-transition rule like the text does: a short thinking pause
875 * keeps the previous accessory. The source depends on the shown state only, not on time,
876 * and is cached per state.
877 */
878export function spriteSvg(a: Agent, now: number): string {
879  const activity = a.status === 'running' ? shownActivity(a, now).activity : a.activity
880  const waiting = a.status === 'running' && !!a.waitingFor
881  const scene: ThinkingScene = a.status === 'running' && activity === 'thinking' && !waiting ? thinkingScene(a, now) : 'hourglass'
882  const thinking = a.status === 'running' && activity === 'thinking' && !waiting
883  const n = scene !== 'hourglass' ? sceneFrame(a, now) : thinking ? sceneFrame(a, now) % 2 : 0
884  return memo(spriteCache, `${a.status}|${activity}|${waiting}|${scene}|${n}`, () => {
885    const shown = { ...a, activity, waitingFor: waiting ? a.waitingFor : undefined }
886    const body = scene === 'hourglass' ? accessory(shown, n === 1) + creature(shown) : sceneSvg(shown, scene, n)
887    return `<svg xmlns="http://www.w3.org/2000/svg" width="${SPRITE_W}" height="${SPRITE_H}" viewBox="0 -4 44 34">${styleFor(body)}${body}</svg>`
888  })
889}
890
891/** The header's small crew member: running or done (with the flag). */
892export function headerSvg(done: boolean): string {
893  const a = { status: done ? 'done' : 'running', activity: 'thinking' } as Agent
894  return spriteSvg(a, 0)
895}
896
897// ── Type label (desktop) ─────────────────────────────────────
898// The type and the model, drawn small as an SVG: the desktop's Text has one size, and at that
899// size two lines of label crowd the row.
900export const LABEL_W = 70
901export const LABEL_H = 30
902const labelCache = new Map<string, string>()
903const escXml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
904
905export function labelSvg(type: string, model: string): string {
906  const name = typeLabel(type)
907  const sub = modelLabel(model)
908  return memo(labelCache, `${name}|${sub}`, () => {
909    const font = 'font-family="ui-sans-serif,system-ui,-apple-system,sans-serif"'
910    const top = sub ? 13 : 19
911    return `<svg xmlns="http://www.w3.org/2000/svg" width="${LABEL_W}" height="${LABEL_H}" viewBox="0 0 ${LABEL_W} ${LABEL_H}" ${font}>` +
912      `<text x="0" y="${top}" font-size="10.5" font-weight="700" letter-spacing=".4" fill="${typeColor(type)}">${escXml(name)}</text>` +
913      (sub ? `<text x="0" y="25" font-size="9.5" fill="${C.grey}">${escXml(sub)}</text>` : '') +
914      `</svg>`
915  })
916}
917
918const barCache = new Map<string, string>()
919
920/**
921 * The segmented pixel progress bar. While running, the next segment blinks, and the bar is
922 * never drawn full: the segments round down and stop one short of the end.
923 */
924export function barSvg(share: number, color: string, running: boolean, width: number, segments = 14, height = 7): string {
925  const filled = running ? Math.min(segments - 1, Math.floor(share * segments)) : Math.min(segments, Math.round(share * segments))
926  return memo(barCache, `${filled}|${color}|${running}|${width}|${segments}|${height}`, () => {
927    const segW = (width - (segments - 1) * 2) / segments
928    let body = ''
929    for (let i = 0; i < segments; i++) {
930      const x = (i * (segW + 2)).toFixed(1)
931      const next = running && i === filled
932      body += `<rect${next ? ' class="seg"' : ''} x="${x}" y="0" width="${segW.toFixed(1)}" height="${height}" fill="${i < filled || next ? color : C.track}"/>`
933    }
934    return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${styleFor(body)}${body}</svg>`
935  })
936}
937
938// ── Terminal ─────────────────────────────────────────────────
939export const textBar = (share: number, width = 16, running = false) =>
940  '█'.repeat(running ? Math.min(width - 1, Math.floor(share * width)) : Math.round(share * width)).padEnd(width, '░')
941// ── Terminal marker ──────────────────────────────────────────
942// The terminal draws no critter: each row starts with a dot whose color carries the state.
943export const DOT = '●'
944
945/** The terminal row's dot color: working, waiting on you, done, failed or cancelled. */
946export function dotColor(a: Pick<Agent, 'status' | 'waitingFor'>): string {
947  if (a.status === 'running') return a.waitingFor ? C.yellow : C.body
948  return a.status === 'done' ? C.green : a.status === 'failed' ? C.red : C.grey
949}
950
951
types/index.d.ts 77 lines
1export type AgentStatus = 'running' | 'done' | 'failed' | 'cancelled'
2
3/** What an agent is doing right now: picks the accessory its crew member holds. */
4export type Activity = 'starting' | 'thinking' | 'searching' | 'reading' | 'writing' | 'running' | 'web'
5
6/** One step of the agent's own to-do list (TodoWrite / TaskCreate). */
7export type Step = { id: string; label: string; status: 'pending' | 'in_progress' | 'completed' }
8
9/** Token counts as the API reports them, summed over the agent's requests. */
10export type Tokens = { input: number; output: number; cacheWrite: number; cacheRead: number }
11
12/** One recent tool call, for the details panel. */
13export type RecentTool = { label: string; failed?: boolean }
14
15/** A file an agent read or changed, for the details panel. */
16export type TouchedFile = { path: string; changed?: true }
17
18export type Agent = {
19  id: string
20  /** The subagent type: Explore, Plan, general-purpose, a plugin's agent. */
21  type: string
22  description: string
23  model: string
24  status: AgentStatus
25  activity: Activity
26  /** What the activity is about: the pattern searched, the file read, the command run. */
27  target: string
28  /** When the activity last changed, and what it was before: short thinking pauses don't flicker. */
29  activityAt: number
30  /** The last time any event of this agent arrived: a sign of life for spotting agents that vanished. */
31  seenAt: number
32  prevActivity?: Activity
33  prevTarget?: string
34  /** The agent's own to-do list, when it keeps one: real step progress. */
35  steps?: Step[]
36  startedAt: number
37  endedAt?: number
38  tokens: Tokens
39  tools: number
40  /** The tool an agent is waiting on the person's approval for; absent when it isn't waiting. */
41  waitingFor?: string
42  /** The last few tool calls, newest last: shown in the details panel. */
43  recent?: RecentTool[]
44  /** The files it read or changed, newest last; a file changed once stays changed. */
45  files?: TouchedFile[]
46  /** Closed by the mod (the engine reported it ended, or it vanished), not by its own turn.complete. */
47  retired?: true
48  /** The highest progress shown so far: a revised estimate never moves the bar backwards. */
49  shownShare?: number
50  /** Model requests made (turn.step): a progress clock independent of time. */
51  requests: number
52  /** The subagent that started this one; absent for agents started by the main conversation. */
53  parentId?: string
54  demo?: boolean
55  /** Fixed duration for demo agents; real agents learn from history. */
56  expectedMs?: number
57  /** The main conversation's turn, on the title's own record: its events count only while it runs. */
58  turnId?: string
59}
60
61declare module 'claude-code' {
62  interface PluginState {
63    'agent-crew': {
64      agents: Agent[]
65      /** Claude's own work in the main conversation, drawn on the title row; null between turns. */
66      main: Agent | null
67      hidden: boolean
68      expanded: string[]
69      collapsed: boolean
70      minimized: boolean
71      side: boolean
72      paneUp: boolean
73      foldFrame: number
74    }
75  }
76}
77