SLOPSHOPPER

agent-deck

A live side panel of the session's subagents (model, current activity, context, time) and a progress bar above the prompt. /subagents opens or closes the panel.

newpanebandguardcommandprompt
★ 4v0.3.0MITupdated 2026-10-23igorfduarte/claude-mods/plugins/agent-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-deck
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ Running 0/0 · ↑0 · 0:00 │ ┃ ⏺ Read(src/auth.ts) │ ┃ No subagents yet. They show up here as soon ⎿ Read 6 lines │ ┃ as they start. ⏺ 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 │ │ › /subagents │ ⎿ agent-deck: Agents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
Running 0/0 · ↑0 · 0:00 No subagents yet. They show up here as soon as they start.
README

claude-mods

Mods for Claude Code. Each one is a standalone plugin under plugins/.

ModWhat it does
context-handoffWatches the context window. Past a threshold, the agent writes a thorough handoff, the session compacts, and the handoff comes back with your next message, so long sessions keep their context.
agent-deckA live side panel of every subagent in the session (what it is doing, its steps, context and time) and a progress bar above the prompt for the subagents running now.

Install

At the prompt of a Claude Code terminal session, with the mod's name from the table:

/plugin install context-handoff --marketplace igorfduarte/claude-mods
/plugin install agent-deck --marketplace igorfduarte/claude-mods

Answer y to add the marketplace, pick a scope (user is the default), and set the options or keep the defaults.

From a shell:

claude plugin install agent-deck --marketplace igorfduarte/claude-mods

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with igorfduarte/claude-mods, then Add plugin.

Start a new session afterwards; sessions already open do not pick it up.

Mods are built on function hooks, an early-access Claude Code API: tested on Claude Code 2.1.308, and it may change between releases.

context-handoff

Long sessions fill the context window, and compaction alone keeps only a short summary. A common workaround is asking the agent, right before compacting, for a handoff to paste back afterwards. This mod does that for you.

How it works

  1. After each turn of the main session, the mod checks the context size. Past the threshold (300k tokens by default) it arms a handoff and shows a notice.
  2. With your next message, the mod writes a conversation inventory taken from the transcript, not from the model's memory: your messages verbatim, every file touched and with which tools, shell commands, searches, subagents launched and tool errors.
  3. In the same turn, before handling your message, the session's own agent goes through the whole conversation, uses the inventory as a checklist, carries over the previous handoff if there is one, records git status and git diff --stat, and writes a handoff with:
  4. goal and current state
  5. your instructions and preferences, quoted where the wording matters
  6. decisions, and approaches rejected and why
  7. each relevant file: role, what changed and why, state
  8. tools, commands and environment
  9. work log and next steps
  10. open questions, risks and errors
  11. files to re-read after compaction

Then it handles your message as usual and adds what it did to the handoff.

  1. After that turn the mod compacts the session. Where Claude Code does not let a mod compact directly (the desktop app and other SDK hosts), it runs /compact for you.
  2. Your following message carries the handoff and the inventory to the agent, unseen in the chat, and the agent re-reads the files it listed before answering.

/context-handoff arms a cycle on demand, without waiting for the threshold.

The handoff is written by the session's own model on purpose: the conversation is already in its prompt cache, so reading it back costs little, and only that agent can re-read files and run git. A separate model would read the whole conversation again uncached.

Options

OptionDefaultWhat it sets
thresholdK300Context size, in thousands of tokens, that arms a handoff. Keep it below the auto-compact window, or Claude Code's own compaction comes first.
retentionDays2When a session starts, handoff folders of other sessions untouched for this many days are deleted. 0 keeps them forever.

Set them when installing, with /plugin configure context-handoff@claude-mods in a terminal session, or in ~/.claude/settings.json:

{ "pluginConfigs": { "context-handoff@claude-mods": { "options": { "thresholdK": 350, "retentionDays": 7 } } } }

Files

Each session gets a folder ~/.claude/handoffs/<session-id>/ with handoff-N.md and inventory-N.md per cycle. They stay on your machine. They hold your messages verbatim and what the agent wrote about your work, so treat them like the transcript itself.

Limits

  • The mod acts at the end of a turn. If Claude Code's auto-compaction fires first, in the middle of a very long turn, the mod does not intercept it: keep the threshold well below the auto-compact window.
  • The handoff rides on your next message rather than a turn of its own, because the desktop app does not run turns started by a mod. The cycle needs one more message from you to start.
  • If a cycle is interrupted or the handoff is not written, the mod does not retry by itself until the session compacts; /context-handoff arms it again.
  • If the session cannot compact by itself, a notice asks you to run /compact; the handoff still comes back with your next message.

Uninstall

claude plugin uninstall context-handoff@claude-mods

agent-deck

A live view of the subagents a session runs, in the background or not.

Inspired by savvy-progress by JohnnyVizz: its agents panel and progress bar, made for any subagent rather than one workflow.

Progress bar

Above the prompt, for the current batch (the subagents started while others still run): one segment per subagent. A segment fills as the subagent reports its steps, pulses while it works without reporting, and turns red if it failed. Beside it: how many are done and running, the percent and the elapsed time. ×N opens or closes the panel, and ✕ hides the bar until the next batch. Once every subagent has ended the bar reads All done, and it steps aside with your next message.

Agents panel

/subagents opens or closes it, and /subagents open only opens it. It also opens by itself when a new batch starts.

  • A header with the subagents running out of the total, output tokens and time.
  • One row per subagent: a pixel robot that walks while it works, smiles when done and turns red-eyed on an error; the task's description; its type, model and effort, and whether it runs in the background; what it does now, read off its latest tool calls (Grep · invalidateCache); its reported steps (2/5 · reading the tests); context size, output tokens and time.
  • Finished subagents collapse under their own heading and can be cleared.

In a terminal session both draw as text.

Step reports

To fill each subagent's bar, the mod registers a step tool and appends one paragraph to every subagent's brief, asking it to report its plan and its progress through that tool. That changes every subagent's prompt slightly and costs a few cheap tool calls. With stepReports off, briefs are left as they are and each bar sweeps while its subagent works.

Options

OptionDefaultWhat it sets
autoOpenonOpens the panel by itself when a new batch of subagents starts; off opens it only with /subagents.
stepReportsonAsks each subagent to report its steps, as above.

Set them when installing, with /plugin configure agent-deck@claude-mods in a terminal session, or in ~/.claude/settings.json:

{ "pluginConfigs": { "agent-deck@claude-mods": { "options": { "autoOpen": "off" } } } }

Limits

  • No cost estimate: the panel counts tokens, not dollars.
  • Context is shown in tokens (the latest request's prompt and output), not as a share of the window.
  • A subagent stopped from outside, whose end no event reports, is marked ended once Claude Code's own agent list no longer has it.

Uninstall

claude plugin uninstall agent-deck@claude-mods

License

MIT

Source 2 files
hooks/register.tsx 894 lines
1// agent-deck: a side panel of the session's subagents and a progress bar above
2// the prompt. Inspired by savvy-progress by JohnnyVizz (MIT):
3// https://github.com/JohnnyVizz/claude-kit/tree/main/plugins/savvy-progress
4import { atom, read, update } from 'claude-code'
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Deck, Run, RunStatus } from '../types'
8
9const runs = atom({ plugin: 'agent-deck', key: 'runs' } as const, [])
10const deck = atom({ plugin: 'agent-deck', key: 'deck' } as const, {
11  hiddenBatch: 0,
12  isDoneCollapsed: false,
13  autoOpenedBatch: 0,
14})
15const now = atom({ plugin: 'agent-deck', key: 'now' } as const, 0)
16
17const PANE = 'agent-deck'
18const STEP_TOOL = 'mcp__agent-deck__step'
19const MAX_RUNS = 200
20const MAX_NOTE = 80
21// A running row the engine's agent list no longer names, and that made no
22// request for this long, is taken as ended.
23const GONE_AFTER_MS = 120_000
24
25// Appended to each subagent's brief: a request in the brief is followed far
26// more reliably than one in the tool's description.
27const STEP_BRIEF = `
28
29---
30Progress reporting (only if the \`${STEP_TOOL}\` tool is available to you): before other work, call it with \`total\` (your plan in 3-8 steps) and \`done: 0\`; call it again with \`done\` and a few-word \`note\` each time a step finishes. It only draws a progress bar in the user's panel; it is not your answer.`
31
32// ---------------------------------------------------------------------------
33// Words
34
35
36const STRINGS = {
37  pane: 'Agents',
38  band: 'Subagents',
39  running: 'Running',
40  finished: 'Finished',
41  agents: 'Running / total',
42  output: 'Tokens out',
43  time: 'Time',
44  empty: 'No subagents yet. They show up here as soon as they start.',
45  clear: 'Clear finished',
46  background: 'background',
47  starting: 'starting…',
48  thinking: 'thinking…',
49  answering: 'writing its answer…',
50  done: 'done',
51  failed: 'failed',
52  stopped: 'stopped',
53  ready: (done: number, total: number) => `${done}/${total} done`,
54  busy: (n: number) => `${n} running`,
55  allDone: 'All done',
56  withErrors: (n: number) => `${n} failed`,
57  opened: 'Agents panel opened.',
58  closed: 'Agents panel closed.',
59  command: 'Show or hide the subagents panel (/subagents open only opens it)',
60} as const
61
62type Strings = typeof STRINGS
63
64// ---------------------------------------------------------------------------
65// Formatting
66
67const clip = (s: string, max: number): string => (s.length > max ? s.slice(0, Math.max(1, max - 1)) + '…' : s)
68const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
69const xml = (s: string): string =>
70  s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c)
71
72const fmtTokens = (n: number): string =>
73  n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(n < 1e4 ? 1 : 0)}k` : `${Math.round(n)}`
74
75const fmtTime = (ms: number): string => {
76  const s = Math.max(0, Math.round(ms / 1000))
77  const h = Math.floor(s / 3600)
78  const m = Math.floor((s % 3600) / 60)
79  const ss = String(s % 60).padStart(2, '0')
80  return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
81}
82
83// "claude-opus-5-5" → "Opus 5.5"; anything else shows as it came.
84const modelName = (id: string): string => {
85  const m = /(fable|mythos|opus|sonnet|haiku)(?:-(\d)(?!\d)(?:-(\d{1,2})(?!\d))?)?/i.exec(id)
86  if (!m) return id.replace(/^claude-/, '').replace(/\[.*\]$/, '') || '—'
87  const [, family = '', major, minor] = m
88  return `${family.charAt(0).toUpperCase()}${family.slice(1).toLowerCase()}${major ? ' ' + major : ''}${minor ? '.' + minor : ''}`
89}
90
91const modelLabel = (r: Run): string => (r.effort ? `${modelName(r.model)} · ${r.effort}` : modelName(r.model))
92
93// `general-purpose`, or `plugin:agent` shown as `agent`.
94const bareType = (type: string): string => type.replace(/^[^:]*:/, '')
95
96const KNOWN_COLOR: Record<string, string> = {
97  'general-purpose': '#7C6CF2',
98  Explore: '#16A37F',
99  Plan: '#E07B39',
100  fork: '#D4537E',
101  'claude-code-guide': '#2E9BD6',
102}
103// Neighbours differ most, and nothing reads as the failed red or the stopped grey.
104const PALETTE = ['#7C6CF2', '#E07B39', '#2E9BD6', '#D4537E', '#16A37F', '#C29A1C', '#A35BD6', '#3FA8A0', '#5B8DEF', '#7F9F2E']
105const hash = (s: string): number => [...s].reduce((h, c) => (Math.imul(h, 31) + c.charCodeAt(0)) >>> 0, 7)
106const colorOf = (type: string): string => KNOWN_COLOR[bareType(type)] ?? PALETTE[hash(type) % PALETTE.length] ?? '#7C6CF2'
107
108// A run's own color, given when it started; runs from before colors were per
109// run, or with `colors: type`, wear their type's.
110const runColor = (r: Run): string => r.color ?? colorOf(r.type)
111
112// The color for a new run: one no running run wears, the one worn longest ago
113// first, so runs side by side never share one while the palette lasts.
114const pickColor = (list: readonly Run[]): string => {
115  const worn = new Set(list.filter(isRunning).map(runColor))
116  const lastWorn = (c: string): number => list.reduce((at, r, i) => (runColor(r) === c ? i : at), -1)
117  const free = PALETTE.filter(c => !worn.has(c))
118  return [...(free.length ? free : PALETTE)].sort((a, b) => lastWorn(a) - lastWorn(b))[0] ?? '#7C6CF2'
119}
120
121const FAILED = '#D9534F'
122const STOPPED = '#9A9AA0'
123const GOOD = '#3B9C5F'
124const ACCENT = '#7C6CF2'
125const fillOf = (r: Run): string => (r.status === 'failed' ? FAILED : r.status === 'stopped' ? STOPPED : runColor(r))
126
127// Shifts a #rrggbb color toward black (amount < 0) or white (amount > 0).
128const shade = (hex: string, amount: number): string => {
129  const n = parseInt(hex.slice(1), 16)
130  const mix = (c: number) => Math.round(amount < 0 ? c * (1 + amount) : c + (255 - c) * amount)
131  const [r, g, b] = [mix((n >> 16) & 255), mix((n >> 8) & 255), mix(n & 255)]
132  return `#${((1 << 24) | (r << 16) | (g << 8) | b).toString(16).slice(1)}`
133}
134
135// ---------------------------------------------------------------------------
136// What a run is doing, read off the tool calls of its last request
137
138const baseName = (p: string): string => p.split(/[\\/]/).filter(Boolean).pop() ?? p
139const hostOf = (url: string): string => /^[a-z]+:\/\/([^/?#]+)/i.exec(url)?.[1] ?? url
140
141const activityOf = (use: { name: string; input: unknown }): string => {
142  const input = (use.input ?? {}) as Record<string, unknown>
143  const str = (k: string): string => (typeof input[k] === 'string' ? oneLine(input[k] as string) : '')
144  const name = use.name.startsWith('mcp__') ? use.name.split('__').slice(2).join('__') || use.name : use.name
145  const arg =
146    (str('file_path') && baseName(str('file_path'))) ||
147    (str('notebook_path') && baseName(str('notebook_path'))) ||
148    str('description') ||
149    str('pattern') ||
150    str('query') ||
151    (str('url') && hostOf(str('url'))) ||
152    str('command') ||
153    str('skill')
154  return clip(arg ? `${name} · ${arg}` : name, 70)
155}
156
157// ---------------------------------------------------------------------------
158// Runs and batches
159
160const isRunning = (r: Run): boolean => r.status === 'running'
161const latestBatch = (list: readonly Run[]): number => list.reduce((m, r) => Math.max(m, r.batch), 0)
162const batchOf = (list: readonly Run[], batch: number): Run[] => list.filter(r => r.batch === batch)
163
164// A run joins the batch in progress; once every run of it has ended the next one
165// starts a new batch, numbered by its start time so a number is never reused.
166const batchFor = (list: readonly Run[], at: number): number => {
167  const current = batchOf(list, latestBatch(list))
168  return current.some(isRunning) ? latestBatch(list) : at
169}
170
171// The run's own progress from 0 to 1, or null when only time tells.
172const progressOf = (r: Run): number | null => {
173  if (!isRunning(r)) return 1
174  if (r.stepTotal) return Math.min(1, (r.stepDone ?? 0) / r.stepTotal)
175  return null
176}
177
178const ratioOf = (list: readonly Run[]): number =>
179  list.length ? list.reduce((s, r) => s + (progressOf(r) ?? 0), 0) / list.length : 0
180
181const elapsed = (r: Run, at: number): number => (r.endedAt ?? Math.max(at, r.startedAt)) - r.startedAt
182
183const spanOf = (list: readonly Run[], at: number): number => {
184  if (!list.length) return 0
185  const start = Math.min(...list.map(r => r.startedAt))
186  const end = Math.max(...list.map(r => r.endedAt ?? Math.max(at, r.startedAt)))
187  return end - start
188}
189
190// ---------------------------------------------------------------------------
191// Desktop drawings: each row is one SVG
192
193const FONT = "-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif"
194
195const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩\u{1F300}-\u{1FAFF}]/u
196const em = (ch: string): number =>
197  WIDE.test(ch) ? 1 : /[ilj.,:;'|!()[\]\s]/.test(ch) ? 0.3 : /[A-ZÀ-ÝmwMW@%]/.test(ch) ? 0.74 : 0.57
198const widthOf = (s: string, size: number): number => [...s].reduce((w, ch) => w + em(ch) * size, 0)
199
200// Cuts `s` to `max` pixels, with an ellipsis where it cut.
201const fit = (s: string, size: number, max: number): string => {
202  if (widthOf(s, size) <= max) return s
203  let out = ''
204  for (const ch of s) {
205    if (widthOf(out + ch + '…', size) > max) break
206    out += ch
207  }
208  return out + '…'
209}
210
211const CSS = `<style>
212.t{fill:#1d1d1f}.s{fill:#6b6b70}.m{fill:#a1a1a6}.k{fill:#ebebef}.ln{stroke:#e6e6ea}.tile{fill:#f5f5f7}
213@media (prefers-color-scheme: dark){.t{fill:#f2f2f4}.s{fill:#a8a8ad}.m{fill:#74747a}.k{fill:#2c2c31}.ln{stroke:#323237}.tile{fill:#222226}}
214.pulse{animation:pulse 1s ease-in-out infinite}@keyframes pulse{50%{opacity:.3}}
215.run .bd{animation:bob .5s steps(1) infinite}
216.run .la{animation:step .5s steps(1) infinite}.run .lb{animation:step .5s steps(1) infinite -.25s}
217.run .eye{animation:look 1s steps(1) infinite}
218.run .ant,.run .core{animation:blink .5s steps(1) infinite}.run .core{animation-delay:-.25s}
219@keyframes bob{50%{transform:translateY(1px)}}@keyframes step{50%{transform:translateY(-1px)}}
220@keyframes look{25%{transform:translateX(1px)}50%{transform:none}75%{transform:translateX(-1px)}}
221@keyframes blink{50%{opacity:.2}}
222@media (prefers-reduced-motion: reduce){.pulse,.sweep,.run *{animation:none!important}}
223</style>`
224
225const svg = (W: number, H: number, body: string, extraCss = ''): string =>
226  `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${CSS}${extraCss}${body}</svg>`
227
228// A 16×16 pixel robot in the run's color. Running, it bobs, steps,
229// looks around and blinks; finished, it smiles; failed, its eyes go red.
230const bot = (x: number, y: number, color: string, status: RunStatus, scale = 2): string => {
231  const groups: Record<'bd' | 'la' | 'lb', string[]> = { bd: [], la: [], lb: [] }
232  const px = (gx: number, gy: number, w: number, h: number, fill: string, cls = '', group: keyof typeof groups = 'bd') =>
233    groups[group].push(`<rect${cls ? ` class="${cls}"` : ''} x="${gx}" y="${gy}" width="${w}" height="${h}" fill="${fill}"/>`)
234  const body = shade(color, -0.3)
235  const metal = '#8E929A'
236  const eye = status === 'failed' ? '#FF6B6B' : status === 'stopped' ? '#8E929A' : '#9FF3FF'
237  const isRun = status === 'running'
238
239  px(7, 0, 2, 1, isRun ? '#FFD84D' : metal, 'ant')
240  px(7, 1, 2, 1, metal)
241  px(3, 2, 10, 1, color)
242  px(2, 3, 12, 6, color)
243  px(3, 9, 10, 1, color)
244  px(1, 5, 1, 2, metal)
245  px(14, 5, 1, 2, metal)
246  px(3, 3, 10, 5, '#22252B')
247  if (status === 'done') {
248    // Happy eyes: ^ ^
249    px(5, 4, 2, 1, eye)
250    px(9, 4, 2, 1, eye)
251    px(4, 5, 1, 1, eye)
252    px(7, 5, 1, 1, eye)
253    px(8, 5, 1, 1, eye)
254    px(11, 5, 1, 1, eye)
255  } else {
256    px(5, 4, 2, 2, eye, 'eye')
257    px(9, 4, 2, 2, eye, 'eye')
258  }
259  px(6, 7, 4, 1, status === 'failed' ? '#FF6B6B' : shade(color, 0.45))
260  px(6, 10, 4, 1, body)
261  px(4, 11, 8, 3, body)
262  px(7, 12, 2, 1, isRun ? '#FFD84D' : metal, 'core')
263  px(2, 11, 2, 2, color)
264  px(12, 11, 2, 2, color)
265  px(5, 14, 2, 2, '#4A4D55', '', 'la')
266  px(9, 14, 2, 2, '#4A4D55', '', 'lb')
267
268  const g = (k: keyof typeof groups) => `<g class="${k}">${groups[k].join('')}</g>`
269  return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="${isRun ? 'run' : ''}">${g('bd')}${g('la')}${g('lb')}</g></g>`
270}
271
272const statusMark = (x: number, y: number, r: Run): string => {
273  if (r.status === 'running') return `<circle class="pulse" cx="${x}" cy="${y}" r="3.5" fill="${runColor(r)}"/>`
274  if (r.status === 'done')
275    return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="${GOOD}" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
276  if (r.status === 'failed')
277    return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="${FAILED}" stroke-width="1.8" stroke-linecap="round"/>`
278  return `<rect x="${x - 4}" y="${y - 4}" width="8" height="8" rx="1.5" fill="${STOPPED}"/>`
279}
280
281const headerSvg = (W: number, list: readonly Run[], at: number, s: Strings): string => {
282  const gap = 6
283  const tw = (W - gap * 2) / 3
284  const running = list.filter(isRunning).length
285  const out = list.reduce((sum, r) => sum + r.outputTokens, 0)
286  const tile = (i: number, label: string, value: string) =>
287    `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="44" rx="8"/>
288<text class="s" x="${i * (tw + gap) + 10}" y="17" font-family="${FONT}" font-size="11">${xml(fit(label, 11, tw - 16))}</text>
289<text class="t" x="${i * (tw + gap) + 10}" y="36" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${xml(value)}</text>`
290  return svg(W, 44, tile(0, s.agents, `${running} / ${list.length}`) + tile(1, s.output, fmtTokens(out)) + tile(2, s.time, fmtTime(spanOf(list, at))))
291}
292
293const ROW_H = 70
294
295const rowSvg = (W: number, r: Run, at: number, s: Strings): string => {
296  const color = runColor(r)
297  const X = 46
298  const textW = W - X - 22
299  const steps = r.stepTotal ? `${Math.min(r.stepDone ?? 0, r.stepTotal)}/${r.stepTotal}` : ''
300  const doing =
301    r.status === 'running'
302      ? r.stepNote || r.activity || (r.steps ? s.thinking : s.starting)
303      : r.status === 'done'
304        ? s.done
305        : r.status === 'failed'
306          ? s.failed
307          : s.stopped
308  const stats = `ctx ${fmtTokens(r.contextTokens)} · ↑${fmtTokens(r.outputTokens)} · ${fmtTime(elapsed(r, at))}`
309  const statsW = widthOf(stats, 11)
310  const stepsW = steps ? widthOf(steps, 11) + 6 : 0
311  const doingW = Math.max(0, textW - statsW - stepsW - 12)
312  const meta = [modelLabel(r), r.isBackground ? s.background : ''].filter(Boolean).join('  ·  ')
313
314  const p = progressOf(r)
315  const barY = 58
316  const fillColor = fillOf(r)
317  const bar =
318    p !== null
319      ? `<rect class="k" x="${X}" y="${barY}" width="${textW}" height="5" rx="2.5"/><rect x="${X}" y="${barY}" width="${Math.max(p > 0 ? 5 : 0, Math.round(textW * p))}" height="5" rx="2.5" fill="${fillColor}"/>`
320      : // Nothing reported: a segment sweeps the track while the run works.
321        `<defs><clipPath id="c"><rect x="${X}" y="${barY}" width="${textW}" height="5" rx="2.5"/></clipPath></defs>
322<rect class="k" x="${X}" y="${barY}" width="${textW}" height="5" rx="2.5"/>
323<g clip-path="url(#c)"><rect class="sweep" x="${X - Math.round(textW * 0.3)}" y="${barY}" width="${Math.round(textW * 0.3)}" height="5" rx="2.5" fill="${color}"/></g>`
324  const sweepCss = `<style>.sweep{animation:sw 1s linear infinite}@keyframes sw{to{transform:translateX(${textW + Math.round(textW * 0.3)}px)}}</style>`
325
326  return svg(
327    W,
328    ROW_H,
329    `${bot(2, 10, color, r.status)}
330<text class="t" x="${X}" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fit(r.description || bareType(r.type), 13, textW))}</text>
331<text x="${X}" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}" font-weight="600">${xml(bareType(r.type))}</tspan><tspan class="s">  ·  ${xml(fit(meta, 11, Math.max(20, textW - widthOf(bareType(r.type), 11) - 20)))}</tspan></text>
332${steps ? `<text x="${X}" y="50" font-family="${FONT}" font-size="11" font-weight="600" font-variant-numeric="tabular-nums" fill="${color}">${steps}</text>` : ''}
333${doingW > 24 ? `<text class="s" x="${X + stepsW}" y="50" font-family="${FONT}" font-size="11">${xml(fit(doing, 11, doingW))}</text>` : ''}
334<text class="m" x="${X + textW}" y="50" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>
335${bar}
336${statusMark(W - 9, 14, r)}
337<line class="ln" x1="0" y1="${ROW_H - 0.5}" x2="${W}" y2="${ROW_H - 0.5}"/>`,
338    p === null ? sweepCss : '',
339  )
340}
341
342// The band's row: robot, title, count, and a bar with one segment per run of the batch.
343const BAND_H = 24
344
345const bandSvg = (W: number, batch: readonly Run[], at: number, s: Strings): string => {
346  const working = batch.some(isRunning)
347  const title = s.band
348  const label = bandLabel(batch, s)
349  const percent = `${Math.round(ratioOf(batch) * 100)}%`
350  const time = fmtTime(spanOf(batch, at))
351  const tail = `${percent} · ${time}`
352  const tailW = widthOf(tail, 12) + 8
353  const titleX = 24
354  const labelX = titleX + widthOf(title, 13) + 10
355  let barX = labelX + widthOf(label, 12) + 12
356  const showLabel = W - barX - tailW >= 80
357  if (!showLabel) barX = labelX
358  const barW = Math.max(40, W - barX - tailW)
359  const barY = 7
360  const barH = 10
361
362  const parts: string[] = []
363  const n = batch.length
364  if (n <= 40) {
365    const gap = n > 1 ? 3 : 0
366    const segW = (barW - gap * (n - 1)) / n
367    batch.forEach((r, i) => {
368      const x = barX + i * (segW + gap)
369      const p = progressOf(r)
370      const color = fillOf(r)
371      parts.push(`<rect class="k" x="${x}" y="${barY}" width="${segW}" height="${barH}" rx="3"/>`)
372      if (p === null) {
373        parts.push(`<rect class="pulse" x="${x}" y="${barY}" width="${segW}" height="${barH}" rx="3" fill="${color}" opacity=".45"/>`)
374      } else {
375        if (isRunning(r) && p < 1)
376          parts.push(`<rect class="pulse" x="${x}" y="${barY}" width="${segW}" height="${barH}" rx="3" fill="${color}" opacity=".25"/>`)
377        if (p > 0) parts.push(`<rect x="${x}" y="${barY}" width="${Math.max(4, segW * p)}" height="${barH}" rx="3" fill="${color}"/>`)
378      }
379    })
380  } else {
381    const ratio = ratioOf(batch)
382    parts.push(`<rect class="k" x="${barX}" y="${barY}" width="${barW}" height="${barH}" rx="5"/>`)
383    parts.push(`<rect x="${barX}" y="${barY}" width="${Math.round(barW * ratio)}" height="${barH}" rx="5" fill="${working ? ACCENT : GOOD}"/>`)
384  }
385
386  return svg(
387    W,
388    BAND_H,
389    `${bot(0, 4, working ? ACCENT : GOOD, working ? 'running' : 'done', 1)}
390<text class="t" x="${titleX}" y="16.5" font-family="${FONT}" font-size="13" font-weight="600">${xml(title)}</text>
391${showLabel ? `<text class="s" x="${labelX}" y="16.5" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(label)}</text>` : ''}
392${parts.join('')}
393<text class="s" x="${W - 2}" y="16.5" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(tail)}</text>`,
394  )
395}
396
397// ---------------------------------------------------------------------------
398// Terminal drawings
399
400const GLYPH: Record<RunStatus, string> = { running: '●', done: '✓', failed: '✗', stopped: '■' }
401
402const runBar = (r: Run, width: number, at: number): string => {
403  const p = progressOf(r)
404  if (p !== null) {
405    const filled = Math.round(width * p)
406    return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
407  }
408  // Nothing reported: a block walks the track once a second.
409  const head = Math.floor(at / 1000) % width
410  return [...Array(width)].map((_, i) => (Math.abs(i - head) <= 1 ? '▓' : '░')).join('')
411}
412
413// The batch's bar in cells: finished, failed and stopped runs, then the running
414// ones' share split into what they reported done and what is left. Every run is
415// running or not, so the parts always add up to `width`.
416type BatchCells = { done: number; failed: number; stopped: number; runFilled: number; runLeft: number }
417
418const batchCells = (batch: readonly Run[], width: number): BatchCells => {
419  const n = batch.length || 1
420  const of = (status: RunStatus) => batch.filter(r => r.status === status).length
421  const running = batch.filter(isRunning)
422  const settledW = Math.round((width * (n - running.length)) / n)
423  const failed = Math.round((width * of('failed')) / n)
424  const stopped = Math.min(settledW - failed, Math.round((width * of('stopped')) / n))
425  const runW = width - settledW
426  const partial = running.length ? running.reduce((s, r) => s + (progressOf(r) ?? 0), 0) / running.length : 0
427  const runFilled = Math.round(runW * partial)
428  return { done: Math.max(0, settledW - failed - stopped), failed, stopped, runFilled, runLeft: runW - runFilled }
429}
430
431// What the band says of its batch, on every surface.
432const bandLabel = (batch: readonly Run[], s: Strings): string => {
433  const settled = batch.filter(r => !isRunning(r)).length
434  const failed = batch.filter(r => r.status === 'failed').length
435  return batch.some(isRunning)
436    ? `${s.ready(settled, batch.length)} · ${s.busy(batch.length - settled)}`
437    : `${s.allDone} · ${batch.length}/${batch.length}${failed ? ` · ${s.withErrors(failed)}` : ''}`
438}
439
440// ---------------------------------------------------------------------------
441
442type StepInput = { done?: unknown; total?: unknown; note?: unknown }
443
444const count = (v: unknown, fallback: number): number =>
445  typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.min(1000, Math.round(v))) : fallback
446
447async function isPaneOpen($: EngineInterface): Promise<boolean> {
448  return (await $.ui.panes()).some(p => p.id === PANE)
449}
450
451async function openPane($: EngineInterface, title: string): Promise<void> {
452  const at = await $.clock.now()
453  await update($, now, () => at)
454  await $.ui.open({ id: PANE, title })
455}
456
457// Opens the panel, or closes it when it is up; true when it ends up open.
458async function togglePane($: EngineInterface, title: string): Promise<boolean> {
459  if (await isPaneOpen($)) {
460    await $.ui.close({ id: PANE })
461    return false
462  }
463  await openPane($, title)
464  return true
465}
466
467// Rows whose end no event reported: the engine's own list says how they ended.
468async function reconcile($: EngineInterface): Promise<void> {
469  const listed = new Map((await $.agent.list()).map(a => [a.id, a.status] as const))
470  const at = await $.clock.now()
471  await update($, runs, list =>
472    list.map((r): Run => {
473      if (!isRunning(r) || !r.agentId || r.isWorkflow) return r
474      const st = listed.get(r.agentId)
475      const ended: RunStatus | null =
476        st === 'completed'
477          ? 'done'
478          : st === 'failed'
479            ? 'failed'
480            : st === 'killed'
481              ? 'stopped'
482              : st === undefined && at - r.lastSeenAt > GONE_AFTER_MS
483                ? 'stopped'
484                : null
485      return ended ? { ...r, status: ended, endedAt: r.endedAt ?? at, activity: undefined } : r
486    }),
487  )
488}
489
490export const register: Register = (on, options) => {
491  const s = STRINGS
492  const stepReports = options.stepReports !== 'off'
493  const autoOpen = options.autoOpen !== 'off'
494  const colorPerRun = options.colors !== 'type'
495
496  on('session.start', async ($, e, next) => {
497    const started = await next(e)
498    await $.command.register({ name: 'subagents', description: s.command })
499    if (stepReports) {
500      await $.tool.register({
501        name: 'step',
502        description:
503          'For subagents: report progress on your own task to the user\'s agents panel. ' +
504          'Right after reading your brief, call it with `total` (your plan in 3-8 steps) and `done: 0`; ' +
505          'call it again with `done` and a short `note` as each step finishes. Cheap and silent: it only draws a bar.',
506        inputSchema: {
507          type: 'object',
508          properties: {
509            done: { type: 'integer', minimum: 0, description: 'Steps finished so far.' },
510            total: { type: 'integer', minimum: 1, description: 'Steps planned; may change if the plan changes.' },
511            note: { type: 'string', description: 'The step in progress, a few words.' },
512          },
513          required: ['done'],
514        },
515        // In every agent's tool list from the start: workers do not go looking for it.
516        isDeferred: false,
517      })
518    }
519
520    // Ticks the clocks of running rows once a second, and every 5s asks the
521    // engine about rows that may have ended unseen. Quiet when nothing runs.
522    let ticks = 0
523    $.clock.every(1000, () => {
524      void (async () => {
525        const list = await read($, runs)
526        if (!list.some(isRunning)) return
527        const at = await $.clock.now()
528        await update($, now, () => at)
529        ticks += 1
530        if (ticks % 5 === 0) await reconcile($)
531      })()
532    })
533    return started
534  })
535
536  on('command.run', { command: 'subagents' }, async ($, e) => {
537    const arg = (e.args ?? '').trim().toLowerCase()
538    if (arg === 'open') {
539      await openPane($, s.pane)
540      return { text: s.opened }
541    }
542    return { text: (await togglePane($, s.pane)) ? s.opened : s.closed }
543  })
544
545  // A subagent's own steps: the call runs in its loop, so agentId names it.
546  on('tool.call', { tool: STEP_TOOL }, async ($, e) => {
547    const agentId = e.agentId
548    if (!agentId) return { result: 'ignored: only subagents report steps' }
549    const input = e as unknown as StepInput
550    let matched = false
551    await update($, runs, list =>
552      list.map((r): Run => {
553        if (r.agentId !== agentId) return r
554        matched = true
555        const total = count(input.total, r.stepTotal ?? 0)
556        const done = count(input.done, r.stepDone ?? 0)
557        const note = typeof input.note === 'string' ? clip(oneLine(input.note), MAX_NOTE) : ''
558        return { ...r, stepTotal: total || undefined, stepDone: total ? Math.min(total, done) : done, stepNote: note || undefined }
559      }),
560    )
561    return { result: matched ? 'ok' : 'ignored: this agent has no row in the panel' }
562  }).catch(() => ({ result: 'ignored' }))
563
564  on('agent.spawn', async ($, e, next) => {
565    const wantsBrief = stepReports && !e.resume && !e.workflow && !e.prompt.includes(STEP_TOOL)
566    const started = await next(wantsBrief ? { ...e, prompt: e.prompt + STEP_BRIEF } : e)
567    if (started.deny !== undefined) return started
568
569    const at = await $.clock.now()
570    const id = started.agentId ?? e.resume?.agentId ?? `${e.tool_use_id}:${e.workflow?.agentIndex ?? ''}`
571    let batch = at
572    await update($, runs, list => {
573      batch = batchFor(list, at)
574      const prev = list.find(r => r.id === id)
575      const run: Run = prev
576        ? { ...prev, status: 'running', batch, endedAt: undefined, lastSeenAt: at, activity: undefined }
577        : {
578            id,
579            agentId: started.agentId,
580            type: e.subagentType,
581            color: colorPerRun ? pickColor(list) : undefined,
582            description: oneLine(e.description),
583            model: started.model,
584            isBackground: e.background,
585            isWorkflow: e.workflow !== undefined,
586            status: 'running',
587            batch,
588            startedAt: at,
589            lastSeenAt: at,
590            contextTokens: 0,
591            outputTokens: 0,
592            steps: 0,
593          }
594      return [...list.filter(r => r.id !== id), run].slice(-MAX_RUNS)
595    })
596    await update($, now, () => at)
597
598    const d: Deck = await read($, deck)
599    const isNewBatch = d.autoOpenedBatch !== batch
600    await update($, deck, p => ({
601      ...p,
602      hiddenBatch: p.hiddenBatch === batch ? 0 : p.hiddenBatch,
603      autoOpenedBatch: batch,
604    }))
605    if (autoOpen && isNewBatch && !(await isPaneOpen($))) void $.ui.open({ id: PANE, title: s.pane })
606    return started
607    // The panel never stands in a spawn's way: a failure here lets it go on as asked.
608  }).catch(($, e, next) => next(e))
609
610  // Each model request of a subagent: context, output, and what it does next.
611  on('turn.step', async function* ($, e, next) {
612    const result = yield* next(e)
613    const agentId = e.agentId
614    if (!agentId) return result
615
616    const at = await $.clock.now()
617    const usage = result.usage
618    const uses = result.toolUses.filter(u => u.name !== STEP_TOOL)
619    const first = uses[0]
620    const activity = first
621      ? activityOf(first) + (uses.length > 1 ? ` +${uses.length - 1}` : '')
622      : result.answer
623        ? s.answering
624        : undefined
625    await update($, runs, list =>
626      list.map((r): Run =>
627        r.agentId !== agentId
628          ? r
629          : {
630              ...r,
631              status: 'running',
632              endedAt: undefined,
633              lastSeenAt: at,
634              model: usage?.model || r.model,
635              effort: typeof e.effort === 'string' ? e.effort : r.effort,
636              steps: r.steps + 1,
637              activity: activity ?? r.activity,
638              ...(usage
639                ? {
640                    contextTokens:
641                      (usage.input_tokens || 0) +
642                      (usage.cache_read_input_tokens || 0) +
643                      (usage.cache_creation_input_tokens || 0) +
644                      (usage.output_tokens || 0),
645                    outputTokens: r.outputTokens + (usage.output_tokens || 0),
646                  }
647                : {}),
648            },
649      ),
650    )
651    return result
652  })
653
654  on('turn.complete', async ($, e, next) => {
655    const agentId = e.agentId
656    if (agentId) {
657      const at = await $.clock.now()
658      const status: RunStatus = e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'stopped' : 'failed'
659      await update($, runs, list =>
660        list.map((r): Run => {
661          if (r.agentId !== agentId) return r
662          // A run whose requests went unseen still gets the turn's own sum.
663          const fallback = r.steps === 0 && e.usage ? { outputTokens: e.usage.output_tokens || 0 } : {}
664          return { ...r, ...fallback, status, endedAt: at, activity: undefined }
665        }),
666      )
667      await update($, now, () => at)
668    }
669    return next(e)
670  })
671
672  // Once a batch has ended and the person sends their next prompt, the band steps aside.
673  on('prompt.submit', async ($, e, next) => {
674    if (!e.isMeta) {
675      const list = await read($, runs)
676      const latest = latestBatch(list)
677      const batch = batchOf(list, latest)
678      if (batch.length && !batch.some(isRunning)) await update($, deck, p => ({ ...p, hiddenBatch: latest }))
679    }
680    return next(e)
681  }).catch(($, e, next) => next(e))
682
683  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
684    const ui = $.ui.resolve(e)
685    const { Box, Text, Button } = ui
686    const list = await read($, runs)
687    const d = await read($, deck)
688    const at = Math.max(await read($, now), ...list.map(r => r.startedAt), 0)
689
690    const running = list.filter(isRunning).reverse()
691    const finished = list.filter(r => !isRunning(r)).sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
692
693    const toggleDone = (
694      <Button
695        key="deck-done"
696        label={`${d.isDoneCollapsed ? '▸' : '▾'} ${s.finished} · ${finished.length}`}
697        plain
698        onPress={() => update($, deck, p => ({ ...p, isDoneCollapsed: !p.isDoneCollapsed }))}
699      />
700    )
701    const clear = (
702      <Button key="deck-clear" label={s.clear} plain onPress={() => update($, runs, l => l.filter(isRunning))} />
703    )
704
705    if (e.surface !== 'terminal' && 'Svg' in ui) {
706      const { Svg } = ui
707      const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
708      return (
709        <Box flexDirection="column">
710          <Svg source={headerSvg(W, list, at, s)} alt={`${running.length} / ${list.length}`} width={W} height={44} />
711          {list.length === 0 && <Text dimColor>{s.empty}</Text>}
712          {running.length > 0 && (
713            <Text dimColor>
714              {s.running} · {running.length}
715            </Text>
716          )}
717          {running.map(r => (
718            <Svg key={r.id} source={rowSvg(W, r, at, s)} alt={`${r.description}: ${r.activity ?? s.thinking}`} width={W} height={ROW_H} />
719          ))}
720          {finished.length > 0 && (
721            <Box flexDirection="row" gap={2}>
722              {toggleDone}
723              {clear}
724            </Box>
725          )}
726          {!d.isDoneCollapsed &&
727            finished.map(r => (
728              <Svg key={r.id} source={rowSvg(W, r, at, s)} alt={`${r.description}: ${s[r.status as 'done' | 'failed' | 'stopped']}`} width={W} height={ROW_H} />
729            ))}
730        </Box>
731      )
732    }
733
734    const cols = Math.max(24, e.props.bodyColumns || 40)
735    const barW = Math.max(6, Math.min(24, cols - 30))
736    const row = (r: Run) => {
737      const color = runColor(r)
738      const steps = r.stepTotal ? `${Math.min(r.stepDone ?? 0, r.stepTotal)}/${r.stepTotal} ` : ''
739      const doing = isRunning(r) ? r.stepNote || r.activity || (r.steps ? s.thinking : s.starting) : s[r.status as 'done' | 'failed' | 'stopped']
740      return (
741        <Box key={r.id} flexDirection="column" marginBottom={1}>
742          <Box flexDirection="row" gap={1}>
743            <Text color={r.status === 'done' ? GOOD : r.status === 'running' ? color : fillOf(r)}>{GLYPH[r.status]}</Text>
744            <Text bold wrap="truncate-end">
745              {r.description || bareType(r.type)}
746            </Text>
747          </Box>
748          <Text wrap="truncate-end">
749            {'  '}
750            <Text color={color}>{bareType(r.type)}</Text>
751            <Text dimColor>
752              {' · '}
753              {modelLabel(r)}
754              {r.isBackground ? ` · ${s.background}` : ''}
755            </Text>
756          </Text>
757          <Text wrap="truncate-end">
758            {'  '}
759            <Text color={fillOf(r)}>{runBar(r, barW, at)}</Text>
760            <Text dimColor>
761              {' '}
762              {steps}
763              {doing}
764            </Text>
765          </Text>
766          <Text dimColor wrap="truncate-end">
767            {'  '}ctx {fmtTokens(r.contextTokens)} · ↑{fmtTokens(r.outputTokens)} · {fmtTime(elapsed(r, at))}
768          </Text>
769        </Box>
770      )
771    }
772
773    // A finished run needs no bar: its name, then what it came to.
774    const doneRow = (r: Run) => (
775      <Box key={r.id} flexDirection="column">
776        <Box flexDirection="row" gap={1}>
777          <Text color={r.status === 'done' ? GOOD : fillOf(r)}>{GLYPH[r.status]}</Text>
778          <Text bold wrap="truncate-end">
779            {r.description || bareType(r.type)}
780          </Text>
781        </Box>
782        <Text dimColor wrap="truncate-end">
783          {'  '}
784          {bareType(r.type)} · {modelName(r.model)} · {s[r.status as 'done' | 'failed' | 'stopped']} · ↑{fmtTokens(r.outputTokens)} ·{' '}
785          {fmtTime(elapsed(r, at))}
786        </Text>
787      </Box>
788    )
789
790    return (
791      <Box flexDirection="column">
792        <Text dimColor>
793          {s.running} {running.length}/{list.length} · ↑{fmtTokens(list.reduce((sum, r) => sum + r.outputTokens, 0))} · {fmtTime(spanOf(list, at))}
794        </Text>
795        <Box flexDirection="column" marginTop={1}>
796          {list.length === 0 && <Text dimColor>{s.empty}</Text>}
797          {running.map(row)}
798          {finished.length > 0 && (
799            <Box flexDirection="row" gap={2}>
800              {toggleDone}
801              {clear}
802            </Box>
803          )}
804          {!d.isDoneCollapsed && finished.map(doneRow)}
805        </Box>
806      </Box>
807    )
808  })
809
810  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
811    if (e.props.hasSurvey) return next(e)
812    const list = await read($, runs)
813    const d = await read($, deck)
814    const latest = latestBatch(list)
815    const batch = batchOf(list, latest)
816    if (!batch.length || d.hiddenBatch === latest) return next(e)
817
818    // Drawn above the other mods' bands; Claude Code's own placeholder
819    // ({ type: 'engine' }) is never nested, as the desktop drops a band that nests it.
820    const drawn = await Promise.resolve(next(e)).catch(() => null)
821    const below = drawn && typeof drawn === 'object' && (drawn as { type?: string }).type !== 'engine' ? drawn : null
822
823    const ui = $.ui.resolve(e)
824    const { Box, Text, Button } = ui
825    const at = Math.max(await read($, now), ...batch.map(r => r.startedAt))
826    const working = batch.some(isRunning)
827    const crew = <Button key="deck-agents" label={`×${batch.length}`} plain onPress={() => void togglePane($, s.pane)} />
828    // A Button of its own, not the desktop's native close (`role="dismiss"`),
829    // which was not to be seen on the band.
830    const dismiss = (
831      <Button key="deck-dismiss" label="✕" plain onPress={() => update($, deck, p => ({ ...p, hiddenBatch: latest }))} />
832    )
833    const label = bandLabel(batch, s)
834    const percent = `${Math.round(ratioOf(batch) * 100)}%`
835    const tail = `${percent} · ${fmtTime(spanOf(batch, at))}`
836    const hasFailures = batch.some(r => r.status === 'failed')
837    const mark = working ? ACCENT : hasFailures ? FAILED : GOOD
838
839    if (e.surface !== 'terminal' && 'Svg' in ui) {
840      const { Svg } = ui
841      // Pixels per column vary with the app's font: the drawing errs narrow so
842      // the buttons keep their room, and wrap below it rather than leave the row.
843      const W = Math.max(200, Math.min(1200, (e.props.bodyColumns || 100) * 7 - 140))
844      return (
845        <Box flexDirection="column">
846          <Box flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
847            <Svg source={bandSvg(W, batch, at, s)} alt={`${s.band}: ${label}, ${percent}`} width={W} height={BAND_H} />
848            <Box flexDirection="row" flexShrink={0} gap={1}>
849              {crew}
850              {dismiss}
851            </Box>
852          </Box>
853          {below}
854        </Box>
855      )
856    }
857
858    // The row in cells: the bar takes what the words and buttons leave, the label
859    // goes first when that is too little, then the percent and time.
860    const cols = e.props.bodyColumns || 100
861    const buttons = `[×${batch.length}] [✕]`.length + 2
862    const fixed = 2 + s.band.length + 1 + buttons
863    const room = (words: number) => cols - fixed - words
864    const showLabel = room(label.length + 1 + tail.length + 1) >= 10
865    const showTail = showLabel || room(tail.length + 1) >= 8
866    const barW = Math.max(4, Math.min(40, room((showLabel ? label.length + 1 : 0) + (showTail ? tail.length + 1 : 0))))
867    const cells = batchCells(batch, barW)
868    return (
869      <Box flexDirection="column">
870        <Box flexDirection="row" gap={1}>
871          <Text color={mark}>◆</Text>
872          <Text bold>{s.band}</Text>
873          <Text>
874            <Text color={working ? ACCENT : GOOD}>{'█'.repeat(cells.done)}</Text>
875            <Text color={FAILED}>{'█'.repeat(cells.failed)}</Text>
876            <Text color={STOPPED}>{'█'.repeat(cells.stopped)}</Text>
877            <Text color={ACCENT}>{'▓'.repeat(cells.runFilled)}</Text>
878            <Text dimColor>{'▒'.repeat(cells.runLeft)}</Text>
879          </Text>
880          {showLabel && (
881            <Text bold wrap="truncate-end">
882              {label}
883            </Text>
884          )}
885          {showTail && <Text dimColor>{tail}</Text>}
886          {crew}
887          {dismiss}
888        </Box>
889        {below}
890      </Box>
891    )
892  })
893}
894
types/index.d.ts 49 lines
1export type RunStatus = 'running' | 'done' | 'failed' | 'stopped'
2
3export type Run = {
4  /** The agent's id, or the spawning call's when the engine gave none. */
5  id: string
6  agentId?: string
7  type: string
8  /** The run's own color, picked when it started; unset, its type's color. */
9  color?: string
10  description: string
11  model: string
12  effort?: string
13  isBackground: boolean
14  isWorkflow: boolean
15  status: RunStatus
16  /** Runs started while others still ran share a batch: the band's bar is one batch. */
17  batch: number
18  startedAt: number
19  endedAt?: number
20  /** The last time a model request of the run was seen. */
21  lastSeenAt: number
22  contextTokens: number
23  outputTokens: number
24  steps: number
25  /** What the run is doing now, from its last request's tool calls. */
26  activity?: string
27  /** Self-reported through the `step` tool. */
28  stepDone?: number
29  stepTotal?: number
30  stepNote?: string
31}
32
33export type Deck = {
34  /** The batch whose band was dismissed. */
35  hiddenBatch: number
36  isDoneCollapsed: boolean
37  autoOpenedBatch: number
38}
39
40declare module 'claude-code' {
41  interface PluginState {
42    'agent-deck': {
43      runs: Run[]
44      deck: Deck
45      now: number
46    }
47  }
48}
49