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

A Claude Code plugin marketplace of mods: function-hooks plugins that draw bands, panes and other UI inside Claude Code.
| Plugin | What it does | ||
|---|---|---|---|
meter | Band 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-graph | Pane 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-handoff | Once 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. |
statusline | A 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. |
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
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
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.
hooks/register.tsx 280 lines1import { 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}
280hooks/draw-svg.ts 135 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
135hooks/draw-text.tsx 131 lines1import 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}
131hooks/layout.ts 211 lines1import 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}
211hooks/pricing.ts 68 lines1import 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}
68hooks/model.ts 16 lines1import { 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}
16types/index.d.ts 39 lines1export 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