Side pane showing every subagent of the session: status, model, type, task title, prompt summary, elapsed time and what it is doing.

A side pane listing every subagent of your Claude Code session as a card: what it is, what model it runs on, what it was asked, how long it has run and what it is doing right now.
┌ Agents ─────────────────────────────────────────┐
│ 2 running · 1 done · 1 failed 4 │
│ │
│ ● Find auth middleware 1m 12s │
│ Explore · haiku 4.5 │
│ Look for where the JWT is verified and rep… │
│ 14 tools · ▸ Grep "verifyJwt" │
│ │
│ ● Write unit tests for lib.ts 38s │
│ general-purpose · opus 5.5 │
│ Add tests covering formatElapsed and the p… │
│ 6 tools · ▸ Edit tests/lib.test.ts │
│ │
│ ✓ Summarise README 21s │
│ Explore · haiku 4.5 │
│ Read README.md and summarise it in three b… │
│ 3 tools · last Read README.md │
│ │
│ ✗ Migrate config 4s │
│ general-purpose · sonnet 4.5 │
│ 1 tool · last Bash npm run migrate │
│ stopped │
│ │
│ /agents clear removes finished agents │
└─────────────────────────────────────────────────┘
⚙ 2 agents running
Each card shows:
● running (accent colour), ✓ done (green), ✗ failed (red). Finished cards are dimmed and stay until you clear them.description.Explore, general-purpose, a plugin's agent) and the model it resolved to (haiku 4.5), or the alias asked for until the resolved one is known.▸ Grep "foo") or the last one (last Edit src/x.ts).While any subagent runs, a status line under the prompt reads ⚙ 2 agents running.
On the desktop app the same cards are drawn as bordered boxes coloured by status.
| Surface | What you get |
|---|---|
| Terminal | The side pane (docked in the fullscreen layout, inline above the prompt on the main screen) and the status line. |
| Desktop (Code tab) | The same pane, with each card a bordered box coloured by status. |
| Claude mobile app | No pane: the phone never docks one. /agents (or /agent-deck) answers inline, as compact cards in the command's output row, and the status line names the newest running agent. |
The inline deck is used whenever the pane cannot be shown: /agents sent from the phone (a Remote Control message while the mobile app is attached, or a session only phones are watching), or $.ui.open answering isPlaced: false because no attached surface places panes. Each compact card fits the screen's width:
Agents · 2 running · 1 done
as of 14:03:27
● Find auth middleware 1m 12s
haiku 4.5 · Explore
▸ Grep "verifyJwt"
✓ Summarise README 21s
haiku 4.5 · Explore
last Read README.md
$.state, which subscribes it, so it redraws as agents start, call tools and finish, and ticks every second while any run. as of HH:MM:SS is when the cards last changed. The text behind the row (what the model reads, and what a surface shows if it draws the plain text) is a snapshot taken when you ran the command, stamped the same way. Every inline deck row in the transcript shows the current deck, not the deck at the time it was asked.⚙ 2 agents running to a short variant naming the newest running agent: ⚙ 2 · Find auth middleware (title cut to 24 characters). It switches back when the phone detaches.Box and Text, which every surface draws./plugin install agent-deck --marketplace mrjk05/modemon
Answer y to add the marketplace, then pick a scope.
| Command | What it does | ||
|---|---|---|---|
/agents | Toggle the pane (on the phone: show the deck inline) | ||
/agents open / /agents close | Open or close it | ||
/agents clear | Remove finished (done and failed) cards | ||
| `/agent-deck [clear\ | open\ | close]` | The same, under the mod's own name |
The deck opens by itself the first time a subagent spawns, but only where it docks beside the transcript as a sidebar: the fullscreen layout, with a terminal at least 144 columns wide. It does this once per session. Elsewhere, open it with /agents.
Set these in /config (or under pluginConfigs["agent-deck"].options in settings):
| Option | Default | What it does |
|---|---|---|
autoOpen | true | Open the deck when the first subagent spawns (sidebar layouts only) |
statusLine | true | Show ⚙ N agents running while subagents run (⚙ N · <newest title> with a phone attached) |
tool.call for the Agent tool (and Task, its older name) makes a card when the call starts and reads its result. A background agent answers async_launched with its id and resolved model. A foreground agent answers completed with its tool count.agent.spawn links the card to the agent id and the resolved model, including spawns made by other plugins.tool.call that carries an agentId is one of a subagent's own tool calls. It feeds the tool count and the current or last tool.turn.complete with an agentId and classic.SubagentStop end the card: done on an answer, failed on an error, refusal or interrupt.$.agent.list() is checked every 5 seconds while agents run, to catch agents that ended or were killed without an event the deck saw.$.state, so editing or hot-reloading the mod keeps the deck./agents is also the name of a retired, hidden built-in command. The mod answers it in place of that stub, and /agent-deck always works as an alias.isolation: "remote") are marked done with the note "running in the cloud", because their progress is not visible to the session.$.agent.list() start with no prompt summary and no tool history.bridge) message with the mobile app attached counts as the phone. A message from a web client while a phone is also attached is answered inline too./agents clear and /agents close work from the phone; close closes the pane on the terminal or desktop, if one is open.hooks/register.tsx 717 lines1// agent-deck: a side pane with one card per subagent of the session.
2//
3// Data: the Agent tool's `tool.call` (start and result), `agent.spawn`
4// (agent id and resolved model), every `tool.call` carrying `agentId` (the
5// subagent's own tool calls), `turn.complete` and `classic.SubagentStop`
6// (its end), and `$.agent.list()` (reconciled while any run).
7//
8// Every card lives in `$.state`, so a hot reload keeps the deck. Two runtime
9// facts stay module-level on purpose: the ticking timer's handle (a reload
10// drops the timers, and the handle with them) and the layout last seen while
11// drawing (a render hook may not write `$.state`; it is re-learned at the
12// next draw).
13
14import { atom, read, update } from 'claude-code'
15import type {
16 AgentSpawnInput,
17 CommandRunInput,
18 CommandRunResult,
19 EngineInterface,
20 RenderSurface,
21 PluginOptions,
22 Register,
23 RenderViewport,
24 Timer,
25 ToolCallResult,
26} from 'claude-code'
27
28import type { AgentDeckCard, AgentDeckStatus } from '../types'
29import {
30 describeTool,
31 elapsedOf,
32 formatClock,
33 formatElapsed,
34 headerText,
35 INLINE_HEAD,
36 inlineDeckText,
37 metaLine,
38 orderCards,
39 parseCommand,
40 shortModel,
41 statusGlyph,
42 statusText,
43 summarizePrompt,
44 toolLine,
45 truncate,
46} from './lib'
47
48type Engine = EngineInterface
49type Cards = AgentDeckCard[]
50
51const PANE = 'agent-deck'
52const TITLE = 'Agents'
53const TICK_MS = 1000
54const RECONCILE_EVERY = 5
55/** The width from which a pane opened unasked is seated (see PaneOpenArgs). */
56const UNASKED_MIN_COLUMNS = 144
57
58const agents = atom({ plugin: 'agent-deck', key: 'agents' } as const, [] as Cards)
59const clockNow = atom({ plugin: 'agent-deck', key: 'now' } as const, 0)
60const autoOpened = atom({ plugin: 'agent-deck', key: 'autoOpened' } as const, false)
61const cwdAtom = atom({ plugin: 'agent-deck', key: 'cwd' } as const, '')
62const phoneWatching = atom({ plugin: 'agent-deck', key: 'phoneWatching' } as const, false)
63
64/** The ticking timer while any agent runs (runtime handle, see the header). */
65let ticker: Timer | undefined
66/** The layout last seen while drawing or running a command (see the header). */
67let layout: { isFullscreen?: boolean; columns?: number } = {}
68
69function learnLayout(viewport: RenderViewport | undefined): void {
70 if (viewport === undefined) return
71 layout = {
72 isFullscreen: viewport.isFullscreen ?? layout.isFullscreen,
73 columns: viewport.columns,
74 }
75}
76
77/** Runs bookkeeping that must never stand in the way of the call it watches. */
78async function quietly(work: () => Promise<unknown>): Promise<void> {
79 try {
80 await work()
81 } catch {
82 // A tracking miss costs a card's detail, never the user's tool call.
83 }
84}
85
86function str(record: Record<string, unknown>, key: string): string | undefined {
87 const value = record[key]
88 return typeof value === 'string' && value.length > 0 ? value : undefined
89}
90
91function num(record: Record<string, unknown>, key: string): number | undefined {
92 const value = record[key]
93 return typeof value === 'number' && Number.isFinite(value) ? value : undefined
94}
95
96function replaceCard(list: Cards, key: string, change: (card: AgentDeckCard) => AgentDeckCard): Cards {
97 return list.map(card => (card.key === key ? change(card) : card))
98}
99
100function findByAgent(list: Cards, agentId: string): AgentDeckCard | undefined {
101 return list.find(card => card.agentId === agentId)
102}
103
104function finish(card: AgentDeckCard, status: AgentDeckStatus, at: number, note?: string): AgentDeckCard {
105 if (card.status !== 'running') return card
106 const ended: AgentDeckCard = { ...card, status, endedAt: at }
107 if (card.currentTool !== undefined) {
108 ended.lastTool = card.currentTool
109 delete ended.currentTool
110 }
111 if (note !== undefined) ended.note = note
112 return ended
113}
114
115type Config = { autoOpen: boolean; statusLine: boolean }
116
117/** Status line and ticker follow the cards after every change. */
118async function settle($: Engine, cfg: Config): Promise<void> {
119 const list = await read($, agents)
120 if (cfg.statusLine) $.ui.status(statusText(list, await read($, phoneWatching)))
121 const isRunning = list.some(card => card.status === 'running')
122 if (isRunning && ticker === undefined) {
123 let ticks = 0
124 ticker = $.clock.every(TICK_MS, () => {
125 ticks += 1
126 void quietly(() => tick($, cfg, ticks % RECONCILE_EVERY === 0))
127 })
128 } else if (!isRunning && ticker !== undefined) {
129 ticker.cancel()
130 ticker = undefined
131 }
132}
133
134async function tick($: Engine, cfg: Config, reconcileToo: boolean): Promise<void> {
135 const at = await $.clock.now()
136 await update($, clockNow, () => at)
137 if (reconcileToo) await reconcile($, cfg)
138 else await settle($, cfg)
139}
140
141async function change($: Engine, cfg: Config, edit: (list: Cards, at: number) => Cards): Promise<void> {
142 const at = await $.clock.now()
143 await update($, agents, list => edit(list, at))
144 await update($, clockNow, previous => Math.max(previous, at))
145 await settle($, cfg)
146}
147
148/** Brings the cards in line with the engine's own list of agents. */
149async function reconcile($: Engine, cfg: Config): Promise<void> {
150 const listed = await $.agent.list()
151 await change($, cfg, (list, at) => {
152 let next = list
153 for (const info of listed) {
154 const card = findByAgent(next, info.id)
155 if (card === undefined) {
156 const isLive = info.status === 'running' || info.status === 'pending' || info.status === 'waiting'
157 if (!isLive) continue
158 next = [
159 ...next,
160 {
161 key: `agent:${info.id}`,
162 agentId: info.id,
163 status: 'running',
164 type: info.type,
165 title: info.description || info.name || info.type,
166 summary: '',
167 startedAt: at,
168 toolCount: 0,
169 },
170 ]
171 } else if (info.status === 'completed') {
172 next = replaceCard(next, card.key, one => finish(one, 'done', at))
173 } else if (info.status === 'failed' || info.status === 'killed') {
174 next = replaceCard(next, card.key, one => finish(one, 'failed', at, info.status))
175 }
176 }
177 return next
178 })
179}
180
181async function surfacesOf($: Engine): Promise<readonly RenderSurface[]> {
182 try {
183 return await $.session.surfaces()
184 } catch {
185 return []
186 }
187}
188
189/** Re-reads whether a phone watches the session; the status line follows. */
190async function learnSurfaces($: Engine, cfg: Config): Promise<void> {
191 const isPhone = (await surfacesOf($)).includes('mobile')
192 if ((await read($, phoneWatching)) !== isPhone) {
193 await update($, phoneWatching, () => isPhone)
194 await settle($, cfg)
195 }
196}
197
198async function cwdOf($: Engine): Promise<string | undefined> {
199 const known = await read($, cwdAtom)
200 if (known.length > 0) return known
201 try {
202 const cwd = await $.session.cwd()
203 await update($, cwdAtom, () => cwd)
204 return cwd
205 } catch {
206 return undefined
207 }
208}
209
210async function isPaneUp($: Engine): Promise<boolean> {
211 return (await $.ui.panes()).some(pane => pane.id === PANE)
212}
213
214/** Opens the deck unasked, once, and only where it docks as a sidebar. */
215async function maybeAutoOpen($: Engine, cfg: Config): Promise<void> {
216 if (!cfg.autoOpen || layout.isFullscreen !== true) return
217 if (layout.columns !== undefined && layout.columns < UNASKED_MIN_COLUMNS) return
218 if (await read($, autoOpened)) return
219 if (await isPaneUp($)) return
220 const opened = await $.ui.open({ id: PANE, title: TITLE })
221 if (opened.isPlaced) await update($, autoOpened, () => true)
222 else await $.ui.close({ id: PANE })
223}
224
225/** A card for an Agent call, made or filled from what the call says. */
226async function agentStarted(
227 $: Engine,
228 cfg: Config,
229 toolUseId: string,
230 fields: { type?: string; title?: string; prompt?: string; model?: string },
231): Promise<void> {
232 await change($, cfg, (list, at) => {
233 const existing = list.find(card => card.toolUseId === toolUseId)
234 if (existing !== undefined) {
235 return replaceCard(list, existing.key, card => ({
236 ...card,
237 type: fields.type ?? card.type,
238 title: fields.title ?? card.title,
239 summary: fields.prompt !== undefined ? summarizePrompt(fields.prompt) : card.summary,
240 model: card.model ?? fields.model,
241 }))
242 }
243 const card: AgentDeckCard = {
244 key: toolUseId,
245 toolUseId,
246 status: 'running',
247 type: fields.type ?? 'general-purpose',
248 title: fields.title ?? 'Subagent',
249 summary: fields.prompt !== undefined ? summarizePrompt(fields.prompt) : '',
250 startedAt: at,
251 toolCount: 0,
252 }
253 if (fields.model !== undefined) card.model = fields.model
254 return [...list, card]
255 })
256 await maybeAutoOpen($, cfg)
257}
258
259/** What the Agent call answered: launched in the background, finished, or failed. */
260async function agentSettled($: Engine, cfg: Config, toolUseId: string, ran: ToolCallResult): Promise<void> {
261 await change($, cfg, (list, at) => {
262 const card = list.find(one => one.toolUseId === toolUseId)
263 if (card === undefined) return list
264 if (ran.deny !== undefined) {
265 return replaceCard(list, card.key, one => finish(one, 'failed', at, truncate(ran.deny, 120)))
266 }
267 const result: Record<string, unknown> =
268 typeof ran.result === 'object' && ran.result !== null ? (ran.result as Record<string, unknown>) : {}
269 const agentId = str(result, 'agentId')
270 const model = str(result, 'resolvedModel')
271 const status = str(result, 'status')
272 const totalTools = num(result, 'totalToolUseCount')
273 return replaceCard(list, card.key, one => {
274 let next: AgentDeckCard = { ...one }
275 if (agentId !== undefined) next.agentId = agentId
276 if (model !== undefined) next.model = model
277 if (totalTools !== undefined) next.toolCount = Math.max(next.toolCount, totalTools)
278 if (ran.isError === true) {
279 next = finish(next, 'failed', at, ran.text !== undefined ? truncate(ran.text, 120) : 'error')
280 } else if (status === 'completed') {
281 next = finish(next, 'done', at)
282 } else if (status === 'remote_launched') {
283 next = finish(next, 'done', at, 'running in the cloud')
284 }
285 return next
286 })
287 })
288}
289
290/** A subagent's own tool call started or finished. */
291async function subagentTool($: Engine, cfg: Config, agentId: string, described: string, phase: 'start' | 'end'): Promise<void> {
292 await change($, cfg, (list, at) => {
293 const card = findByAgent(list, agentId)
294 if (card === undefined) {
295 if (phase === 'end') return list
296 return [
297 ...list,
298 {
299 key: `agent:${agentId}`,
300 agentId,
301 status: 'running',
302 type: 'subagent',
303 title: 'Subagent',
304 summary: '',
305 startedAt: at,
306 toolCount: 1,
307 currentTool: described,
308 },
309 ]
310 }
311 return replaceCard(list, card.key, one => {
312 if (phase === 'start') {
313 const woke: AgentDeckCard = { ...one, toolCount: one.toolCount + 1, currentTool: described }
314 if (one.status !== 'running') {
315 // A teammate woke for another turn.
316 woke.status = 'running'
317 delete woke.endedAt
318 delete woke.note
319 }
320 return woke
321 }
322 const ended: AgentDeckCard = { ...one, lastTool: described }
323 if (one.currentTool === described) delete ended.currentTool
324 return ended
325 })
326 })
327}
328
329async function agentEnded($: Engine, cfg: Config, agentId: string, status: AgentDeckStatus, note?: string): Promise<void> {
330 await change($, cfg, (list, at) => {
331 const card = findByAgent(list, agentId)
332 return card === undefined ? list : replaceCard(list, card.key, one => finish(one, status, at, note))
333 })
334}
335
336/** `/agents [clear|open|close]`: toggles the deck, or clears finished cards. */
337async function runDeck($: Engine, cfg: Config, e: CommandRunInput): Promise<CommandRunResult> {
338 layout = { isFullscreen: e.presentation.isFullscreen, columns: e.presentation.columns }
339 const name = e.command
340 const command = parseCommand(e.args)
341 if (command === 'help') {
342 return {
343 text: `Usage: /${name} toggles the agent deck; /${name} open, /${name} close; /${name} clear removes finished agents.`,
344 }
345 }
346 if (command === 'clear') {
347 const before = await read($, agents)
348 const kept = before.filter(card => card.status === 'running')
349 const removed = before.length - kept.length
350 await update($, agents, list => list.filter(card => card.status === 'running'))
351 await settle($, cfg)
352 return { text: removed === 0 ? 'No finished agents to clear.' : `Cleared ${removed} finished agent${removed === 1 ? '' : 's'}.` }
353 }
354 // The phone docks no pane: asked from it (or where only phones draw),
355 // the deck answers inline, as the command's output row.
356 const surfaces = await surfacesOf($)
357 const isPhoneOnly = surfaces.length > 0 && surfaces.every(surface => surface === 'mobile')
358 const isFromPhone = isPhoneOnly || (e.origin.kind === 'bridge' && surfaces.includes('mobile'))
359 if (isFromPhone && command !== 'close') {
360 await quietly(() => reconcile($, cfg))
361 return inline($)
362 }
363 const isUp = await isPaneUp($)
364 if (command === 'close' || (command === 'toggle' && isUp)) {
365 if (isUp) await $.ui.close({ id: PANE })
366 return { text: 'Agent deck closed.' }
367 }
368 await quietly(() => reconcile($, cfg))
369 const opened = await $.ui.open({ id: PANE, title: TITLE })
370 if (opened.isPlaced) return { text: 'Agent deck opened.' }
371 // Open but unplaced (no attached surface places panes): it is seated when
372 // one that does attaches; until then the deck answers inline.
373 return inline($)
374}
375
376/** The deck as the command's output: text the model reads, cards the CommandOutput hook draws. */
377async function inline($: Engine): Promise<CommandRunResult> {
378 const at = await $.clock.now()
379 await update($, clockNow, previous => Math.max(previous, at))
380 const now = await read($, clockNow)
381 return { text: inlineDeckText(await read($, agents), now) }
382}
383
384export const register: Register = (on, options: PluginOptions) => {
385 const cfg: Config = {
386 autoOpen: options['autoOpen'] !== false,
387 statusLine: options['statusLine'] !== false,
388 }
389
390 // --- Session and commands -------------------------------------------------
391
392 on('session.start', async ($, e, next) => {
393 await quietly(async () => {
394 // `/agents` is the name of a removed, hidden built-in, which
395 // `$.command.register` refuses: the `command.run` hook below answers
396 // it all the same. `/agent-deck` is the always-registered alias.
397 await quietly(() =>
398 $.command.register({
399 name: 'agents',
400 description: 'Toggle the agent deck pane; /agents clear removes finished agents',
401 argumentHint: '[clear|open|close]',
402 immediate: true,
403 }),
404 )
405 await $.command.register({
406 name: 'agent-deck',
407 description: 'Toggle the agent deck pane; /agent-deck clear removes finished agents',
408 argumentHint: '[clear|open|close]',
409 immediate: true,
410 })
411 })
412 await quietly(() => cwdOf($))
413 await quietly(() => learnSurfaces($, cfg))
414 await quietly(() => settle($, cfg))
415 return next(e)
416 })
417
418 // A phone joining or leaving switches the status line's variant.
419 on('session.attach', async ($, e, next) => {
420 const joined = await next(e)
421 await quietly(() => learnSurfaces($, cfg))
422 return joined
423 })
424 on('session.detach', async ($, e, next) => {
425 const left = await next(e)
426 await quietly(() => learnSurfaces($, cfg))
427 return left
428 })
429
430 on('command.describe', { command: 'agents' }, async ($, e, next) => {
431 const described = await next(e)
432 return {
433 ...described,
434 description: 'Toggle the agent deck pane; /agents clear removes finished agents',
435 argumentHint: '[clear|open|close]',
436 isHidden: false,
437 }
438 })
439
440 on('command.run', { command: 'agents' }, ($, e) => runDeck($, cfg, e)).catch(($, e, next) =>
441 next.called ? next(e) : { text: 'agent-deck: the command failed.' },
442 )
443 on('command.run', { command: 'agent-deck' }, ($, e) => runDeck($, cfg, e)).catch(($, e, next) =>
444 next.called ? next(e) : { text: 'agent-deck: the command failed.' },
445 )
446
447 // --- Agent lifecycle ------------------------------------------------------
448
449 on('tool.call', async ($, e, next) => {
450 const tool = String(e.tool)
451 const input = e as unknown as Record<string, unknown>
452 const parent = e.agentId
453 const isAgentCall = tool === 'Agent' || tool === 'Task'
454 let described: string | undefined
455 if (parent !== undefined) {
456 await quietly(async () => {
457 described = describeTool(tool, input, await cwdOf($))
458 await subagentTool($, cfg, parent, described, 'start')
459 })
460 }
461 if (isAgentCall) {
462 const fields: { type?: string; title?: string; prompt?: string; model?: string } = {}
463 const type = str(input, 'subagent_type')
464 const title = str(input, 'description')
465 const prompt = str(input, 'prompt')
466 const model = str(input, 'model')
467 if (type !== undefined) fields.type = type
468 if (title !== undefined) fields.title = title
469 if (prompt !== undefined) fields.prompt = prompt
470 if (model !== undefined) fields.model = model
471 await quietly(() => agentStarted($, cfg, e.tool_use_id, fields))
472 }
473 const ran = await next(e)
474 if (isAgentCall) await quietly(() => agentSettled($, cfg, e.tool_use_id, ran))
475 if (parent !== undefined && described !== undefined) {
476 const done = described
477 await quietly(() => subagentTool($, cfg, parent, done, 'end'))
478 }
479 return ran
480 }).catch(($, e, next) => next(e))
481
482 on('agent.spawn', async ($, e: AgentSpawnInput, next) => {
483 const fields: { type?: string; title?: string; prompt?: string; model?: string } = {
484 type: e.subagentType,
485 prompt: e.prompt,
486 }
487 if (e.description.length > 0) fields.title = e.description
488 const model = e.model ?? (e.fork ? e.parentModel : undefined)
489 if (model !== undefined) fields.model = model
490 await quietly(() => agentStarted($, cfg, e.tool_use_id, fields))
491 const ran = await next(e)
492 await quietly(() =>
493 change($, cfg, (list, at) => {
494 const card = list.find(one => one.toolUseId === e.tool_use_id)
495 if (card === undefined) return list
496 if (ran.deny !== undefined) {
497 return replaceCard(list, card.key, one => finish(one, 'failed', at, truncate(ran.deny, 120)))
498 }
499 return replaceCard(list, card.key, one => {
500 const linked: AgentDeckCard = { ...one, model: ran.model }
501 if (ran.agentId !== undefined) linked.agentId = ran.agentId
502 return linked
503 })
504 }),
505 )
506 return ran
507 }).catch(($, e, next) => next(e))
508
509 on('turn.complete', async ($, e, next) => {
510 const agentId = e.agentId
511 if (agentId !== undefined) {
512 const status: AgentDeckStatus = e.reason === 'answer' ? 'done' : 'failed'
513 const note = e.reason === 'answer' ? undefined : e.reason === 'aborted' ? 'stopped' : e.reason
514 await quietly(() => agentEnded($, cfg, agentId, status, note))
515 }
516 return next(e)
517 })
518
519 on('classic.SubagentStop', async ($, e, next) => {
520 await quietly(() => agentEnded($, cfg, e.agent_id, 'done'))
521 return next(e)
522 }).catch(($, e, next) => next(e))
523
524 // --- Drawing --------------------------------------------------------------
525
526 // Passive: learns whether the surface docks panes, so the deck opens by
527 // itself only where it is a sidebar. The spinner draws while a turn runs,
528 // which is when subagents spawn.
529 on('ui.render', { component: 'Spinner' }, ($, e, next) => {
530 learnLayout(e.viewport)
531 return next(e)
532 })
533
534 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
535 learnLayout(e.viewport)
536 const { Box, Text } = $.ui.resolve(e)
537 const list = orderCards(await read($, agents))
538 const now = await read($, clockNow)
539 const width = Math.max(16, e.props.bodyColumns)
540 const isDesktop = e.surface === 'desktop'
541 const inner = isDesktop ? width - 4 : width - 2
542 const viewing = e.props.view.agentId
543
544 const header = (
545 <Box flexDirection="row" justifyContent="space-between">
546 <Text bold>{truncate(headerText(list), Math.max(4, width - 8))}</Text>
547 <Text dimColor>{list.length > 0 ? `${list.length}` : ''}</Text>
548 </Box>
549 )
550
551 if (list.length === 0) {
552 return (
553 <Box flexDirection="column" gap={1}>
554 {header}
555 <Text dimColor wrap="wrap">
556 Cards appear here when Claude spawns a subagent.
557 </Text>
558 </Box>
559 )
560 }
561
562 const cards = list.map(card => {
563 const isRunning = card.status === 'running'
564 const isFailed = card.status === 'failed'
565 const color = isRunning ? 'claude' : isFailed ? 'error' : 'success'
566 const glyph = isRunning ? '●' : isFailed ? '✗' : '✓'
567 const elapsed = formatElapsed(elapsedOf(card, now))
568 const titleRoom = Math.max(4, inner - elapsed.length - 3)
569 const model = shortModel(card.model)
570 const meta = [card.type, model].filter((part): part is string => part !== undefined && part.length > 0).join(' · ')
571 const tool = card.currentTool ?? card.lastTool
572 const toolLine =
573 `${card.toolCount} tool${card.toolCount === 1 ? '' : 's'}` +
574 (tool === undefined ? '' : card.currentTool !== undefined && isRunning ? ` · ▸ ${tool}` : ` · last ${tool}`)
575 const isViewed = viewing !== undefined && card.agentId === viewing
576 const body = (
577 <Box flexDirection="column">
578 <Box flexDirection="row" justifyContent="space-between">
579 <Text wrap="truncate">
580 <Text color={color}>{glyph}</Text> <Text bold={!isFailed} dimColor={!isRunning}>
581 {truncate(card.title, titleRoom)}
582 </Text>
583 </Text>
584 <Text dimColor={!isRunning} color={isRunning ? 'claude' : undefined}>
585 {elapsed}
586 </Text>
587 </Box>
588 <Text dimColor wrap="truncate">
589 {' '}
590 {truncate(meta + (isViewed ? ' · in view' : ''), inner)}
591 </Text>
592 {card.summary.length > 0 && (
593 <Text dimColor={!isRunning} wrap="truncate">
594 {' '}
595 {truncate(card.summary, inner)}
596 </Text>
597 )}
598 <Text dimColor wrap="truncate">
599 {' '}
600 {truncate(toolLine, inner)}
601 </Text>
602 {card.note !== undefined && (
603 <Text color={isFailed ? 'error' : undefined} dimColor={!isFailed} wrap="truncate">
604 {' '}
605 {truncate(card.note, inner)}
606 </Text>
607 )}
608 </Box>
609 )
610 return isDesktop ? (
611 <Box
612 key={`card:${card.key}`}
613 flexDirection="column"
614 borderStyle="round"
615 borderColor={isViewed ? 'suggestion' : color}
616 borderDimColor={!isRunning}
617 paddingX={1}
618 >
619 {body}
620 </Box>
621 ) : (
622 <Box key={`card:${card.key}`} flexDirection="column">
623 {body}
624 </Box>
625 )
626 })
627
628 const hasFinished = list.some(card => card.status !== 'running')
629 return (
630 <Box flexDirection="column" gap={1}>
631 {header}
632 {cards}
633 {hasFinished && <Text dimColor>/agents clear removes finished agents</Text>}
634 </Box>
635 )
636 })
637
638 // The inline deck: `/agents` answered as its output row (a phone, or no
639 // surface that places panes). It reads the cards from `$.state`, which
640 // subscribes the row, so it redraws live as agents change, ticking with
641 // the clock while any run; the stamp says when it last changed.
642 on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
643 const command = e.props.command
644 const isOurs = command === 'agents' || command === 'agent-deck'
645 if (!isOurs || e.props.isErrored || !e.props.text.startsWith(INLINE_HEAD)) return next(e)
646 const { Box, Text } = $.ui.resolve(e)
647 const list = orderCards(await read($, agents))
648 const now = await read($, clockNow)
649 const width = Math.max(20, Math.min(e.viewport?.columns ?? 40, 72))
650 const stamp = `as of ${formatClock(now)}`
651
652 const header = (
653 <Box flexDirection="column">
654 <Text bold wrap="truncate">
655 {truncate(`Agents · ${headerText(list)}`, width)}
656 </Text>
657 <Text dimColor wrap="truncate">
658 {stamp}
659 </Text>
660 </Box>
661 )
662 if (list.length === 0) {
663 return (
664 <Box flexDirection="column">
665 {header}
666 <Text dimColor wrap="wrap">
667 Cards appear here when Claude spawns a subagent.
668 </Text>
669 </Box>
670 )
671 }
672 const inner = width - 2
673 const cards = list.map(card => {
674 const isRunning = card.status === 'running'
675 const isFailed = card.status === 'failed'
676 const color = isRunning ? 'claude' : isFailed ? 'error' : 'success'
677 const elapsed = formatElapsed(elapsedOf(card, now))
678 const titleRoom = Math.max(4, width - elapsed.length - 3)
679 const tool = toolLine(card)
680 return (
681 <Box key={`inline:${card.key}`} flexDirection="column">
682 <Box flexDirection="row" justifyContent="space-between">
683 <Text wrap="truncate">
684 <Text color={color}>{statusGlyph(card.status)}</Text> <Text bold={!isFailed} dimColor={!isRunning}>
685 {truncate(card.title, titleRoom)}
686 </Text>
687 </Text>
688 <Text dimColor={!isRunning}>{elapsed}</Text>
689 </Box>
690 <Text dimColor wrap="truncate">
691 {' '}
692 {truncate(metaLine(card), inner)}
693 </Text>
694 {tool !== undefined && (
695 <Text dimColor wrap="truncate">
696 {' '}
697 {truncate(tool, inner)}
698 </Text>
699 )}
700 {card.note !== undefined && (
701 <Text color={isFailed ? 'error' : undefined} dimColor={!isFailed} wrap="truncate">
702 {' '}
703 {truncate(card.note, inner)}
704 </Text>
705 )}
706 </Box>
707 )
708 })
709 return (
710 <Box flexDirection="column" gap={1}>
711 {header}
712 {cards}
713 </Box>
714 )
715 })
716}
717hooks/lib.ts 246 lines1// Pure helpers for agent-deck: formatting and describing, no `$`.
2
3import type { AgentDeckCard, AgentDeckStatus } from '../types'
4
5/** Cuts `text` to `max` characters, ending in an ellipsis when cut. */
6export function truncate(text: string, max: number): string {
7 if (max <= 0) return ''
8 if (text.length <= max) return text
9 if (max === 1) return '…'
10 return text.slice(0, max - 1).trimEnd() + '…'
11}
12
13/** Elapsed time as a card shows it: `0s`, `42s`, `3m 07s`, `1h 02m`. */
14export function formatElapsed(ms: number): string {
15 const total = Math.max(0, Math.floor(ms / 1000))
16 const hours = Math.floor(total / 3600)
17 const minutes = Math.floor((total % 3600) / 60)
18 const seconds = total % 60
19 if (hours > 0) return `${hours}h ${String(minutes).padStart(2, '0')}m`
20 if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, '0')}s`
21 return `${seconds}s`
22}
23
24/**
25 * A prompt summarised to one line: markdown markers and whitespace folded,
26 * cut at a word boundary to `max` characters.
27 */
28export function summarizePrompt(prompt: string, max = 120): string {
29 const lines = prompt
30 .split(/\r?\n/)
31 .map(line => line.replace(/^\s*(?:#{1,6}\s+|[-*+>]\s+|\d+[.)]\s+)/, '').trim())
32 .filter(line => line.length > 0 && !/^```/.test(line))
33 const flat = lines.join(' ').replace(/\s+/g, ' ').trim()
34 if (flat.length <= max) return flat
35 const cut = flat.slice(0, max - 1)
36 const space = cut.lastIndexOf(' ')
37 const head = space > max * 0.6 ? cut.slice(0, space) : cut
38 return head.replace(/[\s,.;:]+$/, '') + '…'
39}
40
41/** A path made short: relative to `cwd` when under it, else its last three parts. */
42export function shortPath(path: string, cwd?: string): string {
43 if (cwd !== undefined && cwd.length > 0) {
44 const root = cwd.endsWith('/') ? cwd : cwd + '/'
45 if (path.startsWith(root)) return path.slice(root.length)
46 }
47 const parts = path.split('/').filter(part => part.length > 0)
48 if (parts.length <= 3) return path
49 return '…/' + parts.slice(-3).join('/')
50}
51
52function str(input: Record<string, unknown>, key: string): string | undefined {
53 const value = input[key]
54 return typeof value === 'string' && value.length > 0 ? value : undefined
55}
56
57function quoted(text: string, max: number): string {
58 return `"${truncate(text.replace(/\s+/g, ' '), max)}"`
59}
60
61/**
62 * One tool call described in a few words: `Grep "foo"`, `Edit src/x.ts`,
63 * `Bash npm test`, `WebFetch example.com`. Cut to `max` characters.
64 */
65export function describeTool(
66 tool: string,
67 input: Record<string, unknown>,
68 cwd?: string,
69 max = 60,
70): string {
71 const name = String(tool)
72 let text: string
73 const path = str(input, 'file_path') ?? str(input, 'notebook_path')
74 if (name === 'Grep' || name === 'Glob') {
75 const pattern = str(input, 'pattern')
76 text = pattern === undefined ? name : `${name} ${quoted(pattern, 40)}`
77 } else if (path !== undefined) {
78 text = `${name} ${shortPath(path, cwd)}`
79 } else if (name === 'Bash' || name === 'PowerShell') {
80 const command = str(input, 'command')
81 const first = command?.split(/\r?\n/)[0]?.trim()
82 text = first === undefined || first.length === 0 ? name : `${name} ${first}`
83 } else if (name === 'WebFetch') {
84 const url = str(input, 'url')
85 text = url === undefined ? name : `${name} ${hostOf(url)}`
86 } else if (name === 'WebSearch') {
87 const query = str(input, 'query')
88 text = query === undefined ? name : `${name} ${quoted(query, 40)}`
89 } else if (name === 'Agent' || name === 'Task') {
90 const description = str(input, 'description')
91 text = description === undefined ? name : `${name} ${quoted(description, 40)}`
92 } else if (name === 'Skill') {
93 const skill = str(input, 'skill') ?? str(input, 'command')
94 text = skill === undefined ? name : `${name} ${skill}`
95 } else if (name.startsWith('mcp__')) {
96 const [, server, ...rest] = name.split('__')
97 text = rest.length > 0 ? `${server ?? 'mcp'} ${rest.join('__')}` : name
98 } else {
99 text = name
100 }
101 return truncate(text, max)
102}
103
104function hostOf(url: string): string {
105 try {
106 return new URL(url).host || url
107 } catch {
108 return url
109 }
110}
111
112/**
113 * A model id made short: `claude-haiku-4-5-20251001` → `haiku 4.5`,
114 * `claude-opus-5-5[1m]` → `opus 5.5`; an alias (`haiku`) stays as given.
115 */
116export function shortModel(model: string | undefined): string | undefined {
117 if (model === undefined || model.length === 0) return undefined
118 let id = model.replace(/\[[^\]]*\]$/, '').replace(/^(?:[a-z]+\.)?anthropic\./, '')
119 id = id.replace(/^claude-/, '').replace(/-\d{8}(?:-v\d+(?::\d+)?)?$/, '').replace(/@\d{8}$/, '')
120 const pair = /^([a-z]+)-(\d+)-(\d+)$/.exec(id)
121 if (pair !== null) return `${pair[1]} ${pair[2]}.${pair[3]}`
122 const single = /^([a-z]+)-(\d+)$/.exec(id)
123 if (single !== null) return `${single[1]} ${single[2]}`
124 const legacy = /^(\d+)-(\d+)-([a-z]+)$/.exec(id)
125 if (legacy !== null) return `${legacy[3]} ${legacy[1]}.${legacy[2]}`
126 return id
127}
128
129/** How many cards stand in each status. */
130export function countByStatus(cards: readonly AgentDeckCard[]): Record<AgentDeckStatus, number> {
131 const counts: Record<AgentDeckStatus, number> = { running: 0, done: 0, failed: 0 }
132 for (const card of cards) counts[card.status] += 1
133 return counts
134}
135
136/** The running card that started last, if any. */
137export function newestRunning(cards: readonly AgentDeckCard[]): AgentDeckCard | undefined {
138 let newest: AgentDeckCard | undefined
139 for (const card of cards) {
140 if (card.status !== 'running') continue
141 if (newest === undefined || card.startedAt >= newest.startedAt) newest = card
142 }
143 return newest
144}
145
146/** How long the newest agent's title may run in the phone's status line. */
147export const STATUS_TITLE_MAX = 24
148
149/**
150 * The status line while agents run, else undefined: `⚙ 2 agents running`,
151 * or with `withTitle` (a phone is watching, where the deck is no pane) the
152 * short variant naming the newest running agent: `⚙ 2 · Find auth middleware`.
153 */
154export function statusText(cards: readonly AgentDeckCard[], withTitle = false): string | undefined {
155 const running = countByStatus(cards).running
156 if (running === 0) return undefined
157 const newest = withTitle ? newestRunning(cards) : undefined
158 if (newest !== undefined && newest.title.length > 0) {
159 return `⚙ ${running} · ${truncate(newest.title, STATUS_TITLE_MAX)}`
160 }
161 return `⚙ ${running} agent${running === 1 ? '' : 's'} running`
162}
163
164/** A clock time as `HH:MM:SS` (local), for "as of" stamps. */
165export function formatClock(ms: number): string {
166 const at = new Date(ms)
167 const two = (n: number): string => String(n).padStart(2, '0')
168 return `${two(at.getHours())}:${two(at.getMinutes())}:${two(at.getSeconds())}`
169}
170
171/** What starts the text of an inline deck, which the CommandOutput hook draws as cards. */
172export const INLINE_HEAD = 'Agent deck ·'
173
174/** The glyph a card's status draws with. */
175export function statusGlyph(status: AgentDeckStatus): string {
176 return status === 'running' ? '●' : status === 'failed' ? '✗' : '✓'
177}
178
179/** `model · type`, the parts known. */
180export function metaLine(card: AgentDeckCard): string {
181 return [shortModel(card.model), card.type].filter((part): part is string => part !== undefined && part.length > 0).join(' · ')
182}
183
184/** The current tool (`▸ Grep "foo"`) or the last one (`last Read x.ts`), else undefined. */
185export function toolLine(card: AgentDeckCard): string | undefined {
186 if (card.currentTool !== undefined && card.status === 'running') return `▸ ${card.currentTool}`
187 const tool = card.currentTool ?? card.lastTool
188 return tool === undefined ? undefined : `last ${tool}`
189}
190
191/**
192 * The deck as markdown text, for a surface with no pane (the phone): what the
193 * model reads, and what a surface draws when no hook draws the cards. A
194 * snapshot, stamped `as of HH:MM:SS`.
195 */
196export function inlineDeckText(cards: readonly AgentDeckCard[], now: number): string {
197 const list = orderCards(cards)
198 const head = `${INLINE_HEAD} ${headerText(list)} · as of ${formatClock(now)}`
199 if (list.length === 0) return `${head}\nCards appear here when Claude spawns a subagent.`
200 const lines = list.map(card => {
201 const parts = [`${statusGlyph(card.status)} **${card.title}**`, metaLine(card), formatElapsed(elapsedOf(card, now))]
202 const tool = toolLine(card)
203 if (tool !== undefined) parts.push(tool)
204 if (card.note !== undefined) parts.push(card.note)
205 return `- ${parts.filter(part => part.length > 0).join(' · ')}`
206 })
207 return [head, ...lines].join('\n')
208}
209
210/** Running cards first (oldest first), then finished ones, latest to end first. */
211export function orderCards(cards: readonly AgentDeckCard[]): AgentDeckCard[] {
212 const running = cards.filter(card => card.status === 'running')
213 const finished = cards
214 .filter(card => card.status !== 'running')
215 .sort((a, b) => (b.endedAt ?? b.startedAt) - (a.endedAt ?? a.startedAt))
216 return [...running, ...finished]
217}
218
219/** A card's elapsed time: to `now` while it runs, to its end once finished. */
220export function elapsedOf(card: AgentDeckCard, now: number): number {
221 const end = card.status === 'running' ? Math.max(now, card.startedAt) : (card.endedAt ?? now)
222 return Math.max(0, end - card.startedAt)
223}
224
225/** The header line over the cards: `2 running · 1 done · 1 failed`. */
226export function headerText(cards: readonly AgentDeckCard[]): string {
227 if (cards.length === 0) return 'No subagents yet'
228 const counts = countByStatus(cards)
229 const parts: string[] = []
230 if (counts.running > 0) parts.push(`${counts.running} running`)
231 if (counts.done > 0) parts.push(`${counts.done} done`)
232 if (counts.failed > 0) parts.push(`${counts.failed} failed`)
233 return parts.join(' · ')
234}
235
236/** What `/agents <args>` asks for. */
237export type DeckCommand = 'toggle' | 'open' | 'close' | 'clear' | 'help'
238
239/** Parses the arguments of `/agents`. */
240export function parseCommand(args: string): DeckCommand {
241 const word = args.trim().toLowerCase()
242 if (word === '') return 'toggle'
243 if (word === 'clear' || word === 'open' || word === 'close') return word
244 return 'help'
245}
246types/index.d.ts 53 lines1// agent-deck's state contract: the values it keeps in `$.state` for the
2// session, so a hot reload of the module keeps every card.
3
4/** Where a subagent stands, as its card shows it. */
5export type AgentDeckStatus = 'running' | 'done' | 'failed';
6
7/** One subagent of the session, as its card draws it. */
8export type AgentDeckCard = {
9 /** Stable key: the Agent call's tool_use_id, or `agent:<id>` when only the id is known. */
10 key: string;
11 /** The Agent tool call that started it, when known. */
12 toolUseId?: string;
13 /** The agent's id (`$.agent.list()`, `tool.call`'s `agentId`), once known. */
14 agentId?: string;
15 status: AgentDeckStatus;
16 /** Agent type (`general-purpose`, `Explore`, a plugin's agent). */
17 type: string;
18 /** The Agent call's `description`. */
19 title: string;
20 /** One-line summary of the prompt. */
21 summary: string;
22 /** Model: the resolved id when known, else the alias asked for. */
23 model?: string;
24 /** Epoch milliseconds. */
25 startedAt: number;
26 endedAt?: number;
27 /** Tool calls the subagent made. */
28 toolCount: number;
29 /** The tool call running now, described (`Grep "foo"`). */
30 currentTool?: string;
31 /** The last tool call that finished, described. */
32 lastTool?: string;
33 /** Why it failed, or a note on how it ended. */
34 note?: string;
35};
36
37declare module 'claude-code' {
38 interface PluginState {
39 'agent-deck': {
40 /** Every subagent card, in spawn order. */
41 agents: AgentDeckCard[];
42 /** The clock's last tick, which running cards' elapsed time reads. */
43 now: number;
44 /** True once the deck opened by itself this session. */
45 autoOpened: boolean;
46 /** The session's directory, which tool paths are shown relative to. */
47 cwd: string;
48 /** True while the Claude mobile app is among the session's surfaces. */
49 phoneWatching: boolean;
50 };
51 }
52}
53