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

<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>
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.
claude plugin update subagents-panel
Then run /reload-plugins in any open session, or start a new one.
claude plugin uninstall subagents-panel
| To | Do this |
|---|---|
| Open the panel | It opens by itself when a subagent starts. Or type /subagents. |
| See an agent's progress | Click its name in the overview. Click ← to go back. |
| Hide or show a section | Click In progress or Completed. |
| Find an agent | Type in the search box at the top of the overview. |
| Message a running agent | Open it, click the message box at the bottom, type, press Enter. |
| Start a new line in a message | Shift+Enter on desktop. In a terminal: Ctrl+J, or \ then Enter. |
| Hide the band above the prompt | Click × 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.
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.
Contributions are welcome: bug reports, ideas and pull requests.
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.
| Path | What it is |
|---|---|
hooks/register.tsx | The mod: event hooks, state, the panel, the band, delivery of messages. |
hooks/elapsed.tsx | A surface module: the live elapsed-time readout. |
hooks/composer.tsx | A surface module: the multi-line message box. |
types/index.d.ts | The shape of the state the mod keeps. |
tests/panel.test.tsx | Tests, run on both the terminal and desktop surfaces. |
assets/ | The logo and screenshots. |
Run these from the project folder; all three should pass.
claude plugin validate .
claude plugin test .
npx -p typescript tsc -p .
terminal and desktop; add a test for any behavior you add or fix.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.
© 2026 Berk Akkaya<br> <a href="https://cyberk.dev/">cyberk.dev</a>
hooks/register.tsx 1447 lines1import { 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 lines1import 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
47hooks/composer.tsx 111 lines1import 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
111types/index.d.ts 62 lines1/** 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