Session meter as one compact line above the Claude Code prompt: context-window fill, five-hour and weekly plan-limit use, and prompt-cache use with a live…

A Claude Code mod. It shows the session's context fill, plan-limit use and prompt-cache use as one line above the prompt, and counts down to the moment the cache lapses.
ctx 12% · 5h 2% 4h14m · wk 2% · cache 100% · read 566k · wrote 1.1k · new 2 · 59:38 · warm
ctx N%: share of the context window in use.5h N%, wk N%: share of the five-hour and weekly plan limits used, as the last API response reported them. Absent off a subscription. Green below 50%, yellow below 80%, red from 80%. After 5h N% comes the time until the five-hour window resets (4h14m, or 42m under an hour).cache N%: share of the last request's prompt that the cache served. Green from 90%, yellow from 50%, red below.read, wrote, new: tokens read from the cache, written to it, and sent uncached.59:38: time until the cache lapses. It starts at the cache lifetime (5:00 or 1:00:00) and turns yellow, then red as the cache runs out. /meter names the lifetime.warm, expires soon, expired: /compact, expired, missed, cold, uncached, or off.The line stays on one row. When the width is short it drops the advice, then new, wrote, read and wk. It draws below whatever other band mods return.
/meter opens a pane:
/meter stop closes it. Options (ttl, warnSeconds, compactAtTokens, band, status, toast) are in .claude-plugin/plugin.json.
Needs function hooks (early access): Claude Code 2.1.259 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1. The lifetime follows Claude Code's own cache rules; see hooks/cache.ts (decideTtl).
Install with /plugin install session-meter --marketplace Allmight97/agents. Test with claude plugin test plugins/session-meter. Adapted from MIT-licensed work; see THIRD_PARTY_NOTICES.md.
hooks/session-meter.tsx 557 lines1/**
2 * session-meter — Claude Mod (EARLY ACCESS)
3 *
4 * A session meter for Claude Code: prompt cache, context window, plan limits
5 * and subagent usage. Every main-loop request reports how many prompt tokens the cache served (`cache_read_input_tokens`), wrote
6 * (`cache_creation_input_tokens`) and sent uncached (`input_tokens`); this mod
7 * keeps those per request and per turn, counts down to the moment the cache
8 * lapses, and says what to do about it: keep going, /compact or /clear.
9 *
10 * - `turn.step` reads each main-loop request's usage (subagents have their
11 * own prefixes and are left out)
12 * - `session.measure` reads the context fill and the plan's rate-limit
13 * windows (five-hour, weekly)
14 * - `agent.spawn` names each subagent; `turn.complete` adds each subagent
15 * turn's tokens to its row
16 * - `$.clock.every(1000)` redraws the countdown, and only while its text
17 * changes: an idle, expired session costs nothing
18 * - one compact line above the prompt (the AbovePrompt component; it keeps what other
19 * band mods draw), an optional status
20 * line entry, and `/meter`, a pane with context, limits, one row per turn and one
21 * per subagent
22 *
23 * The lifetime is counted from the start of the request that last wrote or read
24 * the cache, as Anthropic documents it. Which lifetime Claude Code asked for
25 * follows its documented rules (see decideTtl in ./cache.ts): FORCE_PROMPT_CACHING_5M,
26 * CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H,
27 * then the account (1 hour on a Claude subscription, 5 minutes otherwise). The
28 * API names the TTL of a write but the mod API passes on only the token counts,
29 * so the mod also watches the gaps between requests (a hit after more than 5
30 * minutes proves 1 hour; see observeTtl). `ttl: "5m" | "1h"` pins it.
31 *
32 * Needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 (Claude Code >= 2.1.259).
33 *
34 * Options (pluginConfigs["session-meter@personal"].options):
35 * ttl: "auto" | "5m" | "1h" cache lifetime (default auto)
36 * warnSeconds: number countdown threshold for the warning (default 60)
37 * compactAtTokens: number prompt size that makes an expired cache suggest /compact (default 100000)
38 * band: boolean row above the prompt (default true)
39 * status: boolean entry under the prompt (default false)
40 * toast: boolean toasts near expiry: at warnSeconds, then 10, 3, 2 and 1 s (default true)
41 */
42import type { EngineInterface, Register } from 'claude-code'
43import {
44 advise,
45 COUNTDOWN_MARKS,
46 byTurn,
47 fit,
48 fmtClock,
49 fmtTokens,
50 hitRatio,
51 isCachingDisabled,
52 accountOf,
53 decideTtl,
54 observeTtl,
55 lifeColor,
56 lifeRatio,
57 nextToastMark,
58 positive,
59 promptTokens,
60 remainingMs,
61 rowRatio,
62 ratioColor,
63 fitParts,
64 segments,
65} from './cache.ts'
66import type { Account, Advice, BandPart, CacheEnv, Sample, Ttl } from './cache.ts'
67import { addTurn, fmtReset, fmtResetShort, limitLabel, planLimits, share, stepTokens, usageColor } from './usage.ts'
68import type { AgentTally, Limit, Tokens } from './usage.ts'
69
70const PANE = 'meter'
71const COMMAND = 'meter'
72const KEEP = 200
73// below this a lapsed cache costs too little to interrupt anyone about
74const TOAST_MIN_TOKENS = 20_000
75
76let samples: Sample[] = []
77let ttl: Ttl = '5m'
78let baseTtl: Ttl = '5m'
79let pinned = false
80let observed: Ttl | undefined
81let setting: unknown
82let account: Account = 'other'
83let ttlSource = 'default'
84let envSource = 'default'
85let env: CacheEnv = {}
86let timer: { cancel: () => void } | undefined
87let lastKey = ''
88let toastedFor = 0
89let toastLevel = Infinity
90let isPaneOpen = false
91let context: { tokens?: number; window: number; percent?: number } | undefined
92let limits: Limit[] = []
93let mainTokens: Tokens = { input: 0, output: 0 }
94let agents = new Map<string, AgentTally>()
95const agentLabels = new Map<string, string>()
96
97function resetUsage() {
98 mainTokens = { input: 0, output: 0 }
99 agents = new Map()
100}
101
102type Policy = { warnMs: number; compactAtTokens: number }
103
104function current(policy: Policy, now: number) {
105 const last = samples[samples.length - 1]
106 const prev = samples[samples.length - 2]
107 const disabled = last ? isCachingDisabled(last.model, env) : isCachingDisabled('', env)
108 const advice: Advice = advise(last, prev, { ttl, ...policy }, now, disabled)
109 const left = last ? remainingMs(last, ttl, now) : 0
110 return { last, advice, left }
111}
112
113const COLOR: Record<Advice['kind'], string | undefined> = {
114 warm: 'green',
115 soon: 'yellow',
116 expired: 'red',
117 miss: 'red',
118 off: undefined,
119 cold: undefined,
120 uncached: undefined,
121}
122
123const fiveHourReset = (now: number) => fmtResetShort(limits.find(l => l.kind === 'five_hour')?.resetsAt, now)
124
125function shortLine(policy: Policy, now: number): string {
126 const { last, advice, left } = current(policy, now)
127 if (!last || advice.kind === 'off') return `cache: ${advice.text}`
128 const clock = left > 0 ? ` · ${fmtClock(left)}` : ''
129 return `cache ${Math.round(hitRatio(last) * 100)}%${clock}`
130}
131
132// the promptCacheTtl setting, from the settings files that can carry it (local over project over user)
133async function readSetting($: EngineInterface): Promise<unknown> {
134 const home = await $.env.get('HOME').catch(() => undefined)
135 const cwd = await $.session.cwd().catch(() => undefined)
136 const files = [cwd && `${cwd}/.claude/settings.local.json`, cwd && `${cwd}/.claude/settings.json`, home && `${home}/.claude/settings.json`]
137 for (const file of files) {
138 if (!file) continue
139 try {
140 const value = JSON.parse(await $.fs.read(file)).promptCacheTtl
141 if (value === '5m' || value === '1h') return value
142 } catch {
143 // missing or unreadable: the next file
144 }
145 }
146 return undefined
147}
148
149export const register: Register = (on, options) => {
150 const policy: Policy = {
151 warnMs: positive(options.warnSeconds, 60) * 1000,
152 compactAtTokens: positive(options.compactAtTokens, 100_000),
153 }
154 const showBand = options.band !== false
155 const showStatus = options.status === true
156 const wantToast = options.toast !== false
157
158 on('session.start', async ($, e, next) => {
159 const r = await next(e)
160 samples = []
161 lastKey = ''
162 toastedFor = 0
163 resetUsage()
164 const measured = await $.session.usage().catch(() => undefined)
165 context = measured?.context
166 limits = measured?.rateLimits ?? []
167 const none = () => undefined
168 env = {
169 enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(none),
170 force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(none),
171 ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(none),
172 disableAll: await $.env.get('DISABLE_PROMPT_CACHING').catch(none),
173 disableHaiku: await $.env.get('DISABLE_PROMPT_CACHING_HAIKU').catch(none),
174 disableSonnet: await $.env.get('DISABLE_PROMPT_CACHING_SONNET').catch(none),
175 disableOpus: await $.env.get('DISABLE_PROMPT_CACHING_OPUS').catch(none),
176 }
177 pinned = options.ttl === '5m' || options.ttl === '1h'
178 observed = undefined
179 setting = await readSetting($)
180 account = accountOf(limits)
181 const choice = decideTtl(options.ttl, env, setting, account)
182 baseTtl = choice.ttl
183 ttl = baseTtl
184 envSource = choice.source
185 ttlSource = envSource
186
187 await $.command
188 .register({
189 name: COMMAND,
190 description: 'Context, plan limits, prompt cache per turn and subagent usage (stop closes)',
191 argumentHint: '[stop]',
192 immediate: true,
193 })
194 .catch(err => $.ui.log(`session-meter: /${COMMAND} not registered: ${err}`))
195 $.ui.log(`session-meter loaded: ${ttl} cache (${ttlSource}), /${COMMAND} opens the pane`, { to: 'debug' })
196
197 timer?.cancel()
198 timer = $.clock.every(1000, () => {
199 const now = Date.now()
200 const { last, advice, left } = current(policy, now)
201 const key = `${advice.kind}|${advice.text}|${left > 0 ? fmtClock(left) : ''}|${fiveHourReset(now)}`
202 if (key !== lastKey) {
203 lastKey = key
204 if (showStatus) $.ui.status(shortLine(policy, now))
205 $.ui.invalidate('ui.render')
206 }
207 if (wantToast && last && left > 0 && promptTokens(last) >= TOAST_MIN_TOKENS) {
208 if (toastedFor !== last.startedAt) {
209 toastedFor = last.startedAt
210 toastLevel = Infinity
211 }
212 // the first toast comes at warnSeconds, then 10, 3, 2 and 1 seconds; a late tick skips to the newest one
213 const secs = Math.ceil(left / 1000)
214 const mark = nextToastMark(secs, policy.warnMs / 1000, toastLevel)
215 if (mark !== undefined) {
216 toastLevel = mark
217 const tail = secs <= COUNTDOWN_MARKS[0] ? 'send a message now' : `send a message to keep ${fmtTokens(promptTokens(last))} tokens warm`
218 $.ui.toast(`cache expires in ${secs >= 60 ? fmtClock(left) : `${secs}s`}: ${tail}`)
219 }
220 }
221 })
222 return r
223 })
224
225 on('session.end', async ($, e, next) => {
226 // /clear starts a new conversation in the same process: its cache is a new one
227 if (e.reason === 'clear') {
228 samples = []
229 lastKey = ''
230 toastedFor = 0
231 resetUsage()
232 observed = undefined
233 ttl = baseTtl
234 ttlSource = envSource
235 $.ui.invalidate('ui.render')
236 return next(e)
237 }
238 timer?.cancel()
239 timer = undefined
240 return next(e)
241 })
242
243 // each main-loop request: what the cache did with it
244 on('turn.step', async function* ($, e, next) {
245 if (e.agentId) return yield* next(e)
246 const startedAt = Date.now()
247 const r = yield* next(e)
248 if (r.usage) {
249 const sample: Sample = {
250 turnId: e.turnId,
251 index: e.index,
252 model: r.usage.model || e.model,
253 startedAt,
254 read: r.usage.cache_read_input_tokens,
255 write: r.usage.cache_creation_input_tokens,
256 fresh: r.usage.input_tokens,
257 output: r.usage.output_tokens,
258 }
259 samples.push(sample)
260 if (samples.length > KEEP) samples = samples.slice(-KEEP)
261 const step = stepTokens(r.usage)
262 mainTokens = { input: mainTokens.input + step.input, output: mainTokens.output + step.output }
263 if (!pinned) {
264 // the account can change under a session: a subscription running out of plan usage moves to usage credits
265 account = accountOf((await $.session.usage().catch(() => undefined))?.rateLimits ?? [])
266 const choice = decideTtl(options.ttl, env, setting, account)
267 baseTtl = choice.ttl
268 envSource = choice.source
269 if (observed === undefined) {
270 ttl = baseTtl
271 ttlSource = envSource
272 }
273 const seen = observeTtl(samples[samples.length - 2], sample, observed)
274 if (seen !== observed) {
275 observed = seen
276 ttl = seen ?? baseTtl
277 ttlSource = `observed from request timing; ${envSource} said ${baseTtl}`
278 $.ui.log(`session-meter: cache lifetime is ${ttl} (${ttlSource})`, { to: 'debug' })
279 }
280 }
281 lastKey = ''
282 if (showStatus) $.ui.status(shortLine(policy, Date.now()))
283 $.ui.invalidate('ui.render')
284 }
285 return r
286 })
287
288 on('session.measure', async ($, e, next) => {
289 context = e.context
290 limits = e.rateLimits
291 $.ui.invalidate('ui.render')
292 return next(e)
293 })
294
295 on('agent.spawn', async ($, e, next) => {
296 const r = await next(e)
297 if (r.agentId) agentLabels.set(r.agentId, `${e.subagentType}: ${e.description}`)
298 return r
299 })
300
301 on('turn.complete', async ($, e, next) => {
302 if (e.agentId && e.usage) {
303 agents.set(e.agentId, addTurn(agents.get(e.agentId), e.agentId, agentLabels.get(e.agentId) ?? e.agentId, e.usage))
304 if (isPaneOpen) $.ui.invalidate('ui.render')
305 }
306 return next(e)
307 })
308
309 on('command.run', { command: COMMAND }, async ($, e) => {
310 if (e.args.trim().toLowerCase() === 'stop') {
311 await $.ui.close({ id: PANE }).catch(() => undefined)
312 isPaneOpen = false
313 return { text: 'session meter closed' }
314 }
315 isPaneOpen = true
316 await $.ui.open({ id: PANE, title: 'session meter', focus: true })
317 $.ui.invalidate('ui.render')
318 const { advice } = current(policy, Date.now())
319 return { text: `${ttl} cache (${ttlSource}) · ${advice.text} · /${COMMAND} stop closes` }
320 })
321
322 on('ui.close', async ($, e, next) => {
323 if (e.id !== PANE) return next(e)
324 isPaneOpen = false
325 return next(e)
326 })
327
328 on('ui.press', async ($, e, next) => {
329 if (e.plugin !== $.plugin.name || e.requestId !== PANE) return next(e)
330 if (e.element === 'close') await $.ui.close({ id: PANE }).catch(() => undefined)
331 return next(e)
332 })
333
334 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
335 if (!showBand || e.props.hasSurvey || isPaneOpen) return next(e)
336 const { last, advice, left } = current(policy, Date.now())
337 if (!last && advice.kind !== 'off') return next(e)
338 const below = await next(e)
339 const { Box, Text } = $.ui.resolve(e)
340 const columns = e.props.bodyColumns
341
342 const line = (children: JSX.Element | JSX.Element[]) => (
343 <Box flexDirection="column">
344 {below}
345 <Box key="row" flexDirection="row" columnGap={1}>{children}</Box>
346 </Box>
347 )
348 if (!last) return line(<Text dimColor wrap="truncate-end">{fit(`cache: ${advice.text}`, columns)}</Text>)
349
350 const pct = Math.round(hitRatio(last) * 100)
351 const counting = advice.kind !== 'uncached' && advice.kind !== 'off'
352 const ctxPct = context?.percent === undefined ? undefined : Math.round(context.percent)
353 const plan = planLimits(limits).filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')
354 const reset = fiveHourReset(Date.now())
355 const parts: BandPart[] = [
356 ...(ctxPct === undefined ? [] : [{ key: 'ctx' as const, text: `ctx ${ctxPct}%` }]),
357 ...plan.map(l => ({
358 key: (l.kind === 'five_hour' ? '5h' : 'wk') as BandPart['key'],
359 text: `${limitLabel(l.kind)} ${Math.round(l.percentUsed)}%${l.kind === 'five_hour' && reset ? ` ${reset}` : ''}`,
360 })),
361 { key: 'cache', text: `cache ${pct}%` },
362 { key: 'read', text: `read ${fmtTokens(last.read)}` },
363 { key: 'wrote', text: `wrote ${fmtTokens(last.write)}` },
364 { key: 'new', text: `new ${fmtTokens(last.fresh)}` },
365 ...(counting ? [{ key: 'clock' as const, text: fmtClock(left) }] : []),
366 { key: 'advice', text: advice.short },
367 ]
368 type Cell = { key: string; text: string; color?: string; bold?: boolean }
369 const cells = fitParts(parts, columns).flatMap((part, i): Cell[] => {
370 const lead: Cell[] = i > 0 ? [{ key: `${part.key}:dot`, text: '·' }] : []
371 if (part.key === 'ctx' || part.key === '5h' || part.key === 'wk') {
372 const value = part.key === 'ctx' ? (ctxPct ?? 0) : Math.round(plan.find(l => limitLabel(l.kind) === part.key)?.percentUsed ?? 0)
373 const resetCell: Cell[] = part.key === '5h' && reset ? [{ key: '5h:reset', text: reset }] : []
374 return [...lead, { key: part.key, text: part.key, color: 'cyan' }, { key: `${part.key}:pct`, text: `${value}%`, color: usageColor(value), bold: true }, ...resetCell]
375 }
376 if (part.key === 'cache') {
377 return [...lead, { key: 'cache', text: 'cache', color: 'cyan', bold: true }, { key: 'pct', text: `${pct}%`, color: ratioColor(pct), bold: true }]
378 }
379 if (part.key === 'clock') {
380 const color = left > 0 ? lifeColor(left, ttl, policy.warnMs) : 'red'
381 return [...lead, { key: 'clock', text: fmtClock(left), color, bold: true }]
382 }
383 const color = part.key === 'read' ? 'green' : part.key === 'wrote' ? 'yellow' : part.key === 'new' ? 'cyan' : undefined
384 return [...lead, { key: part.key, text: part.text, color }]
385 })
386 return line(
387 cells.map((cell, i) => (
388 <Text key={cell.key} color={cell.color} bold={cell.bold} dimColor={cell.color === undefined} wrap={i === cells.length - 1 ? 'truncate-end' : undefined}>
389 {cell.text}
390 </Text>
391 )),
392 )
393 })
394
395 on('ui.render', { component: 'Pane' }, async ($, e, next) => {
396 if (e.requestId !== PANE) return next(e)
397 const { Box, Text, Button } = $.ui.resolve(e)
398 const width = Math.max(30, e.props.bodyColumns - 1)
399 // HTML collapses runs of spaces and trims a text's ends; a no-break space keeps them
400 const sp = (t: string) => (e.surface === 'terminal' ? t : t.replace(/ /g, ' '))
401 const now = Date.now()
402 const { last, advice, left } = current(policy, now)
403 const all = byTurn(samples)
404 const counting = !!last && advice.kind !== 'uncached' && advice.kind !== 'off'
405 // the countdown goes green, then yellow, then red as the cache runs out
406 const clockColor = counting ? lifeColor(left, ttl, policy.warnMs) : undefined
407 const stateColor = advice.kind === 'expired' || advice.kind === 'miss' ? 'red' : (clockColor ?? COLOR[advice.kind])
408 const hitColor = (pct: number) => (pct >= 80 ? 'green' : pct >= 40 ? 'yellow' : 'red')
409 // solid bars are filled Boxes, not block characters, so HTML draws no seams between cells
410 const solid = (key: string, parts: [number, string | undefined][]) => (
411 <Box key={key} flexDirection="row" height={1} flexShrink={0}>
412 {parts.map(([w, c], i) => (w > 0 ? <Box key={`${key}:${i}`} width={w} height={1} flexShrink={0} backgroundColor={c} /> : null))}
413 </Box>
414 )
415 const cell = (key: string, w: number, text: string, c?: string, bold = false) => (
416 <Box key={key} width={w} flexShrink={0} justifyContent="flex-end">
417 <Text color={c} bold={bold} dimColor={!c}>{sp(text)}</Text>
418 </Box>
419 )
420
421 const barW = Math.min(width, 40)
422 const life = lifeRatio(left, ttl)
423 const lifeFilled = Math.round(life * barW)
424 const [sr, sw, sn] = last ? segments(last.read, last.write, last.fresh, barW) : [0, 0, 0]
425 const rows = all.slice(-Math.max(3, (e.viewport?.rows ?? 24) - 16))
426 const meters = [
427 ...(context?.percent === undefined
428 ? []
429 : [{ key: 'ctx', label: 'context', pct: context.percent, note: `${fmtTokens(context.tokens ?? 0)} / ${fmtTokens(context.window)}` }]),
430 ...planLimits(limits).map(l => {
431 const reset = fmtReset(l.resetsAt, now)
432 return { key: l.kind, label: limitLabel(l.kind), pct: l.percentUsed, note: reset ? `resets in ${reset}` : '' }
433 }),
434 ]
435 const agentRows = [...agents.values()].sort((a, b) => b.input + b.output - (a.input + a.output))
436 const total: Tokens = agentRows.reduce((t, a) => ({ input: t.input + a.input, output: t.output + a.output }), mainTokens)
437 const icon = advice.kind === 'warm' ? '●' : advice.kind === 'soon' ? '▲' : advice.kind === 'expired' || advice.kind === 'miss' ? '✖' : '○'
438
439 return (
440 <Box flexDirection="column">
441 <Box key="title" flexDirection="row" columnGap={1}>
442 <Text bold color="cyan">{sp('⚡ SESSION METER')}</Text>
443 <Text dimColor>{sp(`· ${ttl} lifetime (${ttlSource})`)}</Text>
444 </Box>
445
446 <Box key="usage" flexDirection="column" marginTop={1}>
447 <Text bold color="cyan">{sp('CONTEXT AND PLAN LIMITS')}</Text>
448 {meters.length === 0 ? <Text dimColor>{sp('no reading yet')}</Text> : null}
449 {meters.map(m => {
450 const filled = Math.round((Math.min(100, m.pct) / 100) * barW)
451 return (
452 <Box key={`m:${m.key}`} flexDirection="row" columnGap={1}>
453 {cell(`m:label:${m.key}`, 7, m.label, 'cyan', true)}
454 {solid(`m:bar:${m.key}`, [[filled, usageColor(m.pct)], [barW - filled, 'gray']])}
455 <Text bold color={usageColor(m.pct)}>{sp(`${Math.round(m.pct)}%`)}</Text>
456 <Text dimColor>{sp(m.note)}</Text>
457 </Box>
458 )
459 })}
460 </Box>
461
462 <Box key="cachehead" marginTop={1}>
463 <Text bold color="cyan">{sp('PROMPT CACHE')}</Text>
464 </Box>
465
466 <Box key="clock" flexDirection="column" marginTop={1}>
467 <Text bold color={clockColor}>{sp(counting ? `⏱ ${left > 0 ? fmtClock(left) : '0:00'}` : '⏱ --:--')}</Text>
468 {counting ? (
469 <Box flexDirection="row" columnGap={1}>
470 {solid('life', [[lifeFilled, clockColor], [barW - lifeFilled, 'gray']])}
471 <Text dimColor>{sp(`${Math.round(life * 100)}%`)}</Text>
472 </Box>
473 ) : null}
474 </Box>
475
476 <Box key="advice" marginTop={1} flexDirection="column">
477 <Text bold color={stateColor}>{sp(`${icon} ${advice.text}`)}</Text>
478 {last ? <Text dimColor>{sp(fit(`${last.model} · prompt ${fmtTokens(promptTokens(last))} tokens`, width))}</Text> : null}
479 </Box>
480
481 {last ? (
482 <Box key="stack" flexDirection="column" marginTop={1}>
483 <Box flexDirection="row" columnGap={1}>
484 {solid('stack', [[sr, 'green'], [sw, 'yellow'], [sn, 'cyan']])}
485 <Text bold color={hitColor(Math.round(hitRatio(last) * 100))}>{sp(`${Math.round(hitRatio(last) * 100)}% hit`)}</Text>
486 </Box>
487 <Box flexDirection="row" columnGap={2}>
488 <Text color="green">{sp(`■ read ${fmtTokens(last.read)}`)}</Text>
489 <Text color="yellow">{sp(`■ wrote ${fmtTokens(last.write)}`)}</Text>
490 <Text color="cyan">{sp(`■ new ${fmtTokens(last.fresh)}`)}</Text>
491 </Box>
492 </Box>
493 ) : null}
494
495 <Box key="table" flexDirection="column" marginTop={1}>
496 <Box key="head" flexDirection="row" columnGap={1}>
497 {cell('h:turn', 4, 'turn', 'cyan', true)}
498 {cell('h:steps', 5, 'steps', 'cyan', true)}
499 {cell('h:read', 6, 'read', 'green', true)}
500 {cell('h:wrote', 6, 'wrote', 'yellow', true)}
501 {cell('h:new', 5, 'new', 'cyan', true)}
502 {cell('h:hit', 4, 'hit', 'magenta', true)}
503 </Box>
504 {rows.length === 0 ? <Text dimColor>{sp('no requests yet')}</Text> : null}
505 {rows.map((row, i) => {
506 const n = all.length - rows.length + i + 1
507 const pct = Math.round(rowRatio(row) * 100)
508 return (
509 <Box key={`t:${row.turnId}`} flexDirection="row" columnGap={1}>
510 {cell(`c:turn:${row.turnId}`, 4, String(n))}
511 {cell(`c:steps:${row.turnId}`, 5, String(row.steps))}
512 {cell(`c:read:${row.turnId}`, 6, fmtTokens(row.read), 'green')}
513 {cell(`c:wrote:${row.turnId}`, 6, fmtTokens(row.write), 'yellow')}
514 {cell(`c:new:${row.turnId}`, 5, fmtTokens(row.fresh), 'cyan')}
515 {cell(`c:hit:${row.turnId}`, 4, `${pct}%`, hitColor(pct), true)}
516 </Box>
517 )
518 })}
519 </Box>
520
521 <Box key="agents" flexDirection="column" marginTop={1}>
522 <Text bold color="cyan">{sp('SUBAGENTS')}</Text>
523 <Text dimColor>{sp(fit(`main loop ${fmtTokens(mainTokens.input)} in · ${fmtTokens(mainTokens.output)} out · ${share(mainTokens, total)}% of session tokens`, width))}</Text>
524 {agentRows.length === 0 ? <Text dimColor>{sp('none this session')}</Text> : null}
525 {agentRows.length > 0 ? (
526 <Box key="ahead" flexDirection="row" columnGap={1}>
527 {cell('ah:turns', 5, 'turns', 'cyan', true)}
528 {cell('ah:in', 6, 'in', 'green', true)}
529 {cell('ah:out', 6, 'out', 'yellow', true)}
530 {cell('ah:share', 5, 'share', 'magenta', true)}
531 <Text bold color="cyan">{sp('agent')}</Text>
532 </Box>
533 ) : null}
534 {agentRows.map(a => (
535 <Box key={`a:${a.id}`} flexDirection="row" columnGap={1}>
536 {cell(`a:turns:${a.id}`, 5, String(a.turns))}
537 {cell(`a:in:${a.id}`, 6, fmtTokens(a.input), 'green')}
538 {cell(`a:out:${a.id}`, 6, fmtTokens(a.output), 'yellow')}
539 {cell(`a:share:${a.id}`, 5, `${share(a, total)}%`, 'magenta', true)}
540 <Text dimColor wrap="truncate-end">{sp(fit(a.label, Math.max(10, width - 28)))}</Text>
541 </Box>
542 ))}
543 </Box>
544
545 <Box key="foot" marginTop={1} flexDirection="column">
546 <Button key="close" label="close" onPress={() => {}} />
547 <Box key="legend" marginTop={1} flexDirection="column">
548 <Text color="green">{sp('■ read: served by the cache')}</Text>
549 <Text color="yellow">{sp('■ wrote: new cache entry')}</Text>
550 <Text color="cyan">{sp('■ new: sent uncached')}</Text>
551 </Box>
552 </Box>
553 </Box>
554 )
555 })
556}
557hooks/cache.ts 339 lines1/**
2 * cache.ts — the pure half of session-meter's prompt-cache view: no `$`, no engine.
3 *
4 * What it models, from Anthropic's prompt-caching documentation:
5 * - the cache lives 5 minutes by default, 1 hour when asked for; a read
6 * refreshes the entry at no extra cost, and the lifetime is measured from
7 * the START of the request that wrote or read it
8 * - a request's prompt is `input_tokens` (uncached remainder) +
9 * `cache_read_input_tokens` + `cache_creation_input_tokens`
10 * - writes cost 1.25x base input for 5m and 2x for 1h; reads about 0.1x
11 * (less on some models), so an expired cache on a large context is the
12 * expensive moment
13 * - a prefix change (model, effort/thinking settings, tool set, system
14 * prompt) makes the next request write instead of read
15 *
16 * Claude Code's own switches (read from the environment):
17 * ENABLE_PROMPT_CACHING_1H=1 ask for the 1-hour TTL
18 * FORCE_PROMPT_CACHING_5M=1 force the 5-minute TTL, beating the above
19 * DISABLE_PROMPT_CACHING=1 no caching; DISABLE_PROMPT_CACHING_{HAIKU,SONNET,OPUS}
20 * turn it off for that model family only
21 */
22
23export type Ttl = '5m' | '1h'
24
25export type CacheEnv = {
26 enable1h?: string
27 force5m?: string
28 /** CLAUDE_CODE_PROMPT_CACHE_TTL: "5m" or "1h" for the main conversation */
29 ttlVar?: string
30 disableAll?: string
31 disableHaiku?: string
32 disableSonnet?: string
33 disableOpus?: string
34}
35
36/** One main-loop request, as the API reported it. */
37export type Sample = {
38 turnId: string
39 index: number
40 model: string
41 /** ms since the epoch when the request started: the cache's lifetime is counted from here */
42 startedAt: number
43 read: number
44 write: number
45 fresh: number
46 output: number
47}
48
49export type AdviceKind = 'off' | 'cold' | 'uncached' | 'warm' | 'soon' | 'expired' | 'miss'
50
51export type Advice = {
52 kind: AdviceKind
53 /** the full sentence, for the /cache pane and toasts */
54 text: string
55 /** a word or two, for the compact band */
56 short: string
57}
58
59export type Policy = {
60 ttl: Ttl
61 warnMs: number
62 compactAtTokens: number
63}
64
65export const isOn = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'
66
67/** What the account is billed as, as far as the mod can tell. */
68export type Account = 'subscription' | 'credits' | 'other'
69
70export type TtlChoice = { ttl: Ttl; source: string }
71
72const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
73
74/**
75 * Which lifetime Claude Code asks for on the main conversation, in the order
76 * its documentation gives (https://code.claude.com/docs/en/prompt-caching,
77 * "Choose the TTL yourself"), after the mod's own `ttl` option:
78 *
79 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl
80 * setting, ENABLE_PROMPT_CACHING_1H, then the default of the account: one
81 * hour on a Claude subscription within its plan usage, five minutes on usage
82 * credits, an API key or a cloud provider.
83 */
84export function decideTtl(option: unknown, env: CacheEnv, setting?: unknown, account?: Account): TtlChoice {
85 const pinned = asTtl(option)
86 if (pinned) return { ttl: pinned, source: 'the ttl option' }
87 if (isOn(env.force5m)) return { ttl: '5m', source: 'FORCE_PROMPT_CACHING_5M' }
88 const fromVar = asTtl(env.ttlVar)
89 if (fromVar) return { ttl: fromVar, source: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
90 const fromSetting = asTtl(setting)
91 if (fromSetting) return { ttl: fromSetting, source: 'the promptCacheTtl setting' }
92 if (isOn(env.enable1h)) return { ttl: '1h', source: 'ENABLE_PROMPT_CACHING_1H' }
93 if (account === 'subscription') return { ttl: '1h', source: 'Claude subscription default' }
94 if (account === 'credits') return { ttl: '5m', source: 'usage credits default' }
95 return { ttl: '5m', source: 'default' }
96}
97
98export const resolveTtl = (option: unknown, env: CacheEnv, setting?: unknown, account?: Account): Ttl =>
99 decideTtl(option, env, setting, account).ttl
100
101/**
102 * The account, from the rate-limit windows the last response reported: a
103 * five-hour or seven-day window means a Claude subscription, and one that is
104 * full means the next requests draw on usage credits. No window (an API key,
105 * a cloud provider, or no response yet) says nothing.
106 */
107export function accountOf(windows: readonly { kind: string; percentUsed: number }[]): Account {
108 const plan = windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
109 if (plan.length === 0) return 'other'
110 return plan.some(w => w.percentUsed >= 100) ? 'credits' : 'subscription'
111}
112
113export function ttlMs(ttl: Ttl): number {
114 return ttl === '1h' ? 3_600_000 : 300_000
115}
116
117/** Caching switched off for this model by the environment. */
118export function isCachingDisabled(model: string, env: CacheEnv): boolean {
119 if (isOn(env.disableAll)) return true
120 const name = model.toLowerCase()
121 if (name.includes('haiku')) return isOn(env.disableHaiku)
122 if (name.includes('sonnet')) return isOn(env.disableSonnet)
123 if (name.includes('opus')) return isOn(env.disableOpus)
124 return false
125}
126
127export const promptTokens = (s: Sample) => s.read + s.write + s.fresh
128
129/** Share of the prompt the cache served, 0 to 1; 0 for an empty prompt. */
130export function hitRatio(s: Sample): number {
131 const total = promptTokens(s)
132 return total === 0 ? 0 : s.read / total
133}
134
135/** When the cache entry the sample touched lapses, ms since the epoch. */
136export const expiresAt = (s: Sample, ttl: Ttl) => s.startedAt + ttlMs(ttl)
137
138/** Zero for a request that read and wrote nothing: it created or refreshed no entry, so there is nothing to count down. */
139export function remainingMs(s: Sample, ttl: Ttl, now: number): number {
140 if (s.read + s.write === 0) return 0
141 return Math.max(0, expiresAt(s, ttl) - now)
142}
143
144/**
145 * Why a request that should have read the cache wrote it instead; undefined
146 * when it did not miss. A prompt that shrank is a /compact or /clear, not a
147 * miss, and the first request of a session has nothing to read.
148 */
149export function missReason(prev: Sample | undefined, cur: Sample, ttl: Ttl): string | undefined {
150 if (!prev) return undefined
151 const before = promptTokens(prev)
152 if (before === 0 || promptTokens(cur) < before * 0.7) return undefined
153 if (cur.read >= before * 0.5 || cur.write === 0) return undefined
154 if (cur.model !== prev.model) return `model changed (${prev.model} to ${cur.model})`
155 if (cur.startedAt - prev.startedAt > ttlMs(ttl)) return `the ${ttl} cache had lapsed`
156 return 'the prompt prefix changed (effort, tools, system prompt or CLAUDE.md)'
157}
158
159export function advise(last: Sample | undefined, prev: Sample | undefined, policy: Policy, now: number, disabled: boolean): Advice {
160 if (disabled) return { kind: 'off', short: 'off', text: 'prompt caching is off for this model (DISABLE_PROMPT_CACHING*)' }
161 if (!last) return { kind: 'cold', short: 'cold', text: 'no request yet: the first one writes the cache' }
162 if (last.read + last.write === 0) {
163 return { kind: 'uncached', short: 'uncached', text: 'this request was not cached (prompt under the model minimum, or caching off)' }
164 }
165 const miss = missReason(prev, last, policy.ttl)
166 const left = remainingMs(last, policy.ttl, now)
167 const size = promptTokens(last)
168 if (left <= 0) {
169 const big = size >= policy.compactAtTokens
170 return {
171 kind: 'expired',
172 short: big ? 'expired: /compact' : 'expired',
173 text: big
174 ? `expired: the next message rewrites ${fmtTokens(size)} tokens. /compact first, or /clear if the task is done`
175 : `expired: only ${fmtTokens(size)} tokens to rebuild, just keep going`,
176 }
177 }
178 if (left <= policy.warnMs) {
179 return { kind: 'soon', short: 'expires soon', text: 'expires soon: any message refreshes it for free' }
180 }
181 if (miss) return { kind: 'miss', short: 'missed', text: `cache missed: ${miss}` }
182 return { kind: 'warm', short: 'warm', text: 'warm: keep going' }
183}
184
185export function fmtTokens(n: number): string {
186 if (n < 1000) return String(n)
187 if (n < 100_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
188 if (n < 1_000_000) return `${Math.round(n / 1000)}k`
189 return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
190}
191
192/** m:ss, or h:mm:ss from an hour up. */
193export function fmtClock(ms: number): string {
194 const total = Math.max(0, Math.ceil(ms / 1000))
195 const h = Math.floor(total / 3600)
196 const m = Math.floor((total % 3600) / 60)
197 const s = total % 60
198 const pad = (n: number) => String(n).padStart(2, '0')
199 return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`
200}
201
202export function bar(ratio: number, width: number): string {
203 const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
204 return '█'.repeat(filled) + '░'.repeat(width - filled)
205}
206
207export type TurnRow = {
208 turnId: string
209 steps: number
210 read: number
211 write: number
212 fresh: number
213 output: number
214}
215
216/** Samples grouped by turn, oldest first, each turn's requests summed. */
217export function byTurn(samples: readonly Sample[]): TurnRow[] {
218 const rows: TurnRow[] = []
219 for (const s of samples) {
220 let row = rows[rows.length - 1]
221 if (!row || row.turnId !== s.turnId) {
222 row = { turnId: s.turnId, steps: 0, read: 0, write: 0, fresh: 0, output: 0 }
223 rows.push(row)
224 }
225 row.steps += 1
226 row.read += s.read
227 row.write += s.write
228 row.fresh += s.fresh
229 row.output += s.output
230 }
231 return rows
232}
233
234export const rowRatio = (r: TurnRow) => {
235 const total = r.read + r.write + r.fresh
236 return total === 0 ? 0 : r.read / total
237}
238
239export function fit(text: string, width: number): string {
240 return text.length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}…`
241}
242
243/** Colour of the hit percentage: green from 90%, yellow from 50%, red below. */
244export function ratioColor(pct: number): 'green' | 'yellow' | 'red' {
245 return pct >= 90 ? 'green' : pct >= 50 ? 'yellow' : 'red'
246}
247
248export type BandPart = { key: 'ctx' | '5h' | 'wk' | 'cache' | 'read' | 'wrote' | 'new' | 'clock' | 'advice'; text: string }
249
250/** The parts that fit `columns` joined by ' · '; advice goes first, then new, wrote, read and the weekly limit. */
251export function fitParts(parts: readonly BandPart[], columns: number): BandPart[] {
252 const width = (list: readonly BandPart[]) => list.reduce((n, p) => n + p.text.length, 0) + 3 * Math.max(0, list.length - 1)
253 let kept = [...parts]
254 for (const drop of ['advice', 'new', 'wrote', 'read', 'wk'] as const) {
255 if (width(kept) <= columns) break
256 kept = kept.filter(p => p.key !== drop)
257 }
258 return kept
259}
260
261export function positive(v: unknown, fallback: number): number {
262 return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : fallback
263}
264
265/** Share of the cache lifetime left, 0 to 1. */
266export function lifeRatio(leftMs: number, ttl: Ttl): number {
267 return Math.min(1, Math.max(0, leftMs / ttlMs(ttl)))
268}
269
270/**
271 * Widths of the three stacked-bar segments (read, wrote, new) over `width`
272 * cells: proportional, each non-empty part at least one cell, summing to width.
273 */
274export function segments(read: number, write: number, fresh: number, width: number): [number, number, number] {
275 const total = read + write + fresh
276 if (total === 0 || width <= 0) return [0, 0, 0]
277 const parts = [read, write, fresh]
278 const cells = parts.map(p => (p > 0 ? Math.max(1, Math.round((p / total) * width)) : 0))
279 let over = cells.reduce((a, b) => a + b, 0) - width
280 while (over !== 0) {
281 const i = over > 0 ? cells.indexOf(Math.max(...cells)) : parts.indexOf(Math.max(...parts))
282 cells[i] = (cells[i] ?? 0) + (over > 0 ? -1 : 1)
283 over += over > 0 ? -1 : 1
284 }
285 return [cells[0] ?? 0, cells[1] ?? 0, cells[2] ?? 0]
286}
287
288/** Seconds left at which a toast counts down after the one at the warning threshold. */
289export const COUNTDOWN_MARKS = [10, 3, 2, 1] as const
290
291/**
292 * The toast mark to fire now, or undefined. `level` is the mark last fired for
293 * this cache entry (Infinity before any); a late tick skips straight to the
294 * newest mark crossed, so a stalled clock never replays old ones.
295 */
296export function nextToastMark(secsLeft: number, warnSecs: number, level: number): number | undefined {
297 const marks = [warnSecs, ...COUNTDOWN_MARKS].filter(m => m <= warnSecs)
298 const due = marks.filter(m => secsLeft <= m && m < level)
299 return due.length ? Math.min(...due) : undefined
300}
301
302export type LifeColor = 'green' | 'yellow' | 'red'
303
304/** Countdown colour: green while there is plenty, yellow below 40% of the lifetime, red from the warning threshold down. */
305export function lifeColor(leftMs: number, ttl: Ttl, warnMs: number): LifeColor {
306 if (leftMs <= warnMs) return 'red'
307 return leftMs / ttlMs(ttl) <= 0.4 ? 'yellow' : 'green'
308}
309
310// requests are timed from their start, so a little slack keeps a hit that
311// landed just inside the lifetime from reading as proof of the longer one
312const SLACK_MS = 10_000
313
314/**
315 * What the traffic says about the cache lifetime, given the request before and
316 * `known`, what earlier requests already showed.
317 *
318 * - a hit (the cache served at least half of the previous prompt) more than
319 * 5 minutes after the previous request began proves the 1-hour lifetime,
320 * and nothing later undoes it: a miss afterwards is more likely a changed
321 * prefix than a lapse
322 * - a miss with the same model and a prompt that did not shrink, 5 minutes to
323 * an hour after the previous request, says the entry lapsed: 5 minutes
324 * (weaker: a changed prefix looks the same, so a later hit overrules it)
325 *
326 * Needed because the API names the TTL of a write (`cache_creation.ephemeral_*`)
327 * but Claude Code's mod API passes on only the four token counts.
328 */
329export function observeTtl(prev: Sample | undefined, cur: Sample, known: Ttl | undefined): Ttl | undefined {
330 if (!prev || prev.read + prev.write === 0 || cur.model !== prev.model) return known
331 const gap = cur.startedAt - prev.startedAt
332 const before = promptTokens(prev)
333 if (gap <= ttlMs('5m') + SLACK_MS) return known
334 if (cur.read >= before * 0.5) return '1h'
335 if (known === '1h') return known
336 const lapsed = cur.write > 0 && promptTokens(cur) >= before * 0.7 && gap < ttlMs('1h') + SLACK_MS
337 return lapsed ? '5m' : known
338}
339hooks/usage.ts 69 lines1/**
2 * usage.ts — the pure half of session-meter's context, plan-limit and
3 * subagent views: no `$`, no engine.
4 */
5
6export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
7
8export type Tokens = { input: number; output: number }
9
10export type AgentTally = Tokens & { id: string; label: string; turns: number }
11
12export type StepUsage = {
13 input_tokens: number
14 output_tokens: number
15 cache_read_input_tokens: number
16 cache_creation_input_tokens: number
17}
18
19const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: 'wk' }
20
21export const limitLabel = (kind: string): string => LIMIT_LABEL[kind] ?? kind.replace(/_/g, ' ')
22
23/** Plan limits in a fixed order: five-hour, then weekly, then any other window. */
24export const planLimits = (limits: readonly Limit[]): Limit[] =>
25 [...limits].sort((a, b) => rank(a.kind) - rank(b.kind))
26
27const rank = (kind: string) => (kind === 'five_hour' ? 0 : kind === 'seven_day' ? 1 : 2)
28
29/** Green below 50%, yellow below 80%, red from 80%. */
30export function usageColor(pct: number): 'green' | 'yellow' | 'red' {
31 return pct >= 80 ? 'red' : pct >= 50 ? 'yellow' : 'green'
32}
33
34/** Time until `resetsAt`, as `4h 14m`, `2d 3h` or `12m`; empty when unknown or past. */
35export function fmtReset(resetsAt: string | undefined, now: number): string {
36 const at = resetsAt === undefined ? Number.NaN : Date.parse(resetsAt)
37 const mins = Math.floor((at - now) / 60_000)
38 if (!Number.isFinite(mins) || mins <= 0) return ''
39 const days = Math.floor(mins / 1440)
40 const hours = Math.floor((mins % 1440) / 60)
41 if (days > 0) return `${days}d ${hours}h`
42 return hours > 0 ? `${hours}h ${mins % 60}m` : `${mins}m`
43}
44
45/** `fmtReset` without the space, for the band: `4h14m`. */
46export const fmtResetShort = (resetsAt: string | undefined, now: number): string => fmtReset(resetsAt, now).replace(' ', '')
47
48export const stepTokens = (u: StepUsage): Tokens => ({
49 input: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens,
50 output: u.output_tokens,
51})
52
53export function addTurn(tally: AgentTally | undefined, id: string, label: string, usage: StepUsage): AgentTally {
54 const t = stepTokens(usage)
55 return {
56 id,
57 label: tally?.label ?? label,
58 turns: (tally?.turns ?? 0) + 1,
59 input: (tally?.input ?? 0) + t.input,
60 output: (tally?.output ?? 0) + t.output,
61 }
62}
63
64/** Share of all tokens, 0 to 100, rounded. */
65export function share(part: Tokens, total: Tokens): number {
66 const all = total.input + total.output
67 return all === 0 ? 0 : Math.round(((part.input + part.output) / all) * 100)
68}
69