SLOPSHOPPER

session-pulse

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

newbandcommandtimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-pulse
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /pulse ⎿ session-pulse: Session Pulse hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Session Pulse

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.

Session Pulse showing cache warm 56m, ctx 216k of 1.0M (22%), rewrite about $1.73, 5h limit 2%, week limit 0%, session $3.79 after 16 minutes

SegmentWhat it tells you
● cache warm 56mTime 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.73Roughly 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 · 16mWhat 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%.

What "cache warm 56m" means

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.

  • Warm cache: Claude reads the conversation from memory. It is fast and costs about a tenth of the normal price or less.
  • 56m: you have 56 minutes to send your next message before the memory is cleared. Each reply from Claude resets the timer to the full time, 1 hour by default.
  • Cold cache: after the timer runs out, the next message must write the whole conversation to memory again. That is the rewrite figure.

With the numbers in the screenshot (216k tokens on Opus 5.5):

You reply...CacheCost to re-read the conversation
within 56 minuteswarmabout $0.04 (cache read, $0.20 per 1M tokens)
after 56 minutescoldabout $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.

Install

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.

Use

ActionHow
Hide or show the line/pulse
Change settings/config, then find session-pulse

Settings

SettingDefaultWhat it does
Prompt cache TTL1hSet it to how long your cache lives: 1h or 5m. Drives the countdown and the rewrite price.
Cache write price (USD per 1M tokens)0Overrides the built-in price table. Use it for custom or negotiated pricing. 0 uses the table.

How the numbers are worked out

  • Context, rate limits and session cost come straight from Claude Code, the same figures /context, /usage and /cost show.
  • The cache countdown starts at the last main-thread response and runs for the configured TTL. It is an estimate: the server keeps the real timer.
  • The rewrite estimate is 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:
ModelInput $/1M1h write $/1M5m write $/1M
Fable 5 / 5.110.0020.0012.50
Opus 5.54.008.005.00
Opus 5, 4.5–4.85.0010.006.25
Opus 4 / 4.115.0030.0018.75
Sonnet 5 / 5.52.004.002.50
Sonnet 4.x3.006.003.75
Haiku 4.51.002.001.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.

Privacy

Session Pulse makes no network calls and writes no files. It reads only the usage figures Claude Code already keeps for your session.

Development

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

License

MIT © 2026 Mejba Ahmed

Source 3 files
hooks/register.tsx 179 lines
1import { 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}
179
hooks/format.ts 81 lines
1export 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}
81
types/index.d.ts 20 lines
1export 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