SLOPSHOPPER

monitor

No description.

newpaneguardcommandmodeltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · monitor
│ ┃ Monitor ✕ › fix the failing auth test and add an audit log call │ ┃ $0.42 · no subagents yet │ ┃ ⏺ Read(src/auth.ts) │ ┃ ○ main session ⎿ Read 6 lines │ ┃ opus-5-5 · 0:00 · idle · $0.42 ⏺ Update(src/auth.ts) │ ┃ 0 in · 0 cached · 0 out ⎿ Added 2 lines, removed 1 line │ ┃ Done. I made `refresh` reject expired claim… ⏺ 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 │ ┃ │ ┃ › /monitor │ ┃ ⎿ monitor: Monitor pane opened. │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ │ ┃ ───────────────────────────────────────────… │ ┃ ╭──╮ ╭────────────────────────────────── │ ┃ │ │ │ It looks like you're writing a pr │ ┃ │ │ ◀┤ Would you like help with that? │ ┃ @ @ ╰────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Monitor
$0.42 · no subagents yet ○ main session opus-5-5 · 0:00 · idle · $0.42 0 in · 0 cached · 0 out Done. I made `refresh` reject expired claims, added an audi… ─────────────────────────────────────────────────────── ╭──╮ ╭────────────────────────────────────────╮ │ │ │ It looks like you're writing a prompt. │ │ │ ◀┤ Would you like help with that? │ @ @ ╰────────────────────────────────────────╯ │ │ ││ │╮ ││ ││ │╰─╯│ ╰───╯
README

Dom's Dotfiles

Personal dotfiles managed with chezmoi.

Setting Up a New Machine

1. Install Homebrew

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"

2. Install chezmoi

brew install chezmoi

3. Initialize chezmoi with these dotfiles

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 Cheat Sheet

Daily Commands

  • chezmoi apply --init; applies changes from source to home asking new questions if needed
  • chezmoi diff; shows what would change
  • chezmoi update; pulls latest from repo and applies

Editing Files

  • chezmoi edit ~/.config/fish/config.fish; edits a managed file
  • chezmoi edit --apply ~/.gitconfig; edits and immediately applies
  • chezmoi cd; cd into the source directory

Adding New Files

  • chezmoi add ~/.newfile; adds a file to be managed
  • chezmoi add --template ~/.somefile; adds as a template
  • chezmoi forget ~/.oldfile; stops managing a file

Syncing Changes

  • chezmoi git status; checks git status of source
  • chezmoi git add .; stages all changes
  • chezmoi git commit -m "msg"; commits changes
  • chezmoi git push; pushes to remote

Troubleshooting

  • chezmoi status; shows files that differ
  • chezmoi verify; checks if destination matches source
  • chezmoi data; shows template data
  • chezmoi doctor; checks for potential problems

What's Included

  • Shell: Fish shell with Starship prompt
  • Terminal: Ghostty configuration
  • Git: Global gitconfig and gitignore
  • CLI Tools: Modern replacements (eza, bat, fd, ripgrep, fzf, zoxide, etc.)
Source 2 files
hooks/register.tsx 1036 lines
1import { 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}
1036
types/index.d.ts 63 lines
1export 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