Live band above the prompt: prompt-cache countdown, context fill, re-cache cost, 5h/weekly limits and session cost

See your Claude Code session at a glance. Session Pulse adds one live line above the prompt with the numbers that decide what your next message costs.

| Segment | What it tells you |
|---|---|
● cache warm 56m | Time left before the prompt cache expires. Turns yellow near the end and red (○ cache cold) once it has expired. |
ctx 216k/1.0M 22% | How much of the context window the conversation fills. |
rewrite ≈ $1.73 | Roughly what it costs to write the current context to the cache again once it goes cold. |
5h 2% · week 0% | Your subscription's rate-limit use, with the time until each window resets (↻). |
session $3.79 · 16m | What this session has cost so far, and how long it has run. |
Values change colour as they rise: green below 60%, yellow from 60%, red from 85%.
Every message you send makes Claude read the whole conversation again. To make that cheap, Anthropic keeps the conversation in a short-term memory called the prompt cache.
rewrite figure.With the numbers in the screenshot (216k tokens on Opus 5.5):
| You reply... | Cache | Cost to re-read the conversation |
|---|---|---|
| within 56 minutes | warm | about $0.04 (cache read, $0.20 per 1M tokens) |
| after 56 minutes | cold | about $1.73 (cache write, $8 per 1M tokens) |
In short: while the dot is green, continue the conversation. After a long break, the next message pays the rewrite price. If you no longer need the old conversation, run /clear first and start fresh at almost no cost.
In Claude Code:
/plugin marketplace add mejba13/session-pulse
/plugin install session-pulse@session-pulse
Restart Claude Code. The line appears above the prompt.
Requirements: a Claude Code build with function-hook plugins. Session Pulse was built and tested on Claude Code 2.1.288. That plugin API is early access and may change between releases. If the line stops showing after an update, please open an issue.
| Action | How |
|---|---|
| Hide or show the line | /pulse |
| Change settings | /config, then find session-pulse |
| Setting | Default | What it does |
|---|---|---|
| Prompt cache TTL | 1h | Set it to how long your cache lives: 1h or 5m. Drives the countdown and the rewrite price. |
| Cache write price (USD per 1M tokens) | 0 | Overrides the built-in price table. Use it for custom or negotiated pricing. 0 uses the table. |
/context, /usage and /cost show.context tokens × cache-write price. The cache-write price is the model's list input price × 2 for a 1h cache, or × 1.25 for a 5m cache:| Model | Input $/1M | 1h write $/1M | 5m write $/1M |
|---|---|---|---|
| Fable 5 / 5.1 | 10.00 | 20.00 | 12.50 |
| Opus 5.5 | 4.00 | 8.00 | 5.00 |
| Opus 5, 4.5–4.8 | 5.00 | 10.00 | 6.25 |
| Opus 4 / 4.1 | 15.00 | 30.00 | 18.75 |
| Sonnet 5 / 5.5 | 2.00 | 4.00 | 2.50 |
| Sonnet 4.x | 3.00 | 6.00 | 3.75 |
| Haiku 4.5 | 1.00 | 2.00 | 1.25 |
These are Anthropic first-party API prices. On Bedrock or Vertex AI, or with negotiated rates, set your own price in /config. On a subscription plan the figure is what the same work would cost at API rates.
Session Pulse makes no network calls and writes no files. It reads only the usage figures Claude Code already keeps for your session.
git clone https://github.com/mejba13/session-pulse
claude --plugin-dir session-pulse/plugins/session-pulse # run it without installing
claude plugin validate session-pulse/plugins/session-pulse
claude plugin test session-pulse/plugins/session-pulse
plugins/session-pulse/
├── .claude-plugin/plugin.json manifest and settings
├── hooks/register.tsx the line and its data feed
├── hooks/format.ts price table, countdown and formatting
├── types/index.d.ts state contract
└── tests/pulse.test.tsx tests
MIT © 2026 Mejba Ahmed
hooks/register.tsx 179 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, SessionRateLimit, SessionUsage } from 'claude-code'
3
4import type { Meter } from '../types'
5import { cacheState, cacheWritePerMTok, duration, percentTone, tokens, usd } from './format'
6import type { Tone } from './format'
7
8const TICK_MS = 30_000
9
10const EMPTY: Meter = {
11 ctxTokens: null,
12 ctxWindow: 0,
13 ctxPercent: null,
14 fiveHour: null,
15 fiveHourResetsAt: null,
16 week: null,
17 weekResetsAt: null,
18 costUsd: null,
19 startedAt: null,
20 lastResponseAt: null,
21 model: null,
22}
23
24const meter = atom({ plugin: 'session-pulse', key: 'meter' } as const, EMPTY)
25const now = atom({ plugin: 'session-pulse', key: 'now' } as const, 0)
26const isHidden = atom({ plugin: 'session-pulse', key: 'isHidden' } as const, false)
27
28const COLOR: Record<Tone, string | undefined> = {
29 good: 'green',
30 warn: 'yellow',
31 bad: 'red',
32 dim: undefined,
33}
34
35const windowOf = (limits: readonly SessionRateLimit[], kind: string): SessionRateLimit | undefined =>
36 limits.find(limit => limit.kind === kind)
37
38const fromUsage = (usage: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>): Partial<Meter> => {
39 const five = windowOf(usage.rateLimits, 'five_hour')
40 const week = windowOf(usage.rateLimits, 'seven_day')
41
42 return {
43 ctxTokens: usage.context.tokens ?? null,
44 ctxWindow: usage.context.window,
45 ctxPercent: usage.context.percent ?? null,
46 fiveHour: five?.percentUsed ?? null,
47 fiveHourResetsAt: five?.resetsAt ?? null,
48 week: week?.percentUsed ?? null,
49 weekResetsAt: week?.resetsAt ?? null,
50 costUsd: usage.cost?.usd ?? null,
51 }
52}
53
54const untilReset = (resetsAt: string | null, at: number): string | null => {
55 if (resetsAt === null) {
56 return null
57 }
58 const left = Date.parse(resetsAt) - at
59
60 return Number.isFinite(left) && left > 0 ? duration(left) : null
61}
62
63async function tick($: EngineInterface): Promise<void> {
64 const at = await $.clock.now()
65 await update($, now, () => at)
66}
67
68export const register: Register = (on, options) => {
69 const ttl = options.cacheTtl === '5m' ? '5m' : '1h'
70 const priceOverride = typeof options.cacheWritePerMTok === 'number' ? options.cacheWritePerMTok : 0
71
72 on('session.start', async ($, e, next) => {
73 await $.command.register({
74 name: 'pulse',
75 description: 'Show or hide the Session Pulse band above the prompt',
76 })
77 const [usage, model] = await Promise.all([$.session.usage(), $.session.model()])
78 await update($, meter, current => ({ ...current, ...fromUsage(usage), startedAt: usage.startedAt, model }))
79 await tick($)
80 $.clock.every(TICK_MS, () => void tick($))
81
82 return next(e)
83 })
84
85 on('command.run', { command: 'pulse' }, async $ => {
86 const hidden = await update($, isHidden, value => !value)
87
88 return { text: hidden ? 'Session Pulse hidden.' : 'Session Pulse shown.' }
89 })
90
91 on('session.measure', async ($, e, next) => {
92 await update($, meter, current => ({ ...current, ...fromUsage(e) }))
93
94 return next(e)
95 })
96
97 on('turn.complete', async ($, e, next) => {
98 if (e.agentId === undefined && e.usage) {
99 const at = await $.clock.now()
100 const model = e.usage.model
101 await update($, meter, current => ({ ...current, lastResponseAt: at, model }))
102 await update($, now, () => at)
103 }
104
105 return next(e)
106 })
107
108 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
109 if (e.props.hasSurvey || (await read($, isHidden))) {
110 return next(e)
111 }
112
113 const { Box, Text } = $.ui.resolve(e)
114 const m = await read($, meter)
115 const at = (await read($, now)) || (await $.clock.now())
116 const isWide = (e.viewport?.columns ?? 120) >= 110
117
118 const cache = cacheState(m.lastResponseAt, at, ttl)
119 const rate = cacheWritePerMTok(m.model, ttl, priceOverride)
120 const rewrite = rate !== null && m.ctxTokens !== null ? (m.ctxTokens * rate) / 1_000_000 : null
121
122 const sep = () => <Text dimColor> │ </Text>
123 const label = (text: string) => <Text dimColor>{`${text} `}</Text>
124 const value = (tone: Tone, text: string) => (
125 <Text color={COLOR[tone]} dimColor={tone === 'dim'} bold={tone !== 'dim'}>
126 {text}
127 </Text>
128 )
129 const limit = (name: string, percent: number | null, resetsAt: string | null) => {
130 if (percent === null) {
131 return null
132 }
133 const reset = isWide ? untilReset(resetsAt, at) : null
134
135 return (
136 <Text>
137 {label(name)}
138 {value(percentTone(percent), `${Math.round(percent)}%`)}
139 {reset !== null && <Text dimColor>{` ↻${reset}`}</Text>}
140 </Text>
141 )
142 }
143 const hasLimits = m.fiveHour !== null || m.week !== null
144
145 return (
146 <Box flexDirection="row" flexWrap="wrap" paddingLeft={1}>
147 {value(cache.tone, cache.label)}
148
149 {sep()}
150 {label('ctx')}
151 {m.ctxTokens === null ? (
152 value('dim', '—')
153 ) : (
154 <Text>
155 {value(percentTone(m.ctxPercent ?? 0), tokens(m.ctxTokens))}
156 {isWide && m.ctxWindow > 0 && (
157 <Text dimColor>{`/${tokens(m.ctxWindow)} ${m.ctxPercent ?? 0}%`}</Text>
158 )}
159 </Text>
160 )}
161
162 {sep()}
163 {label('rewrite')}
164 {value(rewrite === null ? 'dim' : 'good', rewrite === null ? '—' : `≈ ${usd(rewrite)}`)}
165
166 {hasLimits && sep()}
167 {limit('5h', m.fiveHour, m.fiveHourResetsAt)}
168 {m.fiveHour !== null && m.week !== null && <Text dimColor> · </Text>}
169 {limit('week', m.week, m.weekResetsAt)}
170
171 {sep()}
172 {label('session')}
173 {value('good', m.costUsd === null ? '—' : usd(m.costUsd))}
174 {isWide && m.startedAt !== null && <Text dimColor>{` · ${duration(at - m.startedAt)}`}</Text>}
175 </Box>
176 )
177 })
178}
179hooks/format.ts 81 lines1export type Tone = 'good' | 'warn' | 'bad' | 'dim'
2
3const MINUTE = 60_000
4
5// Anthropic first-party list price for input, USD per 1M tokens.
6// Cache writes cost 1.25x base (5m TTL) or 2x base (1h TTL). Order matters: first match wins.
7const BASE_INPUT_PER_MTOK: ReadonlyArray<readonly [RegExp, number]> = [
8 [/fable|mythos/i, 10],
9 [/opus-5-5/i, 4],
10 [/opus-4-1|opus-4-20|3-opus/i, 15],
11 [/opus/i, 5],
12 [/sonnet-5/i, 2],
13 [/sonnet/i, 3],
14 [/haiku-4/i, 1],
15 [/haiku-3-5|3-5-haiku/i, 0.8],
16 [/haiku/i, 0.25],
17]
18
19export const ttlMs = (ttl: string): number => (ttl === '5m' ? 5 : 60) * MINUTE
20
21export const cacheWritePerMTok = (model: string | null, ttl: string, override: number): number | null => {
22 if (override > 0) {
23 return override
24 }
25 const hit = BASE_INPUT_PER_MTOK.find(([pattern]) => model !== null && pattern.test(model))
26 if (!hit) {
27 return null
28 }
29
30 return hit[1] * (ttl === '5m' ? 1.25 : 2)
31}
32
33export const tokens = (n: number): string => {
34 if (n >= 1_000_000) {
35 return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`
36 }
37 if (n >= 1_000) {
38 return `${Math.round(n / 1_000)}k`
39 }
40
41 return String(n)
42}
43
44export const usd = (n: number): string => `$${n < 10 ? n.toFixed(2) : n.toFixed(1)}`
45
46export const duration = (ms: number): string => {
47 const minutes = Math.max(0, Math.floor(ms / MINUTE))
48 if (minutes < 60) {
49 return `${minutes}m`
50 }
51 const hours = Math.floor(minutes / 60)
52 if (hours < 24) {
53 return `${hours}h${String(minutes % 60).padStart(2, '0')}m`
54 }
55
56 return `${Math.floor(hours / 24)}d${hours % 24}h`
57}
58
59export const percentTone = (percent: number): Tone => {
60 if (percent >= 85) {
61 return 'bad'
62 }
63
64 return percent >= 60 ? 'warn' : 'good'
65}
66
67export type CacheState = { tone: Tone; label: string }
68
69export const cacheState = (lastResponseAt: number | null, now: number, ttl: string): CacheState => {
70 if (lastResponseAt === null) {
71 return { tone: 'dim', label: '○ cache idle' }
72 }
73 const left = ttlMs(ttl) - (now - lastResponseAt)
74 if (left <= 0) {
75 return { tone: 'bad', label: '○ cache cold' }
76 }
77 const isLow = left < (ttl === '5m' ? MINUTE : 5 * MINUTE)
78
79 return { tone: isLow ? 'warn' : 'good', label: `● cache warm ${duration(Math.max(left, MINUTE))}` }
80}
81types/index.d.ts 20 lines1export type Meter = {
2 ctxTokens: number | null
3 ctxWindow: number
4 ctxPercent: number | null
5 fiveHour: number | null
6 fiveHourResetsAt: string | null
7 week: number | null
8 weekResetsAt: string | null
9 costUsd: number | null
10 startedAt: number | null
11 lastResponseAt: number | null
12 model: string | null
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'session-pulse': { meter: Meter; now: number; isHidden: boolean }
18 }
19}
20