SLOPSHOPPER

session-dashboard

Side pane: changed files, subagents, scheduled tasks, and the session's context, cache, quota and cost overhead

newpaneguardcommandprompttimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-dashboard
│ ┃ Session ✕ › fix the failing auth test and add an audit log call │ ┃ 1: tab-activity 2: tab-overhead │ ┃ ⏺ Read(src/auth.ts) │ ┃ 3 files +0 −0 · 0 agents · 0 scheduled ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ FILES ⎿ Added 2 lines, removed 1 line │ ┃ ────────────────────────────────────────── ⏺ Bash(bun test) │ ┃ ───── 3 ⎿ 3 pass, 1 fail │ ┃ M cache.ts src/ now │ ┃ M audit.ts src/ now ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ M auth.ts src/ now │ ┃ ✻ Worked for 42s · done 4:20 PM │ ┃ AGENTS │ ┃ ────────────────────────────────────────── › /session-pane │ ┃ ──── 0 ⎿ session-dashboard: Session pane opened. │ ┃ none running │ ┃ │ ┃ SCHEDULED │ ┃ ────────────────────────────────────────── │ ┃ ─ 0 │ ┃ nothing scheduled │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session
1: tab-activity 2: tab-overhead 3 files +0 −0 · 0 agents · 0 scheduled FILES ─────────────────────────────────────────────── 3 M cache.ts src/ now M audit.ts src/ now M auth.ts src/ now AGENTS ────────────────────────────────────────────── 0 none running SCHEDULED ─────────────────────────────────────────── 0 nothing scheduled
README

claude-session-mods

Two Claude Code mods that keep an eye on your session.

session-dashboard

A side pane with two tabs (switch with 1 / 2, or open it with /session-pane):

  • Activity — files changed this session (new vs. modified, +added −removed, edit count, age), subagents while they run (live timer, status; press one to open its transcript), and scheduled tasks (CronCreate, /loop wake-ups) with countdowns.
  • Overhead — context window fill and auto-compact headroom, the window broken down as /context estimates it, per-turn context growth, main-conversation tokens and cache hit rate, 5h / 7d quota with a window-average projection, cost, and per-subagent token use.

ctx-band

One line above the prompt:

ctx ■■■□□□□□□□ 27% 271k/1M  ▁▁▃█▁▇▁ ↑3.4k │ cache warm 38m │ 5h 42% ↻2h34m │ 7d 63% ↻2d7h │ cost ≈$1.84 (+$0.12)

On a narrow terminal the lower-priority segments drop first; the context bar always stays.

Install

At the prompt of a Claude Code terminal session:

/plugin install session-dashboard --marketplace IanGely/claude-session-mods
/plugin install ctx-band --marketplace IanGely/claude-session-mods

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

From a shell:

claude plugin install session-dashboard --marketplace IanGely/claude-session-mods
claude plugin install ctx-band --marketplace IanGely/claude-session-mods

In the desktop app: click + next to the prompt box → Plugins → Add marketplace with IanGely/claude-session-mods, then Add plugin.

Options

Both mods have a Prompt cache TTL option (1h default, or 5m) in /config. The "cache warm" countdown is estimated from the time of the last request plus this TTL.

Notes

  • Token, cache and growth figures count the requests seen after the mod loaded.
  • Quota shows only on a Claude subscription; the projection assumes the window's average rate continues.
  • Cost is an API-price reference, not a billing receipt.

License

MIT

Source 3 files
hooks/register.tsx 668 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, Register } from 'claude-code'
3
4import type { AgentRow, ChangedFile, Job, Meter, Tab } from '../types'
5import { NO_TOKENS, OverheadView, addTokens, requestSize, toBreakdown } from './overhead'
6
7const PANE = 'session-dashboard'
8const TITLE = 'Session'
9const ACTIVE = new Set(['pending', 'running', 'waiting', 'idle'])
10const ENDED_SHOWN_MS = 5 * 60_000
11const ENDED_SHOWN_MAX = 3
12const SPINNER = ['◐', '◓', '◑', '◒']
13
14const files = atom({ plugin: 'session-dashboard', key: 'files' } as const, [])
15const agents = atom({ plugin: 'session-dashboard', key: 'agents' } as const, [])
16const jobs = atom({ plugin: 'session-dashboard', key: 'jobs' } as const, [])
17const tab = atom({ plugin: 'session-dashboard', key: 'tab' } as const, 'activity')
18const meter = atom({ plugin: 'session-dashboard', key: 'meter' } as const, null)
19const breakdown = atom({ plugin: 'session-dashboard', key: 'breakdown' } as const, null)
20const main = atom({ plugin: 'session-dashboard', key: 'main' } as const, NO_TOKENS)
21const modelInfo = atom({ plugin: 'session-dashboard', key: 'modelInfo' } as const, {})
22const agentTokens = atom({ plugin: 'session-dashboard', key: 'agentTokens' } as const, {})
23const growth = atom({ plugin: 'session-dashboard', key: 'growth' } as const, { deltas: [] })
24const compactions = atom({ plugin: 'session-dashboard', key: 'compactions' } as const, [])
25const lastRequestAt = atom({ plugin: 'session-dashboard', key: 'lastRequestAt' } as const, 0)
26const lastTurnUsd = atom({ plugin: 'session-dashboard', key: 'lastTurnUsd' } as const, null)
27
28type UsageLike = {
29  context: { tokens?: number; window: number; percent?: number }
30  rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]
31  cost?: { usd: number }
32}
33const toMeter = (u: UsageLike): Meter => ({
34  tokens: u.context.tokens,
35  window: u.context.window,
36  percent: u.context.percent,
37  limits: u.rateLimits.map(r => ({
38    kind: r.kind,
39    percent: r.percentUsed,
40    resetsAt: r.resetsAt === undefined ? undefined : Date.parse(r.resetsAt),
41  })),
42  usd: u.cost?.usd,
43})
44
45// ---------- pure helpers ----------
46
47type Hunk = { lines: string[] }
48
49const diffStat = (patch: readonly Hunk[] | undefined) => {
50  let added = 0
51  let removed = 0
52  for (const hunk of patch ?? []) {
53    for (const line of hunk.lines) {
54      if (line.startsWith('+')) added++
55      else if (line.startsWith('-')) removed++
56    }
57  }
58  return { added, removed }
59}
60
61const addFile =
62  (path: string, isNew: boolean, stat: { added: number; removed: number }, at: number) =>
63  (list: ChangedFile[]): ChangedFile[] => {
64    const old = list.find(f => f.path === path)
65    const rest = list.filter(f => f.path !== path)
66    const row: ChangedFile = {
67      path,
68      isNew: old?.isNew ?? isNew,
69      added: (old?.added ?? 0) + stat.added,
70      removed: (old?.removed ?? 0) + stat.removed,
71      edits: (old?.edits ?? 0) + 1,
72      at,
73    }
74    return [...rest, row].slice(-300)
75  }
76
77/** Merge the engine's agent list into ours, keeping start/end times. */
78const mergeAgents = (prev: AgentRow[], live: AgentInfo[], now: number): AgentRow[] => {
79  const seen = new Map(prev.map(a => [a.id, a]))
80  const liveIds = new Set(live.map(a => a.id))
81  const next: AgentRow[] = []
82  for (const a of live) {
83    const old = seen.get(a.id)
84    const isActive = ACTIVE.has(a.status)
85    // Only track agents we saw running, or that are running now.
86    if (old === undefined && !isActive) continue
87    next.push({
88      id: a.id,
89      type: a.type,
90      description: a.description,
91      status: a.status,
92      startedAt: old?.startedAt ?? now,
93      endedAt: isActive ? undefined : (old?.endedAt ?? now),
94    })
95  }
96  for (const old of prev) {
97    if (liveIds.has(old.id)) continue
98    // Gone from the engine's list: it ended.
99    next.push(
100      old.endedAt === undefined ? { ...old, status: 'completed', endedAt: now } : old,
101    )
102  }
103  return next.filter(a => a.endedAt === undefined || now - a.endedAt < ENDED_SHOWN_MS)
104}
105
106const pending = (list: Job[], now: number) =>
107  list.filter(j => j.recurring || j.nextFireAt === undefined || j.nextFireAt > now - 5000)
108
109const pad = (n: number) => String(n).padStart(2, '0')
110const clockTime = (ms: number) => {
111  const d = new Date(ms)
112  return `${pad(d.getHours())}:${pad(d.getMinutes())}`
113}
114const duration = (ms: number) => {
115  const s = Math.max(0, Math.floor(ms / 1000))
116  if (s < 60) return `${s}s`
117  const m = Math.floor(s / 60)
118  if (m < 60) return `${m}m${pad(s % 60)}s`
119  return `${Math.floor(m / 60)}h${pad(m % 60)}m`
120}
121const ago = (ms: number) => {
122  const s = Math.max(0, Math.floor(ms / 1000))
123  if (s < 10) return 'now'
124  if (s < 60) return `${s}s`
125  if (s < 3600) return `${Math.floor(s / 60)}m`
126  return `${Math.floor(s / 3600)}h`
127}
128const oneLine = (text: string) => text.replace(/\s+/g, ' ').trim()
129const splitPath = (path: string, cwd: string) => {
130  const rel = cwd !== '' && path.startsWith(cwd + '/') ? path.slice(cwd.length + 1) : path
131  const cut = rel.lastIndexOf('/')
132  return cut < 0 ? { dir: '', name: rel } : { dir: rel.slice(0, cut + 1), name: rel.slice(cut + 1) }
133}
134
135const AGENT_LOOK: Record<string, { mark: string; color: string; label: string }> = {
136  pending: { mark: '○', color: 'inactive', label: 'starting' },
137  running: { mark: '', color: 'claude', label: 'running' },
138  waiting: { mark: '◌', color: 'warning', label: 'waiting' },
139  idle: { mark: '◌', color: 'subtle', label: 'idle' },
140  completed: { mark: '✓', color: 'success', label: 'done' },
141  failed: { mark: '✗', color: 'error', label: 'failed' },
142  killed: { mark: '■', color: 'inactive', label: 'stopped' },
143}
144
145// ---------- the mod ----------
146
147export const register: Register = (on, options) => {
148  let cwd = ''
149  let sessionId = ''
150  const ttlMs = options.cacheTtl === '5m' ? 5 * 60_000 : 60 * 60_000
151  // Cost and context size when the current main-loop turn began.
152  let turnStartUsd: number | undefined
153  let mainContext: number | undefined
154
155  on('session.start', async ($, e, next) => {
156    cwd = e.cwd
157    sessionId = await $.session.id()
158    const usage = await $.session.usage({ breakdown: 'summary' })
159    await update($, meter, () => toMeter(usage))
160    if (usage.context.breakdown !== undefined) {
161      const b = toBreakdown(usage.context.breakdown)
162      await update($, breakdown, () => b)
163    }
164    await $.command.register({
165      name: 'session-pane',
166      description: 'Open the pane of changed files, subagents and scheduled tasks',
167    })
168    void $.ui.open({ id: PANE, title: TITLE })
169
170    $.clock.every(1000, async () => {
171      const now = await $.clock.now()
172      const before = await read($, agents)
173      const merged = mergeAgents(before, await $.agent.list(), now)
174      if (JSON.stringify(before) !== JSON.stringify(merged)) {
175        await update($, agents, () => merged)
176      }
177      const list = await read($, jobs)
178      if (pending(list, now).length !== list.length) {
179        await update($, jobs, l => pending(l, now))
180      }
181      // Keep the clocks (elapsed time, countdowns) moving while anything is live.
182      const isLive = merged.some(a => a.endedAt === undefined) || list.length > 0
183      const isMinute = Math.floor(now / 1000) % 30 === 0
184      if (isLive || isMinute) $.ui.invalidate('ui.render')
185    })
186
187    return next(e)
188  })
189
190  on('command.run', { command: 'session-pane' }, async $ => {
191    await $.ui.open({ id: PANE, title: TITLE, focus: true })
192
193    return { text: 'Session pane opened.' }
194  }).catch(() => ({ text: 'Could not open the session pane.' }))
195
196  // ----- overhead: requests, turns, measurements, compactions -----
197
198  on('turn.step', async function* ($, e, next) {
199    const step = yield* next(e)
200    const u = step.usage
201    if (u === null) return step
202
203    const now = await $.clock.now()
204    const effort = e.effort === undefined ? undefined : String(e.effort)
205    if (e.agentId === undefined) {
206      mainContext = requestSize(u)
207      await update($, main, t => addTokens(t, u))
208      await update($, modelInfo, () => ({ model: u.model || e.model, effort }))
209      await update($, lastRequestAt, () => now)
210    } else {
211      const id = e.agentId
212      await update($, agentTokens, all => {
213        const old = all[id]
214        return {
215          ...all,
216          [id]: {
217            model: u.model || e.model,
218            effort,
219            tokens: addTokens(old?.tokens ?? NO_TOKENS, u),
220            inputs: [...(old?.inputs ?? []), requestSize(u)].slice(-12),
221          },
222        }
223      })
224    }
225    return step
226  })
227
228  on('session.measure', async ($, e, next) => {
229    const measured = await next(e)
230    await update($, meter, () => toMeter(e))
231    return measured
232  }).catch(($, e, next) => next(e))
233
234  on('prompt.submit', async ($, e, next) => {
235    turnStartUsd = (await read($, meter))?.usd
236    return next(e)
237  }).catch(($, e, next) => next(e))
238
239  on('turn.complete', async ($, e, next) => {
240    const done = await next(e)
241    if (e.agentId !== undefined) return done
242
243    const usage = await $.session.usage({ breakdown: 'summary' })
244    const after = toMeter(usage)
245    await update($, meter, () => after)
246    if (usage.context.breakdown !== undefined) {
247      const b = toBreakdown(usage.context.breakdown)
248      await update($, breakdown, () => b)
249    }
250    if (turnStartUsd !== undefined && after.usd !== undefined) {
251      const spent = after.usd - turnStartUsd
252      await update($, lastTurnUsd, () => spent)
253    }
254    turnStartUsd = after.usd
255    const size = mainContext ?? after.tokens
256    if (size !== undefined) {
257      await update($, growth, g => ({
258        last: size,
259        deltas: g.last === undefined ? g.deltas : [...g.deltas, size - g.last].slice(-200),
260      }))
261    }
262    return done
263  }).catch(($, e, next) => next(e))
264
265  on('session.compact', async ($, e, next) => {
266    const result = await next(e)
267    if (e.agentId === undefined && result.skip === undefined) {
268      const { tokensBefore, tokensAfter } = result
269      if (tokensBefore !== undefined && tokensAfter !== undefined) {
270        await update($, compactions, list => [...list, { before: tokensBefore, after: tokensAfter }])
271        // The next turn's growth counts from the compacted size.
272        await update($, growth, g => ({ ...g, last: tokensAfter }))
273        mainContext = tokensAfter
274      }
275    }
276    return result
277  }).catch(($, e, next) => next(e))
278
279  // ----- files -----
280
281  on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
282    const ran = await next(e)
283    if (ran.deny === undefined && ran.isError !== true && ran.result !== undefined) {
284      const stat = diffStat(ran.result.structuredPatch)
285      await update($, files, addFile(e.file_path, false, stat, await $.clock.now()))
286    }
287    return ran
288  }).catch(($, e, next) => next(e))
289
290  on('tool.call', { tool: 'Write' }, async ($, e, next) => {
291    const ran = await next(e)
292    if (ran.deny === undefined && ran.isError !== true && ran.result !== undefined) {
293      const r = ran.result
294      const stat =
295        r.type === 'create'
296          ? { added: e.content.split('\n').length, removed: 0 }
297          : diffStat(r.structuredPatch)
298      await update($, files, addFile(e.file_path, r.type === 'create', stat, await $.clock.now()))
299    }
300    return ran
301  }).catch(($, e, next) => next(e))
302
303  on('tool.call', { tool: 'NotebookEdit' }, async ($, e, next) => {
304    const ran = await next(e)
305    if (ran.deny === undefined && ran.isError !== true) {
306      const stat = { added: 0, removed: 0 }
307      await update($, files, addFile(e.notebook_path, false, stat, await $.clock.now()))
308    }
309    return ran
310  }).catch(($, e, next) => next(e))
311
312  // ----- subagents: show a new one at once, not at the next poll -----
313
314  on('agent.spawn', async ($, e, next) => {
315    const spawned = await next(e)
316    const now = await $.clock.now()
317    const live = await $.agent.list()
318    await update($, agents, prev => mergeAgents(prev, live, now))
319    return spawned
320  }).catch(($, e, next) => next(e))
321
322  // ----- scheduled tasks -----
323
324  on('tool.call', { tool: 'CronCreate' }, async ($, e, next) => {
325    const ran = await next(e)
326    if (ran.deny === undefined && ran.isError !== true && ran.result !== undefined) {
327      const r = ran.result
328      const job: Job = {
329        id: r.id,
330        kind: 'cron',
331        schedule: r.humanSchedule || e.cron,
332        prompt: e.prompt,
333        recurring: r.recurring,
334        nextFireAt: r.nextFireAt,
335      }
336      await update($, jobs, list => [...list.filter(j => j.id !== job.id), job])
337    }
338    return ran
339  }).catch(($, e, next) => next(e))
340
341  on('tool.call', { tool: 'CronDelete' }, async ($, e, next) => {
342    const ran = await next(e)
343    if (ran.deny === undefined && ran.isError !== true) {
344      await update($, jobs, list => list.filter(j => j.id !== e.id))
345    }
346    return ran
347  }).catch(($, e, next) => next(e))
348
349  on('tool.call', { tool: 'CronList' }, async ($, e, next) => {
350    const ran = await next(e)
351    if (ran.deny === undefined && ran.isError !== true && ran.result !== undefined) {
352      const crons: Job[] = ran.result.jobs.map(j => ({
353        id: j.id,
354        kind: 'cron',
355        schedule: j.humanSchedule || j.cron,
356        prompt: j.prompt,
357        recurring: j.recurring ?? true,
358        nextFireAt: j.nextFireAt,
359      }))
360      await update($, jobs, list => [...crons, ...list.filter(j => j.kind === 'wakeup')])
361    }
362    return ran
363  }).catch(($, e, next) => next(e))
364
365  on('tool.call', { tool: 'ScheduleWakeup' }, async ($, e, next) => {
366    const ran = await next(e)
367    if (ran.deny === undefined && ran.isError !== true && ran.result !== undefined) {
368      const r = ran.result
369      const crons = (list: Job[]) => list.filter(j => j.kind !== 'wakeup')
370      if (r.stopped === true) {
371        await update($, jobs, crons)
372      } else {
373        const wakeup: Job = {
374          id: `wakeup-${r.scheduledFor}`,
375          kind: 'wakeup',
376          schedule: '/loop wake-up',
377          prompt: e.prompt ?? '',
378          recurring: false,
379          nextFireAt: r.scheduledFor,
380        }
381        await update($, jobs, list => [...crons(list), wakeup])
382      }
383    }
384    return ran
385  }).catch(($, e, next) => next(e))
386
387  // ----- drawing -----
388
389  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
390    const { Box, Text, Button } = $.ui.resolve(e)
391    const width = Math.max(24, e.props.bodyColumns)
392    const height = e.props.scroll?.bodyRows ?? e.viewport?.rows ?? 30
393    const now = await $.clock.now()
394    const fileList = await read($, files)
395    const agentList = await read($, agents)
396    const jobList = await read($, jobs)
397    const viewedAgent = e.props.view?.agentId
398    const currentTab: Tab = await read($, tab)
399
400    const tabs = (
401      <Box flexDirection="row" gap={2}>
402        {(['activity', 'overhead'] as const).map((id, i) => (
403          <Button
404            key={`tab-${id}`}
405            plain
406            hotkey={String(i + 1)}
407            dimColor={currentTab !== id}
408            onPress={() => void update($, tab, () => id)}
409          >
410            <Text bold={currentTab === id} underline={currentTab === id}>
411              {id === 'activity' ? 'Activity' : 'Overhead'}
412            </Text>
413          </Button>
414        ))}
415      </Box>
416    )
417
418    if (currentTab === 'overhead') {
419      return (
420        <Box flexDirection="column" paddingX={1}>
421          {tabs}
422          <OverheadView
423            el={$.ui.resolve(e)}
424            now={now}
425            width={width}
426            data={{
427              sessionId,
428              meter: await read($, meter),
429              breakdown: await read($, breakdown),
430              main: await read($, main),
431              modelInfo: await read($, modelInfo),
432              agentTokens: await read($, agentTokens),
433              agents: agentList,
434              growth: await read($, growth),
435              compactions: await read($, compactions),
436              lastRequestAt: await read($, lastRequestAt),
437              lastTurnUsd: await read($, lastTurnUsd),
438              ttlMs,
439            }}
440          />
441        </Box>
442      )
443    }
444
445    const active = agentList.filter(a => a.endedAt === undefined)
446    const ended = agentList
447      .filter(a => a.endedAt !== undefined)
448      .sort((a, b) => (b.endedAt ?? 0) - (a.endedAt ?? 0))
449      .slice(0, ENDED_SHOWN_MAX)
450    const totalAdded = fileList.reduce((n, f) => n + f.added, 0)
451    const totalRemoved = fileList.reduce((n, f) => n + f.removed, 0)
452
453    // Rows left for files once the other sections have theirs.
454    const fixedRows = 6 + (active.length + ended.length) * 2 + jobList.length * 2 + 8
455    const fileRoom = Math.max(4, height - fixedRows)
456    const shownFiles = [...fileList].sort((a, b) => b.at - a.at).slice(0, fileRoom)
457
458    const Header = (props: { label: string; count: string; color: string }) => {
459      const rule = Math.max(2, width - props.label.length - props.count.length - 3)
460      return (
461        <Box marginTop={1}>
462          <Text>
463            <Text bold color={props.color}>
464              {props.label}
465            </Text>
466            <Text color="subtle"> {'─'.repeat(rule)} </Text>
467            <Text dimColor>{props.count}</Text>
468          </Text>
469        </Box>
470      )
471    }
472
473    const Empty = (props: { text: string }) => (
474      <Box paddingLeft={2}>
475        <Text dimColor italic>
476          {props.text}
477        </Text>
478      </Box>
479    )
480
481    // ----- summary -----
482    const summary = (
483      <Box flexDirection="column">
484        <Text>
485          <Text bold>{fileList.length}</Text>
486          <Text dimColor> files </Text>
487          <Text color="diffAdded">+{totalAdded}</Text>
488          <Text> </Text>
489          <Text color="diffRemoved">−{totalRemoved}</Text>
490          <Text dimColor>  ·  </Text>
491          <Text bold color={active.length > 0 ? 'claude' : undefined}>
492            {active.length}
493          </Text>
494          <Text dimColor> agents  ·  </Text>
495          <Text bold color={jobList.length > 0 ? 'suggestion' : undefined}>
496            {jobList.length}
497          </Text>
498          <Text dimColor> scheduled</Text>
499        </Text>
500        {viewedAgent !== undefined && (
501          <Box marginTop={1}>
502            <Button key="back-to-main" onPress={() => void $.ui.view({})}>
503              ← Back to main conversation
504            </Button>
505          </Box>
506        )}
507      </Box>
508    )
509
510    // ----- files -----
511    const fileRows = shownFiles.map(f => {
512      const { dir, name } = splitPath(f.path, cwd)
513      return (
514        <Box flexDirection="row" justifyContent="space-between" paddingLeft={1}>
515          <Box flexShrink={1} flexGrow={1}>
516            <Text wrap="truncate-end">
517              <Text bold color={f.isNew ? 'success' : 'warning'}>
518                {f.isNew ? 'A ' : 'M '}
519              </Text>
520              <Text>{name}</Text>
521              <Text dimColor> {dir}</Text>
522            </Text>
523          </Box>
524          <Box flexShrink={0} paddingLeft={1}>
525            <Text>
526              {f.added > 0 && <Text color="diffAdded">+{f.added}</Text>}
527              {f.added > 0 && f.removed > 0 && <Text> </Text>}
528              {f.removed > 0 && <Text color="diffRemoved">−{f.removed}</Text>}
529              {f.edits > 1 && <Text dimColor> ×{f.edits}</Text>}
530              <Text dimColor> {ago(now - f.at).padStart(3)}</Text>
531            </Text>
532          </Box>
533        </Box>
534      )
535    })
536
537    // ----- agents -----
538    const agentRow = (a: AgentRow) => {
539      const look = AGENT_LOOK[a.status] ?? AGENT_LOOK.running!
540      const isEnded = a.endedAt !== undefined
541      const mark = a.status === 'running' ? SPINNER[Math.floor(now / 500) % 4]! : look.mark
542      const time = isEnded
543        ? `${duration((a.endedAt ?? now) - a.startedAt)} · ${ago(now - (a.endedAt ?? now))} ago`
544        : duration(now - a.startedAt)
545      const isViewed = viewedAgent === a.id
546      return (
547        <Box flexDirection="column" paddingLeft={1}>
548          <Box flexDirection="row" justifyContent="space-between">
549            <Box flexShrink={1}>
550              <Button
551                key={`agent-${a.id}`}
552                plain
553                dimColor={isEnded}
554                onPress={() => void $.ui.view({ agentId: a.id })}
555              >
556                <Text color={look.color}>{mark} </Text>
557                <Text bold={!isEnded} inverse={isViewed}>
558                  {a.type}
559                </Text>
560              </Button>
561            </Box>
562            <Box flexShrink={0} paddingLeft={1}>
563              <Text>
564                <Text color={look.color} dimColor={isEnded}>
565                  {look.label}
566                </Text>
567                <Text dimColor> {time}</Text>
568              </Text>
569            </Box>
570          </Box>
571          <Box paddingLeft={2}>
572            <Text dimColor wrap="truncate-end">
573              {oneLine(a.description) || '—'}
574            </Text>
575          </Box>
576        </Box>
577      )
578    }
579
580    // ----- scheduled -----
581    const jobRow = (j: Job) => {
582      const until = j.nextFireAt !== undefined ? j.nextFireAt - now : undefined
583      const when =
584        !j.recurring && until !== undefined
585          ? until > 0
586            ? `in ${duration(until)} · ${clockTime(j.nextFireAt!)}`
587            : 'firing…'
588          : 'recurring'
589      return (
590        <Box flexDirection="column" paddingLeft={1}>
591          <Box flexDirection="row" justifyContent="space-between">
592            <Box flexShrink={1}>
593              <Text wrap="truncate-end">
594                <Text color="suggestion">{j.kind === 'wakeup' ? '⏱ ' : '↻ '}</Text>
595                <Text bold>{j.schedule}</Text>
596              </Text>
597            </Box>
598            <Box flexShrink={0} paddingLeft={1}>
599              <Text dimColor>{when}</Text>
600            </Box>
601          </Box>
602          <Box paddingLeft={2}>
603            <Text dimColor wrap="truncate-end">
604              {j.kind === 'cron' ? `${j.id} · ` : ''}
605              {oneLine(j.prompt) || '—'}
606            </Text>
607          </Box>
608        </Box>
609      )
610    }
611
612    const isEmpty = fileList.length === 0 && agentList.length === 0 && jobList.length === 0
613    if (isEmpty) {
614      return (
615        <Box flexDirection="column" paddingX={1}>
616          {tabs}
617          <Box marginTop={1}>
618            <Text bold>Nothing yet</Text>
619          </Box>
620          <Box marginTop={1} flexDirection="column">
621            <Text dimColor>This pane follows the session as it works:</Text>
622            <Text>
623              <Text color="warning">  M </Text>
624              <Text dimColor>files Claude edits or creates</Text>
625            </Text>
626            <Text>
627              <Text color="claude">  ◐ </Text>
628              <Text dimColor>subagents while they run</Text>
629            </Text>
630            <Text>
631              <Text color="suggestion">  ↻ </Text>
632              <Text dimColor>scheduled tasks and /loop wake-ups</Text>
633            </Text>
634          </Box>
635        </Box>
636      )
637    }
638
639    return (
640      <Box flexDirection="column" paddingX={1}>
641        {tabs}
642        <Box marginTop={1}>{summary}</Box>
643
644        <Header label="FILES" count={String(fileList.length)} color="text" />
645        {fileList.length === 0 ? <Empty text="no changes yet" /> : fileRows}
646        {fileList.length > shownFiles.length && (
647          <Empty text={`… ${fileList.length - shownFiles.length} older`} />
648        )}
649
650        <Header
651          label="AGENTS"
652          count={ended.length > 0 ? `${active.length} live · ${ended.length} done` : String(active.length)}
653          color="claude"
654        />
655        {agentList.length === 0 ? (
656          <Empty text="none running" />
657        ) : (
658          [...active, ...ended].map(agentRow)
659        )}
660        {active.length > 0 && <Empty text="press a name to open its transcript" />}
661
662        <Header label="SCHEDULED" count={String(jobList.length)} color="suggestion" />
663        {jobList.length === 0 ? <Empty text="nothing scheduled" /> : jobList.map(jobRow)}
664      </Box>
665    )
666  })
667}
668
hooks/overhead.tsx 420 lines
1import type { ElementTable } from 'claude-code'
2
3import type {
4  AgentRow,
5  AgentTokens,
6  Breakdown,
7  Compaction,
8  Growth,
9  Meter,
10  ModelInfo,
11  Tokens,
12} from '../types'
13
14// ---------- data helpers ----------
15
16export const NO_TOKENS: Tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, uncached: 0 }
17
18type Usage = {
19  input_tokens: number
20  output_tokens: number
21  cache_read_input_tokens: number
22  cache_creation_input_tokens: number
23}
24
25export const requestSize = (u: Usage) =>
26  u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
27
28export const addTokens = (t: Tokens, u: Usage): Tokens => ({
29  input: t.input + requestSize(u),
30  output: t.output + u.output_tokens,
31  cacheRead: t.cacheRead + u.cache_read_input_tokens,
32  cacheWrite: t.cacheWrite + u.cache_creation_input_tokens,
33  uncached: t.uncached + u.input_tokens,
34})
35
36type RawBreakdown = {
37  categories: readonly { name: string; tokens: number; color: string; kind: string }[]
38  mcpTools: readonly { serverName: string; tokens: number; isLoaded: boolean }[]
39  totalTokens: number
40  rawMaxTokens: number
41  autoCompactThreshold?: number
42}
43
44export const toBreakdown = (b: RawBreakdown): Breakdown => {
45  const servers = new Map<string, number>()
46  for (const tool of b.mcpTools) {
47    if (!tool.isLoaded) continue
48    servers.set(tool.serverName, (servers.get(tool.serverName) ?? 0) + tool.tokens)
49  }
50  return {
51    rows: b.categories
52      .filter(c => c.kind === 'used' && c.tokens > 0)
53      .map(c => ({ name: c.name, tokens: c.tokens, color: c.color }))
54      .sort((x, y) => y.tokens - x.tokens),
55    mcpServers: [...servers]
56      .map(([name, tokens]) => ({ name, tokens, color: 'subtle' }))
57      .sort((x, y) => y.tokens - x.tokens),
58    deferred: b.categories.filter(c => c.kind === 'deferred').reduce((n, c) => n + c.tokens, 0),
59    total: b.totalTokens,
60    maxTokens: b.rawMaxTokens,
61    compactAt: b.autoCompactThreshold,
62  }
63}
64
65// ---------- formatting ----------
66
67export const compact = (n: number) => {
68  const abs = Math.abs(n)
69  if (abs >= 1_000_000) return `${+(n / 1_000_000).toFixed(abs >= 10_000_000 ? 0 : 1)}M`
70  if (abs >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
71  return String(Math.round(n))
72}
73export const span = (ms: number) => {
74  const m = Math.max(0, Math.round(ms / 60_000))
75  if (m < 60) return `${m}m`
76  const h = Math.floor(m / 60)
77  if (h < 24) return `${h}h${String(m % 60).padStart(2, '0')}m`
78  return `${Math.floor(h / 24)}d${h % 24}h`
79}
80const pct = (part: number, whole: number) => (whole > 0 ? Math.round((part / whole) * 100) : 0)
81const level = (percent: number) => (percent >= 85 ? 'error' : percent >= 60 ? 'warning' : 'success')
82
83const SPARKS = '▁▂▃▄▅▆▇█'
84export const sparkline = (values: number[]) => {
85  const top = Math.max(1, ...values)
86  return values.map(v => SPARKS[v <= 0 ? 0 : Math.min(7, Math.round((v / top) * 7))]).join('')
87}
88
89const LIMIT_WINDOW_MS: Record<string, number> = { five_hour: 5 * 3_600_000, seven_day: 7 * 86_400_000 }
90const LIMIT_NAME: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
91
92// ---------- the view ----------
93
94export type OverheadData = {
95  sessionId: string
96  meter: Meter | null
97  breakdown: Breakdown | null
98  main: Tokens
99  modelInfo: ModelInfo
100  agentTokens: Record<string, AgentTokens>
101  agents: AgentRow[]
102  growth: Growth
103  compactions: Compaction[]
104  lastRequestAt: number
105  lastTurnUsd: number | null
106  ttlMs: number
107}
108
109export const OverheadView = (props: {
110  el: ElementTable
111  data: OverheadData
112  now: number
113  width: number
114}) => {
115  const { Box, Text } = props.el
116  const { data: d, now } = props
117  const LABEL = 14
118  const BAR = Math.max(8, Math.min(16, props.width - LABEL - 22))
119
120  const Section = (p: { title: string; children?: unknown }) => (
121    <Box flexDirection="column" marginTop={1}>
122      <Text bold>{p.title}</Text>
123      {p.children as never}
124    </Box>
125  )
126  const Row = (p: { label: string; children?: unknown }) => (
127    <Box flexDirection="row">
128      <Box width={LABEL} flexShrink={0}>
129        <Text>{p.label}</Text>
130      </Box>
131      <Box flexShrink={1}>
132        <Text wrap="truncate-end">{p.children as never}</Text>
133      </Box>
134    </Box>
135  )
136  const Note = (p: { text: string }) => (
137    <Row label="">
138      <Text dimColor>{p.text}</Text>
139    </Row>
140  )
141  const Bar = (p: { percent: number; projected?: number; color: string }) => {
142    const filled = Math.min(BAR, Math.round((p.percent / 100) * BAR))
143    const shaded =
144      p.projected === undefined
145        ? 0
146        : Math.max(0, Math.min(BAR, Math.round((p.projected / 100) * BAR)) - filled)
147    return (
148      <Text>
149        <Text color={p.color}>{'█'.repeat(filled)}</Text>
150        <Text color="subtle">{'▒'.repeat(shaded)}</Text>
151        <Text color="subtle">{'░'.repeat(BAR - filled - shaded)}</Text>
152      </Text>
153    )
154  }
155
156  // ----- context -----
157  const m = d.meter
158  const b = d.breakdown
159  const tokens = m?.tokens ?? b?.total
160  const windowSize = b?.maxTokens ?? m?.window
161  const fill = tokens !== undefined && windowSize ? pct(tokens, windowSize) : (m?.percent ?? 0)
162  const context = (
163    <Section title="Context">
164      <Row label="window">
165        <Bar percent={fill} color={level(fill)} />
166        <Text bold color={level(fill)}>
167          {' '}
168          {fill}%
169        </Text>
170        <Text dimColor>
171          {' '}
172          {tokens !== undefined ? compact(tokens) : '—'} of {windowSize ? compact(windowSize) : '—'}
173        </Text>
174      </Row>
175      {m !== null && windowSize !== m.window && (
176        <Row label="model limit">
177          <Text dimColor>{compact(m.window)} tokens</Text>
178        </Row>
179      )}
180      {b?.compactAt !== undefined && (
181        <Row label="compacts">
182          <Text dimColor>at {compact(b.compactAt)}</Text>
183          {tokens !== undefined && (
184            <Text color={b.compactAt - tokens < 50_000 ? 'error' : 'warning'}>
185              {' '}
186              · {compact(Math.max(0, b.compactAt - tokens))} to go
187            </Text>
188          )}
189        </Row>
190      )}
191      <Row label="model">
192        <Text dimColor>
193          {d.modelInfo.model ?? '—'}
194          {d.modelInfo.effort !== undefined ? ` · effort ${d.modelInfo.effort}` : ''}
195        </Text>
196      </Row>
197      <Row label="session ID">
198        <Text dimColor>{d.sessionId || '—'}</Text>
199      </Row>
200    </Section>
201  )
202
203  // ----- breakdown -----
204  const breakdown =
205    b === null ? null : (
206      <Section title="In the window, as /context estimates it">
207        {b.rows.map(row => (
208          <Box flexDirection="column">
209            <Text>
210              <Text color={row.color}>{compact(row.tokens).padStart(8)}</Text>
211              <Text dimColor>{`${pct(row.tokens, b.total)}%`.padStart(6)}</Text>
212              <Text>{'  '}{row.name}</Text>
213            </Text>
214            {row.name === 'MCP tools' &&
215              b.mcpServers.map(server => (
216                <Text dimColor>
217                  {compact(server.tokens).padStart(8)}
218                  {'      '}
219                  {'    '}
220                  {server.name}
221                </Text>
222              ))}
223          </Box>
224        ))}
225        {b.deferred > 0 && (
226          <Text dimColor>
227            {compact(b.deferred).padStart(8)}
228            {'      '}
229            {'  '}deferred tool schemas, not in the window
230          </Text>
231        )}
232      </Section>
233    )
234
235  // ----- growth -----
236  const deltas = d.growth.deltas
237  const last10 = deltas.slice(-10)
238  const latest = last10[last10.length - 1]
239  const positive = deltas.filter(x => x > 0)
240  const largest = positive.length > 0 ? Math.max(...positive) : undefined
241  const average =
242    positive.length > 0 ? positive.reduce((n, x) => n + x, 0) / positive.length : undefined
243  const growth = (
244    <Section title="Growth">
245      <Row label="last 10">
246        {last10.length === 0 ? (
247          <Text dimColor>no turns yet</Text>
248        ) : (
249          <Text>
250            <Text color="claude">{sparkline(last10)}</Text>
251            {latest !== undefined && (
252              <Text dimColor> {latest >= 0 ? `↑${compact(latest)}` : `↓${compact(-latest)}`}</Text>
253            )}
254          </Text>
255        )}
256      </Row>
257      {largest !== undefined && average !== undefined && (
258        <Row label="largest">
259          <Text color="warning">↑{compact(largest)}</Text>
260          <Text dimColor> · average ↑{compact(average)}</Text>
261        </Row>
262      )}
263      {d.compactions.length > 0 && (
264        <Row label="compacted">
265          <Text dimColor>
266            {d.compactions
267              .slice(-3)
268              .map(c => `${compact(c.before)} → ${compact(c.after)}`)
269              .join(' · ')}
270          </Text>
271        </Row>
272      )}
273    </Section>
274  )
275
276  // ----- tokens & cache -----
277  const t = d.main
278  const cacheLeft = d.lastRequestAt > 0 ? d.lastRequestAt + d.ttlMs - now : -1
279  const hitRate = pct(t.cacheRead, t.input)
280  const cache = (
281    <Section title="Tokens & cache, main conversation">
282      <Row label="state">
283        {d.lastRequestAt === 0 ? (
284          <Text dimColor>no request yet</Text>
285        ) : cacheLeft > 0 ? (
286          <Text>
287            <Text color={cacheLeft < 5 * 60_000 ? 'warning' : 'suggestion'}>
288              warm {span(cacheLeft)}
289            </Text>
290            <Text dimColor> left of {span(d.ttlMs)}</Text>
291          </Text>
292        ) : (
293          <Text color="inactive">cold · next request rewrites the cache</Text>
294        )}
295      </Row>
296      <Row label="tokens">
297        <Text>
298          {compact(t.input)} in · {compact(t.output)} out
299        </Text>
300      </Row>
301      <Note text="Observed requests only; input includes cache" />
302      <Row label="hit rate">
303        <Text color="suggestion">{hitRate}%</Text>
304        <Text dimColor>
305          {' '}
306          · read {compact(t.cacheRead)} · written {compact(t.cacheWrite)} · uncached{' '}
307          {compact(t.uncached)}
308        </Text>
309      </Row>
310    </Section>
311  )
312
313  // ----- quota, with a window-average projection -----
314  const limits = m?.limits ?? []
315  const quota =
316    limits.length === 0 ? null : (
317      <Section title="Quota, shaded = window-average projection">
318        {limits.map(limit => {
319          const name = LIMIT_NAME[limit.kind] ?? limit.kind
320          const windowMs = LIMIT_WINDOW_MS[limit.kind]
321          const left = limit.resetsAt !== undefined ? limit.resetsAt - now : undefined
322          let projected: number | undefined
323          let note = ''
324          if (windowMs !== undefined && left !== undefined && left > 0) {
325            const elapsed = windowMs - left
326            const rate = elapsed > 0 ? limit.percent / elapsed : 0
327            projected = Math.round(limit.percent + rate * left)
328            if (projected < 100) {
329              note = `≈${projected}% by reset · reset comes first`
330            } else {
331              const untilFull = rate > 0 ? (100 - limit.percent) / rate : Infinity
332              note = `hits 100% in ≈${span(untilFull)} · before reset`
333            }
334          }
335          return (
336            <Box flexDirection="column">
337              <Row label={name}>
338                <Bar percent={limit.percent} projected={projected} color={level(limit.percent)} />
339                <Text bold color={level(limit.percent)}>
340                  {' '}
341                  {Math.round(limit.percent)}%
342                </Text>
343                <Text dimColor>{left !== undefined ? ` ↻${span(left)}` : ''}</Text>
344              </Row>
345              {note !== '' && <Note text={note} />}
346            </Box>
347          )
348        })}
349      </Section>
350    )
351
352  // ----- cost -----
353  const cost =
354    m?.usd === undefined ? null : (
355      <Section title="Cost, API-price reference">
356        <Row label="session">
357          <Text color="warning">≈${m.usd.toFixed(2)}</Text>
358        </Row>
359        {d.lastTurnUsd !== null && (
360          <Row label="last turn">
361            <Text dimColor>+${d.lastTurnUsd.toFixed(2)}</Text>
362          </Row>
363        )}
364        <Note text="API-price reference, not a billing receipt" />
365      </Section>
366    )
367
368  // ----- subagents -----
369  const ids = Object.keys(d.agentTokens)
370  const subagents =
371    ids.length === 0 ? null : (
372      <Section title="Subagents">
373        {ids.slice(-4).map((id, i) => {
374          const a = d.agentTokens[id]!
375          const row = d.agents.find(x => x.id === id)
376          const lastInput = a.inputs[a.inputs.length - 1]
377          return (
378            <Box flexDirection="column" marginTop={i === 0 ? 0 : 1}>
379              <Row label={row?.type ?? 'agent'}>
380                <Text>{row?.description ?? ''}</Text>
381              </Row>
382              <Row label="agent ID">
383                <Text dimColor>{id}</Text>
384              </Row>
385              <Row label="model">
386                <Text>{a.model ?? '—'}</Text>
387                <Text dimColor>{a.effort !== undefined ? ` · effort ${a.effort}` : ''}</Text>
388              </Row>
389              {lastInput !== undefined && (
390                <Row label="last input">
391                  <Text>{compact(lastInput)} </Text>
392                  <Text color="suggestion">{sparkline(a.inputs.slice(-8))}</Text>
393                </Row>
394              )}
395              <Row label="tokens">
396                <Text>
397                  {compact(a.tokens.input)} in · {compact(a.tokens.output)} out
398                </Text>
399                <Text dimColor> · {compact(a.tokens.cacheRead)} cache read</Text>
400              </Row>
401            </Box>
402          )
403        })}
404        <Note text="Observed requests only; input includes cache" />
405      </Section>
406    )
407
408  return (
409    <Box flexDirection="column">
410      {context}
411      {breakdown}
412      {growth}
413      {cache}
414      {quota}
415      {cost}
416      {subagents}
417    </Box>
418  )
419}
420
types/index.d.ts 88 lines
1export type ChangedFile = {
2  path: string
3  /** Created by this session (a Write that made the file), not just modified. */
4  isNew: boolean
5  added: number
6  removed: number
7  edits: number
8  at: number
9}
10export type AgentRow = {
11  id: string
12  type: string
13  description: string
14  status: string
15  startedAt: number
16  endedAt?: number
17}
18export type Job = {
19  id: string
20  kind: 'cron' | 'wakeup'
21  schedule: string
22  prompt: string
23  recurring: boolean
24  nextFireAt?: number
25}
26
27export type Tab = 'activity' | 'overhead'
28
29/** Observed request tokens; `input` includes cache reads and writes. */
30export type Tokens = {
31  input: number
32  output: number
33  cacheRead: number
34  cacheWrite: number
35  uncached: number
36}
37export type ModelInfo = { model?: string; effort?: string }
38export type AgentTokens = ModelInfo & {
39  tokens: Tokens
40  /** Input size of each request, the latest last (kept short). */
41  inputs: number[]
42}
43export type Limit = { kind: string; percent: number; resetsAt?: number }
44export type Meter = {
45  tokens?: number
46  window: number
47  percent?: number
48  limits: Limit[]
49  usd?: number
50}
51export type BreakdownRow = { name: string; tokens: number; color: string }
52export type Breakdown = {
53  rows: BreakdownRow[]
54  /** MCP tool tokens by server. */
55  mcpServers: BreakdownRow[]
56  deferred: number
57  total: number
58  /** The window compaction measures against. */
59  maxTokens: number
60  compactAt?: number
61}
62export type Growth = {
63  /** Context size at the end of the last main turn. */
64  last?: number
65  deltas: number[]
66}
67export type Compaction = { before: number; after: number }
68
69declare module 'claude-code' {
70  interface PluginState {
71    'session-dashboard': {
72      files: ChangedFile[]
73      agents: AgentRow[]
74      jobs: Job[]
75      tab: Tab
76      meter: Meter | null
77      breakdown: Breakdown | null
78      main: Tokens
79      modelInfo: ModelInfo
80      agentTokens: Record<string, AgentTokens>
81      growth: Growth
82      compactions: Compaction[]
83      lastRequestAt: number
84      lastTurnUsd: number | null
85    }
86  }
87}
88