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

Two Claude Code mods that keep an eye on your session.
A side pane with two tabs (switch with 1 / 2, or open it with /session-pane):
+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./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.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.
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.
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.
MIT
hooks/register.tsx 668 lines1import { 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}
668hooks/overhead.tsx 420 lines1import 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}
420types/index.d.ts 88 lines1export 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