A band above the prompt with the rate-limit windows, a prompt-cache countdown, and warming that keeps the cache from going cold.

A Claude Code mod that keeps the session's cost in view above the prompt and keeps the prompt cache from going cold while you are away.
cache ██░░░░░░ 18 min 5h ░░░░░░░░ 3% resets in 3h 2m week ████░░░░ 44% [Auto Warm]
cold once it has gone); the five-hour window's share and time to reset; the weekly window's share. The limits are the ones the status line gets from the API's rate-limit headers, so off a subscription there are none. They are the account's, so every session of the mod shares the newest reading through the mod's store: an idle, resumed or new session shows what another session last read, within five seconds.1 h (1 warm-up), 2 h (2 warm-ups), 4 h, 8 h, or a number of hours typed under Other (no field on mobile); Cancel or Esc closes it — then warms the cache two minutes before each cooling until the hours or the warm-ups run out. The button counts the warm-ups (Auto Warm 1/2); pressing it again turns auto-warming off. A turn that is running refreshes the cache itself, so no warm-up is sent during one./session-pulse hides or shows the band. Subagents cache prefixes of their own and do not move the countdown.
A warm-up is a one-word fork of the conversation ($.model.fork): it is served from the cache, which restarts the countdown, and it adds nothing to the conversation. It reads the whole cached prefix from the cache, billed at the cache-read rate (a tenth of the input price), plus a few dozen fresh tokens. Letting the cache go cold costs a write of the whole prefix at the cache-write rate on the next turn. With an 80K-token conversation an hour's warm-up costs about 8K input-token equivalents, against about 160K for the rewrite.
A mod is not told how long a cache entry lives: the usage it sees has no split between five-minute and one-hour writes. With cacheTtl set to auto (the default) the mod starts at an hour, as Claude Code's subscription cache lives, and learns from what the requests say:
The learned value is kept across sessions. Set cacheTtl to 5m or 1h in /config to pin it instead.
Install it from the marketplace:
/plugin install session-pulse --marketplace dlin10/ai-coding-plugins
Or load it from this folder for one session:
claude --plugin-dir /path/to/ai-coding-plugins/mods/session-pulse
Or load it in every session by naming the folder in ~/.claude/settings.json:
{
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "/path/to/ai-coding-plugins/mods/session-pulse"
}
}
Mods are a Claude Code-only feature; this one is written and tested against Claude Code 2.1.302.
claude plugin validate mods/session-pulse
claude plugin test mods/session-pulse
Once Claude Code has loaded the mod it lays the API's types in .claude-plugin/types/ (ignored by git), and tsc -p mods/session-pulse type-checks it.
hooks/register.tsx 395 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderChildren } from 'claude-code'
3
4import type { CacheReading, Limit } from '../types'
5import {
6 AUTO_HOURS,
7 FIVE_MINUTES,
8 MINUTE,
9 ONE_HOUR,
10 autoLabel,
11 autoLead,
12 bar,
13 barCells,
14 cachedPrefix,
15 isRunningLow,
16 kindLabel,
17 learnTtl,
18 minutesLeft,
19 parseHours,
20 resetsIn,
21 ttlFromOption,
22 ttlLabel,
23 warmsFor,
24} from './format'
25
26const COMMAND = 'session-pulse'
27const STORED_TTL = 'ttlMs'
28// The rate limits are the account's, so every session of the mod shares the
29// newest reading through the store: an idle session takes what another read.
30const STORED_LIMITS = 'limits'
31const AUTO_QUESTION = 'How long should auto-warming keep the cache warm?'
32// The mod's own dialog: AskUserQuestion raised outside a turn leaves the
33// desktop showing the session as working once it is answered.
34const AUTO_DIALOG = 'session-pulse-auto'
35const WARM_PROMPT = 'This is a check that keeps the prompt cache warm. Reply with one word: ok.'
36const TICK_MS = 5000
37
38const limits = atom({ plugin: 'session-pulse', key: 'limits' } as const, [])
39const cache = atom({ plugin: 'session-pulse', key: 'cache' } as const, null)
40const ttlMs = atom({ plugin: 'session-pulse', key: 'ttlMs' } as const, ONE_HOUR)
41const isTtlLearned = atom({ plugin: 'session-pulse', key: 'isTtlLearned' } as const, false)
42const now = atom({ plugin: 'session-pulse', key: 'now' } as const, 0)
43const isHidden = atom({ plugin: 'session-pulse', key: 'isHidden' } as const, false)
44const isWarming = atom({ plugin: 'session-pulse', key: 'isWarming' } as const, false)
45const auto = atom({ plugin: 'session-pulse', key: 'auto' } as const, null)
46
47const toLimits = (windows: readonly Limit[]): Limit[] =>
48 windows.map(({ kind, percentUsed, resetsAt }) => ({ kind, percentUsed, resetsAt }))
49
50let lastTickWrite = 0
51let lastTickMinute = 0
52let isTurnRunning = false
53let limitsAt = 0
54let quietMisses = 0
55
56async function shareLimits($: EngineInterface, windows: readonly Limit[]) {
57 const at = await $.clock.now()
58 const shown = toLimits(windows)
59 await update($, limits, () => shown)
60 limitsAt = at
61 await $.store.set(STORED_LIMITS, { at, windows: shown })
62}
63
64async function takeSharedLimits($: EngineInterface) {
65 const stored = await $.store.get(STORED_LIMITS)
66 if (typeof stored !== 'object' || stored === null) return
67 const { at, windows } = stored as { at?: unknown; windows?: unknown }
68 if (typeof at !== 'number' || at <= limitsAt || !Array.isArray(windows) || windows.length === 0) return
69 limitsAt = at
70 await update($, limits, () => toLimits(windows as Limit[]))
71}
72
73/**
74 * Reads the main thread's cached prefix once more with a one-word fork, which
75 * refreshes the cache entry without adding a message to the conversation.
76 */
77async function warm($: EngineInterface): Promise<boolean> {
78 if (await read($, isWarming)) return false
79 await update($, isWarming, () => true)
80 try {
81 const previous = await read($, cache)
82 const at = await $.clock.now()
83 const reply = await $.model.fork({ prompt: WARM_PROMPT })
84 if (!reply.isAnswered) {
85 $.ui.toast(`Could not warm the cache: ${reply.reason}`)
86 return false
87 }
88 const { usage } = reply
89 if (previous !== null && usage.cache_read_input_tokens < cachedPrefix(previous) * 0.5) {
90 $.ui.toast('The cache had already gone cold: the warm-up read the conversation afresh')
91 }
92 await update($, cache, () => ({
93 at,
94 model: previous?.model ?? '',
95 read: usage.cache_read_input_tokens,
96 write: usage.cache_creation_input_tokens,
97 uncached: usage.input_tokens,
98 }))
99 await update($, now, () => at)
100 return true
101 } finally {
102 await update($, isWarming, () => false)
103 }
104}
105
106async function chooseAuto($: EngineInterface) {
107 if ((await read($, auto)) !== null) {
108 await update($, auto, () => null)
109 $.ui.toast('Auto-warming is off')
110 return
111 }
112
113 await $.ui.open({ id: AUTO_DIALOG, title: 'Auto-warm', focus: true, closeOnEscape: true, rows: 4 })
114}
115
116async function startTypedAuto($: EngineInterface, text: string) {
117 const hours = parseHours(text)
118 if (hours === undefined) {
119 $.ui.toast(`Not a number of hours: "${text}"`)
120 return
121 }
122 await startAuto($, hours)
123}
124
125async function startAuto($: EngineInterface, hours: number) {
126 await $.ui.close({ id: AUTO_DIALOG })
127 const ttl = await read($, ttlMs)
128 const at = await $.clock.now()
129 const total = warmsFor(hours, ttl)
130 await update($, auto, () => ({ until: at + hours * ONE_HOUR, done: 0, total }))
131 $.ui.toast(`Auto-warming is on: ${autoLabel(hours, ttl)}`)
132}
133
134// Auto-warming warms once the countdown reaches its lead, and stops when its
135// hours or warms run out or the cache went cold anyway. The band shows whole
136// minutes, so it is redrawn when the minute changes, or every half minute for
137// the reset times.
138async function tick($: EngineInterface) {
139 await takeSharedLimits($)
140 const at = await $.clock.now()
141 const reading = await read($, cache)
142 const ttl = await read($, ttlMs)
143 const left = reading === null ? -1 : reading.at + ttl - at
144
145 const plan = await read($, auto)
146 if (plan !== null) {
147 if (at >= plan.until || plan.done >= plan.total) {
148 await update($, auto, () => null)
149 $.ui.toast('Auto-warming has finished')
150 } else if (reading !== null && left <= 0) {
151 await update($, auto, () => null)
152 $.ui.toast('The cache went cold, so auto-warming is off')
153 } else if (reading !== null && left <= autoLead(ttl) && !isTurnRunning && (await warm($))) {
154 await update($, auto, value => (value === null ? null : { ...value, done: value.done + 1 }))
155 }
156 }
157
158 const minute = Math.ceil(left / MINUTE)
159 if (minute !== lastTickMinute || at - lastTickWrite >= 30_000) {
160 lastTickMinute = minute
161 lastTickWrite = at
162 await update($, now, () => at)
163 }
164}
165
166export const register: Register = (on, options) => {
167 const pinnedTtl = ttlFromOption(options.cacheTtl)
168
169 on('session.start', async ($, e, next) => {
170 await $.command.register({
171 name: COMMAND,
172 description: 'Show or hide the session pulse band: rate limits and prompt cache',
173 })
174
175 // Before its first request a process has no reading yet; a reload or resume
176 // keeps the windows the session's state already holds, or takes the store's.
177 const usage = await $.session.usage()
178 if (usage.rateLimits.length > 0) await shareLimits($, usage.rateLimits)
179 else await takeSharedLimits($)
180
181 const stored = await $.store.get(STORED_TTL)
182 const learned = typeof stored === 'number' ? stored : undefined
183 await update($, ttlMs, () => pinnedTtl ?? learned ?? ONE_HOUR)
184 await update($, isTtlLearned, () => pinnedTtl === undefined && learned !== undefined)
185 const startedAt = await $.clock.now()
186 await update($, now, () => startedAt)
187
188 $.clock.every(TICK_MS, () => void tick($))
189
190 return next(e)
191 })
192
193 on('command.run', { command: COMMAND }, async $ => {
194 const hidden = await update($, isHidden, value => !value)
195
196 return { text: hidden ? 'Session pulse hidden.' : 'Session pulse shown.' }
197 })
198
199 on('session.measure', async ($, e, next) => {
200 if (e.changed.includes('rateLimits')) {
201 if (e.rateLimits.length > 0) await shareLimits($, e.rateLimits)
202 else await update($, limits, () => [])
203 }
204
205 return next(e)
206 })
207
208 on('turn.start', async ($, e, next) => {
209 if (e.agentId === undefined) isTurnRunning = true
210
211 return next(e)
212 })
213
214 on('turn.complete', async ($, e, next) => {
215 if (e.agentId === undefined) isTurnRunning = false
216
217 return next(e)
218 })
219
220 // Every request of the main thread refreshes the cache entry it reads, so the
221 // countdown runs from the last one sent. Subagents cache prefixes of their own.
222 on('turn.step', async function* ($, e, next) {
223 if (e.agentId !== undefined) {
224 return yield* next(e)
225 }
226
227 const at = await $.clock.now()
228 const result = yield* next(e)
229 const usage = result.usage
230 if (usage === null) {
231 return result
232 }
233
234 const current: CacheReading = {
235 at,
236 model: usage.model,
237 read: usage.cache_read_input_tokens,
238 write: usage.cache_creation_input_tokens,
239 uncached: usage.input_tokens,
240 }
241 const previous = await read($, cache)
242 await update($, cache, () => current)
243 await update($, now, () => at)
244
245 if (pinnedTtl === undefined && previous !== null && previous.model !== '') {
246 // A lone miss can be an entry evicted before its hour, so five minutes
247 // takes two in a row; a hit after the same quiet starts the count over.
248 let learned = learnTtl(previous, current)
249 if (learned === FIVE_MINUTES) {
250 quietMisses += 1
251 if (quietMisses < 2) learned = undefined
252 } else if (learned !== undefined) {
253 quietMisses = 0
254 }
255 if (learned !== undefined && learned !== (await read($, ttlMs))) {
256 await update($, ttlMs, () => learned)
257 await update($, isTtlLearned, () => true)
258 await $.store.set(STORED_TTL, learned)
259 $.ui.toast(`The prompt cache seems to live ${ttlLabel(learned)}`)
260 }
261 }
262
263 return result
264 })
265
266 on('session.end', { reason: 'clear' }, async ($, e, next) => {
267 await update($, cache, () => null)
268 await update($, auto, () => null)
269
270 return next(e)
271 })
272
273 on('ui.render', { component: 'Pane', requestId: AUTO_DIALOG }, async ($, e) => {
274 const elements = $.ui.resolve(e)
275 const { Box, Button, Text } = elements
276 const Input = 'Input' in elements ? elements.Input : undefined
277 const ttl = await read($, ttlMs)
278
279 return (
280 <Box flexDirection="column" gap={1}>
281 <Text>{AUTO_QUESTION}</Text>
282 <Box flexDirection="row" gap={1} flexWrap="wrap">
283 {AUTO_HOURS.map((hours, index) => (
284 <Button
285 key={`auto-${hours}`}
286 label={autoLabel(hours, ttl)}
287 autoFocus={index === 0 ? true : undefined}
288 onPress={() => startAuto($, hours)}
289 />
290 ))}
291 <Button key="auto-cancel" label="Cancel" role="dismiss" onPress={() => $.ui.close({ id: AUTO_DIALOG })} />
292 </Box>
293 {Input && (
294 <Input
295 key="auto-hours"
296 label="Other"
297 placeholder="hours, up to 24"
298 submitLabel="Start"
299 onSubmit={text => startTypedAuto($, text)}
300 />
301 )}
302 </Box>
303 )
304 })
305
306 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
307 if (e.props.hasSurvey || (await read($, isHidden))) {
308 return next(e)
309 }
310
311 const { Box, Button, Text } = $.ui.resolve(e)
312 const t = await read($, now)
313 const windows = await read($, limits)
314 const reading = await read($, cache)
315 const ttl = await read($, ttlMs)
316 const plan = await read($, auto)
317
318 const left = reading === null ? 0 : reading.at + ttl - t
319 const isWarm = reading !== null && left > 0
320 const cacheText = reading === null ? 'no requests yet' : isWarm ? minutesLeft(left) : 'cold'
321 const shown = windows.map(limit => ({
322 limit,
323 label: kindLabel(limit.kind),
324 percent: `${Math.round(limit.percentUsed)}%`,
325 reset: limit.kind === 'five_hour' ? resetsIn(limit, t) : undefined,
326 }))
327 const autoText = plan === null ? 'Auto Warm' : `Auto Warm ${plan.done}/${plan.total}`
328
329 // The row's text in cells: the label's column and its gap, each part after a
330 // gap, and the button's `[ label ]` after one.
331 const textCells =
332 8 +
333 cacheText.length +
334 shown.reduce(
335 (sum, { label, percent, reset }) =>
336 sum + 1 + label.length + 1 + percent.length + (reset === undefined ? 0 : ` resets in ${reset}`.length),
337 0,
338 ) +
339 1 +
340 autoText.length +
341 4
342 const cells = barCells(e.props.bodyColumns, textCells, (reading === null ? 0 : 1) + windows.length)
343
344 const row = (label: string, ...children: RenderChildren[]) => (
345 <Box flexDirection="row" gap={1}>
346 <Box width={7} flexShrink={0}>
347 <Text dimColor>{label}</Text>
348 </Box>
349 <Box flexDirection="row" gap={1} flexWrap="wrap" flexShrink={1}>
350 {children}
351 </Box>
352 </Box>
353 )
354
355 let cacheParts
356 if (reading === null) {
357 cacheParts = <Text dimColor>{cacheText}</Text>
358 } else if (isWarm) {
359 const color = isRunningLow(left, ttl) ? 'warning' : 'suggestion'
360 cacheParts = (
361 <Text color={color}>
362 {cells === 0 ? '' : `${bar(left / ttl, cells)} `}
363 {cacheText}
364 </Text>
365 )
366 } else {
367 cacheParts = <Text color="error">{cells === 0 ? '' : `${bar(0, cells)} `}{cacheText}</Text>
368 }
369
370 const limitParts = shown.map(({ limit, label, percent, reset }) => {
371 const color = limit.percentUsed >= 95 ? 'error' : limit.percentUsed >= 80 ? 'warning' : 'suggestion'
372 return (
373 <Text>
374 <Text dimColor>{label} </Text>
375 {cells > 0 && <Text color={color}>{bar(limit.percentUsed / 100, cells)} </Text>}
376 <Text weight="bold">{percent}</Text>
377 {reset !== undefined && <Text dimColor> resets in {reset}</Text>}
378 </Text>
379 )
380 })
381
382 return (
383 <Box flexDirection="row" gap={1} justifyContent="space-between">
384 {row('cache', cacheParts, ...limitParts)}
385 <Button
386 key="auto"
387 label={autoText}
388 variant={plan === null ? 'secondary' : 'primary'}
389 onPress={() => chooseAuto($)}
390 />
391 </Box>
392 )
393 })
394}
395hooks/format.ts 122 lines1import type { CacheReading, Limit } from '../types'
2
3export const MINUTE = 60_000
4export const FIVE_MINUTES = 5 * MINUTE
5export const ONE_HOUR = 60 * MINUTE
6
7const KIND_LABELS: Record<string, string> = {
8 five_hour: '5h',
9 seven_day: 'week',
10 seven_day_opus: 'week Opus',
11 seven_day_sonnet: 'week Sonnet',
12 spend_limit: 'spend',
13}
14
15export function kindLabel(kind: string): string {
16 return KIND_LABELS[kind] ?? kind
17}
18
19export function bar(fraction: number, width = 8): string {
20 const filled = Math.round(Math.min(1, Math.max(0, fraction)) * width)
21 return '█'.repeat(filled) + '░'.repeat(width - filled)
22}
23
24/**
25 * The widest bar, up to eight cells, with which `bars` of them, each with its
26 * space, fit beside `textCells` of text in `columns`: a narrowing band shortens
27 * the bars before it drops them. 0, no bars, when not even three-cell ones fit.
28 */
29export function barCells(columns: number, textCells: number, bars: number): number {
30 const cells = Math.min(8, Math.floor((columns - textCells) / bars) - 1)
31 return cells >= 3 ? cells : 0
32}
33
34/** "2h 59m", "18m", "<1m": a span rounded down to the minute. */
35export function humanSpan(ms: number): string {
36 if (ms < MINUTE) return '<1m'
37 const minutes = Math.floor(ms / MINUTE)
38 const days = Math.floor(minutes / 1440)
39 const hours = Math.floor((minutes % 1440) / 60)
40 const rest = minutes % 60
41 if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`
42 if (hours > 0) return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`
43 return `${rest}m`
44}
45
46/** What is left of the cache, rounded up: "18 min" until the last minute. */
47export function minutesLeft(ms: number): string {
48 return `${Math.max(1, Math.ceil(ms / MINUTE))} min`
49}
50
51export function ttlLabel(ttlMs: number): string {
52 return ttlMs >= ONE_HOUR ? '1 hour' : `${Math.round(ttlMs / MINUTE)} minutes`
53}
54
55export function resetsIn(limit: Limit, now: number): string | undefined {
56 if (limit.resetsAt === undefined) return undefined
57 const at = Date.parse(limit.resetsAt)
58 return Number.isNaN(at) ? undefined : humanSpan(Math.max(0, at - now))
59}
60
61/** The prefix the cache held after the request: what the next one reads or writes again. */
62export function cachedPrefix(cache: CacheReading): number {
63 return cache.read + cache.write
64}
65
66/** How long before the cache goes cold auto-warming sends its warm. */
67export function autoLead(ttlMs: number): number {
68 return ttlMs >= ONE_HOUR ? 2 * MINUTE : MINUTE
69}
70
71/** The countdown turns from blue to orange at a quarter of the TTL (15 of 60 minutes). */
72export function isRunningLow(left: number, ttlMs: number): boolean {
73 return left <= ttlMs / 4
74}
75
76export function warmsWord(n: number): string {
77 return n === 1 ? 'warm-up' : 'warm-ups'
78}
79
80/** How many warms keep the cache for `hours` at this TTL. */
81export function warmsFor(hours: number, ttlMs: number): number {
82 return Math.max(1, Math.round((hours * ONE_HOUR) / ttlMs))
83}
84
85export const AUTO_HOURS = [1, 2, 4, 8] as const
86
87export function autoLabel(hours: number, ttlMs: number): string {
88 const warms = warmsFor(hours, ttlMs)
89 return `${hours} h (${warms} ${warmsWord(warms)})`
90}
91
92/** The hours an answer names: one of the labels, or a number typed under Other. */
93export function parseHours(answer: string): number | undefined {
94 const match = answer.replace(',', '.').match(/\d+(?:\.\d+)?/)
95 if (match === null) return undefined
96 const hours = Number(match[0])
97 return hours > 0 && hours <= 24 ? hours : undefined
98}
99
100export function ttlFromOption(option: unknown): number | undefined {
101 if (option === '5m') return FIVE_MINUTES
102 if (option === '1h') return ONE_HOUR
103 return undefined
104}
105
106/**
107 * What a request says about the cache's lifetime, given the reading before it:
108 * a hit after more than five minutes of quiet proves an hour; a miss after
109 * five minutes to an hour of quiet, on the same model, points back to five.
110 */
111export function learnTtl(previous: CacheReading, current: CacheReading): number | undefined {
112 const gap = current.at - previous.at
113 const prefix = cachedPrefix(previous)
114 if (prefix === 0 || gap <= FIVE_MINUTES + 30_000) return undefined
115 if (current.read >= prefix * 0.5) return ONE_HOUR
116 const isMiss = current.read < prefix * 0.25 && current.write >= prefix * 0.5
117 if (isMiss && gap < ONE_HOUR - MINUTE && current.model === previous.model) {
118 return FIVE_MINUTES
119 }
120 return undefined
121}
122types/index.d.ts 29 lines1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3export type CacheReading = {
4 /** When the request that last read or wrote the cache was sent, in `$.clock.now()` ms. */
5 at: number
6 model: string
7 read: number
8 write: number
9 uncached: number
10}
11
12/** Auto-warming: on until `until`, `done` of `total` warms sent. */
13export type AutoWarm = { until: number; done: number; total: number }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'session-pulse': {
18 limits: Limit[]
19 cache: CacheReading | null
20 ttlMs: number
21 isTtlLearned: boolean
22 now: number
23 isHidden: boolean
24 isWarming: boolean
25 auto: AutoWarm | null
26 }
27 }
28}
29