SLOPSHOPPER

agent-graph

Pane drawing the session's running subagents: a left-to-right card graph in the desktop app, an indented tree in the terminal

newpaneguardcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-graph
│ ┃ Agents ✕ › fix the failing auth test and add an audit log call │ ┃ 0 running │ ┃ ⏺ Read(src/auth.ts) │ ┃ No subagents running. The graph appears here ⎿ Read 6 lines │ ┃ when the session spawns one. ⏺ 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 │ │ › /agent-graph │ ⎿ agent-graph: Agents graph opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Agents
0 running No subagents running. The graph appears here when the session spawns one.
README

claude-mods

A Claude Code plugin marketplace of mods: function-hooks plugins that draw bands, panes and other UI inside Claude Code.

Plugins

PluginWhat it does
meterBand above the prompt showing context, prompt cache, usage limits and cost; /meter opens a detailed metrics pane. Works in the terminal and the desktop app.
agent-graphPane graphing the session's running subagents: a left-to-right card graph in the desktop app, which opens it on the first subagent, and an indented tree in the terminal, opened with /agent-graph.
auto-handoffOnce the context passes 40% of a window over 200k tokens (50% of a smaller one), has a dedicated turn write a nine-section handoff, with the decisions, gotchas, conventions and open questions it names, into an Open Knowledge Format bundle at .auto-handoff/ in the project root, which git ignores by default; then clears the context and continues from the handoff, and shows each new conversation the bundle's index. Safe with several sessions in one project; `/auto-handoff on\off\status` switches it per session. No UI.
statuslineA styled row under the prompt's mode line, above the engine's hints: model (warning colour on a fallback), effort, directory, branch with a ● when dirty and ↑N↓N against the upstream. Terminal only; the desktop app shows the model in its own composer.

Install

git clone <repo-url> claude-mods
claude plugin marketplace add ./claude-mods
claude plugin install meter@claude-mods --scope user
claude plugin install agent-graph@claude-mods --scope user
claude plugin install auto-handoff@claude-mods --scope user
claude plugin install statusline@claude-mods --scope user

Layout

claude-mods/
├── .claude-plugin/
│   └── marketplace.json          # marketplace name, owner, plugin list
└── plugins/
    └── <name>/
        ├── .claude-plugin/
        │   └── plugin.json       # name, version, description
        ├── hooks/                # hooks.json and the function-hooks modules
        ├── types/                # this plugin's exported types ("types" in plugin.json)
        └── tsconfig.json

Development

Installed plugins load in place from this checkout. After editing, run /reload-plugins or restart Claude Code. For live iteration without installing, run claude --plugin-dir plugins/<name>.

Check a plugin with:

claude plugin validate plugins/<name>
cd plugins/<name> && claude plugin test .

Check the marketplace with claude plugin validate . from the repo root.

License

MIT

Source 7 files
hooks/register.tsx 280 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Activity, AgentRow } from '../types'
5import { drawSvg } from './draw-svg'
6import { drawText } from './draw-text'
7import {
8  MAIN,
9  RECENT_MS,
10  blankActivity,
11  buildGraph,
12  capAgents,
13  sanitize,
14  summarizeTool,
15  visibleAgents,
16} from './layout'
17import { withUsage } from './pricing'
18
19const PANE = 'agent-graph'
20const TITLE = 'Agents'
21
22const agentsAtom = atom({ plugin: 'agent-graph', key: 'agents' } as const, [])
23const activityAtom = atom({ plugin: 'agent-graph', key: 'activity' } as const, {})
24const nowAtom = atom({ plugin: 'agent-graph', key: 'now' } as const, 0)
25const autoOpenedAtom = atom({ plugin: 'agent-graph', key: 'autoOpened' } as const, false)
26const mainWorkingAtom = atom({ plugin: 'agent-graph', key: 'mainWorking' } as const, false)
27
28const PENDING_MS = 5000
29// The engine refuses an Svg over 4096px tall; 34 rows of cards is the most that fit.
30const MAX_AGENT_CARDS = 34
31// A pane tree draws only its first 100,000 characters of text; 100 entries stay well under it.
32const MAX_TEXT_AGENTS = 100
33
34const isRunning = (status: string) => status === 'running' || status === 'pending'
35
36let timer: Timer | undefined
37let wasLive = false
38let isSetUp = false
39let spawnsInFlight = 0
40let needsRegister = false
41let refreshChain: Promise<void> = Promise.resolve()
42let outstandingRefreshes = 0
43
44async function onDesktop($: EngineInterface) {
45  return (await $.session.surfaces()).includes('desktop')
46}
47
48function setUp($: EngineInterface) {
49  if (isSetUp) return
50  isSetUp = true
51  void $.command.register({ name: 'agent-graph', description: 'Show running subagents' }).catch(() => {})
52}
53
54function ensureTimer($: EngineInterface) {
55  timer ??= $.clock.every(1000, () => {
56    if (outstandingRefreshes === 0) void refresh($)
57  })
58}
59
60function stopTimer() {
61  timer?.cancel()
62  timer = undefined
63}
64
65function refresh($: EngineInterface) {
66  outstandingRefreshes += 1
67  refreshChain = refreshChain
68    .then(() => syncAgents($))
69    .catch(() => {})
70    .finally(() => {
71      outstandingRefreshes -= 1
72    })
73  return refreshChain
74}
75
76async function syncAgents($: EngineInterface) {
77  const now = await $.clock.now()
78  const list = await $.agent.list()
79  const rows: AgentRow[] = list.map(a => ({
80    id: a.id,
81    type: a.type,
82    description: a.description,
83    status: a.status,
84    parentId: a.parentId,
85    name: a.name,
86  }))
87  const prev = await read($, agentsAtom)
88  const prevStatus = new Map(prev.map(a => [a.id, a.status]))
89  if (JSON.stringify(prev) !== JSON.stringify(rows)) {
90    await update($, agentsAtom, () => rows)
91    await update($, activityAtom, act => {
92      const next: Record<string, Activity> = { ...act }
93      for (const r of rows) {
94        const cur = next[r.id] ?? blankActivity(now)
95        if (isRunning(r.status)) {
96          const { endedAt: _, ...running } = cur
97          const started = !isRunning(prevStatus.get(r.id) ?? '')
98          next[r.id] = started ? { ...running, runStart: prevStatus.has(r.id) ? now : cur.firstSeen } : running
99        } else if (isRunning(prevStatus.get(r.id) ?? '')) {
100          next[r.id] = { ...cur, endedAt: now, tool: undefined }
101        } else {
102          next[r.id] = cur
103        }
104      }
105      return next
106    })
107  }
108  const listed = new Set(rows.map(r => r.id))
109  await update($, activityAtom, act => {
110    const stale = Object.keys(act).filter(id => id !== MAIN && !listed.has(id) && now - (act[id]?.lastSeen ?? 0) >= PENDING_MS)
111    if (stale.length === 0) return act
112    const next = { ...act }
113    for (const id of stale) delete next[id]
114    return next
115  })
116  const activity = await read($, activityAtom)
117  const isLive = rows.some(r => isRunning(r.status) || now - (activity[r.id]?.endedAt ?? -Infinity) < RECENT_MS)
118  if (isLive || wasLive) await update($, nowAtom, () => now)
119  wasLive = isLive
120  if (isLive || spawnsInFlight > 0) ensureTimer($)
121  else stopTimer()
122}
123
124async function noteActivity($: EngineInterface, id: string, fn: (a: Activity) => Activity) {
125  const now = await $.clock.now()
126  await update($, activityAtom, act => ({
127    ...act,
128    [id]: { ...fn(act[id] ?? blankActivity(now)), lastSeen: now },
129  }))
130}
131
132function openPane($: EngineInterface) {
133  return $.ui.open({ id: PANE, title: TITLE })
134}
135
136export const register: Register = on => {
137  on('session.start', async ($, e, next) => {
138    setUp($)
139    return next(e)
140  })
141
142  on('session.attach', async ($, e, next) => {
143    setUp($)
144    return next(e)
145  })
146
147  on('session.end', async ($, e, next) => {
148    await update($, mainWorkingAtom, () => false)
149    if (e.reason === 'clear' || e.reason === 'resume') {
150      needsRegister = true
151      stopTimer()
152      wasLive = false
153      isSetUp = false
154      await update($, agentsAtom, () => [])
155      await update($, activityAtom, () => ({}))
156      await update($, autoOpenedAtom, () => false)
157    }
158
159    return next(e)
160  })
161
162  on('command.run', { command: 'agent-graph' }, async $ => {
163    ensureTimer($)
164    const opened = await openPane($)
165    if (opened.isPlaced) return { text: 'Agents graph opened.' }
166    const reason = sanitize(opened.reason)
167
168    return { text: reason ? `The Agents graph could not be opened: ${reason}` : 'The Agents graph could not be opened' }
169  })
170
171  on('turn.start', async ($, e, next) => {
172    if (needsRegister) {
173      needsRegister = false
174      setUp($)
175    }
176    await update($, mainWorkingAtom, () => true)
177
178    return next(e)
179  })
180
181  on('turn.complete', async ($, e, next) => {
182    if (e.agentId === undefined) await update($, mainWorkingAtom, () => false)
183    else await refresh($)
184
185    return next(e)
186  })
187
188  on('tool.call', async ($, e, next) => {
189    const isSpawn = e.tool === 'Agent' || (e.tool as string) === 'Task'
190    try {
191      if (isSpawn) {
192        spawnsInFlight += 1
193        ensureTimer($)
194      }
195      const summary = summarizeTool(e as unknown as Record<string, unknown>)
196      await noteActivity($, e.agentId ?? MAIN, a => ({ ...a, tool: summary }))
197      if (!isSpawn) return await next(e)
198
199      if (!(await read($, autoOpenedAtom)) && (await onDesktop($).catch(() => false))) {
200        await update($, autoOpenedAtom, () => true)
201        void openPane($).catch(() => {})
202      }
203      void $.clock.sleep(300).then(() => refresh($)).catch(() => {})
204      return await next(e)
205    } finally {
206      if (isSpawn) {
207        spawnsInFlight -= 1
208        await refresh($)
209      }
210    }
211  })
212
213  on('turn.step', async function* ($, e, next) {
214    const result = yield* next(e)
215    const { usage } = result
216    await noteActivity($, e.agentId ?? MAIN, a => {
217      const stepped = { ...a, steps: a.steps + 1 }
218      return usage ? withUsage(stepped, usage) : stepped
219    })
220
221    return result
222  })
223
224  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e, next) => {
225    if (e.surface !== 'desktop' && e.surface !== 'terminal') return next(e)
226    const { Box, Text } = $.ui.resolve(e)
227    const agents = await read($, agentsAtom)
228    const activity = await read($, activityAtom)
229    const mainWorking = await read($, mainWorkingAtom)
230    await read($, nowAtom)
231    const now = await $.clock.now()
232
233    const visible = visibleAgents(agents, activity, now)
234    const { agents: shown, hidden } = capAgents(visible, activity, e.surface === 'terminal' ? MAX_TEXT_AGENTS : MAX_AGENT_CARDS)
235    const running = visible.filter(a => isRunning(a.status)).length
236    const finished = agents.filter(a => !isRunning(a.status) && activity[a.id]?.endedAt !== undefined).length
237
238    const header = (
239      <Text key="header">
240        <Text bold>{running}</Text>
241        <Text dimColor> running</Text>
242        {finished > 0 ? <Text dimColor>{`  ·  ${finished} finished this session`}</Text> : null}
243      </Text>
244    )
245
246    if (shown.length === 0) {
247      return (
248        <Box flexDirection="column" rowGap={1}>
249          {header}
250          <Text dimColor>No subagents running. The graph appears here when the session spawns one.</Text>
251        </Box>
252      )
253    }
254
255    const { root, slots, depth } = buildGraph(shown, activity, now, mainWorking)
256    if (e.surface === 'terminal') {
257      return (
258        <Box flexDirection="column" rowGap={1}>
259          {header}
260          <Box key="graph" flexDirection="column">
261            {drawText($.ui.resolve(e), root, e.props.bodyColumns)}
262          </Box>
263          {hidden > 0 ? <Text key="more" dimColor>{`+${hidden} more ${hidden === 1 ? 'agent' : 'agents'}`}</Text> : null}
264        </Box>
265      )
266    }
267    const { Svg } = $.ui.resolve(e)
268    const { svg, width, height } = drawSvg(root, slots, depth)
269    const alt = shown.map(a => sanitize(`${a.name ?? a.type} (${a.status}): ${a.description}`)).join('; ')
270
271    return (
272      <Box flexDirection="column" rowGap={1}>
273        {header}
274        <Svg key="graph" source={svg} alt={alt} width={width} height={height} />
275        {hidden > 0 ? <Text key="more" dimColor>{`+${hidden} more ${hidden === 1 ? 'agent' : 'agents'}`}</Text> : null}
276      </Box>
277    )
278  })
279}
280
hooks/draw-svg.ts 135 lines
1import { type GraphNode, truncate, walk } from './layout'
2
3const W = 264
4const H = 106
5const GAP_X = 56
6const GAP_Y = 14
7const PAD = 8
8const INSET = 12
9const BAR_W = W - 2 * INSET
10const BAR_H = 4
11const EXTRA_DX = 8
12const CHAR_W = 6.6
13const TITLE_CHAR_W = 7.8
14const TITLE_X = 27
15const TITLE_MAX = 26
16const MODEL_MAX = 18
17const MODEL_GAP = 8
18
19const esc = (s: string) =>
20  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
21
22export const fitMetrics = (ctx: string, extras: string[]) => {
23  const parts = [ctx]
24  let left = BAR_W - ctx.length * CHAR_W
25  for (const e of extras) {
26    const avail = Math.floor((left - EXTRA_DX) / CHAR_W) - 1
27    if (e.length <= avail) {
28      parts.push(e)
29      left -= EXTRA_DX + (e.length + 1) * CHAR_W
30      continue
31    }
32    if (avail >= 2) parts.push(truncate(e, avail))
33    else if (avail === 1) parts.push('…')
34    else parts[parts.length - 1] = `${[...parts.at(-1)!].slice(0, -1).join('')}…`
35    break
36  }
37
38  return { ctx: parts[0]!, extras: parts.slice(1) }
39}
40
41const LIGHT = `
42.card{fill:#faf9f7;stroke:#d6d4ce;stroke-width:1}
43.card.running{stroke:#409524;stroke-width:1.5}
44.card.failed{stroke:#d0544a;stroke-width:1.5}
45.card.done{fill:none;stroke-dasharray:4 3}
46.title{font:600 12.5px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;fill:#1f1e1d}
47.meta,.act,.model{font:11px ui-monospace,SFMono-Regular,Menlo,monospace;fill:#8a877f}
48.ctx.warn,.bar.warn{fill:#c98a1b}.ctx.hot,.bar.hot{fill:#d0544a}.bar.ok{fill:#409524}
49.track{fill:rgba(128,128,128,0.28)}
50.done .bar,.idle .bar,.failed .bar{fill-opacity:.5}
51.desc{font:12px -apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;fill:#3d3c39}
52.done .title,.done .desc{fill:#8a877f}
53.edge{fill:none;stroke:#c4c1b9;stroke-width:1.25}
54.edge.running{stroke:#409524;stroke-opacity:.7}
55.dot.running{fill:#409524}.dot.failed{fill:#d0544a}.dot.done{fill:#8a877f}.dot.idle{fill:none;stroke:#8a877f}`
56
57const DARK = `
58.card{fill:#242423;stroke:#3d3d3b}
59.card.done{fill:none}
60.title{fill:#edede9}.desc{fill:#c9c7c0}.meta,.act,.model{fill:#8f8d87}
61.done .title,.done .desc{fill:#8f8d87}
62.edge{stroke:#4d4c49}
63.ctx.warn,.bar.warn{fill:#c98a1b}.ctx.hot,.bar.hot{fill:#d0544a}.bar.ok{fill:#409524}`
64
65const STYLE = `${LIGHT}\n@media (prefers-color-scheme: dark){${DARK}}`
66
67export const drawSvg = (root: GraphNode, slots: number, depth: number) => {
68  const width = PAD * 2 + (depth + 1) * W + depth * GAP_X
69  const height = PAD * 2 + slots * H + (slots - 1) * GAP_Y
70  const pos = (n: GraphNode) => ({ x: PAD + n.depth * (W + GAP_X), y: PAD + n.slot * (H + GAP_Y) })
71  const edges: string[] = []
72  const nodes: string[] = []
73
74  walk(root, (n, parent) => {
75    const { x, y } = pos(n)
76    if (parent) {
77      const p = pos(parent)
78      const x1 = p.x + W
79      const y1 = p.y + H / 2
80      const y2 = y + H / 2
81      const mid = (x1 + x) / 2
82      edges.push(
83        `<path class="edge ${n.status}" d="M${x1} ${y1} C${mid} ${y1} ${mid} ${y2} ${x} ${y2}"/>`,
84      )
85    }
86    const pulse =
87      n.status === 'running'
88        ? `<circle class="dot running" cx="${x + 16}" cy="${y + 19}" r="4"></circle>`
89        : `<circle class="dot ${n.status}" cx="${x + 16}" cy="${y + 19}" r="3.5"/>`
90    const meta = n.meta || (n.status === 'running' ? 'working' : 'idle')
91    const fit = fitMetrics(n.ctx, n.extras)
92    const metrics =
93      `<text class="meta" x="${x + INSET}" y="${y + 56}">` +
94      `<tspan class="ctx ${n.ctxTone}">${esc(fit.ctx)}</tspan>` +
95      fit.extras.map(e => `<tspan dx="${EXTRA_DX}"> ${esc(e)}</tspan>`).join('') +
96      `</text>`
97    const gauge =
98      n.ctxPct === undefined
99        ? ''
100        : `<rect class="track" x="${x + INSET}" y="${y + 62}" width="${BAR_W}" height="${BAR_H}" rx="2"/>` +
101          `<rect class="bar ${n.ctxTone}" x="${x + INSET}" y="${y + 62}" width="${+((BAR_W * n.ctxPct) / 100).toFixed(2)}" height="${BAR_H}" rx="2"/>`
102    const model = n.model === undefined ? undefined : truncate(n.model, MODEL_MAX)
103    const titleMax =
104      model === undefined
105        ? TITLE_MAX
106        : Math.min(
107            TITLE_MAX,
108            Math.floor((W - INSET - TITLE_X - [...model].length * CHAR_W - MODEL_GAP) / TITLE_CHAR_W),
109          )
110    const modelText =
111      model === undefined
112        ? ''
113        : `<text class="model" x="${x + W - INSET}" y="${y + 23}" text-anchor="end">${esc(model)}</text>`
114    const desc = n.description || (n.depth === 0 ? 'this session' : '')
115    nodes.push(
116      `<g class="${n.status}">` +
117        `<rect class="card ${n.status}" x="${x + 0.5}" y="${y + 0.5}" width="${W - 1}" height="${H - 1}" rx="9"/>` +
118        pulse +
119        `<text class="title" x="${x + TITLE_X}" y="${y + 23}">${esc(truncate(n.title, titleMax))}</text>` +
120        modelText +
121        `<text class="meta" x="${x + 12}" y="${y + 40}">${esc(truncate(meta, 30))}</text>` +
122        metrics +
123        gauge +
124        `<text class="desc" x="${x + 12}" y="${y + 82}">${esc(truncate(desc, 31))}</text>` +
125        `<text class="act" x="${x + 12}" y="${y + 97}">${esc(n.status === 'running' || n.depth === 0 ? truncate(n.activity, 30) : '')}</text>` +
126        `</g>`,
127    )
128  })
129
130  const svg =
131    `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
132    `<style>${STYLE}</style>${edges.join('')}${nodes.join('')}</svg>`
133  return { svg, width, height }
134}
135
hooks/draw-text.tsx 131 lines
1import type { Color, Elements } from 'claude-code'
2
3import { type GraphNode, type NodeParts, type Tone, truncate } from './layout'
4
5type Els = Elements['terminal']
6type Run = { text: string; color?: Color; dim?: boolean; bold?: boolean }
7type Line = { key: string; gutter: string; runs: Run[]; right?: Run }
8type Tier = { descOnHead: boolean; splitMetrics: boolean; gauge: number }
9
10const WIDE: Tier = { descOnHead: true, splitMetrics: false, gauge: 12 }
11const MEDIUM: Tier = { descOnHead: true, splitMetrics: true, gauge: 8 }
12const NARROW: Tier = { descOnHead: false, splitMetrics: true, gauge: 0 }
13const TITLE_MAX = 32
14const MODEL_MAX = 18
15const TEXT_MAX = 200
16const GLYPH: Record<GraphNode['status'], string> = { running: '●', done: '✓', failed: '✗', idle: '○' }
17
18export const tierOf = (columns: number): Tier => (columns >= 80 ? WIDE : columns >= 56 ? MEDIUM : NARROW)
19
20const plain = (text: string): Run => ({ text })
21const dim = (text: string): Run => ({ text, dim: true })
22const toned = (text: string, tone: Tone): Run =>
23  tone === 'hot' ? { text, color: 'error', bold: true } : tone === 'warn' ? { text, color: 'warning', bold: true } : { text }
24
25const glyphOf = (status: GraphNode['status']): Run =>
26  status === 'failed'
27    ? { text: GLYPH.failed, color: 'error', bold: true }
28    : status === 'running'
29      ? { text: GLYPH.running, bold: true }
30      : dim(GLYPH[status])
31
32const filledCells = (pct: number, cells: number) => {
33  const ratio = Math.min(Math.max(pct, 0), 100) / 100
34  return ratio === 0 ? 0 : Math.max(1, Math.round(ratio * cells))
35}
36
37const gaugeRuns = (pct: number, tone: Tone, cells: number, alerts: boolean): Run[] => {
38  const filled = filledCells(pct, cells)
39  const runs: Run[] = []
40  if (filled > 0) runs.push(alerts ? toned('━'.repeat(filled), tone) : dim('━'.repeat(filled)))
41  if (cells - filled > 0) runs.push(dim('─'.repeat(cells - filled)))
42  return runs
43}
44
45const join = (segments: Run[][]): Run[] =>
46  segments.filter(s => s.length > 0).flatMap((s, i) => (i === 0 ? s : [plain('  '), ...s]))
47
48const countSegments = (p: NodeParts): Run[][] => [
49  p.elapsed ? [plain(p.elapsed)] : [],
50  p.steps ? [plain(String(p.steps)), dim(p.steps === 1 ? ' step' : ' steps')] : [],
51  p.tokens ? [plain(p.tokens), dim(' tok')] : [],
52]
53
54const ctxSegments = (n: GraphNode, tier: Tier, alerts: boolean): Run[][] => {
55  const p = n.parts
56  if (p.ctxTokens === undefined) return [[dim('waiting for first reply')]]
57  const tone: Tone = alerts ? n.ctxTone : 'ok'
58  const ctx: Run[] = [dim('ctx '), plain(p.ctxTokens)]
59  if (n.ctxPct !== undefined) {
60    ctx.push(plain(' '), toned(`${Math.round(n.ctxPct)}%`, tone))
61    if (tier.gauge > 0) ctx.push(plain(' '), ...gaugeRuns(n.ctxPct, tone, tier.gauge, alerts))
62  }
63  return [ctx, p.cacheHit ? [dim('cache '), plain(p.cacheHit)] : [], p.cost ? [plain(p.cost)] : []]
64}
65
66const rail = (isOpen: boolean) => (isOpen ? '│ ' : '  ')
67
68const nodeLines = (n: GraphNode, tier: Tier, rails: string, isLast: boolean): Line[] => {
69  const isRoot = n.depth === 0
70  const hasKids = n.children.length > 0
71  const head = isRoot ? '' : `${rails}${isLast ? '└─' : '├─'}`
72  const body = isRoot ? rail(hasKids) : `${rails}${rail(!isLast)}${rail(hasKids)}`
73  const alerts = isRoot || n.status === 'running'
74  const mute = (runs: Run[]) => (n.status === 'done' ? runs.map(r => dim(r.text)) : runs)
75  const desc = truncate(n.description, TEXT_MAX)
76  const own: Line[] = [
77    {
78      key: `${n.id}:head`,
79      gutter: head,
80      runs: mute([
81        glyphOf(n.status),
82        plain(' '),
83        { text: truncate(n.title, TITLE_MAX), bold: true },
84        ...(tier.descOnHead && desc ? [plain('  '), plain(desc)] : []),
85      ]),
86      right: n.model === undefined ? undefined : dim(truncate(n.model, MODEL_MAX)),
87    },
88  ]
89  if (!tier.descOnHead && desc) own.push({ key: `${n.id}:desc`, gutter: body, runs: mute([plain(desc)]) })
90  const counts = join(countSegments(n.parts))
91  const ctx = join(ctxSegments(n, tier, alerts))
92  if (tier.splitMetrics) {
93    if (counts.length > 0) own.push({ key: `${n.id}:count`, gutter: body, runs: mute(counts) })
94    own.push({ key: `${n.id}:ctx`, gutter: body, runs: mute(ctx) })
95  } else {
96    own.push({ key: `${n.id}:metrics`, gutter: body, runs: mute(join([counts, ctx])) })
97  }
98  if (n.activity && (n.status === 'running' || isRoot)) own.push({ key: `${n.id}:act`, gutter: body, runs: [dim(truncate(n.activity, TEXT_MAX))] })
99  const childRails = isRoot ? '' : `${rails}${rail(!isLast)}`
100  return [...own, ...n.children.flatMap((c, i) => nodeLines(c, tier, childRails, i === n.children.length - 1))]
101}
102
103const runText = (Text: Els['Text'], key: string, r: Run) => (
104  <Text key={key} color={r.color} dimColor={r.dim} bold={r.bold}>
105    {r.text}
106  </Text>
107)
108
109const maxDepthOf = (n: GraphNode): number => Math.max(n.depth, ...n.children.map(maxDepthOf))
110
111export const drawText = (els: Els, root: GraphNode, columns: number) => {
112  const { Box, Text } = els
113  return nodeLines(root, tierOf(columns - 2 * (maxDepthOf(root) + 1)), '', true).map(line => (
114    <Box key={line.key}>
115      {line.gutter ? (
116        <Box key="g" flexShrink={0}>
117          <Text dimColor>{line.gutter}</Text>
118        </Box>
119      ) : null}
120      <Box key="c" flexGrow={1} flexShrink={1}>
121        <Text wrap="truncate">{line.runs.map((r, i) => runText(Text, `r${i}`, r))}</Text>
122      </Box>
123      {line.right ? (
124        <Box key="m" flexShrink={0} marginLeft={2}>
125          {runText(Text, 'm', line.right)}
126        </Box>
127      ) : null}
128    </Box>
129  ))
130}
131
hooks/layout.ts 211 lines
1import type { Activity, AgentRow } from '../types'
2import { modelLabel } from './model'
3import { windowFor } from './pricing'
4
5export const MAIN = 'main'
6export const RECENT_MS = 2 * 60 * 1000
7
8export type Tone = 'ok' | 'warn' | 'hot'
9
10export type NodeParts = {
11  elapsed?: string
12  steps?: number
13  tokens?: string
14  ctxTokens?: string
15  cacheHit?: string
16  cost?: string
17}
18
19export type GraphNode = {
20  id: string
21  title: string
22  status: 'running' | 'done' | 'failed' | 'idle'
23  description: string
24  activity: string
25  meta: string
26  ctx: string
27  ctxPct?: number
28  ctxTone: Tone
29  model?: string
30  extras: string[]
31  parts: NodeParts
32  depth: number
33  slot: number
34  children: GraphNode[]
35}
36
37export const blankActivity = (now: number): Activity => ({
38  steps: 0,
39  outputTokens: 0,
40  input: 0,
41  cacheRead: 0,
42  cacheWrite: 0,
43  firstSeen: now,
44  lastSeen: now,
45})
46
47export const statusOf = (s: string): GraphNode['status'] =>
48  s === 'running' || s === 'pending' ? 'running' : s === 'completed' ? 'done' : s === 'failed' ? 'failed' : 'idle'
49
50export const fmtElapsed = (ms: number) => {
51  const s = Math.max(0, Math.floor(ms / 1000))
52  if (s < 60) return `${s}s`
53  const m = Math.floor(s / 60)
54  if (m < 60) return `${m}m ${String(s % 60).padStart(2, '0')}s`
55  return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, '0')}m`
56}
57
58export const sanitize = (s: string) =>
59  s
60    .replace(/[\t\n\r]+/g, ' ')
61    .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, '')
62
63const basename = (p: string) => p.split('/').filter(Boolean).pop() ?? p
64
65export const summarizeTool = (e: Record<string, unknown>) => {
66  const str = (k: string) => (typeof e[k] === 'string' ? (e[k] as string) : '')
67  const tool = str('tool')
68  const arg =
69    tool === 'Agent' || tool === 'Task'
70      ? str('subagent_type') || str('description')
71      : str('file_path')
72        ? basename(str('file_path'))
73        : str('command').split('\n')[0] || str('pattern') || str('url') || str('query') || str('description')
74  return arg ? `${tool} ${arg}` : tool
75}
76
77const fmtTokens = (n: number) => {
78  if (n < 1000) return `${n}`
79  if (Math.round(n / 100) < 100) return `${(n / 1000).toFixed(1)}k`
80  const k = Math.round(n / 1000)
81
82  return k < 1000 ? `${k}k` : `${(n / 1_000_000).toFixed(1)}M`
83}
84
85const partsOf = (status: GraphNode['status'], act: Activity | undefined, now: number, withElapsed: boolean): NodeParts => {
86  const start = act?.runStart ?? act?.firstSeen ?? now
87  const end = status === 'running' ? now : (act?.endedAt ?? now)
88  const replied = act?.lastContext === undefined ? undefined : act
89  return {
90    elapsed: withElapsed ? fmtElapsed(end - start) : undefined,
91    steps: act?.steps ? act.steps : undefined,
92    tokens: act?.outputTokens ? fmtTokens(act.outputTokens) : undefined,
93    ctxTokens: replied ? fmtTokens(replied.lastContext ?? 0) : undefined,
94    cacheHit: replied?.lastCacheHit === undefined ? undefined : `${Math.round(replied.lastCacheHit * 100)}%`,
95    cost: replied?.costUsd === undefined ? undefined : fmtCost(replied.costUsd, replied.costPartial === true),
96  }
97}
98
99const metaOf = (p: NodeParts) =>
100  sanitize(
101    [p.elapsed ?? '', p.steps ? `${p.steps} ${p.steps === 1 ? 'step' : 'steps'}` : '', p.tokens ? `${p.tokens} tok` : '']
102      .filter(Boolean)
103      .join(' · '),
104  )
105
106export const toneOf = (pct: number): Tone => (pct >= 80 ? 'hot' : pct >= 50 ? 'warn' : 'ok')
107
108const fmtCost = (usd: number, isPartial: boolean) =>
109  `${usd > 0 && usd < 0.01 ? '<$0.01' : `~$${usd.toFixed(2)}`}${isPartial ? '+' : ''}`
110
111const metricsOf = (act: Activity | undefined, p: NodeParts) => {
112  if (p.ctxTokens === undefined) return { ctx: 'waiting for first reply', ctxPct: undefined, ctxTone: 'ok' as Tone, extras: [] }
113  const window = act?.lastModel === undefined ? undefined : windowFor(act.lastModel)
114  const ctxPct = window ? Math.min(100, Math.max(0, ((act?.lastContext ?? 0) / window) * 100)) : undefined
115  const ctx = `ctx ${p.ctxTokens}${ctxPct === undefined ? '' : ` · ${Math.round(ctxPct)}%`}`
116  const extras = [p.cacheHit === undefined ? '' : `cache ${p.cacheHit}`, p.cost ?? ''].filter(Boolean)
117  return { ctx: sanitize(ctx), ctxPct, ctxTone: toneOf(ctxPct ?? 0), extras: extras.map(sanitize) }
118}
119
120export const visibleAgents = (agents: AgentRow[], activity: Record<string, Activity>, now: number) => {
121  const byId = new Map(agents.map(a => [a.id, a]))
122  const keep = new Set<string>()
123  for (const a of agents) {
124    const ended = activity[a.id]?.endedAt
125    const isLive = statusOf(a.status) === 'running'
126    const isRecent = ended !== undefined && now - ended < RECENT_MS
127    if (!isLive && !isRecent) continue
128    let cur: AgentRow | undefined = a
129    while (cur && !keep.has(cur.id)) {
130      keep.add(cur.id)
131      cur = cur.parentId ? byId.get(cur.parentId) : undefined
132    }
133  }
134  return agents.filter(a => keep.has(a.id))
135}
136
137export const capAgents = (shown: AgentRow[], activity: Record<string, Activity>, max: number) => {
138  if (shown.length <= max) return { agents: shown, hidden: 0 }
139  const isLive = (a: AgentRow) => statusOf(a.status) === 'running'
140  const endedAt = (a: AgentRow) => activity[a.id]?.endedAt ?? 0
141  const byId = new Map(shown.map(a => [a.id, a]))
142  const keep = new Set<string>()
143  const ranked = [...shown].sort((a, b) => Number(isLive(b)) - Number(isLive(a)) || endedAt(b) - endedAt(a))
144  for (const a of ranked) {
145    const chain: string[] = []
146    let cur: AgentRow | undefined = a
147    while (cur && !keep.has(cur.id) && !chain.includes(cur.id)) {
148      chain.push(cur.id)
149      cur = cur.parentId ? byId.get(cur.parentId) : undefined
150    }
151    if (keep.size + chain.length <= max) for (const id of chain) keep.add(id)
152  }
153
154  return { agents: shown.filter(a => keep.has(a.id)), hidden: shown.length - keep.size }
155}
156
157export const buildGraph = (
158  agents: AgentRow[],
159  activity: Record<string, Activity>,
160  now: number,
161  isMainWorking: boolean,
162): { root: GraphNode; slots: number; depth: number } => {
163  const known = new Set(agents.map(a => a.id))
164  const childrenOf = new Map<string, AgentRow[]>()
165  for (const a of agents) {
166    const parent = a.parentId && known.has(a.parentId) ? a.parentId : MAIN
167    childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), a])
168  }
169
170  let nextSlot = 0
171  let maxDepth = 0
172  const make = (row: AgentRow | null, depth: number): GraphNode => {
173    const id = row?.id ?? MAIN
174    const act = activity[id]
175    maxDepth = Math.max(maxDepth, depth)
176    const kids = childrenOf.get(id) ?? []
177    const slot = nextSlot
178    const children = kids.map(k => make(k, depth + 1))
179    if (children.length === 0) nextSlot += 1
180    const status = row ? statusOf(row.status) : isMainWorking ? 'running' : 'idle'
181    const parts = partsOf(status, act, now, row !== null)
182    return {
183      id,
184      title: row ? sanitize(row.name ?? row.type) : 'Main',
185      status,
186      description: row ? sanitize(row.description) : '',
187      activity: sanitize(act?.tool ?? ''),
188      meta: metaOf(parts),
189      model: modelLabel(act?.lastModel === undefined ? undefined : sanitize(act.lastModel)),
190      parts,
191      ...metricsOf(act, parts),
192      depth,
193      slot,
194      children,
195    }
196  }
197  const root = make(null, 0)
198  return { root, slots: Math.max(1, nextSlot), depth: maxDepth }
199}
200
201export const walk = (n: GraphNode, fn: (n: GraphNode, parent?: GraphNode) => void, parent?: GraphNode) => {
202  fn(n, parent)
203  for (const c of n.children) walk(c, fn, n)
204}
205
206export const truncate = (s: string, max: number) => {
207  const chars = [...s]
208  if (chars.length <= max) return s
209  return max <= 1 ? chars.slice(0, max).join('') : `${chars.slice(0, max - 1).join('')}…`
210}
211
hooks/pricing.ts 68 lines
1import type { Activity } from '../types'
2
3export type Price = { input: number; output: number; cacheRead: number; cacheWrite: number }
4
5export type RequestUsage = {
6  input_tokens: number
7  output_tokens: number
8  cache_read_input_tokens: number
9  cache_creation_input_tokens: number
10  model: string
11}
12
13const FABLE_5: Price = { input: 10, output: 50, cacheRead: 1, cacheWrite: 20 }
14const FABLE: Price = { input: 10, output: 50, cacheRead: 0.25, cacheWrite: 20 }
15const SONNET: Price = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 4 }
16
17const MODELS: Record<string, { price: Price; window: number }> = {
18  'claude-fable-5': { price: FABLE_5, window: 1_000_000 },
19  'claude-fable-5-1': { price: FABLE, window: 1_000_000 },
20  'claude-mythos-5-1': { price: FABLE, window: 1_000_000 },
21  'claude-opus-5': { price: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 10 }, window: 1_000_000 },
22  'claude-opus-5-5': { price: { input: 4, output: 20, cacheRead: 0.2, cacheWrite: 8 }, window: 1_000_000 },
23  'claude-sonnet-5': { price: SONNET, window: 1_000_000 },
24  'claude-sonnet-5-5': { price: SONNET, window: 1_000_000 },
25  'claude-haiku-4-5': { price: { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 2 }, window: 200_000 },
26}
27
28const SUFFIX = /(?:-\d{8})?(?:[@[].*)?$/s
29
30export const baseId = (model: string) => model.replace(SUFFIX, '')
31
32const entryFor = (model: string) => MODELS[baseId(model)]
33
34export const priceFor = (model: string): Price | undefined => entryFor(model)?.price
35
36export const windowFor = (model: string): number | undefined =>
37  model.includes('[1m]') ? 1_000_000 : entryFor(model)?.window
38
39export const requestCost = (u: RequestUsage): number | undefined => {
40  const p = priceFor(u.model)
41  if (!p) return undefined
42  // The engine does not report the cache TTL of a write, so it is priced at the 1-hour rate.
43  return (
44    (u.input_tokens * p.input +
45      u.output_tokens * p.output +
46      u.cache_read_input_tokens * p.cacheRead +
47      u.cache_creation_input_tokens * p.cacheWrite) /
48    1e6
49  )
50}
51
52export const withUsage = (a: Activity, u: RequestUsage): Activity => {
53  const cost = requestCost(u)
54  const prompt = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
55  return {
56    ...a,
57    outputTokens: a.outputTokens + u.output_tokens,
58    input: a.input + u.input_tokens,
59    cacheRead: a.cacheRead + u.cache_read_input_tokens,
60    cacheWrite: a.cacheWrite + u.cache_creation_input_tokens,
61    lastContext: prompt,
62    lastModel: u.model,
63    lastCacheHit: prompt > 0 ? u.cache_read_input_tokens / prompt : undefined,
64    costUsd: cost === undefined ? a.costUsd : (a.costUsd ?? 0) + cost,
65    costPartial: cost === undefined ? true : a.costPartial,
66  }
67}
68
hooks/model.ts 16 lines
1import { baseId } from './pricing'
2
3const FAMILY = /^(?:claude-)?(opus|sonnet|haiku|fable|mythos)-(\d+)(?:-(\d{1,2}))?$/
4
5export const modelLabel = (id: string | undefined): string | undefined => {
6  if (!id) return undefined
7  const stripped = baseId(id)
8  const match = FAMILY.exec(stripped)
9  if (!match) {
10    const rest = stripped.replace(/^claude-/, '')
11    return rest || undefined
12  }
13  const [, family, major, minor] = match
14  return `${family![0]!.toUpperCase()}${family!.slice(1)} ${major}${minor === undefined ? '' : `.${minor}`}`
15}
16
types/index.d.ts 39 lines
1export type AgentRow = {
2  id: string
3  type: string
4  description: string
5  status: string
6  parentId?: string
7  name?: string
8}
9
10export type Activity = {
11  tool?: string
12  steps: number
13  outputTokens: number
14  input: number
15  cacheRead: number
16  cacheWrite: number
17  lastContext?: number
18  lastModel?: string
19  lastCacheHit?: number
20  costUsd?: number
21  costPartial?: boolean
22  firstSeen: number
23  lastSeen: number
24  runStart?: number
25  endedAt?: number
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'agent-graph': {
31      agents: AgentRow[]
32      activity: Record<string, Activity>
33      now: number
34      autoOpened: boolean
35      mainWorking: boolean
36    }
37  }
38}
39