子代理監看:prompt 上方顯示子代理摘要列,/agent-monitor 開關面板,列出模型、context、token、估計費用和時間。

一個 Claude Code Mod:子代理在跑時,輸入列上方顯示一條摘要列,列出執行中和完成的數量、估計費用、token 和時間。
| 想做的事 | 做法 |
|---|---|
| 開關子代理面板 | /agent-monitor,或按摘要列的「面板」 |
| 隱藏摘要列 | 按摘要列的 ✕。全部子代理結束 10 秒後,摘要列也會自動收起。下一個子代理開始時,摘要列會再出現 |
| 清掉已完成的子代理 | 在面板按「清除已完成」 |
面板列出每個子代理的模型、context、token、估計費用和時間,以及它最近呼叫的工具,例如 Read register.tsx。桌面 App 用會走路的像素螃蟹顯示,終端機用文字顯示。
費用是用 token 數和公開價格估算的,不是帳單金額。
/plugin marketplace add jaaaackieLai/claude-mods
/plugin install agent-monitor@claude-mods
改寫自 JohnnyVizz/claude-kit 的 savvy-progress。原作移除了搭配 savvy-flow skill 的進度條,只保留子代理監看,並改成和其他 mod 共用輸入列上方的位置。
螃蟹像素圖、面板的 SVG、價格表和大部分工具函式沿用原作。原作和這個 mod 都是 MIT 授權,著作權聲明見 LICENSE。
hooks/register.tsx 479 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AgentRun } from '../types'
5import { agentSvg, BAND_H, bandSvg, colorOf, HEADER_H, headerSvg, ROW_H } from './svg'
6import type { Fmt } from './svg'
7
8const agents = atom({ plugin: 'agent-monitor', key: 'agents' } as const, [])
9const panel = atom({ plugin: 'agent-monitor', key: 'panel' } as const, {
10 isDoneCollapsed: false,
11 isBandHidden: false,
12})
13const now = atom({ plugin: 'agent-monitor', key: 'now' } as const, 0)
14
15const PANE = 'agent-monitor'
16const TITLE = '子代理'
17const ACCENT = '#8f8cf4'
18const STATUS_GLYPH: Record<string, string> = { running: '●', done: '✓', failed: '✗' }
19const STATUS_COLOR: Record<string, string> = { running: ACCENT, done: 'green', failed: 'red' }
20const STATUS_WORD: Record<string, string> = { running: '執行中', done: '完成', failed: '失敗' }
21
22// USD per million tokens: input, output, cache read, cache write (5-minute TTL).
23// The engine reports tokens, not money, so every cost here is an estimate.
24const PRICES: [RegExp, [number, number, number, number]][] = [
25 [/fable|mythos/, [10, 50, 0.25, 12.5]],
26 [/opus-5-5/, [4, 20, 0.2, 5]],
27 [/opus/, [5, 25, 0.5, 6.25]],
28 [/sonnet/, [2, 10, 0.2, 2.5]],
29 [/haiku/, [1, 5, 0.1, 1.25]],
30]
31
32type Usage = {
33 input_tokens: number
34 output_tokens: number
35 cache_read_input_tokens: number
36 cache_creation_input_tokens: number
37}
38
39const priceOf = (model: string): [number, number, number, number] =>
40 PRICES.find(([re]) => re.test(model.toLowerCase()))?.[1] ?? [4, 20, 0.2, 5]
41
42const costOf = (model: string, u: Usage): number => {
43 const [i, o, r, w] = priceOf(model)
44 return (
45 ((u.input_tokens || 0) * i +
46 (u.output_tokens || 0) * o +
47 (u.cache_read_input_tokens || 0) * r +
48 (u.cache_creation_input_tokens || 0) * w) /
49 1e6
50 )
51}
52
53// Tokens the step added: what it sent fresh, wrote to the cache and wrote back.
54// Cache reads are the same prefix read again, so counting them each step inflates totals.
55const newTokensOf = (u: Usage): number =>
56 (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0) + (u.output_tokens || 0)
57
58// How full the context was: the whole request the model read, cached or not.
59const contextOf = (u: Usage): number =>
60 (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0)
61
62const windowOf = (model: string): number => (/haiku/i.test(model) ? 200_000 : 1_000_000)
63
64const FAMILIES = new Set(['fable', 'mythos', 'opus', 'sonnet', 'haiku'])
65
66// "claude-opus-5-5[1m]" -> "Opus 5.5"; "claude-haiku-4-5-20251001" -> "Haiku 4.5".
67const modelName = (id: string): string => {
68 const bare = id.replace(/\[.*\]$/, '')
69 const parts = bare.toLowerCase().split('-')
70 const at = parts.findIndex(part => FAMILIES.has(part))
71 const major = parts[at + 1] ?? ''
72 if (at < 0 || !/^\d+$/.test(major)) return bare.replace(/^claude-/, '') || '—'
73 const family = parts[at] ?? ''
74 const minor = parts[at + 2] ?? ''
75 const version = /^\d{1,2}$/.test(minor) ? `${major}.${minor}` : major
76 return `${family[0]?.toUpperCase() ?? ''}${family.slice(1)} ${version}`
77}
78
79const TOKEN_UNITS: [size: number, suffix: string, digits: number][] = [
80 [1e6, 'M', 1],
81 [1e3, 'k', 0],
82]
83
84const fmtTokens = (n: number): string => {
85 const unit = TOKEN_UNITS.find(([size]) => n >= size)
86 return unit ? `${(n / unit[0]).toFixed(unit[2])}${unit[1]}` : String(Math.round(n))
87}
88
89const fmtCost = (usd: number): string => '$' + usd.toFixed(usd < 10 ? 2 : 1)
90
91const pad2 = (n: number): string => String(n).padStart(2, '0')
92
93// m:ss below an hour, h:mm:ss from there.
94const fmtTime = (ms: number): string => {
95 const secs = Math.max(0, Math.round(ms / 1000))
96 const hours = Math.floor(secs / 3600)
97 const mins = Math.floor(secs / 60) % 60
98 const rest = pad2(secs % 60)
99 return hours > 0 ? `${hours}:${pad2(mins)}:${rest}` : `${mins}:${rest}`
100}
101
102const FMT: Fmt = { cost: fmtCost, tokens: fmtTokens, time: fmtTime, model: id => modelName(id) }
103
104const elapsed = (a: AgentRun, at: number): number => (a.endedAt ?? Math.max(at, a.startedAt)) - a.startedAt
105
106const ctxOf = (a: AgentRun): number => (a.contextMax ? Math.min(100, Math.round((a.contextTokens / a.contextMax) * 100)) : 0)
107
108const bar = (pct: number, width: number): string => {
109 const filled = Math.round((width * pct) / 100)
110 return '█'.repeat(filled) + '░'.repeat(Math.max(0, width - filled))
111}
112
113const totals = (list: AgentRun[], at: number) => {
114 const cost = list.reduce((s, a) => s + a.costUsd, 0)
115 const tokens = list.reduce((s, a) => s + a.tokens, 0)
116 const start = Math.min(...list.map(a => a.startedAt))
117 const end = Math.max(...list.map(a => a.endedAt ?? Math.max(at, a.startedAt)))
118 const count = (s: AgentRun['status']) => list.filter(a => a.status === s).length
119 return { cost, tokens, time: list.length ? end - start : 0, running: count('running'), done: count('done'), failed: count('failed') }
120}
121
122// The band shows totals only, so a 5-second step is enough and spares the whole
123// AbovePrompt chain (other mods' bands too) a redraw every second. The open pane
124// shows each subagent's own m:ss, so it keeps the 1-second step while it is shown.
125const PANE_TICK_MS = 1_000
126const BAND_TICK_MS = 5_000
127// The band folds away by itself this long after the last subagent finished.
128const COLLAPSE_MS = 10_000
129
130const isRunning = (a: AgentRun): boolean => a.status === 'running'
131
132const isPaneShown = async ($: EngineInterface): Promise<boolean> => {
133 try {
134 return (await $.ui.panes()).some(p => p.id === PANE && p.isShown)
135 } catch {
136 return false
137 }
138}
139
140// One ticker at most; it stops by itself once no subagent runs.
141let ticker: Timer | undefined
142
143async function tick($: EngineInterface): Promise<void> {
144 if (!(await read($, agents)).some(isRunning)) {
145 ticker?.cancel()
146 ticker = undefined
147 return
148 }
149 const at = await $.clock.now()
150 if (!(await isPaneShown($))) {
151 if ((await read($, panel)).isBandHidden) return
152 if (at - (await read($, now)) < BAND_TICK_MS) return
153 }
154 await update($, now, () => at)
155}
156
157function startTicker($: EngineInterface): void {
158 ticker ??= $.clock.every(PANE_TICK_MS, () => void tick($))
159}
160
161// A short label for one tool call: the tool, plus its file, pattern or command when it has one.
162const ACTIVITY_MAX = 40
163
164const clip = (text: string, max: number): string => (text.length > max ? `${text.slice(0, max - 1)}…` : text)
165
166const activityOf = (e: { tool: string } & Record<string, unknown>): string => {
167 const arg = (key: string): string => (typeof e[key] === 'string' ? (e[key] as string) : '')
168 const base = (path: string): string => path.split(/[\\/]/).pop() ?? path
169 let detail = ''
170 if (['Read', 'Edit', 'Write', 'NotebookEdit'].includes(e.tool)) detail = base(arg('file_path') || arg('notebook_path'))
171 else if (e.tool === 'Grep' || e.tool === 'Glob') detail = arg('pattern') && `'${arg('pattern')}'`
172 else if (e.tool === 'Bash') detail = arg('command').replace(/\s+/g, ' ').trim().slice(0, 30)
173 return clip(detail ? `${e.tool} ${detail}` : e.tool, ACTIVITY_MAX)
174}
175
176// Opens the pane, or closes it when it is up; true when it ends up open.
177async function togglePane($: EngineInterface): Promise<boolean> {
178 const isOpen = (await $.ui.panes()).some(p => p.id === PANE)
179 if (isOpen) {
180 await $.ui.close({ id: PANE })
181 return false
182 }
183 const at = await $.clock.now()
184 await update($, now, () => at)
185 await $.ui.open({ id: PANE, title: TITLE })
186 return true
187}
188
189export const register: Register = on => {
190 on('session.start', async ($, e, next) => {
191 const started = await next(e)
192 await $.command.register({
193 name: 'agent-monitor',
194 description: '開關子代理面板:執行中和已完成的子代理,含模型、context、token、估計費用和時間',
195 })
196
197 // A reload drops the old ticker; pick the clocks up again for subagents still running.
198 if ((await read($, agents)).some(isRunning)) startTicker($)
199 return started
200 })
201
202 // Notes what each subagent did last; the call itself goes on untouched.
203 on('tool.call', async ($, e, next) => {
204 const agentId = e.agentId
205 if (agentId) {
206 const activity = activityOf(e as { tool: string } & Record<string, unknown>)
207 const list = await read($, agents)
208 if (list.some(a => a.agentId === agentId && a.activity !== activity)) {
209 await update($, agents, prev => prev.map(a => (a.agentId === agentId ? { ...a, activity } : a)))
210 }
211 }
212 return next(e)
213 })
214
215 on('command.run', { command: 'agent-monitor' }, async $ => {
216 const isOpen = await togglePane($)
217 return { text: isOpen ? '子代理面板已開啟。' : '子代理面板已關閉。' }
218 })
219
220 on('agent.spawn', async ($, e, next) => {
221 const started = await next(e)
222 if (started.deny !== undefined) return started
223
224 const at = await $.clock.now()
225 const run: AgentRun = {
226 id: started.agentId ?? e.tool_use_id,
227 agentId: started.agentId,
228 type: e.subagentType,
229 description: e.description,
230 model: started.model,
231 status: 'running',
232 startedAt: at,
233 contextTokens: 0,
234 contextMax: windowOf(started.model),
235 tokens: 0,
236 costUsd: 0,
237 steps: 0,
238 }
239 await update($, agents, list => [...list.filter(a => a.id !== run.id), run].slice(-200))
240 await update($, now, () => at)
241 // A new subagent brings a dismissed band back.
242 await update($, panel, p => ({ ...p, isBandHidden: false }))
243 startTicker($)
244 return started
245 })
246
247 // Each model request of a subagent: live context, tokens and cost.
248 on('turn.step', async function* ($, e, next) {
249 const result = yield* next(e)
250 const agentId = e.agentId
251 const usage = result.usage
252 if (!agentId || !usage) return result
253
254 const model = usage.model || e.model
255 await update($, agents, list =>
256 list.map(a =>
257 a.agentId !== agentId
258 ? a
259 : {
260 ...a,
261 model,
262 contextTokens: contextOf(usage),
263 contextMax: windowOf(model),
264 tokens: a.tokens + newTokensOf(usage),
265 costUsd: a.costUsd + costOf(model, usage),
266 steps: a.steps + 1,
267 },
268 ),
269 )
270 return result
271 })
272
273 on('turn.complete', async ($, e, next) => {
274 const agentId = e.agentId
275 if (agentId) {
276 const at = await $.clock.now()
277 await update($, agents, list =>
278 list.map(a => {
279 if (a.agentId !== agentId) return a
280 // A run whose steps went unseen still gets the turn's own sum.
281 const usage = a.steps === 0 ? e.usage : undefined
282 return {
283 ...a,
284 status: e.reason === 'answer' ? 'done' : 'failed',
285 endedAt: at,
286 ...(usage
287 ? { model: usage.model || a.model, tokens: newTokensOf(usage), costUsd: costOf(usage.model || a.model, usage) }
288 : {}),
289 }
290 }),
291 )
292 await update($, now, () => at)
293 // Redraw once more when the band's collapse time comes, with no ticker running then.
294 if (!(await read($, agents)).some(isRunning)) {
295 $.clock.after(COLLAPSE_MS, () => {
296 void (async () => {
297 const later = await $.clock.now()
298 await update($, now, () => later)
299 })()
300 })
301 }
302 }
303 return next(e)
304 })
305
306 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307 const list = await read($, agents)
308 const p = await read($, panel)
309 if (e.props.hasSurvey || list.length === 0 || p.isBandHidden) return next(e)
310
311 const at = Math.max(await read($, now), ...list.map(a => a.startedAt))
312 const t = totals(list, at)
313 const lastEnd = Math.max(...list.map(a => a.endedAt ?? a.startedAt))
314 if (t.running === 0 && at - lastEnd >= COLLAPSE_MS) return next(e)
315
316 const ui = $.ui.resolve(e)
317 const { Box, Text, Button } = ui
318 const counts = [`${t.running} 執行中`, `${t.done} 完成`, ...(t.failed ? [`${t.failed} 失敗`] : [])].join(' · ')
319 const cost = `≈${fmtCost(t.cost)} · ${fmtTokens(t.tokens)} tokens · ${fmtTime(t.time)}`
320 const buttons = [
321 <Button key="am-open" label="面板" plain onPress={() => void togglePane($)} />,
322 <Button
323 key="am-dismiss"
324 label="✕"
325 plain
326 role="dismiss"
327 onPress={() => update($, panel, prev => ({ ...prev, isBandHidden: true }))}
328 />,
329 ]
330
331 let band
332 if (e.surface === 'desktop' && 'Svg' in ui) {
333 const { Svg } = ui
334 // About 8 CSS px per reported column; the rest is the two buttons and their gaps.
335 const width = Math.max(180, Math.min(1600, (e.props.bodyColumns || 100) * 8 - 96))
336 const summary = `${counts} · ${cost}`
337 band = (
338 <Box flexDirection="row" alignItems="center" gap={1}>
339 <Svg source={bandSvg(width, list, summary)} alt={`${TITLE}:${summary}`} width={width} height={BAND_H} />
340 {buttons}
341 </Box>
342 )
343 } else {
344 band = (
345 <Box flexDirection="row" gap={2}>
346 <Text color={t.running ? ACCENT : 'green'}>
347 {t.running ? '●' : '✓'} {TITLE}
348 </Text>
349 <Text wrap="truncate-end">
350 {counts} <Text dimColor>· {cost}</Text>
351 </Text>
352 {buttons}
353 </Box>
354 )
355 }
356
357 // Share the band: whatever the plugins beneath draw (e.g. typo-picker) goes under this row.
358 const below = await next(e)
359 if (!below) return band
360 return (
361 <Box flexDirection="column">
362 {band}
363 {below}
364 </Box>
365 )
366 })
367
368 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
369 const ui = $.ui.resolve(e)
370 const { Box, Text, Button } = ui
371 const list = await read($, agents)
372 const p = await read($, panel)
373 const at = Math.max(await read($, now), ...list.map(a => a.startedAt), 0)
374 const t = totals(list, at)
375
376 const running = list.filter(a => a.status === 'running').reverse()
377 const finished = list.filter(a => a.status !== 'running').reverse()
378 const summary = `費用 ≈${fmtCost(t.cost)},Tokens ${fmtTokens(t.tokens)},時間 ${fmtTime(t.time)}`
379
380 const empty = list.length === 0 && <Text dimColor>目前沒有子代理。</Text>
381 const runningHead = running.length > 0 && <Text dimColor>執行中 · {running.length}</Text>
382 const finishedHead = finished.length > 0 && (
383 <Box flexDirection="row" gap={2}>
384 <Button
385 key="am-done"
386 label={`${p.isDoneCollapsed ? '▸' : '▾'} 已完成 · ${finished.length}`}
387 plain
388 onPress={() => update($, panel, prev => ({ ...prev, isDoneCollapsed: !prev.isDoneCollapsed }))}
389 />
390 <Button
391 key="am-clear"
392 label="清除已完成"
393 plain
394 onPress={() => update($, agents, prev => prev.filter(a => a.status === 'running'))}
395 />
396 </Box>
397 )
398
399 if (e.surface === 'desktop' && 'Svg' in ui) {
400 const { Svg } = ui
401 const W = Math.max(240, Math.min(900, (e.props.bodyColumns || 40) * 8 - 8))
402 const row = (a: AgentRun) => (
403 <Svg
404 key={a.id}
405 source={agentSvg(W, a, ctxOf(a), elapsed(a, at), FMT)}
406 alt={`${a.description}:${a.type} · ${modelName(a.model)} · ${STATUS_WORD[a.status]}`}
407 width={W}
408 height={ROW_H}
409 />
410 )
411 return (
412 <Box flexDirection="column">
413 <Svg source={headerSvg(W, t, FMT)} alt={summary} width={W} height={HEADER_H} />
414 {empty}
415 {runningHead}
416 {running.map(row)}
417 {finishedHead}
418 {!p.isDoneCollapsed && finished.map(row)}
419 </Box>
420 )
421 }
422
423 // Terminal: the same content in text rows.
424 const barW = Math.max(6, Math.min(24, (e.props.bodyColumns || 40) - 34))
425 const stat = (k: string, v: string) => (
426 <Text key={k}>
427 <Text dimColor>{k} </Text>
428 <Text bold>{v}</Text>
429 </Text>
430 )
431 const row = (a: AgentRun) => {
432 const ctx = ctxOf(a)
433 const color = colorOf(a.model)
434 return (
435 <Box key={a.id} flexDirection="column" marginBottom={1}>
436 <Box flexDirection="row" justifyContent="space-between">
437 <Text bold wrap="truncate-end">
438 {a.description || a.type}
439 </Text>
440 <Text color={STATUS_COLOR[a.status]}>{STATUS_GLYPH[a.status]}</Text>
441 </Box>
442 <Text wrap="truncate-end">
443 <Text color={color}>{a.type}</Text>
444 <Text dimColor> {modelName(a.model)}</Text>
445 </Text>
446 {a.activity && (
447 <Text dimColor wrap="truncate-end">
448 {a.activity}
449 </Text>
450 )}
451 <Text wrap="truncate-end">
452 <Text dimColor>
453 ctx {ctx}% · {fmtTokens(a.tokens)} ≈{fmtCost(a.costUsd)} {fmtTime(elapsed(a, at))}
454 </Text>
455 </Text>
456 <Text color={color}>{bar(a.status === 'done' ? 100 : ctx, barW)}</Text>
457 </Box>
458 )
459 }
460
461 return (
462 <Box flexDirection="column">
463 <Box flexDirection="row" gap={3}>
464 {stat('費用', `≈${fmtCost(t.cost)}`)}
465 {stat('Tokens', fmtTokens(t.tokens))}
466 {stat('時間', fmtTime(t.time))}
467 </Box>
468 <Box flexDirection="column" marginTop={1}>
469 {empty}
470 {runningHead}
471 {running.map(row)}
472 {finishedHead}
473 {!p.isDoneCollapsed && finished.map(row)}
474 </Box>
475 </Box>
476 )
477 })
478}
479hooks/svg.ts 242 lines1// Desktop drawings: the header tiles, one row per subagent, and the band.
2// Pixel crabs, costumes and row layout adapted from savvy-progress
3// (github.com/JohnnyVizz/claude-kit, MIT, © 2026 johnnyvizz), itself after DockCrab's Clawdy.
4
5import type { AgentRun } from '../types'
6
7export type Totals = { cost: number; tokens: number; time: number; running: number; done: number; failed: number }
8
9export type Fmt = {
10 cost: (usd: number) => string
11 tokens: (n: number) => string
12 time: (ms: number) => string
13 model: (id: string) => string
14}
15
16const FONT = "-apple-system,BlinkMacSystemFont,'SF Pro Text','PingFang TC','Noto Sans TC','Segoe UI',sans-serif"
17const CLAY = '#D97757'
18const INK = '#1F1E1D'
19
20// One color per model family: the costume's accent, the type label and the bar.
21const MODEL_COLOR: [RegExp, string][] = [
22 [/fable|mythos/i, '#7F77DD'],
23 [/opus/i, '#D85A30'],
24 [/sonnet/i, '#378ADD'],
25 [/haiku/i, '#1D9E75'],
26]
27
28export const colorOf = (model: string): string => MODEL_COLOR.find(([re]) => re.test(model))?.[1] ?? '#888780'
29
30// One costume per subagent type; a type not listed is a plain crab.
31const COSTUME_OF: Record<string, string> = {
32 Explore: 'pirate',
33 Plan: 'engineer',
34 'general-purpose': 'chef',
35 'claude-code-guide': 'detective',
36 fork: 'astronaut',
37}
38
39export const costumeOf = (type: string): string => COSTUME_OF[type.replace(/^[^:]*:/, '')] ?? 'plain'
40
41const xml = (s: string): string =>
42 s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] ?? c)
43
44// Rough advance of system UI text, in em; CJK is a full em.
45const charEm = (ch: string): number =>
46 /[⺀-鿿가--]/.test(ch)
47 ? 1
48 : /[\s.,:;'|!il1()[\]]/.test(ch)
49 ? 0.3
50 : /[A-Zm@%]/.test(ch)
51 ? 0.72
52 : 0.56
53
54const textWidth = (s: string, size: number): number => [...s].reduce((w, ch) => w + charEm(ch) * size, 0)
55
56// Cuts `s` to fit `maxW` pixels, with an ellipsis when it had to cut.
57const fitText = (s: string, size: number, maxW: number): string => {
58 if (textWidth(s, size) <= maxW) return s
59 let out = ''
60 for (const ch of s) {
61 if (textWidth(out + ch + '…', size) > maxW) break
62 out += ch
63 }
64 return out + '…'
65}
66
67const PANE_CSS = `<style>
68.t{fill:#1f1f1f}.s{fill:#6b6b68}.m{fill:#9a9a96}.k{fill:#ecebe8}.ln{stroke:#e4e4e1}.tile{fill:#f4f3f0}
69@media (prefers-color-scheme: dark){.t{fill:#ececec}.s{fill:#a8a8a4}.m{fill:#7d7d79}.k{fill:#2c2c2b}.ln{stroke:#333331}.tile{fill:#262625}}
70.live{animation:p 1.6s ease-in-out infinite}@keyframes p{50%{opacity:.3}}
71@media (prefers-reduced-motion: reduce){.live{animation:none}}
72</style>`
73
74// `cls` puts a pixel in a named group: `bd` (the default) is the body and its
75// costume, `la`/`lb` the leg pairs, anything else a prop with its own motion.
76type Fill = (x: number, y: number, w: number, h: number, c: string, cls?: string) => void
77
78const stamp = (f: Fill, x: number, y: number, rows: string[], map: Record<string, string>, cls?: string): void =>
79 rows.forEach((row, dy) => [...row].forEach((ch, dx) => map[ch] && f(x + dx, y + dy, 1, 1, map[ch] ?? '', cls)))
80
81// `armCls` lets a raised claw travel with the prop it holds.
82const crabBody = (f: Fill, armFront = 0, armCls?: string): void => {
83 f(7, 10, 16, 12, CLAY)
84 f(3, 14, 4, 4, CLAY)
85 f(23, 14 + armFront, 4, 4, CLAY, armCls)
86 f(9, 12, 2, 2, INK)
87 f(19, 12, 2, 2, INK)
88 f(7, 22, 2, 4, CLAY, 'la')
89 f(17, 22, 2, 4, CLAY, 'la')
90 f(11, 22, 2, 4, CLAY, 'lb')
91 f(21, 22, 2, 4, CLAY, 'lb')
92}
93
94// Pure CSS, run by the compositor. Every running crab walks; each costume adds its prop's motion.
95const CRAB_CSS = `<style>
96.run .la{animation:st .5s steps(1) infinite}.run .lb{animation:st .5s steps(1) infinite -.25s}
97.run .bd{animation:bob .5s steps(1) infinite -.125s}
98.run g{transform-box:fill-box}
99@keyframes st{50%{transform:translateY(-1px)}}@keyframes bob{50%{transform:translateY(1px)}}
100.c-astronaut.run{animation:float 1s ease-in-out infinite}
101.c-astronaut.run .la,.c-astronaut.run .lb,.c-astronaut.run .bd{animation:none}
102.c-astronaut.run .ant{animation:blink 1s steps(1) infinite}
103.c-astronaut.run .star{animation:blink .5s steps(1) infinite -.25s}
104@keyframes float{50%{transform:translateY(-2px)}}@keyframes blink{50%{opacity:.15}}
105.c-detective.run .it{animation:scan 1s steps(1) infinite}
106.c-detective.run .gl{animation:blink 1s steps(1) infinite -.5s}
107@keyframes scan{25%{transform:translate(-1px,1px)}50%{transform:translate(-2px,2px)}75%{transform:translate(-1px,1px)}}
108.c-engineer.run .it{transform-origin:100% 100%;animation:twist .5s ease-in-out infinite}
109@keyframes twist{50%{transform:rotate(-35deg)}}
110.c-chef.run .pan{transform-origin:0 50%;animation:tilt 1s ease-in-out infinite}
111.c-chef.run .egg{animation:flip 1s ease-in-out infinite}
112@keyframes tilt{20%,40%{transform:rotate(-12deg)}}@keyframes flip{30%{transform:translateY(-5px) scaleY(-1)}60%{transform:translateY(0)}}
113.c-pirate.run .it{transform-origin:50% 100%;animation:fence .5s ease-in-out infinite}
114@keyframes fence{50%{transform:rotate(25deg)}}
115@media (prefers-reduced-motion: reduce){.run,.run g{animation:none!important}}
116</style>`
117
118const COSTUMES: Record<string, (f: Fill, t: string) => void> = {
119 // Astronaut in a glass dome; floats instead of walking, the antenna and the star blink.
120 astronaut: (f, t) => {
121 crabBody(f)
122 f(6, 7, 18, 1, '#E6E8EE'); f(5, 8, 1, 14, '#E6E8EE'); f(24, 8, 1, 14, '#E6E8EE'); f(6, 22, 18, 1, '#C9CCD2')
123 f(6, 8, 18, 14, 'rgba(169,214,245,.32)'); f(8, 9, 2, 1, '#fff'); f(8, 10, 1, 2, '#fff')
124 f(14, 4, 2, 3, '#C9CCD2'); f(14, 2, 2, 2, t, 'ant'); f(13, 18, 4, 2, t)
125 f(27, 3, 1, 3, '#F5C542', 'star'); f(26, 4, 3, 1, '#F5C542', 'star')
126 },
127 // Detective with a deerstalker; the magnifier sweeps and glints.
128 detective: (f, t) => {
129 crabBody(f, -4, 'it')
130 stamp(f, 6, 3, ['......bbbbbb......', '....bbcbbcbbbb....', '...bbbbbbbbbbbb...', '..bcbbcbbcbbcbbb..', '.bbbbbbbbbbbbbbbb.', 'dddddddddddddddddd'], { b: '#7A4A26', c: '#A0703F', d: '#5A3519' })
131 f(6, 9, 18, 1, t)
132 stamp(f, 23, 1, ['.kkk.', 'k...k', 'k...k', 'k...k', '.kkk.'], { k: '#3A3A3C' }, 'it')
133 f(24, 2, 3, 3, 'rgba(169,214,245,.7)', 'it'); f(25, 6, 1, 4, '#7A4A26', 'it'); f(24, 2, 1, 1, '#fff', 'gl')
134 },
135 // Engineer in a hard hat; the wrench turns a bolt.
136 engineer: (f, t) => {
137 crabBody(f)
138 stamp(f, 6, 4, ['.....yyyyyyyy.....', '...yyyyyhhyyyyy...', '..yyyyyyhhyyyyyy..', '..yyyyyyhhyyyyyy..', '.yyyyyyyhhyyyyyyy.', 'dddddddddddddddddd'], { y: '#F5C542', h: '#FBE08A', d: '#C99A1E' })
139 f(13, 5, 4, 2, t)
140 stamp(f, 0, 10, ['.s.s', 'sss.', '.s..', '.s..'], { s: '#8E929A' }, 'it')
141 },
142 // Chef in a toque; tosses the omelette.
143 chef: (f, t) => {
144 crabBody(f, -4, 'pan')
145 stamp(f, 6, 0, ['........lll.......', '.......lllll......', '.wwwwgwwwwwwgwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwwwwwwwwwwwwww', 'wwwwwgwwwwwggwwwww', '.wwwwgwwwwwggwwwww', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.', '.dddbbbbbbbbbbbbb.'], { w: '#F4F3EE', l: '#F7F6F2', g: '#D2D1C8', b: t, d: '#B45F43' })
146 f(22, 8, 7, 2, '#4A4A48', 'pan'); f(26, 10, 1, 1, '#4A4A48', 'pan'); f(24, 7, 3, 1, '#F5B731', 'egg')
147 },
148 // Pirate scouting the code; the cutlass fences.
149 pirate: f => {
150 crabBody(f)
151 stamp(f, 5, 3, ['.kk..............kk.', '.kkk....kkkk....kkk.', '..kkkkkkkwwkkkkkkk..', '..kkkkkkkkkkkkkkkk..', '.gggggggggggggggggg.'], { k: '#55514C', w: '#F8F6F1', g: '#F5C542' })
152 f(7, 11, 11, 1, INK); f(18, 11, 4, 3, INK)
153 f(27, 6, 1, 9, '#C9CCD2', 'it'); f(26, 15, 3, 1, '#7A4A26', 'it')
154 },
155 plain: f => crabBody(f),
156}
157
158// Body and props nest inside `bd` so a prop rides the bob and adds its own motion;
159// legs stay outside it and step on their own.
160const crab = (x: number, y: number, costume: string, color: string, isWalking: boolean, scale = 1.1): string => {
161 const groups = new Map<string, string[]>([['bd', []]])
162 const f: Fill = (cx, cy, w, h, c, cls = 'bd') => {
163 if (!groups.has(cls)) groups.set(cls, [])
164 groups.get(cls)?.push(`<rect x="${cx}" y="${cy}" width="${w}" height="${h}" fill="${c}"/>`)
165 }
166 ;(COSTUMES[costume] ?? COSTUMES.plain)?.(f, color)
167 const group = (cls: string) => `<g class="${cls}">${(groups.get(cls) ?? []).join('')}</g>`
168 const props = [...groups.keys()].filter(k => k !== 'bd' && k !== 'la' && k !== 'lb')
169 const body = `<g class="bd">${(groups.get('bd') ?? []).join('')}${props.map(group).join('')}</g>`
170 return `<g transform="translate(${x},${y}) scale(${scale})" shape-rendering="crispEdges"><g class="c-${costume}${isWalking ? ' run' : ''}">${body}${group('la')}${group('lb')}</g></g>`
171}
172
173const statusMark = (x: number, y: number, status: string, color: string): string => {
174 if (status === 'running') return `<circle class="live" cx="${x}" cy="${y}" r="3.5" fill="${color}"/>`
175 if (status === 'done') return `<path d="M${x - 5} ${y}l3.5 3.5 6.5-7" fill="none" stroke="#3B9C5F" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>`
176 return `<path d="M${x - 4} ${y - 4}l8 8M${x + 4} ${y - 4}l-8 8" stroke="#D0453F" stroke-width="1.8" stroke-linecap="round"/>`
177}
178
179const svg = (W: number, H: number, body: string): string =>
180 `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${PANE_CSS}${CRAB_CSS}${body}</svg>`
181
182export const HEADER_H = 44
183export const ROW_H = 66
184export const BAND_H = 32
185
186export const headerSvg = (W: number, t: Totals, fmt: Fmt): string => {
187 const gap = 6
188 const tw = (W - gap * 2) / 3
189 const tile = (i: number, k: string, v: string) =>
190 `<rect class="tile" x="${i * (tw + gap)}" y="0" width="${tw}" height="40" rx="8"/>
191<text class="s" x="${i * (tw + gap) + 9}" y="16" font-family="${FONT}" font-size="11">${k}</text>
192<text class="t" x="${i * (tw + gap) + 9}" y="33" font-family="${FONT}" font-size="15" font-weight="600" font-variant-numeric="tabular-nums">${v}</text>`
193 return svg(W, HEADER_H, `${tile(0, '費用', '≈' + fmt.cost(t.cost))}${tile(1, 'Tokens', fmt.tokens(t.tokens))}${tile(2, '時間', fmt.time(t.time))}`)
194}
195
196export const agentSvg = (W: number, a: AgentRun, ctx: number, elapsedMs: number, fmt: Fmt): string => {
197 const color = colorOf(a.model)
198 const textW = W - 42 - 22
199 const meta = [fmt.model(a.model), ...(a.status === 'failed' ? ['失敗'] : [])].join(' · ')
200 const stats = `ctx ${ctx}% · ${fmt.tokens(a.tokens)} ≈${fmt.cost(a.costUsd)} ${fmt.time(elapsedMs)}`
201 const fillW = Math.round(textW * (a.status === 'done' ? 1 : ctx / 100))
202 // What it did last, on the left of the stats line, in the room the stats leave.
203 const actW = textW - textWidth(stats, 11) - 12
204 const activity =
205 a.activity && actW > 40
206 ? `<text class="s act" x="42" y="49" font-family="${FONT}" font-size="11">${xml(fitText(a.activity, 11, actW))}</text>`
207 : ''
208 return svg(
209 W,
210 ROW_H,
211 `${crab(0, 14, costumeOf(a.type), color, a.status === 'running')}
212<text class="t" x="42" y="18" font-family="${FONT}" font-size="13" font-weight="600">${xml(fitText(a.description || a.type, 13, textW))}</text>
213<text x="42" y="34" font-family="${FONT}" font-size="11"><tspan fill="${color}">${xml(a.type)}</tspan><tspan class="s"> ${xml(meta)}</tspan></text>
214<text class="s" x="${42 + textW}" y="49" text-anchor="end" font-family="${FONT}" font-size="11" font-variant-numeric="tabular-nums">${xml(stats)}</text>${activity}
215<rect class="k" x="42" y="55" width="${textW}" height="4" rx="2"/><rect x="42" y="55" width="${fillW}" height="4" rx="2" fill="${color}"/>
216${statusMark(W - 8, 16, a.status, color)}
217<line class="ln" x1="0" y1="65.5" x2="${W}" y2="65.5"/>`,
218 )
219}
220
221// The band: one crab per subagent, running ones first, then the summary on the right.
222export const bandSvg = (W: number, list: AgentRun[], summary: string): string => {
223 const ordered = [...list.filter(a => a.status === 'running'), ...list.filter(a => a.status !== 'running')]
224 const room = Math.max(1, Math.floor((W - textWidth(summary, 12) - 24) / 36))
225 const shown = ordered.slice(-room)
226 const more = ordered.length - shown.length
227 const crabs = shown
228 .map((a, i) => {
229 const color = colorOf(a.model)
230 const live = a.status === 'running' ? `<circle class="live" cx="${i * 36 + 32}" cy="4" r="3" fill="${color}"/>` : ''
231 return `<g opacity="${a.status === 'running' ? 1 : 0.55}">${crab(i * 36, 0, costumeOf(a.type), color, a.status === 'running')}</g>${live}`
232 })
233 .join('')
234 const x = shown.length * 36 + 4
235 return svg(
236 W,
237 BAND_H,
238 `${crabs}${more ? `<text class="s" x="${x}" y="21" font-family="${FONT}" font-size="12">+${more}</text>` : ''}
239<text class="s" x="${W}" y="21" text-anchor="end" font-family="${FONT}" font-size="12" font-variant-numeric="tabular-nums">${xml(summary)}</text>`,
240 )
241}
242types/index.d.ts 35 lines1export type AgentStatus = 'running' | 'done' | 'failed'
2
3export type AgentRun = {
4 id: string
5 agentId?: string
6 type: string
7 description: string
8 model: string
9 status: AgentStatus
10 startedAt: number
11 endedAt?: number
12 contextTokens: number
13 contextMax: number
14 tokens: number
15 costUsd: number
16 steps: number
17 /** What the subagent did last, e.g. "Read register.tsx"; absent before its first tool call. */
18 activity?: string
19}
20
21export type Panel = {
22 isDoneCollapsed: boolean
23 isBandHidden: boolean
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'agent-monitor': {
29 agents: AgentRun[]
30 panel: Panel
31 now: number
32 }
33 }
34}
35