SLOPSHOPPER

mod-monitor

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

newpanecommandpromptagents
v0.2.1no licenseupdated 2026-10-09alexatennea/claude-mods/mod-monitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · mod-monitor
│ ┃ Mods ✕ › fix the failing auth test and add an audit log call │ ┃ 🎛️ Mods │ ┃ all quiet… try /plan ⏺ Read(src/auth.ts) │ ┃ 🤖 Agents ⎿ Read 6 lines │ ┃ none yet ⏺ Update(src/auth.ts) │ ┃ 📡 Messages ⎿ Added 2 lines, removed 1 line │ ┃ nothing yet ⏺ 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 │ │ › /mods │ ⎿ mod-monitor: Mods pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Mods
🎛️ Mods all quiet… try /plan 🤖 Agents none yet 📡 Messages nothing yet
README

Claude Code mods

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.

ModWhat 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-agentsAdds a coder subagent and tells the main session to hand each focused coding task to it, one at a time.
model-routerBefore 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-monitorA 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.

Mods pane in the desktop app

Install

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.

How to use

  1. Open your project and start Claude Code there (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.
  2. Watch: /mods opens the Mods pane.
  3. Plan: /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.
  4. Review the plan. Ask for changes in plain words ("split step 3", "skip the email step") if needed.
  5. Build: say "go ahead". The session hands each step to a 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 … ``
  6. Track progress: /plan-done 2 ticks off step 2; /plan-view reopens the Plan pane.
  7. Check spend: /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.

Commands

CommandModDoes
/plan <goal>task-plannerPlan a goal with the Opus planner
/plan-viewtask-plannerOpen the Plan pane
/plan-done Ntask-plannerTick (or untick) step N
/routingmodel-routerTasks per model so far
/modsmod-monitorOpen the Mods pane

Configure

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 haiku
  • routedAgents: comma-separated agent types to route, default coding-agents:coder. Add more coding agents here as you build them.

Develop

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-monitor
Source 3 files
hooks/register.tsx 262 lines
1import { 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}
262
hooks/scene.ts 263 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
263
types/index.d.ts 19 lines
1export 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