A live side pane of session stats (model, tokens, prompt cache and its savings, usage limits, cost and burn rate, tools, agents), threshold alerts, a done…

A Claude Code side pane with live stats for the current session. In fullscreen mode it docks on the right of the transcript.
It also includes cache-tax: it keeps the one-hour prompt cache warm during breaks, and stops a send that would re-write a cold cache once, showing what it would cost. If you have cache-tax installed separately, uninstall it so the guard doesn't run twice.
At the Claude Code prompt:
/plugin install session-stats --marketplace miskopisko/claude-session-stats
From a shell:
claude plugin install session-stats --marketplace miskopisko/claude-session-stats
The pane opens by itself when the terminal is at least 144 columns wide.
/session-stats opens it at any width. It docks on the right in fullscreen mode from 110 columns, and sits above the prompt otherwise./session-stats close hides it./keepwarm keep the cache warm for six hours /keepwarm 90m keep it warm for a window of your own (also 2h30m, 6h) /keepwarm always arm a six-hour window at every session start /keepwarm 6h every 2m ping every two minutes (a testing knob, floor 1m) /keepwarm status the window, next ping and last ping /keepwarm off stop, and turn always off /cache-tax the cache card: state, cold cost, keepwarm, tally /cache-tax guard warn show the price of a cold send and send it /cache-tax guard refuse drop a cold send once; the resend goes through (default)
While keepwarm is on, after 50 idle minutes it sends one small, tool-less request over the conversation so the cache is read rather than re-written. Each ping costs tokens. Keepwarm stops if a ping finds the cache already gone. A cold write that gets paid arms keepwarm for three hours.
The guard steps in only when the context is at least 50,000 tokens and the one-hour clock says the cache is cold. Prices are list prices from September 2026, and the cold cost is an upper bound.
Keepwarm assumes a one-hour cache. Subscription usage defaults to one hour; for API-key or cloud-provider sessions, set promptCacheTtl to "1h".
The plugin writes a dim line in the transcript, once each, when:
They appear whether or not the pane is open. /session-stats alerts off turns them off in every session; /session-stats alerts on turns them back on.
When a turn runs two minutes or longer, a desktop notification says so when it finishes, with the first line of the answer, so you can step away during long runs. It goes through your own notification channel (preferredNotifChannel); an interrupted turn sends none. /session-stats notify off turns it off in every session; /session-stats notify on turns it back on.
The pane's Controls section holds four switches. Click one, or focus the pane (ctrl+x tab) and press its key:
k keepwarm on arms a six-hour window, as /keepwarm; off as /keepwarm off g guard refuse a cold send once, or only warn a alerts the transcript alerts above n notify the done notification
Each is remembered across sessions, the same as its slash command.
The hooks module is hooks/register.tsx, with number formatting in hooks/format.ts, burn rate, forecasts and cache savings in hooks/pace.ts, alert thresholds in hooks/alerts.ts, cache-tax in hooks/cache-tax.ts and the pane's state types in types/index.d.ts.
claude plugin validate . claude plugin test .
cache-tax is Copyright (c) 2026 Karan Bansal, used under the MIT License: see LICENSE-cache-tax. hooks/cache-tax.ts and tests/cache-tax.test.ts are ported from it.
hooks/register.tsx 663 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionContextUsage, SessionCost, SessionRateLimit, Timer } from 'claude-code'
3
4import type { AgentRow, CacheTaxView, Pace, Savings, Settings, Snapshot, ToolStats, Tokens, TurnInfo } from '../types'
5import { checkAlerts, freshAlerts, turnCostAlert } from './alerts'
6import type { Alerts } from './alerts'
7import {
8 addUsage,
9 bar,
10 duration,
11 emptyUsage,
12 hitRate,
13 levelColor,
14 limitName,
15 tokens as tok,
16 usd,
17} from './format'
18import {
19 cacheTaxSessionCompact,
20 cacheTaxSessionStart,
21 cacheTaxTurnComplete,
22 cacheTaxTurnStep,
23 freshState,
24 hookCacheTax,
25 keepwarmOff,
26 keepwarmOn,
27 priceOf,
28 setGuard,
29} from './cache-tax'
30import { addSample, cacheSavings, perHour } from './pace'
31
32const PANE = 'session-stats'
33const TITLE = 'Session stats'
34const WIDTH = 46
35/** Cells of space between the pane's left edge and its text. */
36const GUTTER = 1
37const ACTIVE = new Set(['pending', 'running', 'waiting'])
38/** A main turn at least this long sends a desktop notification when it finishes. */
39const NOTIFY_AFTER_MS = 2 * 60 * 1000
40/** The store keys of the settings, each written only while it is off. */
41const SETTING_KEYS = { alerts: 'alerts', notify: 'notify' } as const
42
43const snap = atom({ plugin: 'session-stats', key: 'snap' } as const, {
44 startedAt: null,
45 model: null,
46 effort: null,
47 contextTokens: null,
48 contextWindow: null,
49 contextPercent: null,
50 rateLimits: [],
51 costUsd: null,
52 version: null,
53} satisfies Snapshot)
54const tokens = atom({ plugin: 'session-stats', key: 'tokens' } as const, {
55 main: emptyUsage(),
56 agents: emptyUsage(),
57 last: null,
58} satisfies Tokens)
59const turn = atom({ plugin: 'session-stats', key: 'turn' } as const, {
60 count: 0,
61 runningSince: null,
62 lastMs: null,
63 steps: 0,
64 compactions: 0,
65} satisfies TurnInfo)
66const tools = atom({ plugin: 'session-stats', key: 'tools' } as const, {
67 counts: {},
68 errors: 0,
69 running: [],
70} satisfies ToolStats)
71const agents = atom({ plugin: 'session-stats', key: 'agents' } as const, [] as AgentRow[])
72const now = atom({ plugin: 'session-stats', key: 'now' } as const, 0)
73// Written by hooks/cache-tax.ts, which declares the same reference.
74const cacheTaxView = atom({ plugin: 'session-stats', key: 'cacheTax' } as const, null as CacheTaxView | null)
75const pace = atom({ plugin: 'session-stats', key: 'pace' } as const, { cost: [], limits: {} } as Pace)
76const savings = atom({ plugin: 'session-stats', key: 'savings' } as const, {
77 readsUsd: 0,
78 premiumUsd: 0,
79 unpriced: 0,
80} satisfies Savings)
81const settings = atom({ plugin: 'session-stats', key: 'settings' } as const, { alerts: true, notify: true } satisfies Settings)
82
83type Measured = { context: SessionContextUsage; rateLimits: SessionRateLimit[]; cost?: SessionCost }
84
85async function measured($: EngineInterface, m: Measured) {
86 const at = await $.clock.now()
87 await update($, pace, p => ({
88 cost: m.cost === undefined ? p.cost : addSample(p.cost, at, m.cost.usd),
89 limits: {
90 ...p.limits,
91 ...Object.fromEntries(m.rateLimits.map(r => [r.kind, addSample(p.limits[r.kind] ?? [], at, r.percentUsed)])),
92 },
93 }))
94 await update($, snap, s => ({
95 ...s,
96 contextTokens: m.context.tokens ?? s.contextTokens,
97 contextWindow: m.context.window,
98 contextPercent: m.context.percent ?? s.contextPercent,
99 rateLimits: m.rateLimits.length > 0 ? m.rateLimits.map(r => ({ ...r })) : s.rateLimits,
100 costUsd: m.cost?.usd ?? s.costUsd,
101 }))
102}
103
104async function pollAgents($: EngineInterface) {
105 const at = await $.clock.now()
106 const list = await $.agent.list()
107 await update($, agents, rows => {
108 const byId = new Map(rows.map(r => [r.id, r]))
109 for (const a of list) {
110 const was = byId.get(a.id)
111 byId.set(a.id, {
112 id: a.id,
113 description: a.description,
114 type: a.type,
115 status: a.status,
116 firstSeen: was?.firstSeen ?? at,
117 lastSeen: ACTIVE.has(a.status) || was === undefined ? at : was.lastSeen,
118 tokens: was?.tokens ?? 0,
119 tools: was?.tools ?? 0,
120 lastTool: was?.lastTool ?? null,
121 })
122 }
123 // An agent the engine dropped from its list while we last saw it active has finished.
124 const listed = new Set(list.map(a => a.id))
125 for (const [id, r] of byId) {
126 if (!listed.has(id) && ACTIVE.has(r.status)) byId.set(id, { ...r, status: 'completed' })
127 }
128 return [...byId.values()].slice(-30)
129 })
130}
131
132// The pane's clock: one tick a second, run only while the pane is drawn, so a
133// hidden pane costs nothing. The agent list is polled on every other tick.
134let ticker: Timer | null = null
135
136function startTicker($: EngineInterface) {
137 if (ticker !== null) return
138 let ticks = 0
139 ticker = $.clock.every(1000, async () => {
140 ticks += 1
141 const tickAt = await $.clock.now()
142 await update($, now, () => tickAt)
143 if (ticks % 2 === 0) await pollAgents($)
144 })
145}
146
147function stopTicker() {
148 ticker?.cancel()
149 ticker = null
150}
151
152function say($: EngineInterface, line: string) {
153 $.ui.log(`session-stats: ${line}`)
154}
155
156/** Turns a setting on or off, for this session and the ones after it. */
157async function setSetting($: EngineInterface, name: keyof Settings, isOn: boolean) {
158 await update($, settings, v => ({ ...v, [name]: isOn }))
159 if (isOn) await $.store.delete(SETTING_KEYS[name])
160 else await $.store.set(SETTING_KEYS[name], false)
161}
162
163/** Flips a setting from what it is now, not from what a drawing showed: two quick presses land as two. */
164async function toggleSetting($: EngineInterface, name: keyof Settings) {
165 await setSetting($, name, !(await read($, settings))[name])
166}
167
168/** Logs what crossed an alert threshold since the last check, unless alerts are off. */
169async function raiseAlerts($: EngineInterface, alerts: Alerts) {
170 if (!(await read($, settings)).alerts) return
171 const [s, p] = await Promise.all([read($, snap), read($, pace)])
172 for (const line of checkAlerts(alerts, s, p, await $.clock.now())) say($, line)
173}
174
175/** The first line of an answer, cut to fit a notification. */
176function firstLine(text: string): string {
177 const line = text.trim().split('\n')[0]?.trim() ?? ''
178 return line.length > 100 ? `${line.slice(0, 99)}…` : line
179}
180
181/** Notifies the desktop that a long main turn finished; a channel that is off or fails is passed over. */
182async function notifyDone($: EngineInterface, ms: number, answer: string) {
183 const summary = firstLine(answer)
184 try {
185 await $.ui.notify(`Done after ${duration(ms)}${summary === '' ? '' : `: ${summary}`}`, { title: 'Claude Code' })
186 } catch {
187 // A hook refused it or the write failed: nothing to do.
188 }
189}
190
191function touchAgent(id: string, at: number, change: (r: AgentRow) => AgentRow) {
192 return (rows: AgentRow[]) => {
193 const row = rows.find(r => r.id === id) ?? {
194 id,
195 description: '',
196 type: 'agent',
197 status: 'running',
198 firstSeen: at,
199 lastSeen: at,
200 tokens: 0,
201 tools: 0,
202 lastTool: null,
203 }
204 return [...rows.filter(r => r.id !== id), change(row)].slice(-30)
205 }
206}
207
208export const register: Register = on => {
209 const cacheTax = freshState()
210 hookCacheTax(on, cacheTax)
211 const alerts = freshAlerts()
212
213 on('session.start', async ($, e, next) => {
214 await $.command.register({
215 name: 'session-stats',
216 description: 'Toggle the session stats pane (/session-stats close hides it; alerts on|off, notify on|off)',
217 argumentHint: '[close | alerts on|off | notify on|off]',
218 })
219 const [alertsOff, notifyOff] = await Promise.all([$.store.get(SETTING_KEYS.alerts), $.store.get(SETTING_KEYS.notify)])
220 await update($, settings, () => ({ alerts: alertsOff !== false, notify: notifyOff !== false }))
221 const started = await $.clock.now()
222 const [usage, version] = await Promise.all([$.session.usage(), $.session.version()])
223 await update($, snap, s => ({ ...s, startedAt: usage.startedAt ?? started, version: version.version }))
224 await measured($, usage)
225 await update($, now, () => started)
226 await raiseAlerts($, alerts)
227
228 // Unasked, the pane is placed only on a wide terminal; its first draw starts the clock.
229 void $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
230
231 const result = await next(e)
232 await cacheTaxSessionStart($, cacheTax)
233 return result
234 })
235
236 on('command.run', { command: 'session-stats' }, async ($, e) => {
237 const words = e.args.trim().split(/\s+/)
238 const name = words[0]
239 if (name === 'alerts' || name === 'notify') {
240 const what = name === 'alerts' ? 'Alerts' : 'Notifications when a long turn finishes'
241 if (words[1] !== 'on' && words[1] !== 'off') {
242 const isOn = (await read($, settings))[name]
243 return { text: `${what} are ${isOn ? 'on' : 'off'}: /session-stats ${name} on|off` }
244 }
245 await setSetting($, name, words[1] === 'on')
246 return { text: words[1] === 'on' ? `${what} are on.` : `${what} are off, in every session until /session-stats ${name} on.` }
247 }
248 if (words[0] === 'close') {
249 await $.ui.close({ id: PANE })
250 stopTicker()
251 return { text: 'Session stats pane closed.' }
252 }
253 const opened = await $.ui.open({ id: PANE, title: TITLE, columns: WIDTH })
254 return {
255 text: opened.isPlaced
256 ? 'Session stats pane opened. In fullscreen mode it docks on the right.'
257 : `Session stats pane is waiting to be placed: ${opened.reason}`,
258 }
259 })
260
261 on('ui.close', { id: PANE }, async ($, e, next) => {
262 const closed = await next(e)
263 stopTicker()
264 return closed
265 }).catch(($, e, next) => next(e))
266
267 on('session.measure', async ($, e, next) => {
268 await measured($, e)
269 await raiseAlerts($, alerts)
270 return next(e)
271 })
272
273 on('turn.start', async ($, e, next) => {
274 if (e.agentId === undefined) {
275 const at = await $.clock.now()
276 await update($, turn, t => ({ ...t, count: t.count + 1, runningSince: at, steps: 0 }))
277 alerts.turnStartUsd = (await $.session.usage()).cost?.usd ?? null
278 }
279 return next(e)
280 })
281
282 on('turn.complete', async ($, e, next) => {
283 const done = await next(e)
284 await cacheTaxTurnComplete($, cacheTax, e)
285 if (e.agentId === undefined) {
286 await update($, turn, t => ({ ...t, runningSince: null, lastMs: e.durationMs }))
287 await measured($, await $.session.usage())
288 const costly = turnCostAlert(alerts, (await read($, snap)).costUsd)
289 const prefs = await read($, settings)
290 if (costly !== null && prefs.alerts) say($, costly)
291 await raiseAlerts($, alerts)
292 if (prefs.notify && !e.isAborted && e.durationMs >= NOTIFY_AFTER_MS) {
293 await notifyDone($, e.durationMs, e.reason === 'refusal' ? 'the model declined' : e.answer)
294 }
295 } else {
296 const at = await $.clock.now()
297 await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, lastSeen: at })))
298 }
299 return done
300 })
301
302 on('turn.step', async function* ($, e, next) {
303 await cacheTaxTurnStep($, cacheTax, e)
304 if (e.agentId === undefined) {
305 await update($, turn, t => ({ ...t, steps: e.index + 1 }))
306 await update($, snap, s => ({
307 ...s,
308 model: e.model,
309 effort: e.effort === undefined ? null : String(e.effort),
310 }))
311 }
312
313 const result = yield* next(e)
314
315 const u = result.usage
316 if (u !== null) {
317 const at = await $.clock.now()
318 const price = priceOf(u.model)
319 const saved = price === null ? null : cacheSavings(u, price)
320 await update($, savings, v =>
321 saved === null
322 ? { ...v, unpriced: v.unpriced + 1 }
323 : { ...v, readsUsd: v.readsUsd + saved.readsUsd, premiumUsd: v.premiumUsd + saved.premiumUsd },
324 )
325 if (e.agentId === undefined) {
326 await update($, tokens, t => ({
327 ...t,
328 main: addUsage(t.main, u),
329 last: {
330 model: u.model,
331 at,
332 input: u.input_tokens,
333 output: u.output_tokens,
334 cacheRead: u.cache_read_input_tokens,
335 cacheWrite: u.cache_creation_input_tokens,
336 },
337 }))
338 } else {
339 const spent = u.input_tokens + u.output_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
340 await update($, tokens, t => ({ ...t, agents: addUsage(t.agents, u) }))
341 await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, tokens: r.tokens + spent, lastSeen: at })))
342 }
343 }
344
345 return result
346 })
347
348 on('tool.call', async ($, e, next) => {
349 const at = await $.clock.now()
350 const id = e.tool_use_id
351 const name = String(e.tool)
352 await update($, tools, t => ({
353 ...t,
354 running: [...t.running.filter(r => r.id !== id), { id, tool: name, since: at, agentId: e.agentId }].slice(-50),
355 }))
356 if (e.agentId !== undefined) {
357 await update($, agents, touchAgent(e.agentId, at, r => ({ ...r, tools: r.tools + 1, lastTool: name, lastSeen: at })))
358 }
359
360 let isError = true
361 try {
362 const ran = await next(e)
363 isError = ran.isError === true || typeof ran.deny === 'string'
364 return ran
365 } finally {
366 await update($, tools, t => ({
367 counts: { ...t.counts, [name]: (t.counts[name] ?? 0) + 1 },
368 errors: t.errors + (isError ? 1 : 0),
369 running: t.running.filter(r => r.id !== id),
370 }))
371 }
372 }).catch(($, e, next) => next(e))
373
374 on('session.compact', async ($, e, next) => {
375 const done = await next(e)
376 // A precompute installs nothing: the conversation is not compacted until a later dispatch uses it.
377 if (e.trigger !== 'precompute') {
378 await update($, turn, t => ({ ...t, compactions: t.compactions + 1 }))
379 await cacheTaxSessionCompact($, cacheTax, e)
380 }
381 return done
382 }).catch(($, e, next) => next(e))
383
384 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
385 startTicker($)
386 const { Box, Button, Text } = $.ui.resolve(e)
387 const [s, tk, t, tl, ag, tickAt, ct, pc, sv, prefs] = await Promise.all([
388 read($, snap),
389 read($, tokens),
390 read($, turn),
391 read($, tools),
392 read($, agents),
393 read($, now),
394 read($, cacheTaxView),
395 read($, pace),
396 read($, savings),
397 read($, settings),
398 ])
399 // The tick redraws the pane each second; the live clock keeps a pane drawn again after a while hidden from showing an old time.
400 const at = Math.max(tickAt, await $.clock.now())
401 const cols = Math.max(24, e.props.bodyColumns - GUTTER)
402 const barWidth = Math.max(6, Math.min(20, cols - 26))
403 const half = Math.floor(cols / 2)
404
405 const head = (children: string) => (
406 <Box marginTop={1}>
407 <Text bold color="claude">
408 {children}
409 </Text>
410 </Box>
411 )
412 const row = (label: string, value: string, color?: string) => (
413 <Box>
414 <Box width={13} flexShrink={0}>
415 <Text dimColor>{label}</Text>
416 </Box>
417 <Text color={color} wrap="truncate-end">
418 {value}
419 </Text>
420 </Box>
421 )
422
423 // Model and context
424 const model = tk.last?.model ?? s.model
425 const ctxPct = s.contextPercent
426
427 // Cache
428 const last = tk.last
429 const lastHit = last === null ? null : hitRate(last)
430 const allHit = hitRate(tk.main)
431 const age = last === null ? null : at - last.at
432 // Warm or cold by the clock now, not as of cache-tax's last change: the cache lapses with nothing happening.
433 const netSaved = sv.readsUsd - sv.premiumUsd
434 const cacheState = ct === null || ct.expiresAt === null ? 'unknown' : at >= ct.expiresAt ? 'cold' : 'warm'
435
436 // Pace
437 const burn = perHour(pc.cost, at)
438
439 // Tools
440 const totalCalls = Object.values(tl.counts).reduce((a, b) => a + b, 0)
441 const top = Object.entries(tl.counts)
442 .sort((a, b) => b[1] - a[1])
443 .slice(0, 5)
444 .map(([n, c]) => `${n} ${c}`)
445 .join(' · ')
446
447 // Agents
448 const active = ag.filter(a => ACTIVE.has(a.status))
449 const finished = ag.filter(a => !ACTIVE.has(a.status))
450 const shownAgents = [...active, ...finished.slice(-Math.max(0, 6 - active.length))]
451
452 const both = {
453 input: tk.main.input + tk.agents.input,
454 output: tk.main.output + tk.agents.output,
455 cacheRead: tk.main.cacheRead + tk.agents.cacheRead,
456 cacheWrite: tk.main.cacheWrite + tk.agents.cacheWrite,
457 }
458
459 return (
460 <Box flexDirection="column" width={cols + GUTTER} paddingLeft={GUTTER}>
461 <Text bold>Session</Text>
462 {row("Model", model ?? 'not seen yet')}
463 {s.effort !== null && row("Effort", s.effort)}
464 {row("Status", t.runningSince === null ? 'idle' : `working ${duration(at - t.runningSince)} · step ${t.steps}`, t.runningSince === null ? undefined : 'warning')}
465 {row("Uptime", s.startedAt === null ? '—' : duration(at - s.startedAt))}
466 {row("Turns", `${t.count}${t.lastMs === null ? '' : ` · last ${duration(t.lastMs)}`}${t.compactions > 0 ? ` · ${t.compactions} compacted` : ''}`)}
467 {s.costUsd !== null && row("Cost", usd(s.costUsd))}
468 {burn !== null && row("Burn rate", `${usd(burn)} an hour`)}
469
470 {head('Context')}
471 {ctxPct === null ? (
472 <Text dimColor>No reading until the next response.</Text>
473 ) : (
474 <>
475 <Box>
476 <Text color={levelColor(ctxPct)}>{bar(ctxPct, barWidth)}</Text>
477 <Text> {ctxPct}%</Text>
478 </Box>
479 <Text dimColor>
480 {tok(s.contextTokens ?? 0)} of {tok(s.contextWindow ?? 0)} · {tok(Math.max(0, (s.contextWindow ?? 0) - (s.contextTokens ?? 0)))} free
481 </Text>
482 </>
483 )}
484
485 {head('Usage limits')}
486 {s.rateLimits.length === 0 ? (
487 <Text dimColor>No reading (API key, or no response yet).</Text>
488 ) : (
489 s.rateLimits.map(r => (
490 <Box key={r.kind}>
491 <Box width={13} flexShrink={0}>
492 <Text dimColor>{limitName(r.kind)}</Text>
493 </Box>
494 <Text color={levelColor(r.percentUsed)}>{bar(r.percentUsed, barWidth)}</Text>
495 <Text> {Math.round(r.percentUsed)}%</Text>
496 </Box>
497 ))
498 )}
499
500 {head('Tokens')}
501 {row("Input", `${tok(both.input)} new · ${tok(both.cacheRead)} cached`)}
502 {row("Cache write", tok(both.cacheWrite))}
503 {row("Output", tok(both.output))}
504 {row("Requests", `${tk.main.requests} main · ${tk.agents.requests} agents`)}
505 {tk.agents.requests > 0 &&
506 row("Agents' share", `${tok(tk.agents.input + tk.agents.cacheRead + tk.agents.cacheWrite + tk.agents.output)} tokens`)}
507
508 {head('Prompt cache')}
509 {last === null ? (
510 <Text dimColor>No request yet.</Text>
511 ) : (
512 <>
513 {row("Last request", `${lastHit ?? 0}% hit`, lastHit !== null && lastHit >= 70 ? 'success' : 'warning')}
514 <Text dimColor>
515 {' '}
516 read {tok(last.cacheRead)} · write {tok(last.cacheWrite)} · new {tok(last.input)}
517 </Text>
518 {row("Session hit", allHit === null ? '—' : `${allHit}%`)}
519 {row("Idle for", age === null ? '—' : duration(age))}
520 {(sv.readsUsd > 0 || sv.premiumUsd > 0) && (
521 <>
522 {row(
523 "Cache saved",
524 netSaved >= 0 ? `${usd(netSaved)} net` : `-${usd(-netSaved)} net`,
525 netSaved >= 0 ? 'success' : 'warning',
526 )}
527 <Text dimColor wrap="truncate-end">
528 {' '}
529 {usd(sv.readsUsd)} on reads · {usd(sv.premiumUsd)} on writes
530 {sv.unpriced > 0 ? ` · ${sv.unpriced} unpriced` : ''}
531 </Text>
532 </>
533 )}
534 </>
535 )}
536
537 {head('Cache tax')}
538 {ct === null ? (
539 <Text dimColor>Starting…</Text>
540 ) : (
541 <>
542 {row(
543 'Cache',
544 cacheState === 'unknown'
545 ? 'unknown until the next turn'
546 : cacheState === 'cold'
547 ? `cold for ${duration(at - (ct.expiresAt ?? at))}`
548 : `warm, ${duration((ct.expiresAt ?? at) - at)} left`,
549 cacheState === 'warm' ? 'success' : cacheState === 'cold' ? 'error' : undefined,
550 )}
551 {ct.ctx > 0 &&
552 row(
553 'Cold cost',
554 `${ct.coldUsd === null ? 'n/a' : usd(ct.coldUsd)} to re-write${ct.warmUsd === null ? '' : ` · warm ${usd(ct.warmUsd)}`}`,
555 )}
556 {row(
557 'Keepwarm',
558 ct.stopped !== null
559 ? `stopped: ${ct.stopped}`
560 : ct.deadline === null
561 ? ct.always ? 'off until next session (always)' : 'off · /keepwarm to arm'
562 : `on, ${duration(ct.deadline - at)} left${ct.always ? ' (always)' : ''}`,
563 ct.stopped !== null ? 'warning' : ct.deadline === null ? undefined : 'success',
564 )}
565 {ct.deadline !== null &&
566 row(
567 'Next ping',
568 ct.nextPingAt !== null && cacheState === 'warm' ? `in ${duration(Math.max(0, ct.nextPingAt - at))}` : 'after the next turn',
569 )}
570 {ct.lastPing !== null &&
571 row(
572 'Last ping',
573 `read ${tok(ct.lastPing.read)} · ${ct.lastPing.usd === null ? 'n/a' : usd(ct.lastPing.usd)} · ${duration(at - ct.lastPing.at)} ago`,
574 )}
575 {row('Guard', ct.guard === 'refuse' ? 'refuse a cold send once' : 'warn only')}
576 {row(
577 'Cold writes',
578 `${ct.coldWrites} paid${ct.coldWrites > 0 ? `, ${usd(ct.paidUsd)}` : ''}`,
579 ct.coldWrites > 0 ? 'warning' : undefined,
580 )}
581 </>
582 )}
583
584 {head('Tools')}
585 {row("Calls", `${totalCalls}${tl.errors > 0 ? ` · ${tl.errors} failed` : ''}`)}
586 {top !== '' && <Text dimColor wrap="wrap">{top}</Text>}
587 {tl.running.map(r => (
588 <Text color="warning" wrap="truncate-end" key={r.id}>
589 ▸ {r.tool} {duration(at - r.since)}
590 {r.agentId === undefined ? '' : ' (agent)'}
591 </Text>
592 ))}
593
594 {head('Agents')}
595 {shownAgents.length === 0 ? (
596 <Text dimColor>None this session.</Text>
597 ) : (
598 <>
599 <Text dimColor>
600 {active.length} running · {finished.length} finished
601 </Text>
602 {shownAgents.map(a => {
603 const isActive = ACTIVE.has(a.status)
604 const color = isActive ? 'warning' : a.status === 'completed' || a.status === 'idle' ? 'success' : 'error'
605 return (
606 <Box flexDirection="column" key={a.id}>
607 <Text wrap="truncate-end">
608 <Text color={color}>{isActive ? '●' : a.status === 'completed' || a.status === 'idle' ? '✓' : '✗'}</Text>{' '}
609 <Text bold>{a.type}</Text> {a.description}
610 </Text>
611 <Text dimColor wrap="truncate-end">
612 {' '}
613 {a.status} · {duration((isActive ? at : a.lastSeen) - a.firstSeen)} · {a.tools} tools · {tok(a.tokens)} tok
614 {isActive && a.lastTool !== null ? ` · ${a.lastTool}` : ''}
615 </Text>
616 </Box>
617 )
618 })}
619 </>
620 )}
621
622 {head('Controls')}
623 <Box>
624 <Box width={half} flexShrink={0}>
625 {ct === null ? (
626 <Text dimColor>k: keepwarm …</Text>
627 ) : (
628 <Button
629 key="keepwarm"
630 plain
631 hotkey="k"
632 label={`keepwarm ${ct.deadline === null ? 'off' : 'on'}`}
633 onPress={() => (cacheTax.deadline ? keepwarmOff($, cacheTax) : keepwarmOn($, cacheTax))}
634 />
635 )}
636 </Box>
637 {ct !== null && (
638 <Button
639 key="guard"
640 plain
641 hotkey="g"
642 label={`guard ${ct.guard}`}
643 onPress={() => setGuard($, cacheTax, cacheTax.guard === 'refuse' ? 'warn' : 'refuse')}
644 />
645 )}
646 </Box>
647 <Box>
648 <Box width={half} flexShrink={0}>
649 <Button key="alerts" plain hotkey="a" label={`alerts ${prefs.alerts ? 'on' : 'off'}`} onPress={() => toggleSetting($, 'alerts')} />
650 </Box>
651 <Button key="notify" plain hotkey="n" label={`notify ${prefs.notify ? 'on' : 'off'}`} onPress={() => toggleSetting($, 'notify')} />
652 </Box>
653 <Text dimColor>Click, or ctrl+x tab then the key.</Text>
654 {s.version !== null && (
655 <Box marginTop={1}>
656 <Text dimColor>Claude Code {s.version}</Text>
657 </Box>
658 )}
659 </Box>
660 )
661 })
662}
663hooks/alerts.ts 80 lines1import type { Pace, Snapshot } from '../types'
2import { duration, limitName, tokens as tok, usd } from './format'
3import { forecast, perHour } from './pace'
4
5/** Context fill that warns, and the fill it must drop under (a compaction) to warn again. */
6export const CONTEXT_ALERT = 80
7const CONTEXT_REARM = 70
8/** A usage limit this full warns, once per window. */
9export const LIMIT_ALERT = 90
10/** A forecast that runs a limit out within this long, before it resets, warns; at twice it re-arms. */
11export const FORECAST_ALERT_MS = 60 * 60 * 1000
12/** A main turn that costs this much warns. */
13export const TURN_ALERT_USD = 5
14
15export type Alerts = {
16 context: boolean
17 limits: Set<string>
18 forecasts: Set<string>
19 /** The session's cost when the running main turn started. */
20 turnStartUsd: number | null
21}
22
23export const freshAlerts = (): Alerts => ({
24 context: false,
25 limits: new Set(),
26 forecasts: new Set(),
27 turnStartUsd: null,
28})
29
30/** The lines to log for what crossed a threshold since the last check; each warns once until it re-arms. */
31export function checkAlerts(a: Alerts, s: Snapshot, pace: Pace, now: number): string[] {
32 const lines: string[] = []
33
34 const ctx = s.contextPercent
35 if (ctx !== null) {
36 if (ctx >= CONTEXT_ALERT && !a.context) {
37 a.context = true
38 lines.push(
39 `context is ${ctx}% full (${tok(s.contextTokens ?? 0)} of ${tok(s.contextWindow ?? 0)}). ` +
40 'Compaction is coming; /compact at a natural break keeps the timing yours.',
41 )
42 } else if (ctx < CONTEXT_REARM) a.context = false
43 }
44
45 for (const r of s.rateLimits) {
46 const name = limitName(r.kind)
47 const resetsAt = r.resetsAt === undefined ? NaN : Date.parse(r.resetsAt)
48 const resetsIn = Number.isNaN(resetsAt) ? null : Math.max(0, resetsAt - now)
49 const resetText = resetsIn === null ? '' : `, resets in ${duration(resetsIn)}`
50
51 if (r.percentUsed >= LIMIT_ALERT) {
52 if (!a.limits.has(r.kind)) {
53 a.limits.add(r.kind)
54 lines.push(`the ${name} limit is ${r.percentUsed}% used${resetText}.`)
55 }
56 continue
57 }
58 a.limits.delete(r.kind)
59
60 const f = forecast(r.percentUsed, perHour(pace.limits[r.kind] ?? [], now), resetsIn)
61 const isSoon = f !== null && f.kind === 'full' && f.inMs < FORECAST_ALERT_MS
62 if (isSoon && !a.forecasts.has(r.kind)) {
63 a.forecasts.add(r.kind)
64 const early = resetsIn === null ? '' : `, ${duration(resetsIn - f.inMs)} before it resets`
65 lines.push(`at this pace the ${name} limit runs out in ${duration(f.inMs)}${early}.`)
66 } else if (f === null || f.kind !== 'full' || f.inMs >= 2 * FORECAST_ALERT_MS) a.forecasts.delete(r.kind)
67 }
68
69 return lines
70}
71
72/** The line for a main turn that cost `TURN_ALERT_USD` or more, if this one did. */
73export function turnCostAlert(a: Alerts, costUsd: number | null): string | null {
74 const start = a.turnStartUsd
75 a.turnStartUsd = null
76 if (start === null || costUsd === null) return null
77 const spent = costUsd - start
78 return spent >= TURN_ALERT_USD ? `that turn cost ${usd(spent)} (session ${usd(costUsd)}).` : null
79}
80hooks/format.ts 73 lines1import type { Usage } from '../types'
2
3export const emptyUsage = (): Usage => ({
4 input: 0,
5 output: 0,
6 cacheRead: 0,
7 cacheWrite: 0,
8 requests: 0,
9})
10
11/** 950 -> "950", 12_345 -> "12.3k", 1_250_000 -> "1.25M". */
12export function tokens(n: number): string {
13 if (n < 1000) return String(Math.round(n))
14 if (n < 1_000_000) return `${(n / 1000).toFixed(n < 10_000 ? 2 : 1).replace(/\.?0+$/, '')}k`
15 return `${(n / 1_000_000).toFixed(2).replace(/\.?0+$/, '')}M`
16}
17
18/** 45_000 -> "45s", 192_000 -> "3m12s", 8_040_000 -> "2h14m", 2 days -> "2d3h". */
19export function duration(ms: number): string {
20 const s = Math.max(0, Math.floor(ms / 1000))
21 if (s < 60) return `${s}s`
22 const m = Math.floor(s / 60)
23 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}s`
24 const h = Math.floor(m / 60)
25 if (h < 48) return `${h}h${String(m % 60).padStart(2, '0')}m`
26 return `${Math.floor(h / 24)}d${h % 24}h`
27}
28
29/** A bar `width` cells wide, filled to `percent` (clamped to 0-100). */
30export function bar(percent: number, width: number): string {
31 const w = Math.max(1, width)
32 const filled = Math.round((Math.min(100, Math.max(0, percent)) / 100) * w)
33 return '█'.repeat(filled) + '░'.repeat(w - filled)
34}
35
36/** The theme colour for how full something is: green, then amber past 70, red past 90. */
37export function levelColor(percent: number): 'success' | 'warning' | 'error' {
38 if (percent >= 90) return 'error'
39 if (percent >= 70) return 'warning'
40 return 'success'
41}
42
43/** Share of the input the prompt cache served: read over all input, 0-100; null with no input. */
44export function hitRate(u: { input: number; cacheRead: number; cacheWrite: number }): number | null {
45 const all = u.input + u.cacheRead + u.cacheWrite
46 return all === 0 ? null : Math.round((u.cacheRead / all) * 100)
47}
48
49export function addUsage(
50 into: Usage,
51 u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number },
52): Usage {
53 return {
54 input: into.input + u.input_tokens,
55 output: into.output + u.output_tokens,
56 cacheRead: into.cacheRead + u.cache_read_input_tokens,
57 cacheWrite: into.cacheWrite + u.cache_creation_input_tokens,
58 requests: into.requests + 1,
59 }
60}
61
62const LIMIT_NAMES: Record<string, string> = {
63 five_hour: '5-hour',
64 seven_day: '7-day',
65 seven_day_opus: '7-day Opus',
66 seven_day_sonnet: '7-day Sonnet',
67 spend_limit: 'Spend',
68}
69
70export const limitName = (kind: string): string => LIMIT_NAMES[kind] ?? kind.replace(/_/g, ' ')
71
72export const usd = (n: number): string => `$${n.toFixed(2)}`
73hooks/cache-tax.ts 518 lines1// Ported from cache-tax 2.2.1 (https://github.com/karanb192/cache-tax),
2// Copyright (c) 2026 Karan Bansal, MIT License: see LICENSE-cache-tax.
3// Changes: the icon band above the prompt is gone (the session-stats pane
4// shows the same state), and each state change is published to the pane.
5import { atom, update } from 'claude-code'
6import type { EngineInterface, On, SessionCompactInput, TurnCompleteInput, TurnStepInput } from 'claude-code'
7
8import type { CacheTaxView } from '../types'
9
10const TTL_MS = 60 * 60 * 1000
11const PING_AFTER_MS = 50 * 60 * 1000
12const MIN_PING_MS = 60 * 1000
13const AUTO_WARM_MS = 3 * 60 * 60 * 1000
14const DEFAULT_WINDOW_MS = 6 * 60 * 60 * 1000
15const BIG_TOKENS = 50000
16const PING_PROMPT = 'Reply with the single word: warm'
17const KEY_DEADLINE = 'deadline'
18const KEY_EVERY = 'every'
19const KEY_GUARD = 'guard'
20const KEY_ALWAYS = 'always'
21
22const cacheTaxView = atom({ plugin: 'session-stats', key: 'cacheTax' } as const, null as CacheTaxView | null)
23
24// $ per million tokens, [cache read, 1h cache write, output], list prices September 2026.
25// Longer family names first: a model id matches the first row it contains.
26const PRICES: Array<[string, number, number, number]> = [
27 ['fable-5-1', 0.25, 20, 50],
28 ['fable-5', 1, 20, 50],
29 ['opus-5', 0.5, 10, 25],
30 ['opus-4', 0.5, 10, 25],
31 ['sonnet-5', 0.2, 4, 10],
32 ['sonnet', 0.3, 6, 15],
33 ['haiku', 0.1, 2, 5],
34]
35
36type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
37type Miss = { at: number; tokens: number; usd: number | null }
38type GuardMode = 'refuse' | 'warn'
39
40export type State = {
41 sid: string
42 deadline: number
43 every: number
44 always: boolean
45 lastRequestAt: number
46 lastModel: string | null
47 ctx: number
48 compacted: boolean
49 guard: GuardMode
50 ackedAt: number
51 coldWritePending: boolean
52 misses: Miss[]
53 pending: { cancel: () => void } | null
54 last: PingRecord | null
55 stopped: string | null
56}
57
58export function priceOf(model: string | null): [number, number, number] | null {
59 const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
60 for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
61 return null
62}
63
64export function parseDuration(text: string): number | null {
65 const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim())
66 if (!m || (m[1] === undefined && m[2] === undefined)) return null
67 return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 * 1000
68}
69
70export function fmtDuration(ms: number): string {
71 const total = Math.max(0, Math.round(ms / 60000))
72 const h = Math.floor(total / 60)
73 const m = total % 60
74 if (h >= 48) return `${Math.floor(h / 24)}d ${h % 24}h`
75 return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
76}
77
78function fmtUsd(usd: number | null): string {
79 return usd == null ? 'n/a' : '$' + (usd >= 100 ? usd.toFixed(0) : usd.toFixed(2))
80}
81
82function fmtTok(n: number): string {
83 return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
84}
85
86function coldUsd(s: State): number | null {
87 const price = priceOf(s.lastModel)
88 return price ? s.ctx * price[1] / 1e6 : null
89}
90
91function warmUsd(s: State): number | null {
92 const price = priceOf(s.lastModel)
93 return price ? s.ctx * price[0] / 1e6 : null
94}
95
96/** Everything a fork bills: the cache read, any cache write, uncached input at the base rate (half the 1h write rate), and the output. */
97function pingUsd(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, price: [number, number, number]): number {
98 return (u.cache_read_input_tokens * price[0] + u.cache_creation_input_tokens * price[1] + u.input_tokens * price[1] / 2 + u.output_tokens * price[2]) / 1e6
99}
100
101/** The read-only upper bound: pings at the cache-read rate that cost as much as one cold write of the same context. */
102function breakEvenPings(s: State): number | null {
103 const price = priceOf(s.lastModel)
104 if (!price || s.ctx <= 0) return null
105 return Math.floor(price[1] / price[0])
106}
107
108function isCold(s: State, now: number): boolean {
109 return s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
110}
111
112function guardText(s: State, now: number): string {
113 const price = priceOf(s.lastModel)
114 const rate = price ? `$${price[1]}/MTok` : 'the cache-write rate'
115 const warm = warmUsd(s)
116 return `the prompt cache went cold ${fmtDuration(now - s.lastRequestAt - TTL_MS)} ago. Sending this re-writes ` +
117 `up to ${s.ctx.toLocaleString('en-US')} tokens at ${rate} = ${fmtUsd(coldUsd(s))}` +
118 (warm == null ? '' : ` (a warm turn would have cost ${fmtUsd(warm)})`) + '.'
119}
120
121export type ResumeFields = {
122 source: string
123 model?: string
124 context_tokens?: number
125 seconds_since_last_response?: number
126 prompt_cache_likely_expired?: boolean
127 estimated_cache_write_usd?: number
128}
129
130/** /clear starts a new conversation in the same process; nothing priced before it still exists. */
131export function resetForClear(s: State) {
132 s.ctx = 0
133 s.lastRequestAt = 0
134 s.compacted = false
135 s.ackedAt = 0
136 s.coldWritePending = false
137 s.misses = []
138 disarm(s)
139}
140
141/** Applies a resumed session's fields to the state; returns the line to log, if any. */
142export function seedFromResume(s: State, e: ResumeFields, now: number): string | null {
143 if (e.source !== 'resume' && e.source !== 'fork') return null
144 if (typeof e.context_tokens === 'number' && e.context_tokens > 0) s.ctx = e.context_tokens
145 if (typeof e.seconds_since_last_response === 'number') s.lastRequestAt = now - e.seconds_since_last_response * 1000
146 if (typeof e.model === 'string') s.lastModel = e.model
147 s.compacted = false
148 if (e.prompt_cache_likely_expired !== true || s.ctx < BIG_TOKENS) return null
149 const usd = typeof e.estimated_cache_write_usd === 'number' ? fmtUsd(e.estimated_cache_write_usd) : fmtUsd(coldUsd(s))
150 return `resuming cold. The first message re-writes ${s.ctx.toLocaleString('en-US')} tokens, about ${usd}. /clear and paste a summary if you only need the conclusions.`
151}
152
153function statusText(s: State, now: number): string | undefined {
154 if (s.stopped) return `keepwarm stopped: ${s.stopped}`
155 if (!s.deadline) return undefined
156 const pingText = s.last ? ` · last ping read ${fmtTok(s.last.read)} ${fmtUsd(s.last.usd)}` : ''
157 const nextText = !s.lastRequestAt ? ' · waiting for the first turn'
158 : s.compacted ? ' · waiting for the first turn after compaction'
159 : isCold(s, now) ? ` · cold now, first ping ${fmtDuration(s.every)} after the next turn`
160 : ` · ping in ${fmtDuration(s.lastRequestAt + s.every - now)}`
161 return `keepwarm ${fmtDuration(s.deadline - now)} left${nextText}${pingText}`
162}
163
164/** What the session-stats pane draws: the same clock and prices as the card; the pane works out warm or cold from `expiresAt` as it draws. */
165export function viewOf(s: State, now: number): CacheTaxView {
166 const isKnown = s.lastRequestAt > 0 && !s.compacted
167 return {
168 lastRequestAt: s.lastRequestAt,
169 expiresAt: isKnown ? s.lastRequestAt + TTL_MS : null,
170 ctx: s.ctx,
171 coldUsd: coldUsd(s),
172 warmUsd: warmUsd(s),
173 deadline: s.deadline > now ? s.deadline : null,
174 nextPingAt: s.deadline > 0 && isKnown && !isCold(s, now) ? s.lastRequestAt + s.every : null,
175 stopped: s.stopped,
176 lastPing: s.last === null ? null : { at: s.last.at, read: s.last.read, usd: s.last.usd },
177 guard: s.guard,
178 always: s.always,
179 coldWrites: s.misses.length,
180 paidUsd: s.misses.reduce((a, m) => a + (m.usd ?? 0), 0),
181 }
182}
183
184async function updateStatus($: EngineInterface, s: State, now: number) {
185 $.ui.status(statusText(s, now))
186 const view = viewOf(s, now)
187 await update($, cacheTaxView, () => view)
188}
189
190function disarm(s: State) {
191 if (s.pending) s.pending.cancel()
192 s.pending = null
193}
194
195// The window and its ping period belong to the session that armed them, so a
196// second session, or one resumed from another transcript, never inherits them
197// and cannot turn them off. The always switch and the guard mode stay global.
198function deadlineKey(s: State): string {
199 return `${KEY_DEADLINE}:${s.sid}`
200}
201
202function everyKey(s: State): string {
203 return `${KEY_EVERY}:${s.sid}`
204}
205
206/** Clears this session's own dead window and the bare keys a store written before 2.1.1 still holds. Other sessions' keys are never touched: a read followed by a delete cannot be made atomic against their renewal. */
207async function prune($: EngineInterface, s: State, now: number) {
208 for (const key of [KEY_DEADLINE, deadlineKey(s)]) {
209 const deadline = await $.store.get(key)
210 if (deadline === undefined) continue
211 if (typeof deadline === 'number' && deadline > now) continue
212 await $.store.delete(key)
213 await $.store.delete(KEY_EVERY + key.slice(KEY_DEADLINE.length))
214 }
215}
216
217async function stop($: EngineInterface, s: State, why: string | null, forgetAlways = false) {
218 s.deadline = 0
219 s.every = PING_AFTER_MS
220 s.stopped = why
221 disarm(s)
222 await $.store.delete(deadlineKey(s))
223 await $.store.delete(everyKey(s))
224 if (forgetAlways) {
225 s.always = false
226 await $.store.delete(KEY_ALWAYS)
227 }
228 await updateStatus($, s, await $.clock.now())
229}
230
231async function arm($: EngineInterface, s: State) {
232 disarm(s)
233 if (!s.deadline) return updateStatus($, s, await $.clock.now())
234 const now = await $.clock.now()
235 if (now >= s.deadline) return stop($, s, null)
236 // A cold window still needs expiry cleanup, but must not send a model request.
237 if (s.lastRequestAt && !s.compacted && !isCold(s, now)) {
238 const untilCold = s.lastRequestAt + TTL_MS - now
239 const delay = Math.min(s.deadline - now, untilCold, Math.max(1000, s.lastRequestAt + s.every - now))
240 s.pending = $.clock.after(delay, () => { void ping($, s) })
241 } else {
242 s.pending = $.clock.after(s.deadline - now, () => { void arm($, s) })
243 }
244 await updateStatus($, s, now)
245}
246
247async function ping($: EngineInterface, s: State) {
248 s.pending = null
249 if (!s.deadline) return
250 const now = await $.clock.now()
251 if (now >= s.deadline) return arm($, s)
252 // A turn in the meantime re-armed the timer; this callback is stale.
253 if (isCold(s, now)) return arm($, s)
254 if (now - s.lastRequestAt < s.every - 1000) return
255 let reply
256 try {
257 reply = await $.model.fork({ prompt: PING_PROMPT })
258 } catch (err) {
259 return stop($, s, `the ping failed, ${err instanceof Error ? err.message : String(err)}`)
260 }
261 // Older Claude Code releases answered null instead of a failure object.
262 if ((reply as unknown) === null) return stop($, s, 'the engine did not send the ping, either the snapshot was cold or the API call failed')
263 if (reply.isAnswered === false) {
264 const reason = reply.reason === 'nothing-to-fork' ? 'no conversation to warm yet'
265 : reply.reason === 'api-error' ? `the API call failed${reply.status === null ? '' : ` (${reply.status})`}`
266 : reply.reason === 'aborted' ? 'the ping was interrupted'
267 : 'the ping returned no text'
268 return stop($, s, reason)
269 }
270 const u = reply.usage
271 const price = priceOf(s.lastModel)
272 // A warm ping reads the prefix and writes only its own message; a write of a tenth of the read or more means the prefix broke.
273 const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
274 const usd = price ? pingUsd(u, price) : null
275 s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
276 if (!warm) return stop($, s, `the ping read ${fmtTok(u.cache_read_input_tokens)} and wrote ${fmtTok(u.cache_creation_input_tokens)} tokens (${fmtUsd(usd)}), the cache was already gone`)
277 s.lastRequestAt = now
278 await arm($, s)
279}
280
281/** The reply to an arming command; on a cold cache it says when the first ping can come. */
282function armedText(s: State, now: number, windowMs: number): string {
283 if (isCold(s, now)) return `keepwarm on for ${fmtDuration(windowMs)}. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn`
284 return `keepwarm on for ${fmtDuration(windowMs)}, a ping ${fmtDuration(s.every)} after each idle stretch keeps the cache read, not re-written`
285}
286
287async function startWindow($: EngineInterface, s: State, windowMs: number, every: number) {
288 const now = await $.clock.now()
289 s.every = every
290 if (every === PING_AFTER_MS) await $.store.delete(everyKey(s))
291 else await $.store.set(everyKey(s), every)
292 s.deadline = now + windowMs
293 s.stopped = null
294 await $.store.set(deadlineKey(s), s.deadline)
295 await arm($, s)
296}
297
298/** Arms a six-hour window, as a bare /keepwarm does: the pane's keepwarm button. */
299export async function keepwarmOn($: EngineInterface, s: State) {
300 await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
301}
302
303/** Stops keepwarm and turns always off, as /keepwarm off does. */
304export async function keepwarmOff($: EngineInterface, s: State) {
305 await stop($, s, null, true)
306}
307
308/** Sets the guard mode and remembers it across sessions. */
309export async function setGuard($: EngineInterface, s: State, mode: GuardMode) {
310 s.guard = mode
311 await $.store.set(KEY_GUARD, mode)
312 await updateStatus($, s, await $.clock.now())
313}
314
315function card(s: State, now: number): string {
316 const lines: string[] = []
317 lines.push(`${s.lastModel ?? 'model not seen yet'}`)
318 if (s.compacted) lines.push('state reset by compaction, waiting for the first turn')
319 else if (!s.lastRequestAt) lines.push('state no request yet this session')
320 else if (isCold(s, now)) lines.push(`state COLD, last request ${fmtDuration(now - s.lastRequestAt)} ago`)
321 else lines.push(`state warm, ${fmtDuration(s.lastRequestAt + TTL_MS - now)} left`)
322 lines.push(`context ${s.ctx.toLocaleString('en-US')} tokens`)
323 lines.push(`cold cost ${fmtUsd(coldUsd(s))} to re-write it (warm turn ${fmtUsd(warmUsd(s))})`)
324 const always = s.always ? ' (always)' : ''
325 const idle = s.always ? 'off until the next session start, which arms 6h00m (always)' : 'off (/keepwarm to arm it for 6h00m)'
326 lines.push(`keepwarm ${s.deadline ? (statusText(s, now) ?? '').replace(/^keepwarm /, 'on, ') + always : s.stopped ? `stopped, ${s.stopped}${always}` : idle}`)
327 const pings = breakEvenPings(s)
328 if (pings != null) lines.push(`break-even up to ${pings} pings at the read rate cost one cold write, about ${fmtDuration(pings * s.every)} of idle at one ping per ${fmtDuration(s.every)}`)
329 lines.push(`guard ${s.guard === 'refuse' ? 'refuse once (/cache-tax guard warn to only show the price)' : 'warn only (/cache-tax guard refuse to be stopped once)'}`)
330 const paid = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
331 lines.push(`session ${s.misses.length} cold write${s.misses.length === 1 ? '' : 's'} paid, ${fmtUsd(paid)}`)
332 return lines.join('\n')
333}
334
335export function freshState(): State {
336 return {
337 sid: '', deadline: 0, every: PING_AFTER_MS, always: false, lastRequestAt: 0, lastModel: null, ctx: 0, compacted: false,
338 guard: 'refuse', ackedAt: 0, coldWritePending: false, misses: [], pending: null, last: null, stopped: null,
339 }
340}
341
342/** After `next` at session start: restores the window, guard and switches, and registers the commands. */
343export async function cacheTaxSessionStart($: EngineInterface, s: State) {
344 s.sid = await $.session.id()
345 const now = await $.clock.now()
346 await prune($, s, now)
347 const saved = await $.store.get(deadlineKey(s))
348 const savedEvery = await $.store.get(everyKey(s))
349 const savedGuard = await $.store.get(KEY_GUARD)
350 s.deadline = typeof saved === 'number' && saved > now ? saved : 0
351 s.every = typeof savedEvery === 'number' && savedEvery >= MIN_PING_MS ? savedEvery : PING_AFTER_MS
352 s.guard = savedGuard === 'warn' ? 'warn' : 'refuse'
353 s.always = (await $.store.get(KEY_ALWAYS)) === true
354 // Always means a fresh default window every session, whatever the last one left behind.
355 if (s.always) await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
356 const usage = await $.session.usage()
357 if (usage.context.tokens) s.ctx = usage.context.tokens
358 await $.command.register({
359 name: 'keepwarm',
360 description: 'Keep the prompt cache warm: bare for 6h, a window such as 90m, always, off, or status (session-stats)',
361 argumentHint: '[6h | always | off | status]',
362 immediate: true,
363 })
364 await $.command.register({
365 name: 'cache-tax',
366 description: 'Prompt cache state, cold price and this session\'s cold writes; guard warn|refuse (session-stats)',
367 argumentHint: '[status | guard warn | guard refuse]',
368 immediate: true,
369 })
370 // The hook form of cache-tax ships a /cache-tax:status skill; both installed means two guards.
371 const commands = await $.command.list()
372 if (commands.some(c => c.name === 'cache-tax:status')) {
373 $.ui.log('the hook form (cache-tax@claude-code-hooks) is also installed, so a cold send is warned about or refused twice. Uninstall it, or /cache-tax guard warn here.')
374 }
375 await updateStatus($, s, now)
376}
377
378/** Before a step is sent: stamps the main loop's request time. */
379export async function cacheTaxTurnStep($: EngineInterface, s: State, e: TurnStepInput) {
380 if (!e.agentId) s.lastRequestAt = await $.clock.now()
381}
382
383/** After `next` at a turn's end: the context, a paid cold write, and the next ping. */
384export async function cacheTaxTurnComplete($: EngineInterface, s: State, e: TurnCompleteInput) {
385 if (e.agentId) return
386 const now = await $.clock.now()
387 // A sleeping host may deliver this turn before the expired window's timer.
388 if (s.deadline && now >= s.deadline) await stop($, s, null)
389 // turn.step stamps the exact request time; when no step of this turn did, the turn's end is the floor.
390 if (now - s.lastRequestAt > e.durationMs) s.lastRequestAt = now
391 s.compacted = false
392 s.ackedAt = 0
393 const u = e.usage
394 if (u) {
395 if (u.model) s.lastModel = u.model
396 const prev = s.ctx
397 // A turn's usage is its responses summed, so a ten-step turn reports ten
398 // reads of the context. The live window is the engine's figure; the sum
399 // is only the fallback for a host that reports no tokens.
400 const write = u.cache_creation_input_tokens
401 const live = (await $.session.usage()).context.tokens
402 s.ctx = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + write
403 const full = prev > 20000 && write >= 0.5 * prev
404 if (full || s.coldWritePending) {
405 const price = priceOf(s.lastModel)
406 const usd = price ? write * price[1] / 1e6 : null
407 s.misses.push({ at: now, tokens: write, usd })
408 if (s.deadline < now + AUTO_WARM_MS) {
409 await startWindow($, s, AUTO_WARM_MS, s.every)
410 $.ui.log(`cold write of ${fmtTok(write)} tokens paid (${fmtUsd(usd)}). Keeping the cache warm for ${fmtDuration(AUTO_WARM_MS)} so it is not paid again today; /keepwarm off to stop.`)
411 }
412 }
413 }
414 s.coldWritePending = false
415 await arm($, s)
416}
417
418/** After a compaction that installed a summary: the clock is unknown until the next turn. */
419export async function cacheTaxSessionCompact($: EngineInterface, s: State, e: SessionCompactInput) {
420 if (!e.agentId) {
421 s.compacted = true
422 s.ctx = 0
423 s.ackedAt = 0
424 disarm(s)
425 await arm($, s)
426 }
427}
428
429/** Hooks the events only cache-tax uses: resume and /clear, its two commands, and the cold-send guard. */
430export function hookCacheTax(on: On, s: State) {
431 // Resume fields seed the guard before any turn of the resumed session has run.
432 on('classic.SessionStart', async ($, e, next) => {
433 const r = await next(e)
434 if (e.source === 'clear') {
435 await stop($, s, null)
436 s.stopped = null
437 resetForClear(s)
438 s.sid = await $.session.id()
439 await updateStatus($, s, await $.clock.now())
440 return r
441 }
442 const line = seedFromResume(s, e, await $.clock.now())
443 // The resume payload may omit the model; without it the guard cannot price the cold write.
444 if (!s.lastModel) s.lastModel = await $.session.model()
445 if (line) $.ui.log(line)
446 // The seeded clock decides whether a restored or always window pings before the first turn: never when it is cold.
447 await arm($, s)
448 return r
449 }).catch(($, e, next) => next(e))
450
451 on('command.run', { command: 'keepwarm' }, async ($, e) => {
452 const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
453 const now = await $.clock.now()
454 if (words[0] === 'off') {
455 const wasAlways = s.always
456 await keepwarmOff($, s)
457 return { text: wasAlways ? 'keepwarm is off, and no longer arms itself at session start' : 'keepwarm is off' }
458 }
459 if (words[0] === 'always') {
460 s.always = true
461 await $.store.set(KEY_ALWAYS, true)
462 await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
463 const cold = isCold(s, now) ? `. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn` : ''
464 return { text: `keepwarm always on: every session starts with a ${fmtDuration(DEFAULT_WINDOW_MS)} window; /keepwarm off turns it off for good${cold}` }
465 }
466 if (!words.length) {
467 await keepwarmOn($, s)
468 return { text: armedText(s, now, DEFAULT_WINDOW_MS) }
469 }
470 const first = words[0] ?? ''
471 if (first !== 'status') {
472 const window = parseDuration(first)
473 if (window == null) return { text: 'keepwarm takes a window such as 6h or 90m, or always, off, or status' }
474 // "every 2m" is a testing knob and lasts only for the window it was given with.
475 let every = PING_AFTER_MS
476 if (words[1] === 'every') {
477 const period = parseDuration(words[2] ?? '')
478 if (period == null || period < MIN_PING_MS) return { text: 'every takes a period of at least 1m' }
479 every = period
480 }
481 await startWindow($, s, window, every)
482 return { text: armedText(s, now, window) }
483 }
484 return { text: statusText(s, now) ?? 'keepwarm is off' }
485 })
486
487 on('command.run', { command: 'cache-tax' }, async ($, e) => {
488 const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
489 const now = await $.clock.now()
490 if (words[0] === 'guard') {
491 if (words[1] !== 'warn' && words[1] !== 'refuse') return { text: '/cache-tax guard takes warn or refuse' }
492 await setGuard($, s, words[1])
493 return { text: s.guard === 'refuse' ? 'guard set to refuse once: a cold send is dropped with its price, the resend goes through' : 'guard set to warn: a cold send goes through with its price shown' }
494 }
495 return { text: card(s, now) }
496 })
497
498 // The message that pays. Only its first character is read.
499 on('prompt.submit', async ($, e, next) => {
500 if (e.origin.kind === 'plugin') return next(e)
501 if (typeof e.text !== 'string' || e.text.trimStart().startsWith('/')) return next(e)
502 const now = await $.clock.now()
503 if (!isCold(s, now) || s.ctx < BIG_TOKENS) return next(e)
504 if (s.guard === 'warn') {
505 $.ui.log(`${guardText(s, now)} Sending anyway; keepwarm will hold the cache for ${fmtDuration(AUTO_WARM_MS)} once it lands.`)
506 s.coldWritePending = true
507 return next(e)
508 }
509 if (s.ackedAt === s.lastRequestAt) {
510 s.ackedAt = 0
511 s.coldWritePending = true
512 return next(e)
513 }
514 s.ackedAt = s.lastRequestAt
515 return { drop: `cache-tax: ${guardText(s, now)} Send it again to pay it, and keepwarm will then hold the cache for ${fmtDuration(AUTO_WARM_MS)}. Or /clear and start from a note.` }
516 }).catch(($, e, next) => next(e))
517}
518hooks/pace.ts 74 lines1import type { Sample } from '../types'
2
3const HOUR = 60 * 60 * 1000
4/** How far back a pace is measured. */
5export const HORIZON_MS = HOUR
6/** Less history than this gives no pace: a few minutes' spend says little about the next hour. */
7const MIN_SPAN_MS = 5 * 60 * 1000
8
9/**
10 * Adds a reading, keeping the ones inside the horizon plus the newest one
11 * before it, the baseline a pace is measured from. A reading lower than the
12 * last (a usage limit that reset) starts the series over.
13 */
14export function addSample(samples: Sample[], at: number, value: number): Sample[] {
15 const last = samples[samples.length - 1]
16 if (last !== undefined && value < last.value) return [{ at, value }]
17 // A flat run keeps its first and last readings: the ones between change no pace.
18 const before = samples[samples.length - 2]
19 const kept = before !== undefined && last !== undefined && before.value === value && last.value === value ? samples.slice(0, -1) : samples
20 const all = [...kept, { at, value }]
21 const cut = at - HORIZON_MS
22 let first = 0
23 while (first + 1 < all.length && (all[first + 1] as Sample).at <= cut) first += 1
24 return all.slice(first)
25}
26
27/**
28 * Growth per hour over the last hour up to `now`. Idle time counts, so a break
29 * slows the pace; null with under five minutes of history.
30 */
31export function perHour(samples: Sample[], now: number): number | null {
32 const latest = samples[samples.length - 1]
33 if (latest === undefined) return null
34 const cut = now - HORIZON_MS
35 let base = samples[0] as Sample
36 for (const s of samples) if (s.at <= cut) base = s
37 const span = now - base.at
38 if (span < MIN_SPAN_MS) return null
39 return ((latest.value - base.value) / span) * HOUR
40}
41
42export type Forecast = { kind: 'full'; inMs: number } | { kind: 'at-reset'; percent: number }
43
44/**
45 * Where a usage limit lands at `ratePerHour`: full before it resets (and when),
46 * or the share used when it resets. Null with no pace, or no pace and no reset.
47 */
48export function forecast(percentUsed: number, ratePerHour: number | null, resetsInMs: number | null): Forecast | null {
49 if (percentUsed >= 100) return { kind: 'full', inMs: 0 }
50 if (ratePerHour === null) return null
51 if (ratePerHour <= 0) return resetsInMs === null ? null : { kind: 'at-reset', percent: percentUsed }
52 const inMs = ((100 - percentUsed) / ratePerHour) * HOUR
53 if (resetsInMs === null || inMs < resetsInMs) return { kind: 'full', inMs }
54 return { kind: 'at-reset', percent: percentUsed + (ratePerHour * resetsInMs) / HOUR }
55}
56
57/**
58 * What the prompt cache saved on one response, in dollars, at `price` ($ per
59 * million tokens: [cache read, 1h cache write, output]). Reads are saved
60 * against the base input rate, half the 1h write rate; writes cost a premium
61 * over it, priced at the 1h rate.
62 */
63export function cacheSavings(
64 u: { cache_read_input_tokens: number; cache_creation_input_tokens: number },
65 price: [number, number, number],
66): { readsUsd: number; premiumUsd: number } {
67 const [read, write] = price
68 const base = write / 2
69 return {
70 readsUsd: (u.cache_read_input_tokens * (base - read)) / 1e6,
71 premiumUsd: (u.cache_creation_input_tokens * (write - base)) / 1e6,
72 }
73}
74types/index.d.ts 128 lines1export type Usage = {
2 input: number
3 output: number
4 cacheRead: number
5 cacheWrite: number
6 requests: number
7}
8
9export type LastRequest = {
10 model: string
11 at: number
12 input: number
13 output: number
14 cacheRead: number
15 cacheWrite: number
16}
17
18export type Tokens = {
19 main: Usage
20 agents: Usage
21 /** The last main-loop request: what the prompt cache holds now. */
22 last: LastRequest | null
23}
24
25export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
26
27export type Snapshot = {
28 startedAt: number | null
29 model: string | null
30 effort: string | null
31 contextTokens: number | null
32 contextWindow: number | null
33 contextPercent: number | null
34 rateLimits: Limit[]
35 costUsd: number | null
36 version: string | null
37}
38
39export type TurnInfo = {
40 count: number
41 runningSince: number | null
42 lastMs: number | null
43 steps: number
44 compactions: number
45}
46
47export type RunningTool = { id: string; tool: string; since: number; agentId?: string }
48
49export type ToolStats = {
50 counts: Record<string, number>
51 errors: number
52 running: RunningTool[]
53}
54
55export type AgentRow = {
56 id: string
57 description: string
58 type: string
59 status: string
60 firstSeen: number
61 lastSeen: number
62 tokens: number
63 tools: number
64 lastTool: string | null
65}
66
67/** The cache-tax part of the pane: its one-hour clock, prices, keepwarm window and tally. */
68export type CacheTaxView = {
69 lastRequestAt: number
70 /** When the one-hour cache lapses (or lapsed); null before the first request or after compaction. */
71 expiresAt: number | null
72 ctx: number
73 coldUsd: number | null
74 warmUsd: number | null
75 /** The keepwarm window's end; null while keepwarm is off. */
76 deadline: number | null
77 nextPingAt: number | null
78 stopped: string | null
79 lastPing: { at: number; read: number; usd: number | null } | null
80 guard: 'refuse' | 'warn'
81 always: boolean
82 coldWrites: number
83 paidUsd: number
84}
85
86/** One reading of a running total (cost in dollars, a limit's percent used) at a time. */
87export type Sample = { at: number; value: number }
88
89/** The last hour or so of readings each pace is measured over. */
90export type Pace = {
91 cost: Sample[]
92 /** Keyed by the limit's `kind`. */
93 limits: Record<string, Sample[]>
94}
95
96/** What the prompt cache saved this session, main loop and agents, in dollars. */
97export type Savings = {
98 readsUsd: number
99 /** Cache writes' cost over the base input rate, priced at the 1-hour write rate. */
100 premiumUsd: number
101 /** Responses on a model with no price, left out of both figures. */
102 unpriced: number
103}
104
105/** The switches the pane's buttons and /session-stats toggle; each is remembered across sessions. */
106export type Settings = {
107 alerts: boolean
108 /** A desktop notification when a long turn finishes. */
109 notify: boolean
110}
111
112declare module 'claude-code' {
113 interface PluginState {
114 'session-stats': {
115 snap: Snapshot
116 tokens: Tokens
117 turn: TurnInfo
118 tools: ToolStats
119 agents: AgentRow[]
120 now: number
121 cacheTax: CacheTaxView | null
122 pace: Pace
123 savings: Savings
124 settings: Settings
125 }
126 }
127}
128