SLOPSHOPPER

subagents-panel

A side panel listing this session's subagents, what each is doing now, and each one's step-by-step progress.

newpanebandguardcommandtoast
v1.0.0MITupdated 2026-10-14berkakkaya/subagents-panel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · subagents-panel
│ ┃ Subagents ✕ › fix the failing auth test and add an audit log call │ ┃ │ ┃ ╭────────────────────────────────────────╮ ⏺ Read(src/auth.ts) │ ┃ │ ⌕ Search subagents │ ⎿ Read 6 lines │ ┃ ╰────────────────────────────────────────╯ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ▾ In progress · 0 ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ No subagents running │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ▾ Completed · 0 ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ None yet › /subagents │ ⎿ subagents-panel: Subagents panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Subagents
╭────────────────────────────────────────────────────────╮ │ ⌕ Search subagents │ ╰────────────────────────────────────────────────────────╯ ▾ In progress · 0 No subagents running ▾ Completed · 0 None yet
README

<img src="assets/logo.svg" alt="Subagents Panel logo" width="128" height="128">

<h1 align="center">Subagents Panel</h1>

See what your Claude Code subagents are doing, live, in a side panel.<br> Works in the Claude Code CLI and in the Claude desktop app.

<a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a> <img alt="Claude Code mod" src="https://img.shields.io/badge/Claude%20Code-mod-D97757.svg">


When Claude hands work to subagents, they run out of sight. Subagents Panel gives each one a place you can watch: what it is doing right now, every step it takes, how long it has been running, and the report it hands back. You can even send it a message while it works.

<img src="assets/screenshots/desktop-overview.png" alt="The Subagents panel in the Claude desktop app: six agents in progress and three completed, with a status band above the prompt">

<table> <tr> <td width="34%" valign="top"> <img src="assets/screenshots/desktop-detail.png" alt="One agent's detail view: narration, tool calls grouped with colored icons, a message sent to the agent, and the message box"> </td> <td width="66%" valign="top"> <img src="assets/screenshots/terminal.png" alt="The same panel in the Claude Code CLI, beside the conversation, with the band above the prompt"> </td> </tr> <tr> <td align="center"><sub>An agent's progress, and a message box to talk to it</sub></td> <td align="center"><sub>The same panel and band in the Claude Code CLI</sub></td> </tr> </table>

Features

  • A live overview. Every subagent in the session, grouped into In progress and Completed. Running ones show a one-line summary of what they are doing right now.
  • Step-by-step detail. Click an agent to see its narration and its tool calls, folded into groups like "Read 3 files, ran 2 commands", with the final report at the end.
  • Talk to a running agent. A message box at the foot of a running agent's detail view sends your words straight to that agent. They show up in its timeline as You → ….
  • A status band above the prompt. How many agents are running or done, their avatars (stacked with +N when there are many), and an Open subagents button. Close it with ×.
  • Search. Filter the overview by name, type, current activity, task or report as you type.
  • Live timers. Elapsed times keep ticking, even while an agent sits waiting on its own work.
  • A unique avatar for every agent. Each agent's id seeds its own shape and colors, so you can tell agents apart at a glance, in the same look every time.
  • Colored tool icons. Blue for file work, green for commands, purple for everything else.
  • Survives a restart. Resume a session and the panel rebuilds its list from the transcript.
  • Light and dark themes, on both the CLI and the desktop app.

Install

You need Claude Code. In a Claude Code session in your terminal, type:

/plugin install subagents-panel --marketplace berkakkaya/subagents-panel

Answer y to add the marketplace, then pick a scope. Choose user to have the panel in every session, including sessions you start from the Claude desktop app.

That's it: the next time Claude starts a subagent, the panel opens by itself.

Updating

claude plugin update subagents-panel

Then run /reload-plugins in any open session, or start a new one.

Uninstalling

claude plugin uninstall subagents-panel

Using it

ToDo this
Open the panelIt opens by itself when a subagent starts. Or type /subagents.
See an agent's progressClick its name in the overview. Click ← to go back.
Hide or show a sectionClick In progress or Completed.
Find an agentType in the search box at the top of the overview.
Message a running agentOpen it, click the message box at the bottom, type, press Enter.
Start a new line in a messageShift+Enter on desktop. In a terminal: Ctrl+J, or \ then Enter.
Hide the band above the promptClick × on its right. Type /subagents to bring it back.

The message box sits under agents that are still running (or waiting on their own work); finished agents have no box. A message reaches the agent the same way Claude's own SendMessage tool would, and the agent reads it at its next step.

Troubleshooting

The panel does not open. Type /subagents. If the reply says the panel is open but not shown, it gives the reason. In a terminal, a panel that opens by itself needs a window at least 144 columns wide; /subagents opens it at any width.

A click does not seem to register, or a timer looks stuck. Type /subagents debug. It prints when the elapsed times last moved and the panel's last renders, focus changes and presses, with timings. Please include that output when you open an issue.

Message box keys in a terminal. Most terminals send Shift+Enter the same as Enter, so use Ctrl+J (or \ then Enter) for a new line.

Contributing

Contributions are welcome: bug reports, ideas and pull requests.

Getting set up

  1. Fork the repository and clone your fork.
  2. Load the mod from your working copy for a Claude Code session:
   claude --plugin-dir /path/to/subagents-panel

Claude Code watches the folder and reloads the mod when you save a file. The first load also writes the API's type declarations into .claude-plugin/types/ (ignored by git), which your editor and tsc use.

Project layout

PathWhat it is
hooks/register.tsxThe mod: event hooks, state, the panel, the band, delivery of messages.
hooks/elapsed.tsxA surface module: the live elapsed-time readout.
hooks/composer.tsxA surface module: the multi-line message box.
types/index.d.tsThe shape of the state the mod keeps.
tests/panel.test.tsxTests, run on both the terminal and desktop surfaces.
assets/The logo and screenshots.

Before you open a pull request

Run these from the project folder; all three should pass.

claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .
  • Keep changes working on both surfaces. Tests mount the panel on terminal and desktop; add a test for any behavior you add or fix.
  • Match the style of the surrounding code: short functions, comments that say why.
  • Describe what you changed and how you checked it. A screenshot helps for anything visual.

Reporting a bug

Open an issue with what you did, what you expected, what happened, whether it was the CLI or the desktop app, and the output of /subagents debug.

License

MIT


© 2026 Berk Akkaya<br> <a href="https://cyberk.dev/">cyberk.dev</a>

Source 4 files
hooks/register.tsx 1447 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentStatus, EngineInterface, Register } from 'claude-code'
3
4import type { AgentPhase, AgentRow, AgentStep } from '../types'
5
6const PANE = 'subagents'
7const TITLE = 'Subagents'
8const MAX_STEPS = 400
9const MAX_TEXT = 6000
10const MAX_KEPT_SESSIONS = 40
11
12const agents = atom({ plugin: 'subagents-panel', key: 'agents' } as const, [])
13const selected = atom({ plugin: 'subagents-panel', key: 'selected' } as const, null)
14const showActive = atom({ plugin: 'subagents-panel', key: 'showActive' } as const, true)
15const showDone = atom({ plugin: 'subagents-panel', key: 'showDone' } as const, true)
16const toggled = atom({ plugin: 'subagents-panel', key: 'toggled' } as const, [])
17const autoOpened = atom({ plugin: 'subagents-panel', key: 'autoOpened' } as const, false)
18const tick = atom({ plugin: 'subagents-panel', key: 'tick' } as const, 0)
19const query = atom({ plugin: 'subagents-panel', key: 'query' } as const, '')
20const focusedKey = atom({ plugin: 'subagents-panel', key: 'focusedKey' } as const, null)
21const bandHidden = atom({ plugin: 'subagents-panel', key: 'bandHidden' } as const, false)
22
23// A steady mark for work in flight: an animated one redraws the pane every second, and a
24// click that lands while its elements are being replaced can be lost.
25const RUNNING = '●'
26const TICK_MS = 5000
27
28const isActive = (phase: AgentPhase) => phase === 'running' || phase === 'waiting'
29
30function hashOf(id: string) {
31  let hash = 0
32  for (const ch of id) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
33  return hash
34}
35
36// ── Avatars ───────────────────────────────────────────────────────────────────
37// Each agent's id seeds a small random generator, so its avatar is its own and the same every
38// time it is drawn, a resume included. Desktop draws an SVG from one of several shape families;
39// the terminal draws a glyph of the same family in the same color.
40
41type Family = 'star' | 'flower' | 'gear' | 'polygon' | 'burst' | 'orbit' | 'crystal' | 'blob'
42const FAMILIES: Family[] = ['star', 'flower', 'gear', 'polygon', 'burst', 'orbit', 'crystal', 'blob']
43
44// One terminal cell each, and none a terminal may swap for an emoji (❄ ✳ ⚙ are left out).
45const FAMILY_GLYPHS: Record<Family, string[]> = {
46  star: ['✦', '✶', '✷', '✸', '✹'],
47  flower: ['✿', '❀', '❁', '✾', '❃'],
48  gear: ['❂', '✪', '✺'],
49  polygon: ['◆', '◈', '❖'],
50  burst: ['❋', '✱', '✲', '❊', '❉'],
51  orbit: ['◉', '◎', '⊛'],
52  crystal: ['✧', '◇', '✥'],
53  blob: ['●', '✽', '✻', '✼'],
54}
55
56/** mulberry32: a tiny seeded generator, plenty for picking shapes. */
57function seeded(seed: number) {
58  let a = seed >>> 0
59  return () => {
60    a = (a + 0x6d2b79f5) >>> 0
61    let t = a
62    t = Math.imul(t ^ (t >>> 15), t | 1)
63    t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
64    return ((t ^ (t >>> 14)) >>> 0) / 4294967296
65  }
66}
67
68function hslHex(h: number, s: number, l: number) {
69  const a = (s / 100) * Math.min(l / 100, 1 - l / 100)
70  const f = (n: number) => {
71    const k = (n + h / 30) % 12
72    const c = l / 100 - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
73    return Math.round(c * 255)
74      .toString(16)
75      .padStart(2, '0')
76  }
77  return `#${f(0)}${f(8)}${f(4)}`
78}
79
80type Avatar = {
81  /** The gradient's id, unique per agent: several avatars may share one page, and ids are page-wide. */
82  gid: string
83  family: Family
84  glyph: string
85  /** The main color; the terminal draws the glyph in it. */
86  color: string
87  /** The gradient's second stop. */
88  color2: string
89  count: number
90  rotation: number
91  depth: number
92  detail: 'none' | 'dot' | 'ring'
93  jitter: number[]
94}
95
96function avatar(id: string): Avatar {
97  const rand = seeded(hashOf(id))
98  const pick = <T,>(list: readonly T[]): T => list[Math.floor(rand() * list.length)] as T
99  const family = pick(FAMILIES)
100  const hue = Math.floor(rand() * 360)
101  const shift = 25 + Math.floor(rand() * 70)
102  return {
103    gid: `sa${hashOf(id).toString(36)}`,
104    family,
105    glyph: pick(FAMILY_GLYPHS[family]),
106    // Mid lightness and saturation read on dark and light themes alike.
107    color: hslHex(hue, 68, 62),
108    color2: hslHex((hue + shift) % 360, 72, 52),
109    count: 5 + Math.floor(rand() * 5),
110    rotation: Math.floor(rand() * 360),
111    depth: 0.35 + rand() * 0.25,
112    detail: pick(['none', 'dot', 'ring'] as const),
113    jitter: Array.from({ length: 10 }, () => 0.75 + rand() * 0.25),
114  }
115}
116
117function phaseOf(status: AgentStatus): AgentPhase {
118  if (status === 'pending' || status === 'running') return 'running'
119  if (status === 'waiting' || status === 'idle') return 'waiting'
120  return status
121}
122
123function titleCase(text: string) {
124  const t = text.trim()
125  return t ? t.charAt(0).toUpperCase() + t.slice(1) : 'Subagent'
126}
127
128function clip(text: string, max: number) {
129  const one = text.replace(/\s+/g, ' ').trim()
130  if (max <= 1) return ''
131  return one.length > max ? one.slice(0, max - 1) + '…' : one
132}
133
134function shortPath(path: unknown) {
135  if (typeof path !== 'string' || !path) return ''
136  const parts = path.split(/[\\/]/).filter(Boolean)
137  return parts.slice(-2).join('/')
138}
139
140function firstLine(text: unknown) {
141  return typeof text === 'string' ? (text.split('\n').find(l => l.trim()) ?? '').trim() : ''
142}
143
144/** A tool call as one line, what the agent is doing in plain words. */
145function describeTool(tool: string, input: Record<string, unknown>): string {
146  switch (tool) {
147    case 'Read':
148      return `Read ${shortPath(input.file_path)}`
149    case 'Edit':
150    case 'MultiEdit':
151      return `Edit ${shortPath(input.file_path)}`
152    case 'Write':
153      return `Write ${shortPath(input.file_path)}`
154    case 'NotebookEdit':
155      return `Edit ${shortPath(input.notebook_path)}`
156    case 'Bash':
157    case 'PowerShell':
158      return firstLine(input.description) || firstLine(input.command)
159    case 'Grep':
160      return `Search for "${input.pattern ?? ''}"${input.path ? ` in ${shortPath(input.path)}` : ''}`
161    case 'Glob':
162      return `Find files ${input.pattern ?? ''}`
163    case 'LS':
164      return `List ${shortPath(input.path)}`
165    case 'WebSearch':
166      return `Search the web for "${input.query ?? ''}"`
167    case 'WebFetch': {
168      const url = typeof input.url === 'string' ? input.url : ''
169      return `Fetch ${url.replace(/^https?:\/\//, '').split('/')[0]}`
170    }
171    case 'Agent':
172    case 'Task':
173      return `Delegate: ${input.description ?? 'subagent'}`
174    case 'SendMessage':
175      return `Message ${input.to ?? 'an agent'}`
176    case 'TodoWrite':
177      return 'Update the todo list'
178    case 'ToolSearch':
179      return 'Load tools'
180    case 'AskUserQuestion':
181      return 'Ask the user a question'
182    case 'Skill':
183      return `Use skill ${input.skill ?? ''}`
184    default: {
185      const mcp = /^mcp__(.+?)__(.+)$/.exec(tool)
186      return mcp ? `${mcp[1]} › ${mcp[2]}` : tool
187    }
188  }
189}
190
191type Kind = 'read' | 'edit' | 'run' | 'search' | 'web' | 'agent' | 'other'
192const KINDS: Kind[] = ['read', 'edit', 'run', 'search', 'web', 'agent', 'other']
193
194function kindOf(tool: string): Kind {
195  if (tool === 'Read') return 'read'
196  if (tool === 'Edit' || tool === 'MultiEdit' || tool === 'Write' || tool === 'NotebookEdit') return 'edit'
197  if (tool === 'Bash' || tool === 'PowerShell') return 'run'
198  if (tool === 'Grep' || tool === 'Glob' || tool === 'LS') return 'search'
199  if (tool === 'WebFetch' || tool === 'WebSearch') return 'web'
200  if (tool === 'Agent' || tool === 'Task' || tool === 'SendMessage') return 'agent'
201  return 'other'
202}
203
204const KIND_GLYPH: Record<Kind, string> = { read: '▤', edit: '✎', run: '›_', search: '⌕', web: '◍', agent: '◎', other: '◆' }
205
206// Desktop and the other graphical surfaces draw these as tinted SVG chips.
207// Blue for file operations, green for commands, purple for every other tool.
208const FILE_BLUE = '#5B9BF0'
209const RUN_GREEN = '#3FB98A'
210const TOOL_PURPLE = '#A57FE0'
211const KIND_COLOR: Record<Kind, string> = {
212  read: FILE_BLUE,
213  edit: FILE_BLUE,
214  search: FILE_BLUE,
215  run: RUN_GREEN,
216  web: TOOL_PURPLE,
217  agent: TOOL_PURPLE,
218  other: TOOL_PURPLE,
219}
220
221/** How a subagent hands its report back: a tool call, not a step of its work. */
222const HANDBACK = 'SubagentHandback'
223
224/** The report a hand-back call carries; its argument's name is the engine's, so read it loosely. */
225function handbackReport(input: Record<string, unknown>) {
226  for (const key of ['report', 'text', 'message', 'content', 'result']) {
227    const v = input[key]
228    if (typeof v === 'string' && v.trim()) return v.trim()
229  }
230  const strings = Object.values(input).filter((v): v is string => typeof v === 'string')
231  return strings.sort((a, b) => b.length - a.length)[0]?.trim() || undefined
232}
233const KIND_PATH: Record<Kind, string> = {
234  read: 'M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z M14 3v5h5 M9 13h6 M9 17h4',
235  edit: 'M12 20h9 M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z',
236  run: 'M4 17l6-5-6-5 M12 19h8',
237  search: 'M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14z M21 21l-4.3-4.3',
238  web: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z M3 12h18 M12 3c3 3.5 3 14.5 0 18 M12 3c-3 3.5-3 14.5 0 18',
239  agent: 'M12 11a4 4 0 1 0 0-8a4 4 0 1 0 0 8z M4 21a8 8 0 0 1 16 0',
240  other: 'M12 3l2.4 6.6L21 12l-6.6 2.4L12 21l-2.4-6.6L3 12l6.6-2.4z',
241}
242
243const SVG_NS = 'xmlns="http://www.w3.org/2000/svg"'
244
245function kindIcon(kind: Kind) {
246  const c = KIND_COLOR[kind]
247  return (
248    `<svg ${SVG_NS} width="24" height="24" viewBox="0 0 24 24">` +
249    `<rect width="24" height="24" rx="6" fill="${c}" fill-opacity="0.16"/>` +
250    `<g transform="translate(5 5) scale(0.5833)" fill="none" stroke="${c}" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">` +
251    `<path d="${KIND_PATH[kind]}"/></g></svg>`
252  )
253}
254
255const at = (r: number, deg: number) => {
256  const a = ((deg - 90) * Math.PI) / 180
257  return `${(12 + r * Math.cos(a)).toFixed(2)},${(12 + r * Math.sin(a)).toFixed(2)}`
258}
259
260/** The shape itself, centered on (12, 12) within a radius of about 8, filled with the gradient. */
261function avatarShape(v: Avatar) {
262  const fill = `fill="url(#${v.gid})"`
263  const n = v.count
264  switch (v.family) {
265    case 'star': {
266      const pts = Array.from({ length: n * 2 }, (_, i) => at(i % 2 ? 8 * v.depth : 8, (360 / (n * 2)) * i))
267      return `<polygon points="${pts.join(' ')}" ${fill} stroke-linejoin="round"/>`
268    }
269    case 'flower': {
270      const petals = Array.from(
271        { length: n },
272        (_, i) => `<ellipse cx="12" cy="7.2" rx="2.4" ry="4.4" transform="rotate(${(360 / n) * i} 12 12)" ${fill}/>`,
273      )
274      return petals.join('') + `<circle cx="12" cy="12" r="2.6" fill="${v.color2}"/>`
275    }
276    case 'gear': {
277      const teeth = n + 2
278      const step = 360 / teeth
279      const pts: string[] = []
280      for (let i = 0; i < teeth; i++) {
281        const a = step * i
282        pts.push(at(6, a - step * 0.3), at(8.4, a - step * 0.18), at(8.4, a + step * 0.18), at(6, a + step * 0.3))
283      }
284      return `<polygon points="${pts.join(' ')}" ${fill} stroke-linejoin="round"/><circle cx="12" cy="12" r="2.4" fill="#000" fill-opacity="0.35"/>`
285    }
286    case 'polygon': {
287      const sides = 5 + (n % 4)
288      const outer = Array.from({ length: sides }, (_, i) => at(8, (360 / sides) * i))
289      const inner = Array.from({ length: sides }, (_, i) => at(4.2, (360 / sides) * i + 180 / sides))
290      return `<polygon points="${outer.join(' ')}" ${fill} stroke-linejoin="round"/><polygon points="${inner.join(' ')}" fill="${v.color2}" fill-opacity="0.85"/>`
291    }
292    case 'burst': {
293      const rays = n + 4
294      const lines = Array.from({ length: rays }, (_, i) => {
295        const a = (360 / rays) * i
296        const len = i % 2 ? 6.2 : 8.4
297        const [x1, y1] = at(3, a).split(',')
298        const [x2, y2] = at(len, a).split(',')
299        return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="url(#${v.gid})" stroke-width="1.8" stroke-linecap="round"/>`
300      })
301      return lines.join('') + `<circle cx="12" cy="12" r="3" ${fill}/>`
302    }
303    case 'orbit': {
304      const moons = 2 + (n % 3)
305      const dots = Array.from({ length: moons }, (_, i) => {
306        const [x, y] = at(7.2, (360 / moons) * i).split(',')
307        return `<circle cx="${x}" cy="${y}" r="1.6" fill="${v.color2}"/>`
308      })
309      return `<circle cx="12" cy="12" r="7.2" fill="none" stroke="url(#${v.gid})" stroke-width="1.2" stroke-opacity="0.8"/><circle cx="12" cy="12" r="4" ${fill}/>` + dots.join('')
310    }
311    case 'crystal': {
312      const big = [at(8.6, 0), at(3.2, 90), at(8.6, 180), at(3.2, 270)]
313      const small = [at(5.6, 45), at(1.6, 135), at(5.6, 225), at(1.6, 315)]
314      return `<polygon points="${small.join(' ')}" fill="${v.color2}" fill-opacity="0.8"/><polygon points="${big.join(' ')}" ${fill} stroke-linejoin="round"/>`
315    }
316    case 'blob': {
317      // A smooth closed curve through jittered points: quadratic curves between their midpoints.
318      const k = 6 + (n % 3)
319      const pts = Array.from({ length: k }, (_, i) => at(8 * (v.jitter[i] ?? 1), (360 / k) * i).split(',').map(Number))
320      const mid = (p: number[], q: number[]) => `${(((p[0] ?? 0) + (q[0] ?? 0)) / 2).toFixed(2)},${(((p[1] ?? 0) + (q[1] ?? 0)) / 2).toFixed(2)}`
321      let d = `M${mid(pts[k - 1] ?? [], pts[0] ?? [])}`
322      for (let i = 0; i < k; i++) d += ` Q${(pts[i] ?? []).join(',')} ${mid(pts[i] ?? [], pts[(i + 1) % k] ?? [])}`
323      return `<path d="${d} Z" ${fill}/>`
324    }
325  }
326}
327
328/** A round avatar: a tinted disc, the agent's shape rotated its own way, and an optional center detail. */
329function avatarIcon(id: string) {
330  const v = avatar(id)
331  const detail =
332    v.detail === 'dot'
333      ? `<circle cx="12" cy="12" r="1.4" fill="#fff" fill-opacity="0.85"/>`
334      : v.detail === 'ring'
335        ? `<circle cx="12" cy="12" r="1.9" fill="none" stroke="#fff" stroke-opacity="0.8" stroke-width="0.9"/>`
336        : ''
337  return (
338    `<svg ${SVG_NS} width="24" height="24" viewBox="0 0 24 24">` +
339    `<defs><linearGradient id="${v.gid}" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${v.color}"/><stop offset="1" stop-color="${v.color2}"/></linearGradient></defs>` +
340    `<circle cx="12" cy="12" r="11.5" fill="${v.color}" fill-opacity="0.18"/>` +
341    `<g transform="rotate(${v.rotation} 12 12)">${avatarShape(v)}</g>${detail}</svg>`
342  )
343}
344
345/**
346 * Several avatars in one SVG, overlapping like a stack of profile pictures: each ringed in a dark
347 * edge so the one beneath shows where it ends. One image, so the surface lays out nothing per head.
348 */
349function avatarStack(ids: string[]) {
350  const size = 24
351  const step = 14
352  const width = size + step * Math.max(0, ids.length - 1)
353  // Each avatar's own SVG is nested at its offset; the first sits on top, as a stack reads.
354  const heads = ids
355    .map((id, i) => {
356      const inner = avatarIcon(id).replace(/^<svg [^>]*>/, '').replace(/<\/svg>$/, '')
357      return `<g transform="translate(${i * step} 0)"><circle class="ring" cx="12" cy="12" r="12"/><svg x="1" y="1" width="22" height="22" viewBox="0 0 24 24">${inner}</svg></g>`
358    })
359    .reverse()
360  // The ring follows the surface's theme: dark on a dark theme, white on a light one. A desktop is
361  // a web page that states its scheme, so the image answers its `prefers-color-scheme` itself.
362  const style = `<style>.ring{fill:#1E1F22}@media (prefers-color-scheme: light){.ring{fill:#FFFFFF}}</style>`
363  return `<svg ${SVG_NS} width="${width}" height="${size}" viewBox="0 0 ${width} ${size}">${style}${heads.join('')}</svg>`
364}
365
366const DIVIDER =
367  `<svg ${SVG_NS} width="2000" height="1" viewBox="0 0 2000 1" preserveAspectRatio="none">` +
368  `<rect width="2000" height="1" fill="#8A8A8A" fill-opacity="0.35"/></svg>`
369
370/** "Read 3 files, ran 2 commands", the header of a run of tool calls. */
371function groupTitle(steps: AgentStep[]) {
372  const count: Record<Kind, number> = { read: 0, edit: 0, run: 0, search: 0, web: 0, agent: 0, other: 0 }
373  for (const s of steps) count[kindOf(s.tool ?? '')]++
374  const plural = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`
375  const parts = [
376    count.read && `read ${plural(count.read, 'file', 'files')}`,
377    count.edit && `edited ${plural(count.edit, 'file', 'files')}`,
378    count.run && `ran ${plural(count.run, 'command', 'commands')}`,
379    count.search && `searched ${plural(count.search, 'time', 'times')}`,
380    count.web && `browsed ${plural(count.web, 'page', 'pages')}`,
381    count.agent && `messaged ${plural(count.agent, 'agent', 'agents')}`,
382    count.other && `used ${plural(count.other, 'tool', 'tools')}`,
383  ].filter(Boolean) as string[]
384  const text = parts.join(', ')
385  return text ? text.charAt(0).toUpperCase() + text.slice(1) : 'Worked'
386}
387
388function duration(ms: number) {
389  const s = Math.max(0, Math.round(ms / 1000))
390  if (s < 60) return `${s}s`
391  const m = Math.floor(s / 60)
392  if (m < 60) return `${m}m ${s % 60}s`
393  return `${Math.floor(m / 60)}h ${m % 60}m`
394}
395
396function ago(ms: number) {
397  const s = Math.max(0, Math.round(ms / 1000))
398  if (s < 10) return 'just now'
399  if (s < 60) return `${s}s ago`
400  const m = Math.floor(s / 60)
401  if (m < 60) return `${m} min ago`
402  const h = Math.floor(m / 60)
403  return h < 24 ? `${h} h ago` : `${Math.floor(h / 24)} d ago`
404}
405
406async function patch($: EngineInterface, id: string, fn: (row: AgentRow) => AgentRow) {
407  await update($, agents, list => list.map(row => (row.id === id ? fn(row) : row)))
408}
409
410function pushStep(row: AgentRow, step: AgentStep): AgentRow {
411  return { ...row, steps: [...row.steps, step].slice(-MAX_STEPS) }
412}
413
414// ── Kept across restarts ──────────────────────────────────────────────────────
415// The transcript holds what each agent did; it does not hold when. A few numbers
416// per agent go to the store, under the session's id, so a resumed session shows
417// real times. The steps themselves are rebuilt from the transcript.
418
419type Kept = { s?: number; e?: number; p?: AgentPhase; m?: string }
420
421async function remember($: EngineInterface, id: string, kept: Kept) {
422  try {
423    const sid = await $.session.id()
424    const key = `times:${sid}`
425    const all = ((await $.store.get(key)) ?? {}) as Record<string, Kept>
426    all[id] = { ...all[id], ...kept }
427    await $.store.set(key, all)
428
429    const index = ((await $.store.get('sessions')) ?? []) as string[]
430    if (index[index.length - 1] !== sid) {
431      const next = [...index.filter(s => s !== sid), sid]
432      for (const old of next.splice(0, Math.max(0, next.length - MAX_KEPT_SESSIONS))) await $.store.delete(`times:${old}`)
433      await $.store.set('sessions', next)
434    }
435  } catch {
436    // Times are a nicety; the panel works without them.
437  }
438}
439
440/** Rebuilds the agent list from the transcript: a resumed session starts with none in memory. */
441/** The agent an Agent call started: on the call when the session tracks it, else in what the call stored. */
442function spawnedId(use: { agentId?: string; result?: unknown }) {
443  if (use.agentId) return use.agentId
444  const r = use.result
445  if (r && typeof r === 'object') {
446    const rec = r as Record<string, unknown>
447    for (const key of ['agentId', 'agent_id']) if (typeof rec[key] === 'string' && rec[key]) return rec[key] as string
448  }
449  return undefined
450}
451
452type Restored = { agentCalls: number; rebuilt: number; unreadable: number }
453
454/**
455 * What an Agent call stored once it settled: its report, tool count and duration. A background
456 * call stores only its launch, so this is empty for one; the report then arrives later in main.
457 */
458function callRecord(result: unknown): { report?: string; toolCount?: number; durationMs?: number; model?: string } {
459  if (!result || typeof result !== 'object') return {}
460  const rec = result as Record<string, unknown>
461  // Every record names the model it ran on, a launch included.
462  const model = typeof rec.resolvedModel === 'string' && rec.resolvedModel ? rec.resolvedModel : undefined
463  // `async_launched` and `remote_launched` hold only the launch notice, never a report.
464  if (rec.status !== undefined && rec.status !== 'completed') return { model }
465  const handback = rec.handbackReport as { text?: unknown } | undefined
466  const blocks = Array.isArray(rec.content) ? (rec.content as { type?: unknown; text?: unknown }[]) : []
467  const content = blocks
468    .filter(b => b?.type === 'text' && typeof b.text === 'string')
469    .map(b => b.text as string)
470    .join('\n')
471    .trim()
472  return {
473    report: (typeof handback?.text === 'string' && handback.text.trim()) || content || undefined,
474    toolCount: typeof rec.totalToolUseCount === 'number' ? rec.totalToolUseCount : undefined,
475    durationMs: typeof rec.totalDurationMs === 'number' ? rec.totalDurationMs : undefined,
476    model,
477  }
478}
479
480/** A background agent's report, as it reached the main conversation: its hand-back, or its finished task's notice. */
481function reportFromMain(main: { role: string; text: string }[], id: string) {
482  for (let i = main.length - 1; i >= 0; i--) {
483    const msg = main[i]
484    if (!msg || msg.role !== 'user' || !msg.text.includes(id)) continue
485    const text = msg.text
486    const handback = /The report follows:\s*([\s\S]*?)(?:<\/agent-message>|$)/.exec(text)
487    if (handback?.[1]?.trim()) return handback[1].replace(/^ {2}/gm, '').trim()
488    const result = /<result>([\s\S]*?)<\/result>/.exec(text)
489    if (result?.[1]?.trim() && !/delivered to you as a message/i.test(result[1])) return result[1].trim()
490  }
491  return undefined
492}
493
494async function restore($: EngineInterface): Promise<Restored> {
495  const found: Restored = { agentCalls: 0, rebuilt: 0, unreadable: 0 }
496  const main = await $.session.messages()
497  if (!Array.isArray(main)) return found
498  const calls = main.flatMap(m => (m.role === 'assistant' ? (m.toolUses ?? []) : [])).filter(u => u.tool === 'Agent' || u.tool === 'Task')
499  found.agentCalls = calls.length
500  const uses = calls.map(u => ({ ...u, agentId: spawnedId(u) })).filter(u => u.agentId)
501  if (uses.length === 0) return found
502
503  const sid = await $.session.id().catch(() => '')
504  const kept = (sid ? ((await $.store.get(`times:${sid}`).catch(() => undefined)) ?? {}) : {}) as Record<string, Kept>
505  const live = await $.agent.list().catch(() => [])
506  const have = new Set((await read($, agents)).map(r => r.id))
507  const rows: AgentRow[] = []
508
509  for (const use of uses) {
510    const id = use.agentId
511    if (!id || have.has(id)) continue
512    have.add(id)
513    const input = use.input
514    const info = live.find(a => a.id === id)
515    const k = kept[id] ?? {}
516    const phase: AgentPhase = info ? phaseOf(info.status) : (k.p ?? (use.isError ? 'failed' : 'completed'))
517
518    const steps: AgentStep[] = []
519    let toolCount = 0
520    let answer: string | undefined
521    let note: string | undefined
522    const child = await $.session.messages({ agentId: id }).catch((err: unknown) => ({ deny: err instanceof Error ? err.message : String(err) }))
523    if (!Array.isArray(child)) note = 'deny' in child && child.deny ? child.deny : 'The engine returned no transcript for this agent.'
524    if (Array.isArray(child)) {
525      for (const msg of child) {
526        if (msg.role !== 'assistant') continue
527        if (msg.text.trim()) steps.push({ kind: 'say', text: msg.text.trim().slice(0, MAX_TEXT), at: 0 })
528        for (const t of msg.toolUses ?? []) {
529          if (t.tool === HANDBACK) {
530            answer = handbackReport(t.input) ?? answer
531            continue
532          }
533          toolCount++
534          const state = t.isError ? 'error' : t.text === undefined && isActive(phase) ? 'running' : 'done'
535          steps.push({ kind: 'tool', id: t.tool_use_id, tool: t.tool, text: describeTool(t.tool, t.input) || t.tool, at: 0, state })
536        }
537      }
538    } else {
539      found.unreadable++
540    }
541    // Without a hand-back, the agent's last words are its report.
542    const last = steps[steps.length - 1]
543    if (answer === undefined && last?.kind === 'say' && !isActive(phase)) {
544      answer = last.text
545      steps.pop()
546    } else if (answer !== undefined && last?.kind === 'say' && last.text === answer.slice(0, MAX_TEXT)) {
547      steps.pop()
548    }
549    if (Array.isArray(child) && child.length === 0) note = 'The engine kept no steps for this agent.'
550
551    // The main conversation still holds what the call stored and the report handed back.
552    const record = callRecord(use.result)
553    if (toolCount === 0 && record.toolCount !== undefined) toolCount = record.toolCount
554    answer ??= record.report ?? reportFromMain(main, id)
555
556    const description = typeof input.description === 'string' ? input.description : ''
557    const type = typeof input.subagent_type === 'string' ? input.subagent_type : info?.type ?? 'general-purpose'
558    rows.push({
559      id,
560      description: titleCase(description || info?.description || type),
561      type,
562      model: k.m ?? record.model ?? (typeof input.model === 'string' ? input.model : undefined),
563      prompt: typeof input.prompt === 'string' ? input.prompt : '',
564      phase,
565      startedAt: k.s ?? 0,
566      endedAt: isActive(phase) ? undefined : k.e,
567      current: isActive(phase) ? 'Working…' : phase === 'completed' ? firstLine(answer) || 'Done' : phase === 'killed' ? 'Stopped' : 'Failed',
568      toolCount,
569      steps: steps.slice(-MAX_STEPS),
570      answer,
571      restored: true,
572      durationMs: record.durationMs,
573      note: steps.length === 0 ? note : undefined,
574    })
575  }
576  if (rows.length > 0) await update($, agents, list => [...rows, ...list.filter(r => !rows.some(n => n.id === r.id))])
577  found.rebuilt = rows.length
578  return found
579}
580
581/** Reconciles rows with the engine's own list: agents the hooks missed, and ones stopped from outside. */
582async function sync($: EngineInterface) {
583  const live = await $.agent.list()
584  const now = await $.clock.now()
585  // Write only when something moved: every write redraws the pane.
586  const current = await read($, agents)
587  const ids = new Set(current.map(row => row.id))
588  const moved = live.some(info => {
589    if (!ids.has(info.id)) return !info.teammateId
590    return current.find(row => row.id === info.id)?.phase !== phaseOf(info.status)
591  })
592  if (!moved) return
593  await update($, agents, list => {
594    const known = new Map(list.map(row => [row.id, row]))
595    const next = list.map(row => {
596      const info = live.find(a => a.id === row.id)
597      if (!info) return row
598      const phase = phaseOf(info.status)
599      if (phase === row.phase) return row
600      return { ...row, phase, endedAt: isActive(phase) ? undefined : (row.endedAt ?? now) }
601    })
602    for (const info of live) {
603      if (known.has(info.id) || info.teammateId) continue
604      const phase = phaseOf(info.status)
605      next.push({
606        id: info.id,
607        description: titleCase(info.description || info.name || info.type),
608        type: info.type,
609        prompt: '',
610        phase,
611        startedAt: now,
612        endedAt: isActive(phase) ? undefined : now,
613        current: isActive(phase) ? 'Working…' : '',
614        toolCount: 0,
615        steps: [],
616      })
617    }
618    return next
619  })
620}
621
622// ── Search ────────────────────────────────────────────────────────────────────
623// The field filters as the person types. The filter itself waits a beat so a fast typist does
624// not redraw the list on every key; what they typed is kept here meanwhile, so a redraw in that
625// beat shows their text and not the last filter's.
626const SEARCH_DEBOUNCE_MS = 120
627let draft: string | undefined
628let pendingFilter: { cancel: () => void } | undefined
629const SEARCH_ICON =
630  `<svg ${SVG_NS} width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#9AA0A6" stroke-width="2.2" stroke-linecap="round">` +
631  `<circle cx="10.5" cy="10.5" r="6.5"/><path d="M15.5 15.5L20 20"/></svg>`
632
633// ── Elapsed times ─────────────────────────────────────────────────────────────
634// A heartbeat from session.start moves them every few seconds while anything runs. Should that
635// timer ever stop, the times would freeze, so every drawing that shows a running agent also arms
636// a one-shot refresh of its own: whichever fires first moves the times, and the other stands down.
637let lastBeat = 0
638let armed: { cancel: () => void } | undefined
639let lastBeatError = ''
640async function beat($: EngineInterface) {
641  armed?.cancel()
642  armed = undefined
643  try {
644    const list = await read($, agents)
645    if (list.length === 0) return
646    // Reconcile first, whatever the rows say: one marked done too soon (a turn that ended while
647    // the agent waits on work of its own) comes back here, and its times move again.
648    await sync($)
649    if (!(await read($, agents)).some(row => isActive(row.phase))) return
650    lastBeat = await $.clock.now()
651    await update($, tick, n => n + 1)
652    // The tick redraws the drawings that read it; invalidate is the engine's own word for a
653    // countdown, in case a surface holds a drawing that did not subscribe.
654    $.ui.invalidate('ui.render')
655    lastBeatError = ''
656  } catch (err) {
657    lastBeatError = err instanceof Error ? err.message : String(err)
658  }
659}
660function armRefresh($: EngineInterface) {
661  if (armed) return
662  armed = $.clock.after(TICK_MS, () => {
663    armed = undefined
664    void beat($).catch(() => undefined)
665  })
666}
667
668/**
669 * Sends the person's message to a running agent, as the SendMessage tool delivers one, and
670 * records it in the agent's timeline as "You →". Answers why not, when it was not delivered.
671 */
672async function deliver($: EngineInterface, id: string, text: string): Promise<string | undefined> {
673  const message = text.trim()
674  if (!message) return undefined
675  const sent = await $.session.send({ to: { agentId: id }, text: message }).catch((err: unknown) => ({
676    isDelivered: false as const,
677    reason: err instanceof Error ? err.message : String(err),
678  }))
679  if (!sent.isDelivered) return sent.reason
680  const row = (await read($, agents)).find(r => r.id === id)
681  const at = row ? (await $.clock.now()) - row.startedAt : 0
682  await patch($, id, r => pushStep(r, { kind: 'you', text: message, at }))
683  return undefined
684}
685let restoreCount = 0
686
687async function reset($: EngineInterface) {
688  draft = ''
689  pendingFilter?.cancel()
690  pendingFilter = undefined
691  await update($, agents, () => [])
692  await update($, selected, () => null)
693  await update($, toggled, () => [])
694  await update($, autoOpened, () => false)
695  await update($, query, () => '')
696  await update($, bandHidden, () => false)
697}
698
699// A short log of what reached the pane (renders, focus moves, presses) for `/subagents debug`.
700// Module memory: a reload starts it over, which is what a fresh reproduction wants anyway.
701const trace: string[] = []
702let traceStart = 0
703async function traceEvent($: EngineInterface, line: string) {
704  const now = await $.clock.now()
705  if (traceStart === 0) traceStart = now
706  trace.push(`+${((now - traceStart) / 1000).toFixed(2)}s ${line}`)
707  if (trace.length > 40) trace.splice(0, trace.length - 40)
708}
709
710export const register: Register = on => {
711  on('session.start', async ($, e, next) => {
712    await $.command.register({
713      name: 'subagents',
714      description: "Open the side panel with this session's subagents",
715    })
716    await restore($).catch(() => undefined)
717    await sync($).catch(() => undefined)
718
719    // Elapsed times move every few seconds, and only while something runs.
720    $.clock.every(TICK_MS, () => {
721      void beat($).catch(() => undefined)
722    })
723
724    return next(e)
725  })
726
727  // A /resume inside a running session swaps the conversation: drop the old list, rebuild the resumed one.
728  on('session.end', async ($, e, next) => {
729    if (e.reason === 'clear' || e.reason === 'resume') await reset($)
730    return next(e)
731  })
732
733  on('classic.SessionStart', async ($, e, next) => {
734    if (e.source === 'resume' || e.source === 'fork' || e.source === 'compact') await restore($).catch(() => undefined)
735    return next(e)
736  }).catch(($, e, next) => next(e))
737
738  // Opening the panel also catches up with the transcript, and says what it found when that adds anything or fails.
739  on('ui.message', async ($, e, next) => {
740    if (e.requestId !== PANE || e.element !== 'agent-chat') return next(e)
741    const data = e.data as { type?: unknown; agentId?: unknown; text?: unknown }
742    if (data?.type !== 'send' || typeof data.agentId !== 'string' || typeof data.text !== 'string') return {}
743    const reason = await deliver($, data.agentId, data.text)
744    if (!reason) return {}
745    $.ui.toast(`Message not delivered: ${reason}`)
746    // Put the text back in the box so nothing typed is lost.
747    return {
748      props: { agentId: data.agentId, placeholder: 'Message this subagent', terminal: e.surface === 'terminal', restore: { text: data.text, n: ++restoreCount } },
749    }
750  }).catch(($, e, next) => next(e))
751
752  // Which of the pane's elements holds the ring, so the terminal's search field can light its border.
753  // A person's move names `element`; this plugin's own `$.ui.focus` names `key`.
754  on('ui.focus', async ($, e, next) => {
755    if (e.requestId !== PANE) return next(e)
756    const moved = e as { element?: string; key?: string }
757    const to = moved.element ?? moved.key ?? null
758    await traceEvent($, `focus -> ${to ?? '(pane)'}`)
759    const result = await next(e)
760    if (!result.deny) await update($, focusedKey, () => to)
761    return result
762  }).catch(($, e, next) => next(e))
763
764  on('ui.press', async ($, e, next) => {
765    if (e.requestId === PANE) await traceEvent($, `press ${e.element} (${e.surface})`)
766    return next(e)
767  }).catch(($, e, next) => next(e))
768
769  on('command.run', { command: 'subagents' }, async ($, e) => {
770    if (e.args.trim() === 'debug') {
771      const since = lastBeat ? `${((await $.clock.now()) - lastBeat) / 1000}s ago` : 'not since the mod loaded'
772      const head = `Elapsed times last moved ${since}.${lastBeatError ? ` Last heartbeat failed: ${lastBeatError}` : ''}`
773      return { text: trace.length ? [head, 'Last events in the Subagents panel:', ...trace].join('\n') : `${head}\nNo panel events recorded since the mod loaded.` }
774    }
775    await update($, bandHidden, () => false)
776    let extra = ''
777    try {
778      const found = await restore($)
779      if (found.rebuilt > 0) extra = ` Rebuilt ${found.rebuilt} from the transcript.`
780      else if (found.agentCalls > 0 && (await read($, agents)).length === 0)
781        extra = ` Found ${found.agentCalls} subagent call(s) in the transcript but none with an agent id${found.unreadable ? `; ${found.unreadable} unreadable` : ''}.`
782    } catch (err) {
783      extra = ` Could not read the transcript: ${err instanceof Error ? err.message : String(err)}`
784    }
785    const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true })
786    return { text: (opened.isPlaced ? 'Subagents panel opened.' : `Subagents panel is open but not shown: ${opened.reason}`) + extra }
787  })
788
789  on('agent.spawn', async ($, e, next) => {
790    const started = await next(e)
791    if (!started.agentId || e.isTeammate) return started
792
793    const now = await $.clock.now()
794    const row: AgentRow = {
795      id: started.agentId,
796      description: titleCase(e.description || e.name || e.subagentType),
797      type: e.subagentType,
798      model: started.model,
799      prompt: e.prompt,
800      phase: 'running',
801      startedAt: now,
802      current: 'Starting…',
803      toolCount: 0,
804      steps: [],
805    }
806    await update($, agents, list => [...list.filter(r => r.id !== row.id), row])
807    void remember($, row.id, { s: now, m: started.model })
808
809    if (!(await read($, autoOpened))) {
810      await update($, autoOpened, () => true)
811      void $.ui
812        .open({ id: PANE, title: TITLE })
813        .then(opened => {
814          if (!opened.isPlaced) $.ui.log(`subagents-panel: panel waits unshown: ${opened.reason}`)
815        })
816        .catch(() => undefined)
817    }
818    return started
819  }).catch(($, e, next) => next(e))
820
821  on('tool.call', async ($, e, next) => {
822    const id = e.agentId
823    if (!id) return next(e)
824    const list = await read($, agents)
825    const row = list.find(r => r.id === id)
826    if (!row) return next(e)
827
828    const { tool, tool_use_id, ...input } = e as { tool: string; tool_use_id: string } & Record<string, unknown>
829    if (String(tool) === HANDBACK) {
830      const report = handbackReport(input)
831      if (report) await patch($, id, r => ({ ...r, answer: report.slice(0, 40000), current: firstLine(report) || r.current }))
832      return next(e)
833    }
834    const text = describeTool(String(tool), input) || String(tool)
835    const at = (await $.clock.now()) - row.startedAt
836    await patch($, id, r =>
837      pushStep(
838        { ...r, phase: 'running', endedAt: undefined, current: text, toolCount: r.toolCount + 1 },
839        { kind: 'tool', id: tool_use_id, tool: String(tool), text, at, state: 'running' },
840      ),
841    )
842
843    const ran = await next(e)
844    const failed = 'deny' in ran && ran.deny !== undefined ? true : Boolean(ran.isError)
845    await patch($, id, r => ({
846      ...r,
847      steps: r.steps.map(s => (s.id === tool_use_id ? { ...s, state: failed ? 'error' : 'done' } : s)),
848    }))
849    return ran
850  }).catch(($, e, next) => next(e))
851
852  // The agent's own words between tool calls: its narration, and the freshest "what it is doing".
853  on('turn.step', async function* ($, e, next) {
854    const step = yield* next(e)
855    const id = e.agentId
856    if (id && step.answer.trim()) {
857      const text = step.answer.trim().slice(0, MAX_TEXT)
858      const list = await read($, agents)
859      const row = list.find(r => r.id === id)
860      if (row) {
861        const at = (await $.clock.now()) - row.startedAt
862        await patch($, id, r => pushStep({ ...r, current: firstLine(text) }, { kind: 'say', text, at }))
863      }
864    }
865    return step
866  })
867
868  on('turn.complete', async ($, e, next) => {
869    const result = await next(e)
870    const id = e.agentId
871    if (!id) return result
872    const now = await $.clock.now()
873    const answer = e.answer.trim()
874    // A turn ends whenever the agent stops to wait (on a background command of its own, say) and
875    // it picks up again later. Only the engine knows which this is: while it still lists the
876    // agent as running or waiting, the row stays in progress, shown as waiting.
877    const info = (await $.agent.list().catch(() => [])).find(a => a.id === id)
878    const still = info ? phaseOf(info.status) : undefined
879    if (still && isActive(still)) {
880      await patch($, id, r => ({ ...r, phase: 'waiting', endedAt: undefined, current: firstLine(answer) || r.current }))
881      return result
882    }
883    const phase: AgentPhase = e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'killed' : 'failed'
884    await patch($, id, r => {
885      // The last narration is usually the report itself: keep it once, as the result.
886      const last = r.steps[r.steps.length - 1]
887      const steps = last?.kind === 'say' && answer && last.text === answer.slice(0, MAX_TEXT) ? r.steps.slice(0, -1) : r.steps
888      const current = phase === 'completed' ? firstLine(answer) || 'Done' : phase === 'killed' ? 'Stopped' : 'Failed'
889      return { ...r, phase, endedAt: now, answer: answer || r.answer, steps, current }
890    })
891    void remember($, id, { e: now, p: phase })
892    return result
893  })
894
895  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
896    void traceEvent($, `render ${e.surface} focused=${e.props.isFocused} placement=${e.props.placement}`)
897    const els = $.ui.resolve(e)
898    const { Box, Text, Button, Markdown } = els
899    // Graphical surfaces (desktop, VS Code, mobile) draw SVG; the terminal draws glyphs. Decide by
900    // surface, not by the table: at run time the terminal's table answers `Svg` too, and draws nothing.
901    const isTerminal = e.surface === 'terminal'
902    const Svg = !isTerminal && 'Svg' in els ? els.Svg : undefined
903    // The terminal and a desktop run a `Client` module on the surface itself: elapsed times tick
904    // there, on the surface's clock, whether or not the engine raises anything meanwhile.
905    const Client = (isTerminal || e.surface === 'desktop') && 'Client' in els ? els.Client : undefined
906    const liveElapsed = (key: string, baseMs: number, prefix = '', dim = true) =>
907      Client ? (
908        <Client key={key} module="./elapsed.tsx" props={{ baseMs, prefix, dim }} />
909      ) : (
910        <Text dimColor={dim}>
911          {prefix}
912          {duration(baseMs)}
913        </Text>
914      )
915
916    const list = await read($, agents)
917    const chosen = await read($, selected)
918    const open = await read($, toggled)
919    await read($, tick) // redraw as elapsed times move
920    if (list.some(r => isActive(r.phase))) armRefresh($)
921    const now = await $.clock.now()
922    const width = Math.max(20, e.props.bodyColumns)
923    const flip = (key: string) => update($, toggled, keys => (keys.includes(key) ? keys.filter(k => k !== key) : [...keys, key]))
924
925    const face = (row: AgentRow) => {
926      // A desktop draws no Svg whose alt is empty, so every Svg here names itself.
927      if (Svg) return <Svg source={avatarIcon(row.id)} alt="Agent avatar" width={18} height={18} />
928      const a = avatar(row.id)
929      return <Text color={a.color}>{a.glyph}</Text>
930    }
931
932    const kindChip = (kind: Kind, size = 20) => {
933      if (Svg) return <Svg source={kindIcon(kind)} alt={kind} width={size} height={size} />
934      // The terminal draws the same colors: blue files, green commands, purple the rest.
935      return <Text color={KIND_COLOR[kind]}>{KIND_GLYPH[kind]}</Text>
936    }
937
938    const statusMark = (row: AgentRow) => {
939      if (row.phase === 'running') return <Text color="claude">{RUNNING}</Text>
940      if (row.phase === 'waiting') return <Text color="warning">◌</Text>
941      if (row.phase === 'failed') return <Text color="error">✗</Text>
942      if (row.phase === 'killed') return <Text color="inactive">■</Text>
943      return <Text color="success">✓</Text>
944    }
945
946    const divider = () =>
947      Svg ? (
948        <Box marginTop={1}>
949          <Svg source={DIVIDER} alt="Divider" height={1} />
950        </Box>
951      ) : (
952        <Text dimColor>{'─'.repeat(Math.max(1, width - 2))}</Text>
953      )
954
955    const agent = chosen ? list.find(r => r.id === chosen) : undefined
956    // The mobile app draws no text field yet.
957    const Input = e.surface !== 'mobile' && 'Input' in els ? els.Input : undefined
958
959    // Switching views removes the element that held the focus, and the surface then lets go of
960    // the pane: the next click only took the focus back (a "double click" on desktop). Hand the
961    // ring to an element the new view draws; `$.ui.focus` waits for that drawing.
962    const handFocus = async (key: string) => {
963      const moved = await $.ui.focus({ requestId: PANE, key }).catch((err: unknown) => ({ deny: err instanceof Error ? err.message : String(err) }))
964      await traceEvent($, `hand focus -> ${key}: ${moved.deny ? `refused (${moved.deny})` : 'ok'}`)
965    }
966    const openAgent = async (id: string) => {
967      await update($, selected, () => id)
968      await handFocus('back')
969    }
970    const goBack = async (from: string) => {
971      await update($, selected, () => null)
972      await handFocus(`open-${from}`)
973    }
974
975    // ── Detail: one agent's progress ──────────────────────────────────────────
976    if (agent) {
977      // The default agent type says nothing; the header shows a type only when it is a chosen one.
978      const meta = [agent.model, agent.type === 'general-purpose' ? '' : agent.type].filter(Boolean).join(' · ')
979      const nameRoom = Math.max(8, width - meta.length - 10)
980      // Kept start and end times first; else the duration the Agent call's record states.
981      const hasTimes = agent.startedAt > 0 && (isActive(agent.phase) || agent.endedAt !== undefined)
982      const ran = hasTimes ? (agent.endedAt ?? now) - agent.startedAt : (agent.durationMs ?? 0)
983      const ranLabel = isActive(agent.phase)
984        ? `${agent.phase === 'waiting' ? 'Waiting' : 'Running'}${hasTimes ? ` for ${duration(ran)}` : ''}`
985        : !hasTimes && agent.durationMs === undefined
986          ? agent.phase === 'completed'
987            ? 'Completed'
988            : agent.phase === 'failed'
989              ? 'Failed'
990              : 'Stopped'
991          : agent.phase === 'completed'
992            ? `Ran for ${duration(ran)}`
993            : `${agent.phase === 'failed' ? 'Failed' : 'Stopped'} after ${duration(ran)}`
994
995      // Runs of consecutive tool calls fold into one group, as Codex shows them.
996      type Block = { kind: 'say'; step: AgentStep } | { kind: 'you'; step: AgentStep } | { kind: 'tools'; key: string; steps: AgentStep[] }
997      const blocks: Block[] = []
998      agent.steps.forEach((step, i) => {
999        const last = blocks[blocks.length - 1]
1000        if (step.kind === 'say') blocks.push({ kind: 'say', step })
1001        else if (step.kind === 'you') blocks.push({ kind: 'you', step })
1002        else if (last?.kind === 'tools') last.steps.push(step)
1003        else blocks.push({ kind: 'tools', key: `${agent.id}:g${i}`, steps: [step] })
1004      })
1005      const lastTools = [...blocks].reverse().find(b => b.kind === 'tools')
1006      const taskKey = `${agent.id}:task`
1007
1008      return (
1009        <Box flexDirection="column" paddingX={1} minHeight={isActive(agent.phase) && Client ? e.props.scroll.bodyRows : undefined}>
1010          <Box flexDirection="row" alignItems="center" gap={1}>
1011            {isTerminal ? (
1012              <Button key="back" plain hotkey="b" label="←" onPress={() => goBack(agent.id)} />
1013            ) : (
1014              <Button key="back" plain label="←" onPress={() => goBack(agent.id)} />
1015            )}
1016            {face(agent)}
1017            <Text bold wrap="truncate-end">
1018              {clip(agent.description, nameRoom)}
1019            </Text>
1020            <Box flexGrow={1} />
1021            <Text dimColor wrap="truncate-end">
1022              {meta}
1023            </Text>
1024          </Box>
1025          {divider()}
1026
1027          <Box flexDirection="row" alignItems="center" marginTop={1}>
1028            {statusMark(agent)}
1029            {isActive(agent.phase) && hasTimes ? (
1030              liveElapsed('elapsed-detail', ran, ` ${agent.phase === 'waiting' ? 'Waiting' : 'Running'} for `)
1031            ) : (
1032              <Text dimColor> {ranLabel}</Text>
1033            )}
1034            {agent.toolCount > 0 && <Text dimColor> · {agent.toolCount} tool calls</Text>}
1035          </Box>
1036
1037          {agent.prompt !== '' && (
1038            <Box flexDirection="column" marginTop={1}>
1039              <Button
1040                key="task"
1041                plain
1042                dimColor
1043                label={`${open.includes(taskKey) ? '▾' : '▸'} Task`}
1044                onPress={() => flip(taskKey)}
1045              />
1046              {open.includes(taskKey) && (
1047                <Box paddingLeft={2} marginTop={1}>
1048                  <Markdown text={agent.prompt.slice(0, MAX_TEXT)} dimColor />
1049                </Box>
1050              )}
1051            </Box>
1052          )}
1053
1054          {blocks.length === 0 && !isActive(agent.phase) && agent.note && (
1055            <Box marginTop={1}>
1056              <Text dimColor italic>
1057                Steps unavailable after resume: {agent.note}
1058              </Text>
1059            </Box>
1060          )}
1061
1062          {blocks.length === 0 && isActive(agent.phase) && (
1063            <Box marginTop={1}>
1064              <Text dimColor italic>
1065                {agent.current}
1066              </Text>
1067            </Box>
1068          )}
1069
1070          {blocks.map(block => {
1071            if (block.kind === 'you') {
1072              return (
1073                <Box marginTop={1} flexDirection="row">
1074                  <Text color="claude" bold>
1075                    You →{' '}
1076                  </Text>
1077                  <Text>{block.step.text}</Text>
1078                </Box>
1079              )
1080            }
1081            if (block.kind === 'say') {
1082              return (
1083                <Box marginTop={1}>
1084                  <Markdown text={block.step.text} />
1085                </Box>
1086              )
1087            }
1088            // The live group stays open while the agent works; the person's toggle flips it.
1089            const isLive = block === lastTools && isActive(agent.phase)
1090            const isOpen = isLive !== open.includes(block.key)
1091            const running = block.steps.some(s => s.state === 'running')
1092            const failed = block.steps.filter(s => s.state === 'error').length
1093            const kinds = KINDS.filter(k => block.steps.some(s => kindOf(s.tool ?? '') === k))
1094            const label = `${isOpen ? '▾' : '▸'} ${groupTitle(block.steps)}`
1095            // The label and its marks share a row; the row wraps, so the marks move to a second
1096            // line only when the surface itself runs out of room. Neither part shrinks.
1097            return (
1098              <Box flexDirection="column" marginTop={1}>
1099                <Box flexDirection="row" flexWrap="wrap" alignItems="center" columnGap={1}>
1100                  <Box flexShrink={0}>
1101                    <Button key={block.key} plain dimColor label={label} onPress={() => flip(block.key)} />
1102                  </Box>
1103                  <Box flexDirection="row" flexShrink={0} alignItems="center" gap={1}>
1104                    {kinds.map(k => kindChip(k, 16))}
1105                    {failed > 0 && <Text color="error">{failed} failed</Text>}
1106                    {running && <Text color="claude">{RUNNING}</Text>}
1107                  </Box>
1108                </Box>
1109                {isOpen && (
1110                  <Box flexDirection="column" paddingLeft={2} marginTop={Svg ? 1 : 0} gap={Svg ? 1 : 0}>
1111                    {block.steps.slice(-40).map(s => {
1112                      const kind = kindOf(s.tool ?? '')
1113                      if (Svg) {
1114                        return (
1115                          <Box flexDirection="row" alignItems="center" gap={1}>
1116                            {kindChip(kind)}
1117                            <Text color={s.state === 'error' ? 'error' : undefined} dimColor={s.state === 'done'} wrap="truncate-end">
1118                              {clip(s.text, width - 10)}
1119                            </Text>
1120                            <Box flexGrow={1} />
1121                            {s.state === 'running' && <Text color="claude">{RUNNING}</Text>}
1122                            {s.state === 'error' && <Text color="error">✗</Text>}
1123                          </Box>
1124                        )
1125                      }
1126                      return (
1127                        <Box flexDirection="row">
1128                          <Text color={s.state === 'error' ? 'error' : s.state === 'running' ? 'claude' : KIND_COLOR[kind]}>
1129                            {s.state === 'error' ? '✗ ' : s.state === 'running' ? `${RUNNING} ` : `${KIND_GLYPH[kind]} `}
1130                          </Text>
1131                          <Text dimColor={s.state === 'done'} wrap="truncate-end">
1132                            {clip(s.text, width - 8)}
1133                          </Text>
1134                        </Box>
1135                      )
1136                    })}
1137                  </Box>
1138                )}
1139              </Box>
1140            )
1141          })}
1142
1143          {agent.answer && !isActive(agent.phase) && (
1144            <Box marginTop={1} flexDirection="column">
1145              <Markdown text={agent.answer.slice(0, 40000)} />
1146            </Box>
1147          )}
1148
1149          {/* A running agent takes messages, as the SendMessage tool delivers them: the engine
1150              queues the text for the agent's loop, which reads it at its next step. */}
1151          {/* A running agent takes messages. The box is a surface module of its own: it wraps what
1152              is typed and grows a line at a time, full width, at the foot of the pane. Where no
1153              surface module runs (VS Code, mobile), a one-line field stands in. */}
1154          {isActive(agent.phase) && (Client || Input) && <Box flexGrow={1} />}
1155          {isActive(agent.phase) && Client && (
1156            <Box marginTop={1} width="100%">
1157              <Client key="agent-chat" module="./composer.tsx" width="100%" props={{ agentId: agent.id, placeholder: 'Message this subagent', terminal: isTerminal }} />
1158            </Box>
1159          )}
1160          {isActive(agent.phase) && !Client && Input && (
1161            <Box marginTop={1} width="100%">
1162              <Input
1163                key="agent-chat-field"
1164                placeholder="Message this subagent"
1165                submitLabel="send"
1166                onSubmit={value => {
1167                  void deliver($, agent.id, value).then(reason => {
1168                    if (reason) $.ui.toast(`Message not delivered: ${reason}`)
1169                  })
1170                }}
1171              />
1172            </Box>
1173          )}
1174        </Box>
1175      )
1176    }
1177
1178    // ── Overview: every subagent, in two sections ─────────────────────────────
1179    // Search: every word must appear in the agent's name, type, current line, task or report.
1180    // The mobile app draws no text field yet, so it lists everything.
1181    const raw = Input ? await read($, query) : ''
1182    const search = raw.trim()
1183    const typed = draft ?? raw
1184    // Lit while the person's keys are in the field: the pane has the keyboard and its ring is on it.
1185    const searchFocused = e.props.isFocused && (await read($, focusedKey)) === 'search'
1186    const setSearch = (value: string, now = false) => {
1187      draft = value
1188      pendingFilter?.cancel()
1189      pendingFilter = undefined
1190      if (now) {
1191        void update($, query, () => value)
1192        return
1193      }
1194      pendingFilter = $.clock.after(SEARCH_DEBOUNCE_MS, () => {
1195        pendingFilter = undefined
1196        void update($, query, () => value)
1197      })
1198    }
1199    const words = search.toLowerCase().split(/\s+/).filter(Boolean)
1200    const matches = (r: AgentRow) => {
hooks/elapsed.tsx 47 lines
1import type { ClientModule } from 'claude-code'
2
3/**
4 * A live elapsed-time readout that runs on the surface itself.
5 *
6 * The hooks module hands it the elapsed time as of its drawing (`baseMs`); from there it counts
7 * up on the surface's own frame clock, a second at a time, with no event from the engine needed.
8 * So "Waiting for 1m 12s" keeps moving while the agent waits on work of its own and nothing else
9 * redraws the pane. Each new drawing re-bases it on the hooks module's figure, so it never drifts.
10 */
11type Props = { baseMs: number; prefix?: string; suffix?: string; dim?: boolean }
12type State = { base: number; ticks: number }
13
14function duration(ms: number) {
15  const s = Math.max(0, Math.round(ms / 1000))
16  if (s < 60) return `${s}s`
17  const m = Math.floor(s / 60)
18  if (m < 60) return `${m}m ${s % 60}s`
19  return `${Math.floor(m / 60)}h ${m % 60}m`
20}
21
22const Elapsed: ClientModule<Props, State> = (props, surface) => {
23  const { Text } = surface.elements
24  const state = surface.state
25  if (state === undefined) {
26    // Start the clock once, on the first call; it lives as long as the instance.
27    surface.every(1000, () => {
28      const now = surface.state
29      if (now) surface.setState({ base: now.base, ticks: now.ticks + 1 })
30    })
31    surface.setState({ base: props.baseMs, ticks: 0 })
32  } else if (state.base !== props.baseMs) {
33    // A new drawing from the hooks module: count on from its figure.
34    surface.setState({ base: props.baseMs, ticks: 0 })
35  }
36  const shown = (state && state.base === props.baseMs ? state.base + state.ticks * 1000 : props.baseMs)
37  return (
38    <Text dimColor={props.dim ?? true}>
39      {props.prefix ?? ''}
40      {duration(shown)}
41      {props.suffix ?? ''}
42    </Text>
43  )
44}
45
46export default Elapsed
47
hooks/composer.tsx 111 lines
1import type { ClientModule } from 'claude-code'
2
3/**
4 * The message box under a running agent's detail: a full-width box that wraps what you type
5 * and grows a line at a time, instead of a one-line field that scrolls sideways.
6 *
7 * It runs on the surface itself and edits its own text: characters and pastes insert at the
8 * caret, ←/→ and Home/End move it, Backspace/Delete remove, Shift+Enter starts a new line, and
9 * Enter posts the text to the hooks module, which delivers it to the agent. Should delivery
10 * fail, the hooks module hands the text back (`restore`) and it reappears here.
11 */
12type Props = {
13  agentId: string
14  /** Drawn in a terminal: Shift+Enter is not told apart from Enter there, so the hint differs. */
15  terminal?: boolean
16  placeholder?: string
17  /** Text to put back in the box, once per `n`. */
18  restore?: { text: string; n: number }
19}
20type State = { text: string; caret: number; active: boolean; restored: number }
21
22// Keys a surface reports by name. `space` is a key name too, typed as a space; the rest insert
23// nothing. A string that is none of these is a character or a paste, inserted as it came.
24const NAMED = new Set([
25  'up', 'down', 'left', 'right', 'return', 'enter', 'tab', 'backspace', 'delete',
26  'pageup', 'pagedown', 'home', 'end', 'escape', 'insert', 'space',
27  'f1', 'f2', 'f3', 'f4', 'f5', 'f6', 'f7', 'f8', 'f9', 'f10', 'f11', 'f12',
28])
29const CARET = '▏'
30
31const Composer: ClientModule<Props, State> = (props, surface) => {
32  const { Box, Text } = surface.elements
33  let state = surface.state
34
35  if (state === undefined) {
36    state = { text: '', caret: 0, active: false, restored: 0 }
37    surface.onPointer(event => {
38      const now = surface.state
39      if (now && event.type === 'down' && !now.active) surface.setState({ ...now, active: true })
40    })
41    surface.onKey(event => {
42      const now = surface.state
43      if (!now) return
44      const { text, caret } = now
45      const put = (next: string, at: number) => surface.setState({ ...now, text: next, caret: at, active: true })
46      const key = event.key
47      const newline = () => put(text.slice(0, caret) + '\n' + text.slice(caret), caret + 1)
48      // Ctrl+J is a line feed in a terminal: a new line, never a send.
49      if (event.ctrl && key === 'j') return newline()
50      if (key === 'return' || key === 'enter') {
51        // A new line: Shift+Enter where the surface tells it apart, Alt+Enter anywhere, and in a
52        // terminal (which sends Shift+Enter as plain Enter) a backslash right before the caret,
53        // as Claude Code's own prompt does: the backslash becomes the line break.
54        if (event.shift || event.meta) return newline()
55        if (caret > 0 && text[caret - 1] === '\\') return put(text.slice(0, caret - 1) + '\n' + text.slice(caret), caret)
56        if (text.trim()) {
57          surface.post({ type: 'send', agentId: props.agentId, text })
58          surface.setState({ ...now, text: '', caret: 0, active: true })
59        }
60        return
61      }
62      if (key === 'escape') return surface.setState({ ...now, active: false })
63      if (key === 'backspace') return caret > 0 ? put(text.slice(0, caret - 1) + text.slice(caret), caret - 1) : undefined
64      if (key === 'delete') return caret < text.length ? put(text.slice(0, caret) + text.slice(caret + 1), caret) : undefined
65      if (key === 'left') return put(text, Math.max(0, caret - 1))
66      if (key === 'right') return put(text, Math.min(text.length, caret + 1))
67      if (key === 'home') return put(text, text.lastIndexOf('\n', caret - 1) + 1)
68      if (key === 'end') {
69        const nl = text.indexOf('\n', caret)
70        return put(text, nl === -1 ? text.length : nl)
71      }
72      if (key === 'space') return put(text.slice(0, caret) + ' ' + text.slice(caret), caret + 1)
73      if (event.ctrl || event.meta || NAMED.has(key)) return
74      // A character, or a paste arriving as one string: insert it at the caret.
75      const typed = key.replace(/\r\n?/g, '\n')
76      put(text.slice(0, caret) + typed + text.slice(caret), caret + typed.length)
77    })
78    surface.setState(state)
79  }
80
81  // Delivery failed: the hooks module handed the text back. Take it once.
82  if (props.restore && props.restore.n !== state.restored) {
83    const back = props.restore
84    state = { ...state, text: back.text, caret: back.text.length, restored: back.n, active: true }
85    surface.setState(state)
86  }
87
88  const { text, caret, active } = state
89  const shown = active ? text.slice(0, caret) + CARET + text.slice(caret) : text
90  const lines = shown.split('\n')
91
92  return (
93    <Box flexDirection="column" width="100%" borderStyle="round" borderDimColor={!active} borderColor={active ? 'text' : undefined} paddingX={1}>
94      {text === '' ? (
95        <Box flexDirection="row">
96          {active && <Text>{CARET}</Text>}
97          <Text dimColor>{props.placeholder ?? 'Message this subagent'}</Text>
98        </Box>
99      ) : (
100        lines.map(line => <Text wrap="wrap">{line === '' ? ' ' : line}</Text>)
101      )}
102      <Box flexDirection="row">
103        <Box flexGrow={1} />
104        <Text dimColor>{props.terminal ? '↵ send · ctrl+j or \\↵ new line' : '↵ send · ⇧↵ new line'}</Text>
105      </Box>
106    </Box>
107  )
108}
109
110export default Composer
111
types/index.d.ts 62 lines
1/** Where a subagent's loop stands, folded from the engine's AgentStatus. */
2export type AgentPhase = 'running' | 'waiting' | 'completed' | 'failed' | 'killed'
3
4/** One entry of a subagent's activity timeline. */
5export type AgentStep = {
6  /** `tool` a tool call, `say` the agent's own narration, `you` a message the person sent it. */
7  kind: 'tool' | 'say' | 'you'
8  /** The tool call's id, so its outcome lands on its own entry. */
9  id?: string
10  /** For a tool call, its tool's name. */
11  tool?: string
12  text: string
13  /** Milliseconds since the agent started. */
14  at: number
15  /** For a tool call: still running, done, or failed. */
16  state?: 'running' | 'done' | 'error'
17}
18
19export type AgentRow = {
20  id: string
21  description: string
22  type: string
23  model?: string
24  prompt: string
25  phase: AgentPhase
26  startedAt: number
27  endedAt?: number
28  /** The one-line summary of what it is doing now. */
29  current: string
30  toolCount: number
31  steps: AgentStep[]
32  /** Its final report, once it has one. */
33  answer?: string
34  /** Rebuilt from the transcript after a resume; `startedAt` is 0 when its times were not kept. */
35  restored?: boolean
36  /** How long it ran, when only the Agent call's record says so (a resume without kept times). */
37  durationMs?: number
38  /** Why its steps are not shown: the engine's own reason, when it would not read the agent's transcript. */
39  note?: string
40}
41
42declare module 'claude-code' {
43  interface PluginState {
44    'subagents-panel': {
45      agents: AgentRow[]
46      selected: string | null
47      showActive: boolean
48      showDone: boolean
49      /** Keys of the tool groups and task blocks the person expanded or collapsed. */
50      toggled: string[]
51      autoOpened: boolean
52      tick: number
53      /** The overview's search text; empty shows every agent. */
54      query: string
55      /** The pane element holding the focus ring, from `ui.focus`; null when none of ours does. */
56      focusedKey: string | null
57      /** The person closed the band above the prompt; `/subagents` brings it back. */
58      bandHidden: boolean
59    }
60  }
61}
62