SLOPSHOPPER

agent-deck

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

newpaneguardcommandtoastprompt
v0.2.6MITupdated 2026-10-08Prabal-Singh/claude-mods/agent-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-deck
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet. They show here as a tree │ ┃ when one starts. ● agent-deck: agent-deck: session.start │ ┃ ● agent-deck: agent-deck: ok command deck │ ┃ ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ › /deck │ ┃ Blocked on you ⎿ agent-deck: Agents pane opened. │ ┃ Nothing. Claude can keep going. ● agent-deck: agent-deck: ok tool blocked_on_user │ ● agent-deck: agent-deck: ok tool unblocked │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
No subagents yet. They show here as a tree when one starts. Blocked on you Nothing. Claude can keep going.
README

claude-mods

Two mods for Claude Code.

agent-deck: your subagents, live, in a panel on the right

/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.

  • Status: ◐ running, ✓ done, ✗ failed, ■ stopped, ⚠ stuck (5 minutes with no activity).
  • Task, type and model, elapsed time, tool calls, files changed, and estimated cost for the agent and everything under it.
  • What it is doing right now (Edit …/app/x.py, Bash pytest -q).
  • Click a row for the task it was given, tokens in and out, files changed, its last actions and its result.
  • A pop-up when an agent finishes or fails. Finished agents take one line and leave the panel 10 minutes later.
  • Many agents: rows are cut whole, never squeezed. Running agents come first, then "+N more not shown".

Bottom half: Blocked on you. Everything that stops progress until you act:

  • Things Claude flags itself through the mod's blocked_on_user tool: a decision, a command only you can run (with Copy), access or credentials, a review.
  • Any ! command a reply hands you to run.
  • Open questions, plans waiting for approval and permission prompts (an agent waiting on its own subagents is not blocked on you, so it is not listed).

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.

cache-clock: compact before the prompt cache goes cold

/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.

License

MIT

Source 2 files
hooks/register.tsx 746 lines
1import { 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}
746
types/index.d.ts 47 lines
1export 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