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.

Mods for Claude Code. Each one is a standalone plugin under plugins/.
| Mod | What it does |
|---|---|
| context-handoff | Watches 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-deck | A 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. |
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.
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.
git status and git diff --stat, and writes a handoff with:Then it handles your message as usual and adds what it did to the handoff.
/compact for you./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.
| Option | Default | What it sets |
|---|---|---|
thresholdK | 300 | Context size, in thousands of tokens, that arms a handoff. Keep it below the auto-compact window, or Claude Code's own compaction comes first. |
retentionDays | 2 | When 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 } } } }
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.
/context-handoff arms it again./compact; the handoff still comes back with your next message.claude plugin uninstall context-handoff@claude-mods
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.
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.
/subagents opens or closes it, and /subagents open only opens it. It also opens by itself when a new batch starts.
Grep · invalidateCache); its reported steps (2/5 · reading the tests); context size, output tokens and time.In a terminal session both draw as text.
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.
| Option | Default | What it sets |
|---|---|---|
autoOpen | on | Opens the panel by itself when a new batch of subagents starts; off opens it only with /subagents. |
stepReports | on | Asks 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" } } } }
claude plugin uninstall agent-deck@claude-mods
MIT
hooks/register.tsx 894 lines1// 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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}
894types/index.d.ts 49 lines1export 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