Docked session dashboard (limits with pace, context, agents, other sessions), its status line and month-to-date cost, and /dash all: all-time usage by model…

Private Claude Code mods. The repo is itself a marketplace (drprockz-mods).
At the prompt of a Claude Code terminal session (the machine needs GitHub access to this private repo, e.g. gh auth login):
/plugin install session-dash --marketplace drprockz/claude-mods
Answer y to add the marketplace and pick the user scope.
/dash toggles it. It opens by itself in a fullscreen terminal unless SESSION_DASH_AUTOOPEN=0. It shows limits with pace, the context breakdown, models, live agents and workflows, and other Claude sessions on the machine.bin/statusline.sh shows the model, effort, session and month cost, context, and the 5-hour and 7-day limits.bin/cost.py totals API-equivalent cost from transcripts for the calendar month (IST). The status line refreshes it in the background at most every 10 minutes, and the sidebar reads the same cache./dash all opens a Usage pane with six tabs (Overview, Models, Projects, Tools & skills, Plugins & MCP, Timeline). Keys while it has focus: 1–6 tabs, r range (all / 7d / 30d), click a project (or Tab to its row, then Enter) to filter every tab, x clears it, Esc closes. bin/history.py rolls each transcript up into ~/.claude/session-dash/history-index.json in the background when the pane opens; Claude Code deletes old transcripts, and their rollups stay in the index, so totals keep growing. Days before the index existed come from ~/.claude/stats-cache.json, prompts per project from ~/.claude/history.jsonl. Not counted: claude.ai web chats, per-call use of hook-only or LSP plugins, and claude.ai connectors never called. $ is API list price, not a bill. Days follow the machine's timezone, while the status line's month cost uses IST calendar months, so the two can differ near a month's edge on a machine outside IST. Not yet tried on Windows: the indexer expects / in paths.On each session start the mod copies bin/ to ~/.claude/session-dash/bin/ when the copies differ, so updating the plugin updates the scripts. If no status line is set, the mod sets one. It never replaces a status line you already have; run /dash statusline to switch to this one. The previous settings are saved as ~/.claude/settings.json.bak-session-dash.
Needs bash, awk and perl. Works on macOS, Linux, and Windows under Git Bash (Claude Code runs the status line through Git Bash automatically when it's installed). jq is no longer required — the status line parses its JSON with awk instead, since jq isn't bundled with Git for Windows. python3 is optional: it powers the month-to-date cost cache, which degrades quietly without it, and /dash all's index, which shows the error in its pane without it. The dashboard's own file bookkeeping (heartbeats, peer cleanup, the installer) shells out to mkdir/rm/mv/chmod by name, so on Windows they need Git Bash's usr/bin on PATH (true for most Git-for-Windows installs already used as the terminal's shell).
Bump version in plugins/session-dash/.claude-plugin/plugin.json, push, then on each machine run /plugin marketplace update drprockz-mods.
cd plugins/session-dash
claude plugin validate . && claude plugin test .
python3 -m unittest discover -s tests/py
claude --plugin-dir plugins/session-dash runs a session with the working copy.
hooks/register.tsx 588 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On, Register, SessionRateLimit, Timer } from 'claude-code'
3
4import type { Peer, Usage } from '../types'
5import * as A from './lib/agents'
6import { groups } from './lib/breakdown'
7import { dur, prettyModel } from './lib/format'
8import { BUNDLE, binDir, planStatusLine, withStatusLine } from './lib/install'
9import type { StatusLinePlan } from './lib/install'
10import { addSample } from './lib/pace'
11import { heartbeat, labelFor, parsePeer, REAP_MS, tildify } from './lib/peers'
12import { registerAll, toggleAll } from './all'
13import { live, swallow } from './state'
14import type { DashData } from './view/dash'
15import { Dash, Strip } from './view/dash'
16
17// ── State ───────────────────────────────────────────────────────────
18
19const PANE = 'session-dash'
20
21const activeAtom = atom({ plugin: 'session-dash', key: 'active' } as const, false)
22const usageAtom = atom({ plugin: 'session-dash', key: 'usage' } as const, null)
23const modelAtom = atom({ plugin: 'session-dash', key: 'model' } as const, '')
24const effortAtom = atom({ plugin: 'session-dash', key: 'effort' } as const, '')
25const paceAtom = atom({ plugin: 'session-dash', key: 'pace' } as const, {})
26const historyAtom = atom({ plugin: 'session-dash', key: 'history' } as const, [])
27const turnAtom = atom({ plugin: 'session-dash', key: 'turn' } as const, null)
28const totalsAtom = atom({ plugin: 'session-dash', key: 'totals' } as const, { turns: 0, inTok: 0, outTok: 0 })
29const busyAtom = atom({ plugin: 'session-dash', key: 'busy' } as const, false)
30const agentsAtom = atom({ plugin: 'session-dash', key: 'agents' } as const, A.EMPTY)
31const groupsAtom = atom({ plugin: 'session-dash', key: 'groups' } as const, [])
32const loadoutAtom = atom({ plugin: 'session-dash', key: 'loadout' } as const, null)
33const peersAtom = atom({ plugin: 'session-dash', key: 'peers' } as const, [])
34const foldsAtom = atom({ plugin: 'session-dash', key: 'folds' } as const, {})
35const tickAtom = atom({ plugin: 'session-dash', key: 'tick' } as const, 0)
36const alertedAtom = atom({ plugin: 'session-dash', key: 'alerted' } as const, [])
37const monthAtom = atom({ plugin: 'session-dash', key: 'month' } as const, null)
38const effortsAtom = atom({ plugin: 'session-dash', key: 'efforts' } as const, {})
39const modelIdAtom = atom({ plugin: 'session-dash', key: 'modelId' } as const, '')
40const autoOpenedAtom = atom({ plugin: 'session-dash', key: 'autoOpened' } as const, false)
41
42// ── Alerts ──────────────────────────────────────────────────────────
43
44const LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d' }
45const STORE_KEY = 'alerted'
46
47// One alert per threshold per window. Limit keys carry the window's reset
48// time and live in the store, so a new session in the same window stays
49// quiet; the context key re-arms once the context drops below 80%.
50const checkAlerts = async ($: EngineInterface, u: Usage) => {
51 const now = await $.clock.now()
52 const stored = await $.store.get(STORE_KEY)
53 const seen = new Set<string>(Array.isArray(stored) ? (stored as string[]) : [])
54 const before = seen.size
55 for (const l of u.limits) {
56 const label = LABEL[l.kind]
57 if (!label) continue
58 const at = l.resetsAt ?? 0
59 const resets = l.resetsAt ? ` · resets in ${dur(l.resetsAt - now)}` : ''
60 if (l.pct >= 90 && !seen.has(`${l.kind}:90:${at}`)) {
61 seen.add(`${l.kind}:90:${at}`).add(`${l.kind}:80:${at}`)
62 $.ui.toast(`${label} limit at ${Math.round(l.pct)}%${resets}`, { timeoutMs: 8000 })
63 swallow($.ui.notify(`${label} limit at ${Math.round(l.pct)}%${resets}`, { title: 'Claude usage' }))
64 } else if (l.pct >= 80 && !seen.has(`${l.kind}:80:${at}`)) {
65 seen.add(`${l.kind}:80:${at}`)
66 $.ui.toast(`${label} limit at ${Math.round(l.pct)}%${resets}`)
67 }
68 }
69 if (seen.size !== before) {
70 const keep = [...seen].filter(k => {
71 const at = Number(k.split(':')[2])
72 return !(at > 0 && at < now)
73 })
74 await $.store.set(STORE_KEY, keep)
75 }
76
77 const ctx = u.ctxPct
78 const alerted = await read($, alertedAtom)
79 if (ctx !== undefined && ctx >= 85 && !alerted.includes('ctx')) {
80 await update($, alertedAtom, a => [...a, 'ctx'])
81 $.ui.toast(`Context at ${Math.round(ctx)}% · compact from /dash or /compact`)
82 } else if (ctx !== undefined && ctx < 80 && alerted.includes('ctx')) {
83 await update($, alertedAtom, a => a.filter(k => k !== 'ctx'))
84 }
85}
86
87// ── Heartbeat, peers and the spinner clock ──────────────────────────
88
89const POLL_MS = 5000
90const SPIN_MS = 500
91const KEEPALIVE_MS = 60_000
92
93let poll: Timer | undefined
94let spin: Timer | undefined
95let lastBody = ''
96let lastWrite = 0
97let isDirReady = false
98
99const dir = () => `${live.home}/.claude/session-dash/sessions`
100
101// Writes this session's heartbeat when what it says changed, and at least
102// once a minute so other sessions keep seeing it as live.
103const writeBeat = async ($: EngineInterface, now: number) => {
104 if (!live.home) return
105 if (!isDirReady) {
106 await $.process.run(['mkdir', '-p', dir()])
107 isDirReady = true
108 }
109 const id = await $.session.id()
110 const usage = await read($, usageAtom)
111 const peer: Peer = {
112 sessionId: id,
113 label: live.label,
114 cwd: tildify(live.cwd, live.home),
115 model: await read($, modelAtom),
116 isBusy: await read($, busyAtom),
117 agentsRunning: A.counts(await read($, agentsAtom)).running,
118 costUsd: usage?.costUsd,
119 updatedAt: now,
120 }
121 const body = heartbeat({ ...peer, updatedAt: 0 })
122 if (body === lastBody && now - lastWrite < KEEPALIVE_MS) return
123 await $.fs.write(`${dir()}/${id}.json`, heartbeat(peer))
124 lastBody = body
125 lastWrite = now
126}
127
128const readPeers = async ($: EngineInterface, now: number) => {
129 if (!live.home) return
130 const self = await $.session.id()
131 let entries
132 try {
133 entries = await $.fs.list(dir())
134 } catch {
135 return
136 }
137 const peers: Peer[] = []
138 const reap: string[] = []
139 for (const f of entries) {
140 if (f.kind !== 'file' || !f.name.endsWith('.json') || f.name === `${self}.json`) continue
141 const path = `${dir()}/${f.name}`
142 if (now - f.mtimeMs > REAP_MS) {
143 reap.push(path)
144 continue
145 }
146 try {
147 const p = parsePeer(await $.fs.read(path), self, now)
148 if (p) peers.push(p)
149 } catch {
150 // unreadable: skip this file
151 }
152 }
153 peers.sort((a, b) => Number(b.isBusy) - Number(a.isBusy) || b.updatedAt - a.updatedAt)
154 await update($, peersAtom, () => peers)
155 if (reap.length) swallow($.process.run(['rm', '-f', ...reap]))
156}
157
158// The month's API-equivalent cost by model, kept fresh by the status line's
159// background run of session-dash/bin/cost.py, and each model's effort
160// setting ('*' holds the default for models without their own).
161const readMonth = async ($: EngineInterface) => {
162 if (!live.home) return
163 try {
164 const j = JSON.parse(await $.fs.read(`${live.home}/.claude/session-dash/month-cost.json`)) as Record<string, unknown>
165 if (typeof j.month === 'string' && typeof j.usd === 'number') {
166 const byModel: Record<string, number> = {}
167 if (j.by_model && typeof j.by_model === 'object') {
168 for (const [k, v] of Object.entries(j.by_model as Record<string, unknown>)) {
169 if (typeof v === 'number') byModel[k] = v
170 }
171 }
172 const next = { month: j.month, usd: j.usd, byModel }
173 const prev = await read($, monthAtom)
174 if (JSON.stringify(prev) !== JSON.stringify(next)) await update($, monthAtom, () => next)
175 }
176 } catch {
177 // no cache yet
178 }
179 try {
180 const s = await $.settings.read()
181 const efforts: Record<string, string> = {}
182 if (typeof s.effortLevel === 'string') efforts['*'] = s.effortLevel
183 const per = s.modelSettings
184 if (per && typeof per === 'object') {
185 for (const [id, v] of Object.entries(per as Record<string, unknown>)) {
186 const level = v && typeof v === 'object' ? (v as Record<string, unknown>).effortLevel : undefined
187 if (typeof level === 'string') efforts[id] = level
188 }
189 }
190 const prev = await read($, effortsAtom)
191 if (JSON.stringify(prev) !== JSON.stringify(efforts)) await update($, effortsAtom, () => efforts)
192 } catch {
193 // settings unreadable: keep the last known efforts
194 }
195}
196
197// Opens the sidebar once per session, unasked, only where it docks as a
198// sidebar (a fullscreen terminal), unless SESSION_DASH_AUTOOPEN=0.
199const autoOpen = async ($: EngineInterface) => {
200 if (!live.sawFullscreen || (await read($, autoOpenedAtom))) return
201 await update($, autoOpenedAtom, () => true)
202 if ((await $.env.get('SESSION_DASH_AUTOOPEN')) !== '0') swallow(openDash($))
203}
204
205const pollOnce = async ($: EngineInterface) => {
206 const now = await $.clock.now()
207 await update($, agentsAtom, s => A.sweep(s, now))
208 await writeBeat($, now)
209 await readPeers($, now)
210 await readMonth($)
211 await autoOpen($)
212}
213
214const startClocks = ($: EngineInterface) => {
215 poll?.cancel()
216 swallow(pollOnce($))
217 poll = $.clock.every(POLL_MS, () => swallow(pollOnce($)))
218 syncSpinner($)
219}
220
221// The spinner frame advances only while some agent runs.
222const syncSpinner = ($: EngineInterface) => {
223 swallow(
224 (async () => {
225 const isRunning = A.counts(await read($, agentsAtom)).running > 0
226 if (isRunning && !spin) {
227 spin = $.clock.every(SPIN_MS, () => {
228 swallow(
229 (async () => {
230 const s = await read($, agentsAtom)
231 if (A.counts(s).running === 0) {
232 spin?.cancel()
233 spin = undefined
234 }
235 await update($, tickAtom, t => (t + 1) % 1_000_000)
236 })(),
237 )
238 })
239 }
240 })(),
241 )
242}
243
244// ── Collection ──────────────────────────────────────────────────────
245
246const HISTORY_MS = 6 * 60 * 60_000
247const HISTORY_KEY = 'history5h'
248
249type U = { context: { tokens?: number; window: number; percent?: number }; rateLimits: SessionRateLimit[]; cost?: { usd: number } }
250
251const toUsage = (u: U, startedAt: number): Usage => ({
252 startedAt,
253 ctxTokens: u.context.tokens,
254 ctxWindow: u.context.window,
255 ctxPct: u.context.percent,
256 limits: u.rateLimits.map(r => ({
257 kind: r.kind,
258 pct: r.percentUsed,
259 resetsAt: r.resetsAt ? Date.parse(r.resetsAt) : undefined,
260 })),
261 costUsd: u.cost?.usd,
262})
263
264const record = async ($: EngineInterface, u: Usage) => {
265 const now = await $.clock.now()
266 await update($, usageAtom, () => u)
267 await update($, paceAtom, pace => {
268 const next = { ...pace }
269 for (const l of u.limits) next[l.kind] = addSample(pace[l.kind], { t: now, pct: l.pct }, l.resetsAt)
270 return next
271 })
272 const five = u.limits.find(l => l.kind === 'five_hour')
273 if (five) {
274 const hist = await update($, historyAtom, h => {
275 const last = h[h.length - 1]
276 if (last && last.pct === five.pct && now - last.t < 2 * 60_000) return h
277 return [...h, { t: now, pct: five.pct }].filter(s => now - s.t <= HISTORY_MS).slice(-360)
278 })
279 await $.store.set(HISTORY_KEY, hist)
280 }
281 await checkAlerts($, u)
282}
283
284const refreshBreakdown = async ($: EngineInterface) => {
285 const u = await $.session.usage({ breakdown: 'summary' })
286 const b = u.context.breakdown
287 if (!b) return
288 await update($, groupsAtom, () => groups(b.categories))
289 const fm = b.skills?.skillFrontmatter ?? []
290 await update($, loadoutAtom, () => ({
291 skills: b.skills?.totalSkills ?? fm.length,
292 userSkills: fm.filter(s => !s.pluginName && !/bundled|built-?in|managed/i.test(s.source)).length,
293 plugins: live.plugins,
294 mcp: new Set(b.mcpTools.map(t => t.serverName)).size,
295 agents: b.agents.length,
296 }))
297}
298
299const seed = async ($: EngineInterface, cwd: string) => {
300 live.cwd = cwd
301 live.home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
302 live.label = labelFor(await $.env.get('SESSION_DASH_LABEL'), cwd)
303 try {
304 const text = await $.fs.read(`${live.home}/.claude/plugins/installed_plugins.json`)
305 const plugins = (JSON.parse(text) as { plugins?: Record<string, unknown> }).plugins
306 live.plugins = plugins ? Object.keys(plugins).length : 0
307 } catch {
308 live.plugins = 0
309 }
310 const stored = await $.store.get(HISTORY_KEY)
311 if (Array.isArray(stored)) await update($, historyAtom, () => stored)
312 const folds = await $.store.get('folds')
313 if (folds && typeof folds === 'object') {
314 await update($, foldsAtom, () => folds as Record<string, boolean>)
315 }
316 const u = await $.session.usage()
317 await record($, toUsage(u, u.startedAt))
318 await refreshBreakdown($)
319}
320
321// ── Status line install ─────────────────────────────────────────────
322
323const run = async ($: EngineInterface, args: string[]) => {
324 const r = await $.process.run(args)
325 if (r.exitCode !== 0) throw new Error(`${args[0]} exited ${r.exitCode}`)
326}
327
328// Copies the bundled scripts to the stable folder when they differ, so an
329// update of the plugin updates the status line too.
330export const installBundle = async ($: EngineInterface, home: string) => {
331 const dst = binDir(home)
332 const changed: string[] = []
333 for (const f of BUNDLE) {
334 const src = await $.fs.read(`${$.plugin.root}/bin/${f}`)
335 let cur: string | undefined
336 try {
337 cur = await $.fs.read(`${dst}/${f}`)
338 } catch {
339 // not installed yet
340 }
341 if (cur !== src) {
342 await $.fs.write(`${dst}/${f}`, src)
343 changed.push(`${dst}/${f}`)
344 }
345 }
346 if (changed.length) await run($, ['chmod', '755', ...changed])
347}
348
349// Points the user's status line at the bundled one: by itself when none is
350// set, over another one only when forced. The file is replaced atomically,
351// keeping its mode, with the previous text saved beside it.
352export const ensureStatusLine = async ($: EngineInterface, home: string, force: boolean): Promise<StatusLinePlan> => {
353 const plan = planStatusLine((await $.settings.read({ source: 'user' })).statusLine)
354 if (plan === 'ours' || (plan === 'other' && !force)) return plan
355 const path = `${home}/.claude/settings.json`
356 let text = ''
357 try {
358 text = await $.fs.read(path)
359 } catch {
360 // no user settings yet
361 }
362 const next = withStatusLine(text)
363 if (text) await $.fs.write(`${path}.bak-session-dash`, text)
364 const tmp = `${path}.session-dash.tmp`
365 await $.fs.write(tmp, next)
366 await run($, ['chmod', '600', tmp])
367 await run($, ['mv', '-f', tmp, path])
368 return plan
369}
370
371// The bundled status line and cost script, installed once a session knows
372// its home. A first install on a machine also sets the status line, when
373// none was set; a failure here costs the status line, never the dashboard.
374const setup = async ($: EngineInterface) => {
375 if (!live.home) return
376 await installBundle($, live.home)
377 if ((await ensureStatusLine($, live.home, false)) === 'set') {
378 $.ui.toast('session-dash: status line installed')
379 }
380}
381
382const collect = (on: On) => {
383 on('session.start', async ($, e, next) => {
384 const r = await next(e)
385 await update($, activeAtom, () => e.isInteractive)
386 if (e.isInteractive) {
387 swallow(seed($, e.cwd).then(() => setup($)))
388 startClocks($)
389 swallow($.command.register({ name: 'dash', description: 'Toggle the session dashboard sidebar; /dash all shows all-time usage; /dash statusline installs its status line' }))
390 }
391 return r
392 }).catch(($, e, next) => next(e))
393
394 on('session.measure', async ($, e, next) => {
395 const prev = await read($, usageAtom)
396 await record($, toUsage(e, prev?.startedAt ?? (await $.clock.now())))
397 return next(e)
398 }).catch(($, e, next) => next(e))
399
400 on('turn.step', async function* ($, e, next) {
401 if (!e.agentId) {
402 const effort = e.effort === undefined ? '' : String(e.effort)
403 swallow(update($, effortAtom, () => effort))
404 swallow(update($, modelAtom, () => prettyModel(e.model)))
405 swallow(update($, modelIdAtom, () => e.model))
406 }
407 return yield* next(e)
408 })
409
410 on('turn.start', async ($, e, next) => {
411 live.turnTools = 0
412 live.turnCost = (await read($, usageAtom))?.costUsd
413 await update($, busyAtom, () => true)
414 return next(e)
415 }).catch(($, e, next) => next(e))
416
417 on('tool.call', async ($, e, next) => {
418 const at = await $.clock.now()
419 if (e.agentId) {
420 swallow(update($, agentsAtom, s => A.tool(s, e.agentId!, String(e.tool), at)))
421 } else {
422 live.turnTools++
423 if (String(e.tool) === 'Workflow') {
424 const name = A.workflowName(e as unknown as Record<string, unknown>)
425 swallow(update($, agentsAtom, s => A.nameRun(s, e.tool_use_id, name)))
426 }
427 }
428 return next(e)
429 }).catch(($, e, next) => next(e))
430
431 on('turn.complete', async ($, e, next) => {
432 const r = await next(e)
433 const at = await $.clock.now()
434 if (e.agentId) {
435 await update($, agentsAtom, s => A.complete(s, e.agentId!, e.reason, at, e.usage?.output_tokens))
436 syncSpinner($)
437 return r
438 }
439 const u = await $.session.usage()
440 const cost = u.cost?.usd
441 await update($, turnAtom, () => ({
442 durationMs: e.durationMs,
443 tools: live.turnTools,
444 costDelta: cost !== undefined && live.turnCost !== undefined ? cost - live.turnCost : undefined,
445 }))
446 await update($, totalsAtom, t => ({
447 turns: t.turns + 1,
448 inTok: t.inTok + (e.usage ? e.usage.input_tokens + e.usage.cache_creation_input_tokens : 0),
449 outTok: t.outTok + (e.usage?.output_tokens ?? 0),
450 }))
451 await update($, busyAtom, () => false)
452 swallow(refreshBreakdown($))
453 return r
454 }).catch(($, e, next) => next(e))
455
456 on('agent.spawn', async ($, e, next) => {
457 const r = await next(e)
458 if (r.agentId) {
459 const at = await $.clock.now()
460 await update($, agentsAtom, s =>
461 A.spawn(s, {
462 id: r.agentId!,
463 type: e.subagentType,
464 description: e.description,
465 model: r.model,
466 at,
467 runId: e.workflow?.runId,
468 toolUseId: e.tool_use_id,
469 }),
470 )
471 syncSpinner($)
472 }
473 return r
474 }).catch(($, e, next) => next(e))
475
476 on('session.end', async ($, e, next) => {
477 if (e.reason === 'clear') {
478 await update($, turnAtom, () => null)
479 await update($, totalsAtom, () => ({ turns: 0, inTok: 0, outTok: 0 }))
480 await update($, agentsAtom, () => A.EMPTY)
481 await update($, groupsAtom, () => [])
482 }
483 if (live.home) {
484 swallow($.process.run(['rm', '-f', `${live.home}/.claude/session-dash/sessions/${e.sessionId}.json`]))
485 }
486 return next(e)
487 }).catch(($, e, next) => next(e))
488}
489
490// ── Views ───────────────────────────────────────────────────────────
491
492const snapshot = async ($: EngineInterface, width: number, focusAgentId?: string): Promise<DashData> => ({
493 now: await $.clock.now(),
494 tick: await read($, tickAtom),
495 width,
496 isBusy: await read($, busyAtom),
497 model: await read($, modelAtom),
498 modelId: await read($, modelIdAtom),
499 effort: await read($, effortAtom),
500 usage: await read($, usageAtom),
501 pace: await read($, paceAtom),
502 history: await read($, historyAtom),
503 turn: await read($, turnAtom),
504 totals: await read($, totalsAtom),
505 agents: await read($, agentsAtom),
506 groups: await read($, groupsAtom),
507 loadout: await read($, loadoutAtom),
508 peers: await read($, peersAtom),
509 folds: await read($, foldsAtom),
510 month: await read($, monthAtom),
511 efforts: await read($, effortsAtom),
512 focusAgentId,
513})
514
515const openDash = ($: EngineInterface) => $.ui.open({ id: PANE, title: 'Session', columns: 50 })
516
517const toggleFold = async ($: EngineInterface, section: string) => {
518 const folds = await update($, foldsAtom, f => ({ ...f, [section]: !f[section] }))
519 await $.store.set('folds', folds)
520}
521
522const compactNow = async ($: EngineInterface) => {
523 try {
524 await $.session.compact()
525 } catch {
526 $.ui.toast('Compact is only possible while Claude is idle')
527 }
528}
529
530const views = (on: On) => {
531 on('command.run', { command: 'dash' }, async ($, e) => {
532 if (e.args.trim() === 'all') return toggleAll($)
533 if (e.args.trim() === 'statusline') {
534 if (!live.home) return { text: 'The session has not started yet.' }
535 await installBundle($, live.home)
536 const was = await ensureStatusLine($, live.home, true)
537 return {
538 text:
539 was === 'ours'
540 ? 'The status line is already session-dash.'
541 : 'Status line set to session-dash (previous settings saved as settings.json.bak-session-dash).',
542 }
543 }
544 const mine = (await $.ui.panes()).find(p => p.id === PANE)
545 if (mine?.isShown) {
546 await $.ui.close({ id: PANE })
547 return { text: 'Dashboard closed.' }
548 }
549 const opened = await openDash($)
550 return { text: opened.isPlaced ? 'Dashboard open.' : 'Dashboard waits for a wider terminal.' }
551 })
552
553 on('ui.render', { component: 'Pane', requestId: 'session-dash' }, async ($, e, next) => {
554 if (e.surface !== 'terminal') return next(e)
555 const El = $.ui.resolve(e)
556 const d = await snapshot($, e.props.bodyColumns, e.props.view.agentId)
557 return (
558 <Dash
559 E={El}
560 d={d}
561 act={{
562 toggle: section => swallow(toggleFold($, section)),
563 compact: () => swallow(compactNow($)),
564 }}
565 />
566 )
567 }).catch(($, e, next) => next(e))
568
569 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
570 if (e.surface !== 'terminal' || !(await read($, activeAtom)) || e.props.hasSurvey) return next(e)
571 const isFullscreen = e.viewport?.isFullscreen === true
572 if (isFullscreen) live.sawFullscreen = true
573 const agents = await read($, agentsAtom)
574 if (A.counts(agents).running === 0) return next(e)
575 const mine = (await $.ui.panes()).find(p => p.id === PANE)
576 const isDocked = isFullscreen && mine?.isPlaced === true && mine.isShown
577 const El = $.ui.resolve(e)
578 const d = await snapshot($, e.props.bodyColumns)
579 return <Strip E={El} d={d} isDocked={isDocked} onOpen={() => swallow(openDash($))} />
580 }).catch(($, e, next) => next(e))
581}
582
583export const register: Register = on => {
584 collect(on)
585 views(on)
586 registerAll(on)
587}
588hooks/lib/agents.ts 163 lines1import type { Agent, Agents } from '../../types'
2
3const MIN = 60_000
4const LOST_AFTER = 30 * MIN
5const KEEP_AFTER = 10 * MIN
6
7export const EMPTY: Agents = { list: [], runNames: {} }
8
9export type Spawn = {
10 id: string
11 type: string
12 description: string
13 model?: string
14 at: number
15 runId?: string
16 toolUseId?: string
17}
18
19export const spawn = (s: Agents, a: Spawn): Agents => ({
20 ...s,
21 list: [
22 ...s.list.filter(x => x.id !== a.id),
23 {
24 id: a.id,
25 type: a.type,
26 description: a.description,
27 model: a.model,
28 startedAt: a.at,
29 lastEventAt: a.at,
30 tools: 0,
31 lastTools: [],
32 status: 'running',
33 runId: a.runId || undefined,
34 toolUseId: a.toolUseId || undefined,
35 },
36 ],
37})
38
39const patch = (s: Agents, id: string, fn: (a: Agent) => Agent): Agents => {
40 if (!s.list.some(a => a.id === id)) return s
41 return { ...s, list: s.list.map(a => (a.id === id ? fn(a) : a)) }
42}
43
44export const tool = (s: Agents, id: string, name: string, at: number): Agents =>
45 patch(s, id, a => ({
46 ...a,
47 tools: a.tools + 1,
48 lastTools: [...a.lastTools, name].slice(-3),
49 lastEventAt: at,
50 status: a.status === 'lost' ? 'running' : a.status,
51 }))
52
53export const complete = (
54 s: Agents,
55 id: string,
56 reason: string,
57 at: number,
58 outTok?: number,
59): Agents =>
60 patch(s, id, a => ({
61 ...a,
62 status: reason === 'answer' ? 'done' : 'failed',
63 endedAt: at,
64 lastEventAt: at,
65 outTok,
66 }))
67
68export const nameRun = (s: Agents, toolUseId: string, name: string): Agents => ({
69 ...s,
70 runNames: { ...s.runNames, [toolUseId]: name },
71})
72
73const isOpen = (a: Agent) => a.status === 'running'
74
75// Marks silent agents lost and drops what finished more than 10 min ago.
76// A workflow's agents stay until the whole run has been quiet that long.
77export const sweep = (s: Agents, now: number): Agents => {
78 const marked = s.list.map(a =>
79 isOpen(a) && now - a.lastEventAt > LOST_AFTER ? { ...a, status: 'lost' as const, endedAt: now } : a,
80 )
81 const runLast = new Map<string, number>()
82 const runOpen = new Set<string>()
83 for (const a of marked) {
84 if (!a.runId) continue
85 runLast.set(a.runId, Math.max(runLast.get(a.runId) ?? 0, a.endedAt ?? a.lastEventAt))
86 if (isOpen(a)) runOpen.add(a.runId)
87 }
88 const list = marked.filter(a => {
89 if (isOpen(a)) return true
90 if (a.runId) return runOpen.has(a.runId) || now - (runLast.get(a.runId) ?? 0) <= KEEP_AFTER
91 return now - (a.endedAt ?? a.lastEventAt) <= KEEP_AFTER
92 })
93 const isSame = list.length === s.list.length && list.every((a, i) => a === s.list[i])
94 return isSame ? s : { ...s, list }
95}
96
97export type RunRow = {
98 kind: 'run'
99 runId: string
100 name: string
101 startedAt: number
102 done: number
103 running: number
104 failed: number
105 lastTool?: string
106}
107export type AgentRow = { kind: 'agent'; agent: Agent }
108export type Row = AgentRow | RunRow
109
110const isRowRunning = (r: Row) => (r.kind === 'agent' ? isOpen(r.agent) : r.running > 0)
111const rowStart = (r: Row) => (r.kind === 'agent' ? r.agent.startedAt : r.startedAt)
112
113export const rows = (s: Agents, cap = 6): { rows: Row[]; more: number } => {
114 const out: Row[] = []
115 const runs = new Map<string, RunRow>()
116 for (const a of s.list) {
117 if (!a.runId) {
118 out.push({ kind: 'agent', agent: a })
119 continue
120 }
121 let r = runs.get(a.runId)
122 if (!r) {
123 r = {
124 kind: 'run',
125 runId: a.runId,
126 name: (a.toolUseId && s.runNames[a.toolUseId]) || 'workflow',
127 startedAt: a.startedAt,
128 done: 0,
129 running: 0,
130 failed: 0,
131 }
132 runs.set(a.runId, r)
133 out.push(r)
134 }
135 r.startedAt = Math.min(r.startedAt, a.startedAt)
136 if (isOpen(a)) {
137 r.running++
138 r.lastTool = a.lastTools.at(-1) ?? r.lastTool
139 } else if (a.status === 'done') r.done++
140 else r.failed++
141 }
142 out.sort((a, b) =>
143 isRowRunning(a) !== isRowRunning(b) ? (isRowRunning(a) ? -1 : 1) : rowStart(b) - rowStart(a),
144 )
145 return { rows: out.slice(0, cap), more: Math.max(0, out.length - cap) }
146}
147
148export const counts = (s: Agents) => {
149 const { rows: all } = rows(s, Number.MAX_SAFE_INTEGER)
150 const agents = all.filter(r => r.kind === 'agent' && isOpen(r.agent)).length
151 const runs = all.filter(r => r.kind === 'run' && r.running > 0).length
152 const running = s.list.filter(isOpen).length
153 return { agents, runs, running }
154}
155
156// A Workflow call's name: its `name` input, else the script's meta name.
157export const workflowName = (input: Record<string, unknown>): string => {
158 if (typeof input.name === 'string' && input.name) return input.name
159 const script = typeof input.script === 'string' ? input.script : ''
160 const m = /meta\s*=\s*\{[^}]*?name\s*:\s*['"`]([^'"`]+)['"`]/.exec(script)
161 return m?.[1] ?? 'workflow'
162}
163hooks/lib/breakdown.ts 53 lines1import type { Group, GroupKey } from '../../types'
2
3export const ORDER: GroupKey[] = ['messages', 'tools', 'system', 'memory', 'skills', 'other']
4
5// Category names are free text, so match loosely and fall through to other.
6export const groupOf = (name: string): GroupKey => {
7 const n = name.toLowerCase()
8 if (n.includes('message')) return 'messages'
9 if (n.includes('tool') || n.includes('mcp')) return 'tools'
10 if (n.includes('system')) return 'system'
11 if (n.includes('memory') || n.includes('claude.md')) return 'memory'
12 if (n.includes('skill')) return 'skills'
13 return 'other'
14}
15
16export const groups = (
17 categories: readonly { name: string; tokens: number; kind: string }[],
18): Group[] => {
19 const sums = new Map<GroupKey, number>()
20 for (const c of categories) {
21 if (c.kind !== 'used' || !(c.tokens > 0)) continue
22 const k = groupOf(c.name)
23 sums.set(k, (sums.get(k) ?? 0) + c.tokens)
24 }
25 return ORDER.filter(k => sums.has(k)).map(k => ({ key: k, tokens: sums.get(k)! }))
26}
27
28// Splits the bar's filled cells across the groups by largest remainder, so
29// the segments always add up to exactly the fill the window percent gives.
30export const segments = (
31 gs: readonly Group[],
32 pct: number | undefined,
33 width: number,
34): { key: GroupKey; cells: number }[] => {
35 if (pct === undefined || width <= 0) return []
36 const fill = Math.min(width, Math.max(0, Math.round((pct / 100) * width)))
37 const total = gs.reduce((s, g) => s + g.tokens, 0)
38 if (fill === 0) return []
39 if (total <= 0) return [{ key: 'other', cells: fill }]
40 const raw = gs.map(g => ({ key: g.key, exact: (g.tokens / total) * fill }))
41 const out = raw.map(r => ({ key: r.key, cells: Math.floor(r.exact) }))
42 let left = fill - out.reduce((s, o) => s + o.cells, 0)
43 const byRem = raw
44 .map((r, i) => ({ i, rem: r.exact - Math.floor(r.exact) }))
45 .sort((a, b) => b.rem - a.rem)
46 for (const { i } of byRem) {
47 if (left <= 0) break
48 out[i]!.cells++
49 left--
50 }
51 return out.filter(o => o.cells > 0)
52}
53hooks/lib/format.ts 92 lines1const MIN = 60_000
2const HOUR = 60 * MIN
3const DAY = 24 * HOUR
4
5// 24s · 1m12s · 34m · 1h 12m · 4d 2h
6export const dur = (ms: number | undefined): string => {
7 if (ms === undefined || !(ms > 0)) return '—'
8 if (ms < MIN) return `${Math.floor(ms / 1000)}s`
9 if (ms < 10 * MIN) {
10 const s = Math.floor((ms % MIN) / 1000)
11 return s ? `${Math.floor(ms / MIN)}m${String(s).padStart(2, '0')}s` : `${Math.floor(ms / MIN)}m`
12 }
13 if (ms < HOUR) return `${Math.floor(ms / MIN)}m`
14 if (ms < DAY) {
15 const m = Math.floor((ms % HOUR) / MIN)
16 return `${Math.floor(ms / HOUR)}h ${m}m`
17 }
18 return `${Math.floor(ms / DAY)}d ${Math.floor((ms % DAY) / HOUR)}h`
19}
20
21export const tok = (n: number | undefined): string => {
22 if (n === undefined || !Number.isFinite(n)) return '—'
23 if (n < 1000) return String(Math.round(n))
24 if (n < 10_000) return `${(n / 1000).toFixed(1)}k`
25 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
26 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
27}
28
29// $1,489 — whole dollars with thousands separators
30export const usdWhole = (x: number): string =>
31 '$' + String(Math.round(x)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
32
33export const usd = (x: number | undefined): string => {
34 if (x === undefined || !Number.isFinite(x)) return '—'
35 return x >= 100 ? `$${Math.round(x)}` : `$${x.toFixed(2)}`
36}
37
38export const pct = (x: number | undefined): string =>
39 x === undefined ? '—' : `${Math.round(x)}%`
40
41export const truncMid = (s: string, n: number): string => {
42 if (s.length <= n) return s
43 if (n <= 1) return '…'.slice(0, n)
44 const head = Math.ceil((n - 1) / 2)
45 const tail = n - 1 - head
46 return s.slice(0, head) + '…' + (tail ? s.slice(-tail) : '')
47}
48
49export const truncEnd = (s: string, n: number): string =>
50 s.length <= n ? s : n <= 1 ? '…' : s.slice(0, n - 1) + '…'
51
52// claude-opus-5-5 → Opus 5.5
53export const prettyModel = (id: string | undefined): string => {
54 if (!id) return 'Claude'
55 const m = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i.exec(id)
56 if (!m || !m[1] || !m[2]) return id
57 const name = m[1][0]!.toUpperCase() + m[1].slice(1).toLowerCase()
58 return m[3] ? `${name} ${m[2]}.${m[3]}` : `${name} ${m[2]}`
59}
60
61const IST_OFFSET = 330 * MIN
62const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
63const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
64const DAY_MS = 24 * HOUR
65
66// A reset time in IST, 12-hour: '1:50 AM' today, 'tomorrow 1:50 AM',
67// 'Wed 14 Oct, 2:30 PM' beyond. IST has no daylight saving, so a fixed
68// offset is exact.
69export const stamp = (ms: number, now: number): string => {
70 const ist = new Date(ms + IST_OFFSET)
71 const day = (t: number) => new Date(t + IST_OFFSET).toISOString().slice(0, 10)
72 const h = ist.getUTCHours()
73 const time = `${h % 12 || 12}:${String(ist.getUTCMinutes()).padStart(2, '0')} ${h < 12 ? 'AM' : 'PM'}`
74 if (day(ms) === day(now)) return time
75 if (day(ms) === day(now + DAY_MS)) return `tomorrow ${time}`
76 return `${DAYS[ist.getUTCDay()]} ${ist.getUTCDate()} ${MONTHS[ist.getUTCMonth()]}, ${time}`
77}
78
79const EIGHTHS = ['', '▏', '▎', '▍', '▌', '▋', '▊', '▉']
80
81// A bar `w` cells wide filled to `frac`, in eighth-cell steps, padded so
82// columns after it line up.
83export const hbar = (frac: number, w: number): string => {
84 const f = Math.min(1, Math.max(0, Number.isFinite(frac) ? frac : 0))
85 const eighths = Math.round(f * w * 8)
86 const full = Math.floor(eighths / 8)
87 const s = '█'.repeat(full) + (full < w ? EIGHTHS[eighths % 8]! : '')
88 return s.padEnd(w)
89}
90
91export const num = (n: number): string => String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
92hooks/lib/install.ts 30 lines1// The status line and the month-cost script ship inside the plugin, but an
2// installed plugin's folder carries its version, so the status line setting
3// cannot point into it. They are copied to one stable folder instead, and the
4// status line setting points there.
5
6// prices.py first: cost.py and history.py import it, and a copy that stops
7// partway must not leave the status line's cost.py without it.
8export const BUNDLE = ['prices.py', 'statusline.sh', 'cost.py', 'history.py'] as const
9
10export const binDir = (home: string) => `${home}/.claude/session-dash/bin`
11
12export const STATUS_LINE_COMMAND = 'bash "$HOME/.claude/session-dash/bin/statusline.sh"'
13
14// 'set': nothing configured, so ours goes in. 'ours': already ours.
15// 'other': someone else's, left alone unless asked with /dash statusline.
16export type StatusLinePlan = 'set' | 'ours' | 'other'
17
18export const planStatusLine = (current: unknown): StatusLinePlan => {
19 if (current === undefined || current === null) return 'set'
20 const cmd = typeof current === 'object' ? (current as { command?: unknown }).command : undefined
21 return typeof cmd === 'string' && cmd.includes('session-dash/bin/statusline.sh') ? 'ours' : 'other'
22}
23
24// settings.json's text with our status line in it, every other key kept.
25export const withStatusLine = (text: string): string => {
26 const s: unknown = text.trim() ? JSON.parse(text) : {}
27 if (!s || typeof s !== 'object' || Array.isArray(s)) throw new Error('settings.json does not hold an object')
28 return `${JSON.stringify({ ...s, statusLine: { type: 'command', command: STATUS_LINE_COMMAND } }, null, 2)}\n`
29}
30hooks/lib/pace.ts 72 lines1import type { PaceWindow, Sample } from '../../types'
2
3const MIN = 60_000
4export const WINDOW_MS: Record<string, number> = {
5 five_hour: 5 * 60 * MIN,
6 seven_day: 7 * 24 * 60 * MIN,
7}
8const KEEP_MS = 30 * MIN
9const MIN_SPAN_MS = 5 * MIN
10
11export type Pace =
12 | { state: 'unknown' }
13 | { state: 'on'; ratePerH: number }
14 | { state: 'ahead'; ratePerH: number; toFullMs: number }
15
16// Adds a reading. A drop in % or a moved reset time starts a new window.
17// Keeps the last 30 min plus one older anchor, so a quiet window still
18// has a baseline to measure from.
19export const addSample = (
20 win: PaceWindow | undefined,
21 s: Sample,
22 resetsAt?: number,
23): PaceWindow => {
24 if (!win) return { samples: [s], resetsAt }
25 const last = win.samples[win.samples.length - 1]
26 const isMoved =
27 resetsAt !== undefined &&
28 win.resetsAt !== undefined &&
29 Math.abs(resetsAt - win.resetsAt) > MIN
30 const isDropped = last !== undefined && s.pct < last.pct - 0.5
31 if (isMoved || isDropped) return { samples: [s], resetsAt }
32 if (last && last.pct === s.pct) return { ...win, resetsAt: resetsAt ?? win.resetsAt }
33 const all = [...win.samples, s]
34 const recent = all.filter(x => s.t - x.t <= KEEP_MS)
35 const anchor = all.filter(x => s.t - x.t > KEEP_MS).at(-1)
36 return {
37 samples: (anchor ? [anchor, ...recent] : recent).slice(-200),
38 resetsAt: resetsAt ?? win.resetsAt,
39 }
40}
41
42export const paceOf = (
43 win: PaceWindow | undefined,
44 now: number,
45 pct: number,
46 resetsAt?: number,
47): Pace => {
48 const base = win?.samples[0]
49 if (!base || now - base.t < MIN_SPAN_MS || (win?.samples.length ?? 0) < 2) {
50 return { state: 'unknown' }
51 }
52 const ratePerMs = (pct - base.pct) / (now - base.t)
53 const ratePerH = ratePerMs * 60 * MIN
54 if (ratePerMs <= 0) return { state: 'on', ratePerH: 0 }
55 const toFullMs = (100 - pct) / ratePerMs
56 if (resetsAt !== undefined && now + toFullMs < resetsAt) {
57 return { state: 'ahead', ratePerH, toFullMs }
58 }
59 return { state: 'on', ratePerH }
60}
61
62// Where usage would sit if spent evenly across the window: 0..1.
63export const evenFraction = (
64 kind: string,
65 now: number,
66 resetsAt?: number,
67): number | undefined => {
68 const len = WINDOW_MS[kind]
69 if (!len || resetsAt === undefined) return undefined
70 return Math.min(1, Math.max(0, 1 - (resetsAt - now) / len))
71}
72hooks/lib/peers.ts 46 lines1import type { Peer } from '../../types'
2
3export const STALE_MS = 10 * 60_000
4export const REAP_MS = 60 * 60_000
5
6export const labelFor = (envLabel: string | undefined, cwd: string): string => {
7 const set = envLabel?.trim()
8 if (set) return set
9 const base = cwd.replace(/\/+$/, '').split('/').pop()
10 return base ? base.toUpperCase() : 'SESSION'
11}
12
13export const tildify = (path: string, home: string | undefined): string =>
14 home && (path === home || path.startsWith(home + '/')) ? '~' + path.slice(home.length) : path
15
16export type Heartbeat = Omit<Peer, 'updatedAt'> & { v: 1; updatedAt: string }
17
18export const heartbeat = (p: Peer): string =>
19 JSON.stringify({ v: 1, ...p, updatedAt: new Date(p.updatedAt).toISOString() })
20
21// A peer from a heartbeat file, or undefined for a corrupt, foreign,
22// stale or own file.
23export const parsePeer = (text: string, selfId: string, now: number): Peer | undefined => {
24 let j: unknown
25 try {
26 j = JSON.parse(text)
27 } catch {
28 return undefined
29 }
30 if (!j || typeof j !== 'object') return undefined
31 const h = j as Partial<Heartbeat>
32 if (h.v !== 1 || typeof h.sessionId !== 'string' || h.sessionId === selfId) return undefined
33 const updatedAt = typeof h.updatedAt === 'string' ? Date.parse(h.updatedAt) : NaN
34 if (!Number.isFinite(updatedAt) || now - updatedAt > STALE_MS) return undefined
35 return {
36 sessionId: h.sessionId,
37 label: String(h.label ?? 'SESSION'),
38 cwd: String(h.cwd ?? ''),
39 model: String(h.model ?? ''),
40 isBusy: h.isBusy === true,
41 agentsRunning: Number(h.agentsRunning) || 0,
42 costUsd: typeof h.costUsd === 'number' ? h.costUsd : undefined,
43 updatedAt,
44 }
45}
46hooks/all.tsx 162 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, On } from 'claude-code'
3
4import { aggregate, todayOf } from './lib/history'
5import type { HistoryDash, Range, TabId } from './lib/history'
6import type { Extras, HistoryIndex, PluginInfo } from './lib/history-data'
7import { parseIndex, parsePrompts, parseStats, pickPlugins, pluginInfo, reportLine } from './lib/history-data'
8import { insights } from './lib/insights'
9import { binDir } from './lib/install'
10import { live, swallow } from './state'
11import { History } from './view/history'
12
13// /dash all: the all-time usage pane. bin/history.py keeps the index; this
14// loads it with the small files beside it, runs the indexer in the
15// background on open, and redraws when it is done.
16
17export const ALL_PANE = 'session-dash-all'
18
19const tabAtom = atom({ plugin: 'session-dash', key: 'allTab' } as const, 'overview')
20const rangeAtom = atom({ plugin: 'session-dash', key: 'allRange' } as const, 'all')
21const projectAtom = atom({ plugin: 'session-dash', key: 'allProject' } as const, null)
22const statusAtom = atom({ plugin: 'session-dash', key: 'allStatus' } as const, '')
23const revAtom = atom({ plugin: 'session-dash', key: 'allRev' } as const, 0)
24
25// Large and rebuilt from disk, so module variables rather than state.
26const hist = {
27 home: '',
28 index: null as HistoryIndex | null,
29 extras: null as Extras | null,
30 isRunning: false,
31 // the last aggregate: a render per keypress need not recount the index
32 memo: null as { key: string; d: HistoryDash; insights: ReturnType<typeof insights> } | null,
33}
34
35// Every helper given $ is a function declaration: past an import the engine
36// follows $ only into those.
37function text($: EngineInterface, path: string) {
38 return $.fs.read(path).catch(() => '')
39}
40
41// FsEntry.kind is 'file' | 'dir' | 'other', and a symlink is 'other': so a
42// folder is anything not a plain file, a file anything not a folder
43async function names($: EngineInterface, path: string, kind: 'file' | 'dir' | 'any' = 'any') {
44 try {
45 return (await $.fs.list(path))
46 .filter(e => kind === 'any' || (kind === 'file' ? e.kind !== 'dir' : e.kind !== 'file'))
47 .map(e => e.name)
48 } catch {
49 return []
50 }
51}
52
53async function readPlugins($: EngineInterface, c: string): Promise<PluginInfo[]> {
54 const out: PluginInfo[] = []
55 for (const p of pickPlugins(await text($, `${c}/plugins/installed_plugins.json`))) {
56 const md = (xs: string[]) => xs.filter(f => f.endsWith('.md')).map(f => f.slice(0, -3))
57 out.push(
58 pluginInfo({
59 name: p.name,
60 marketplace: p.marketplace,
61 root: await names($, p.path),
62 skills: await names($, `${p.path}/skills`, 'dir'),
63 agents: md(await names($, `${p.path}/agents`, 'file')),
64 commands: md(await names($, `${p.path}/commands`, 'file')),
65 mcp: [],
66 pluginJson: await text($, `${p.path}/.claude-plugin/plugin.json`),
67 mcpJson: await text($, `${p.path}/.mcp.json`),
68 }),
69 )
70 }
71 return out
72}
73
74async function load($: EngineInterface) {
75 const c = `${live.home}/.claude`
76 hist.index = parseIndex(await text($, `${c}/session-dash/history-index.json`))
77 hist.memo = null
78 hist.extras = {
79 home: live.home,
80 stats: parseStats(await text($, `${c}/stats-cache.json`)),
81 prompts: parsePrompts(await text($, `${c}/history.jsonl`)),
82 plugins: await readPlugins($, c),
83 userSkills: (await names($, `${c}/skills`, 'dir')).filter(n => n !== 'synced'),
84 }
85}
86
87// the last line: a Python traceback ends with the error itself
88const lastLine = (s: string) => s.trim().split('\n').at(-1)?.slice(0, 120) ?? ''
89
90async function reindex($: EngineInterface) {
91 if (hist.isRunning || !live.home) return
92 hist.isRunning = true
93 try {
94 // python3 by name, not by path: Windows (Git Bash) has no /usr/bin/env
95 const r = await $.process.run(['python3', '-I', `${binDir(live.home)}/history.py`, 'index'], { timeoutMs: 120_000 })
96 const line = r.exitCode === 0 ? reportLine(r.stdout) : `index failed: ${lastLine(r.stderr || r.stdout) || `exit ${r.exitCode}`}`
97 await update($, statusAtom, () => line)
98 } catch (err) {
99 await update($, statusAtom, () => `index failed: ${lastLine(String(err))}`)
100 } finally {
101 hist.isRunning = false
102 }
103 await load($)
104 await update($, revAtom, n => n + 1)
105}
106
107export async function toggleAll($: EngineInterface): Promise<{ text: string }> {
108 const mine = (await $.ui.panes()).find(p => p.id === ALL_PANE)
109 if (mine?.isShown) {
110 await $.ui.close({ id: ALL_PANE })
111 return { text: 'Usage closed.' }
112 }
113 // with no home there is no index to build: the pane would wait forever
114 if (!live.home) return { text: 'The session has not started yet.' }
115 // a reload or another home (tests) starts from disk again
116 if (hist.home !== live.home) Object.assign(hist, { home: live.home, index: null, extras: null, isRunning: false, memo: null })
117 if (!hist.index) await load($)
118 swallow(reindex($))
119 const opened = await $.ui.open({ id: ALL_PANE, title: 'Usage', columns: 110, focus: true, closeOnEscape: true })
120 return { text: opened.isPlaced ? 'Usage open.' : 'Usage waits for a wider terminal.' }
121}
122
123const RANGES: Range[] = ['all', '7d', '30d']
124
125export function registerAll(on: On) {
126 on('ui.render', { component: 'Pane', requestId: ALL_PANE }, async ($, e, next) => {
127 if (e.surface !== 'terminal') return next(e)
128 const El = $.ui.resolve(e)
129 const rev = await read($, revAtom)
130 const tab = (await read($, tabAtom)) as TabId
131 const range = (await read($, rangeAtom)) as Range
132 const project = await read($, projectAtom)
133 const status = await read($, statusAtom)
134 const now = await $.clock.now()
135 if (hist.index && hist.extras) {
136 const key = JSON.stringify([rev, range, project, todayOf(hist.index, now)])
137 if (hist.memo?.key !== key) {
138 const d = aggregate(hist.index, hist.extras, { range, project, now })
139 hist.memo = { key, d, insights: insights(d) }
140 }
141 }
142 const memo = hist.index && hist.extras ? hist.memo : null
143 const d = memo?.d ?? null
144 return (
145 <History
146 E={El}
147 w={e.props.bodyColumns}
148 d={d}
149 tab={tab}
150 status={status}
151 insight={memo?.insights[tab]}
152 home={live.home}
153 act={{
154 tab: id => swallow(update($, tabAtom, () => id)),
155 range: () => swallow(update($, rangeAtom, r => RANGES[(RANGES.indexOf(r as Range) + 1) % RANGES.length]!)),
156 project: p => swallow(update($, projectAtom, () => p)),
157 }}
158 />
159 )
160 }).catch(($, e, next) => next(e))
161}
162hooks/state.ts 18 lines1// The module's own variables: they start over on a reload, which only
2// costs the current turn's tool count.
3export const live = {
4 cwd: '',
5 home: '',
6 label: '',
7 turnTools: 0,
8 // set by the prompt band's render (which must not change state); the
9 // poll opens the sidebar once it has seen a fullscreen terminal
10 sawFullscreen: false,
11 turnCost: undefined as number | undefined,
12 plugins: 0,
13}
14
15export const swallow = (p: Promise<unknown>): void => {
16 p.catch(() => {})
17}
18hooks/view/dash.tsx 650 lines1import type { RenderNode } from 'claude-code'
2
3import type {
4 Agent,
5 Agents,
6 Group,
7 Loadout,
8 MonthCost,
9 PaceWindow,
10 Peer,
11 Sample,
12 Totals,
13 Turn,
14 Usage,
15} from '../../types'
16import * as A from '../lib/agents'
17import { segments } from '../lib/breakdown'
18import { dur, pct, prettyModel, stamp, tok, truncEnd, truncMid, usd, usdWhole } from '../lib/format'
19import { effortDots, groupPills, pills } from '../lib/meter'
20import { agentColor, deskPixels, DESK_W, toCells } from '../lib/office'
21import { evenFraction, paceOf, WINDOW_MS } from '../lib/pace'
22import { C, GROUP_COLOR, hexToInt, levelColor, modelColor } from '../lib/palette'
23import { rasterCells, resample, sparkGlyph } from '../lib/spark'
24import type { E } from './ui'
25import { Bar, cell, Hover, Row, Rule, Section } from './ui'
26
27const PILLS = 20
28const LABEL_W = 10
29
30export type DashData = {
31 now: number
32 tick: number
33 width: number
34 isBusy: boolean
35 model: string
36 modelId: string
37 effort: string
38 usage: Usage | null
39 pace: Record<string, PaceWindow>
40 history: Sample[]
41 turn: Turn | null
42 totals: Totals
43 agents: Agents
44 groups: Group[]
45 loadout: Loadout | null
46 peers: Peer[]
47 folds: Record<string, boolean>
48 month: MonthCost | null
49 efforts: Record<string, string>
50 focusAgentId?: string
51}
52
53export type DashActions = {
54 toggle: (section: string) => void
55 compact: () => void
56}
57
58const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
59const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
60const monthName = (ym: string) => MONTHS[Number(ym.slice(5, 7)) - 1] ?? ym
61
62const LIMITS: Record<string, { label: string; glyph: string; color: string }> = {
63 five_hour: { label: '5-hour', glyph: '◷', color: C.live },
64 seven_day: { label: '7-day', glyph: '▦', color: C.skills },
65 spend_limit: { label: 'Spend', glyph: '$', color: C.money },
66}
67const GROUP_LABEL = {
68 messages: 'messages',
69 tools: 'tools',
70 system: 'system',
71 memory: 'memory',
72 skills: 'skills',
73 other: 'other',
74} as const
75
76const worst = (u: Usage | null) => Math.max(0, ...(u?.limits.map(l => l.pct) ?? []))
77
78export const spinner = (tick: number) => SPIN[tick % SPIN.length]!
79
80const statusGlyph = (a: Agent, tick: number): [string, string] =>
81 a.status === 'running'
82 ? [spinner(tick), C.live]
83 : a.status === 'done'
84 ? ['✓', C.ok]
85 : a.status === 'failed'
86 ? ['✗', C.hot]
87 : ['◌', C.muted]
88
89const Dots = ({ E: El, effort, color = C.ident }: { E: E; effort: string; color?: string }) => {
90 const { Text } = El
91 const d = effortDots(effort)
92 return (
93 <Text>
94 <Text color={color}>{d.on}</Text>
95 <Text color={C.track}>{d.off}</Text>
96 <Text color={C.muted}>{` ${cell(effort || '—', 6)}`}</Text>
97 </Text>
98 )
99}
100
101// A muted header row for a table: the column names, aligned to the cells.
102const Head = ({ E: El, text }: { E: E; text: string }) => {
103 const { Text } = El
104 return <Text color={C.track}>{text}</Text>
105}
106
107// ── Header card ────────────────────────────────────────────────────────
108
109const Top = ({ E: El, d }: { E: E; d: DashData }) => {
110 const { Box, Text } = El
111 const u = d.usage
112 const w = worst(u)
113 const chip = w >= 90 ? C.hot : w >= 80 ? C.watch : C.ident
114 const elapsed = u ? d.now - u.startedAt : 0
115 const perHour =
116 u?.costUsd !== undefined && elapsed > 10 * 60_000 ? u.costUsd / (elapsed / 3_600_000) : undefined
117 const half = Math.floor(d.width / 2)
118 const pair = (label: string, value: RenderNode, valueLen: number) => (
119 <Text>
120 <Text color={C.muted}>{cell(label, 9)}</Text>
121 {value}
122 {' '.repeat(Math.max(1, half - 9 - valueLen))}
123 </Text>
124 )
125 const monthTxt = d.month ? `${usdWhole(d.month.usd)} ${monthName(d.month.month)}` : '—'
126 const rateTxt = perHour !== undefined ? `${usd(perHour)}/h` : '—'
127 const tokTxt = `↑${tok(d.totals.inTok)} ↓${tok(d.totals.outTok)}`
128 return (
129 <Box flexDirection="column" width={d.width}>
130 <Row
131 E={El}
132 w={d.width}
133 left={[
134 <Text bold color={C.ink} backgroundColor={chip}>{` ✦ ${d.model || 'Claude'} `}</Text>,
135 ' ',
136 <Dots E={El} effort={d.effort} />,
137 ]}
138 right={[
139 d.isBusy ? <Text color={C.live}>{`${spinner(d.tick)} working`}</Text> : <Text color={C.muted}>idle</Text>,
140 ]}
141 />
142 <Rule E={El} w={d.width} />
143 <Text wrap="truncate">
144 {pair('session', <Text bold color={C.money}>{usd(u?.costUsd)}</Text>, usd(u?.costUsd).length)}
145 <Text color={C.muted}>{cell('month', 8)}</Text>
146 <Text color={C.money}>{monthTxt}</Text>
147 </Text>
148 <Text wrap="truncate">
149 {pair('time', <Text>{dur(elapsed)}</Text>, dur(elapsed).length)}
150 <Text color={C.muted}>{cell('rate', 8)}</Text>
151 <Text>{rateTxt}</Text>
152 </Text>
153 <Text wrap="truncate">
154 {pair('turns', <Text>{String(d.totals.turns)}</Text>, String(d.totals.turns).length)}
155 <Text color={C.muted}>{cell('tokens', 8)}</Text>
156 <Text>{tokTxt}</Text>
157 </Text>
158 </Box>
159 )
160}
161
162// ── Agent focus (when a subagent's transcript is on screen) ────────────
163
164const Focus = ({ E: El, d, agent }: { E: E; d: DashData; agent: Agent }) => {
165 const { Box, Text } = El
166 const [g, gc] = statusGlyph(agent, d.tick)
167 const ran = (agent.endedAt ?? d.now) - agent.startedAt
168 return (
169 <Box flexDirection="column" borderStyle="round" borderColor={C.live} paddingX={1} marginTop={1} width={d.width}>
170 <Text wrap="truncate">
171 <Text color={gc}>{`${g} `}</Text>
172 <Text bold color={agentColor(agent.type)}>{agent.type}</Text>
173 <Text color={C.muted}>{agent.model ? ` · ${prettyModel(agent.model)}` : ''}</Text>
174 </Text>
175 <Text wrap="wrap">{agent.description}</Text>
176 <Text color={C.muted} wrap="truncate">
177 {`${agent.status} · ${dur(ran)} · ${agent.tools} tools${agent.outTok ? ` · ${tok(agent.outTok)} out` : ''}`}
178 </Text>
179 {agent.lastTools.length ? (
180 <Text color={C.muted} wrap="truncate">{`recent ${agent.lastTools.join(' → ')}`}</Text>
181 ) : null}
182 </Box>
183 )
184}
185
186// ── Office: everyone at their desk ─────────────────────────────────────
187
188const DESK_COL = DESK_W + 3
189
190const Desk = ({ E: El, d, a }: { E: E; d: DashData; a: Agent }) => {
191 const { Box, Raster, Text } = El
192 const color = agentColor(a.type)
193 const ran = (a.endedAt ?? d.now) - a.startedAt
194 const status =
195 a.status === 'running'
196 ? (a.lastTools.at(-1) ?? 'thinking')
197 : a.status === 'done'
198 ? `✓ ${dur(ran)}`
199 : a.status === 'failed'
200 ? '✗ failed'
201 : '◌ quiet'
202 const statusColor = a.status === 'running' ? C.fg : a.status === 'done' ? C.ok : a.status === 'failed' ? C.hot : C.muted
203 return (
204 <Box flexDirection="column" width={DESK_COL}>
205 <Raster
206 key={`desk-${a.id}`}
207 columns={DESK_W}
208 rows={4}
209 cells={rasterCells(toCells(deskPixels(a.id, a.type, a.status, d.tick)))}
210 />
211 <Text bold color={color} wrap="truncate">{cell(a.type, DESK_COL - 2)}</Text>
212 <Text color={statusColor} wrap="truncate">{cell(status, DESK_COL - 2)}</Text>
213 </Box>
214 )
215}
216
217const Office = ({ E: El, d }: { E: E; d: DashData }) => {
218 const { Box, Text } = El
219 const w = d.width - 1
220 const perRow = Math.max(1, Math.floor(w / DESK_COL))
221 const people = [...d.agents.list]
222 .sort((a, b) => Number(b.status === 'running') - Number(a.status === 'running') || a.startedAt - b.startedAt)
223 .slice(0, perRow * 2)
224 const rows: Agent[][] = []
225 for (let i = 0; i < people.length; i += perRow) rows.push(people.slice(i, i + perRow))
226 const extra = d.agents.list.length - people.length
227 return (
228 <Box flexDirection="column" width={w}>
229 {rows.map((r, i) => (
230 <Box flexDirection="row" width={w} marginTop={i ? 1 : 0}>
231 {r.map(a => (
232 <Desk E={El} d={d} a={a} />
233 ))}
234 </Box>
235 ))}
236 {extra > 0 ? <Text color={C.muted}>{`+${extra} more in the office`}</Text> : null}
237 </Box>
238 )
239}
240
241// ── Limits: one gauge line each ────────────────────────────────────────
242
243const Spark = ({ E: El, d, w }: { E: E; d: DashData; w: number }) => {
244 const { Raster } = El
245 const vals = resample(d.history, w, d.now - WINDOW_MS.five_hour!, d.now)
246 const cells = vals.map(v =>
247 v === undefined ? { ch: ' ', fg: hexToInt(C.ok) } : { ch: sparkGlyph(v), fg: hexToInt(levelColor(v)) },
248 )
249 return <Raster key="spark-5h" columns={w} rows={1} cells={rasterCells(cells)} />
250}
251
252const Limits = ({ E: El, d }: { E: E; d: DashData }) => {
253 const { Box, Text } = El
254 const limits = d.usage?.limits ?? []
255 const w = d.width - 1
256 const indent = ' '.repeat(LABEL_W)
257 if (!limits.length) return <Text color={C.muted}>Limits show after Claude's first reply.</Text>
258 return (
259 <Box flexDirection="column" width={w}>
260 {limits.map((l, i) => {
261 const meta = LIMITS[l.kind] ?? { label: l.kind, glyph: '•', color: C.muted }
262 const even = evenFraction(l.kind, d.now, l.resetsAt)
263 const p = paceOf(d.pace[l.kind], d.now, l.pct, l.resetsAt)
264 const card: RenderNode[][] = [
265 [<Text bold color={meta.color}>{`${meta.label} window`}</Text>],
266 [`${pct(l.pct)} used`, even !== undefined ? ` · even pace ${pct(even * 100)}` : ''],
267 [l.resetsAt ? `resets ${stamp(l.resetsAt, d.now)} IST` : 'reset time unknown'],
268 [p.state === 'unknown' ? 'pace needs 5 min of readings' : `burning ${p.ratePerH.toFixed(1)}% an hour`],
269 ]
270 return (
271 <Box flexDirection="column" width={w} marginTop={i ? 1 : 0}>
272 <Hover E={El} id={`limit-${l.kind}`} w={w} lines={card}>
273 <Text wrap="truncate">
274 <Text bold color={meta.color}>{cell(`${meta.glyph} ${meta.label}`, LABEL_W)}</Text>
275 <Bar E={El} runs={pills(l.pct / 100, PILLS, even)} />
276 <Text bold color={levelColor(l.pct)}>{pct(l.pct).padStart(6)}</Text>
277 </Text>
278 </Hover>
279 {l.resetsAt ? (
280 <Text wrap="truncate">
281 <Text color={C.muted}>{`${indent}↻ `}</Text>
282 <Text>{stamp(l.resetsAt, d.now)}</Text>
283 <Text color={C.muted}>{` · in ${dur(l.resetsAt - d.now)}`}</Text>
284 </Text>
285 ) : null}
286 {p.state === 'ahead' ? (
287 <Text color={C.watch} wrap="truncate">{`${indent}▲ ahead of pace · full in ${dur(p.toFullMs)}`}</Text>
288 ) : p.state === 'on' ? (
289 <Text color={C.ok} wrap="truncate">
290 {`${indent}✓ on pace${p.ratePerH > 0 ? ` · ${p.ratePerH.toFixed(1)}%/h` : ''}`}
291 </Text>
292 ) : null}
293 {l.kind === 'five_hour' && d.history.length > 1 && w >= 36 ? (
294 <Box flexDirection="row">
295 <Text>{indent}</Text>
296 <Spark E={El} d={d} w={PILLS} />
297 <Text color={C.muted}>{' last 5h'}</Text>
298 </Box>
299 ) : null}
300 </Box>
301 )
302 })}
303 </Box>
304 )
305}
306
307// ── Context ────────────────────────────────────────────────────────────
308
309const Context = ({ E: El, d, act }: { E: E; d: DashData; act: DashActions }) => {
310 const { Box, Button, Text } = El
311 const u = d.usage
312 const w = d.width - 1
313 const ctx = u?.ctxPct ?? 0
314 const segs = segments(d.groups, u?.ctxPct, PILLS)
315 const parts = segs.length
316 ? segs.map(s => ({ color: GROUP_COLOR[s.key], count: s.cells }))
317 : [{ color: levelColor(ctx), count: Math.round((ctx / 100) * PILLS) }]
318 const legend = d.groups.filter(g => g.tokens > 0)
319 const rows: Group[][] = []
320 for (let i = 0; i < legend.length; i += 2) rows.push(legend.slice(i, i + 2))
321 const colW = Math.floor((w - LABEL_W) / 2)
322 const valW = colW - 2 - 9 - 1
323 return (
324 <Box flexDirection="column" width={w}>
325 <Text wrap="truncate">
326 <Text bold color={C.context}>{cell('used', LABEL_W)}</Text>
327 <Bar E={El} runs={groupPills(parts, PILLS)} />
328 <Text bold color={levelColor(ctx)}>{pct(u?.ctxPct).padStart(6)}</Text>
329 </Text>
330 <Text wrap="truncate">
331 <Text color={C.muted}>{cell('tokens', LABEL_W)}</Text>
332 <Text>{tok(u?.ctxTokens)}</Text>
333 <Text color={C.muted}>{` of ${tok(u?.ctxWindow)}`}</Text>
334 </Text>
335 {rows.map((r, i) => (
336 <Text wrap="truncate">
337 <Text color={C.muted}>{cell(i === 0 ? 'by kind' : '', LABEL_W)}</Text>
338 {r.map(g => (
339 <Text>
340 <Text color={GROUP_COLOR[g.key]}>{'● '}</Text>
341 <Text color={C.muted}>{cell(GROUP_LABEL[g.key], 9)}</Text>
342 <Text>{cell(tok(g.tokens), valW, 'right')}</Text>
343 <Text>{' '}</Text>
344 </Text>
345 ))}
346 </Text>
347 ))}
348 {ctx >= 85 && !d.isBusy ? (
349 <Box marginTop={1}>
350 <Button key="compact" label="Compact now" variant="primary" onPress={act.compact} />
351 </Box>
352 ) : null}
353 </Box>
354 )
355}
356
357// ── Models: a table, plus one stacked share bar ────────────────────────
358
359const Models = ({ E: El, d }: { E: E; d: DashData }) => {
360 const { Box, Text } = El
361 const w = d.width - 1
362 const by = d.month?.byModel ?? {}
363 const ids = Object.keys(by).sort((a, b) => by[b]! - by[a]!)
364 if (d.modelId && !ids.includes(d.modelId)) ids.unshift(d.modelId)
365 if (!ids.length) return <Text color={C.muted}>Model usage shows once the month total is ready.</Text>
366 const total = Object.values(by).reduce((s, v) => s + v, 0)
367 const shown = ids.slice(0, 4)
368 const NAME = 13
369 const EFF = 12
370 const SHARE = 6
371 const COST = Math.max(7, w - NAME - EFF - SHARE)
372 const parts = shown.map(id => ({
373 color: modelColor(id),
374 count: total > 0 ? Math.max((by[id] ?? 0) > 0 ? 1 : 0, Math.round(((by[id] ?? 0) / total) * PILLS)) : 0,
375 }))
376 return (
377 <Box flexDirection="column" width={w}>
378 <Head E={El} text={`${cell('model', NAME)}${cell('effort', EFF)}${cell('share', SHARE, 'right')}${cell('cost', COST, 'right')}`} />
379 {shown.map(id => {
380 const usdV = by[id] ?? 0
381 const share = total > 0 ? usdV / total : 0
382 const isNow = id === d.modelId
383 const effort = isNow && d.effort ? d.effort : (d.efforts[id] ?? d.efforts['*'] ?? '')
384 const mc = modelColor(id)
385 return (
386 <Text wrap="truncate">
387 <Text bold color={mc}>{`${cell(`${prettyModel(id)}${isNow ? ' ●' : ''}`, NAME - 1)} `}</Text>
388 <Dots E={El} effort={effort} color={mc} />
389 <Text color={mc}>{cell(share > 0 && share < 0.01 ? '<1%' : pct(share * 100), SHARE, 'right')}</Text>
390 <Text color={C.money}>{cell(usdV >= 10 ? usdWhole(usdV) : usd(usdV), COST, 'right')}</Text>
391 </Text>
392 )
393 })}
394 <Text wrap="truncate">
395 <Bar E={El} runs={groupPills(parts, PILLS)} />
396 <Text color={C.muted}>{d.month ? ` ${usdWhole(d.month.usd)} in ${monthName(d.month.month)}` : ''}</Text>
397 </Text>
398 </Box>
399 )
400}
401
402// ── Activity ───────────────────────────────────────────────────────────
403
404const Activity = ({ E: El, d }: { E: E; d: DashData }) => {
405 const { Box, Text } = El
406 const t = d.turn
407 return (
408 <Box flexDirection="column" width={d.width - 1}>
409 <Text wrap="truncate">
410 <Text color={C.muted}>{cell('last turn', LABEL_W)}</Text>
411 {t ? (
412 <Text>
413 <Text>{cell(dur(t.durationMs), 9)}</Text>
414 <Text>{cell(`${t.tools} tool${t.tools === 1 ? '' : 's'}`, 10)}</Text>
415 {t.costDelta !== undefined ? <Text color={C.money}>{`+${usd(t.costDelta)}`}</Text> : ''}
416 </Text>
417 ) : (
418 <Text color={C.muted}>—</Text>
419 )}
420 </Text>
421 </Box>
422 )
423}
424
425// ── Agents: a table ────────────────────────────────────────────────────
426
427const AgentsList = ({ E: El, d }: { E: E; d: DashData }) => {
428 const { Box, Text } = El
429 const w = d.width - 1
430 const { rows, more } = A.rows(d.agents, 6)
431 if (!rows.length) return <Text color={C.muted}>No agents running.</Text>
432 const TYPE = 11
433 const TIME = 7
434 const TOOL = 7
435 const TASK = Math.max(6, w - 2 - TYPE - TIME - TOOL)
436 return (
437 <Box flexDirection="column" width={w}>
438 <Head E={El} text={` ${cell('agent', TYPE)}${cell('task', TASK)}${cell('time', TIME, 'right')}${cell(' tool', TOOL)}`} />
439 {rows.map(r => {
440 if (r.kind === 'run') {
441 const parts = [`${r.done} done`, r.running ? `${r.running} running` : '', r.failed ? `${r.failed} failed` : '']
442 .filter(Boolean)
443 .join(' · ')
444 return (
445 <Text wrap="truncate">
446 <Text color={r.running ? C.live : C.ok}>{r.running ? `${spinner(d.tick)} ` : '◆ '}</Text>
447 <Text bold color={C.live}>{cell(r.name, TYPE + 4)}</Text>
448 <Text color={r.failed ? C.hot : C.muted}>{parts}</Text>
449 </Text>
450 )
451 }
452 const a = r.agent
453 const [g, gc] = statusGlyph(a, d.tick)
454 const ran = (a.endedAt ?? d.now) - a.startedAt
455 const tool = a.status === 'running' ? (a.lastTools.at(-1) ?? '') : a.outTok ? tok(a.outTok) : ''
456 const card: RenderNode[][] = [
457 [
458 <Text bold color={agentColor(a.type)}>{a.type}</Text>,
459 <Text color={C.muted}>{a.model ? ` · ${prettyModel(a.model)}` : ''}</Text>,
460 ],
461 [truncEnd(a.description, 40)],
462 [`${a.status} · ${dur(ran)} · ${a.tools} tools`],
463 [a.lastTools.length ? `recent ${a.lastTools.join(' → ')}` : 'no tools yet'],
464 ]
465 return (
466 <Hover E={El} id={`agent-${a.id}`} w={w} isUp lines={card}>
467 <Text wrap="truncate">
468 <Text color={gc}>{`${g} `}</Text>
469 <Text color={agentColor(a.type)}>{`${cell(a.type, TYPE - 1)} `}</Text>
470 <Text color={a.status === 'running' ? C.fg : C.muted}>{`${cell(truncMid(a.description, TASK - 1), TASK - 1)} `}</Text>
471 <Text color={C.muted}>{cell(dur(ran), TIME, 'right')}</Text>
472 <Text color={C.muted}>{cell(` ${tool}`, TOOL)}</Text>
473 </Text>
474 </Hover>
475 )
476 })}
477 {more ? <Text color={C.muted}>{`+${more} more`}</Text> : null}
478 </Box>
479 )
480}
481
482// ── Sessions: a table ──────────────────────────────────────────────────
483
484const Sessions = ({ E: El, d }: { E: E; d: DashData }) => {
485 const { Box, Text } = El
486 const w = d.width - 1
487 if (!d.peers.length) return <Text color={C.muted}>No other Claude sessions running.</Text>
488 const NAME = 12
489 const COST = 8
490 const STATUS = Math.max(8, w - 2 - NAME - COST)
491 return (
492 <Box flexDirection="column" width={w}>
493 <Head E={El} text={` ${cell('session', NAME)}${cell('status', STATUS)}${cell('cost', COST, 'right')}`} />
494 {d.peers.slice(0, 6).map(p => {
495 const status = p.isBusy
496 ? `working${p.agentsRunning ? ` · ${p.agentsRunning} agent${p.agentsRunning === 1 ? '' : 's'}` : ''}`
497 : 'idle'
498 const card: RenderNode[][] = [
499 [<Text bold>{p.label}</Text>],
500 [p.cwd],
501 [`${p.model || 'Claude'} · ${usd(p.costUsd)}`],
502 [`last seen ${d.now - p.updatedAt < 60_000 ? 'just now' : `${dur(d.now - p.updatedAt)} ago`}`],
503 ]
504 return (
505 <Hover E={El} id={`peer-${p.sessionId}`} w={w} isUp lines={card}>
506 <Text wrap="truncate">
507 <Text color={p.isBusy ? C.live : C.track}>{p.isBusy ? '● ' : '○ '}</Text>
508 <Text bold>{`${cell(p.label, NAME - 1)} `}</Text>
509 <Text color={p.isBusy ? C.fg : C.muted}>{cell(status, STATUS)}</Text>
510 <Text color={C.money}>{cell(usd(p.costUsd), COST, 'right')}</Text>
511 </Text>
512 </Hover>
513 )
514 })}
515 </Box>
516 )
517}
518
519// ── Loadout ────────────────────────────────────────────────────────────
520
521const LoadoutLine = ({ E: El, d }: { E: E; d: DashData }) => {
522 const { Box, Text } = El
523 const l = d.loadout
524 if (!l) return null
525 const item = (glyph: string, color: string, n: number, label: string) => (
526 <Text>
527 <Text color={color}>{`${glyph} `}</Text>
528 <Text bold>{String(n)}</Text>
529 <Text color={C.muted}>{` ${label} `}</Text>
530 </Text>
531 )
532 return (
533 <Box flexDirection="column" marginTop={1} width={d.width}>
534 <Rule E={El} w={d.width} />
535 <Text wrap="truncate">
536 {item('✦', C.skills, l.skills, 'skills')}
537 {item('⊞', C.ident, l.plugins, 'plugins')}
538 {item('⚙', C.money, l.mcp, 'MCP')}
539 {item('◇', C.live, l.agents, 'agents')}
540 </Text>
541 </Box>
542 )
543}
544
545// ── The pane ───────────────────────────────────────────────────────────
546
547export const Dash = ({ E: El, d, act }: { E: E; d: DashData; act: DashActions }) => {
548 const { Box } = El
549 const w = d.width
550 const isOpen = (k: string) => !d.folds[k]
551 const c = A.counts(d.agents)
552 const u = d.usage
553 const focus = d.focusAgentId ? d.agents.list.find(a => a.id === d.focusAgentId) : undefined
554 const fold = (id: string) => ({ id, isOpen: isOpen(id), onToggle: () => act.toggle(id) })
555 const limitSummary = (u?.limits ?? []).map(l => `${LIMITS[l.kind]?.label ?? l.kind} ${pct(l.pct)}`).join(' · ')
556
557 return (
558 <Box flexDirection="column" width={w}>
559 <Top E={El} d={d} />
560 {focus ? <Focus E={El} d={d} agent={focus} /> : null}
561 {d.agents.list.length ? (
562 <Section
563 E={El}
564 w={w}
565 {...fold('office')}
566 hotkey="7"
567 icon="⌂"
568 title="Office"
569 color={C.context}
570 summary={{ text: c.running ? `${c.running} at work` : 'all done', color: c.running ? C.live : C.ok }}
571 >
572 <Office E={El} d={d} />
573 </Section>
574 ) : null}
575 <Section E={El} w={w} {...fold('limits')} hotkey="1" icon="◈" title="Limits" color={C.live}
576 summary={{ text: limitSummary, color: C.muted }}>
577 <Limits E={El} d={d} />
578 </Section>
579 <Section E={El} w={w} {...fold('context')} hotkey="2" icon="◔" title="Context" color={C.context}
580 summary={{ text: pct(u?.ctxPct), color: levelColor(u?.ctxPct ?? 0) }}>
581 <Context E={El} d={d} act={act} />
582 </Section>
583 <Section E={El} w={w} {...fold('models')} hotkey="3" icon="✦" title="Models" color={C.ident}
584 summary={{ text: d.month ? `${monthName(d.month.month)} so far` : '', color: C.muted }}>
585 <Models E={El} d={d} />
586 </Section>
587 <Section E={El} w={w} {...fold('activity')} hotkey="4" icon="↯" title="Activity" color={C.watch}
588 summary={{ text: d.turn ? `last ${dur(d.turn.durationMs)}` : '', color: C.muted }}>
589 <Activity E={El} d={d} />
590 </Section>
591 <Section E={El} w={w} {...fold('agents')} hotkey="5" icon="◎" title="Agents" color={C.live}
592 summary={{ text: c.running ? `${c.running} running` : 'none', color: c.running ? C.live : C.muted }}>
593 <AgentsList E={El} d={d} />
594 </Section>
595 <Section E={El} w={w} {...fold('sessions')} hotkey="6" icon="▣" title="Sessions" color={C.skills}
596 summary={{ text: d.peers.length ? `${d.peers.length} other` : 'only this one', color: C.muted }}>
597 <Sessions E={El} d={d} />
598 </Section>
599 <LoadoutLine E={El} d={d} />
600 </Box>
601 )
602}
603
604// ── The live strip above the prompt ────────────────────────────────────
605
606export const Strip = ({
607 E: El,
608 d,
609 isDocked,
610 onOpen,
611}: {
612 E: E
613 d: DashData
614 isDocked: boolean
615 onOpen: () => void
616}) => {
617 const { Box, Button, Text } = El
618 const { rows } = A.rows(d.agents, 20)
619 const agents = rows.filter((r): r is A.AgentRow => r.kind === 'agent' && r.agent.status === 'running')
620 const runs = rows.filter((r): r is A.RunRow => r.kind === 'run' && r.running > 0)
621 const c = A.counts(d.agents)
622 const parts: RenderNode[] = [
623 <Text color={C.live}>{`${spinner(d.tick)} `}</Text>,
624 <Text bold>{`${c.running} agent${c.running === 1 ? '' : 's'}`}</Text>,
625 ]
626 if (!isDocked) {
627 for (const r of agents.slice(0, 2)) {
628 parts.push(<Text color={C.track}>{' · '}</Text>)
629 parts.push(<Text color={agentColor(r.agent.type)}>{r.agent.type}</Text>)
630 const last = r.agent.lastTools.at(-1)
631 if (last) parts.push(<Text color={C.muted}>{` ${last}`}</Text>)
632 }
633 if (agents.length > 2) parts.push(<Text color={C.muted}>{` +${agents.length - 2}`}</Text>)
634 for (const r of runs.slice(0, 1)) {
635 parts.push(<Text color={C.track}>{' '}</Text>)
636 parts.push(<Text color={C.live}>{'◆ '}</Text>)
637 parts.push(<Text bold>{r.name}</Text>)
638 parts.push(
639 <Text color={C.muted}>{` ${r.done} done · ${r.running} running${r.failed ? ` · ${r.failed} failed` : ''}`}</Text>,
640 )
641 }
642 }
643 return (
644 <Box flexDirection="row" justifyContent="space-between" width={d.width}>
645 <Text wrap="truncate">{parts}</Text>
646 {isDocked ? null : <Button key="open-dash" plain hotkey="d" label="dash" onPress={onOpen} />}
647 </Box>
648 )
649}
650hooks/lib/history.ts 384 lines1import type { Extras, HistoryIndex, Parts, Rollup } from './history-data'
2import { splitWorktree } from './history-data'
3
4// Everything /dash all draws, for one range and project. Pure: the caller
5// reads the files; this only counts. Day series use stats-cache up to its
6// last computed day (it survives transcript pruning) and rollups after it;
7// with a project filter only rollups can say which project a day belongs to.
8
9export type Range = '7d' | '30d' | 'all'
10export type Opts = { range: Range; project: string | null; now: number }
11export type TabId = 'overview' | 'models' | 'projects' | 'tools' | 'plugins' | 'timeline'
12
13export type ModelRow = {
14 model: string
15 calls: number
16 in: number
17 out: number
18 cr: number
19 cw: number
20 usd: number
21 agentCalls: number
22 agentUsd: number
23 hit: number
24 perCall: number
25 share: number
26}
27
28export type ProjectRow = {
29 project: string
30 group: string
31 prompts: number
32 sessions: number
33 calls: number
34 usd: number
35 last: string | null
36 worktrees: { name: string; prompts: number; calls: number; usd: number }[]
37 branches: { name: string; n: number }[]
38 models: { model: string; calls: number }[]
39}
40
41export type Counted = { name: string; n: number }
42
43export type HistoryDash = {
44 range: Range
45 project: string | null
46 today: string
47 files: number
48 pruned: number
49 overview: {
50 sessions: number
51 messages: number
52 calls: number
53 usd: number
54 liveModels: number
55 archivedModels: number
56 projects: number
57 projectsWithTranscripts: number
58 days: { day: string; m: number }[]
59 retainedOnly: boolean
60 }
61 models: {
62 rows: ModelRow[]
63 archived: { model: string; total: number; out: number }[]
64 split: Parts | null
65 unpriced: string[]
66 }
67 projects: ProjectRow[]
68 tools: {
69 tools: Counted[]
70 skills: { name: string; source: string; n: number }[]
71 unused: string[]
72 agents: Counted[]
73 commands: Counted[]
74 }
75 plugins: {
76 plugins: { name: string; marketplace: string; status: 'used' | 'background' | 'idle'; uses: number }[]
77 mcp: { server: string; kind: 'plugin' | 'claude.ai' | 'local'; calls: number; tools: Counted[] }[]
78 }
79 timeline: {
80 active: number
81 streak: number
82 longestStreak: number
83 avgGap: number | null
84 calendar: { day: string; m: number }[]
85 hours: number[]
86 versions: { v: string; first: string; last: string; current: boolean; stale: boolean }[]
87 biggest: { day: string; m: number }[]
88 longest: { messages: number; ms: number; at: string } | null
89 firstSeen: string | null
90 }
91}
92
93const DAY = 86_400_000
94
95const tzMinutes = (tz: string) => {
96 const m = /^([+-])(\d\d):(\d\d)$/.exec(tz)
97 return m ? (m[1] === '-' ? -1 : 1) * (Number(m[2]) * 60 + Number(m[3])) : 0
98}
99const dayOf = (ms: number, tzMin: number) => new Date(ms + tzMin * 60_000).toISOString().slice(0, 10)
100// the index's own today, so a caller can tell when a cached result went stale
101export const todayOf = (index: HistoryIndex, now: number) => dayOf(now, tzMinutes(index.tz))
102const addDays = (day: string, k: number) => new Date(Date.parse(`${day}T00:00:00Z`) + k * DAY).toISOString().slice(0, 10)
103const between = (a: string, b: string) => Math.round((Date.parse(`${b}T00:00:00Z`) - Date.parse(`${a}T00:00:00Z`)) / DAY)
104
105const add = (m: Record<string, number>, k: string, n: number) => {
106 m[k] = (m[k] ?? 0) + n
107}
108const top = (m: Record<string, number>): Counted[] =>
109 Object.entries(m)
110 .filter(([, n]) => n > 0)
111 .map(([name, n]) => ({ name, n }))
112 .sort((a, b) => b.n - a.n || a.name.localeCompare(b.name))
113
114const semver = (v: string) => v.split('.').map(x => Number(x) || 0)
115const newer = (a: string, b: string) => {
116 const [x, y] = [semver(a), semver(b)]
117 for (let i = 0; i < Math.max(x.length, y.length); i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) - (y[i] ?? 0)
118 return 0
119}
120
121const groupOf = (project: string, home: string) => {
122 if (!home || !project.startsWith(`${home}/`)) return 'Other'
123 const rest = project.slice(home.length + 1).split('/')
124 return rest.length >= 2 ? `~/${rest[0]}` : 'Other'
125}
126
127const skillSource = (name: string, plugins: Set<string>, user: Set<string>) => {
128 const at = name.indexOf(':')
129 if (at > 0) {
130 const p = name.slice(0, at)
131 if (p === 'anthropic-skills') return 'claude.ai'
132 return plugins.has(p) ? p : 'built-in'
133 }
134 return user.has(name) ? 'yours' : 'built-in'
135}
136
137export const aggregate = (index: HistoryIndex, x: Extras, o: Opts): HistoryDash => {
138 const tzMin = tzMinutes(index.tz)
139 const today = dayOf(o.now, tzMin)
140 const from = o.range === 'all' ? '' : addDays(today, o.range === '7d' ? -6 : -29)
141 const inRange = (d: string) => d >= from && d <= today
142 const all = Object.values(index.sessions)
143 const ofProject = (r: Rollup) => !o.project || r.project === o.project
144 const scope = all.filter(r => ofProject(r) && Object.keys(r.days).some(inRange))
145 const prompts = x.prompts
146 .map(p => ({ ...p, ...splitWorktree(p.project), day: dayOf(p.at, tzMin) }))
147 .filter(p => (!o.project || p.project === o.project) && inRange(p.day))
148
149 // ── day series ────────────────────────────────────────────────────
150 const useStats = !o.project && x.stats !== null
151 const cut = useStats ? x.stats!.lastDay : ''
152 const series: Record<string, { m: number; s: number }> = {}
153 if (useStats) for (const [d, v] of Object.entries(x.stats!.daily)) if (inRange(d)) series[d] = { ...v }
154 const sessionsByDay: Record<string, Set<string>> = {}
155 for (const r of scope) {
156 for (const [d, v] of Object.entries(r.days)) {
157 if (!inRange(d) || d <= cut) continue
158 series[d] = series[d] ?? { m: 0, s: 0 }
159 series[d]!.m += v.m
160 }
161 if (r.startDay && inRange(r.startDay) && r.startDay > cut && !r.agentFile) {
162 ;(sessionsByDay[r.startDay] ??= new Set()).add(r.sessionId)
163 }
164 }
165 for (const [d, ids] of Object.entries(sessionsByDay)) (series[d] ??= { m: 0, s: 0 }).s += ids.size
166 const days = Object.entries(series)
167 .filter(([, v]) => v.m > 0)
168 .map(([day, v]) => ({ day, m: v.m }))
169 .sort((a, b) => a.day.localeCompare(b.day))
170
171 // ── models ────────────────────────────────────────────────────────
172 const mm: Record<string, Omit<ModelRow, 'hit' | 'perCall' | 'share'>> = {}
173 const parts: Record<string, Parts> = {}
174 const unpriced = new Set<string>()
175 for (const r of scope) {
176 r.unpriced.forEach(u => unpriced.add(u))
177 for (const [id, s] of Object.entries(r.models)) {
178 const t = (mm[id] ??= { model: id, calls: 0, in: 0, out: 0, cr: 0, cw: 0, usd: 0, agentCalls: 0, agentUsd: 0 })
179 const tp = (parts[id] ??= { in: 0, out: 0, cr: 0, cw: 0 })
180 t.calls += s.calls
181 t.in += s.in
182 t.out += s.out
183 t.cr += s.cr
184 t.cw += s.cw
185 t.usd += s.usd
186 t.agentCalls += s.agentCalls
187 t.agentUsd += s.agentUsd
188 for (const k of ['in', 'out', 'cr', 'cw'] as const) tp[k] += s.usdParts?.[k] ?? 0
189 }
190 }
191 const calls = Object.values(mm).reduce((a, m) => a + m.calls, 0)
192 const usd = Object.values(mm).reduce((a, m) => a + m.usd, 0)
193 const rows: ModelRow[] = Object.values(mm)
194 .map(m => ({
195 ...m,
196 hit: m.cr + m.cw + m.in > 0 ? m.cr / (m.cr + m.cw + m.in) : 0,
197 perCall: m.calls ? m.usd / m.calls : 0,
198 share: calls ? m.calls / calls : 0,
199 }))
200 .sort((a, b) => b.usd - a.usd || b.calls - a.calls)
201 const seenModels = new Set(all.flatMap(r => Object.keys(r.models)))
202 const archived = useStats
203 ? Object.entries(x.stats!.modelTokens)
204 .filter(([id]) => !seenModels.has(id))
205 .map(([model, t]) => ({ model, total: t.total, out: t.out }))
206 .sort((a, b) => b.total - a.total)
207 : []
208 const split = rows[0] ? { ...parts[rows[0].model]! } : null
209
210 // ── projects ──────────────────────────────────────────────────────
211 const pr: Record<string, ProjectRow & { ids: Set<string>; wt: Record<string, { prompts: number; calls: number; usd: number }>; br: Record<string, number>; md: Record<string, number> }> = {}
212 const row = (project: string) =>
213 (pr[project] ??= { project, group: groupOf(project, x.home), prompts: 0, sessions: 0, calls: 0, usd: 0, last: null, worktrees: [], branches: [], models: [], ids: new Set(), wt: {}, br: {}, md: {} })
214 for (const r of scope) {
215 const p = row(r.project)
216 const c = Object.values(r.models).reduce((a, m) => a + m.calls, 0)
217 const u = Object.values(r.models).reduce((a, m) => a + m.usd, 0)
218 p.ids.add(r.sessionId)
219 p.calls += c
220 p.usd += u
221 const last = Object.keys(r.days).filter(inRange).sort().at(-1) ?? null
222 if (last && (!p.last || last > p.last)) p.last = last
223 if (r.worktree) {
224 const w = (p.wt[r.worktree] ??= { prompts: 0, calls: 0, usd: 0 })
225 w.calls += c
226 w.usd += u
227 }
228 for (const [b, n] of Object.entries(r.branches)) add(p.br, b, n)
229 for (const [m, s] of Object.entries(r.models)) add(p.md, m, s.calls)
230 }
231 for (const q of prompts) {
232 const p = row(q.project)
233 p.prompts++
234 if (q.worktree) (p.wt[q.worktree] ??= { prompts: 0, calls: 0, usd: 0 }).prompts++
235 if (!p.last || q.day > p.last) p.last = q.day
236 }
237 const projects: ProjectRow[] = Object.values(pr)
238 .map(({ ids, wt, br, md, ...p }) => ({
239 ...p,
240 sessions: ids.size,
241 worktrees: Object.entries(wt)
242 .map(([name, w]) => ({ name, ...w }))
243 .sort((a, b) => b.usd - a.usd || b.prompts - a.prompts),
244 branches: top(br).slice(0, 3),
245 models: top(md).map(c => ({ model: c.name, calls: c.n })),
246 }))
247 .sort((a, b) => b.usd - a.usd || b.prompts - a.prompts || a.project.localeCompare(b.project))
248
249 // ── tools, skills, agents, commands ───────────────────────────────
250 const tools: Record<string, number> = {}
251 const skills: Record<string, number> = {}
252 const agents: Record<string, number> = {}
253 const commands: Record<string, number> = {}
254 const mcpCalls: Record<string, Record<string, number>> = {}
255 for (const r of scope) {
256 for (const [k, n] of Object.entries(r.tools)) add(tools, k, n)
257 for (const [k, n] of Object.entries(r.skills)) add(skills, k, n)
258 for (const [k, n] of Object.entries(r.agents)) add(agents, k, n)
259 for (const [k, n] of Object.entries(r.commands ?? {})) add(commands, k, n)
260 for (const [s, t] of Object.entries(r.mcp)) for (const [k, n] of Object.entries(t)) add((mcpCalls[s] ??= {}), k, n)
261 }
262 const pluginNames = new Set(x.plugins.map(p => p.name))
263 const userSkills = new Set(x.userSkills)
264 const skillRows = top(skills).map(s => ({ name: s.name, source: skillSource(s.name, pluginNames, userSkills), n: s.n }))
265 // a bare name counts for a plugin that ships it; a built-in of the same
266 // name would count too, which is rare enough to accept
267 const used = (name: string) => (skills[name] ?? 0) + (commands[name] ?? 0)
268 const unused = [
269 ...x.plugins.flatMap(p => p.skills.map(s => `${p.name}:${s}`)).filter(s => used(s) === 0 && used(s.split(':')[1]!) === 0),
270 ...x.userSkills.filter(s => used(s) === 0),
271 ].sort()
272
273 // ── plugins and MCP ───────────────────────────────────────────────
274 const plugins = x.plugins
275 .map(p => {
276 let uses = 0
277 // a skill and a command of one name are one thing to count, not two
278 for (const s of new Set([...p.skills, ...p.commands])) uses += used(`${p.name}:${s}`) + used(s)
279 for (const a of p.agents) uses += agents[`${p.name}:${a}`] ?? 0
280 for (const s of p.mcp) uses += Object.values(mcpCalls[`plugin_${p.name}_${s}`] ?? {}).reduce((a, n) => a + n, 0)
281 const status: 'used' | 'background' | 'idle' = uses > 0 ? 'used' : p.background ? 'background' : 'idle'
282 return { name: p.name, marketplace: p.marketplace, status, uses }
283 })
284 .sort((a, b) => b.uses - a.uses || ['used', 'background', 'idle'].indexOf(a.status) - ['used', 'background', 'idle'].indexOf(b.status) || a.name.localeCompare(b.name))
285 const servers = new Set([...Object.keys(mcpCalls), ...x.plugins.flatMap(p => p.mcp.map(s => `plugin_${p.name}_${s}`))])
286 const mcp = [...servers]
287 .map(server => {
288 const t = mcpCalls[server] ?? {}
289 return {
290 server,
291 kind: (server.startsWith('plugin_') ? 'plugin' : server.startsWith('claude_ai_') ? 'claude.ai' : 'local') as 'plugin' | 'claude.ai' | 'local',
292 calls: Object.values(t).reduce((a, n) => a + n, 0),
293 tools: top(t),
294 }
295 })
296 .sort((a, b) => b.calls - a.calls || a.server.localeCompare(b.server))
297
298 // ── timeline ──────────────────────────────────────────────────────
299 const activeDays = days.map(d => d.day)
300 const isActive = new Set(activeDays)
301 let streak = 0
302 for (let d = isActive.has(today) ? today : addDays(today, -1); isActive.has(d); d = addDays(d, -1)) streak++
303 let longestStreak = 0
304 let run = 0
305 let prev = ''
306 for (const d of activeDays) {
307 run = prev && between(prev, d) === 1 ? run + 1 : 1
308 longestStreak = Math.max(longestStreak, run)
309 prev = d
310 }
311 const avgGap = activeDays.length > 1 ? between(activeDays[0]!, activeDays.at(-1)!) / (activeDays.length - 1) : null
312 const calStart = addDays(today, -((new Date(`${today}T00:00:00Z`).getUTCDay() + 6) % 7) - 15 * 7) // Monday, 16 weeks back
313 const calendar = Array.from({ length: 16 * 7 }, (_, i) => {
314 const day = addDays(calStart, i)
315 return { day, m: series[day]?.m ?? 0 }
316 })
317 // stats-cache's hour counts and longest session are all-time totals with
318 // no dates, so they only stand in for the 'all' range
319 const allTime = useStats && o.range === 'all'
320 const hours = allTime ? [...x.stats!.hours] : Array<number>(24).fill(0)
321 for (const r of scope) {
322 if (r.agentFile || r.startHour === null || !r.startDay || !inRange(r.startDay)) continue
323 if (!allTime || r.startDay > cut) hours[r.startHour] = (hours[r.startHour] ?? 0) + 1
324 }
325 const vs: Record<string, { first: string; last: string }> = {}
326 for (const r of scope) {
327 for (const v of Object.keys(r.versions)) {
328 const s = (vs[v] ??= { first: r.start ?? '', last: r.end ?? '' })
329 if (r.start && r.start < s.first) s.first = r.start
330 if (r.end && r.end > s.last) s.last = r.end
331 }
332 }
333 const order = Object.keys(vs).sort((a, b) => newer(b, a))
334 const current = order[0]
335 const versions = order.map(v => ({
336 v,
337 first: vs[v]!.first.slice(0, 10),
338 last: vs[v]!.last.slice(0, 10),
339 current: v === current,
340 stale: v !== current && current !== undefined && vs[v]!.last > vs[current]!.first,
341 }))
342 const firstCandidates = [
343 useStats ? x.stats!.firstDay : null,
344 ...all.map(r => r.startDay),
345 ...x.prompts.map(p => dayOf(p.at, tzMin)),
346 ].filter((d): d is string => !!d)
347
348 return {
349 range: o.range,
350 project: o.project,
351 today,
352 files: all.length,
353 pruned: all.filter(r => r.pruned).length,
354 overview: {
355 sessions: Object.values(series).reduce((a, v) => a + v.s, 0),
356 messages: days.reduce((a, d) => a + d.m, 0),
357 calls,
358 usd,
359 liveModels: rows.length,
360 archivedModels: archived.length,
361 projects: projects.length,
362 projectsWithTranscripts: new Set(scope.filter(r => !r.pruned).map(r => r.project)).size,
363 days,
364 retainedOnly: !useStats,
365 },
366 models: { rows, archived, split, unpriced: [...unpriced].sort() },
367 projects,
368 tools: { tools: top(tools), skills: skillRows, unused, agents: top(agents), commands: top(commands) },
369 plugins: { plugins, mcp },
370 timeline: {
371 active: activeDays.length,
372 streak,
373 longestStreak,
374 avgGap,
375 calendar,
376 hours,
377 versions,
378 biggest: [...days].sort((a, b) => b.m - a.m).slice(0, 2),
379 longest: allTime ? x.stats!.longest : null,
380 firstSeen: firstCandidates.sort()[0] ?? null,
381 },
382 }
383}
384hooks/lib/history-data.ts 193 lines1// The shapes /dash all reads from disk, and pure parsers of each file's
2// text. Every parser takes text and never throws: a file that is missing or
3// malformed gives an empty value and the rest of the dashboard still draws.
4
5export type Parts = { in: number; out: number; cr: number; cw: number }
6
7export type ModelRoll = {
8 calls: number
9 in: number
10 out: number
11 cr: number
12 cw: number
13 usd: number
14 usdParts: Parts
15 agentCalls: number
16 agentUsd: number
17}
18
19// One transcript file, rolled up by bin/history.py.
20export type Rollup = {
21 sessionId: string
22 agentFile: boolean
23 pruned: boolean
24 cwd: string | null
25 project: string
26 worktree: string | null
27 start: string | null
28 end: string | null
29 startDay: string | null
30 startHour: number | null
31 versions: Record<string, number>
32 branches: Record<string, number>
33 models: Record<string, ModelRoll>
34 tools: Record<string, number>
35 skills: Record<string, number>
36 agents: Record<string, number>
37 mcp: Record<string, Record<string, number>>
38 commands: Record<string, number>
39 days: Record<string, { m: number; c: number }>
40 unpriced: string[]
41 skipped: number
42}
43
44export type HistoryIndex = { v: 1; builtAt: string; tz: string; sessions: Record<string, Rollup> }
45
46export type Stats = {
47 lastDay: string
48 firstDay: string | null
49 totalSessions: number
50 totalMessages: number
51 daily: Record<string, { m: number; s: number }>
52 modelTokens: Record<string, { total: number; out: number }>
53 hours: number[]
54 longest: { messages: number; ms: number; at: string } | null
55}
56
57export type Prompt = { project: string; at: number }
58
59export type PluginInfo = {
60 name: string
61 marketplace: string
62 skills: string[]
63 agents: string[]
64 commands: string[]
65 mcp: string[]
66 background: boolean
67}
68
69export type Extras = {
70 home: string
71 stats: Stats | null
72 prompts: Prompt[]
73 plugins: PluginInfo[]
74 userSkills: string[]
75}
76
77const obj = (x: unknown): Record<string, unknown> | null =>
78 x && typeof x === 'object' && !Array.isArray(x) ? (x as Record<string, unknown>) : null
79const json = (text: string): unknown => {
80 try {
81 return JSON.parse(text)
82 } catch {
83 return null
84 }
85}
86const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
87
88export const parseIndex = (text: string): HistoryIndex | null => {
89 const j = obj(json(text))
90 return j && j.v === 1 && obj(j.sessions) ? (j as unknown as HistoryIndex) : null
91}
92
93export const parseStats = (text: string): Stats | null => {
94 const j = obj(json(text))
95 if (!j || typeof j.lastComputedDate !== 'string') return null
96 const daily: Stats['daily'] = {}
97 for (const d of Array.isArray(j.dailyActivity) ? j.dailyActivity : []) {
98 const o = obj(d)
99 if (o && typeof o.date === 'string') daily[o.date] = { m: n(o.messageCount), s: n(o.sessionCount) }
100 }
101 const modelTokens: Stats['modelTokens'] = {}
102 for (const [k, v] of Object.entries(obj(j.modelUsage) ?? {})) {
103 const o = obj(v)
104 if (!o) continue
105 modelTokens[k] = {
106 total: n(o.inputTokens) + n(o.outputTokens) + n(o.cacheReadInputTokens) + n(o.cacheCreationInputTokens),
107 out: n(o.outputTokens),
108 }
109 }
110 const hours = Array.from({ length: 24 }, (_, h) => n(obj(j.hourCounts)?.[String(h)]))
111 const l = obj(j.longestSession)
112 return {
113 lastDay: j.lastComputedDate,
114 firstDay: typeof j.firstSessionDate === 'string' ? j.firstSessionDate.slice(0, 10) : null,
115 totalSessions: n(j.totalSessions),
116 totalMessages: n(j.totalMessages),
117 daily,
118 modelTokens,
119 hours,
120 longest:
121 l && typeof l.timestamp === 'string'
122 ? { messages: n(l.messageCount), ms: n(l.duration), at: l.timestamp.slice(0, 10) }
123 : null,
124 }
125}
126
127export const parsePrompts = (text: string): Prompt[] => {
128 const out: Prompt[] = []
129 for (const line of text.split('\n')) {
130 if (!line.trim()) continue
131 const o = obj(json(line))
132 if (o && typeof o.project === 'string' && typeof o.timestamp === 'number') out.push({ project: o.project, at: o.timestamp })
133 }
134 return out
135}
136
137const WORKTREE = /^(.*?)\/\.?worktrees\/([^/]+)/
138
139export const splitWorktree = (path: string): { project: string; worktree: string | null } => {
140 const m = WORKTREE.exec(path)
141 return m ? { project: m[1]!, worktree: m[2]! } : { project: path, worktree: null }
142}
143
144// installed_plugins.json: {"plugins": {"name@marketplace": [{scope, installPath}]}}
145export const pickPlugins = (text: string): { name: string; marketplace: string; path: string }[] => {
146 const plugins = obj(obj(json(text))?.plugins)
147 if (!plugins) return []
148 const out: { name: string; marketplace: string; path: string }[] = []
149 for (const [id, entries] of Object.entries(plugins)) {
150 const list = (Array.isArray(entries) ? entries : []).map(obj).filter(e => e && typeof e.installPath === 'string')
151 const pick = list.find(e => e!.scope === 'user') ?? list[0]
152 if (!pick) continue
153 const at = id.lastIndexOf('@')
154 out.push({ name: at > 0 ? id.slice(0, at) : id, marketplace: at > 0 ? id.slice(at + 1) : '', path: pick.installPath as string })
155 }
156 return out
157}
158
159// What a plugin's folder holds, read by the caller; this decides what it is.
160export const pluginInfo = (p: {
161 name: string
162 marketplace: string
163 root: string[]
164 skills: string[]
165 agents: string[]
166 commands: string[]
167 mcp: string[]
168 pluginJson: string
169 mcpJson: string
170}): PluginInfo => {
171 const mj = obj(json(p.mcpJson))
172 const servers = mj ? Object.keys(obj(mj.mcpServers) ?? mj) : []
173 const pj = obj(json(p.pluginJson)) ?? {}
174 const mcp = [...new Set([...p.mcp, ...servers, ...Object.keys(obj(pj.mcpServers) ?? {})])]
175 const countable = p.skills.length + p.agents.length + p.commands.length + mcp.length > 0
176 const passive = p.root.includes('hooks') || 'hooks' in pj || 'lspServers' in pj || p.root.includes('bin')
177 return {
178 name: p.name,
179 marketplace: p.marketplace,
180 skills: p.skills,
181 agents: p.agents,
182 commands: p.commands,
183 mcp,
184 background: !countable && passive,
185 }
186}
187
188export const reportLine = (stdout: string): string => {
189 const j = obj(json(stdout.trim()))
190 if (!j || j.ok !== true) return 'indexed'
191 return `indexed ${n(j.files)} files · ${(n(j.ms) / 1000).toFixed(1)}s · ${n(j.skipped)} skipped`
192}
193