SLOPSHOPPER

session-dash

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…

newpanebandguardcommandtoast
v0.4.0no licenseupdated 2026-10-16drprockz/claude-mods/plugins/session-dash
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dash
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ ✦ Claude ○○○○○ — │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ session $0.42 month — ⎿ Read 6 lines │ ┃ time 30m rate $0.84/h ⏺ Update(src/auth.ts) │ ┃ turns 1 tokens ↑6.4k ↓1 ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ 1: fold-limits ───────────────────────────── ⎿ 3 pass, 1 fail │ ┃ ◷ 5-hour ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 2: fold-context ──────────────────────────── │ ┃ used ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% ✻ Worked for 42s · done 4:20 PM │ ┃ tokens 97k of 200k │ ┃ › /dash │ ┃ 3: fold-models ───────────────────────────── ⎿ session-dash: Dashboard open. │ ┃ Model usage shows once the month total is r │ ┃ │ ┃ 4: fold-activity ─────────────────────────── │ ┃ last turn 42s 9 tools +$0.00 │ ┃ │ ┃ 5: fold-agents ───────────────────────────── │ ┃ No agents running. │ ┃ │ ┃ 6: fold-sessions ─────────────────────────── │ ┃ No other Claude sessions running. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
✦ Claude ○○○○○ — idle ──────────────────────────────────────────────────────── session $0.42 month — time 30m rate $0.84/h turns 1 tokens ↑6.4k ↓1.5k 1: fold-limits ───────────────────────────────── 5-hour 31% ◷ 5-hour ▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱▱▱▱▱ 31% 2: fold-context ─────────────────────────────────────── 49% used ▰▰▰▰▰▰▰▰▰▰▱▱▱▱▱▱▱▱▱▱ 49% tokens 97k of 200k 3: fold-models ─────────────────────────────────────────── Model usage shows once the month total is ready. 4: fold-activity ───────────────────────────────── last 42s last turn 42s 9 tools +$0.00 5: fold-agents ─────────────────────────────────────── none No agents running. 6: fold-sessions ──────────────────────────── only this one No other Claude sessions running.
Pane · session-dash-all
Usage needs a wider terminal.
README

claude-mods

Private Claude Code mods. The repo is itself a marketplace (drprockz-mods).

Install on a machine

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.

session-dash

  • Sidebar: /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.
  • Status line: bin/statusline.sh shows the model, effort, session and month cost, context, and the 5-hour and 7-day limits.
  • Month cost: 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.
  • All-time usage: /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).

Update

Bump version in plugins/session-dash/.claude-plugin/plugin.json, push, then on each machine run /plugin marketplace update drprockz-mods.

Develop

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.

Source 20 files
hooks/register.tsx 588 lines
1import { 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}
588
hooks/lib/agents.ts 163 lines
1import 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}
163
hooks/lib/breakdown.ts 53 lines
1import 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}
53
hooks/lib/format.ts 92 lines
1const 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, ',')
92
hooks/lib/install.ts 30 lines
1// 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}
30
hooks/lib/pace.ts 72 lines
1import 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}
72
hooks/lib/peers.ts 46 lines
1import 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}
46
hooks/all.tsx 162 lines
1import { 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}
162
hooks/state.ts 18 lines
1// 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}
18
hooks/view/dash.tsx 650 lines
1import 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}
650
hooks/lib/history.ts 384 lines
1import 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}
384
hooks/lib/history-data.ts 193 lines
1// 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