A pane on the right that tracks this session's subagents as a tree: status, current action, files changed, tokens and cost

Two mods for Claude Code.
/plugin install agent-deck --marketplace Prabal-Singh/claude-mods
Type y to add the marketplace, then pick a scope (user = every session).
Top half: the agent tree. Every subagent shows as a row. Agents that subagents start sit under them, as deep as they go.
Edit …/app/x.py, Bash pytest -q).Bottom half: Blocked on you. Everything that stops progress until you act:
blocked_on_user tool: a decision, a command only you can run (with Copy), access or credentials, a review.! command a reply hands you to run.Items clear when they are resolved, when you run the command, or when you press Done. Each message you send carries the open items (unseen), so when your message settles one ("go with B", pasted output, "done") Claude clears it. A newer reply's commands replace the previous reply's, commands read from replies expire after 2 hours, and flagged items after 24 hours.
Names. Every subagent gets a name, so you can refer to it and message it: Vox Machina, in order. kiki, vex, vax, grog, pike, scanlan, percy…, the first one no running agent holds (a finished agent frees its name; -2 only when all are busy). Two running agents never share a name, even when they start in the same message.
Subagents may spawn subagents. Every subagent prompt says so, and agents are told to hand off parts that split cleanly.
/retire [name]. When an agent's context gets big, it is retired and replaced. Its committed work is pushed and its uncommitted changes are noted. A fresh agent, with the next name, takes over in the same worktree with a tight brief plus the leftover work. Claude also does this by itself once an agent's context passes about 200k tokens.
/deck shows or hides the panel. It opens by itself on the first subagent; it docks on the right in fullscreen on a wide terminal, and sits above the prompt otherwise.
Costs are estimates from list prices set in agent-deck/hooks/register.tsx (PRICES). Edit them if yours differ.
/plugin install cache-clock --marketplace Prabal-Singh/claude-mods
Claude Code's prompt cache lasts an hour after the last reply. After that, your next message re-reads the whole context at full price. cache-clock compacts the session 3 minutes before the cache expires, while compacting is still cheap, and says so in the transcript. It compacts once per reply, so an idle session isn't summarised over and over. It assumes the one-hour cache, not the five-minute one.
MIT
hooks/register.tsx 746 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentMap, AgentRow, Blocker, Reservation } from '../types'
5
6const PANE = 'agents'
7const TITLE = 'Agents'
8const TICK_MS = 2000
9const STUCK_MS = 5 * 60 * 1000
10// "Blocked on you" items go away by themselves after this long: commands read from a reply, and flagged items.
11const REPLY_COMMAND_TTL_MS = 2 * 60 * 60 * 1000
12const FLAG_TTL_MS = 24 * 60 * 60 * 1000
13// Finished agents leave the panel this long after they end (their pop-up already said how it went).
14const DONE_TTL_MS = 10 * 60 * 1000
15const MAY_SPAWN =
16 '\n\nYou may spawn your own subagents (Agent tool) for parts of this task that split cleanly; give each a precise brief.'
17
18// Subagents are named for Vox Machina and their friends, in this order.
19export const NAMES = [
20 'kiki', 'vex', 'vax', 'grog', 'pike', 'scanlan', 'percy', 'tiberius', 'trinket', 'tary',
21 'kima', 'allura', 'gilmore', 'cassandra', 'zahra', 'kashaw', 'doty', 'jarett', 'wilhand', 'syldor',
22]
23
24// Told to every loop that can spawn (main or subagent), so the rule travels with the mod.
25const SPAWN_RULE =
26 'Subagents may spawn their own subagents (Agent tool) for parts of their task that split cleanly. ' +
27 'When you brief a subagent whose work may split, pick an agent type that has the Agent tool, and give each a precise brief. ' +
28 'Always give every subagent a name (the Agent tool\'s `name`): a Vox Machina character no running subagent is using, ' +
29 `in this order: ${NAMES.join(', ')} (past the list, add -2: grog-2). ` +
30 'Refer to each subagent by that name when you talk about it or message it. ' +
31 'When a subagent\'s context grows big (about 200k tokens, many rounds of fixes or rebases, or it gets slow), retire it: ' +
32 'make sure its committed work is pushed, note its uncommitted changes, stop it, and hand a fresh agent in the same ' +
33 'worktree a tight brief plus that leftover work (the /retire command spells out the steps).'
34
35const BLOCK_TOOL = 'blocked_on_user'
36const CLEAR_TOOL = 'unblocked'
37const BLOCK_FULL = `mcp__agent-deck__${BLOCK_TOOL}`
38const CLEAR_FULL = `mcp__agent-deck__${CLEAR_TOOL}`
39export const KINDS = ['decision', 'command', 'access', 'review', 'other'] as const
40
41// Told to every loop, so whatever stops on the user shows in the panel's lower half.
42const BLOCK_RULE =
43 `When progress needs something only the user can give, call ${BLOCK_FULL} once: kind "decision" (a choice that is theirs), ` +
44 '"command" (a command only they can run, e.g. an interactive login; put it in `command`), "access" (a key, account, ' +
45 'permission or invite), "review" (a sign-off before you go on) or "other"; `what` is one plain line. Still say it in your ' +
46 `reply. Call ${CLEAR_FULL} with its id once it is resolved.`
47
48const agents = atom({ plugin: 'agent-deck', key: 'agents' } as const, {})
49const expanded = atom({ plugin: 'agent-deck', key: 'expanded' } as const, null)
50const now = atom({ plugin: 'agent-deck', key: 'now' } as const, 0)
51const opened = atom({ plugin: 'agent-deck', key: 'opened' } as const, false)
52const blockers = atom({ plugin: 'agent-deck', key: 'blockers' } as const, [])
53const reserved = atom({ plugin: 'agent-deck', key: 'reserved' } as const, [])
54// A name handed to an Agent call is held this long, until its spawn is recorded.
55const RESERVE_MS = 2 * 60 * 1000
56const blockerSeq = atom({ plugin: 'agent-deck', key: 'blockerSeq' } as const, 0)
57
58// Estimated list prices, USD per million tokens [input, output]; cache reads 0.1x input, cache writes 1.25x.
59const PRICES: [RegExp, number, number][] = [
60 [/haiku/i, 1, 5],
61 [/sonnet/i, 3, 15],
62 [/opus|fable/i, 5, 25],
63]
64
65type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
66
67export function costOf(model: string, u: Usage): number {
68 const [, inp, out] = PRICES.find(([re]) => re.test(model)) ?? [/opus/, 5, 25]
69 return (u.input_tokens * inp + u.cache_read_input_tokens * inp * 0.1 + u.cache_creation_input_tokens * inp * 1.25 + u.output_tokens * out) / 1e6
70}
71
72function short(text: string, max: number): string {
73 const one = text.replace(/\s+/g, ' ').trim()
74 return one.length > max ? `${one.slice(0, max - 1)}…` : one
75}
76
77function tail(path: string): string {
78 const parts = path.split('/').filter(Boolean)
79 return parts.length > 3 ? `…/${parts.slice(-3).join('/')}` : path
80}
81
82// The first character not already taken this session; past the list, grog-2, kiki-2, ...
83export function nameFor(taken: readonly string[]): string {
84 for (let round = 1; ; round++) {
85 for (const base of NAMES) {
86 const name = round === 1 ? base : `${base}-${round}`
87 if (!taken.includes(name)) return name
88 }
89 }
90}
91
92export function namesInUse(map: AgentMap, held: readonly Reservation[]): string[] {
93 const live = Object.values(map).filter(r => isLive(r) && r.name).map(r => r.name)
94 const recorded = new Set(Object.values(map).map(r => r.name))
95 return [...live, ...held.filter(r => !recorded.has(r.name) || live.includes(r.name)).map(r => r.name)]
96}
97
98const WRITES = new Set(['Edit', 'Write', 'NotebookEdit', 'MultiEdit'])
99
100export function editedPath(tool: string, input: Record<string, unknown>): string | null {
101 if (!WRITES.has(tool)) return null
102 const p = input.file_path ?? input.notebook_path ?? input.path
103 return typeof p === 'string' ? p : null
104}
105
106export function actionLabel(tool: string, input: Record<string, unknown>): string {
107 const s = (k: string) => (typeof input[k] === 'string' ? (input[k] as string) : '')
108 if (tool === 'Bash') return `Bash ${short(s('description') || s('command'), 48)}`
109 if (tool === 'Agent') return `Agent: spawn ${s('name') || short(s('description'), 40)}`
110 const path = s('file_path') || s('notebook_path') || s('path')
111 if (path) return `${tool} ${tail(path)}`
112 const q = s('pattern') || s('query') || s('url')
113 return q ? `${tool} ${short(q, 40)}` : tool
114}
115
116export function elapsed(ms: number): string {
117 const s = Math.max(0, Math.round(ms / 1000))
118 if (s < 60) return `${s}s`
119 const m = Math.floor(s / 60)
120 return m < 60 ? `${m}m${String(s % 60).padStart(2, '0')}s` : `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}m`
121}
122
123export function isLive(row: AgentRow): boolean {
124 return row.status === 'running' || row.status === 'pending' || row.status === 'waiting'
125}
126
127export function glyph(row: AgentRow, at: number): string {
128 if (isLive(row) && at - row.lastActivity > STUCK_MS) return '⚠'
129 return (
130 { running: '◐', pending: '○', waiting: '⏸', idle: '✓', completed: '✓', failed: '✗', killed: '■' } as Record<string, string>
131 )[row.status] ?? '·'
132}
133
134export type TreeLine = { row: AgentRow; lead: string; cont: string; subtreeCost: number }
135
136// Depth-first rows: live roots first, then newest; children under their parent with ├─ └─ guides.
137export function treeLines(map: AgentMap): TreeLine[] {
138 const all = Object.values(map)
139 const kids = new Map<string | null, AgentRow[]>()
140 for (const row of all) {
141 const parent = row.parentId && map[row.parentId] ? row.parentId : null
142 kids.set(parent, [...(kids.get(parent) ?? []), row])
143 }
144 const order = (a: AgentRow, b: AgentRow) => Number(isLive(b)) - Number(isLive(a)) || b.startedAt - a.startedAt
145 const cost = (row: AgentRow): number => row.costUsd + (kids.get(row.id) ?? []).reduce((sum, k) => sum + cost(k), 0)
146 const out: TreeLine[] = []
147 const walk = (parent: string | null, indent: string) => {
148 const list = [...(kids.get(parent) ?? [])].sort(order)
149 list.forEach((row, i) => {
150 const last = i === list.length - 1
151 const lead = parent === null ? '' : `${indent}${last ? '└─ ' : '├─ '}`
152 const next = parent === null ? '' : `${indent}${last ? ' ' : '│ '}`
153 out.push({ row, lead, cont: next, subtreeCost: cost(row) })
154 walk(row.id, next)
155 })
156 }
157 walk(null, '')
158 return out
159}
160
161function money(usd: number): string {
162 return usd < 0.005 ? '' : `≈$${usd.toFixed(2)}`
163}
164
165function tokens(n: number): string {
166 return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${Math.round(n / 1e3)}k` : String(n)
167}
168
169// Drops finished agents that ended more than DONE_TTL_MS ago, keeping any with a running agent under them.
170export function pruneFinished(map: AgentMap, at: number): AgentMap {
171 const liveUnder = (id: string): boolean =>
172 Object.values(map).some(r => r.parentId === id && (isLive(r) || liveUnder(r.id)))
173 return Object.fromEntries(
174 Object.entries(map).filter(([id, r]) => isLive(r) || at - (r.endedAt ?? r.lastActivity) <= DONE_TTL_MS || liveUnder(id)),
175 )
176}
177
178export function newRow(over: Partial<AgentRow> & { id: string }, at: number): AgentRow {
179 return {
180 name: '', description: '', type: '', parentId: null, model: '', status: 'running', prompt: '',
181 startedAt: at, endedAt: null, lastActivity: at, tools: 0, current: '', recent: [], files: [],
182 tokensIn: 0, tokensOut: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0, result: '',
183 alerted: false, stuckAlerted: false, joinedLate: false, ...over,
184 }
185}
186
187// Agents this mod never saw spawn (they started before it loaded): add them from the engine's list.
188export function adoptRows(map: AgentMap, listed: readonly AgentInfoLike[], at: number): AgentRow[] {
189 return listed
190 .filter(info => !map[info.id] && ['running', 'pending', 'waiting'].includes(info.status))
191 .map(info =>
192 newRow(
193 {
194 id: info.id,
195 name: info.name ?? '',
196 description: info.description,
197 type: info.type,
198 parentId: info.parentId ?? null,
199 status: info.status,
200 current: 'started before the panel loaded',
201 alerted: !['running', 'pending', 'waiting', 'idle'].includes(info.status),
202 joinedLate: true,
203 },
204 at,
205 ),
206 )
207}
208
209export type AgentInfoLike = { id: string; name?: string; description: string; type: string; status: string; parentId?: string }
210
211// Commands a reply hands the user to run themselves: lines that start with "! " (Claude Code's run-it-yourself prefix),
212// in prose or in code blocks. Inside a code block the lines after a "! " line, up to a blank line, the fence or the
213// next "! " line, belong to the same command; outside one, only after a line ending in a backslash. Each command's
214// "what" is the nearest real line above it (a heading or a numbered step), never a fence.
215const FENCE = /^\s*(```|~~~)/
216
217function labelLine(raw: string): string {
218 if (FENCE.test(raw) || /^[\s`~>#*_-]*$/.test(raw)) return ''
219 return raw.replace(/^[\s>#*-]+/, '').replace(/\*\*/g, '').replace(/[::]\s*$/, '').trim()
220}
221
222export function userCommands(text: string): { command: string; what: string }[] {
223 const out: { command: string; what: string }[] = []
224 const lines = text.split('\n')
225 let inFence = false
226 let lastLabel = ''
227 for (let i = 0; i < lines.length; i++) {
228 const line = lines[i] ?? ''
229 if (FENCE.test(line)) {
230 inFence = !inFence
231 continue
232 }
233 const m = /^\s*(?:[-*]\s+|\d+[.)]\s+)?(`)?!\s+(.+?)\s*$/.exec(line)
234 if (!m || !m[2] || m[2].length < 2) {
235 const label = labelLine(line)
236 if (label) lastLabel = label
237 continue
238 }
239 let first = m[2]
240 let what = lastLabel
241 if (m[1]) {
242 // `! cmd`: what it does, all on one line: the command ends at the closing backtick.
243 const close = first.indexOf('`')
244 if (close >= 0) {
245 const after = first.slice(close + 1).replace(/^[\s::—–-]+/, '').trim()
246 first = first.slice(0, close)
247 if (after) what = after
248 }
249 }
250 const parts = [first.trim()]
251 while (i + 1 < lines.length) {
252 const nextLine = lines[i + 1] ?? ''
253 const prev = parts[parts.length - 1] ?? ''
254 const continues = inFence
255 ? nextLine.trim() !== '' && !FENCE.test(nextLine) && !/^\s*!\s/.test(nextLine)
256 : /\\\s*$/.test(prev) && nextLine.trim() !== ''
257 if (!continues) break
258 parts.push(nextLine.trim())
259 i++
260 }
261 out.push({ command: parts.join('\n').trim(), what: short(what || 'Run this command', 140) })
262 }
263 return out
264}
265
266export function commandKey(command: string): string {
267 let h = 0
268 for (const ch of command.replace(/\s+/g, ' ').trim()) h = (h * 31 + ch.charCodeAt(0)) | 0
269 return `cmd-${(h >>> 0).toString(36)}`
270}
271
272// What the user ran with "!" (bash mode), however the prompt carries it.
273export function ranCommand(text: string): string | null {
274 const tagged = /<bash-input>([\s\S]*?)<\/bash-input>/.exec(text)
275 if (tagged && tagged[1]) return tagged[1].trim()
276 const bang = /^\s*!\s*([\s\S]+)$/.exec(text)
277 return bang && bang[1] ? bang[1].trim() : null
278}
279
280export function openItemsNote(open: readonly Blocker[]): string {
281 const lines = open.map(b => `- ${b.id} (${b.kind}): ${b.what}${b.command ? ` — command: ${short(b.command.replace(/\s+/g, ' '), 120)}` : ''}`)
282 return (
283 'Open items in the user\'s "Blocked on you" panel:\n' + lines.join('\n') + '\n' +
284 `If this message settles any of them (an answer, a choice, pasted output, "done", or something you can now see is ` +
285 `done), call ${CLEAR_FULL} with each settled id before you go on. Leave the rest. Do not mention this list.`
286 )
287}
288
289const KIND_LABEL: Record<string, string> = {
290 decision: 'Decide', command: 'Run', access: 'Access', review: 'Review', other: 'Needs you',
291 question: 'Answer', plan: 'Approve plan', permission: 'Allow', waiting: 'Waiting',
292}
293
294export function kindLabel(kind: string): string {
295 return KIND_LABEL[kind] ?? 'Needs you'
296}
297
298async function addBlocker($: EngineInterface, item: Omit<Blocker, 'id' | 'since'> & { id?: string }): Promise<Blocker> {
299 const seq = (await read($, blockerSeq)) + 1
300 await update($, blockerSeq, () => seq)
301 const id = item.id ?? `b${seq}`
302 const existing = (await read($, blockers)).find(b => b.id === id)
303 const full: Blocker = { ...item, id, since: existing?.since ?? (await $.clock.now()) }
304 await update($, blockers, list => (existing ? list.map(b => (b.id === id ? full : b)) : [...list, full]))
305 if (!(await read($, opened))) await openPane($)
306 return full
307}
308
309async function dropBlocker($: EngineInterface, test: (b: Blocker) => boolean): Promise<number> {
310 const before = (await read($, blockers)).length
311 await update($, blockers, list => list.filter(b => !test(b)))
312 return before - (await read($, blockers)).length
313}
314
315// Setup steps that fail go to the debug log (claude --debug), never stop the rest.
316async function note($: EngineInterface, line: string): Promise<void> {
317 $.ui.log(`agent-deck: ${line}`, { to: 'debug' })
318}
319
320async function patch($: EngineInterface, id: string, fn: (row: AgentRow) => AgentRow): Promise<void> {
321 await update($, agents, map => (map[id] ? { ...map, [id]: fn(map[id]) } : map))
322}
323
324async function openPane($: EngineInterface): Promise<void> {
325 await update($, opened, () => true)
326 let result
327 try {
328 result = await $.ui.open({ id: PANE, title: TITLE, columns: 56 })
329 } catch {
330 return
331 }
332 // Opened by itself (not by /deck) a pane only docks on a wide terminal; say how to see it.
333 if (!result.isPlaced) $.ui.toast('Subagents are being tracked: type /deck to show the panel')
334}
335
336async function alert($: EngineInterface, row: AgentRow, at: number): Promise<void> {
337 if (row.alerted || isLive(row)) return
338 await patch($, row.id, r => ({ ...r, alerted: true }))
339 const mark = row.status === 'completed' ? '✓' : row.status === 'killed' ? '■ stopped' : '✗ failed'
340 const cost = money(row.costUsd)
341 $.ui.toast(`${mark} ${row.name || short(row.description, 50)} · ${elapsed((row.endedAt ?? at) - row.startedAt)}${cost ? ` · ${cost}` : ''}`)
342}
343
344async function tick($: EngineInterface): Promise<void> {
345 let listed: AgentInfoLike[] = []
346 try {
347 listed = await $.agent.list()
348 } catch {
349 return
350 }
351 const map = await read($, agents)
352 const at = await $.clock.now()
353 const adopted = adoptRows(map, listed, at)
354 if (adopted.length) {
355 await update($, agents, cur => ({ ...cur, ...Object.fromEntries(adopted.map(r => [r.id, r])) }))
356 if (!(await read($, opened))) await openPane($)
357 }
358 const waitingOn = await read($, blockers)
359 if (waitingOn.length) {
360 await dropBlocker($, b => !b.auto && at - b.since > (b.id.startsWith('cmd-') ? REPLY_COMMAND_TTL_MS : FLAG_TTL_MS))
361 await update($, now, () => at)
362 }
363 const before = await read($, agents)
364 const kept = pruneFinished(before, at)
365 if (Object.keys(kept).length !== Object.keys(before).length) {
366 await update($, agents, () => kept)
367 await update($, now, () => at)
368 }
369 const rows = Object.values(await read($, agents))
370 if (!rows.some(isLive)) return
371 await update($, now, () => at)
372 for (const info of listed) {
373 const row = (await read($, agents))[info.id]
374 if (!row || row.status === info.status) continue
375 await patch($, info.id, r => ({ ...r, status: info.status, endedAt: isLive({ ...r, status: info.status }) ? null : r.endedAt ?? at }))
376 }
377 for (const row of Object.values(await read($, agents))) {
378 await alert($, row, at)
379 if (isLive(row) && !row.stuckAlerted && at - row.lastActivity > STUCK_MS) {
380 await patch($, row.id, r => ({ ...r, stuckAlerted: true }))
381 $.ui.toast(`⚠ ${row.name || short(row.description, 50)}: no activity for ${elapsed(at - row.lastActivity)}`)
382 }
383 }
384}
385
386export const register: Register = on => {
387 on('session.start', async ($, e, next) => {
388 const result = await next(e)
389 await note($, 'session.start')
390 const step = async (name: string, fn: () => Promise<unknown>) => {
391 try {
392 await fn()
393 await note($, `ok ${name}`)
394 } catch (err) {
395 await note($, `FAIL ${name}: ${err instanceof Error ? err.message : String(err)}`)
396 }
397 }
398 await step('command deck', () => $.command.register({ name: 'deck', description: 'Show or hide the Agents panel (subagents and what is blocked on you)' }))
399 await step('tool blocked_on_user', () => $.tool.register({
400 name: BLOCK_TOOL,
401 description:
402 'Show the user, in the Agents panel under "Blocked on you", something only they can give before work can go on: ' +
403 'a decision, a command only they can run, access or credentials, or a review. Returns the item id.',
404 inputSchema: {
405 type: 'object',
406 properties: {
407 kind: { type: 'string', enum: [...KINDS] },
408 what: { type: 'string', description: 'One plain line: what you need from the user and why.' },
409 command: { type: 'string', description: 'For kind "command": the exact command for them to run.' },
410 },
411 required: ['kind', 'what'],
412 },
413 isDeferred: false,
414 }))
415 await step('tool unblocked', () => $.tool.register({
416 name: CLEAR_TOOL,
417 description: 'Remove an item from "Blocked on you" in the Agents panel once it is resolved.',
418 inputSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'] },
419 isDeferred: false,
420 }))
421 $.clock.every(TICK_MS, () => {
422 void tick($)
423 })
424 if (await read($, opened)) void openPane($)
425 void tick($)
426 return result
427 })
428
429 on('prompt.compose', async ($, e, next) => {
430 const result = await next(e)
431 const added = [{ id: 'agent-deck:blocked', text: BLOCK_RULE, scope: 'session' as const }]
432 if (e.tools.includes('Agent')) added.unshift({ id: 'agent-deck:spawn', text: SPAWN_RULE, scope: 'session' as const })
433 return { sections: [...result.sections, ...added] }
434 })
435
436 on('prompt.submit', async ($, e, next) => {
437 const ran = ranCommand(e.text)
438 if (ran) {
439 const norm = (t: string) => t.replace(/\s+/g, ' ').trim()
440 const head = norm(ran).slice(0, 60)
441 await dropBlocker($, b => b.kind === 'command' && !!b.command && (norm(b.command) === norm(ran) || norm(b.command).startsWith(head)))
442 return next(e)
443 }
444 // Anything the user's message settles (an answer, pasted output, "done") is cleared by the model: it gets the
445 // open items beside the prompt, unseen by the user.
446 const open = (await read($, blockers)).filter(b => !b.auto)
447 if (!open.length) return next(e)
448 return next({ ...e, context: [...(e.context ?? []), openItemsNote(open)] })
449 }).catch(($, e, next) => next(e))
450
451 on('command.run', { command: 'deck' }, async $ => {
452 const open = (await $.ui.panes()).some(p => p.id === PANE)
453 if (open) {
454 await update($, opened, () => false)
455 await $.ui.close({ id: PANE })
456 return { text: 'Agents pane closed.' }
457 }
458 await openPane($)
459 return { text: 'Agents pane opened.' }
460 }).catch(() => ({ text: 'Agents pane: could not toggle.' }))
461
462 on('agent.spawn', async ($, e, next) => {
463 const prompt = e.prompt.includes(MAY_SPAWN.trim()) ? e.prompt : e.prompt + MAY_SPAWN
464 const result = await next({ ...e, prompt })
465 if (!result.agentId) return result
466 const at = await $.clock.now()
467 const row = newRow(
468 {
469 id: result.agentId,
470 name: e.name ?? '',
471 description: e.description,
472 type: e.subagentType,
473 parentId: e.parentAgentId ?? null,
474 model: result.model ?? e.model ?? '',
475 prompt: short(e.prompt, 300),
476 current: 'starting',
477 },
478 at,
479 )
480 await update($, agents, map => ({ ...map, [row.id]: row }))
481 await update($, now, () => at)
482 if (!(await read($, opened))) await openPane($)
483 return result
484 }).catch(($, e, next) => next(e))
485
486 on('tool.call', { tool: BLOCK_FULL }, async ($, e) => {
487 const input = e as unknown as Record<string, unknown>
488 const str = (k: string) => (typeof input[k] === 'string' ? (input[k] as string).trim() : '')
489 const kind = (KINDS as readonly string[]).includes(str('kind')) ? str('kind') : 'other'
490 const who = e.agentId ? (await read($, agents))[e.agentId]?.name ?? '' : ''
491 const item = await addBlocker($, { kind, what: short(str('what') || 'Needs you', 200), command: str('command'), agent: who, auto: false })
492 $.ui.toast(`Needs you: ${kindLabel(kind)} — ${short(item.what, 60)}`)
493 return { result: `Shown to the user under "Blocked on you" as ${item.id}. Call ${CLEAR_FULL} with that id once it is resolved.` }
494 })
495
496 on('tool.call', { tool: CLEAR_FULL }, async ($, e) => {
497 const id = String((e as unknown as Record<string, unknown>).id ?? '')
498 const n = await dropBlocker($, b => b.id === id && !b.auto)
499 return { result: n ? `Removed ${id}.` : `No open item ${id}.` }
500 })
501
502 on('tool.call', async ($, e, next) => {
503 const map0 = await read($, agents)
504 let call = e
505 const input = e as unknown as Record<string, unknown>
506 if (e.tool === 'Agent') {
507 // Names are held only by running agents, plus names just handed to Agent calls whose spawn is not recorded yet
508 // (parallel calls in one message all reach here first). A finished agent's name is free again.
509 const given = typeof input.name === 'string' ? input.name.trim() : ''
510 const at = await $.clock.now()
511 let name = given
512 await update($, reserved, list => {
513 const fresh = list.filter(r => at - r.at < RESERVE_MS)
514 const taken = namesInUse(map0, fresh)
515 if (!name || taken.includes(name)) name = nameFor(taken)
516 return [...fresh, { name, at }]
517 })
518 if (name !== given) call = { ...e, name } as typeof e
519 }
520 const id = e.agentId
521 if (id && (await read($, agents))[id]) {
522 const args = call as unknown as Record<string, unknown>
523 const label = actionLabel(e.tool, args)
524 const path = editedPath(e.tool, args)
525 const at = await $.clock.now()
526 await patch($, id, r => ({
527 ...r,
528 tools: r.tools + 1,
529 current: label,
530 recent: [...r.recent, label].slice(-5),
531 files: path && !r.files.includes(path) ? [...r.files, path] : r.files,
532 lastActivity: at,
533 stuckAlerted: false,
534 }))
535 }
536 const who = id ? (await read($, agents))[id]?.name ?? '' : ''
537 const asks = e.tool === 'AskUserQuestion' ? 'question' : e.tool === 'ExitPlanMode' ? 'plan' : ''
538 if (asks && e.tool_use_id) {
539 const qs = (call as unknown as { questions?: { question?: string }[] }).questions
540 const what = asks === 'plan' ? 'A plan is waiting for your approval' : short(qs?.[0]?.question ?? 'A question is waiting for you', 160)
541 await addBlocker($, { id: `ask-${e.tool_use_id}`, kind: asks, what, command: '', agent: who, auto: true })
542 try {
543 return await next(call)
544 } finally {
545 await dropBlocker($, b => b.id === `ask-${e.tool_use_id}`)
546 }
547 }
548 try {
549 return await next(call)
550 } finally {
551 // The call ran (or was refused): a permission prompt for this tool is over.
552 await dropBlocker($, b => b.kind === 'permission' && b.id === `perm-${e.tool}${who ? `-${who}` : ''}`)
553 }
554 }).catch(($, e, next) => next(e))
555
556 on('classic.PermissionRequest', async ($, e, next) => {
557 const input = (e.tool_input ?? {}) as Record<string, unknown>
558 const who = e.agent_id ? (await read($, agents))[e.agent_id]?.name ?? '' : ''
559 await addBlocker($, {
560 id: `perm-${e.tool_name}${who ? `-${who}` : ''}`,
561 kind: 'permission',
562 what: actionLabel(e.tool_name, input),
563 command: '',
564 agent: who,
565 auto: true,
566 })
567 return next(e)
568 }).catch(($, e, next) => next(e))
569
570 on('turn.complete', async ($, e, next) => {
571 const result = await next(e)
572 const id = e.agentId
573 if (!id) {
574 await dropBlocker($, b => b.kind === 'permission' && !b.agent)
575 // Fallback for replies that hand the user commands without flagging them.
576 const found = userCommands(e.answer)
577 if (found.length) {
578 const keep = new Set(found.map(c => commandKey(c.command)))
579 await dropBlocker($, b => b.id.startsWith('cmd-') && !keep.has(b.id))
580 }
581 const flagged = new Set((await read($, blockers)).filter(b => !b.id.startsWith('cmd-')).map(b => b.command.replace(/\s+/g, ' ').trim()))
582 for (const c of found) {
583 if (flagged.has(c.command.replace(/\s+/g, ' ').trim())) continue
584 await addBlocker($, { id: commandKey(c.command), kind: 'command', what: c.what, command: c.command, agent: '', auto: false })
585 }
586 }
587 if (!id || !(await read($, agents))[id]) return result
588 const at = await $.clock.now()
589 const u = e.usage
590 const status = e.reason === 'error' ? 'failed' : e.isAborted ? 'killed' : 'completed'
591 await patch($, id, r => ({
592 ...r,
593 status,
594 endedAt: at,
595 lastActivity: at,
596 current: '',
597 model: u?.model ?? r.model,
598 tokensIn: r.tokensIn + (u?.input_tokens ?? 0),
599 tokensOut: r.tokensOut + (u?.output_tokens ?? 0),
600 cacheRead: r.cacheRead + (u?.cache_read_input_tokens ?? 0),
601 cacheWrite: r.cacheWrite + (u?.cache_creation_input_tokens ?? 0),
602 costUsd: r.costUsd + (u ? costOf(u.model, u) : 0),
603 result: short(e.answer, 400),
604 }))
605 await update($, now, () => at)
606 const row = (await read($, agents))[id]
607 if (row) await alert($, row, at)
608 return result
609 })
610
611 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
612 const { Box, Text, Button } = $.ui.resolve(e)
613 const map = await read($, agents)
614 const open = await read($, expanded)
615 const at = Math.max(await read($, now), ...Object.values(map).map(r => r.lastActivity))
616 const rows = Object.values(map)
617 const lines = treeLines(map)
618 const total = rows.reduce((sum, r) => sum + r.costUsd, 0)
619 const count = (s: string[]) => rows.filter(r => s.includes(r.status)).length
620 const head = [
621 `${count(['running', 'pending', 'waiting'])} running`,
622 `${count(['completed'])} done`,
623 count(['failed', 'killed']) ? `${count(['failed', 'killed'])} failed` : '',
624 money(total),
625 ].filter(Boolean).join(' · ')
626 const blocked = await read($, blockers)
627 const ORDER = ['decision', 'access', 'review', 'question', 'plan', 'permission', 'command', 'other', 'waiting']
628 const rank = (b: Blocker) => (ORDER.indexOf(b.kind) + 1 || ORDER.length) * 2 + Number(b.auto)
629 const needs: Blocker[] = [
630 ...[...blocked].sort((a, b) => rank(a) - rank(b) || a.since - b.since),
631 ]
632 const half = Math.max(6, Math.floor((e.props.scroll?.bodyRows ?? 24) / 2))
633 const width = Math.max(24, e.props.bodyColumns ?? 56)
634 const fit = (text: string, room: number) => (text.length > room ? `${text.slice(0, Math.max(1, room - 1))}…` : text)
635 // Rows never shrink: they are cut whole. Running agents take two lines, finished ones one, an open row its details.
636 const budget = half - 2
637 const cost = (r: AgentRow) =>
638 (isLive(r) ? 2 : 1) + (open === r.id ? 4 + r.files.length + (r.result ? 2 : 0) : 0)
639 const shown: TreeLine[] = []
640 let used = 0
641 let hidden = 0
642 for (const line of lines) {
643 const need = cost(line.row)
644 if (used + need > budget && !(isLive(line.row) && used + need <= budget + 2)) {
645 hidden++
646 continue
647 }
648 shown.push(line)
649 used += need
650 }
651 const color = (r: AgentRow) => {
652 const g = glyph(r, at)
653 return g === '⚠' ? 'warning' : g === '✓' ? 'success' : g === '✗' || g === '■' ? 'error' : 'claude'
654 }
655
656 return (
657 <Box flexDirection="column">
658 <Box flexDirection="column" height={half} overflow="hidden" flexShrink={0}>
659 {rows.length === 0 ? (
660 <Text dimColor>No subagents yet. They show here as a tree when one starts.</Text>
661 ) : (
662 <Text bold>{head}</Text>
663 )}
664 {shown.map(({ row, lead, cont, subtreeCost }) => {
665 const isOpen = open === row.id
666 const took = `${row.joinedLate ? '≥' : ''}${elapsed((row.endedAt ?? at) - row.startedAt)}`
667 const files = row.files.length ? `${row.files.length} file${row.files.length === 1 ? '' : 's'}` : ''
668 const label = row.name ? `${row.name} — ${short(row.description, 44)}` : short(row.description, 56)
669 const live = isLive(row)
670 const stats = live
671 ? [took, `${row.tools} tools`, files, money(subtreeCost), row.current].filter(Boolean).join(' · ')
672 : [took, files, money(subtreeCost)].filter(Boolean).join(' · ')
673 const room = Math.max(12, width - lead.length - 2)
674 const line1 = fit(live ? label : `${label} · ${stats}`, room)
675 return (
676 <Box key={row.id} flexDirection="column" flexShrink={0}>
677 <Box flexDirection="row" flexShrink={0}>
678 <Text dimColor>{lead}</Text>
679 <Text color={color(row)}>{glyph(row, at)} </Text>
680 <Button key={`row-${row.id}`} plain dimColor={!live} label={line1} onPress={() => update($, expanded, cur => (cur === row.id ? null : row.id))} />
681 </Box>
682 {live && <Text dimColor>{fit(`${cont} ${stats}`, width)}</Text>}
683 {isOpen && (
684 <Box flexDirection="column" flexShrink={0}>
685 <Text dimColor wrap="truncate-end">{cont} {[row.type, row.model.replace(/^claude-/, '')].filter(Boolean).join(' · ')} · {row.tools} tools</Text>
686 <Text wrap="wrap">{cont} Task: {short(row.prompt.replace(MAY_SPAWN, ''), 200)}</Text>
687 <Text dimColor>
688 {cont} Tokens: {tokens(row.tokensIn + row.cacheRead + row.cacheWrite)} in · {tokens(row.tokensOut)} out
689 </Text>
690 {row.files.length > 0 && <Text>{cont} Files changed:</Text>}
691 {row.files.map(f => (
692 <Text key={`${row.id}-${f}`} color="diffAdded" wrap="truncate-start">
693 {cont} {tail(f)}
694 </Text>
695 ))}
696 {row.recent.length > 0 && <Text dimColor wrap="truncate-end">{cont} Last: {row.recent.slice(-3).join(' → ')}</Text>}
697 {row.result && <Text wrap="wrap">{cont} Result: {short(row.result, 300)}</Text>}
698 </Box>
699 )}
700 </Box>
701 )
702 })}
703 {hidden > 0 && <Text dimColor>+{hidden} more not shown (oldest first) · click a row for details</Text>}
704 </Box>
705 <Text bold color={needs.length ? 'warning' : undefined}>
706 {needs.length ? `Blocked on you (${needs.length})` : 'Blocked on you'}
707 </Text>
708 {needs.length === 0 && <Text dimColor>Nothing. Claude can keep going.</Text>}
709 {needs.map(b => {
710 const lines = b.command.split('\n')
711 const first = lines[0] ?? ''
712 const more = lines.length > 1 ? ` (+${lines.length - 1} lines)` : ''
713 return (
714 <Box key={b.id} flexDirection="column" marginTop={1}>
715 <Box flexDirection="row" justifyContent="space-between">
716 <Text wrap="truncate-end">
717 <Text color="warning" bold>{kindLabel(b.kind)}</Text>
718 {b.agent ? <Text dimColor> · {b.agent}</Text> : null}
719 </Text>
720 <Text dimColor>{elapsed(at - b.since)} ago</Text>
721 </Box>
722 <Text wrap="wrap">{b.what}</Text>
723 {b.command ? (
724 <Text dimColor wrap="truncate-end">
725 $ {first}
726 {more}
727 </Text>
728 ) : null}
729 {!b.auto || b.command ? (
730 <Box flexDirection="row" gap={2}>
731 {b.command ? (
732 <Button key={`copy-${b.id}`} label="Copy" onPress={() => void $.ui.copy({ text: `! ${b.command}`, surface: e.surface })} />
733 ) : null}
734 {!b.auto ? (
735 <Button key={`done-${b.id}`} label="Done" onPress={() => void dropBlocker($, x => x.id === b.id)} />
736 ) : null}
737 </Box>
738 ) : null}
739 </Box>
740 )
741 })}
742 </Box>
743 )
744 })
745}
746types/index.d.ts 47 lines1export type AgentRow = {
2 id: string
3 name: string
4 description: string
5 type: string
6 parentId: string | null
7 model: string
8 status: string
9 prompt: string
10 startedAt: number
11 endedAt: number | null
12 lastActivity: number
13 tools: number
14 current: string
15 recent: string[]
16 files: string[]
17 tokensIn: number
18 tokensOut: number
19 cacheRead: number
20 cacheWrite: number
21 costUsd: number
22 result: string
23 alerted: boolean
24 stuckAlerted: boolean
25 joinedLate: boolean
26}
27
28export type AgentMap = Record<string, AgentRow>
29
30export type Reservation = { name: string; at: number }
31
32export type Blocker = {
33 id: string
34 kind: string
35 what: string
36 command: string
37 agent: string
38 since: number
39 auto: boolean
40}
41
42declare module 'claude-code' {
43 interface PluginState {
44 'agent-deck': { agents: AgentMap; expanded: string | null; now: number; opened: boolean; blockers: Blocker[]; blockerSeq: number; reserved: Reservation[] }
45 }
46}
47