Live pane: what every loop is doing now, the agent tree with who called whom, skills loaded, files changed with +/- and share, time by phase, context, tokens…

A Claude Code mod: a live pane that shows what the session is doing right now and what it costs — the active model and effort, every subagent and its orchestration tree, where the time goes, the context window fill, token usage per model, rate limits, cost, tool statistics and a log of recent events. Numbers plus horizontal bars. Every chat's figures are also written to disk, so you can summarise what a task took and export the summary as a PDF report.
From a terminal session of Claude Code (the repository is https://github.com/osinelnikov/activity-dashboard):
/plugin install activity-dashboard --marketplace osinelnikov/activity-dashboard
Answer y to add the marketplace, then pick the user scope.
From a local folder (no repository needed):
claude plugin marketplace add ~/.claude/mods/activity-dashboard
claude plugin install activity-dashboard@activity-dashboard
Or load it straight from its folder in every session, terminal and desktop app alike, by naming the folder in ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/activity-dashboard" } }
Use one of these ways, not several: each loads its own copy of the mod.
The pane opens by itself when a session starts. /activity-dashboard reopens it with the keyboard focus. Arguments:
| Argument | Effect | |||
|---|---|---|---|---|
summary | prints this chat's summary into the chat: time, tokens, cost, agents, tools | |||
sessions | lists every recorded chat with its task, time, tokens and cost | |||
| `report [md\ | pdf\ | html] [all\ | <id>] [<folder>]` | writes one report file (Markdown unless pdf or html is named) for this chat, one by id, or all chats; asks for the folder unless one is given |
reportdir [<folder>] | shows the report folders, or makes <folder> the first offer | |||
title <text> | names the task this chat is about (otherwise the first prompt is used) | |||
reset | clears every counter | |||
close | closes the pane | |||
breakdown | refreshes the context-window breakdown | |||
| `chart [bars\ | stacked\ | donut]` | how share charts are drawn; no argument cycles the three | |
files, skills | show or hide the files-changed and skills sections | |||
time, models, tools, steps, log | show or hide a section |
While the pane has the keyboard (ctrl+x tab, or a click), the hotkeys c (chart mode), p (PDF report), f (files), k (skills), b, i, m, t, s, l and r do the same.
Every section is its own framed block. Shares — time by phase, tokens by kind and by model, agents by type, tools by calls — are drawn by one chart in the mode you pick: a bar per row, one stacked bar over a legend, or a donut beside a legend. The legend always carries a colored marker, the label, the value and the percent, laid out on a cell grid so columns line up on every surface. On the desktop app, VS Code and mobile the stacked bar and the donut are real SVG; in the terminal they are colored text bars. Colors come from a fixed, colorblind-checked palette with light and dark steps, picked by your theme setting, and each tool, model or agent type keeps its color whatever its rank. Meters (context window, rate limits) stay bars colored by threshold.
At the end of every turn the mod writes ~/.claude/activity-dashboard/sessions/<session id>.json (override the home with ACTIVITY_DASHBOARD_HOME). A record holds:
Subagent time overlaps the main loop when agents run in the background, so the phases need not add up to the wall time. Cost is the host's own ledger.
A report is one file, <folder>/<date>_<id>.md by default, .pdf or .html when asked for. When you ask for one, a dialog offers up to four folders — the one you chose last time, the default, the project's reports/ and your Desktop — and "Other" takes any path (~ allowed). A folder given on the command line skips the dialog: /activity-dashboard report pdf ~/Desktop. Two settings in /config under the plugin: Report folder sets the default (empty means ~/.claude/activity-dashboard/reports), and Ask for the report folder off makes every report go to the default without asking. In a -p run nobody can be asked, so the default is used.
report pdf renders the HTML version (kept in the mod's work folder only while printing) with a headless browser found at one of the usual paths (Google Chrome, Chromium, Microsoft Edge, Brave on macOS or Linux), or at the path in ACTIVITY_DASHBOARD_CHROME. Without one, macOS's cupsfilter renders the markdown as plain text. Without either, the HTML report is written in the PDF's place and can be printed to PDF from any browser.
main and every running agent, indented under the one that started it. Each row says which model it runs on, the skill it loaded in its current turn, what it is doing this second — thinking, writing the answer, preparing a call to a tool, running a tool with a summary of its arguments, or waiting for the agents it started — and for how long.inline through the Skill tool, forked into an agent of its own, or command for a /skill you typed or a preload), its source or plugin, how long it took and its arguments; then the count per skill.+added −removed, a small diff bar, the share of the file that changed (changed lines, the max ofnew for a created file), the number of edits, which loops made them, and when last.claude plugin validate ~/.claude/mods/activity-dashboard
claude plugin test ~/.claude/mods/activity-dashboard
State lives in $.state under the contract in types/index.d.ts, so a hot reload keeps the counters. hooks/register.tsx holds the hooks and the pane, hooks/report.ts the session record and the Markdown and HTML renderers, hooks/format.ts the formatting helpers.
hooks/register.tsx 1825 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelUsage, Register, RenderNode, SessionUsage } from 'claude-code'
3
4import type {
5 DashActiveTool,
6 DashAgent,
7 DashContext,
8 DashEvent,
9 DashFile,
10 DashMain,
11 DashModelUsage,
12 DashNow,
13 DashSkill,
14 DashStep,
15 DashTime,
16 DashTool,
17 DashTurn,
18 DashView,
19 SessionRecord,
20} from '../types'
21import {
22 addUsage,
23 addUsageInto,
24 barOf,
25 cacheHit,
26 clamp,
27 cut,
28 detailOf,
29 emptyUsage,
30 fmtClock,
31 fmtDateTime,
32 fmtMs,
33 fmtStamp,
34 fmtTok,
35 fmtUsd,
36 fromModelUsage,
37 inputOf,
38 isDoneStatus,
39 oneLine,
40 pad,
41 padL,
42 pct,
43 pctColor,
44 shortModel,
45 statusGlyph,
46 toolLabel,
47 total,
48 untilIso,
49} from './format'
50import { MAIN, agentLabel, changedLines, describeNow, extractChanges, fmtFilePct, lineCount, loopLabel, mergeFile, relPath, sinceText, skillBadge } from './activity'
51import { meterRow, nextChartMode, paletteOf, shareChart, textBar } from './charts'
52import type { ChartOptions, ChartTable, Slice } from './charts'
53import { expandPath, labelPath, parseReportArgs } from './paths'
54import type { ReportKind } from './paths'
55import { aggregateToHtml, aggregateToMarkdown, buildRecord, chatSummary, parseRecord, recordToHtml, recordToMarkdown } from './report'
56import type { Snapshot } from './report'
57
58// ---------------------------------------------------------------------------
59// Identity
60// ---------------------------------------------------------------------------
61
62const PANE = 'activity-dashboard'
63const COMMAND = 'activity-dashboard'
64const TITLE = 'Activity'
65
66const MAX_AGENTS = 80
67const MAX_ACTIVE = 60
68const MAX_STEPS = 60
69const MAX_EVENTS = 40
70const MAX_TURNS = 500
71
72// ---------------------------------------------------------------------------
73// State (held by the host; survives hot reloads)
74// ---------------------------------------------------------------------------
75
76const emptyMain = (): DashMain => ({
77 phase: 'idle',
78 sessionId: '',
79 cwd: '',
80 title: '',
81 titleIsManual: false,
82 sessionModel: '',
83 model: '',
84 stepIndex: 0,
85 messageCount: 0,
86 startedAt: 0,
87 turns: 0,
88})
89
90const emptyContext = (): DashContext => ({ window: 0, rateLimits: [], compactions: 0 })
91const emptyTime = (): DashTime => ({ modelMs: 0, toolMs: 0, agentMs: 0, waitMs: 0 })
92
93const mainAtom = atom({ plugin: 'activity-dashboard', key: 'main' } as const, emptyMain())
94const usageAtom = atom({ plugin: 'activity-dashboard', key: 'usage' } as const, {} as DashModelUsage)
95const agentsAtom = atom({ plugin: 'activity-dashboard', key: 'agents' } as const, [] as DashAgent[])
96const contextAtom = atom({ plugin: 'activity-dashboard', key: 'context' } as const, emptyContext())
97const toolsAtom = atom({ plugin: 'activity-dashboard', key: 'tools' } as const, {} as Record<string, DashTool>)
98const activeAtom = atom({ plugin: 'activity-dashboard', key: 'active' } as const, [] as DashActiveTool[])
99const stepsAtom = atom({ plugin: 'activity-dashboard', key: 'steps' } as const, [] as DashStep[])
100const eventsAtom = atom({ plugin: 'activity-dashboard', key: 'events' } as const, [] as DashEvent[])
101const turnsAtom = atom({ plugin: 'activity-dashboard', key: 'turns' } as const, [] as DashTurn[])
102const timeAtom = atom({ plugin: 'activity-dashboard', key: 'time' } as const, emptyTime())
103const filesAtom = atom({ plugin: 'activity-dashboard', key: 'files' } as const, {} as Record<string, DashFile>)
104const skillsAtom = atom({ plugin: 'activity-dashboard', key: 'skills' } as const, [] as DashSkill[])
105const nowAtom = atom({ plugin: 'activity-dashboard', key: 'now' } as const, {} as Record<string, DashNow>)
106
107const MAX_SKILLS = 200
108const MAX_FILES = 400
109const viewAtom = atom({ plugin: 'activity-dashboard', key: 'view' } as const, {
110 showTools: true,
111 showLog: true,
112 showSteps: true,
113 showModels: true,
114 showTime: true,
115 chart: 'bars',
116 dark: false,
117} as DashView)
118
119/** Fixed palette slots by name, alphabetical, so a tool or model keeps its color whatever its rank. */
120const slotsByName = (names: readonly string[]): Map<string, number> => {
121 const m = new Map<string, number>()
122 ;[...new Set(names)].sort((a, b) => a.localeCompare(b)).forEach((n, i) => m.set(n, i % 8))
123 return m
124}
125
126// ---------------------------------------------------------------------------
127// State writers
128// ---------------------------------------------------------------------------
129
130const pushEvent = async ($: EngineInterface, at: number, text: string, kind: DashEvent['kind'] = 'info') => {
131 await update($, eventsAtom, list => [...list, { at, text, kind }].slice(-MAX_EVENTS))
132}
133
134const applySessionUsage = async ($: EngineInterface, u: SessionUsage, at: number) => {
135 await update($, contextAtom, c => {
136 const percent = u.context.percent ?? c.percent
137 return {
138 ...c,
139 tokens: u.context.tokens ?? c.tokens,
140 window: u.context.window || c.window,
141 percent,
142 maxPercent: Math.max(c.maxPercent ?? 0, percent ?? 0),
143 costUsd: u.cost?.usd ?? c.costUsd,
144 rateLimits: u.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
145 measuredAt: at,
146 }
147 })
148}
149
150const fetchBreakdown = async ($: EngineInterface) => {
151 try {
152 const u = await $.session.usage({ breakdown: 'summary' })
153 const b = u.context.breakdown
154 const at = await $.clock.now()
155 await applySessionUsage($, u, at)
156 if (!b) return
157 await update($, contextAtom, c => ({
158 ...c,
159 breakdown: b.categories.map(r => ({ name: r.name, tokens: r.tokens, kind: r.kind, color: r.color })),
160 breakdownMax: b.rawMaxTokens,
161 autoCompactThreshold: b.autoCompactThreshold,
162 }))
163 } catch {
164 // a session that cannot measure yet: keep what we have
165 }
166}
167
168const clearStats = async ($: EngineInterface, keepSession: boolean) => {
169 await update($, usageAtom, () => ({}))
170 await update($, agentsAtom, () => [])
171 await update($, toolsAtom, () => ({}))
172 await update($, activeAtom, () => [])
173 await update($, stepsAtom, () => [])
174 await update($, eventsAtom, () => [])
175 await update($, turnsAtom, () => [])
176 await update($, timeAtom, () => emptyTime())
177 await update($, filesAtom, () => ({}))
178 await update($, skillsAtom, () => [])
179 await update($, nowAtom, () => ({}))
180 await update($, contextAtom, c => (keepSession ? { ...c, compactions: 0, lastCompaction: undefined, maxPercent: c.percent } : emptyContext()))
181}
182
183const resetAll = async ($: EngineInterface) => {
184 const now = await $.clock.now()
185 await clearStats($, true)
186 await update($, mainAtom, m => ({ ...emptyMain(), sessionId: m.sessionId, cwd: m.cwd, repo: m.repo, branch: m.branch, title: m.title, titleIsManual: m.titleIsManual, sessionModel: m.sessionModel, startedAt: now }))
187 await pushEvent($, now, 'statistics reset', 'info')
188}
189
190type ViewFlag = 'showTools' | 'showLog' | 'showSteps' | 'showModels' | 'showTime' | 'showFiles' | 'showSkills'
191
192/** Flips a section's flag; a flag never set reads as shown. */
193const toggleView = async ($: EngineInterface, key: ViewFlag) => {
194 await update($, viewAtom, v => ({ ...v, [key]: !(v[key] ?? true) }))
195}
196
197const bumpTurn = async ($: EngineInterface, change: (t: DashTurn) => DashTurn) => {
198 await update($, turnsAtom, list => {
199 const last = list[list.length - 1]
200 if (!last || last.endedAt !== undefined) return list
201 return [...list.slice(0, -1), change(last)]
202 })
203}
204
205const noteStepUsage = async (
206 $: EngineInterface,
207 model: string,
208 agentId: string | undefined,
209 usage: ModelUsage,
210 ms: number,
211 at: number,
212 stopReason: string | undefined,
213) => {
214 await update($, usageAtom, map => ({ ...map, [model]: addUsage(map[model] ?? emptyUsage(), usage) }))
215 await update($, stepsAtom, list =>
216 [
217 ...list,
218 {
219 at,
220 model,
221 agentId,
222 input: usage.input_tokens ?? 0,
223 output: usage.output_tokens ?? 0,
224 cacheRead: usage.cache_read_input_tokens ?? 0,
225 cacheWrite: usage.cache_creation_input_tokens ?? 0,
226 stopReason,
227 ms,
228 },
229 ].slice(-MAX_STEPS),
230 )
231 if (agentId) {
232 await update($, agentsAtom, list =>
233 list.map(a => (a.id === agentId ? { ...a, usage: addUsage(a.usage, usage), model: model || a.model } : a)),
234 )
235 }
236}
237
238// ---------------------------------------------------------------------------
239// Loops: what each is doing, the agents nobody announced, skills, file changes
240// ---------------------------------------------------------------------------
241
242/** Sets what a loop is doing; keeps the loop's skill unless the patch names one. */
243const setNow = async ($: EngineInterface, loop: string, patch: Omit<DashNow, 'skill'> & { skill?: string | null }) => {
244 await update($, nowAtom, map => {
245 const prev = map[loop]
246 const skill = patch.skill === null ? undefined : (patch.skill ?? prev?.skill)
247 return { ...map, [loop]: { what: patch.what, detail: patch.detail, since: patch.since, skill } }
248 })
249}
250
251const dropNow = async ($: EngineInterface, loop: string) => {
252 await update($, nowAtom, map => {
253 if (!(loop in map)) return map
254 const { [loop]: _gone, ...rest } = map
255 return rest
256 })
257}
258
259/** Agent ids already looked up in `$.agent.list()`, and when: an unknown id is asked again after 5s. */
260const lookedUp = new Map<string, number>()
261
262/**
263 * Adds an agent the session runs that no `agent.spawn` announced (a forked
264 * skill, a loop the engine tracks on its own), from `$.agent.list()`. With
265 * `placeholder`, one the list does not know is added too, as `agent`.
266 */
267const ensureAgent = async ($: EngineInterface, id: string, placeholder: boolean, via?: string) => {
268 const agents = await read($, agentsAtom)
269 if (agents.some(a => a.id === id)) return
270 const at = await $.clock.now()
271 const last = lookedUp.get(id)
272 if (!placeholder && last !== undefined && at - last < 5000) return
273 lookedUp.set(id, at)
274 let info: Awaited<ReturnType<EngineInterface['agent']['list']>>[number] | undefined
275 try {
276 info = (await $.agent.list()).find(a => a.id === id)
277 } catch {
278 info = undefined
279 }
280 if (!info && !placeholder) return
281 const agent: DashAgent = {
282 id,
283 type: info?.type ?? 'agent',
284 description: cut(oneLine(info?.description ?? ''), 80),
285 model: '',
286 parentId: info?.parentId,
287 name: info?.name,
288 status: info?.status ?? 'running',
289 background: false,
290 startedAt: at,
291 usage: emptyUsage(),
292 steps: 0,
293 toolCalls: 0,
294 via: via ?? 'untracked',
295 }
296 await update($, agentsAtom, list => (list.some(a => a.id === id) ? list : [...list, agent].slice(-MAX_AGENTS)))
297}
298
299/** Skill tool calls in flight, by skill name: `skill.prompt` adds its source to them. */
300const pendingSkills = new Map<string, number>()
301const pendingSource = new Map<string, { source: string; plugin?: string }>()
302
303const pushSkill = async ($: EngineInterface, s: DashSkill) => {
304 await update($, skillsAtom, list => [...list, s].slice(-MAX_SKILLS))
305}
306
307const noteFileChanges = async ($: EngineInterface, tool: string, input: Record<string, unknown>, result: unknown, loop: string) => {
308 const changes = extractChanges(tool, input, result)
309 if (!changes.length) return
310 const at = await $.clock.now()
311 const unknownSize: string[] = []
312 await update($, filesAtom, map => {
313 const next = { ...map }
314 for (const ch of changes) {
315 const f = mergeFile(next[ch.path], ch, loop, tool, at)
316 next[ch.path] = f
317 if (f.lines === undefined && !f.deleted) unknownSize.push(ch.path)
318 }
319 const keys = Object.keys(next)
320 if (keys.length > MAX_FILES) {
321 for (const k of keys.sort((a, b) => (next[a]?.lastAt ?? 0) - (next[b]?.lastAt ?? 0)).slice(0, keys.length - MAX_FILES)) delete next[k]
322 }
323 return next
324 })
325 // a Bash change says nothing of the file's size: read it once to size the share
326 for (const path of unknownSize.slice(0, 20)) {
327 let lines: number | undefined
328 try {
329 lines = lineCount(await $.fs.read(path))
330 } catch {
331 lines = undefined
332 }
333 if (lines === undefined) continue
334 const n = lines
335 await update($, filesAtom, map => {
336 const f = map[path]
337 if (!f || f.lines !== undefined) return map
338 return { ...map, [path]: { ...f, lines: n, baseLines: f.baseLines ?? Math.max(0, n - f.added + f.removed) } }
339 })
340 }
341 const label = loop === MAIN ? 'main' : loopLabel(loop, await read($, agentsAtom))
342 const sum = changes.reduce((acc, c) => ({ added: acc.added + c.added, removed: acc.removed + c.removed }), { added: 0, removed: 0 })
343 const cwd = (await read($, mainAtom)).cwd
344 const home = (await $.env.get('HOME').catch(() => undefined)) ?? ''
345 const what = changes.length === 1 ? relPath(changes[0]!.path, cwd, home) : `${String(changes.length)} files`
346 await pushEvent($, at, `✎ ${label}: ${cut(what, 48)} +${String(sum.added)} −${String(sum.removed)}`, 'info')
347}
348
349// ---------------------------------------------------------------------------
350// Persistence: one JSON per chat under <home>/.claude/activity-dashboard
351// ---------------------------------------------------------------------------
352
353const baseDir = async ($: EngineInterface): Promise<string> => {
354 let home: string | undefined
355 try {
356 home = (await $.env.get('ACTIVITY_DASHBOARD_HOME')) ?? (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE'))
357 } catch {
358 home = undefined
359 }
360 if (!home) home = await $.session.cwd()
361 return `${home.replace(/\/+$/, '')}/.claude/activity-dashboard`
362}
363
364const snapshot = async ($: EngineInterface, ended = false): Promise<Snapshot> => {
365 const [main, usage, agents, ctx, tools, turns, time, events, files, skills, now] = await Promise.all([
366 read($, mainAtom),
367 read($, usageAtom),
368 read($, agentsAtom),
369 read($, contextAtom),
370 read($, toolsAtom),
371 read($, turnsAtom),
372 read($, timeAtom),
373 read($, eventsAtom),
374 read($, filesAtom),
375 read($, skillsAtom),
376 $.clock.now(),
377 ])
378 return { main, usage, agents, ctx, tools, turns, time, events, files, skills, now, ended }
379}
380
381const persist = async ($: EngineInterface, ended = false): Promise<SessionRecord | undefined> => {
382 try {
383 const rec = buildRecord(await snapshot($, ended))
384 if (!rec.id || rec.id === 'unknown') return rec
385 const dir = await baseDir($)
386 await $.fs.write(`${dir}/sessions/${rec.id}.json`, JSON.stringify(rec, null, 2))
387 return rec
388 } catch {
389 return undefined
390 }
391}
392
393const loadRecords = async ($: EngineInterface): Promise<SessionRecord[]> => {
394 const dir = `${await baseDir($)}/sessions`
395 if (!(await $.fs.exists(dir))) return []
396 const entries = await $.fs.list(dir)
397 const recs: SessionRecord[] = []
398 for (const e of entries) {
399 if (e.kind !== 'file' || !e.name.endsWith('.json')) continue
400 try {
401 const rec = parseRecord(await $.fs.read(`${dir}/${e.name}`))
402 if (rec) recs.push(rec)
403 } catch {
404 // a file that cannot be read is left out
405 }
406 }
407 return recs.sort((a, b) => b.startedAt - a.startedAt)
408}
409
410// ---------------------------------------------------------------------------
411// Where a report goes: a folder argument, else a dialog (remembered), else the default
412// ---------------------------------------------------------------------------
413
414/** The manifest's userConfig, as `register` received it; a reload brings new values. */
415let config: { reportDir: string; askReportDir: boolean } = { reportDir: '', askReportDir: true }
416
417const STORE_LAST_DIR = 'reportDir:last'
418
419const homeDir = async ($: EngineInterface): Promise<string> => {
420 try {
421 return (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? (await $.session.cwd())
422 } catch {
423 return $.session.cwd()
424 }
425}
426
427const defaultReportDir = async ($: EngineInterface): Promise<string> => {
428 if (config.reportDir.trim()) return expandPath(config.reportDir, await homeDir($), await $.session.cwd())
429 return `${await baseDir($)}/reports`
430}
431
432const rememberReportDir = async ($: EngineInterface, dir: string) => {
433 try {
434 await $.store.set(STORE_LAST_DIR, dir)
435 } catch {
436 // a store that refuses is no reason to lose the report
437 }
438}
439
440const lastReportDir = async ($: EngineInterface): Promise<string | undefined> => {
441 try {
442 const v = await $.store.get(STORE_LAST_DIR)
443 return typeof v === 'string' && v.trim() ? v : undefined
444 } catch {
445 return undefined
446 }
447}
448
449const CANCELLED = Symbol('cancelled')
450
451/**
452 * Resolves the folder a report is written to. `explicit` (a command argument)
453 * wins; else, with `askReportDir` on, a dialog offers the remembered folder,
454 * the default, the project's `reports/` and the Desktop, plus a typed path
455 * under "Other"; a dismissed dialog cancels; where nobody can be asked (a
456 * `-p` run) the default is used.
457 */
458const chooseReportDir = async ($: EngineInterface, explicit: string | undefined): Promise<string | typeof CANCELLED> => {
459 const home = await homeDir($)
460 const cwd = await $.session.cwd()
461 if (explicit) {
462 const dir = expandPath(explicit, home, cwd)
463 await rememberReportDir($, dir)
464 return dir
465 }
466 const fallback = await defaultReportDir($)
467 if (!config.askReportDir) return fallback
468 const last = await lastReportDir($)
469 const candidates: string[] = []
470 const add = (d: string | undefined) => {
471 if (d && !candidates.includes(d)) candidates.push(d)
472 }
473 add(last)
474 add(fallback)
475 add(`${cwd.replace(/\/+$/, '')}/reports`)
476 add(`${home}/Desktop`)
477 const options = candidates.slice(0, 4)
478 const labels = options.map(d => labelPath(d, home))
479 let answer: string
480 try {
481 answer = await $.ui.ask('Where should the report be saved? (pick a folder, or type a path under Other)', { options: labels, header: 'Report dir' })
482 } catch {
483 // dismissed, or nobody to ask: a -p run uses the default, a person who closed the dialog cancels
484 let interactive = true
485 try {
486 interactive = (await $.session.surfaces()).length > 0
487 } catch {
488 interactive = true
489 }
490 return interactive ? CANCELLED : fallback
491 }
492 const picked = labels.indexOf(answer)
493 const dir = picked >= 0 ? (options[picked] ?? fallback) : expandPath(answer, home, cwd)
494 await rememberReportDir($, dir)
495 return dir
496}
497
498// ---------------------------------------------------------------------------
499// Reports: markdown + html always; pdf through a headless browser or cupsfilter
500// ---------------------------------------------------------------------------
501
502const CHROME_CANDIDATES = [
503 '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
504 '/Applications/Chromium.app/Contents/MacOS/Chromium',
505 '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
506 '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
507 '/usr/bin/google-chrome',
508 '/usr/bin/google-chrome-stable',
509 '/usr/bin/chromium',
510 '/usr/bin/chromium-browser',
511 '/usr/bin/microsoft-edge',
512 '/snap/bin/chromium',
513]
514
515const findChrome = async ($: EngineInterface): Promise<string | undefined> => {
516 let override: string | undefined
517 try {
518 override = await $.env.get('ACTIVITY_DASHBOARD_CHROME')
519 } catch {
520 override = undefined
521 }
522 for (const p of [override, ...CHROME_CANDIDATES]) {
523 if (p && (await $.fs.exists(p))) return p
524 }
525 return undefined
526}
527
528const shq = (s: string): string => `'${s.replace(/'/g, `'\\''`)}'`
529
530type PdfOutcome = { path: string; via: 'chrome' | 'cupsfilter'; reason?: undefined } | { path?: undefined; via?: undefined; reason: string }
531
532const toPdf = async ($: EngineInterface, htmlPath: string, textPath: string, pdfPath: string, title: string, profileDir: string): Promise<PdfOutcome> => {
533 const chrome = await findChrome($)
534 if (chrome) {
535 try {
536 const r = await $.process.run(
537 [chrome, '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', `--user-data-dir=${profileDir}`, '--no-pdf-header-footer', `--print-to-pdf=${pdfPath}`, `file://${htmlPath}`],
538 { timeoutMs: 90_000 },
539 )
540 if (r.exitCode === 0 && (await $.fs.exists(pdfPath))) return { path: pdfPath, via: 'chrome' }
541 } catch {
542 // fall through to cupsfilter
543 }
544 }
545 if (await $.fs.exists('/usr/sbin/cupsfilter')) {
546 try {
547 const r = await $.process.run(['/bin/sh', '-c', `/usr/sbin/cupsfilter -t ${shq(title)} ${shq(textPath)} > ${shq(pdfPath)} 2>/dev/null`], { timeoutMs: 60_000 })
548 if (r.exitCode === 0 && (await $.fs.exists(pdfPath))) return { path: pdfPath, via: 'cupsfilter' }
549 } catch {
550 // nothing else to try
551 }
552 }
553 return { reason: chrome ? 'the browser did not produce a PDF' : 'no headless browser (Chrome, Chromium, Edge, Brave) and no cupsfilter found; set ACTIVITY_DASHBOARD_CHROME to a browser binary' }
554}
555
556const makeReport = async ($: EngineInterface, kind: ReportKind, target: string, explicitDir?: string): Promise<string> => {
557 const now = await $.clock.now()
558 const dir = await baseDir($)
559 const chosen = await chooseReportDir($, explicitDir)
560 if (chosen === CANCELLED) return 'Report cancelled: no folder chosen.'
561 let md: string
562 let html: string
563 let stem: string
564 let title: string
565 if (target === 'all') {
566 const recs = await loadRecords($)
567 const current = buildRecord(await snapshot($))
568 const merged = [current, ...recs.filter(r => r.id !== current.id)].sort((a, b) => b.startedAt - a.startedAt)
569 if (!merged.length) return 'No sessions recorded yet.'
570 md = aggregateToMarkdown(merged, now)
571 html = aggregateToHtml(merged, now)
572 stem = `${fmtStamp(now)}_all-sessions`
573 title = 'Activity report: all sessions'
574 } else {
575 let rec: SessionRecord | undefined
576 if (target === '' || target === 'current' || target === 'this') {
577 rec = (await persist($)) ?? buildRecord(await snapshot($))
578 } else {
579 rec = (await loadRecords($)).find(r => r.id === target || r.id.startsWith(target))
580 if (!rec) return `No stored session matches "${target}". Run \`/${COMMAND} sessions\` for the list.`
581 }
582 md = recordToMarkdown(rec)
583 html = recordToHtml(rec)
584 stem = `${fmtStamp(rec.startedAt)}_${rec.id.slice(0, 8)}`
585 title = `Activity report: ${rec.title}`
586 }
587 const reports = chosen
588 const stemPath = `${reports}/${stem}`
589 if (kind === 'md') {
590 await $.fs.write(`${stemPath}.md`, md)
591 return `Report written: ${stemPath}.md`
592 }
593 if (kind === 'html') {
594 await $.fs.write(`${stemPath}.html`, html)
595 return `Report written: ${stemPath}.html`
596 }
597 // pdf: the html and the text it is printed from live in the mod's work folder and go once the pdf exists
598 const work = `${dir}/.work`
599 const htmlPath = `${work}/${stem}.html`
600 const mdPath = `${work}/${stem}.md`
601 const pdfPath = `${stemPath}.pdf`
602 await $.fs.write(mdPath, md)
603 await $.fs.write(htmlPath, html)
604 const out = await toPdf($, htmlPath, mdPath, pdfPath, title, `${dir}/.browser-profile`)
605 if (out.path) {
606 await removeFiles($, [htmlPath, mdPath])
607 $.ui.toast(`PDF report: ${out.path}`)
608 return `PDF report written: ${out.path} (via ${out.via})`
609 }
610 await $.fs.write(`${stemPath}.html`, html)
611 await removeFiles($, [htmlPath, mdPath])
612 return `Could not render a PDF: ${out.reason}. The HTML report is at ${stemPath}.html; open it in a browser and print to PDF.`
613}
614
615const removeFiles = async ($: EngineInterface, files: string[]) => {
616 try {
617 await $.process.run(['rm', '-f', ...files], { timeoutMs: 5000 })
618 } catch {
619 // leftovers in the work folder do no harm
620 }
621}
622
623// ---------------------------------------------------------------------------
624// Ticker: elapsed times tick while something runs; agent statuses refresh
625// ---------------------------------------------------------------------------
626
627let tick = 0
628
629const startTicker = ($: EngineInterface) => {
630 $.clock.every(1000, async () => {
631 tick += 1
632 try {
633 const [main, active, agents] = await Promise.all([read($, mainAtom), read($, activeAtom), read($, agentsAtom)])
634 const liveAgents = agents.some(a => !isDoneStatus(a.status))
635 const busy = main.phase !== 'idle' || active.length > 0 || liveAgents
636 if (liveAgents && tick % 3 === 0) {
637 const live = await $.agent.list()
638 const now = await $.clock.now()
639 let agentMsDelta = 0
640 await update($, agentsAtom, list =>
641 list.map(a => {
642 const l = live.find(x => x.id === a.id)
643 if (!l || l.status === a.status) return a
644 const done = isDoneStatus(l.status)
645 if (done && a.endedAt === undefined) agentMsDelta += now - a.startedAt
646 return { ...a, status: l.status, endedAt: done ? (a.endedAt ?? now) : a.endedAt }
647 }),
648 )
649 if (agentMsDelta > 0) await update($, timeAtom, t => ({ ...t, agentMs: t.agentMs + agentMsDelta }))
650 }
651 if (busy) $.ui.invalidate('ui.render')
652 } catch {
653 // a tick that cannot read is skipped
654 }
655 })
656}
657
658// ---------------------------------------------------------------------------
659// Session identity: id, cwd, repo, branch
660// ---------------------------------------------------------------------------
661
662const learnSession = async ($: EngineInterface) => {
663 let sessionId = ''
664 let cwd = ''
665 let repo: string | undefined
666 let branch: string | undefined
667 let model = ''
668 try {
669 sessionId = await $.session.id()
670 } catch {
671 sessionId = ''
672 }
673 try {
674 cwd = await $.session.cwd()
675 } catch {
676 cwd = ''
677 }
678 try {
679 model = await $.session.model()
680 } catch {
681 model = ''
682 }
683 try {
684 const r = await $.session.repo()
685 if (r) repo = r.name ?? r.root
686 } catch {
687 repo = undefined
688 }
689 try {
690 const g = await $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], { timeoutMs: 5000 })
691 if (g.exitCode === 0) branch = g.stdout.trim() || undefined
692 } catch {
693 branch = undefined
694 }
695 await update($, mainAtom, m => ({
696 ...m,
697 sessionId: sessionId || m.sessionId,
698 cwd: cwd || m.cwd,
699 repo: repo ?? m.repo,
700 branch: branch ?? m.branch,
701 sessionModel: model || m.sessionModel,
702 }))
703 return model
704}
705
706// ---------------------------------------------------------------------------
707// Hooks
708// ---------------------------------------------------------------------------
709
710export const register: Register = (on, options) => {
711 config = {
712 reportDir: typeof options.reportDir === 'string' ? options.reportDir : '',
713 askReportDir: options.askReportDir !== false,
714 }
715
716 on('session.start', async ($, e, next) => {
717 await $.command.register({
718 name: COMMAND,
719 description: 'Activity dashboard: live model, agents, orchestration, time, tokens, cost; summary and PDF reports',
720 argumentHint: '[summary|sessions|report [md|pdf|html] [all|<id>] [<folder>]|reportdir [<folder>]|chart [bars|stacked|donut]|title <text>|reset|close|breakdown|files|skills|tools|log|steps|models|time]',
721 })
722 const now = await $.clock.now()
723 const model = await learnSession($)
724 await update($, mainAtom, m => ({ ...m, startedAt: m.startedAt || now, phase: 'idle' as const }))
725 try {
726 const settings = (await $.settings.read()) as Record<string, unknown>
727 const theme = typeof settings.theme === 'string' ? settings.theme : ''
728 await update($, viewAtom, v => ({ ...v, chart: v.chart ?? 'bars', dark: theme.startsWith('dark') }))
729 } catch {
730 // no settings to read: the light palette stands
731 }
732 await update($, timeAtom, t => ({ ...t, idleSince: t.idleSince ?? now }))
733 try {
734 const u = await $.session.usage()
735 await update($, mainAtom, m => ({ ...m, startedAt: u.startedAt || m.startedAt }))
736 await applySessionUsage($, u, now)
737 } catch {
738 // not bound yet: session.measure fills it in later
739 }
740 await pushEvent($, now, `dashboard ready · ${shortModel(model)}`, 'info')
741 startTicker($)
742 void $.ui.open({ id: PANE, title: TITLE })
743 return next(e)
744 })
745
746 on('command.run', { command: COMMAND }, async ($, e) => {
747 const raw = e.args.trim()
748 const [word = '', ...rest] = raw.split(/\s+/)
749 const arg = word.toLowerCase()
750 switch (arg) {
751 case 'summary': {
752 const rec = (await persist($)) ?? buildRecord(await snapshot($))
753 return { text: chatSummary(rec) }
754 }
755 case 'sessions': {
756 const recs = await loadRecords($)
757 if (!recs.length) return { text: 'No sessions recorded yet: a record is written at the end of every turn.' }
758 const lines = recs.slice(0, 40).map(r => `- ${fmtDateTime(r.startedAt)} · **${cut(r.title, 60)}** · ${cut(r.repo ?? r.cwd, 28)} · ${fmtMs(r.wallMs)} wall · ${r.turns.length} turns · ${fmtTok(total(r.totals))} tok${r.costUsd !== undefined ? ` · ${fmtUsd(r.costUsd)}` : ''} · \`${r.id.slice(0, 8)}\``)
759 return { text: `**Recorded sessions (${recs.length})**\n${lines.join('\n')}\n\n\`/${COMMAND} report pdf <id>\` for one, \`/${COMMAND} report pdf all\` for the whole set.` }
760 }
761 case 'report': {
762 const a = parseReportArgs(rest)
763 return { text: await makeReport($, a.kind, a.target, a.dir) }
764 }
765 case 'reportdir': {
766 const wanted = rest.join(' ').trim()
767 const home = await homeDir($)
768 if (wanted) {
769 const dir = expandPath(wanted, home, await $.session.cwd())
770 await rememberReportDir($, dir)
771 return { text: `Reports will be offered in ${dir} first. (The dialog still asks; set askReportDir to false in /config to skip it.)` }
772 }
773 const last = await lastReportDir($)
774 return { text: `Default report folder: ${await defaultReportDir($)}${last ? `\nLast chosen: ${last}` : ''}\nAsk each time: ${config.askReportDir ? 'yes' : 'no'} (userConfig askReportDir)` }
775 }
776 case 'title': {
777 const title = oneLine(rest.join(' '))
778 if (!title) {
779 const m = await read($, mainAtom)
780 return { text: `Current task title: ${m.title || '(none yet)'}` }
781 }
782 await update($, mainAtom, m => ({ ...m, title: cut(title, 160), titleIsManual: true }))
783 await persist($)
784 return { text: `Task title set: ${cut(title, 160)}` }
785 }
786 case 'reset':
787 await resetAll($)
788 await $.ui.open({ id: PANE, title: TITLE })
789 return { text: 'Activity dashboard: statistics reset.' }
790 case 'close':
791 await $.ui.close({ id: PANE })
792 return { text: 'Activity dashboard closed.' }
793 case 'breakdown':
794 await fetchBreakdown($)
795 await $.ui.open({ id: PANE, title: TITLE })
796 return { text: 'Activity dashboard: context breakdown refreshed.' }
797 case 'tools':
798 await toggleView($, 'showTools')
799 return { text: 'Activity dashboard: tools section toggled.' }
800 case 'log':
801 await toggleView($, 'showLog')
802 return { text: 'Activity dashboard: log section toggled.' }
803 case 'steps':
804 await toggleView($, 'showSteps')
805 return { text: 'Activity dashboard: steps section toggled.' }
806 case 'models':
807 await toggleView($, 'showModels')
808 return { text: 'Activity dashboard: models section toggled.' }
809 case 'time':
810 await toggleView($, 'showTime')
811 return { text: 'Activity dashboard: time section toggled.' }
812 case 'files':
813 await toggleView($, 'showFiles')
814 return { text: 'Activity dashboard: files section toggled.' }
815 case 'skills':
816 await toggleView($, 'showSkills')
817 return { text: 'Activity dashboard: skills section toggled.' }
818 case 'chart': {
819 const wanted = (rest[0] ?? '').toLowerCase()
820 const pick = wanted === 'bars' || wanted === 'stacked' || wanted === 'donut' ? wanted : undefined
821 const v = await update($, viewAtom, x => ({ ...x, chart: pick ?? nextChartMode(x.chart) }))
822 return { text: `Activity dashboard: charts drawn as ${v.chart}.` }
823 }
824 default: {
825 const placed = await $.ui.open({ id: PANE, title: TITLE, focus: true })
826 return {
827 text: placed.isPlaced
828 ? 'Activity dashboard opened.'
829 : 'Activity dashboard is open but waits for room (widen the window or dock a pane).',
830 }
831 }
832 }
833 })
834
835 // --- turns -------------------------------------------------------------
836
837 on('turn.start', async ($, e, next) => {
838 const now = await $.clock.now()
839 const ctx = await read($, contextAtom)
840 const head = oneLine(e.text)
841 await update($, timeAtom, t => ({
842 ...t,
843 waitMs: t.waitMs + (t.idleSince !== undefined ? Math.max(0, now - t.idleSince) : 0),
844 idleSince: undefined,
845 }))
846 await update($, mainAtom, m => ({
847 ...m,
848 phase: 'model' as const,
849 turnId: e.turnId,
850 turnStartedAt: now,
851 stepIndex: 0,
852 costAtTurnStart: ctx.costUsd,
853 title: m.title || (head ? cut(head, 160) : m.title),
854 sessionId: m.sessionId,
855 }))
856 await update($, turnsAtom, list =>
857 [...list, { index: list.length + 1, turnId: e.turnId, startedAt: now, prompt: cut(head, 200), toolCalls: 0, agents: 0, modelMs: 0, toolMs: 0 }].slice(-MAX_TURNS),
858 )
859 await setNow($, MAIN, { what: 'thinking', detail: '', since: now, skill: null })
860 await pushEvent($, now, head ? `turn: ${cut(head, 60)}` : 'turn started (continuation)', 'info')
861 return next(e)
862 })
863
864 on('turn.step', async function* ($, e, next) {
865 const started = await $.clock.now()
866 if (e.agentId) {
867 const id = e.agentId
868 await update($, agentsAtom, list =>
869 list.map(a => (a.id === id ? { ...a, steps: a.steps + 1, model: e.model || a.model, status: 'running' } : a)),
870 )
871 } else {
872 await update($, mainAtom, m => ({
873 ...m,
874 phase: 'model' as const,
875 model: e.model,
876 effort: e.effort === undefined ? m.effort : String(e.effort),
877 stepIndex: e.index,
878 messageCount: e.messageCount,
879 }))
880 }
881 const loop = e.agentId ?? MAIN
882 if (e.agentId) await ensureAgent($, e.agentId, false)
883 // the engine's own forks (compaction, memory) step too: mark only loops the dashboard shows
884 const tracked = !e.agentId || (await read($, agentsAtom)).some(a => a.id === e.agentId)
885 if (tracked) await setNow($, loop, { what: 'thinking', detail: '', since: started })
886 // pass every chunk on unchanged and return what beneath returned (read off
887 // the iterator itself, as yield* does); note only when the kind of work changes
888 const it = next(e)[Symbol.asyncIterator]()
889 let lastKind = 'thinking'
890 let finished = false
891 let step = await it.next()
892 try {
893 while (!step.done) {
894 const chunk = step.value
895 yield chunk
896 const kind = chunk.kind === 'text' ? 'answering' : chunk.kind === 'thinking' ? 'thinking' : chunk.kind === 'tool' ? `tool:${chunk.name}` : undefined
897 if (tracked && kind !== undefined && kind !== lastKind) {
898 lastKind = kind
899 try {
900 const at = await $.clock.now()
901 if (chunk.kind === 'tool') await setNow($, loop, { what: 'preparing', detail: chunk.name, since: at })
902 else await setNow($, loop, { what: kind === 'answering' ? 'answering' : 'thinking', detail: '', since: at })
903 } catch {
904 // bookkeeping never stands in the stream's way
905 }
906 }
907 step = await it.next()
908 }
909 finished = true
910 } finally {
911 // read no further (an interrupt, a consumer that stopped): close what is beneath
912 if (!finished) await it.return(undefined as never)
913 }
914 const r = step.value
915 const now = await $.clock.now()
916 const ms = now - started
917 if (!e.agentId) {
918 await update($, timeAtom, t => ({ ...t, modelMs: t.modelMs + ms }))
919 await bumpTurn($, t => ({ ...t, modelMs: t.modelMs + ms }))
920 }
921 if (r && r.usage) {
922 await noteStepUsage($, e.model, e.agentId, r.usage, ms, now, r.stopReason ?? undefined)
923 }
924 if (r && r.stopReason === 'model_context_window_exceeded') {
925 await pushEvent($, now, 'context window exceeded', 'error')
926 } else if (r && r.stopReason === 'max_tokens') {
927 await pushEvent($, now, 'response cut at max_tokens', 'warn')
928 }
929 return r
930 })
931
932 on('turn.complete', async ($, e, next) => {
933 const now = await $.clock.now()
934 if (e.agentId) {
935 const id = e.agentId
936 const status = e.isAborted ? 'killed' : e.reason === 'error' || e.reason === 'refusal' ? 'failed' : 'completed'
937 let known: DashAgent | undefined
938 let agentMsDelta = 0
939 await update($, agentsAtom, list =>
940 list.map(a => {
941 if (a.id !== id) return a
942 known = a
943 if (a.endedAt === undefined) agentMsDelta = now - a.startedAt
944 return { ...a, status, endedAt: a.endedAt ?? now }
945 }),
946 )
947 if (agentMsDelta > 0) await update($, timeAtom, t => ({ ...t, agentMs: t.agentMs + agentMsDelta }))
948 await dropNow($, id)
949 if (known) {
950 const k = known
951 await pushEvent(
952 $,
953 now,
954 `${status === 'completed' ? '✓' : '✗'} agent ${k.type} (${shortModel(k.model)}) ${fmtMs(e.durationMs)} · ${fmtTok(total(k.usage))} tok`,
955 status === 'completed' ? 'ok' : 'error',
956 )
957 }
958 } else {
959 const ctx = await read($, contextAtom)
960 const u = e.usage ? fromModelUsage(e.usage) : undefined
961 let costDelta: number | undefined
962 await update($, mainAtom, m => {
963 costDelta = ctx.costUsd !== undefined && m.costAtTurnStart !== undefined ? Math.max(0, ctx.costUsd - m.costAtTurnStart) : undefined
964 return {
965 ...m,
966 phase: 'idle' as const,
967 turns: m.turns + 1,
968 lastTurnMs: e.durationMs,
969 lastTurnUsage: u ?? m.lastTurnUsage,
970 lastTurnCostUsd: costDelta ?? m.lastTurnCostUsd,
971 }
972 })
973 await bumpTurn($, t => ({ ...t, endedAt: now, ms: Math.max(e.durationMs, now - t.startedAt), usage: u, costDelta, reason: e.reason }))
974 await setNow($, MAIN, { what: 'idle', detail: '', since: now })
975 await update($, timeAtom, t => ({ ...t, idleSince: now }))
976 await pushEvent(
977 $,
978 now,
979 `${e.reason === 'answer' ? '✓' : '✗'} turn ${e.reason} ${fmtMs(e.durationMs)}${u ? ` · in ${fmtTok(inputOf(u))} out ${fmtTok(u.output)}` : ''}`,
980 e.reason === 'answer' ? 'ok' : e.reason === 'aborted' ? 'warn' : 'error',
981 )
982 void fetchBreakdown($).then(() => persist($))
983 }
984 return next(e)
985 })
986
987 // --- agents --------------------------------------------------------------
988
989 on('agent.spawn', async ($, e, next) => {
990 const r = await next(e)
991 if (r.deny === undefined && r.agentId) {
992 const now = await $.clock.now()
993 const agent: DashAgent = {
994 id: r.agentId,
995 type: e.subagentType,
996 description: cut(oneLine(e.description), 80),
997 model: r.model || e.model || e.parentModel,
998 parentId: e.parentAgentId,
999 name: e.name,
1000 status: 'running',
1001 background: e.background,
1002 startedAt: now,
1003 usage: emptyUsage(),
1004 steps: 0,
1005 toolCalls: 0,
1006 via: e.workflow ? 'workflow' : e.isTeammate ? 'teammate' : e.fork ? 'fork' : 'Agent tool',
1007 }
1008 await update($, agentsAtom, list => [...list.filter(a => a.id !== agent.id), agent].slice(-MAX_AGENTS))
1009 await setNow($, agent.id, { what: 'starting', detail: '', since: now })
1010 if (!e.parentAgentId) await bumpTurn($, t => ({ ...t, agents: t.agents + 1 }))
1011 const parent = e.parentAgentId ? loopLabel(e.parentAgentId, await read($, agentsAtom)) : 'main'
1012 await pushEvent($, now, `▶ ${parent} → ${agent.type} (${shortModel(agent.model)}): ${cut(agent.description, 44)}`, 'info')
1013 }
1014 return r
1015 })
1016
1017 // --- tools ---------------------------------------------------------------
1018
1019 on('tool.call', async ($, e, next) => {
1020 const started = await $.clock.now()
1021 const tool = String(e.tool)
1022 const id = e.tool_use_id
1023 const agentId = e.agentId
1024 const input = e as unknown as Record<string, unknown>
1025 const detail = detailOf(input)
1026 const loop = agentId ?? MAIN
1027 if (agentId) await ensureAgent($, agentId, true)
1028 await update($, activeAtom, list => [...list, { id, tool, agentId, startedAt: started, detail }].slice(-MAX_ACTIVE))
1029 await setNow($, loop, { what: 'tool', detail: `${toolLabel(tool)}${detail ? `: ${detail}` : ''}`, since: started })
1030 if (!agentId) await update($, mainAtom, m => ({ ...m, phase: 'tool' as const }))
1031 const skillName = tool === 'Skill' && typeof input.skill === 'string' ? input.skill : undefined
1032 if (skillName) pendingSkills.set(skillName, started)
1033 let isError = false
1034 try {
1035 const r = await next(e)
1036 isError = r.deny !== undefined || r.isError === true
1037 if (!isError) {
1038 try {
1039 await noteFileChanges($, tool, input, r.result, loop)
1040 } catch {
1041 // a result of an unexpected shape counts no lines
1042 }
1043 }
1044 if (skillName) {
1045 const res = (r.result ?? {}) as Record<string, unknown>
1046 const forked = res.status === 'forked' && typeof res.agentId === 'string' ? res.agentId : undefined
1047 const at = await $.clock.now()
1048 const source = pendingSource.get(skillName)
1049 pendingSource.delete(skillName)
1050 await pushSkill($, {
1051 at: started,
1052 name: typeof res.commandName === 'string' && res.commandName ? res.commandName : skillName,
1053 loop,
1054 mode: forked ? 'forked' : 'inline',
1055 args: typeof input.args === 'string' && input.args ? cut(oneLine(input.args), 120) : undefined,
1056 agentId: forked,
1057 source: source?.source,
1058 plugin: source?.plugin,
1059 ok: !isError && res.success !== false,
1060 ms: at - started,
1061 })
1062 if (forked) {
1063 await ensureAgent($, forked, true, `skill:${skillName}`)
1064 await update($, agentsAtom, list => list.map(a => (a.id === forked ? { ...a, via: `skill:${skillName}`, parentId: a.parentId ?? agentId, type: a.type === 'agent' ? `skill:${skillName}` : a.type } : a)))
1065 } else if (!isError) {
1066 await setNow($, loop, { what: 'thinking', detail: '', since: at, skill: skillName })
1067 }
1068 await pushEvent($, at, `★ ${loop === MAIN ? 'main' : loopLabel(loop, await read($, agentsAtom))} loaded skill ${skillName}${forked ? ' (forked)' : ''}`, isError ? 'error' : 'info')
1069 }
1070 return r
1071 } catch (err) {
1072 isError = true
1073 throw err
1074 } finally {
1075 if (skillName) pendingSkills.delete(skillName)
1076 const now = await $.clock.now()
1077 const ms = now - started
1078 await update($, activeAtom, list => list.filter(a => a.id !== id))
1079 const stillRunning = (await read($, activeAtom)).some(a => (a.agentId ?? MAIN) === loop)
1080 if (!stillRunning) await setNow($, loop, { what: 'thinking', detail: '', since: now })
1081 await update($, toolsAtom, map => {
1082 const t = map[tool] ?? { name: tool, calls: 0, errors: 0, totalMs: 0 }
1083 return { ...map, [tool]: { ...t, calls: t.calls + 1, errors: t.errors + (isError ? 1 : 0), totalMs: t.totalMs + ms } }
1084 })
1085 if (agentId) {
1086 await update($, agentsAtom, list => list.map(a => (a.id === agentId ? { ...a, toolCalls: a.toolCalls + 1 } : a)))
1087 } else {
1088 const countsAsTool = tool !== 'Agent'
1089 if (countsAsTool) await update($, timeAtom, t => ({ ...t, toolMs: t.toolMs + ms }))
1090 await bumpTurn($, t => ({ ...t, toolCalls: t.toolCalls + 1, toolMs: t.toolMs + (countsAsTool ? ms : 0) }))
1091 await update($, mainAtom, m => (m.phase === 'tool' ? { ...m, phase: 'model' as const } : m))
1092 }
1093 if (isError) await pushEvent($, now, `✗ ${toolLabel(tool)} failed${detail ? `: ${cut(detail, 40)}` : ''}`, 'error')
1094 }
1095 })
1096
1097 // --- skills ------------------------------------------------------------------
1098
1099 on('skill.prompt', async ($, e, next) => {
1100 const r = await next(e)
1101 try {
1102 const info = { source: String(e.source), plugin: e.pluginName }
1103 if (pendingSkills.has(e.skill)) {
1104 pendingSource.set(e.skill, info)
1105 } else {
1106 const at = await $.clock.now()
1107 await pushSkill($, { at, name: e.skill, loop: MAIN, mode: 'command', source: info.source, plugin: info.plugin, ok: true })
1108 await setNow($, MAIN, { what: 'thinking', detail: '', since: at, skill: e.skill })
1109 await pushEvent($, at, `★ skill ${e.skill} loaded (${info.plugin ? `plugin ${info.plugin}` : info.source})`, 'info')
1110 }
1111 } catch {
1112 // the skill's text goes through whatever the bookkeeping does
1113 }
1114 return r
1115 })
1116
1117 // --- context, cost, rate limits -----------------------------------------
1118
1119 on('session.measure', async ($, e, next) => {
1120 const now = await $.clock.now()
1121 await update($, contextAtom, c => {
1122 const percent = e.context.percent ?? c.percent
1123 return {
1124 ...c,
1125 tokens: e.context.tokens ?? c.tokens,
1126 window: e.context.window || c.window,
1127 percent,
1128 maxPercent: Math.max(c.maxPercent ?? 0, percent ?? 0),
1129 costUsd: e.cost?.usd ?? c.costUsd,
1130 rateLimits: e.rateLimits.map(r => ({ kind: r.kind, percentUsed: r.percentUsed, resetsAt: r.resetsAt })),
1131 measuredAt: now,
1132 }
1133 })
1134 const hot = e.rateLimits.find(r => r.percentUsed >= 90)
1135 if (hot && e.changed.includes('rateLimits')) {
1136 await pushEvent($, now, `rate limit ${hot.kind} at ${hot.percentUsed}%`, 'warn')
1137 }
1138 return next(e)
1139 })
1140
1141 on('session.compact', async ($, e, next) => {
1142 const r = await next(e)
1143 if (e.trigger !== 'precompute') {
1144 const now = await $.clock.now()
1145 if (r.skip === undefined) {
1146 await update($, contextAtom, c => ({
1147 ...c,
1148 compactions: c.compactions + 1,
1149 lastCompaction: { before: r.tokensBefore, after: r.tokensAfter, at: now },
1150 }))
1151 await pushEvent(
1152 $,
1153 now,
1154 `compaction (${e.trigger})${r.tokensBefore !== undefined && r.tokensAfter !== undefined ? ` ${fmtTok(r.tokensBefore)} → ${fmtTok(r.tokensAfter)}` : ''}`,
1155 'warn',
1156 )
1157 } else {
1158 await pushEvent($, now, `compaction skipped: ${cut(r.skip, 50)}`, 'warn')
1159 }
1160 }
1161 return r
1162 })
1163
1164 on('session.end', async ($, e, next) => {
1165 await persist($, true)
1166 if (e.reason === 'clear') {
1167 await clearStats($, false)
1168 await update($, mainAtom, m => ({ ...emptyMain(), sessionModel: m.sessionModel, cwd: m.cwd, repo: m.repo, branch: m.branch }))
1169 }
1170 return next(e)
1171 })
1172
1173 // --- the pane ------------------------------------------------------------
1174
1175 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
1176 const { Box, Text, Button } = $.ui.resolve(e)
1177 const t: ChartTable = e.surface === 'terminal' ? { Box, Text } : { Box, Text, Svg: $.ui.resolve(e).Svg }
1178 const now = await $.clock.now()
1179 const [main, usage, agents, ctx, tools, active, steps, events, view, turns, time, files, skills, nowMap] = await Promise.all([
1180 read($, mainAtom),
1181 read($, usageAtom),
1182 read($, agentsAtom),
1183 read($, contextAtom),
1184 read($, toolsAtom),
1185 read($, activeAtom),
1186 read($, stepsAtom),
1187 read($, eventsAtom),
1188 read($, viewAtom),
1189 read($, turnsAtom),
1190 read($, timeAtom),
1191 read($, filesAtom),
1192 read($, skillsAtom),
1193 read($, nowAtom),
1194 ])
1195 let home = ''
1196 try {
1197 home = (await $.env.get('HOME')) ?? ''
1198 } catch {
1199 home = ''
1200 }hooks/format.ts 142 lines1import type { ModelUsage } from 'claude-code'
2
3import type { DashUsage } from '../types'
4
5export const fmtTok = (n: number): string =>
6 n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n >= 1e3 ? `${(n / 1e3).toFixed(1)}k` : String(Math.round(n))
7
8export const fmtMs = (ms: number): string => {
9 if (!Number.isFinite(ms) || ms < 0) ms = 0
10 if (ms < 1000) return `${Math.round(ms)}ms`
11 if (ms < 60_000) return `${(ms / 1000).toFixed(ms < 10_000 ? 1 : 0)}s`
12 if (ms < 3_600_000) return `${Math.floor(ms / 60_000)}m${String(Math.round((ms % 60_000) / 1000)).padStart(2, '0')}s`
13 return `${Math.floor(ms / 3_600_000)}h${String(Math.floor((ms % 3_600_000) / 60_000)).padStart(2, '0')}m`
14}
15
16export const fmtUsd = (usd: number): string => (usd >= 10 ? `$${usd.toFixed(2)}` : `$${usd.toFixed(3)}`)
17
18const two = (n: number): string => String(n).padStart(2, '0')
19
20export const fmtClock = (at: number): string => {
21 const d = new Date(at)
22 return `${two(d.getHours())}:${two(d.getMinutes())}:${two(d.getSeconds())}`
23}
24
25export const fmtDate = (at: number): string => {
26 const d = new Date(at)
27 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}`
28}
29
30export const fmtDateTime = (at: number): string => `${fmtDate(at)} ${two(new Date(at).getHours())}:${two(new Date(at).getMinutes())}`
31
32/** A file-name stamp: `2026-10-16_0912`. */
33export const fmtStamp = (at: number): string => {
34 const d = new Date(at)
35 return `${fmtDate(at)}_${two(d.getHours())}${two(d.getMinutes())}`
36}
37
38export const untilIso = (iso: string | undefined, now: number): string => {
39 if (!iso) return ''
40 const t = Date.parse(iso)
41 if (!Number.isFinite(t)) return ''
42 return t > now ? `resets in ${fmtMs(t - now)}` : 'reset due'
43}
44
45/** `claude-opus-5-5` → `opus 5.5`, `us.anthropic.claude-haiku-5-5-20260101` → `haiku 5.5`. */
46export const shortModel = (m: string | undefined): string => {
47 if (!m) return '?'
48 let s = m.replace(/^(us|eu|apac)\./, '').replace(/^anthropic\./, '').replace(/^claude-/, '')
49 s = s.replace(/-\d{8}$/, '').replace(/-v\d+(:\d+)?$/, '').replace(/\[1m\]$/, '')
50 const parts = s.match(/^([a-z]+)-(\d+)(?:-(\d+))?(.*)$/)
51 if (parts) s = `${parts[1]} ${parts[2]}${parts[3] ? `.${parts[3]}` : ''}${parts[4] ?? ''}`
52 return s
53}
54
55export const pad = (s: string, n: number): string => (s.length >= n ? s.slice(0, n) : s + ' '.repeat(n - s.length))
56export const padL = (s: string, n: number): string => (s.length >= n ? s : ' '.repeat(n - s.length) + s)
57export const cut = (s: string, n: number): string => (s.length > n ? `${s.slice(0, Math.max(0, n - 1))}…` : s)
58export const clamp = (n: number, lo: number, hi: number): number => Math.max(lo, Math.min(hi, n))
59export const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
60
61export const barOf = (frac: number, width: number): { filled: string; empty: string } => {
62 const f = clamp(Math.round((Number.isFinite(frac) ? frac : 0) * width), 0, width)
63 return { filled: '█'.repeat(f), empty: '░'.repeat(width - f) }
64}
65
66export const pctColor = (p: number): string => (p >= 85 ? 'error' : p >= 65 ? 'warning' : 'success')
67
68export const pct = (part: number, whole: number): number => (whole > 0 ? (part / whole) * 100 : 0)
69
70export const emptyUsage = (): DashUsage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0 })
71
72export const total = (u: DashUsage): number => u.input + u.output + u.cacheRead + u.cacheWrite
73
74export const inputOf = (u: DashUsage): number => u.input + u.cacheRead + u.cacheWrite
75
76export const cacheHit = (u: DashUsage): number => {
77 const inp = inputOf(u)
78 return inp > 0 ? (u.cacheRead / inp) * 100 : 0
79}
80
81export const addUsage = (u: DashUsage, m: ModelUsage): DashUsage => ({
82 input: u.input + (m.input_tokens ?? 0),
83 output: u.output + (m.output_tokens ?? 0),
84 cacheRead: u.cacheRead + (m.cache_read_input_tokens ?? 0),
85 cacheWrite: u.cacheWrite + (m.cache_creation_input_tokens ?? 0),
86 calls: u.calls + 1,
87})
88
89export const fromModelUsage = (m: ModelUsage): DashUsage => addUsage(emptyUsage(), m)
90
91export const addUsageInto = (acc: DashUsage, u: DashUsage): DashUsage => ({
92 input: acc.input + u.input,
93 output: acc.output + u.output,
94 cacheRead: acc.cacheRead + u.cacheRead,
95 cacheWrite: acc.cacheWrite + u.cacheWrite,
96 calls: acc.calls + u.calls,
97})
98
99export const isDoneStatus = (s: string): boolean => s === 'completed' || s === 'failed' || s === 'killed'
100
101export const statusGlyph = (s: string): { glyph: string; color: string } => {
102 switch (s) {
103 case 'running':
104 return { glyph: '●', color: 'claude' }
105 case 'pending':
106 return { glyph: '…', color: 'subtle' }
107 case 'waiting':
108 return { glyph: '◔', color: 'warning' }
109 case 'idle':
110 return { glyph: '◌', color: 'subtle' }
111 case 'completed':
112 return { glyph: '✓', color: 'success' }
113 case 'failed':
114 case 'killed':
115 return { glyph: '✗', color: 'error' }
116 default:
117 return { glyph: '?', color: 'subtle' }
118 }
119}
120
121/** A short, human line about a tool call's arguments, for the active list. */
122export const detailOf = (e: Record<string, unknown>): string => {
123 const pick = (k: string): string | undefined => (typeof e[k] === 'string' ? (e[k] as string) : undefined)
124 const s =
125 pick('description') ??
126 pick('command') ??
127 pick('file_path') ??
128 pick('pattern') ??
129 pick('query') ??
130 pick('url') ??
131 pick('skill') ??
132 pick('prompt') ??
133 ''
134 return oneLine(s).slice(0, 80)
135}
136
137export const escapeHtml = (s: string): string =>
138 s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
139
140/** A tool's display name: `mcp__server__tool` → `server:tool`. */
141export const toolLabel = (name: string): string => (name.startsWith('mcp__') ? name.slice(5).replace(/__/g, ':') : name)
142hooks/activity.ts 203 lines1import type { DashAgent, DashFile, DashNow } from '../types'
2import { cut, fmtMs, toolLabel } from './format'
3
4// ---------------------------------------------------------------------------
5// File changes: what a tool's result says it changed
6// ---------------------------------------------------------------------------
7
8type Hunk = { readonly lines?: readonly string[] }
9
10/** Added and removed lines of unified-diff hunks (`+x`, `-x`; context and markers ignored). */
11export const countHunks = (hunks: readonly Hunk[] | undefined): { added: number; removed: number } => {
12 let added = 0
13 let removed = 0
14 for (const h of hunks ?? []) {
15 for (const l of h.lines ?? []) {
16 if (l.startsWith('+')) added += 1
17 else if (l.startsWith('-')) removed += 1
18 }
19 }
20 return { added, removed }
21}
22
23/** Lines of a text: `''` is 0, a trailing newline does not open a line. */
24export const lineCount = (text: string | null | undefined): number => {
25 if (!text) return 0
26 const n = text.split('\n').length
27 return text.endsWith('\n') ? n - 1 : n
28}
29
30export type FileChange = {
31 path: string
32 added: number
33 removed: number
34 baseLines?: number
35 created?: boolean
36 deleted?: boolean
37}
38
39const obj = (v: unknown): Record<string, unknown> => (v && typeof v === 'object' ? (v as Record<string, unknown>) : {})
40const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
41const num = (v: unknown): number | undefined => (typeof v === 'number' && Number.isFinite(v) ? v : undefined)
42
43const fromGitDiff = (r: Record<string, unknown>): { added: number; removed: number } | undefined => {
44 const g = obj(r.gitDiff)
45 const a = num(g.additions)
46 const d = num(g.deletions)
47 return a !== undefined && d !== undefined ? { added: a, removed: d } : undefined
48}
49
50/**
51 * The files a finished tool call changed, read off its input and its result:
52 * Edit, Write and NotebookEdit by their own records, Bash by the engine's
53 * working-tree diff of the command (when it has one). A staged or failed
54 * write changed nothing.
55 */
56export const extractChanges = (tool: string, input: Record<string, unknown>, result: unknown): FileChange[] => {
57 const r = obj(result)
58 if (r.staged === true) return []
59 if (tool === 'Edit') {
60 const path = str(r.filePath) ?? str(input.file_path)
61 if (!path) return []
62 const patch = Array.isArray(r.structuredPatch) ? (r.structuredPatch as Hunk[]) : []
63 let c = countHunks(patch)
64 if (c.added === 0 && c.removed === 0) c = fromGitDiff(r) ?? { added: lineCount(str(input.new_string)), removed: lineCount(str(input.old_string)) }
65 const original = r.originalFile
66 return [{ path, ...c, baseLines: typeof original === 'string' ? lineCount(original) : undefined }]
67 }
68 if (tool === 'Write') {
69 const path = str(r.filePath) ?? str(input.file_path)
70 if (!path) return []
71 const content = str(r.content) ?? str(input.content) ?? ''
72 if (r.type === 'create') return [{ path, added: lineCount(content), removed: 0, baseLines: 0, created: true }]
73 const patch = Array.isArray(r.structuredPatch) ? (r.structuredPatch as Hunk[]) : []
74 let c = countHunks(patch)
75 const original = r.originalFile
76 if (c.added === 0 && c.removed === 0) c = fromGitDiff(r) ?? (typeof original === 'string' ? { added: lineCount(content), removed: lineCount(original) } : c)
77 return [{ path, ...c, baseLines: typeof original === 'string' ? lineCount(original) : undefined }]
78 }
79 if (tool === 'NotebookEdit') {
80 const path = str(r.notebook_path) ?? str(input.notebook_path)
81 if (!path || str(r.error)) return []
82 const mode = str(r.edit_mode) ?? str(input.edit_mode) ?? 'replace'
83 const added = mode === 'delete' ? 0 : lineCount(str(r.new_source) ?? str(input.new_source))
84 const removed = mode === 'insert' ? 0 : lineCount(str(r.old_source))
85 return [{ path, added, removed }]
86 }
87 if (tool === 'Bash') {
88 const d = obj(r.bashEditDiff)
89 if (!Array.isArray(d.files)) return []
90 return (d.files as unknown[]).flatMap(f => {
91 const o = obj(f)
92 const path = str(o.filePath)
93 if (!path) return []
94 const c = countHunks(Array.isArray(o.hunks) ? (o.hunks as Hunk[]) : [])
95 return [{ path, ...c, created: o.created === true, deleted: o.deleted === true, baseLines: o.created === true ? 0 : undefined }]
96 })
97 }
98 return []
99}
100
101/** Folds one change into the file's running total. */
102export const mergeFile = (prev: DashFile | undefined, ch: FileChange, loop: string, tool: string, at: number): DashFile => {
103 const added = (prev?.added ?? 0) + ch.added
104 const removed = (prev?.removed ?? 0) + ch.removed
105 const baseLines = prev ? prev.baseLines : ch.baseLines
106 const before = prev?.lines ?? ch.baseLines
107 const lines = ch.deleted ? 0 : before !== undefined ? Math.max(0, before + ch.added - ch.removed) : undefined
108 return {
109 path: ch.path,
110 added,
111 removed,
112 edits: (prev?.edits ?? 0) + 1,
113 baseLines,
114 lines,
115 created: prev ? prev.created : ch.created === true,
116 deleted: ch.deleted === true ? true : prev?.deleted === true && !ch.created ? true : false,
117 tools: prev?.tools.includes(tool) ? prev.tools : [...(prev?.tools ?? []), tool],
118 loops: prev?.loops.includes(loop) ? prev.loops : [...(prev?.loops ?? []), loop],
119 firstAt: prev?.firstAt ?? at,
120 lastAt: at,
121 }
122}
123
124/** Changed lines ≈ max(+, −): a modified line is one − and one +. */
125export const changedLines = (f: DashFile): number => Math.max(f.added, f.removed)
126
127/** Share of the file's original lines the session changed; `undefined` for a new file or an unknown size. */
128export const filePct = (f: DashFile): number | undefined => {
129 if (f.created || !f.baseLines) return undefined
130 return (changedLines(f) / f.baseLines) * 100
131}
132
133export const fmtFilePct = (f: DashFile): string => {
134 if (f.deleted) return 'deleted'
135 if (f.created) return 'new'
136 const p = filePct(f)
137 if (p === undefined) return '—'
138 if (p > 0 && p < 1) return '<1%'
139 return p > 100 ? '>100%' : `${String(Math.round(p))}%`
140}
141
142/** A path for display: under the session's folder relative, under home as `~/…`. */
143export const relPath = (path: string, cwd: string, home: string): string => {
144 const c = cwd.replace(/\/+$/, '')
145 if (c && path.startsWith(`${c}/`)) return path.slice(c.length + 1)
146 const h = home.replace(/\/+$/, '')
147 if (h && path.startsWith(`${h}/`)) return `~${path.slice(h.length)}`
148 return path
149}
150
151// ---------------------------------------------------------------------------
152// Loops: who is who, and what each is doing
153// ---------------------------------------------------------------------------
154
155export const MAIN = 'main'
156
157export const agentLabel = (a: DashAgent | undefined): string => (a ? (a.name ? `${a.type} "${a.name}"` : a.type) : 'agent')
158
159/** `main`, or the agent's type (and name) for an agent id. */
160export const loopLabel = (loop: string, agents: readonly DashAgent[]): string =>
161 loop === MAIN ? 'main' : agentLabel(agents.find(a => a.id === loop))
162
163const NOW_TEXT: Record<DashNow['what'], string> = {
164 starting: 'starting',
165 thinking: 'thinking',
166 answering: 'writing the answer',
167 preparing: 'preparing a call',
168 tool: 'running',
169 idle: 'idle',
170}
171
172export type ActiveCall = { tool: string; detail: string; startedAt: number; agentId?: string }
173
174/**
175 * One line on what a loop is doing: its running tool calls first (the Agent
176 * tool read as waiting for the agents it started), else what its stream says.
177 */
178export const describeNow = (
179 loop: string,
180 now: DashNow | undefined,
181 active: readonly ActiveCall[],
182 agents: readonly DashAgent[],
183 at: number,
184 live: (a: DashAgent) => boolean,
185): { text: string; since?: number } => {
186 const mine = active.filter(x => (x.agentId ?? MAIN) === loop)
187 if (mine.length) {
188 const first = mine[0]!
189 const waits = first.tool === 'Agent'
190 ? agents.filter(a => (a.parentId ?? MAIN) === loop && live(a) && !a.background).map(a => agentLabel(a))
191 : []
192 const head = waits.length ? `waiting for ${waits.join(', ')}` : `${toolLabel(first.tool)}${first.detail ? `: ${first.detail}` : ''}`
193 return { text: `${head}${mine.length > 1 ? ` (+${String(mine.length - 1)} more)` : ''}`, since: first.startedAt }
194 }
195 if (!now) return { text: loop === MAIN ? 'idle' : 'starting' }
196 const base = NOW_TEXT[now.what]
197 return { text: now.what === 'preparing' && now.detail ? `preparing ${toolLabel(now.detail)}` : base, since: now.since }
198}
199
200export const sinceText = (since: number | undefined, at: number): string => (since === undefined ? '' : fmtMs(Math.max(0, at - since)))
201
202export const skillBadge = (name: string | undefined): string => (name ? `skill ${cut(name, 28)}` : '')
203hooks/charts.tsx 258 lines1import type { Elements, RenderNode } from 'claude-code'
2
3import type { DashChartMode } from '../types'
4import { clamp, cut, pad, padL } from './format'
5
6// ---------------------------------------------------------------------------
7// Palette: the dataviz reference palette, validated for adjacent pairs in both
8// modes. Hues are assigned in fixed slot order and never cycled.
9// ---------------------------------------------------------------------------
10
11export const LIGHT = ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'] as const
12export const DARK = ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767'] as const
13const TRACK = { light: '#e6e5e1', dark: '#383835' }
14const INK = { light: '#0b0b0b', dark: '#ffffff' }
15const INK2 = { light: '#52514e', dark: '#c3c2b7' }
16
17export const paletteOf = (dark: boolean): readonly string[] => (dark ? DARK : LIGHT)
18
19/** One item of a share chart: what it is, how much, how to print it, which fixed palette slot it wears. */
20export type Slice = { label: string; value: number; text: string; slot: number }
21
22export type ChartTable = {
23 Box: Elements['terminal']['Box']
24 Text: Elements['terminal']['Text']
25 Svg?: Elements['desktop']['Svg']
26}
27
28export type ChartOptions = {
29 /** Columns the chart may take. */
30 width: number
31 dark: boolean
32 /** What the donut's centre says (the total). */
33 center?: string
34 /** Rows beyond this fold into "other". */
35 maxRows?: number
36 /** A line under the legend (a caveat, a note). */
37 note?: string
38}
39
40const fold = (slices: Slice[], maxRows: number): Slice[] => {
41 if (slices.length <= maxRows) return slices
42 const head = slices.slice(0, maxRows - 1)
43 const rest = slices.slice(maxRows - 1)
44 const value = rest.reduce((n, s) => n + s.value, 0)
45 return [...head, { label: `other (${rest.length})`, value, text: rest[0]?.text ? '' : '', slot: 7 }]
46}
47
48const pctOf = (v: number, total: number): string => `${padL(String(Math.round(total > 0 ? (v / total) * 100 : 0)), 3)}%`
49
50// ---------------------------------------------------------------------------
51// Text pieces (every surface)
52// ---------------------------------------------------------------------------
53
54/** A text bar: `filled` colored cells of `width`, the rest dim track. */
55export const textBar = (t: ChartTable, frac: number, width: number, color: string): RenderNode => {
56 const { Text } = t
57 const f = clamp(Math.round((Number.isFinite(frac) ? frac : 0) * width), 0, width)
58 return (
59 <Text>
60 <Text color={color}>{'█'.repeat(f)}</Text>
61 <Text dimColor>{'░'.repeat(width - f)}</Text>
62 </Text>
63 )
64}
65
66/** One stacked text bar of `width` cells, each slice its palette color, the remainder dim. */
67const textStacked = (t: ChartTable, slices: Slice[], total: number, width: number, palette: readonly string[]): RenderNode => {
68 const { Text } = t
69 const parts: RenderNode[] = []
70 let used = 0
71 slices.forEach((s, i) => {
72 const isLast = i === slices.length - 1
73 const cells = isLast ? clamp(Math.round((slices.reduce((n, x) => n + x.value, 0) / Math.max(1, total)) * width) - used, 0, width - used) : clamp(Math.round((s.value / Math.max(1, total)) * width), 0, width - used)
74 if (cells > 0) parts.push(<Text color={palette[s.slot % palette.length]}>{'█'.repeat(cells)}</Text>)
75 used += cells
76 })
77 if (used < width) parts.push(<Text dimColor>{'░'.repeat(width - used)}</Text>)
78 return <Text>{parts}</Text>
79}
80
81/** `■ label value 42%` on a Box grid, so columns line up on every surface. */
82const legendRow = (t: ChartTable, s: Slice, total: number, labelW: number, valueW: number, color: string, bar?: RenderNode, barW?: number): RenderNode => {
83 const { Box, Text } = t
84 return (
85 <Box flexDirection="row" gap={1}>
86 <Box width={1}>
87 <Text color={color}>■</Text>
88 </Box>
89 <Box width={labelW}>
90 <Text wrap="truncate-end">{cut(s.label, labelW)}</Text>
91 </Box>
92 {bar !== undefined && barW !== undefined ? <Box width={barW}>{bar}</Box> : <Box width={0}> </Box>}
93 <Box width={valueW} justifyContent="flex-end">
94 <Text dimColor>{padL(cut(s.text, valueW), valueW)}</Text>
95 </Box>
96 <Box width={4} justifyContent="flex-end">
97 <Text dimColor>{pctOf(s.value, total)}</Text>
98 </Box>
99 </Box>
100 )
101}
102
103// ---------------------------------------------------------------------------
104// SVG pieces (desktop, vscode, mobile)
105// ---------------------------------------------------------------------------
106
107const svgStacked = (Svg: NonNullable<ChartTable['Svg']>, slices: Slice[], total: number, palette: readonly string[], dark: boolean, alt: string): RenderNode => {
108 const W = 600
109 const H = 16
110 const gap = 2
111 let x = 0
112 const rects: string[] = []
113 const sum = Math.max(total, slices.reduce((n, s) => n + s.value, 0), 1)
114 for (const s of slices) {
115 const w = (s.value / sum) * W
116 if (w <= 0) continue
117 const draw = Math.max(0, Math.min(w, W - x) - gap)
118 if (draw > 0) rects.push(`<rect x="${x.toFixed(1)}" y="0" width="${draw.toFixed(1)}" height="${H}" rx="3" fill="${palette[s.slot % palette.length]}"/>`)
119 x += w
120 }
121 const track = dark ? TRACK.dark : TRACK.light
122 const source = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}"><rect x="0" y="0" width="${W}" height="${H}" rx="3" fill="${track}"/>${rects.join('')}</svg>`
123 return <Svg source={source} alt={alt} height={H} />
124}
125
126const svgDonut = (Svg: NonNullable<ChartTable['Svg']>, slices: Slice[], total: number, palette: readonly string[], dark: boolean, center: string, alt: string): RenderNode => {
127 const size = 132
128 const r = 50
129 const stroke = 18
130 const c = size / 2
131 const circ = 2 * Math.PI * r
132 const sum = Math.max(total, slices.reduce((n, s) => n + s.value, 0), 1)
133 const gapFrac = slices.filter(s => s.value > 0).length > 1 ? 0.012 : 0
134 let offset = 0
135 const arcs: string[] = []
136 for (const s of slices) {
137 const frac = s.value / sum
138 if (frac <= 0) continue
139 const len = Math.max(0, (frac - gapFrac) * circ)
140 arcs.push(
141 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${palette[s.slot % palette.length]}" stroke-width="${stroke}" stroke-dasharray="${len.toFixed(2)} ${(circ - len).toFixed(2)}" stroke-dashoffset="${(-offset * circ).toFixed(2)}" transform="rotate(-90 ${c} ${c})"/>`,
142 )
143 offset += frac
144 }
145 const track = dark ? TRACK.dark : TRACK.light
146 const ink = dark ? INK.dark : INK.light
147 const ink2 = dark ? INK2.dark : INK2.light
148 const label = center.replace(/[<>&"]/g, '')
149 const source =
150 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="${size}" height="${size}">` +
151 `<circle cx="${c}" cy="${c}" r="${r}" fill="none" stroke="${track}" stroke-width="${stroke}"/>` +
152 arcs.join('') +
153 `<text x="${c}" y="${c + 1}" text-anchor="middle" dominant-baseline="middle" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="15" font-weight="600" fill="${ink}">${label}</text>` +
154 `<text x="${c}" y="${c + 17}" text-anchor="middle" dominant-baseline="middle" font-family="-apple-system,Segoe UI,Helvetica,Arial,sans-serif" font-size="9" fill="${ink2}">total</text>` +
155 `</svg>`
156 return <Svg source={source} alt={alt} width={size} height={size} />
157}
158
159// ---------------------------------------------------------------------------
160// The chart: one call, three modes
161// ---------------------------------------------------------------------------
162
163/**
164 * Draws `slices` as a share of `total` in the mode asked: a bar per row, one
165 * stacked bar over a legend, or a donut beside a legend. Legend rows carry the
166 * marker, label, value and percent on a Box grid; text stands in for SVG where
167 * the surface has none.
168 */
169export const shareChart = (t: ChartTable, mode: DashChartMode, input: Slice[], total: number, o: ChartOptions): RenderNode => {
170 const { Box, Text } = t
171 const palette = paletteOf(o.dark)
172 const slices = fold(input.filter(s => s.value > 0 || input.length <= 2), o.maxRows ?? 7)
173 const sum = Math.max(total, 1)
174 const valueW = clamp(Math.max(5, ...slices.map(s => s.text.length)), 5, 12)
175 const labelW = clamp(Math.max(6, ...slices.map(s => s.label.length)), 6, 18)
176 const fixed = 1 + labelW + valueW + 4 + 4
177 const barW = clamp(o.width - fixed - 1, 6, 40)
178 const alt = slices.map(s => `${s.label} ${s.text} ${Math.round((s.value / sum) * 100)}%`).join(', ')
179
180 if (!slices.length) {
181 return <Text dimColor>nothing yet</Text>
182 }
183
184 const legend = (withBars: boolean): RenderNode[] =>
185 slices.map(s => {
186 const color = palette[s.slot % palette.length] ?? '#888888'
187 return withBars ? legendRow(t, s, sum, labelW, valueW, color, textBar(t, s.value / sum, barW, color), barW) : legendRow(t, s, sum, labelW, valueW, color)
188 })
189
190 const note = o.note ? <Text dimColor>{cut(o.note, o.width)}</Text> : undefined
191
192 if (mode === 'bars') {
193 return (
194 <Box flexDirection="column">
195 {legend(true)}
196 {note}
197 </Box>
198 )
199 }
200
201 if (mode === 'stacked') {
202 const bar = t.Svg ? svgStacked(t.Svg, slices, sum, palette, o.dark, alt) : textStacked(t, slices, sum, clamp(o.width - 2, 10, 80), palette)
203 return (
204 <Box flexDirection="column">
205 <Box marginBottom={t.Svg ? 0 : 0}>{bar}</Box>
206 {legend(false)}
207 {note}
208 </Box>
209 )
210 }
211
212 // donut
213 if (t.Svg) {
214 return (
215 <Box flexDirection="column">
216 <Box flexDirection="row" gap={2} alignItems="center">
217 <Box width={18}>{svgDonut(t.Svg, slices, sum, palette, o.dark, o.center ?? '', alt)}</Box>
218 <Box flexDirection="column" flexGrow={1}>
219 {legend(false)}
220 </Box>
221 </Box>
222 {note}
223 </Box>
224 )
225 }
226 return (
227 <Box flexDirection="column">
228 <Text>
229 <Text bold>{o.center ?? ''}</Text>
230 <Text dimColor>{o.center ? ' total · ' : ''}donut needs a graphical surface; showing a stacked bar</Text>
231 </Text>
232 {textStacked(t, slices, sum, clamp(o.width - 2, 10, 80), palette)}
233 {legend(false)}
234 {note}
235 </Box>
236 )
237}
238
239/** A meter: `label ████░░ value` for one ratio against a limit, colored by threshold. */
240export const meterRow = (t: ChartTable, label: string, frac: number, color: string, value: string, labelW: number, barW: number): RenderNode => {
241 const { Box, Text } = t
242 return (
243 <Box flexDirection="row" gap={1}>
244 <Box width={labelW}>
245 <Text wrap="truncate-end">{pad(cut(label, labelW), labelW)}</Text>
246 </Box>
247 <Box width={barW}>{textBar(t, frac, barW, color)}</Box>
248 <Box flexGrow={1}>
249 <Text dimColor wrap="truncate-end">
250 {value}
251 </Text>
252 </Box>
253 </Box>
254 )
255}
256
257export const nextChartMode = (m: DashChartMode): DashChartMode => (m === 'bars' ? 'stacked' : m === 'stacked' ? 'donut' : 'bars')
258hooks/paths.ts 35 lines1/** `~` and `~/x` → the home directory; a relative path → under `cwd`; absolute stays. */
2export const expandPath = (p: string, home: string, cwd: string): string => {
3 const s = p.trim().replace(/\/+$/, '')
4 if (s === '~') return home
5 if (s.startsWith('~/')) return `${home}${s.slice(1)}`
6 if (s.startsWith('/')) return s
7 return `${cwd.replace(/\/+$/, '')}/${s.replace(/^\.\//, '')}`
8}
9
10/** Whether a command argument names a folder rather than a kind or a session id. */
11export const isPathLike = (s: string): boolean => s === '~' || s.startsWith('~/') || s.startsWith('/') || s.startsWith('./') || s.startsWith('../') || s.endsWith('/')
12
13export type ReportKind = 'pdf' | 'html' | 'md'
14
15export type ReportArgs = { kind: ReportKind; target: string; dir?: string }
16
17/** `report [md|pdf|html] [all|<id>] [<folder>]` in any order; markdown unless a kind is named. */
18export const parseReportArgs = (rest: readonly string[]): ReportArgs => {
19 const out: ReportArgs = { kind: 'md', target: '' }
20 for (const r of rest) {
21 const low = r.toLowerCase()
22 if (low === 'pdf' || low === 'html' || low === 'md') out.kind = low
23 else if (isPathLike(r)) out.dir = r
24 else out.target = r
25 }
26 return out
27}
28
29/** Shortens a path for a dialog label: the home directory as `~`, long middles elided. */
30export const labelPath = (p: string, home: string, max = 48): string => {
31 let s = home && p.startsWith(home) ? `~${p.slice(home.length)}` : p
32 if (s.length > max) s = `${s.slice(0, Math.floor(max / 2) - 1)}…${s.slice(-(Math.ceil(max / 2) - 1))}`
33 return s
34}
35hooks/report.ts 494 lines1import type { DashAgent, DashContext, DashEvent, DashFile, DashMain, DashModelUsage, DashSkill, DashTime, DashTool, DashTurn, DashUsage, SessionRecord } from '../types'
2import { changedLines, fmtFilePct, loopLabel, relPath } from './activity'
3import {
4 addUsageInto,
5 cacheHit,
6 cut,
7 emptyUsage,
8 escapeHtml,
9 fmtDateTime,
10 fmtMs,
11 fmtTok,
12 fmtUsd,
13 inputOf,
14 isDoneStatus,
15 oneLine,
16 pct,
17 shortModel,
18 toolLabel,
19 total,
20} from './format'
21
22// ---------------------------------------------------------------------------
23// The record: one chat's figures, as written to disk
24// ---------------------------------------------------------------------------
25
26export type Snapshot = {
27 main: DashMain
28 usage: DashModelUsage
29 agents: DashAgent[]
30 ctx: DashContext
31 tools: Record<string, DashTool>
32 turns: DashTurn[]
33 time: DashTime
34 events: DashEvent[]
35 files?: Record<string, DashFile>
36 skills?: DashSkill[]
37 now: number
38 ended?: boolean
39}
40
41export const buildRecord = (s: Snapshot): SessionRecord => {
42 const totals = Object.values(s.usage).reduce((acc, u) => addUsageInto(acc, u), emptyUsage())
43 const activeMs = s.turns.reduce((n, t) => n + (t.ms ?? (t.endedAt === undefined ? s.now - t.startedAt : 0)), 0)
44 return {
45 version: 1,
46 id: s.main.sessionId || 'unknown',
47 title: s.main.title || '(untitled)',
48 cwd: s.main.cwd,
49 repo: s.main.repo,
50 branch: s.main.branch,
51 sessionModel: s.main.sessionModel,
52 startedAt: s.main.startedAt || s.now,
53 updatedAt: s.now,
54 endedAt: s.ended ? s.now : undefined,
55 wallMs: Math.max(0, s.now - (s.main.startedAt || s.now)),
56 activeMs,
57 time: { modelMs: s.time.modelMs, toolMs: s.time.toolMs, agentMs: s.time.agentMs, waitMs: s.time.waitMs },
58 turns: s.turns,
59 usage: s.usage,
60 totals,
61 costUsd: s.ctx.costUsd,
62 tools: Object.values(s.tools).sort((a, b) => b.calls - a.calls),
63 agents: s.agents,
64 compactions: s.ctx.compactions,
65 maxContextPercent: s.ctx.maxPercent,
66 events: s.events,
67 files: Object.values(s.files ?? {}).sort((a, b) => changedLines(b) - changedLines(a)),
68 skills: s.skills ?? [],
69 }
70}
71
72/** Reads a stored record back; undefined for a file that is not one. */
73export const parseRecord = (text: string): SessionRecord | undefined => {
74 try {
75 const v = JSON.parse(text) as Partial<SessionRecord>
76 if (!v || v.version !== 1 || typeof v.id !== 'string' || typeof v.startedAt !== 'number') return undefined
77 return v as SessionRecord
78 } catch {
79 return undefined
80 }
81}
82
83// ---------------------------------------------------------------------------
84// Derived figures
85// ---------------------------------------------------------------------------
86
87type TimeRow = { label: string; ms: number; note: string }
88
89/** Wall time of top-level subagents the main loop waited for (foreground), which no other phase counts. */
90const foregroundAgentMs = (r: SessionRecord): number =>
91 r.agents.filter(a => !a.background && !a.parentId).reduce((n, a) => n + Math.max(0, (a.endedAt ?? r.updatedAt) - a.startedAt), 0)
92
93const timeRows = (r: SessionRecord): TimeRow[] => {
94 const other = Math.max(0, r.wallMs - r.time.modelMs - r.time.toolMs - r.time.waitMs - foregroundAgentMs(r))
95 return [
96 { label: 'model (main loop)', ms: r.time.modelMs, note: 'requests to the model: thinking and answering' },
97 { label: 'tools (main loop)', ms: r.time.toolMs, note: 'Bash, Read, Edit, MCP and the rest; the Agent tool excluded' },
98 { label: 'subagents', ms: r.time.agentMs, note: 'wall time of subagents, spawn to completion (overlaps the above when run in the background)' },
99 { label: 'waiting for you', ms: r.time.waitMs, note: 'idle between a turn’s end and the next prompt' },
100 { label: 'other', ms: other, note: 'hooks, permission prompts, compaction, engine overhead' },
101 ]
102}
103
104type AgentTypeRow = { type: string; count: number; tokens: number; ms: number; failed: number }
105
106const agentTypes = (r: SessionRecord): AgentTypeRow[] => {
107 const m = new Map<string, AgentTypeRow>()
108 for (const a of r.agents) {
109 const row = m.get(a.type) ?? { type: a.type, count: 0, tokens: 0, ms: 0, failed: 0 }
110 row.count += 1
111 row.tokens += total(a.usage)
112 row.ms += (a.endedAt ?? r.updatedAt) - a.startedAt
113 row.failed += a.status === 'failed' || a.status === 'killed' ? 1 : 0
114 m.set(a.type, row)
115 }
116 return [...m.values()].sort((a, b) => b.tokens - a.tokens)
117}
118
119const modelRows = (usage: DashModelUsage): Array<[string, DashUsage]> =>
120 Object.entries(usage).sort((a, b) => total(b[1]) - total(a[1]))
121
122const homeOf = (cwd: string): string => {
123 const m = cwd.match(/^(\/Users\/[^/]+|\/home\/[^/]+)/)
124 return m ? m[1]! : ''
125}
126
127const fileRows = (r: SessionRecord): string[][] =>
128 (r.files ?? []).map(f => [relPath(f.path, r.cwd, homeOf(r.cwd)), `+${String(f.added)}`, `−${String(f.removed)}`, fmtFilePct(f), String(f.edits), f.loops.map(l => loopLabel(l, r.agents)).join(', '), f.tools.join(', ')])
129
130const skillRows = (r: SessionRecord): string[][] =>
131 (r.skills ?? []).map(k => [fmtDateTime(k.at).slice(11), k.name, loopLabel(k.loop, r.agents), k.mode, k.plugin ? `plugin ${k.plugin}` : k.source ?? '—', k.ok ? 'ok' : 'failed', cut(k.args ?? '', 50)])
132
133const callerOf = (a: DashAgent, agents: readonly DashAgent[]): string => (a.parentId ? loopLabel(a.parentId, agents) : 'main')
134
135const longestTurn = (r: SessionRecord): DashTurn | undefined =>
136 r.turns.reduce<DashTurn | undefined>((best, t) => ((t.ms ?? 0) > (best?.ms ?? -1) ? t : best), undefined)
137
138// ---------------------------------------------------------------------------
139// Markdown
140// ---------------------------------------------------------------------------
141
142const mdTable = (head: string[], rows: string[][]): string => {
143 const line = (cells: string[]) => `| ${cells.map(c => c.replace(/\|/g, '\\|')).join(' | ')} |`
144 return [line(head), `| ${head.map(() => '---').join(' | ')} |`, ...rows.map(line)].join('\n')
145}
146
147/** The compact summary a `/activity-dashboard summary` prints into the chat. */
148export const chatSummary = (r: SessionRecord): string => {
149 const t = r.totals
150 const lines: string[] = []
151 lines.push(`**Activity summary — ${r.title}**`)
152 lines.push(`session ${r.id.slice(0, 8)} · ${fmtDateTime(r.startedAt)} → ${fmtDateTime(r.updatedAt)} · ${r.branch ? `${r.repo ?? ''}@${r.branch}` : r.cwd}`)
153 lines.push('')
154 lines.push(`**TIME** wall ${fmtMs(r.wallMs)} · active ${fmtMs(r.activeMs)} · turns ${r.turns.length}`)
155 for (const row of timeRows(r)) lines.push(`- ${row.label}: ${fmtMs(row.ms)} (${Math.round(pct(row.ms, r.wallMs))}%)`)
156 lines.push('')
157 lines.push(`**TOKENS** ${fmtTok(total(t))} total · in ${fmtTok(inputOf(t))} (uncached ${fmtTok(t.input)}, cache read ${fmtTok(t.cacheRead)}, cache write ${fmtTok(t.cacheWrite)}) · out ${fmtTok(t.output)} · ${t.calls} calls · cache hit ${Math.round(cacheHit(t))}%`)
158 for (const [name, u] of modelRows(r.usage)) {
159 lines.push(`- ${shortModel(name)}: ${Math.round(pct(total(u), total(t)))}% · ${u.calls} calls · in ${fmtTok(inputOf(u))} out ${fmtTok(u.output)}`)
160 }
161 lines.push('')
162 lines.push(`**COST** ${r.costUsd !== undefined ? fmtUsd(r.costUsd) : 'n/a'}${r.costUsd !== undefined && r.turns.length ? ` · ${fmtUsd(r.costUsd / r.turns.length)}/turn` : ''}`)
163 const at = agentTypes(r)
164 if (at.length) {
165 lines.push('')
166 lines.push(`**AGENTS** ${r.agents.length}`)
167 for (const a of at) lines.push(`- ${a.type}: ×${a.count} · ${fmtTok(a.tokens)} tok · ${fmtMs(a.ms)}${a.failed ? ` · ${a.failed} failed` : ''}`)
168 }
169 if (r.tools.length) {
170 lines.push('')
171 lines.push(`**TOOLS** ${r.tools.reduce((n, x) => n + x.calls, 0)} calls`)
172 for (const x of r.tools.slice(0, 6)) lines.push(`- ${toolLabel(x.name)}: ${x.calls}${x.errors ? ` (${x.errors} failed)` : ''} · ${fmtMs(x.totalMs)}`)
173 }
174 const files = r.files ?? []
175 if (files.length) {
176 lines.push('')
177 lines.push(`**FILES CHANGED** ${files.length} · +${files.reduce((n, f) => n + f.added, 0)} −${files.reduce((n, f) => n + f.removed, 0)}`)
178 for (const row of fileRows(r).slice(0, 10)) lines.push(`- \`${row[0]}\` ${row[1]} ${row[2]} (${row[3]} of file) · ${row[4]} edits · ${row[5]}`)
179 if (files.length > 10) lines.push(`- … ${files.length - 10} more`)
180 }
181 const skills = r.skills ?? []
182 if (skills.length) {
183 const counts = new Map<string, number>()
184 for (const k of skills) counts.set(k.name, (counts.get(k.name) ?? 0) + 1)
185 lines.push('')
186 lines.push(`**SKILLS** ${[...counts.entries()].map(([n, c]) => `${n} ×${c}`).join(' · ')}`)
187 }
188 const lt = longestTurn(r)
189 if (lt) {
190 lines.push('')
191 lines.push(`**LONGEST TURN** #${lt.index} ${fmtMs(lt.ms ?? 0)}: ${cut(lt.prompt, 80)}`)
192 }
193 if (r.compactions) lines.push(`compactions: ${r.compactions}`)
194 return lines.join('\n')
195}
196
197export const recordToMarkdown = (r: SessionRecord): string => {
198 const t = r.totals
199 const out: string[] = []
200 out.push(`# Activity report: ${r.title}`)
201 out.push('')
202 out.push(mdTable(
203 ['Field', 'Value'],
204 [
205 ['Session', r.id],
206 ['Started', fmtDateTime(r.startedAt)],
207 ['Last activity', fmtDateTime(r.updatedAt)],
208 ['Project', r.repo ?? r.cwd],
209 ['Branch', r.branch ?? '—'],
210 ['Session model', shortModel(r.sessionModel)],
211 ['Wall time', fmtMs(r.wallMs)],
212 ['Active time (turns)', fmtMs(r.activeMs)],
213 ['Turns', String(r.turns.length)],
214 ['Cost', r.costUsd !== undefined ? fmtUsd(r.costUsd) : 'n/a'],
215 ],
216 ))
217 out.push('')
218 out.push('## Time')
219 out.push('')
220 out.push(mdTable(['Phase', 'Time', 'Share of wall', 'What'], timeRows(r).map(x => [x.label, fmtMs(x.ms), `${Math.round(pct(x.ms, r.wallMs))}%`, x.note])))
221 out.push('')
222 out.push('## Tokens')
223 out.push('')
224 out.push(mdTable(
225 ['Kind', 'Tokens', 'Share'],
226 [
227 ['Input, uncached', fmtTok(t.input), `${Math.round(pct(t.input, total(t)))}%`],
228 ['Input, cache read', fmtTok(t.cacheRead), `${Math.round(pct(t.cacheRead, total(t)))}%`],
229 ['Input, cache write', fmtTok(t.cacheWrite), `${Math.round(pct(t.cacheWrite, total(t)))}%`],
230 ['Output', fmtTok(t.output), `${Math.round(pct(t.output, total(t)))}%`],
231 ['Total', fmtTok(total(t)), `${t.calls} calls, cache hit ${Math.round(cacheHit(t))}%`],
232 ],
233 ))
234 out.push('')
235 out.push('### By model')
236 out.push('')
237 out.push(mdTable(
238 ['Model', 'Share', 'Calls', 'Input', 'Output', 'Cache hit'],
239 modelRows(r.usage).map(([name, u]) => [shortModel(name), `${Math.round(pct(total(u), total(t)))}%`, String(u.calls), fmtTok(inputOf(u)), fmtTok(u.output), `${Math.round(cacheHit(u))}%`]),
240 ))
241 out.push('')
242 out.push('## Agents')
243 out.push('')
244 if (!r.agents.length) out.push('No subagents were spawned.')
245 else {
246 out.push(mdTable(['Type', 'Runs', 'Tokens', 'Time', 'Failed'], agentTypes(r).map(a => [a.type, String(a.count), fmtTok(a.tokens), fmtMs(a.ms), String(a.failed)])))
247 out.push('')
248 out.push(mdTable(
249 ['#', 'Type', 'Called by', 'Via', 'Model', 'Status', 'Time', 'Tokens', 'Tools', 'Task'],
250 r.agents.map((a, i) => [String(i + 1), a.parentId ? `↳ ${a.type}` : a.type, callerOf(a, r.agents), a.via ?? '—', shortModel(a.model), a.status, fmtMs((a.endedAt ?? r.updatedAt) - a.startedAt), fmtTok(total(a.usage)), String(a.toolCalls), cut(a.description, 70)]),
251 ))
252 }
253 out.push('')
254 out.push('## Skills')
255 out.push('')
256 out.push((r.skills ?? []).length ? mdTable(['Time', 'Skill', 'Loaded by', 'Mode', 'Source', 'Result', 'Args'], skillRows(r)) : 'No skills were loaded.')
257 out.push('')
258 out.push('## Files changed')
259 out.push('')
260 out.push(
261 (r.files ?? []).length
262 ? `${(r.files ?? []).length} files · +${(r.files ?? []).reduce((n, f) => n + f.added, 0)} −${(r.files ?? []).reduce((n, f) => n + f.removed, 0)}. "Of file" is changed lines (max of + and −) over the file's lines before its first change.\n\n` +
263 mdTable(['File', '+', '−', 'Of file', 'Edits', 'By', 'Tools'], fileRows(r))
264 : 'No file was changed through Edit, Write, NotebookEdit or a Bash command the engine diffed.',
265 )
266 out.push('')
267 out.push('## Tools')
268 out.push('')
269 out.push(r.tools.length ? mdTable(['Tool', 'Calls', 'Failed', 'Total time', 'Avg'], r.tools.map(x => [toolLabel(x.name), String(x.calls), String(x.errors), fmtMs(x.totalMs), fmtMs(x.totalMs / Math.max(1, x.calls))])) : 'No tool calls.')
270 out.push('')
271 out.push('## Turns')
272 out.push('')
273 out.push(mdTable(
274 ['#', 'Start', 'Duration', 'Model', 'Tools', 'Agents', 'Input', 'Output', 'Cost', 'Prompt'],
275 r.turns.map(x => [
276 String(x.index),
277 fmtDateTime(x.startedAt).slice(11),
278 fmtMs(x.ms ?? r.updatedAt - x.startedAt),
279 fmtMs(x.modelMs),
280 `${x.toolCalls} (${fmtMs(x.toolMs)})`,
281 String(x.agents),
282 x.usage ? fmtTok(inputOf(x.usage)) : '—',
283 x.usage ? fmtTok(x.usage.output) : '—',
284 x.costDelta !== undefined ? fmtUsd(x.costDelta) : '—',
285 cut(x.prompt, 60),
286 ]),
287 ))
288 if (r.compactions || r.maxContextPercent !== undefined) {
289 out.push('')
290 out.push('## Context')
291 out.push('')
292 out.push(`Compactions: ${r.compactions}. Peak context fill: ${r.maxContextPercent !== undefined ? `${Math.round(r.maxContextPercent)}%` : 'n/a'}.`)
293 }
294 out.push('')
295 out.push(`_Generated by activity-dashboard on ${fmtDateTime(r.updatedAt)}._`)
296 return out.join('\n')
297}
298
299export const aggregateToMarkdown = (recs: SessionRecord[], now: number): string => {
300 const out: string[] = []
301 const totals = recs.reduce((acc, r) => addUsageInto(acc, r.totals), emptyUsage())
302 const wall = recs.reduce((n, r) => n + r.wallMs, 0)
303 const active = recs.reduce((n, r) => n + r.activeMs, 0)
304 const cost = recs.reduce((n, r) => n + (r.costUsd ?? 0), 0)
305 out.push('# Activity report: all sessions')
306 out.push('')
307 out.push(`${recs.length} sessions · wall ${fmtMs(wall)} · active ${fmtMs(active)} · ${fmtTok(total(totals))} tokens · ${fmtUsd(cost)}`)
308 out.push('')
309 out.push('## Sessions')
310 out.push('')
311 out.push(mdTable(
312 ['Date', 'Task', 'Project', 'Wall', 'Active', 'Turns', 'Tokens', 'Cost', 'Session'],
313 recs.map(r => [fmtDateTime(r.startedAt), cut(r.title, 60), cut(r.repo ?? r.cwd, 30), fmtMs(r.wallMs), fmtMs(r.activeMs), String(r.turns.length), fmtTok(total(r.totals)), r.costUsd !== undefined ? fmtUsd(r.costUsd) : '—', r.id.slice(0, 8)]),
314 ))
315 out.push('')
316 out.push('## Time across sessions')
317 out.push('')
318 const time: DashTime = recs.reduce(
319 (acc, r) => ({ modelMs: acc.modelMs + r.time.modelMs, toolMs: acc.toolMs + r.time.toolMs, agentMs: acc.agentMs + r.time.agentMs, waitMs: acc.waitMs + r.time.waitMs }),
320 { modelMs: 0, toolMs: 0, agentMs: 0, waitMs: 0 },
321 )
322 out.push(mdTable(['Phase', 'Time', 'Share of wall'], [
323 ['model (main loop)', fmtMs(time.modelMs), `${Math.round(pct(time.modelMs, wall))}%`],
324 ['tools (main loop)', fmtMs(time.toolMs), `${Math.round(pct(time.toolMs, wall))}%`],
325 ['subagents', fmtMs(time.agentMs), `${Math.round(pct(time.agentMs, wall))}%`],
326 ['waiting for you', fmtMs(time.waitMs), `${Math.round(pct(time.waitMs, wall))}%`],
327 ]))
328 out.push('')
329 out.push('## Tokens by model')
330 out.push('')
331 const byModel: DashModelUsage = {}
332 for (const r of recs) for (const [m, u] of Object.entries(r.usage)) byModel[m] = addUsageInto(byModel[m] ?? emptyUsage(), u)
333 out.push(mdTable(['Model', 'Share', 'Calls', 'Input', 'Output', 'Cache hit'], modelRows(byModel).map(([name, u]) => [shortModel(name), `${Math.round(pct(total(u), total(totals)))}%`, String(u.calls), fmtTok(inputOf(u)), fmtTok(u.output), `${Math.round(cacheHit(u))}%`])))
334 out.push('')
335 out.push('## By project')
336 out.push('')
337 const byProject = new Map<string, { sessions: number; wall: number; tokens: number; cost: number }>()
338 for (const r of recs) {
339 const k = r.repo ?? r.cwd
340 const p = byProject.get(k) ?? { sessions: 0, wall: 0, tokens: 0, cost: 0 }
341 p.sessions += 1
342 p.wall += r.wallMs
343 p.tokens += total(r.totals)
344 p.cost += r.costUsd ?? 0
345 byProject.set(k, p)
346 }
347 out.push(mdTable(['Project', 'Sessions', 'Wall', 'Tokens', 'Cost'], [...byProject.entries()].sort((a, b) => b[1].tokens - a[1].tokens).map(([k, p]) => [k, String(p.sessions), fmtMs(p.wall), fmtTok(p.tokens), fmtUsd(p.cost)])))
348 out.push('')
349 out.push(`_Generated by activity-dashboard on ${fmtDateTime(now)}._`)
350 return out.join('\n')
351}
352
353// ---------------------------------------------------------------------------
354// HTML (for the PDF)
355// ---------------------------------------------------------------------------
356
357const CSS = `
358 :root { --ink:#1d2433; --dim:#6b7280; --line:#e5e7eb; --bar:#4c6ef5; --bar2:#12b886; --bar3:#f59f00; --bar4:#adb5bd; --bar5:#845ef7; }
359 * { box-sizing: border-box; }
360 body { font: 11pt/1.45 -apple-system, "Segoe UI", Helvetica, Arial, sans-serif; color: var(--ink); margin: 32px 40px; }
361 h1 { font-size: 20pt; margin: 0 0 4px; }
362 h2 { font-size: 13pt; margin: 22px 0 8px; border-bottom: 1px solid var(--line); padding-bottom: 4px; }
363 h3 { font-size: 11pt; margin: 14px 0 6px; color: var(--dim); }
364 .sub { color: var(--dim); margin-bottom: 14px; }
365 table { border-collapse: collapse; width: 100%; font-size: 9.5pt; page-break-inside: auto; }
366 th, td { text-align: left; padding: 4px 6px; border-bottom: 1px solid var(--line); vertical-align: top; }
367 th { color: var(--dim); font-weight: 600; }
368 td.num, th.num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
369 .bars { margin: 6px 0 2px; }
370 .row { display: flex; align-items: center; gap: 10px; margin: 3px 0; font-size: 9.5pt; }
371 .row .lbl { width: 150px; color: var(--ink); }
372 .row .trk { flex: 1; height: 10px; background: #f1f3f5; border-radius: 3px; overflow: hidden; }
373 .row .fil { height: 100%; background: var(--bar); }
374 .row .val { width: 170px; color: var(--dim); white-space: nowrap; font-variant-numeric: tabular-nums; }
375 .kpis { display: flex; flex-wrap: wrap; gap: 10px; margin: 10px 0 4px; }
376 .kpi { border: 1px solid var(--line); border-radius: 6px; padding: 8px 12px; min-width: 120px; }
377 .kpi b { display: block; font-size: 14pt; }
378 .kpi span { color: var(--dim); font-size: 9pt; }
379 .foot { color: var(--dim); font-size: 8.5pt; margin-top: 24px; }
380 tr { page-break-inside: avoid; }
381 @page { size: A4; margin: 14mm; }
382`
383
384const bar = (label: string, frac: number, value: string, color = 'var(--bar)'): string =>
385 `<div class="row"><div class="lbl">${escapeHtml(label)}</div><div class="trk"><div class="fil" style="width:${Math.round(Math.max(0, Math.min(1, frac)) * 100)}%;background:${color}"></div></div><div class="val">${escapeHtml(value)}</div></div>`
386
387const htmlTable = (head: Array<string | [string, 'num']>, rows: string[][]): string => {
388 const th = head.map(h => (Array.isArray(h) ? `<th class="num">${escapeHtml(h[0])}</th>` : `<th>${escapeHtml(h)}</th>`)).join('')
389 const numCols = head.map(h => Array.isArray(h))
390 const tr = rows.map(r => `<tr>${r.map((c, i) => `<td${numCols[i] ? ' class="num"' : ''}>${escapeHtml(c)}</td>`).join('')}</tr>`).join('')
391 return `<table><thead><tr>${th}</tr></thead><tbody>${tr}</tbody></table>`
392}
393
394const kpi = (value: string, label: string): string => `<div class="kpi"><b>${escapeHtml(value)}</b><span>${escapeHtml(label)}</span></div>`
395
396const page = (title: string, body: string): string =>
397 `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title><style>${CSS}</style></head><body>${body}</body></html>`
398
399export const recordToHtml = (r: SessionRecord): string => {
400 const t = r.totals
401 const tr = timeRows(r)
402 const colors = ['var(--bar)', 'var(--bar3)', 'var(--bar5)', 'var(--bar4)', '#ced4da']
403 const body = [
404 `<h1>${escapeHtml(r.title)}</h1>`,
405 `<div class="sub">Activity report · session ${escapeHtml(r.id)} · ${escapeHtml(fmtDateTime(r.startedAt))} → ${escapeHtml(fmtDateTime(r.updatedAt))} · ${escapeHtml(r.repo ?? r.cwd)}${r.branch ? ` @ ${escapeHtml(r.branch)}` : ''} · model ${escapeHtml(shortModel(r.sessionModel))}</div>`,
406 `<div class="kpis">${kpi(fmtMs(r.wallMs), 'wall time')}${kpi(fmtMs(r.activeMs), 'active (turns)')}${kpi(String(r.turns.length), 'turns')}${kpi(fmtTok(total(t)), 'tokens')}${kpi(r.costUsd !== undefined ? fmtUsd(r.costUsd) : 'n/a', 'cost')}${kpi(String(r.agents.length), 'subagents')}${kpi(String(r.tools.reduce((n, x) => n + x.calls, 0)), 'tool calls')}</div>`,
407 '<h2>Where the time went</h2>',
408 `<div class="bars">${tr.map((x, i) => bar(x.label, x.ms / Math.max(1, r.wallMs), `${fmtMs(x.ms)} · ${Math.round(pct(x.ms, r.wallMs))}%`, colors[i])).join('')}</div>`,
409 htmlTable(['Phase', ['Time', 'num'], ['Share', 'num'], 'What it counts'], tr.map(x => [x.label, fmtMs(x.ms), `${Math.round(pct(x.ms, r.wallMs))}%`, x.note])),
410 '<h2>Tokens</h2>',
411 `<div class="bars">${[
412 bar('input, uncached', t.input / Math.max(1, total(t)), fmtTok(t.input), 'var(--bar3)'),
413 bar('input, cache read', t.cacheRead / Math.max(1, total(t)), fmtTok(t.cacheRead), 'var(--bar2)'),
414 bar('input, cache write', t.cacheWrite / Math.max(1, total(t)), fmtTok(t.cacheWrite), 'var(--bar5)'),
415 bar('output', t.output / Math.max(1, total(t)), fmtTok(t.output), 'var(--bar)'),
416 ].join('')}</div>`,
417 `<div class="sub">${t.calls} model calls · cache hit ${Math.round(cacheHit(t))}% · output/input ${pct(t.output, Math.max(1, inputOf(t))).toFixed(1)}%</div>`,
418 '<h3>By model</h3>',
419 htmlTable(['Model', ['Share', 'num'], ['Calls', 'num'], ['Input', 'num'], ['Output', 'num'], ['Cache hit', 'num']], modelRows(r.usage).map(([name, u]) => [shortModel(name), `${Math.round(pct(total(u), total(t)))}%`, String(u.calls), fmtTok(inputOf(u)), fmtTok(u.output), `${Math.round(cacheHit(u))}%`])),
420 '<h2>Subagents</h2>',
421 r.agents.length
422 ? htmlTable(['Type', ['Runs', 'num'], ['Tokens', 'num'], ['Time', 'num'], ['Failed', 'num']], agentTypes(r).map(a => [a.type, String(a.count), fmtTok(a.tokens), fmtMs(a.ms), String(a.failed)])) +
423 '<h3>Each run</h3>' +
424 htmlTable(['#', 'Type', 'Called by', 'Via', 'Model', 'Status', ['Time', 'num'], ['Tokens', 'num'], ['Tools', 'num'], 'Task'], r.agents.map((a, i) => [String(i + 1), a.parentId ? `↳ ${a.type}` : a.type, callerOf(a, r.agents), a.via ?? '—', shortModel(a.model), a.status, fmtMs((a.endedAt ?? r.updatedAt) - a.startedAt), fmtTok(total(a.usage)), String(a.toolCalls), cut(a.description, 90)]))
425 : '<div class="sub">No subagents were spawned.</div>',
426 '<h2>Skills</h2>',
427 (r.skills ?? []).length
428 ? htmlTable(['Time', 'Skill', 'Loaded by', 'Mode', 'Source', 'Result', 'Args'], skillRows(r))
429 : '<div class="sub">No skills were loaded.</div>',
430 '<h2>Files changed</h2>',
431 (r.files ?? []).length
432 ? `<div class="sub">${(r.files ?? []).length} files · +${(r.files ?? []).reduce((n, f) => n + f.added, 0)} −${(r.files ?? []).reduce((n, f) => n + f.removed, 0)} · “of file” is changed lines (max of + and −) over the file’s lines before its first change</div>` +
433 htmlTable(['File', ['+', 'num'], ['−', 'num'], ['Of file', 'num'], ['Edits', 'num'], 'By', 'Tools'], fileRows(r))
434 : '<div class="sub">No file was changed through Edit, Write, NotebookEdit or a diffed Bash command.</div>',
435 '<h2>Tools</h2>',
436 r.tools.length
437 ? `<div class="bars">${r.tools.slice(0, 8).map(x => bar(toolLabel(x.name), x.calls / Math.max(1, r.tools[0]?.calls ?? 1), `${x.calls} calls · ${fmtMs(x.totalMs)}${x.errors ? ` · ${x.errors} failed` : ''}`, x.errors ? 'var(--bar3)' : 'var(--bar2)')).join('')}</div>` +
438 htmlTable(['Tool', ['Calls', 'num'], ['Failed', 'num'], ['Total', 'num'], ['Avg', 'num']], r.tools.map(x => [toolLabel(x.name), String(x.calls), String(x.errors), fmtMs(x.totalMs), fmtMs(x.totalMs / Math.max(1, x.calls))]))
439 : '<div class="sub">No tool calls.</div>',
440 '<h2>Turns</h2>',
441 htmlTable(['#', 'Start', ['Duration', 'num'], ['Model', 'num'], ['Tools', 'num'], ['Agents', 'num'], ['Input', 'num'], ['Output', 'num'], ['Cost', 'num'], 'Prompt'], r.turns.map(x => [
442 String(x.index),
443 fmtDateTime(x.startedAt).slice(11),
444 fmtMs(x.ms ?? r.updatedAt - x.startedAt),
445 fmtMs(x.modelMs),
446 `${x.toolCalls} · ${fmtMs(x.toolMs)}`,
447 String(x.agents),
448 x.usage ? fmtTok(inputOf(x.usage)) : '—',
449 x.usage ? fmtTok(x.usage.output) : '—',
450 x.costDelta !== undefined ? fmtUsd(x.costDelta) : '—',
451 cut(oneLine(x.prompt), 90),
452 ])),
453 '<h2>Context</h2>',
454 `<div class="sub">Compactions: ${r.compactions}. Peak context fill: ${r.maxContextPercent !== undefined ? `${Math.round(r.maxContextPercent)}%` : 'n/a'}.</div>`,
455 `<div class="foot">Generated by activity-dashboard on ${escapeHtml(fmtDateTime(r.updatedAt))}. Times are wall-clock; subagent time overlaps the main loop when agents run in the background. Cost is the host’s own ledger.</div>`,
456 ].join('\n')
457 return page(`Activity report: ${r.title}`, body)
458}
459
460export const aggregateToHtml = (recs: SessionRecord[], now: number): string => {
461 const totals = recs.reduce((acc, r) => addUsageInto(acc, r.totals), emptyUsage())
462 const wall = recs.reduce((n, r) => n + r.wallMs, 0)
463 const active = recs.reduce((n, r) => n + r.activeMs, 0)
464 const cost = recs.reduce((n, r) => n + (r.costUsd ?? 0), 0)
465 const time: DashTime = recs.reduce(
466 (acc, r) => ({ modelMs: acc.modelMs + r.time.modelMs, toolMs: acc.toolMs + r.time.toolMs, agentMs: acc.agentMs + r.time.agentMs, waitMs: acc.waitMs + r.time.waitMs }),
467 { modelMs: 0, toolMs: 0, agentMs: 0, waitMs: 0 },
468 )
469 const byModel: DashModelUsage = {}
470 for (const r of recs) for (const [m, u] of Object.entries(r.usage)) byModel[m] = addUsageInto(byModel[m] ?? emptyUsage(), u)
471 const maxTok = Math.max(1, ...recs.map(r => total(r.totals)))
472 const body = [
473 '<h1>Activity report: all sessions</h1>',
474 `<div class="sub">${recs.length} sessions · generated ${escapeHtml(fmtDateTime(now))}</div>`,
475 `<div class="kpis">${kpi(String(recs.length), 'sessions')}${kpi(fmtMs(wall), 'wall time')}${kpi(fmtMs(active), 'active')}${kpi(fmtTok(total(totals)), 'tokens')}${kpi(fmtUsd(cost), 'cost')}</div>`,
476 '<h2>Sessions</h2>',
477 `<div class="bars">${recs.slice(0, 20).map(r => bar(cut(r.title, 28), total(r.totals) / maxTok, `${fmtTok(total(r.totals))} · ${fmtMs(r.wallMs)}${r.costUsd !== undefined ? ` · ${fmtUsd(r.costUsd)}` : ''}`)).join('')}</div>`,
478 htmlTable(['Date', 'Task', 'Project', ['Wall', 'num'], ['Active', 'num'], ['Turns', 'num'], ['Tokens', 'num'], ['Cost', 'num'], 'Session'], recs.map(r => [fmtDateTime(r.startedAt), cut(r.title, 70), cut(r.repo ?? r.cwd, 34), fmtMs(r.wallMs), fmtMs(r.activeMs), String(r.turns.length), fmtTok(total(r.totals)), r.costUsd !== undefined ? fmtUsd(r.costUsd) : '—', r.id.slice(0, 8)])),
479 '<h2>Where the time went</h2>',
480 `<div class="bars">${[
481 bar('model (main loop)', time.modelMs / Math.max(1, wall), `${fmtMs(time.modelMs)} · ${Math.round(pct(time.modelMs, wall))}%`),
482 bar('tools (main loop)', time.toolMs / Math.max(1, wall), `${fmtMs(time.toolMs)} · ${Math.round(pct(time.toolMs, wall))}%`, 'var(--bar3)'),
483 bar('subagents', time.agentMs / Math.max(1, wall), `${fmtMs(time.agentMs)} · ${Math.round(pct(time.agentMs, wall))}%`, 'var(--bar5)'),
484 bar('waiting for you', time.waitMs / Math.max(1, wall), `${fmtMs(time.waitMs)} · ${Math.round(pct(time.waitMs, wall))}%`, 'var(--bar4)'),
485 ].join('')}</div>`,
486 '<h2>Tokens by model</h2>',
487 htmlTable(['Model', ['Share', 'num'], ['Calls', 'num'], ['Input', 'num'], ['Output', 'num'], ['Cache hit', 'num']], modelRows(byModel).map(([name, u]) => [shortModel(name), `${Math.round(pct(total(u), total(totals)))}%`, String(u.calls), fmtTok(inputOf(u)), fmtTok(u.output), `${Math.round(cacheHit(u))}%`])),
488 `<div class="foot">Generated by activity-dashboard. Each session’s figures come from its own JSON record under ~/.claude/activity-dashboard/sessions.</div>`,
489 ].join('\n')
490 return page('Activity report: all sessions', body)
491}
492
493export const agentsDone = (agents: DashAgent[]): number => agents.filter(a => isDoneStatus(a.status)).length
494types/index.d.ts 240 lines1export type DashUsage = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6 calls: number
7}
8
9export type DashPhase = 'idle' | 'model' | 'tool'
10
11export type DashMain = {
12 phase: DashPhase
13 sessionId: string
14 cwd: string
15 repo?: string
16 branch?: string
17 /** The task this chat is about: the first prompt's head, or what `title <text>` set. */
18 title: string
19 titleIsManual: boolean
20 sessionModel: string
21 model: string
22 effort?: string
23 turnId?: string
24 stepIndex: number
25 messageCount: number
26 startedAt: number
27 turnStartedAt?: number
28 turns: number
29 lastTurnMs?: number
30 lastTurnUsage?: DashUsage
31 lastTurnCostUsd?: number
32 costAtTurnStart?: number
33}
34
35export type DashModelUsage = Record<string, DashUsage>
36
37export type DashAgent = {
38 id: string
39 type: string
40 description: string
41 model: string
42 parentId?: string
43 name?: string
44 status: string
45 background: boolean
46 startedAt: number
47 endedAt?: number
48 usage: DashUsage
49 steps: number
50 toolCalls: number
51 /** How it was started: `Agent tool`, `workflow`, `fork`, `teammate`, `skill:<name>`, `untracked`. */
52 via?: string
53}
54
55/** What one loop (`main` or an agent id) is doing right now. */
56export type DashNowKind = 'starting' | 'thinking' | 'answering' | 'preparing' | 'tool' | 'idle'
57
58export type DashNow = {
59 what: DashNowKind
60 /** The tool and its argument summary, or the tool being composed. */
61 detail: string
62 since: number
63 /** The skill this loop loaded in its current turn. */
64 skill?: string
65}
66
67/** One skill load: through the Skill tool, a `/command` or a preload. */
68export type DashSkill = {
69 at: number
70 name: string
71 /** `main` or the agent id whose loop loaded it. */
72 loop: string
73 mode: 'inline' | 'forked' | 'command'
74 args?: string
75 /** The forked skill's own agent. */
76 agentId?: string
77 source?: string
78 plugin?: string
79 ok: boolean
80 ms?: number
81}
82
83/** One file this session changed, its edits summed. */
84export type DashFile = {
85 path: string
86 added: number
87 removed: number
88 edits: number
89 /** Lines before the session's first change; absent when not known. */
90 baseLines?: number
91 /** Lines after the latest change; absent when not known. */
92 lines?: number
93 created: boolean
94 deleted: boolean
95 tools: string[]
96 /** `main` or the agent ids that changed it. */
97 loops: string[]
98 firstAt: number
99 lastAt: number
100}
101
102export type DashRateLimit = { kind: string; percentUsed: number; resetsAt?: string }
103
104export type DashBreakdownRow = { name: string; tokens: number; kind: string; color: string }
105
106export type DashContext = {
107 tokens?: number
108 window: number
109 percent?: number
110 maxPercent?: number
111 costUsd?: number
112 rateLimits: DashRateLimit[]
113 compactions: number
114 lastCompaction?: { before?: number; after?: number; at: number }
115 breakdown?: DashBreakdownRow[]
116 breakdownMax?: number
117 autoCompactThreshold?: number
118 measuredAt?: number
119}
120
121export type DashTool = { name: string; calls: number; errors: number; totalMs: number }
122
123export type DashActiveTool = {
124 id: string
125 tool: string
126 agentId?: string
127 startedAt: number
128 detail: string
129}
130
131export type DashStep = {
132 at: number
133 model: string
134 agentId?: string
135 input: number
136 output: number
137 cacheRead: number
138 cacheWrite: number
139 stopReason?: string
140 ms: number
141}
142
143export type DashEvent = { at: number; text: string; kind: 'info' | 'ok' | 'warn' | 'error' }
144
145/** How a share chart is drawn: a bar per item, one stacked bar, or a donut (text bars where no SVG). */
146export type DashChartMode = 'bars' | 'stacked' | 'donut'
147
148export type DashView = {
149 showTools: boolean
150 showLog: boolean
151 showSteps: boolean
152 showModels: boolean
153 showTime: boolean
154 showFiles?: boolean
155 showSkills?: boolean
156 chart: DashChartMode
157 /** The person's theme is dark: picks the dark palette steps. */
158 dark: boolean
159}
160
161/** One main-loop turn of this chat, with what it cost in time and tokens. */
162export type DashTurn = {
163 index: number
164 turnId: string
165 startedAt: number
166 endedAt?: number
167 ms?: number
168 prompt: string
169 usage?: DashUsage
170 toolCalls: number
171 agents: number
172 modelMs: number
173 toolMs: number
174 costDelta?: number
175 reason?: string
176}
177
178/** Where the session's wall time went, in milliseconds. */
179export type DashTime = {
180 /** Main-loop model requests (the model thinking and answering). */
181 modelMs: number
182 /** Main-loop tool calls, the Agent tool excluded. */
183 toolMs: number
184 /** Subagents' own wall time, from spawn to completion. */
185 agentMs: number
186 /** Idle between a turn's end and the next prompt: the person's time. */
187 waitMs: number
188 idleSince?: number
189}
190
191/** What the mod writes to `<home>/.claude/activity-dashboard/sessions/<id>.json`. */
192export type SessionRecord = {
193 version: 1
194 id: string
195 title: string
196 cwd: string
197 repo?: string
198 branch?: string
199 sessionModel: string
200 startedAt: number
201 updatedAt: number
202 endedAt?: number
203 wallMs: number
204 activeMs: number
205 time: DashTime
206 turns: DashTurn[]
207 usage: DashModelUsage
208 totals: DashUsage
209 costUsd?: number
210 tools: DashTool[]
211 agents: DashAgent[]
212 compactions: number
213 maxContextPercent?: number
214 events: DashEvent[]
215 /** Absent in records written before 0.3.0. */
216 files?: DashFile[]
217 skills?: DashSkill[]
218}
219
220declare module 'claude-code' {
221 interface PluginState {
222 'activity-dashboard': {
223 main: DashMain
224 usage: DashModelUsage
225 agents: DashAgent[]
226 context: DashContext
227 tools: Record<string, DashTool>
228 active: DashActiveTool[]
229 steps: DashStep[]
230 events: DashEvent[]
231 view: DashView
232 turns: DashTurn[]
233 time: DashTime
234 files: Record<string, DashFile>
235 skills: DashSkill[]
236 now: Record<string, DashNow>
237 }
238 }
239}
240