SLOPSHOPPER

agent-deck

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

newpanespinnerrowsguardcommand
v0.1.0MITupdated 2026-10-06mrjk05/modemon/mods/agent-deck
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-deck
│ ┃ agent-deck ✕ › fix the failing auth test and add an audit log call │ ┃ No subagents yet │ ┃ ⏺ Read(src/auth.ts) │ ┃ Cards appear here when Claude spawns a ⎿ Read 6 lines │ ┃ subagent. ⏺ 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 │ │ › /agents │ ⎿ agent-deck: Agent deck opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · agent-deck
No subagents yet Cards appear here when Claude spawns a subagent.
README

agent-deck

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:

  • Status: ● running (accent colour), ✓ done (green), ✗ failed (red). Finished cards are dimmed and stay until you clear them.
  • Title: the Agent call's description.
  • Agent type and model: the subagent type (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.
  • Prompt summary: the prompt folded to one line.
  • Elapsed time: ticks every second while the agent runs, then freezes at its final duration.
  • Tool calls: how many tools the subagent has called, and the one running now (▸ 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.

Surfaces: terminal, desktop and mobile

SurfaceWhat you get
TerminalThe 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 appNo 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
  • Live, with a stamp. The row reads the cards from $.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.
  • Status line on the phone. While the mobile app is attached, the status line switches from ⚙ 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.
  • Every tree uses only Box and Text, which every surface draws.

Install

/plugin install agent-deck --marketplace mrjk05/modemon

Answer y to add the marketplace, then pick a scope.

Commands

CommandWhat it does
/agentsToggle the pane (on the phone: show the deck inline)
/agents open / /agents closeOpen or close it
/agents clearRemove 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.

Config

Set these in /config (or under pluginConfigs["agent-deck"].options in settings):

OptionDefaultWhat it does
autoOpentrueOpen the deck when the first subagent spawns (sidebar layouts only)
statusLinetrueShow ⚙ N agents running while subagents run (⚙ N · <newest title> with a phone attached)

How it works

  • 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.
  • Every 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.
  • All cards live in $.state, so editing or hot-reloading the mod keeps the deck.

Limitations

  • /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.
  • The deck learns whether the surface docks panes from what it draws (the spinner during a turn, or the command's own presentation). Until it has seen one, it does not open by itself.
  • Agents launched remotely (isolation: "remote") are marked done with the note "running in the cloud", because their progress is not visible to the session.
  • A subagent's tool calls are only counted from when the mod is loaded. Cards for agents found only through $.agent.list() start with no prompt summary and no tool history.
  • Teammates (named agents that idle between turns) show as done between turns and switch back to running when they call a tool again.
  • The command cannot tell which screen typed it: a Remote Control (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.
  • The status line is one line for every surface, so with a phone attached the terminal shows the phone's variant as well.
  • /agents clear and /agents close work from the phone; close closes the pane on the terminal or desktop, if one is open.
  • Built against Claude Code 2.1.290. The mods API is early access and may change between releases.
Source 3 files
hooks/register.tsx 717 lines
1// 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}
717
hooks/lib.ts 246 lines
1// 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}
246
types/index.d.ts 53 lines
1// 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