No description.

Personal dotfiles managed with chezmoi.
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
brew install chezmoi
chezmoi init --apply DomBlack/dot-files
This will clone the repo and apply all dotfiles. The install script will automatically run brew bundle to install all packages.
chezmoi apply --init; applies changes from source to home asking new questions if neededchezmoi diff; shows what would changechezmoi update; pulls latest from repo and applieschezmoi edit ~/.config/fish/config.fish; edits a managed filechezmoi edit --apply ~/.gitconfig; edits and immediately applieschezmoi cd; cd into the source directorychezmoi add ~/.newfile; adds a file to be managedchezmoi add --template ~/.somefile; adds as a templatechezmoi forget ~/.oldfile; stops managing a filechezmoi git status; checks git status of sourcechezmoi git add .; stages all changeschezmoi git commit -m "msg"; commits changeschezmoi git push; pushes to remotechezmoi status; shows files that differchezmoi verify; checks if destination matches sourcechezmoi data; shows template datachezmoi doctor; checks for potential problemshooks/register.tsx 1036 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Events, Register, RenderElement, SessionMessage, Timer } from 'claude-code'
3
4import type { Quip, RootInfo, Subagent, SubagentStatus, Usage } from '../types'
5
6const PANE = 'monitor'
7const TITLE = 'Monitor'
8const TICK_MS = 1000
9const COLUMNS = 56
10const ACTIVITY_CHARS = 90
11const NAME_CHARS = 24
12const GONE_AFTER_MS = 5000
13const MIN_TREE_ROWS = 6
14const QUIP_EVERY_MS = 25_000
15const QUIP_EVENT_FLOOR_MS = 5_000
16const QUIP_MODEL = 'haiku'
17const COST_MARKS = [1, 5, 10, 25, 50, 100]
18const LEDGER_EVERY_MS = 4000
19const LEDGER_SNIFF_CHARS = 4000
20const SCROLL_TO_END = 1_000_000
21
22const agents = atom({ plugin: 'monitor', key: 'agents' } as const, [])
23const root = atom({ plugin: 'monitor', key: 'root' } as const, { costUsd: 0, isWorking: false })
24const now = atom({ plugin: 'monitor', key: 'now' } as const, 0)
25const view = atom({ plugin: 'monitor', key: 'view' } as const, null)
26const scroll = atom({ plugin: 'monitor', key: 'scroll' } as const, 0)
27const quip = atom({ plugin: 'monitor', key: 'quip' } as const, null)
28const costMark = atom({ plugin: 'monitor', key: 'costMark' } as const, 0)
29
30const NO_USAGE: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
31
32function normalise(a: Partial<Subagent> & { id: string }): Subagent {
33 return {
34 id: a.id,
35 parentId: a.parentId,
36 description: a.description ?? '',
37 type: a.type ?? 'agent',
38 name: a.name,
39 model: a.model,
40 isTeammate: a.isTeammate,
41 source: a.source,
42 status: a.status ?? 'running',
43 startedAt: a.startedAt ?? 0,
44 endedAt: a.endedAt,
45 toolCalls: a.toolCalls ?? 0,
46 activity: a.activity,
47 usage: a.usage ?? NO_USAGE,
48 costUsd: a.costUsd ?? 0,
49 }
50}
51
52async function readAgents($: EngineInterface): Promise<Subagent[]> {
53 const list = await read($, agents)
54 return list.map(normalise)
55}
56
57const isEnded = (status: SubagentStatus) =>
58 status === 'completed' || status === 'failed' || status === 'killed'
59
60const isLive = (status: SubagentStatus) => !isEnded(status)
61
62const GLYPH: Record<SubagentStatus, string> = {
63 pending: '○',
64 running: '●',
65 waiting: '◐',
66 idle: '◦',
67 completed: '✓',
68 failed: '✗',
69 killed: '■',
70}
71
72const STATUS_COLOR: Record<SubagentStatus, string> = {
73 pending: 'subtle',
74 running: 'success',
75 waiting: 'warning',
76 idle: 'subtle',
77 completed: 'success',
78 failed: 'error',
79 killed: 'warning',
80}
81
82const STATUS_WORD: Record<SubagentStatus, string> = {
83 pending: 'pending',
84 running: 'running',
85 waiting: 'waiting',
86 idle: 'idle',
87 completed: 'done',
88 failed: 'failed',
89 killed: 'killed',
90}
91
92const PRICES: ReadonlyArray<readonly [RegExp, number, number]> = [
93 [/fable-5|mythos-5/, 10, 50],
94 [/opus-5-5/, 4, 20],
95 [/opus-5|opus-4/, 5, 25],
96 [/sonnet-5/, 2, 10],
97 [/sonnet-4/, 3, 15],
98 [/haiku/, 1, 5],
99]
100
101const ALIASES: Record<string, string> = {
102 sonnet: 'sonnet-5-5',
103 opus: 'opus-5-5',
104 haiku: 'haiku-4-5',
105 fable: 'fable-5-1',
106}
107
108function shortModel(model: string | undefined): string {
109 if (!model) return '?'
110 const bare = model
111 .toLowerCase()
112 .replace(/^.*anthropic\./, '')
113 .replace(/^claude-/, '')
114 .replace(/-\d{8}$/, '')
115 .replace(/\[.*\]$/, '')
116 return ALIASES[bare] ?? bare
117}
118
119function costOf(usage: Usage, model: string | undefined): number {
120 const short = shortModel(model)
121 const [, input, output] = PRICES.find(([re]) => re.test(short)) ?? [/./, 4, 20]
122 const perToken = (usdPerMillion: number) => usdPerMillion / 1_000_000
123 return (
124 usage.input * perToken(input) +
125 usage.output * perToken(output) +
126 usage.cacheRead * perToken(input) * 0.1 +
127 usage.cacheWrite * perToken(input) * 1.25
128 )
129}
130
131function tokens(n: number): string {
132 if (n < 1000) return String(n)
133 if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`
134 return `${(n / 1_000_000).toFixed(2)}M`
135}
136
137function usd(amount: number): string {
138 return `$${amount.toFixed(2)}`
139}
140
141function tokenLine(usage: Usage): string {
142 return `${tokens(usage.input + usage.cacheWrite)} in · ${tokens(usage.cacheRead)} cached · ${tokens(usage.output)} out`
143}
144
145function addedUsage(a: Usage, b: Usage): Usage {
146 return {
147 input: a.input + b.input,
148 output: a.output + b.output,
149 cacheRead: a.cacheRead + b.cacheRead,
150 cacheWrite: a.cacheWrite + b.cacheWrite,
151 }
152}
153
154function elapsed(ms: number): string {
155 const total = Math.max(0, Math.floor(ms / 1000))
156 const h = Math.floor(total / 3600)
157 const m = Math.floor((total % 3600) / 60)
158 const s = total % 60
159 const mm = h > 0 ? String(m).padStart(2, '0') : String(m)
160 return `${h > 0 ? `${h}:` : ''}${mm}:${String(s).padStart(2, '0')}`
161}
162
163function clip(text: string, max: number): string {
164 const flat = text.replace(/\s+/g, ' ').trim()
165 return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat
166}
167
168function wrap(text: string, width: number): string[] {
169 const lines: string[] = []
170 let line = ''
171 const words = text.split(/\s+/).filter(Boolean).flatMap(word => {
172 const parts: string[] = []
173 for (let i = 0; i < word.length; i += width) parts.push(word.slice(i, i + width))
174 return parts
175 })
176 for (const word of words) {
177 if (line && line.length + 1 + word.length > width) {
178 lines.push(line)
179 line = word
180 } else {
181 line = line ? `${line} ${word}` : word
182 }
183 }
184 if (line) lines.push(line)
185 return lines
186}
187
188function relPath(path: string): string {
189 return path.split('/').filter(Boolean).slice(-2).join('/')
190}
191
192function describeTool(tool: string, args: Record<string, unknown>): string {
193 const str = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : undefined)
194 const file = str('file_path') ?? str('notebook_path')
195 switch (tool) {
196 case 'Edit':
197 case 'Write':
198 case 'NotebookEdit':
199 return `editing ${file ? relPath(file) : ''}`
200 case 'Read':
201 return `reading ${file ? relPath(file) : ''}`
202 case 'Bash':
203 return `running ${str('command') ?? ''}`
204 case 'Grep':
205 case 'Glob':
206 return `searching ${str('pattern') ?? ''}`
207 case 'WebSearch':
208 return `searching ${str('query') ?? ''}`
209 case 'WebFetch':
210 return `fetching ${str('url') ?? ''}`
211 case 'Agent':
212 return `spawning ${str('description') ?? str('subagent_type') ?? 'an agent'}`
213 case 'Skill':
214 return `using skill ${str('skill') ?? ''}`
215 default:
216 return tool.startsWith('mcp__') ? tool.replace(/^mcp__/, '').replace('__', ' › ') : tool
217 }
218}
219
220function summary(list: readonly Subagent[], total: number): string {
221 const cost = usd(total)
222 if (list.length === 0) return `${cost} · no subagents yet`
223 const count = (status: SubagentStatus) => list.filter(a => a.status === status).length
224 const parts = [
225 [count('running') + count('pending') + count('waiting'), 'running'],
226 [count('idle'), 'idle'],
227 [count('completed'), 'done'],
228 [count('failed'), 'failed'],
229 [count('killed'), 'killed'],
230 ] as const
231 const words = parts
232 .filter(([n]) => n > 0)
233 .map(([n, word]) => `${n} ${word}`)
234 .join(' · ')
235 return `${cost} · ${words}`
236}
237
238async function setActivity($: EngineInterface, agentId: string | undefined, activity: string) {
239 const text = clip(activity, ACTIVITY_CHARS)
240 if (agentId === undefined) {
241 await update($, root, r => ({ ...r, activity: text }))
242 return
243 }
244 await update($, agents, list => list.map(a => (a.id === agentId ? { ...a, activity: text } : a)))
245}
246
247async function addUsage($: EngineInterface, agentId: string | undefined, usage: Usage, model: string) {
248 if (agentId === undefined) {
249 await update($, root, r => ({ ...r, usage: addedUsage(r.usage ?? NO_USAGE, usage) }))
250 return
251 }
252 await update($, agents, list =>
253 list.map(normalise).map(a => {
254 if (a.id !== agentId) return a
255 const priced = a.model ?? model
256 return { ...a, model: priced, usage: addedUsage(a.usage, usage), costUsd: a.costUsd + costOf(usage, priced) }
257 }),
258 )
259}
260
261async function openPane($: EngineInterface) {
262 const panes = await $.ui.panes().catch(() => [])
263 if (panes.some(p => p.id === PANE)) return
264 await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS }).catch(() => undefined)
265}
266
267async function showAgent($: EngineInterface, id: string | null) {
268 await update($, view, () => id)
269 await update($, scroll, () => (id === null ? 0 : SCROLL_TO_END))
270}
271
272const CLIPPY_SYSTEM =
273 "You are Clippy, the Microsoft Office paperclip from 1997, now living in a developer's terminal next to Claude Code. " +
274 'Two people are in the room: the developer, whom you address as "you", and Claude, the AI that does the work. ' +
275 'Editing, reading, running commands, searching, thinking, spawning agents and answering are all things Claude does, never the developer: say "Claude is editing", not "you are editing". ' +
276 "The developer only types prompts, reads, waits and pays the bill. Subagents are Claude's helpers. " +
277 'Reply with exactly one line of at most 120 characters, in two parts. ' +
278 "First an observation starting with 'It looks like', naming the specific thing happening in the session. " +
279 'Then an eager, pointless offer of help that nobody wants, in the spirit of "Would you like help with that?": ' +
280 'offer to write a letter, format it as a table, add a wizard, open a help topic, turn on autocorrect, and so on. Vary the offer every time. ' +
281 'Chirpy, overeager, oblivious to how unhelpful you are. No quotes, no emoji, no preamble. ' +
282 'When the briefing says something just happened, react to that first.'
283
284function contextKey(info: RootInfo, list: readonly Subagent[]): string {
285 const running = list.filter(a => isLive(a.status)).map(a => `${a.id}:${a.activity ?? ''}`)
286 return `${info.isWorking ? 'w' : 'i'}|${info.activity ?? ''}|${running.join(',')}`
287}
288
289function clippyPrompt(info: RootInfo, list: readonly Subagent[], total: number, event: string | undefined, lastPrompt: string): string {
290 const running = list.filter(a => isLive(a.status))
291 const lines = [
292 ...(event ? [`What just happened: ${event}`] : []),
293 info.isWorking
294 ? `Claude (main session) is currently ${info.activity ?? 'working'}.`
295 : 'Claude is idle; the developer is reading or typing the next prompt.',
296 `The developer's last prompt: "${lastPrompt || 'none yet'}".`,
297 `Total spent so far: ${usd(total)}.`,
298 running.length === 0 ? 'No subagents are running right now.' : `Subagents running right now: ${running.length}.`,
299 ]
300 for (const a of running.slice(0, 6)) lines.push(`- ${agentLabel(a)} agent: ${a.description}${a.activity ? ` (it is now ${a.activity})` : ''}`)
301 return lines.join('\n')
302}
303
304let quipInFlight = false
305let pendingEvent: string | undefined
306let lastPrompt = ''
307
308function costMarkFor(total: number): number {
309 if (total >= 100) return Math.floor(total / 100) * 100
310 return COST_MARKS.filter(mark => total >= mark).pop() ?? 0
311}
312
313type SessionFacts = { model?: string; startedAt?: number; costUsd?: number; at: number }
314let sessionFacts: SessionFacts = { at: 0 }
315
316async function refreshSessionFacts($: EngineInterface, t: number) {
317 const model = await $.session.model().catch(() => undefined)
318 const usage = await $.session.usage().catch(() => undefined)
319 sessionFacts = { model: model ?? sessionFacts.model, startedAt: usage?.startedAt ?? sessionFacts.startedAt, costUsd: usage?.cost?.usd ?? sessionFacts.costUsd, at: t }
320}
321
322async function sessionCost($: EngineInterface): Promise<number> {
323 const info: RootInfo = await read($, root)
324 const list = await readAgents($)
325 return (sessionFacts.costUsd ?? info.costUsd) + list.reduce((sum, a) => sum + a.costUsd, 0)
326}
327
328async function refreshQuip($: EngineInterface, t: number) {
329 if (quipInFlight) return
330 const info: RootInfo = await read($, root)
331 const list = await readAgents($)
332 const held: Quip | null = await read($, quip)
333 const key = contextKey(info, list)
334 const since = held ? t - held.at : Number.POSITIVE_INFINITY
335 let event: string | undefined
336 if (pendingEvent !== undefined) {
337 if (since < QUIP_EVENT_FLOOR_MS) return
338 event = pendingEvent
339 pendingEvent = undefined
340 } else if (held && (held.key === key || since < QUIP_EVERY_MS)) {
341 return
342 }
343 quipInFlight = true
344 try {
345 const total = await sessionCost($)
346 const reply = await $.model
347 .complete({ model: QUIP_MODEL, system: CLIPPY_SYSTEM, prompt: clippyPrompt(info, list, total, event, lastPrompt), maxTokens: 60, effort: 'low' })
348 .catch(() => undefined)
349 const text = reply?.isAnswered ? clip(reply.text.split('\n')[0] ?? '', 400).replace(/^["']|["']$/g, '') : ''
350 await update($, quip, q => ({ text: text || q?.text || '', at: t, key }))
351 } finally {
352 quipInFlight = false
353 }
354}
355
356async function noteEvent($: EngineInterface, event: string) {
357 pendingEvent = pendingEvent ? `${pendingEvent} Also: ${event}` : event
358 const t = await $.clock.now()
359 await refreshQuip($, t)
360}
361
362async function noteCost($: EngineInterface) {
363 const total = await sessionCost($)
364 const mark = costMarkFor(total)
365 const held = await read($, costMark)
366 if (mark <= held) return
367 await update($, costMark, () => mark)
368 await noteEvent($, `The session's total spend just passed $${mark} (it is ${usd(total)} now).`)
369}
370
371type Ledger = { usage: Usage; model?: string; costUsd?: number; toolCalls: number; activity?: string; lines: string[]; firstAt?: number; lastAt?: number; isMidTool: boolean }
372
373let ledgerPaths = new Map<string, string>()
374let ledgerLines = new Map<string, string[]>()
375let ledgerAt = 0
376
377function parseLedger(text: string): Ledger {
378 const ledger: Ledger = { usage: NO_USAGE, toolCalls: 0, lines: [], isMidTool: false }
379 for (const line of text.split('\n')) {
380 if (!line.trim()) continue
381 let row: Record<string, unknown>
382 try {
383 row = JSON.parse(line) as Record<string, unknown>
384 } catch {
385 continue
386 }
387 if (row.type === 'cost-state' && typeof row.totalCostUSD === 'number') {
388 ledger.costUsd = row.totalCostUSD
389 continue
390 }
391 if (typeof row.timestamp === 'string') {
392 const at = Date.parse(row.timestamp)
393 if (!Number.isNaN(at)) {
394 ledger.firstAt ??= at
395 ledger.lastAt = at
396 }
397 }
398 const message = row.message as Record<string, unknown> | undefined
399 if (!message) continue
400 const content = message.content
401 const blocks = Array.isArray(content) ? (content as Record<string, unknown>[]) : []
402 if (row.type === 'assistant') {
403 const usage = message.usage as Record<string, unknown> | undefined
404 if (usage) {
405 const n = (key: string) => (typeof usage[key] === 'number' ? (usage[key] as number) : 0)
406 ledger.usage = addedUsage(ledger.usage, {
407 input: n('input_tokens'),
408 output: n('output_tokens'),
409 cacheRead: n('cache_read_input_tokens'),
410 cacheWrite: n('cache_creation_input_tokens'),
411 })
412 if (typeof message.model === 'string') ledger.model = message.model
413 }
414 ledger.isMidTool = blocks.some(block => block.type === 'tool_use')
415 for (const block of blocks) {
416 if (block.type === 'tool_use' && typeof block.name === 'string') {
417 ledger.toolCalls += 1
418 const described = describeTool(block.name, (block.input as Record<string, unknown>) ?? {})
419 ledger.activity = described
420 ledger.lines.push(` » ${described}`)
421 } else if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) {
422 ledger.activity = block.text
423 ledger.lines.push(` ${block.text}`)
424 }
425 }
426 } else if (row.type === 'user') {
427 if (typeof content === 'string') {
428 if (content.trim()) ledger.lines.push(`▸ ${content}`)
429 } else {
430 for (const block of blocks) {
431 if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) ledger.lines.push(`▸ ${block.text}`)
432 }
433 }
434 }
435 }
436 return ledger
437}
438
439function projectDirName(cwd: string): string {
440 return cwd.replace(/[^a-zA-Z0-9]/g, '-')
441}
442
443async function findLedgerFile($: EngineInterface, a: Subagent, team: string, dir: string): Promise<string | undefined> {
444 const entries = await $.fs.list(dir).catch(() => [])
445 const name = a.name ?? ''
446 const nameMark = `"agentName":${JSON.stringify(name)}`
447 const teamMark = `"teamName":${JSON.stringify(team)}`
448 for (const entry of entries) {
449 if (entry.kind !== 'file' || !entry.name.endsWith('.jsonl')) continue
450 if (entry.mtimeMs < a.startedAt - 60_000) continue
451 const path = `${dir}/${entry.name}`
452 const head = await $.fs.read(path).catch(() => '')
453 const sniff = head.slice(0, LEDGER_SNIFF_CHARS)
454 if (sniff.includes(nameMark) && sniff.includes(teamMark)) return path
455 }
456 return undefined
457}
458
459type Sidecar = { agentType?: string; description?: string; parentAgentId?: string }
460
461const sidecars = new Map<string, Sidecar>()
462
463function ledgerFields(entry: Subagent, ledger: Ledger): Subagent {
464 return {
465 ...entry,
466 model: ledger.model ?? entry.model,
467 usage: ledger.usage,
468 costUsd: ledger.costUsd ?? costOf(ledger.usage, ledger.model ?? entry.model),
469 toolCalls: ledger.toolCalls,
470 activity: ledger.activity ? clip(ledger.activity, ACTIVITY_CHARS) : entry.activity,
471 }
472}
473
474async function discoverChildren($: EngineInterface, parent: Subagent, parentPath: string, dir: string, t: number) {
475 const sid = parentPath.split('/').pop()?.replace(/\.jsonl$/, '') ?? ''
476 if (!sid) return
477 const subDir = `${dir}/${sid}/subagents`
478 const entries = await $.fs.list(subDir).catch(() => [])
479 const files = entries.filter(e => e.kind === 'file' && /^agent-.+\.jsonl$/.test(e.name))
480 if (files.length === 0) return
481 const ids = new Set(files.map(f => f.name.replace(/^agent-/, '').replace(/\.jsonl$/, '')))
482 const known = await readAgents($)
483 for (const file of files) {
484 const id = file.name.replace(/^agent-/, '').replace(/\.jsonl$/, '')
485 const path = `${subDir}/${file.name}`
486 const existing = known.find(a => a.id === id)
487 if (existing && isEnded(existing.status) && (existing.endedAt === undefined || t - existing.endedAt > 30_000)) continue
488 let meta = sidecars.get(id)
489 if (!meta) {
490 const raw = await $.fs.read(`${subDir}/agent-${id}.meta.json`).catch(() => '')
491 try {
492 meta = raw ? (JSON.parse(raw) as Sidecar) : {}
493 } catch {
494 meta = {}
495 }
496 sidecars.set(id, meta)
497 }
498 const text = await $.fs.read(path).catch(() => '')
499 if (!text) continue
500 const ledger = parseLedger(text)
501 ledgerLines.set(id, ledger.lines.slice(-200))
502 ledgerPaths.set(id, path)
503 const isDone = !ledger.isMidTool && t - file.mtimeMs > 3000 && ledger.lines.length > 0
504 const parentId = meta.parentAgentId && ids.has(meta.parentAgentId) ? meta.parentAgentId : parent.id
505 const base: Subagent = existing ?? {
506 id,
507 parentId,
508 description: meta.description ?? '',
509 type: meta.agentType ?? 'agent',
510 source: 'ledger',
511 status: 'running',
512 startedAt: ledger.firstAt ?? file.mtimeMs,
513 toolCalls: 0,
514 usage: NO_USAGE,
515 costUsd: 0,
516 }
517 const next: Subagent = {
518 ...ledgerFields(base, ledger),
519 parentId,
520 status: isDone ? 'completed' : base.status === 'completed' ? 'completed' : 'running',
521 endedAt: isDone ? (base.endedAt ?? ledger.lastAt ?? t) : base.endedAt,
522 }
523 await update($, agents, all => {
524 const rest = all.map(normalise).filter(a => a.id !== id)
525 return [...rest, next].slice(-200)
526 })
527 if (!existing) void noteEvent($, `A new subagent started under ${agentLabel(parent)}: ${agentLabel(next)}, task "${next.description}".`).catch(() => undefined)
528 else if (!isEnded(existing.status) && isDone) void noteEvent($, `Subagent ${agentLabel(next)} (${next.description}) finished after ${elapsed((next.endedAt ?? t) - next.startedAt)}.`).catch(() => undefined)
529 }
530}
531
532async function refreshTeammateLedgers($: EngineInterface, t: number) {
533 if (t - ledgerAt < LEDGER_EVERY_MS) return
534 ledgerAt = t
535 const list = await readAgents($)
536 const mates = list.filter(a => a.isTeammate && (isLive(a.status) || (a.endedAt !== undefined && t - a.endedAt < 30_000)))
537 if (mates.length === 0) return
538 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
539 const cwd = await $.session.cwd().catch(() => '')
540 const sessionId = await $.session.id().catch(() => '')
541 if (!home || !cwd || !sessionId) return
542 const dir = `${home}/.claude/projects/${projectDirName(cwd)}`
543 const team = `session-${sessionId.slice(0, 8)}`
544 for (const a of mates) {
545 let path = ledgerPaths.get(a.id)
546 if (!path) {
547 path = await findLedgerFile($, a, team, dir)
548 if (!path) continue
549 ledgerPaths.set(a.id, path)
550 }
551 const text = await $.fs.read(path).catch(() => '')
552 if (!text) continue
553 const ledger = parseLedger(text)
554 ledgerLines.set(a.id, ledger.lines.slice(-200))
555 await update($, agents, all => all.map(normalise).map(entry => (entry.id === a.id ? ledgerFields(entry, ledger) : entry)))
556 await discoverChildren($, a, path, dir, t)
557 }
558}
559
560let ticker: Timer | undefined
561
562async function tick($: EngineInterface) {
563 const t = await $.clock.now()
564 await update($, now, () => t)
565 await refreshSessionFacts($, t)
566 const known = await $.agent.list().catch(() => undefined)
567 if (known) {
568 const byId = new Map(known.map(a => [a.id, a]))
569 await update($, agents, list =>
570 list.map(normalise).map(a => {
571 if (isEnded(a.status) || a.source === 'ledger') return a
572 const live = byId.get(a.id)
573 if (!live) {
574 if (t - a.startedAt <= GONE_AFTER_MS) return a
575 void noteEvent($, `Subagent ${agentLabel(a)} (${a.description}) vanished without finishing; it was probably killed.`).catch(() => undefined)
576 return { ...a, status: 'killed' as const, endedAt: t }
577 }
578 if (live.status === a.status) return a
579 const endedAt = isEnded(live.status) ? t : a.endedAt
580 return { ...a, status: live.status, endedAt, name: live.name ?? a.name }
581 }),
582 )
583 }
584 await refreshTeammateLedgers($, t)
585 await noteCost($)
586 void refreshQuip($, t).catch(() => undefined)
587}
588
589function startTicker($: EngineInterface) {
590 if (ticker) return
591 ticker = $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
592}
593
594type TreeNode = { agent: Subagent; children: TreeNode[] }
595
596function buildTree(list: readonly Subagent[]): TreeNode[] {
597 const nodes = new Map(list.map(a => [a.id, { agent: a, children: [] as TreeNode[] }]))
598 const roots: TreeNode[] = []
599 const newestFirst = list
600 .map((a, i) => ({ a, i }))
601 .sort((x, y) => y.a.startedAt - x.a.startedAt || y.i - x.i)
602 .map(({ a }) => a)
603 for (const a of newestFirst) {
604 const node = nodes.get(a.id)
605 if (!node) continue
606 const parent = a.parentId ? nodes.get(a.parentId) : undefined
607 if (parent) parent.children.push(node)
608 else roots.push(node)
609 }
610 return roots
611}
612
613function agentLabel(a: Subagent): string {
614 return clip(a.name ?? a.type, NAME_CHARS)
615}
616
617function agentCost(a: Subagent): string {
618 return `≈${usd(a.costUsd)}`
619}
620
621function agentElapsed(a: Subagent, t: number): string {
622 const end = a.endedAt ?? Math.max(t, a.startedAt)
623 return elapsed(end - a.startedAt)
624}
625
626function detailLine(a: Subagent, t: number): string {
627 const tools = a.toolCalls > 0 ? ` · ${a.toolCalls} tool${a.toolCalls === 1 ? '' : 's'}` : ''
628 return `${shortModel(a.model)} · ${agentElapsed(a, t)} · ${STATUS_WORD[a.status]} · ${agentCost(a)}${tools}`
629}
630
631function doneLine(a: Subagent, t: number): string {
632 return ` · ${a.description} · ${agentElapsed(a, t)} · ${agentCost(a)}`
633}
634
635function transcriptLines(rows: readonly SessionMessage[], width: number): string[] {
636 const lines: string[] = []
637 for (const row of rows) {
638 if (row.text.trim()) {
639 const mark = row.role === 'user' ? '▸ ' : ' '
640 lines.push(`${mark}${clip(row.text, width - 2)}`)
641 }
642 for (const use of row.toolUses) {
643 lines.push(` » ${clip(describeTool(use.tool, use.input), width - 4)}`)
644 if (use.text) lines.push(` ${clip(use.text, width - 6)}`)
645 }
646 }
647 return lines
648}
649
650const CLIPPY = [
651 ' ╭──╮ ',
652 ' │ │ ',
653 ' │ │ ',
654 ' @ @ ',
655 ' │ │ ',
656 ' ││ │╮',
657 ' ││ ││',
658 ' │╰─╯│',
659 ' ╰───╯',
660]
661const CLIPPY_EYES = 3
662
663function clippySays(info: RootInfo, held: Quip | null): string {
664 if (held?.text) return held.text
665 const activity = info.activity ?? ''
666 if (!info.isWorking) return "It looks like you're writing a prompt. Would you like help with that?"
667 if (activity.startsWith('thinking')) return 'It looks like Claude is thinking. Would you like me to hum while you wait?'
668 if (/^(editing|reading|running|searching|fetching|spawning|using)\b/.test(activity)) {
669 return `It looks like Claude is ${activity}. Would you like help with that?`
670 }
671 return 'It looks like Claude is answering. Would you like help reading it?'
672}
673
674type BubbleRow = { left: string; text: string; right: string }
675
676function bubble(text: string, width: number, maxLines: number): BubbleRow[] {
677 const inner = Math.max(8, width - 4)
678 let lines = wrap(text, inner)
679 if (lines.length > maxLines) {
680 lines = lines.slice(0, Math.max(1, maxLines))
681 const last = lines[lines.length - 1] ?? ''
682 lines[lines.length - 1] = `${last.slice(0, Math.max(0, inner - 1)).trimEnd()}…`
683 }
684 const w = Math.max(...lines.map(l => l.length))
685 const tailAt = Math.min(lines.length - 1, Math.max(0, CLIPPY_EYES - 1))
686 return [
687 { left: ' ╭─', text: '─'.repeat(w), right: '─╮' },
688 ...lines.map((l, i) => ({ left: i === tailAt ? '◀┤ ' : ' │ ', text: l.padEnd(w), right: ' │' })),
689 { left: ' ╰─', text: '─'.repeat(w), right: '─╯' },
690 ]
691}
692
693let topWindow = { content: 0, rows: 0 }
694
695export const register: Register = on => {
696 on('session.start', async ($, e, next) => {
697 await $.command.register({
698 name: 'monitor',
699 description: 'Show the session monitor pane: the subagent tree, costs and Clippy',
700 })
701 startTicker($)
702 return next(e)
703 })
704
705 on('command.run', { command: 'monitor' }, async $ => {
706 await $.ui.open({ id: PANE, title: TITLE, columns: COLUMNS })
707 startTicker($)
708 return { text: 'Monitor pane opened.' }
709 })
710
711 on('agent.spawn', async ($, e, next) => {
712 const spawned = await next(e)
713 const id = spawned.teammateId ?? spawned.agentId
714 if (!id) return spawned
715 const startedAt = await $.clock.now()
716 const entry: Subagent = {
717 id,
718 parentId: e.parentAgentId,
719 description: e.description,
720 type: e.subagentType,
721 name: e.name,
722 model: spawned.model ?? e.model,
723 isTeammate: e.isTeammate === true || spawned.teammateId !== undefined,
724 status: 'running',
725 startedAt,
726 toolCalls: 0,
727 usage: NO_USAGE,
728 costUsd: 0,
729 }
730 await update($, agents, list => [...list.filter(a => a.id !== entry.id), entry].slice(-200))
731 await setActivity($, e.parentAgentId, `spawning ${e.description}`)
732 startTicker($)
733 await openPane($)
734 void noteEvent($, `A new subagent started: ${agentLabel(entry)} on ${shortModel(entry.model)}, task "${entry.description}".`).catch(() => undefined)
735 return spawned
736 })
737
738 on('tool.call', async ($, e, next) => {
739 const args = e as unknown as Record<string, unknown>
740 const activity = describeTool(String(e.tool), args)
741 const agentId = e.agentId
742 if (agentId) {
743 await update($, agents, list =>
744 list.map(a => (a.id === agentId ? { ...a, toolCalls: a.toolCalls + 1 } : a)),
745 )
746 }
747 await setActivity($, agentId, activity)
748 return next(e)
749 })
750
751 on('turn.start', async ($, e, next) => {
752 await update($, root, r => ({ ...r, isWorking: true, activity: 'thinking' }))
753 if (e.text.trim()) {
754 lastPrompt = clip(e.text, 200)
755 void noteEvent($, `The developer just sent a prompt: "${lastPrompt}"`).catch(() => undefined)
756 }
757 return next(e)
758 })
759
760 on('turn.step', async function* ($, e, next) {
761 const stream = next(e)
762 let thinking = ''
763 let text = ''
764 let shownAt = 0
765 let block = -1
766 let isFlushed = false
767 const flush = async () => {
768 isFlushed = true
769 if (text.trim()) await setActivity($, e.agentId, text.slice(-ACTIVITY_CHARS))
770 else if (thinking.trim() && shownAt === 0) await setActivity($, e.agentId, `thinking: ${thinking}`)
771 }
772 for await (const chunk of stream) {
773 if (chunk.kind === 'thinking') {
774 if (chunk.index !== block) {
775 block = chunk.index
776 thinking = ''
777 shownAt = 0
778 }
779 thinking += chunk.text
780 if (thinking.length >= 40 && thinking.length - shownAt >= 160) {
781 shownAt = thinking.length
782 await setActivity($, e.agentId, `thinking: ${thinking}`)
783 }
784 } else if (chunk.kind === 'text') {
785 text += chunk.text
786 if (text.length - shownAt >= 160) {
787 shownAt = text.length
788 await setActivity($, e.agentId, `…${text.slice(-ACTIVITY_CHARS)}`)
789 }
790 } else if (chunk.kind === 'stop') {
791 await flush()
792 if (chunk.usage) {
793 await addUsage(
794 $,
795 e.agentId,
796 {
797 input: chunk.usage.input_tokens,
798 output: chunk.usage.output_tokens,
799 cacheRead: chunk.usage.cache_read_input_tokens,
800 cacheWrite: chunk.usage.cache_creation_input_tokens,
801 },
802 chunk.usage.model,
803 )
804 }
805 }
806 yield chunk
807 }
808 if (!isFlushed) await flush()
809 return await stream.result
810 })
811
812 on('turn.complete', async ($, e, next) => {
813 const agentId = e.agentId
814 const endedAt = await $.clock.now()
815 const answer = e.answer ? clip(e.answer, ACTIVITY_CHARS) : undefined
816 if (agentId === undefined) {
817 await update($, root, r => ({ ...r, isWorking: false, activity: answer ?? r.activity }))
818 return next(e)
819 }
820 const status: SubagentStatus =
821 e.reason === 'answer' ? 'completed' : e.reason === 'aborted' ? 'killed' : 'failed'
822 const ended = (await readAgents($)).find(a => a.id === agentId && isLive(a.status))
823 await update($, agents, list =>
824 list.map(a =>
825 a.id === agentId && isLive(a.status)
826 ? { ...a, status, endedAt, activity: answer ?? a.activity }
827 : a,
828 ),
829 )
830 if (ended) {
831 const how = status === 'completed' ? 'finished' : status === 'killed' ? 'was killed' : 'failed'
832 void noteEvent($, `Subagent ${agentLabel(ended)} (${ended.description}) ${how} after ${elapsed(endedAt - ended.startedAt)}${answer ? `, saying: ${clip(answer, 120)}` : ''}.`).catch(() => undefined)
833 }
834 return next(e)
835 })
836
837 on('ui.scroll', { component: 'Pane', requestId: PANE }, async ($, e) => {
838 const inClippy = e.pointer !== undefined && topWindow.rows > 0 && e.pointer.row >= topWindow.rows
839 if (inClippy) return {}
840 const max = Math.max(0, topWindow.content - Math.max(1, topWindow.rows - 2))
841 await update($, scroll, offset => Math.min(max, Math.max(0, (offset ?? 0) + e.by)))
842 return {}
843 })
844
845 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
846 const { Box, Text } = $.ui.resolve(e)
847 try {
848 return await drawPane($, e)
849 } catch (error) {
850 return (
851 <Box flexDirection="column">
852 <Text color="error">monitor: {error instanceof Error ? error.message : String(error)}</Text>
853 </Box>
854 )
855 }
856 })
857}
858
859type PaneRender = Parameters<Events['ui.render']>[1] & { component: 'Pane'; surface: 'terminal' | 'desktop' | 'vscode' | 'mobile' }
860
861async function drawPane($: EngineInterface, e: PaneRender): Promise<RenderElement> {
862 const { Box, Text, Button } = $.ui.resolve(e)
863 const list = await readAgents($)
864 const info: RootInfo = await read($, root)
865 const t = await read($, now)
866 const viewing = await read($, view)
867 const offset = await read($, scroll)
868 const held: Quip | null = await read($, quip)
869 const width = Math.max(20, e.props.bodyColumns)
870 const bodyRows = Math.max(4, e.props.scroll.bodyRows)
871 if (sessionFacts.at === 0 || t - sessionFacts.at >= TICK_MS) await refreshSessionFacts($, t)
872 const rootCost = sessionFacts.costUsd ?? info.costUsd
873 const total = rootCost + list.reduce((sum, a) => sum + a.costUsd, 0)
874 const rootStart = sessionFacts.startedAt ?? info.startedAt ?? t
875
876 const blank = () => <Text> </Text>
877 const line = (key: string | undefined, text: string, dim: boolean) => (
878 <Box key={key}>
879 <Text dimColor={dim} wrap="truncate-end">
880 {text}
881 </Text>
882 </Box>
883 )
884
885 const rows: RenderElement[] = [
886 <Box key="summary">
887 <Text bold wrap="truncate-end">
888 {summary(list, total)}
889 </Text>
890 </Box>,
891 blank(),
892 ]
893
894 const clippyRows = CLIPPY.length + 1
895 const hasClippy = bodyRows >= clippyRows + MIN_TREE_ROWS
896 const topRows = hasClippy ? bodyRows - clippyRows : bodyRows
897
898 const focused = viewing ? list.find(a => a.id === viewing) : undefined
899 if (focused) {
900 let lines: string[] | undefined
901 if (!focused.isTeammate) {
902 const got = await $.session.messages({ agentId: focused.id }).catch(() => undefined)
903 if (Array.isArray(got)) lines = transcriptLines(got, width)
904 }
905 if (!lines || lines.length === 0) lines = (ledgerLines.get(focused.id) ?? []).map(l => clip(l, width))
906 rows.push(
907 <Box>
908 <Button key="back" plain onPress={() => void showAgent($, null)}>
909 ‹ all agents
910 </Button>
911 </Box>,
912 blank(),
913 <Box key={`node-${focused.id}`} flexDirection="row">
914 <Text color={STATUS_COLOR[focused.status]}>{GLYPH[focused.status]} </Text>
915 <Text bold>{agentLabel(focused)}</Text>
916 <Box flexGrow={1} flexShrink={1}>
917 <Text wrap="truncate-end">
918 {' · '}
919 {focused.description}
920 </Text>
921 </Box>
922 </Box>,
923 line(`detail-${focused.id}`, detailLine(focused, t), true),
924 line(`usage-${focused.id}`, `${agentCost(focused)} · ${tokenLine(focused.usage)}${focused.isTeammate ? ' · teammate, read from its transcript' : ''}`, true),
925 blank(),
926 )
927 if (lines.length === 0) rows.push(line('transcript', 'No transcript yet.', true))
928 lines.forEach((text, i) => rows.push(line(i === 0 ? 'transcript' : undefined, text, text.startsWith(' '))))
929 } else {
930 rows.push(
931 <Box key="node-main" flexDirection="row">
932 <Text color={info.isWorking ? 'success' : 'subtle'}>{info.isWorking ? '● ' : '○ '}</Text>
933 <Text color="claude" bold>
934 main session
935 </Text>
936 </Box>,
937 line('detail-main', `${shortModel(sessionFacts.model)} · ${elapsed(Math.max(0, t - rootStart))} · ${info.isWorking ? 'running' : 'idle'} · ${usd(rootCost)}`, true),
938 line('usage-main', tokenLine(info.usage ?? NO_USAGE), true),
939 line('activity-main', info.activity ?? '', false),
940 )
941 const walk = (nodes: TreeNode[], prefix: string) => {
942 nodes.forEach((node, i) => {
943 const a = node.agent
944 const last = i === nodes.length - 1
945 const branch = `${prefix}${last ? '└─ ' : '├─ '}`
946 const cont = `${prefix}${last ? ' ' : '│ '}`
947 const ended = isEnded(a.status)
948 rows.push(
949 <Box key={`node-${a.id}`} flexDirection="row">
950 <Box key={`indent-${a.id}`}>
951 <Text dimColor>{branch}</Text>
952 </Box>
953 <Text color={ended ? undefined : STATUS_COLOR[a.status]} dimColor={ended}>
954 {GLYPH[a.status]}{' '}
955 </Text>
956 <Button key={`press-${a.id}`} plain dimColor={ended} onPress={() => void showAgent($, a.id)}>
957 {agentLabel(a)}
958 </Button>
959 <Box flexGrow={1} flexShrink={1}>
960 <Text dimColor={ended} wrap="truncate-end">
961 {ended ? doneLine(a, t) : ` › · ${a.description}`}
962 </Text>
963 </Box>
964 </Box>,
965 )
966 if (!ended) {
967 rows.push(line(`detail-${a.id}`, `${cont}${detailLine(a, t)}`, true))
968 rows.push(line(`activity-${a.id}`, `${cont}${a.activity ?? ''}`, false))
969 }
970 walk(node.children, cont)
971 })
972 }
973 walk(buildTree(list), '')
974 }
975
976 const maxOffset = Math.max(0, rows.length - Math.max(1, topRows - 2))
977 const at = Math.min(maxOffset, Math.max(0, offset))
978 const aboveHint = at > 0
979 let avail = topRows - (aboveHint ? 1 : 0)
980 let below = rows.length - at - avail
981 if (below > 0) {
982 avail -= 1
983 below = rows.length - at - avail
984 }
985 topWindow = { content: rows.length, rows: topRows }
986 const visible: RenderElement[] = [
987 ...(aboveHint ? [line('more-above', `▲ ${at} more`, true)] : []),
988 ...rows.slice(at, at + Math.max(0, avail)),
989 ...(below > 0 ? [line('more-below', `▼ ${below} more`, true)] : []),
990 ]
991
992 const says = bubble(clippySays(info, held), width - (CLIPPY[0]?.length ?? 8) - 5, clippyRows - 3)
993 return (
994 <Box flexDirection="column">
995 <Box flexDirection="column" height={topRows} overflow="hidden">
996 {visible}
997 </Box>
998 {hasClippy && (
999 <Box key="clippy" flexDirection="column" height={clippyRows} overflow="hidden">
1000 <Text dimColor wrap="truncate-end">
1001 {'─'.repeat(Math.max(1, width - 1))}
1002 </Text>
1003 <Box flexDirection="row">
1004 <Box flexDirection="column">
1005 {CLIPPY.map((art, i) =>
1006 i === CLIPPY_EYES ? (
1007 <Box flexDirection="row">
1008 <Text color="warning">{art.slice(0, 3)}</Text>
1009 <Text bold>{art.slice(3, 4)}</Text>
1010 <Text color="warning">{art.slice(4, 6)}</Text>
1011 <Text bold>{art.slice(6, 7)}</Text>
1012 <Text color="warning">{art.slice(7)}</Text>
1013 </Box>
1014 ) : (
1015 <Text color="warning">{art}</Text>
1016 ),
1017 )}
1018 </Box>
1019 <Box flexDirection="column">
1020 {says.map((row, i) => (
1021 <Box flexDirection="row">
1022 <Text dimColor>{row.left}</Text>
1023 <Text dimColor={i === 0 || i === says.length - 1} wrap="truncate-end">
1024 {row.text}
1025 </Text>
1026 <Text dimColor>{row.right}</Text>
1027 </Box>
1028 ))}
1029 </Box>
1030 </Box>
1031 </Box>
1032 )}
1033 </Box>
1034 )
1035}
1036types/index.d.ts 63 lines1export type SubagentStatus =
2 | 'pending'
3 | 'running'
4 | 'waiting'
5 | 'idle'
6 | 'completed'
7 | 'failed'
8 | 'killed'
9
10export type Usage = {
11 input: number
12 output: number
13 cacheRead: number
14 cacheWrite: number
15}
16
17export type Subagent = {
18 id: string
19 parentId?: string
20 description: string
21 type: string
22 name?: string
23 model?: string
24 isTeammate?: boolean
25 source?: 'ledger'
26 status: SubagentStatus
27 startedAt: number
28 endedAt?: number
29 toolCalls: number
30 activity?: string
31 usage: Usage
32 costUsd: number
33}
34
35export type Quip = {
36 text: string
37 at: number
38 key: string
39}
40
41export type RootInfo = {
42 model?: string
43 startedAt?: number
44 costUsd: number
45 usage?: Usage
46 activity?: string
47 isWorking: boolean
48}
49
50declare module 'claude-code' {
51 interface PluginState {
52 monitor: {
53 agents: Subagent[]
54 root: RootInfo
55 now: number
56 view: string | null
57 scroll: number
58 quip: Quip | null
59 costMark: number
60 }
61 }
62}
63