SLOPSHOPPER

activity-dashboard

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…

newpaneguardcommandtoastprompt
v0.3.0no licenseupdated 2026-10-16osinelnikov/activity-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · activity-dashboard
│ ┃ Activity ✕ › fix the failing auth test and add an audit log call │ ┃ ╭─────────────────────────────────────────── │ ┃ │ ACTIVITY chart: bars ⏺ Read(src/auth.ts) │ ┃ │ status ○ idle turns 1 · up 30m01s ⎿ Read 6 lines │ ┃ │ model opus 5.5 ⏺ Update(src/auth.ts) │ ┃ │ where /work/app @ feat/auth-refresh · ⎿ Added 2 lines, removed 1 line │ ┃ │ last turn 42s · in 97.4k out 1.5k · 1 call ⏺ Bash(bun test) │ ┃ ╰─────────────────────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ╭─────────────────────────────────────────── │ ┃ │ NOW 1 loop · 08:53:20 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ○ main opus 5.5 idle · wa │ ┃ ╰─────────────────────────────────────────── ✻ Worked for 42s · done 4:20 PM │ ┃ ╭─────────────────────────────────────────── │ ┃ │ TIME wall 30m01s · active 0ms › /activity-dashboard │ ┃ │ ■ tools ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 2 ⎿ activity-dashboard: Activity dashboard opened. │ ┃ │ ■ waiting ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ │ ┃ │ ■ other █████████████████████████████ 30 │ ┃ │ 0 turns · avg 0ms │ ┃ ╰─────────────────────────────────────────── │ ┃ ╭─────────────────────────────────────────── │ ┃ │ CONTEXT & COST measured 08:53:20 │ ┃ │ window ████████░░░░░░░░░ 49% 97.4k / │ ┃ │ peak 49% │ ┃ │ limit 5h █████░░░░░░░░░░░░ 31% │ ┃ │ cost $0.420 ~$0.420/turn │ ┃ ╰─────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Activity
╭──────────────────────────────────────────────────────────╮ │ ACTIVITY chart: bars │ │ status ○ idle turns 1 · up 30m01s │ │ model opus 5.5 │ │ where /work/app @ feat/auth-refresh · preview- │ │ last turn 42s · in 97.4k out 1.5k · 1 calls │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ NOW 1 loop · 08:53:20 │ ○ main opus 5.5 idle · waiting for you ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ TIME wall 30m01s · active 0ms │ │ ■ tools ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 240ms 0% │ │ ■ waiting ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 80ms 0% │ │ ■ other █████████████████████████████ 30m00s 100% │ │ 0 turns · avg 0ms │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ CONTEXT & COST measured 08:53:20 │ │ window ████████░░░░░░░░░ 49% 97.4k / 200.0k │ │ peak 49% │ │ limit 5h █████░░░░░░░░░░░░ 31% │ │ cost $0.420 ~$0.420/turn │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ TOKENS │ │ no model calls yet │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ORCHESTRATION │ │ ○ main opus 5.5 · 3 files │ │ no subagents spawned yet │ ╰──────────────────────────────────────────────────────────╯ ╭─────────────────────────────────────────────────────────── │ FILES CHANGED 3 files · +2 −1 │ file + − diff of file edits · by │ src/cache.ts +0 −0 ········ — 1× · main · │ src/audit.ts +0 −0 ········ — 1× · main · │ src/auth.ts +2 −1 ■■■■■■■■ 33% 1× · main · │ of file: changed lines, max of + and −, over the fi… ╰─────────────────────────────────────────────────────────── ╭──────────────────────────────────────────────────────────╮ │ TOOLS 9 calls · 240ms · 1 failed │ │ ■ Bash ███████████░░░░░░░░░░░░░ 4× ✗1 · 20ms 44% │ │ ■ Write █████░░░░░░░░░░░░░░░░░░░ 2× · 40ms 22% │ │ ■ Read ███░░░░░░░░░░░░░░░░░░░░░ 1× · 20ms 11% │ │ ■ Grep ███░░░░░░░░░░░░░░░░░░░░░ 1× · 20ms 11% │ │ ■ Edit ███░░░░░░░░░░░░░░░░░░░░░ 1× · 40ms 11% │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ LOG │ │ 08:53:20 dashboard ready · opus 5.5 │ │ 08:53:20 ✎ main: src/auth.ts +2 −1 │ │ 08:53:20 ✎ main: src/audit.ts +0 −0 │ │ 08:53:20 ✎ main: src/cache.ts +0 −0 │ │ 08:53:20 ✗ Bash failed: Run the test suite │ │ 08:53:20 ✓ turn answer 42s · in 97.4k out 1.5k │ ╰──────────────────────────────────────────────────────────╯ c: c:chart p: p:pdf report b: b:breakdown i: i:time f: f:f
README

activity-dashboard

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.

Install

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.

Use

The pane opens by itself when a session starts. /activity-dashboard reopens it with the keyboard focus. Arguments:

ArgumentEffect
summaryprints this chat's summary into the chat: time, tokens, cost, agents, tools
sessionslists 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)
resetclears every counter
closecloses the pane
breakdownrefreshes the context-window breakdown
`chart [bars\stacked\donut]`how share charts are drawn; no argument cycles the three
files, skillsshow or hide the files-changed and skills sections
time, models, tools, steps, logshow 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.

How it looks

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.

What is recorded

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:

  • the task title, session id, project folder, repository and branch, session model;
  • wall time and active time, and where the time went: model requests of the main loop, tool calls of the main loop, subagents' wall time, and waiting for you between turns;
  • every turn: start, duration, prompt head, model and tool time, tokens, tool calls, agents spawned, cost delta;
  • tokens per model (uncached input, cache read, cache write, output, calls);
  • every subagent: type, model, status, time, tokens, tool calls, task;
  • tool statistics, compactions, peak context fill, cost.

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.

Where reports go

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.

PDF

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.

Sections of the pane

  • NOW — one row per live loop: 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.
  • SKILLS — every skill load: when, which skill, which loop loaded it, how (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.
  • FILES CHANGED — every file this chat changed through Edit, Write, NotebookEdit, or a Bash command the engine diffed: +added −removed, a small diff bar, the share of the file that changed (changed lines, the max of
  • and −, over the file's lines before its first change; new for a created file), the number of edits, which loops made them, and when last.
  • Header — phase of the main loop (idle, thinking, tools), time inside the turn, turn count, uptime, current model and effort, task title, project and branch, the last turn's duration, tokens, calls and cost delta.
  • TIME — wall, active and waiting time; bars for model, tools, agents and waiting as a share of wall time; average and longest turn.
  • CONTEXT — window fill bar (green, amber, red by threshold), tokens and window size, autocompact threshold, peak fill, compaction count with the last before → after, and the window broken down by category.
  • Rate limits and cost — the 5h and 7d windows with reset times; total USD, last-turn delta and average per turn.
  • TOKENS — uncached input, cache read, cache write, output; cache hit rate, call count, output-to-input ratio; then each model's share, calls and hit rate.
  • ORCHESTRATION — the main loop and every subagent nested under the loop that started it: status, type, name, model, elapsed, tokens, tool calls, files changed, how it was started (Agent tool, workflow, fork, teammate, a forked skill), background flag, its task, and for a live one what it is doing now. Agents no spawn announced (a forked skill) are picked up from the engine's agent list. Below it a per-agent-type summary.
  • ACTIVE TOOLS — tool calls in flight, with elapsed time, owner and a short argument summary.
  • TOOLS — top tools by call count, with errors, total and average duration.
  • RECENT MODEL CALLS — the last requests with input and output bars, model, duration, caller and an unusual stop reason when there was one.
  • LOG — turn starts and ends, agent spawns and completions, compactions, tool failures, rate limits past 90 %.

Develop

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.

Source 7 files
hooks/register.tsx 1825 lines
1import { 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 lines
1import 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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)
142
hooks/activity.ts 203 lines
1import 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)}` : '')
203
hooks/charts.tsx 258 lines
1import 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')
258
hooks/paths.ts 35 lines
1/** `~` 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}
35
hooks/report.ts 494 lines
1import 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
494
types/index.d.ts 240 lines
1export 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