SLOPSHOPPER

session-band

Prompt cache countdown, session cost, rate limits and context window usage in the band above the prompt

newbandnetworktimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-band
› 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 ⟨Claude Code's own drawing⟩ Session $0.42 30m elapsed Limits 5h ███░░░░░░░ 31% Context ██████████░░░░░░░░░░ 49% 97k / 200k ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Session $0.42 30m elapsed Limits 5h ███░░░░░░░ 31% Context ██████████░░░░░░░░░░ 49% 97k / 200k
README

session-band

Session figures in the band directly above the Claude Code prompt, on the terminal and in the desktop app's Code tab:

session-band in the desktop app's Code tab

  • Cache: time left before the main thread's prompt cache expires, counted from the start of the last main-thread request that read or wrote the cache, since generation time counts against the cache lifetime. After a session is resumed or forked, the plugin has no request start time, only the time the last response finished (Claude Code's SessionStart field seconds_since_last_response), so the restored countdown is an estimate that can run long by up to that response's generation time and is shown followed by the word estimated in dim text, for example 4:00 estimated, until the next main-thread response that reads or writes the cache replaces it with the exact countdown. When Claude Code reports on resume that the prompt cache has likely expired, the row shows expired instead.
  • Session: the session's API-priced cost and how long it has run.
  • Limits: each rate-limit window the API reports, with a usage bar and the time to its reset. Windows the plugin does not name (an organization spend limit, for example) show under their raw kind. The row is hidden until the first response of the session reports a reading. Plans billed in usage credits, such as Enterprise seats, report no window on responses; turn on showCredits to add their monthly credit spend as Credits, which is read when the session starts, so with it the row can show before any response.
  • Context: the context window fill, split by the largest categories in /context's colors.

Requirements

Function hooks are an early-access Claude Code API that may change between releases without notice. CI tests this plugin on Claude Code 2.1.288. If the band does not appear, update Claude Code; a build where function hooks are still off by default needs CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 in its environment.

Install

claude plugin marketplace add crane-valley/claude-plugins
claude plugin install session-band@crane-valley

Options

Set them with /plugin configure session-band@crane-valley, in /config, or with --config KEY=VALUE on claude plugin install. Unset options take the defaults below.

OptionDefaultMeaning
cacheTtl5mThe prompt cache TTL your main thread uses (5m or 1h). The plugin cannot read it from Claude Code, so a wrong value shows a wrong countdown.
showCosttrueShow the session's cost. On a subscription this is the API price of the usage, not what you are billed.
showCreditsfalseAdd the account's monthly usage-credit spend to the Limits row, as Claude Code's /usage shows it under Usage credits. The plugin asks the same endpoint with your own login at most every 5 minutes, or sooner after a failed request (30 seconds, doubling back up to 5 minutes). The endpoint is undocumented, so when it changes the row is left out; failed requests keep the last figure for up to 30 minutes. It carries no reset time, so none is shown. It needs a claude.ai login: on Bedrock, Vertex, a gateway or an API key the plugin sends no request and shows no Credits.

Sharing the band

The band above the prompt is one slot shared by every plugin. session-band appends its rows to whatever the plugins beneath it drew, so other plugins' rows stay visible; a plugin that draws the band without calling next(e) hides the ones beneath it.

License

MIT

Source 2 files
hooks/register.tsx 367 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, RenderElement } from 'claude-code'
3import type { CreditsReading, RateLimit } from '../types'
4
5const LABEL_WIDTH = 9
6const LIMIT_BAR_CELLS = 10
7const CONTEXT_BAR_CELLS = 20
8const TOP_CATEGORIES = 3
9const SECOND_MS = 1000
10const MINUTE_MS = 60 * SECOND_MS
11const TTL_MS: Record<string, number> = { '5m': 5 * MINUTE_MS, '1h': 60 * MINUTE_MS }
12// The endpoint behind Claude Code's /usage "Usage credits"; undocumented, so every field is checked.
13const CREDITS_URL = 'https://api.anthropic.com/api/oauth/usage'
14const CREDITS_REFRESH_MS = 5 * MINUTE_MS
15// A session that starts with an expired login fails its first ask; /login would otherwise wait out the full interval.
16const CREDITS_RETRY_MS = 30 * SECOND_MS
17// The wait doubles with each failure in a row, so a lasting 429 or outage is not asked every 30 seconds.
18const creditsWait = (prev: CreditsReading) =>
19  prev.requestId !== null
20    ? CREDITS_ABANDON_MS
21    : prev.failures === 0
22      ? CREDITS_REFRESH_MS
23      : Math.min(CREDITS_RETRY_MS * 2 ** (prev.failures - 1), CREDITS_REFRESH_MS)
24// $.http.fetch has no timeout and cannot be cancelled, so a request open this long is given up:
25// its late answer is dropped and the next refresh asks again.
26const CREDITS_ABANDON_MS = 30 * MINUTE_MS
27// Past this the figure is not a percentage the bar can draw.
28const CREDITS_MAX_PERCENT = 10_000
29
30const FULL = String.fromCharCode(0x2588)
31const EMPTY = String.fromCharCode(0x2591)
32const SWATCH = String.fromCharCode(0x25a0)
33
34const LIMIT_NAMES: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'Spend', usage_credits: 'Credits' }
35
36const cache = atom({ plugin: 'session-band', key: 'cache' } as const, null)
37const snapshot = atom({ plugin: 'session-band', key: 'snapshot' } as const, null)
38const credits = atom({ plugin: 'session-band', key: 'credits' } as const, null)
39
40const tokens = (n: number) => {
41  if (n >= 1_000_000) {
42    return `${Number((n / 1_000_000).toFixed(1))}M`
43  }
44  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
45}
46
47const duration = (ms: number) => {
48  const m = Math.max(0, Math.floor(ms / MINUTE_MS))
49  if (m < 60) {
50    return `${m}m`
51  }
52  const h = Math.floor(m / 60)
53  return h < 24 ? `${h}h ${m % 60}m` : `${Math.floor(h / 24)}d ${h % 24}h`
54}
55
56const clock = (ms: number) => {
57  const s = Math.ceil(ms / SECOND_MS)
58  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
59}
60
61const bar = (percent: number) => {
62  const filled = Math.min(LIMIT_BAR_CELLS, Math.round((percent / 100) * LIMIT_BAR_CELLS))
63  return FULL.repeat(filled) + EMPTY.repeat(LIMIT_BAR_CELLS - filled)
64}
65
66// Largest-remainder split, so the segments always add up to the filled cells.
67const segments = (filled: number, weights: number[]) => {
68  const total = weights.reduce((a, b) => a + b, 0)
69  if (total === 0) {
70    return weights.map(() => 0)
71  }
72  const exact = weights.map(w => (w / total) * filled)
73  const cells = exact.map(Math.floor)
74  const order = exact.map((x, i) => ({ i, rest: x - Math.floor(x) })).sort((a, b) => b.rest - a.rest)
75  const missing = filled - cells.reduce((a, b) => a + b, 0)
76  for (let k = 0; k < missing; k++) {
77    cells[order[k]!.i]! += 1
78  }
79  return cells
80}
81
82const severity = (percent: number) => (percent >= 90 ? 'error' : percent >= 75 ? 'warning' : undefined)
83
84const parseCredits = (text: string): number | null => {
85  try {
86    const extra: unknown = (JSON.parse(text) as { extra_usage?: unknown }).extra_usage
87    if (typeof extra !== 'object' || extra === null) {
88      return null
89    }
90    const { is_enabled, utilization, used_credits, monthly_limit } = extra as Record<string, unknown>
91    if (is_enabled !== true) {
92      return null
93    }
94    // An allowance with nothing spent yet answers utilization null; the two amounts share a unit.
95    const percent =
96      typeof utilization === 'number'
97        ? utilization
98        : typeof used_credits === 'number' && typeof monthly_limit === 'number' && monthly_limit > 0
99          ? (used_credits / monthly_limit) * 100
100          : NaN
101    if (!(percent >= 0 && percent <= CREDITS_MAX_PERCENT)) {
102      return null
103    }
104    return Math.round(percent * 10) / 10
105  } catch {
106    return null
107  }
108}
109
110// Credit-billed plans (Enterprise seats, for one) report no rate-limit window on responses;
111// their monthly credit spend is only on the usage endpoint.
112// failed marks a missing login or a refused request, which is asked again sooner than an answer.
113const fetchCredits = async ($: EngineInterface): Promise<{ percentUsed: number | null; failed: boolean }> => {
114  let text: string
115  try {
116    const auth = await $.session.authorize()
117    // Bedrock, Vertex and gateways hold no first-party credential, nor does a session whose login
118    // expired until /login; authorize is local, so asking again soon costs no request.
119    if (auth === null) {
120      return { percentUsed: null, failed: true }
121    }
122    // The endpoint takes only a claude.ai login: an API key would be refused on every poll.
123    if (auth.kind !== 'bearer') {
124      return { percentUsed: null, failed: false }
125    }
126    const r = await $.http.fetch(CREDITS_URL, { auth: auth.handle, headers: { 'anthropic-beta': 'oauth-2025-04-20' } })
127    if (!r.ok) {
128      return { percentUsed: null, failed: true }
129    }
130    text = r.text
131  } catch {
132    return { percentUsed: null, failed: true }
133  }
134  return { percentUsed: parseCredits(text), failed: false }
135}
136
137const pollCredits = async ($: EngineInterface) => {
138  const now = await $.clock.now()
139  const requestId = Math.random().toString(36).slice(2)
140  let started = false
141  // update writes with ifVersion and retries, so of two refreshes racing here only one starts a request.
142  await update($, credits, prev => {
143    // refresh runs after every response; the endpoint is asked at most once per interval.
144    started = prev === null || now - prev.requestedAt >= creditsWait(prev)
145    // pendingSince keeps the first unanswered request's time when a given-up or failed one is asked again.
146    return started
147      ? { percentUsed: prev?.percentUsed ?? null, requestedAt: now, requestId, pendingSince: prev?.pendingSince ?? now, failures: prev?.failures ?? 0 }
148      : prev
149  })
150  if (!started) {
151    return
152  }
153  // The request runs on a timer of its own so a slow endpoint never holds up the response's hooks.
154  $.clock.after(0, async () => {
155    const { percentUsed, failed } = await fetchCredits($)
156    const answeredAt = await $.clock.now()
157    // A late answer to a request given up on, or one after /clear, no longer matches and is dropped.
158    // A failure keeps the last figure, so a passing error does not blink the row; the 30-minute
159    // pendingSince rule hides it once failures last.
160    await update($, credits, prev =>
161      prev === null || prev.requestId !== requestId
162        ? prev
163        : failed
164          ? // The wait runs from the failure, so a request that is slow to fail is not asked again at once.
165            { ...prev, requestedAt: answeredAt, requestId: null, failures: prev.failures + 1 }
166          : { ...prev, percentUsed, requestId: null, pendingSince: null, failures: 0 },
167    )
168  })
169}
170
171const refresh = async ($: EngineInterface, showCredits: boolean) => {
172  // 'full' sends a token-count request per MCP tool and memory file on every turn; 'summary' is local.
173  const usage = await $.session.usage({ breakdown: 'summary' })
174  const used = (usage.context.breakdown?.categories ?? [])
175    .filter(c => c.kind === 'used')
176    .sort((a, b) => b.tokens - a.tokens)
177  const limits: RateLimit[] = usage.rateLimits.map(l => {
178    const resetsAt = l.resetsAt === undefined ? NaN : Date.parse(l.resetsAt)
179    return { kind: l.kind, percentUsed: l.percentUsed, resetsAt: Number.isNaN(resetsAt) ? null : resetsAt }
180  })
181  if (showCredits) {
182    await pollCredits($)
183  }
184  await update($, snapshot, () => ({
185    startedAt: usage.startedAt,
186    percent: usage.context.percent ?? null,
187    tokens: usage.context.tokens ?? null,
188    window: usage.context.window,
189    top: used.slice(0, TOP_CATEGORIES).map(({ name, tokens, color }) => ({ name, tokens, color })),
190    otherTokens: used.slice(TOP_CATEGORIES).reduce((sum, c) => sum + c.tokens, 0),
191    limits,
192    costUsd: usage.cost?.usd ?? null,
193  }))
194}
195
196export const register: Register = (on, options) => {
197  // The plugin cannot observe the TTL the engine requested, so the person states it.
198  const ttlMs = TTL_MS[String(options.cacheTtl)] ?? TTL_MS['5m']!
199  const showCost = options.showCost !== false
200  const showCredits = options.showCredits === true
201
202  on('session.start', async ($, e, next) => {
203    const result = await next(e)
204    await refresh($, showCredits)
205    $.clock.every(SECOND_MS, () => $.ui.invalidate('ui.render'))
206    return result
207  })
208
209  on('session.measure', async ($, e, next) => {
210    const result = await next(e)
211    await refresh($, showCredits)
212    return result
213  })
214
215  on('classic.SessionStart', async ($, e, next) => {
216    if (e.seconds_since_last_response !== undefined) {
217      const now = await $.clock.now()
218      // The payload times the end of the last response, not the start of its request, so the
219      // countdown can run long by that response's generation time until a live one replaces it.
220      const respondedAt = now - e.seconds_since_last_response * SECOND_MS
221      // The engine judges expiry against the TTL it requested, which the plugin is only told.
222      const sentAt = e.prompt_cache_likely_expired === true ? Math.min(respondedAt, now - ttlMs) : respondedAt
223      await update($, cache, () => ({ sentAt, estimated: true }))
224    }
225    const result = await next(e)
226    // A resumed or forked conversation starts with empty session state and no session.start.
227    await refresh($, showCredits)
228    return result
229  })
230
231  on('turn.step', async function* ($, e, next) {
232    // The cache lifetime runs from the start of the request, so generation time counts against it.
233    const sentAt = await $.clock.now()
234    const result = yield* next(e)
235    // Subagents cache their own prefixes; only the main thread's matters for the next prompt.
236    if (e.agentId === undefined && result.usage !== null) {
237      const cached = result.usage.cache_read_input_tokens + result.usage.cache_creation_input_tokens > 0
238      await update($, cache, () => (cached ? { sentAt, estimated: false } : null))
239    }
240    return result
241  })
242
243  // Compaction replaces the cached prefix. classic SessionStart(compact) also fires for a
244  // subagent's compaction with no agent fields (anthropics/claude-code#91910); this event has agentId.
245  on('session.compact', async ($, e, next) => {
246    const result = await next(e)
247    if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
248      await update($, cache, () => null)
249    }
250    return result
251  })
252
253  // Each model keeps its own prompt cache, so the first request after a switch writes a new one.
254  on('classic.PostModelSwitch', async ($, e, next) => {
255    if (e.agent_id !== undefined) {
256      return next(e)
257    }
258    await update($, cache, () => null)
259    const result = await next(e)
260    // The new model may have another context window; session.measure waits for its first response.
261    await refresh($, showCredits)
262    return result
263  })
264
265  on('session.end', async ($, e, next) => {
266    if (e.reason === 'clear') {
267      await update($, cache, () => null)
268      await update($, snapshot, () => null)
269      await update($, credits, () => null)
270    }
271    return next(e)
272  })
273
274  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
275    const below = await next(e)
276    if (e.props.hasSurvey) {
277      return below
278    }
279    const c = await read($, cache)
280    const s = await read($, snapshot)
281    const cr = showCredits ? await read($, credits) : null
282    if (c === null && s === null) {
283      return below
284    }
285    const now = await $.clock.now()
286    const { Box, Text } = $.ui.resolve(e)
287    const label = (text: string) => (
288      <Box width={LABEL_WIDTH} flexShrink={0}>
289        <Text dimColor>{text}</Text>
290      </Box>
291    )
292    const rows: RenderElement[] = []
293
294    if (c !== null) {
295      const left = c.sentAt + ttlMs - now
296      rows.push(
297        <Box flexDirection="row" key="cache">
298          {label('Cache')}
299          <Text color={left > 0 ? undefined : 'warning'}>{left > 0 ? clock(left) : 'expired'}</Text>
300          {left > 0 && c.estimated ? <Text dimColor>{'  estimated'}</Text> : null}
301        </Box>,
302      )
303    }
304
305    if (s !== null) {
306      rows.push(
307        <Box flexDirection="row" key="session">
308          {label('Session')}
309          <Text>
310            {showCost && s.costUsd !== null ? `$${s.costUsd.toFixed(2)}   ` : ''}
311            <Text dimColor>{`${duration(now - s.startedAt)} elapsed`}</Text>
312          </Text>
313        </Box>,
314      )
315
316      const live = s.limits.filter(l => l.resetsAt === null || l.resetsAt > now)
317      // Once requests have gone unanswered or failed past the give-up time, the figure is hidden rather than shown stale.
318      const crHung = cr !== null && cr.pendingSince !== null && now - cr.pendingSince >= CREDITS_ABANDON_MS
319      if (cr !== null && cr.percentUsed !== null && !crHung) {
320        // The endpoint carries no reset time, and billing cycles differ by organization.
321        live.push({ kind: 'usage_credits', percentUsed: cr.percentUsed, resetsAt: null })
322      }
323      if (live.length > 0) {
324        rows.push(
325          <Box flexDirection="row" key="limits">
326            {label('Limits')}
327            <Box flexDirection="row" flexWrap="wrap" columnGap={4}>
328              {live.map(l => (
329                <Text>
330                  {`${LIMIT_NAMES[l.kind] ?? l.kind} `}
331                  <Text color={severity(l.percentUsed)} dimColor={severity(l.percentUsed) === undefined}>{bar(l.percentUsed)}</Text>
332                  {` ${l.percentUsed}%`}
333                  <Text dimColor>{l.resetsAt === null ? '' : `  resets in ${duration(l.resetsAt - now)}`}</Text>
334                </Text>
335              ))}
336            </Box>
337          </Box>,
338        )
339      }
340
341      const filled = s.percent === null ? 0 : Math.min(CONTEXT_BAR_CELLS, Math.round((s.percent / 100) * CONTEXT_BAR_CELLS))
342      const cells = segments(filled, [...s.top.map(c => c.tokens), s.otherTokens])
343      const unattributed = filled - cells.slice(0, s.top.length).reduce((a, b) => a + b, 0)
344      rows.push(
345        <Box flexDirection="row" key="context">
346          {label('Context')}
347          <Text wrap="truncate-end">
348            {s.top.map((c, i) => <Text color={c.color}>{FULL.repeat(cells[i] ?? 0)}</Text>)}
349            <Text dimColor>{FULL.repeat(unattributed) + EMPTY.repeat(CONTEXT_BAR_CELLS - filled)}</Text>
350            {s.percent === null || s.tokens === null ? ' --' : ` ${s.percent}%  ${tokens(s.tokens)} / ${tokens(s.window)}`}
351            {s.top.map(c => (
352              <Text>
353                {'   '}
354                <Text color={c.color}>{SWATCH}</Text>
355                <Text dimColor>{` ${c.name} ${tokens(c.tokens)}`}</Text>
356              </Text>
357            ))}
358          </Text>
359        </Box>,
360      )
361    }
362
363    // AbovePrompt is one band shared by every plugin; wrapping keeps their rows instead of replacing them.
364    return below ? <Box flexDirection="column">{below}{rows}</Box> : <Box flexDirection="column">{rows}</Box>
365  })
366}
367
types/index.d.ts 42 lines
1export type EpochMs = number
2
3export type RateLimit = {
4  kind: string
5  percentUsed: number
6  resetsAt: EpochMs | null
7}
8
9export type CacheCountdown = {
10  sentAt: EpochMs
11  estimated: boolean
12}
13
14export type CreditsReading = {
15  percentUsed: number | null
16  requestedAt: EpochMs
17  requestId: string | null
18  pendingSince: EpochMs | null
19  failures: number
20}
21
22export type SessionSnapshot = {
23  startedAt: EpochMs
24  percent: number | null
25  tokens: number | null
26  window: number
27  top: { name: string; tokens: number; color: string }[]
28  otherTokens: number
29  limits: RateLimit[]
30  costUsd: number | null
31}
32
33declare module 'claude-code' {
34  interface PluginState {
35    'session-band': {
36      cache: CacheCountdown | null
37      snapshot: SessionSnapshot | null
38      credits: CreditsReading | null
39    }
40  }
41}
42