A pane showing what each mod is doing, the agents they start and the messages passing between them

Four mods that work together: plan first, then code with each task sent to a model that fits how hard it is, and watch it all happen.
| Mod | What it does |
|---|---|
task-planner | /plan <goal> runs an Opus planner agent (read-only) that returns a checklist of focused tasks, each rated easy/medium/hard. The steps appear in a Plan pane. |
coding-agents | Adds a coder subagent and tells the main session to hand each focused coding task to it, one at a time. |
model-router | Before each coder starts, Haiku rates the task and the coder runs on Haiku (easy), Sonnet (medium) or Opus (hard). Falls back to Sonnet if rating fails; a model the session asks for explicitly wins. |
mod-monitor | A Mods pane: in the desktop app an animated control room (you → session → mods → Haiku/Sonnet/Opus) with messages flying along the wires, busy mods pulsing, running agents linked to their model, and the model mix; in the terminal an emoji/colour version. Hover anything for details. |
They need Claude Code on your own computer (terminal claude, or the Code tab of the desktop app). Cloud sessions in the Claude app don't load plugins.

In a Claude Code session:
/plugin install task-planner --marketplace alexatennea/claude-mods
/plugin install coding-agents --marketplace alexatennea/claude-mods
/plugin install model-router --marketplace alexatennea/claude-mods
/plugin install mod-monitor --marketplace alexatennea/claude-mods
Already added the marketplace before a mod existed? Refresh it first: /plugin marketplace update void-mods. After an update to a mod you have, run /plugin update <mod>.
Answer y to add the marketplace the first time, then pick user scope so they're on in every session. Start a new session (or run /reload-plugins) and they're active.
cd my-project && claude). Use a wide terminal: panes show beside the conversation from about 144 columns; in a narrower one, open them with the commands below./mods opens the Mods pane./plan add password reset to the login page. The planner (Opus) reads the code and returns numbered steps rated easy/medium/hard; the Plan pane opens with them. Nothing is edited yet.coder. For each one the router asks Haiku to rate it, then the coder runs on Haiku, Sonnet or Opus. In the Mods pane you'll see e.g.: `` model-router → haiku: Task: Add reset route … haiku → model-router: medium session → coding-agents:coder (sonnet): Add reset route coding-agents:coder (sonnet) → session: done in 41s: Changed 2 files … ``/plan-done 2 ticks off step 2; /plan-view reopens the Plan pane./routing shows how many tasks went to each model.You don't have to use /plan: asking for any coding work ("fix the failing date test") also goes through coder + router.
| Command | Mod | Does |
|---|---|---|
/plan <goal> | task-planner | Plan a goal with the Opus planner |
/plan-view | task-planner | Open the Plan pane |
/plan-done N | task-planner | Tick (or untick) step N |
/routing | model-router | Tasks per model so far |
/mods | mod-monitor | Open the Mods pane |
model-router options (in /config, or pluginConfigs.model-router.options in settings):
easyModel / mediumModel / hardModel: defaults haiku / sonnet / opus (aliases always mean the latest of each)classifierModel: default haikuroutedAgents: comma-separated agent types to route, default coding-agents:coder. Add more coding agents here as you build them.claude plugin validate model-router
claude plugin test model-router
claude --plugin-dir task-planner --plugin-dir coding-agents --plugin-dir model-router --plugin-dir mod-monitorhooks/register.tsx 262 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { AgentRow, FlowRow, ModRow } from '../types'
5
6import { FAMILY_COLOR, MOD_ICON, familyOf, kTokens, nodeOf, ownerOf, sceneSvg, statsOf } from './scene'
7
8const PANE = 'mod-monitor'
9const ME = 'mod-monitor'
10const MAX_FLOW = 200
11const MAX_AGENTS = 50
12/** CSS pixels: tall enough to read; the drawing keeps its shape and centres in whatever width the pane has. */
13const SVG_HEIGHT = 380
14
15const agents = atom({ plugin: 'mod-monitor', key: 'agents' } as const, [])
16const flow = atom({ plugin: 'mod-monitor', key: 'flow' } as const, [])
17const mods = atom({ plugin: 'mod-monitor', key: 'mods' } as const, [])
18
19export { ownerOf }
20
21export function short(text: string, max = 60): string {
22 const one = text.replace(/\s+/g, ' ').trim()
23
24 return one.length > max ? `${one.slice(0, max - 1)}…` : one
25}
26
27export function clockOf(ms: number): string {
28 const d = new Date(ms)
29 const two = (n: number) => String(n).padStart(2, '0')
30
31 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
32}
33
34async function say($: EngineInterface, from: string, to: string, text: string) {
35 const row: FlowRow = { at: await $.clock.now(), from, to, text: short(text, 80) }
36 await update($, flow, rows => [...rows, row].slice(-MAX_FLOW))
37}
38
39async function doing($: EngineInterface, name: string, what: string) {
40 if (name === 'engine' || name === ME) return
41 const row: ModRow = { name, doing: short(what, 70), at: await $.clock.now() }
42 await update($, mods, rows => [...rows.filter(r => r.name !== name), row].sort((a, b) => a.name.localeCompare(b.name)))
43}
44
45export const register: Register = on => {
46 // Which plugin answers each slash command, learnt as they register.
47 const commandOwner = new Map<string, string>()
48
49 on('session.start', async ($, e, next) => {
50 await $.command.register({ name: 'mods', description: 'Show what each mod is doing and how they talk to each other' })
51 void $.ui.open({ id: PANE, title: 'Mods' })
52
53 return next(e)
54 })
55
56 on('command.run', { command: 'mods' }, async $ => {
57 await $.ui.open({ id: PANE, title: 'Mods' })
58
59 return { text: 'Mods pane opened.' }
60 })
61
62 // Learn command owners, so "/plan" shows as you → task-planner.
63 on('command.register', async ($, e, next) => {
64 const done = await next(e)
65 if (next.origin.plugin !== 'engine') commandOwner.set(e.name, next.origin.plugin)
66
67 return done
68 }).catch(($, e, next) => next(e))
69
70 on('command.run', async ($, e, next) => {
71 const owner = commandOwner.get(e.command)
72 if (owner && owner !== ME) {
73 await say($, 'you', owner, `/${e.command} ${e.args}`)
74 await doing($, owner, `running /${e.command}`)
75 }
76
77 return next(e)
78 }).catch(($, e, next) => next(e))
79
80 // A mod handing the session a prompt to act on.
81 on('prompt.submit', async ($, e, next) => {
82 if (e.origin.kind === 'plugin' && e.origin.name !== ME) {
83 await say($, e.origin.name, 'session', e.text)
84 await doing($, e.origin.name, 'asked the session to act')
85 }
86
87 return next(e)
88 }).catch(($, e, next) => next(e))
89
90 // A mod asking a model a side question (the router's rating, for one).
91 on('model.complete', async ($, e, next) => {
92 const asker = next.origin.plugin
93 const answer = await next(e)
94 if (asker !== 'engine' && asker !== ME) {
95 const reply = answer.deny ?? (answer.value.isAnswered ? answer.value.text : `no answer (${answer.value.reason})`)
96 await say($, asker, e.model, e.prompt)
97 await say($, e.model, asker, reply)
98 await doing($, asker, `asked ${e.model}: "${short(reply, 20)}"`)
99 }
100
101 return answer
102 }).catch(($, e, next) => next(e))
103
104 // What mods put on the status line or in toasts is what they say they're doing.
105 on('ui.status', async ($, e, next) => {
106 if (e.text) await doing($, next.origin.plugin, e.text)
107
108 return next(e)
109 }).catch(($, e, next) => next(e))
110
111 on('ui.toast', async ($, e, next) => {
112 await doing($, next.origin.plugin, e.text)
113
114 return next(e)
115 }).catch(($, e, next) => next(e))
116
117 // Subagents: who started them, on which model, and when they report back.
118 on('agent.spawn', async ($, e, next) => {
119 const started = await next(e)
120 if (started.agentId) {
121 const row: AgentRow = {
122 id: started.agentId,
123 type: e.subagentType,
124 model: started.model,
125 description: e.description,
126 status: 'running',
127 startedAt: await $.clock.now(),
128 }
129 await update($, agents, rows => [...rows, row].slice(-MAX_AGENTS))
130 await say($, e.parentAgentId ? 'subagent' : 'session', `${e.subagentType} (${started.model})`, e.description)
131 if (e.subagentType.includes(':')) await doing($, ownerOf(e.subagentType), `${e.subagentType} working: ${e.description}`)
132 } else if (started.deny) {
133 await say($, 'session', e.subagentType, `refused: ${started.deny}`)
134 }
135
136 return started
137 }).catch(($, e, next) => next(e))
138
139 on('turn.complete', async ($, e, next) => {
140 if (e.agentId) {
141 const id = e.agentId
142 const list = await read($, agents)
143 const row = list.find(r => r.id === id)
144 if (row) {
145 const seconds = Math.round(e.durationMs / 1000)
146 const tokens = e.usage ? e.usage.input_tokens + e.usage.output_tokens : undefined
147 const status = e.isAborted ? 'stopped' : 'done'
148 await update($, agents, rows => rows.map(r => (r.id === id ? { ...r, status, seconds, tokens } : r)))
149 await say($, `${row.type} (${row.model})`, 'session', `${status} in ${seconds}s: ${e.answer}`)
150 if (row.type.includes(':')) await doing($, ownerOf(row.type), `${row.type} ${status} (${seconds}s)`)
151 }
152 }
153
154 return next(e)
155 }).catch(($, e, next) => next(e))
156
157 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
158 const modRows = await read($, mods)
159 const agentRows = await read($, agents)
160 const flowRows = await read($, flow)
161 const now = await $.clock.now()
162 const running = agentRows.filter(a => a.status === 'running')
163 const finished = agentRows.filter(a => a.status !== 'running').slice(-4)
164 const stats = statsOf(agentRows)
165 const rows = e.viewport?.rows ?? 30
166
167 if (e.surface === 'terminal') {
168 const { Box, Text } = $.ui.resolve(e)
169 const room = Math.max(3, rows - 12 - modRows.length - running.length - finished.length)
170
171 return (
172 <Box flexDirection="column">
173 <Text bold>🎛️ Mods</Text>
174 {modRows.length === 0 && <Text dimColor> all quiet… try /plan</Text>}
175 {modRows.map(m => (
176 <Text wrap="truncate-end">
177 {now - m.at < 20_000 ? '⚡' : ' '} {MOD_ICON[m.name] ?? '🧩'} <Text color="claude">{m.name}</Text> <Text dimColor>{m.doing}</Text>
178 </Text>
179 ))}
180
181 <Text bold>🤖 Agents</Text>
182 {agentRows.length === 0 && <Text dimColor> none yet</Text>}
183 {running.map(a => (
184 <Text wrap="truncate-end">
185 {' '}
186 <Text color="warning">◉</Text> <Text color={badgeOf(a.model).color} bold>{badgeOf(a.model).text}</Text> {a.description} <Text dimColor>· {Math.round((now - a.startedAt) / 1000)}s</Text>
187 </Text>
188 ))}
189 {finished.map(a => (
190 <Text wrap="truncate-end">
191 {' '}
192 {a.status === 'done' ? <Text color="success">✔</Text> : <Text color="error">✘</Text>} <Text color={badgeOf(a.model).color} bold>{badgeOf(a.model).text}</Text> <Text dimColor>{a.description} · {a.seconds ?? 0}s{a.tokens !== undefined ? ` · ${kTokens(a.tokens)} tok` : ''}</Text>
193 </Text>
194 ))}
195 {agentRows.length > 0 && (
196 <Text>
197 {' '}
198 {(['haiku', 'sonnet', 'opus', 'other'] as const).map(f => (
199 <Text color={FAMILY_COLOR[f]}>{'█'.repeat(Math.round((24 * stats.byFamily[f]) / agentRows.length))}</Text>
200 ))}{' '}
201 <Text dimColor>💰 {stats.cheapShare}% on cheaper models · {kTokens(stats.tokens)} tok</Text>
202 </Text>
203 )}
204
205 <Text bold>📡 Messages</Text>
206 {flowRows.length === 0 && <Text dimColor> nothing yet</Text>}
207 {flowRows.slice(-room).map(f => (
208 <Text wrap="truncate-end">
209 <Text dimColor>{clockOf(f.at)}</Text> {iconOf(f.from)} → {iconOf(f.to)} <Text dimColor>{f.from} → {f.to}: {f.text}</Text>
210 </Text>
211 ))}
212 </Box>
213 )
214 }
215
216 // Desktop, editor and phone: the animated control room, then the latest messages.
217 const { Box, Text, Svg } = $.ui.resolve(e)
218 const svg = sceneSvg({ now, mods: modRows, agents: agentRows, flow: flowRows })
219
220 return (
221 <Box flexDirection="column" gap={1}>
222 <Svg source={svg} alt={describe(modRows, agentRows)} height={SVG_HEIGHT} isInteractive />
223 <Box flexDirection="column">
224 <Text bold>📡 Messages</Text>
225 {flowRows.length === 0 && <Text dimColor>Nothing yet. Try /plan or ask for some coding work.</Text>}
226 {flowRows.slice(-8).reverse().map(f => (
227 <Text wrap="truncate-end">
228 <Text dimColor>{clockOf(f.at)}</Text> {iconOf(f.from)} <Text bold>{f.from}</Text> → {iconOf(f.to)} <Text bold>{f.to}</Text> <Text dimColor>{f.text}</Text>
229 </Text>
230 ))}
231 </Box>
232 </Box>
233 )
234 })
235}
236
237/** A model as a coloured badge: 🍃 haiku, 🎼 sonnet, 🧠 opus. */
238export function badgeOf(model: string) {
239 const f = familyOf(model)
240
241 return { text: `${iconOf(f)} ${f}`, color: FAMILY_COLOR[f] }
242}
243
244export function iconOf(label: string): string {
245 const node = nodeOf(label)
246 if (node === 'you') return '🧑'
247 if (node === 'session') return '🤖'
248 if (node === 'model:haiku') return '🍃'
249 if (node === 'model:sonnet') return '🎼'
250 if (node === 'model:opus') return '🧠'
251 if (node.startsWith('model:')) return '❔'
252
253 return MOD_ICON[node] ?? '🧩'
254}
255
256function describe(modRows: readonly ModRow[], agentRows: readonly AgentRow[]): string {
257 const running = agentRows.filter(a => a.status === 'running')
258 const mods = modRows.map(m => `${m.name}: ${m.doing}`).join('; ')
259
260 return `Mod activity. ${mods || 'No mod activity yet.'} ${running.length} agents running, ${agentRows.length} started in all.`
261}
262hooks/scene.ts 263 lines1import type { AgentRow, FlowRow, ModRow } from '../types'
2
3/** The model families the mods route between, and their colours. */
4export type Family = 'haiku' | 'sonnet' | 'opus' | 'other'
5
6export const FAMILY_COLOR: Record<Family, string> = {
7 haiku: '#22a06b',
8 sonnet: '#3b82f6',
9 opus: '#a855f7',
10 other: '#94a3b8',
11}
12
13export const MOD_ICON: Record<string, string> = {
14 'task-planner': '🧭',
15 'model-router': '🔀',
16 'coding-agents': '🛠️',
17}
18
19const DEFAULT_MODS = ['task-planner', 'model-router', 'coding-agents']
20const MOD_COLOR = '#d97757'
21const SESSION_COLOR = '#f59e0b'
22const YOU_COLOR = '#64748b'
23
24export function familyOf(model: string): Family {
25 if (/haiku/i.test(model)) return 'haiku'
26 if (/sonnet/i.test(model)) return 'sonnet'
27 if (/opus/i.test(model)) return 'opus'
28
29 return 'other'
30}
31
32/** The plugin that owns a namespaced name (`model-router:coder` → `model-router`), else the name. */
33export function ownerOf(name: string): string {
34 const i = name.indexOf(':')
35
36 return i > 0 ? name.slice(0, i) : name
37}
38
39/** Which node of the diagram a message end names: you, the session, a mod or a model. */
40export function nodeOf(label: string): string {
41 if (label === 'you') return 'you'
42 if (label === 'session' || label === 'subagent') return 'session'
43 const head = label.split(' ')[0] ?? label
44 if (head.includes(':')) return ownerOf(head)
45 if (/^(claude-)?(haiku|sonnet|opus)\b/i.test(head)) return `model:${familyOf(head)}`
46
47 return head
48}
49
50export type Stats = { byFamily: Record<Family, number>; tokens: number; cheapShare: number | undefined }
51
52export function statsOf(agents: readonly AgentRow[]): Stats {
53 const byFamily: Record<Family, number> = { haiku: 0, sonnet: 0, opus: 0, other: 0 }
54 let tokens = 0
55 for (const a of agents) {
56 byFamily[familyOf(a.model)] += 1
57 tokens += a.tokens ?? 0
58 }
59 const total = agents.length
60 const cheap = byFamily.haiku + byFamily.sonnet
61
62 return { byFamily, tokens, cheapShare: total === 0 ? undefined : Math.round((cheap / total) * 100) }
63}
64
65export function kTokens(n: number): string {
66 return n < 1000 ? `${n}` : `${Math.round(n / 100) / 10}k`
67}
68
69function esc(text: string): string {
70 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
71}
72
73type Node = { id: string; x: number; y: number; r: number; color: string; icon: string; label: string; tip: string }
74
75/** The card is dark in both themes, so the colours never wash out on a white frame. */
76const BG = '#15171c'
77const W = 690
78const H = 360
79const GRAPH_H = 290
80const RECENT_MS = 120_000
81const ACTIVE_MS = 20_000
82const MAX_DOTS = 6
83
84/**
85 * The control room: you → session → mods → models, with live messages as
86 * dots flying along the wires and running agents as pulsing, spinning links.
87 * Drawn as an interactive SVG so SMIL animation and <title> tooltips work.
88 */
89export function sceneSvg(input: { now: number; mods: readonly ModRow[]; agents: readonly AgentRow[]; flow: readonly FlowRow[] }): string {
90 const { now, mods, agents, flow } = input
91 const running = agents.filter(a => a.status === 'running')
92 const stats = statsOf(agents)
93
94 const modNames = [...new Set([...DEFAULT_MODS, ...mods.map(m => m.name), ...agents.map(a => ownerOf(a.type))])].filter(
95 n => n !== 'mod-monitor' && !n.includes(' ') && n !== '' && !['general-purpose', 'Explore', 'Plan', 'fork'].includes(n),
96 )
97 const families: Family[] = ['haiku', 'sonnet', 'opus', ...(stats.byFamily.other > 0 ? (['other'] as const) : [])]
98
99 const nodes = new Map<string, Node>()
100 const mid = GRAPH_H / 2
101 nodes.set('you', { id: 'you', x: 52, y: mid, r: 22, color: YOU_COLOR, icon: '🧑', label: 'you', tip: 'You' })
102 nodes.set('session', {
103 id: 'session',
104 x: 190,
105 y: mid,
106 r: 32,
107 color: SESSION_COLOR,
108 icon: '🤖',
109 label: 'session',
110 tip: 'The main Claude Code session',
111 })
112 modNames.forEach((name, i) => {
113 const y = 40 + ((GRAPH_H - 80) * (i + 0.5)) / modNames.length
114 const row = mods.find(m => m.name === name)
115 nodes.set(name, {
116 id: name,
117 x: 370,
118 y,
119 r: 24,
120 color: MOD_COLOR,
121 icon: MOD_ICON[name] ?? '🧩',
122 label: name,
123 tip: row ? `${name}: ${row.doing}` : `${name}: idle`,
124 })
125 })
126 families.forEach((f, i) => {
127 const y = 30 + ((GRAPH_H - 60) * (i + 0.5)) / families.length
128 const count = stats.byFamily[f]
129 nodes.set(`model:${f}`, {
130 id: `model:${f}`,
131 x: 590,
132 y,
133 r: 26,
134 color: FAMILY_COLOR[f],
135 icon: f === 'haiku' ? '🍃' : f === 'sonnet' ? '🎼' : f === 'opus' ? '🧠' : '❔',
136 label: f,
137 tip: `${f}: ${count} task${count === 1 ? '' : 's'}`,
138 })
139 })
140
141 const parts: string[] = []
142 const line = (a: Node, b: Node, extra: string) =>
143 `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" ${extra}/>`
144
145 // Static wires.
146 const you = nodes.get('you')!
147 const session = nodes.get('session')!
148 parts.push(`<g class="wire">`)
149 parts.push(line(you, session, ''))
150 for (const name of modNames) {
151 parts.push(line(session, nodes.get(name)!, ''))
152 if (name === 'model-router' || name === 'coding-agents') {
153 for (const f of families) parts.push(line(nodes.get(name)!, nodes.get(`model:${f}`)!, 'class="faint"'))
154 }
155 }
156 parts.push(`</g>`)
157
158 // Running agents: a marching link from the owning mod to its model.
159 for (const a of running) {
160 const from = nodes.get(ownerOf(a.type)) ?? session
161 const to = nodes.get(`model:${familyOf(a.model)}`)
162 if (!to) continue
163 parts.push(
164 `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}" stroke="${to.color}" stroke-width="3" stroke-dasharray="6 6" stroke-linecap="round">` +
165 `<animate attributeName="stroke-dashoffset" from="24" to="0" dur="0.8s" repeatCount="indefinite"/>` +
166 `<title>${esc(`${a.type} on ${a.model}: ${a.description}`)}</title></line>`,
167 )
168 }
169
170 // Recent messages: dots flying along their wire.
171 const recent = flow.filter(f => now - f.at < RECENT_MS).slice(-MAX_DOTS)
172 recent.forEach((f, i) => {
173 const a = nodes.get(nodeOf(f.from))
174 const b = nodes.get(nodeOf(f.to))
175 if (!a || !b || a === b) return
176 const begin = `${(i * 0.35).toFixed(2)}s`
177 parts.push(
178 `<line x1="${a.x}" y1="${a.y}" x2="${b.x}" y2="${b.y}" stroke="${a.color}" stroke-width="2" opacity="0">` +
179 `<animate attributeName="opacity" values="0;0.7;0" dur="1.6s" begin="${begin}" repeatCount="3"/></line>`,
180 )
181 parts.push(
182 `<circle r="5" fill="${a.color}" opacity="0"><title>${esc(`${f.from} → ${f.to}: ${f.text}`)}</title>` +
183 `<animate attributeName="opacity" values="0;1;1;0" dur="1.6s" begin="${begin}" repeatCount="3"/>` +
184 `<animateMotion path="M${a.x},${a.y} L${b.x},${b.y}" dur="1.6s" begin="${begin}" repeatCount="3"/></circle>`,
185 )
186 })
187
188 // Nodes, with a pulse when busy.
189 for (const n of nodes.values()) {
190 const modRow = mods.find(m => m.name === n.id)
191 const runningHere = running.filter(a => ownerOf(a.type) === n.id || `model:${familyOf(a.model)}` === n.id).length
192 const isBusy = runningHere > 0 || (modRow !== undefined && now - modRow.at < ACTIVE_MS)
193 const isModel = n.id.startsWith('model:')
194 const isIdleModel = isModel && stats.byFamily[n.label as Family] === 0
195 parts.push(`<g${isIdleModel ? ' opacity="0.45"' : ''}><title>${esc(n.tip)}</title>`)
196 if (isBusy) {
197 parts.push(
198 `<circle cx="${n.x}" cy="${n.y}" r="${n.r}" fill="none" stroke="${n.color}" stroke-width="2">` +
199 `<animate attributeName="r" values="${n.r};${n.r + 14}" dur="1.4s" repeatCount="indefinite"/>` +
200 `<animate attributeName="opacity" values="0.8;0" dur="1.4s" repeatCount="indefinite"/></circle>`,
201 )
202 }
203 parts.push(`<circle cx="${n.x}" cy="${n.y}" r="${n.r}" fill="${n.color}" fill-opacity="0.18" stroke="${n.color}" stroke-width="2"/>`)
204 if (isBusy && !isModel && n.id !== 'you') {
205 // A little orbiting spark while working.
206 parts.push(
207 `<circle r="3.5" fill="${n.color}"><animateMotion dur="2s" repeatCount="indefinite" ` +
208 `path="M${n.x + n.r + 6},${n.y} a${n.r + 6},${n.r + 6} 0 1,1 -${2 * (n.r + 6)},0 a${n.r + 6},${n.r + 6} 0 1,1 ${2 * (n.r + 6)},0"/></circle>`,
209 )
210 }
211 parts.push(`<text x="${n.x}" y="${n.y + 7}" text-anchor="middle" font-size="${Math.round(n.r * 0.85)}">${n.icon}</text>`)
212 parts.push(
213 isModel
214 ? `<text class="label" x="${n.x + n.r + 8}" y="${n.y + 5}">${esc(n.label)}</text>`
215 : `<text class="label" x="${n.x}" y="${n.y + n.r + 18}" text-anchor="middle">${esc(n.label)}</text>`,
216 )
217 if (isModel) {
218 const count = stats.byFamily[n.label as Family]
219 if (count > 0) {
220 parts.push(
221 `<circle cx="${n.x + n.r * 0.75}" cy="${n.y - n.r * 0.75}" r="11" fill="${n.color}"/>` +
222 `<text x="${n.x + n.r * 0.75}" y="${n.y - n.r * 0.75 + 4}" text-anchor="middle" font-size="12" font-weight="700" fill="#fff">${count}</text>`,
223 )
224 }
225 }
226 parts.push(`</g>`)
227 }
228
229 // Bottom strip: the model mix and how much ran on cheaper models.
230 const barX = 24
231 const barW = W - 48
232 const barY = GRAPH_H + 14
233 const total = agents.length
234 parts.push(`<rect x="${barX}" y="${barY}" width="${barW}" height="16" rx="8" class="track"/>`)
235 let x = barX
236 for (const f of ['haiku', 'sonnet', 'opus', 'other'] as const) {
237 const n = stats.byFamily[f]
238 if (n === 0 || total === 0) continue
239 const w = (barW * n) / total
240 parts.push(`<rect x="${x}" y="${barY}" width="${w}" height="16" fill="${FAMILY_COLOR[f]}"><title>${f}: ${n}</title></rect>`)
241 if (w > 70) parts.push(`<text x="${x + 8}" y="${barY + 12}" font-size="11" font-weight="700" fill="#fff">${f} ${n}</text>`)
242 x += w
243 }
244 const summary =
245 total === 0
246 ? 'No agents yet. Try /plan or ask for some coding work.'
247 : `${total} task${total === 1 ? '' : 's'} · ${kTokens(stats.tokens)} tokens · 💰 ${stats.cheapShare}% on cheaper models` +
248 (running.length > 0 ? ` · ⚡ ${running.length} running` : '')
249 parts.push(`<text class="summary" x="${barX}" y="${barY + 38}">${esc(summary)}</text>`)
250
251 return (
252 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" ` +
253 `style="background:${BG}" font-family="system-ui, -apple-system, 'Segoe UI', sans-serif">` +
254 `<style>` +
255 `.wire line{stroke:#ffffff38;stroke-width:2}.wire line.faint{stroke:#ffffff22;stroke-dasharray:3 5}` +
256 `.label{font-size:14px;font-weight:600;fill:#e8eaf0}.summary{font-size:14px;fill:#c4c9d4}.track{fill:#ffffff1a}` +
257 `</style>` +
258 `<rect x="0" y="0" width="${W}" height="${H}" fill="${BG}"/>` +
259 parts.join('') +
260 `</svg>`
261 )
262}
263types/index.d.ts 19 lines1export type AgentRow = {
2 id: string
3 type: string
4 model: string
5 description: string
6 status: 'running' | 'done' | 'stopped'
7 startedAt: number
8 seconds?: number
9 tokens?: number
10}
11export type FlowRow = { at: number; from: string; to: string; text: string }
12export type ModRow = { name: string; doing: string; at: number }
13
14declare module 'claude-code' {
15 interface PluginState {
16 'mod-monitor': { agents: AgentRow[]; flow: FlowRow[]; mods: ModRow[] }
17 }
18}
19