Band above the prompt: turn number, output speed, turn and session cost, prompt cache countdown and context fill; notifies before the cache expires.

A Claude Code mod: one band above the prompt with the session at a glance.
× hides it for the rest of the session.<project> — <session title>, saying how many minutes the cache has left. In the desktop app, clicking it opens the session. Nothing fires while a turn runs.The warning is posted by a small helper app, built once on your Mac (needs Xcode's command line tools; the icon is taken from your installed Claude.app):
plugins/session-band/notifier/build.sh
It builds notifier/Session Band.app next to the script; on its first notification macOS asks whether to allow notifications from "Session Band". Build it in the folder the mod is loaded from, or copy the built app there. Without the helper (or on another OS) the mod falls back to Claude Code's own notification, which also reaches your phone over Remote Control but has no title or click-through; when you are already at the session that one is skipped and a toast says it instead.
/session_data: show or hide the band./session_data demo <minutes>: pretend the cache has that many minutes left, to preview the colors and the warning; the next answer resets it.Install instructions are in the repository README.
hooks/register.tsx 372 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Cache, Stats } from '../types'
5
6// Responses shorter than this are mostly tool-call stubs: their timing is noise.
7const MIN_TOKENS = 20
8// A response that arrived in one burst cannot be timed; such readings are dropped.
9const MIN_SECONDS = 0.5
10const MAX_SPEED = 1000
11
12const CACHE_TTL_MS = 60 * 60 * 1000
13const CACHE_WARN_S = 20 * 60
14const CACHE_ALERT_S = 10 * 60
15// Minutes left at which the cache expiry is announced, once each per answer.
16const CACHE_NOTIFY_MINUTES = [10, 5] as const
17
18const SPEED = '#8b8cf8'
19const MONEY = '#4caf6e'
20const MUTED = '#8a8f8c'
21const CONTEXT = '#5fa8d3'
22const CACHE = '#4fb3a9'
23const WARN = '#e2a94e'
24const ALERT = '#e0846b'
25
26const stats = atom({ plugin: 'session-band', key: 'stats' } as const, {
27 turn: 0,
28 speed: null,
29 turnCost: null,
30 total: 0,
31 context: 0,
32 costAtStart: 0,
33} as Stats)
34
35const cache = atom({ plugin: 'session-band', key: 'cache' } as const, {
36 expiresAt: null,
37 secondsLeft: null,
38} as Cache)
39
40// Closed with ×: stays hidden for the rest of the session.
41const isHidden = atom({ plugin: 'session-band', key: 'isHidden' } as const, false)
42
43// A turn is running: the cache stays warm, so no countdown warnings.
44const isBusy = atom({ plugin: 'session-band', key: 'isBusy' } as const, false)
45
46// The smallest CACHE_NOTIFY_MINUTES already announced since the last answer; 0 none.
47const warned = atom({ plugin: 'session-band', key: 'warned' } as const, 0)
48
49// The Cache mod's own deadline: seeds ours after a reload missed the last turn.complete.
50const cacheModExpiresAt = atom({ plugin: 'Cache', key: 'expiresAt' } as const, null)
51
52const ring = (color: string, glyph: string) =>
53 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
54 <circle cx="12" cy="12" r="10" fill="none" stroke="${color}" stroke-width="2"/>
55 ${glyph}</svg>`
56
57const SPEED_ICON = ring(
58 SPEED,
59 `<path d="M6.5 15a6 6 0 0 1 11 0" fill="none" stroke="${SPEED}" stroke-width="1.8" stroke-linecap="round"/>
60 <path d="M12 15l3-4" stroke="${SPEED}" stroke-width="1.8" stroke-linecap="round"/>`,
61)
62
63const MONEY_ICON = ring(
64 MONEY,
65 `<text x="12" y="16.5" text-anchor="middle" font-family="-apple-system,sans-serif" font-size="13" font-weight="600" fill="${MONEY}">$</text>`,
66)
67
68// The ring itself fills with the context, around a page of lines.
69const contextIcon = (percent: number) => {
70 const c = 2 * Math.PI * 10
71 const filled = (Math.min(100, percent) / 100) * c
72 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
73 <circle cx="12" cy="12" r="10" fill="none" stroke="${MUTED}" stroke-opacity="0.35" stroke-width="2"/>
74 <circle cx="12" cy="12" r="10" fill="none" stroke="${CONTEXT}" stroke-width="2" stroke-linecap="round"
75 stroke-dasharray="${filled.toFixed(1)} ${c.toFixed(1)}" transform="rotate(-90 12 12)"/>
76 <g stroke="${CONTEXT}" stroke-width="1.6" stroke-linecap="round">
77 <path d="M8.5 9h7M8.5 12h7M8.5 15h4.5"/>
78 </g></svg>`
79}
80
81// An hourglass: the prompt cache running out.
82const cacheIcon = (color: string) =>
83 ring(
84 color,
85 `<g fill="none" stroke="${color}" stroke-width="1.6" stroke-linejoin="round" stroke-linecap="round">
86 <path d="M8.5 6.5h7M8.5 17.5h7"/>
87 <path d="M9.5 6.5c0 3 5 3.5 5 5.5s-5 2.5-5 5.5M14.5 6.5c0 3-5 3.5-5 5.5s5 2.5 5 5.5"/>
88 </g>`,
89 )
90
91const pad = (n: number) => String(n).padStart(2, '0')
92
93const contextBar = (percent: number) => {
94 const color = percent >= 85 ? '#e0846b' : percent >= 65 ? '#e2a94e' : MONEY
95 const w = Math.round((Math.min(100, percent) / 100) * 80)
96 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 6" width="80" height="6">
97 <rect width="80" height="6" rx="3" fill="${MUTED}" fill-opacity="0.3"/>
98 <rect width="${w}" height="6" rx="3" fill="${color}"/></svg>`
99}
100
101const usd = (n: number) => `$${n.toFixed(2)}`
102
103// Density by the band's width in cells, so the row shrinks instead of wrapping:
104// 0 full; 1 no context bar; 2 compact turn/speed; 3 turn cost only, smaller icons;
105// 4 no speed. Thresholds are minimum bodyColumns for each tier.
106const TIER_MIN = [78, 64, 52, 40] as const
107const GAPS = [3, 2, 2, 1, 1] as const
108const tierFor = (columns: number) => {
109 const i = TIER_MIN.findIndex(min => columns >= min)
110 return i === -1 ? TIER_MIN.length : i
111}
112
113// Last measured width, for `/session_data width` while tuning TIER_MIN; debug only.
114let lastColumns: number | null = null
115
116const refresh = async ($: EngineInterface) => {
117 const { cost, context } = await $.session.usage()
118 const total = cost?.usd ?? 0
119 await update($, stats, s => ({
120 ...s,
121 total,
122 turnCost: total - s.costAtStart,
123 context: context.percent ?? s.context,
124 }))
125}
126
127// Built by notifier/build.sh (macOS): posts the warning under Claude's icon.
128const NOTIFIER = 'notifier/Session Band.app/Contents/MacOS/session-band-notifier'
129// The desktop app's id for a local Code session, as its claude:// links take it.
130const HOST_SESSION = /^local_[A-Za-z0-9-]{1,64}$/
131
132// This session as the desktop app knows it: its id and the title in its sidebar.
133// Null outside the desktop app; the title null when its record cannot be read.
134const desktopSession = async ($: EngineInterface) => {
135 const id = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
136 if (id === undefined || !HOST_SESSION.test(id)) return null
137
138 const home = await $.env.get('HOME')
139 const account = await $.env.get('CLAUDE_CODE_ACCOUNT_UUID')
140 const org = await $.env.get('CLAUDE_CODE_ORGANIZATION_UUID')
141 const record = `${home}/Library/Application Support/Claude/claude-code-sessions/${account}/${org}/${id}.json`
142 const title = await $.fs.read(record).then(
143 text => (JSON.parse(text) as { title?: unknown }).title,
144 () => undefined,
145 )
146
147 return { id, title: typeof title === 'string' && title.trim() !== '' ? title.trim() : null }
148}
149
150// The notifier's system notification, titled with the project and the session, which
151// opens this session when clicked. Without the notifier (not built, not macOS, or
152// notifications not allowed), Claude Code's own notification, which also reaches
153// the phone over Remote Control; it is skipped while the person is at this session,
154// and then a toast says it instead.
155const notifyCacheExpiry = async ($: EngineInterface, minutes: number) => {
156 const project = (await $.session.cwd()).split('/').filter(Boolean).pop() ?? 'session'
157 const message = `Prompt cache expires in ${minutes} min`
158
159 const notifier = `${$.plugin.root}/${NOTIFIER}`
160 if (await $.fs.exists(notifier)) {
161 const session = await desktopSession($)
162 const argv = [
163 notifier,
164 '-title',
165 session?.title ? `${project} — ${session.title}` : project,
166 '-message',
167 message,
168 '-group',
169 `session-band-${session?.id ?? (await $.session.id())}`,
170 ...(session ? ['-open', `claude://code/continue?session=${session.id}`] : []),
171 ]
172 // The first run waits while macOS asks whether to allow its notifications.
173 const ran = await $.process.run(argv, { timeoutMs: 60_000 }).catch(() => null)
174 if (ran?.exitCode === 0) return
175 }
176
177 const text = `${project}: ${message}`
178 const sent = await $.tool
179 .call({ tool: 'PushNotification', message: text, status: 'proactive' })
180 .catch(() => null)
181 const isDelivered =
182 sent !== null &&
183 sent.deny === undefined &&
184 sent.isError !== true &&
185 (sent.result.pushSent === true || sent.result.localSent === true)
186
187 if (!isDelivered) $.ui.toast(`⏳ ${text}`, { timeoutMs: 30000 })
188}
189
190export const register: Register = on => {
191 on('session.start', async ($, e, next) => {
192 const result = await next(e)
193
194 await $.command.register({
195 name: 'session_data',
196 description: 'Show or hide the session band; `demo <minutes>` previews the cache warning',
197 })
198
199 $.clock.every(1000, async () => {
200 const expiresAt = (await read($, cache)).expiresAt ?? (await read($, cacheModExpiresAt))
201 if (expiresAt === null) return
202
203 const left = Math.max(0, Math.floor((expiresAt - (await $.clock.now())) / 1000))
204 if (left !== (await read($, cache)).secondsLeft) {
205 await update($, cache, c => ({ expiresAt: c.expiresAt ?? expiresAt, secondsLeft: left }))
206 }
207
208 const due = CACHE_NOTIFY_MINUTES.filter(m => left > 0 && left <= m * 60)
209 if (due.length === 0 || (await read($, isBusy))) return
210
211 const minutes = Math.min(...due)
212 const last = await read($, warned)
213 if (last !== 0 && last <= minutes) return
214
215 await update($, warned, () => minutes)
216 await notifyCacheExpiry($, Math.ceil(left / 60))
217 })
218
219 return result
220 })
221
222 on('command.run', { command: 'session_data' }, async ($, e) => {
223 // `/session_data demo <minutes>` fakes the cache deadline to preview the warnings;
224 // the next answer resets it.
225 const demo = e.args.trim().match(/^demo\s+(\d+(?:\.\d+)?)$/)
226 if (demo) {
227 const seconds = Math.round(Number(demo[1]) * 60)
228 const now = await $.clock.now()
229 await update($, cache, () => ({ expiresAt: now + seconds * 1000, secondsLeft: seconds }))
230 await update($, warned, () => 0)
231 await update($, isHidden, () => false)
232
233 return { text: `Cache demo: ${demo[1]} min left until the next answer resets it.` }
234 }
235
236 if (e.args.trim() === 'width') {
237 const columns = lastColumns
238 return { text: columns === null ? 'Band not drawn yet.' : `Band: ${columns} columns, tier ${tierFor(columns)}.` }
239 }
240
241 const hidden = !(await read($, isHidden))
242 await update($, isHidden, () => hidden)
243
244 return { text: hidden ? 'Session band hidden.' : 'Session band shown.' }
245 })
246
247 on('turn.start', async ($, e, next) => {
248 const total = (await $.session.usage()).cost?.usd ?? 0
249 await update($, stats, s => ({ ...s, turn: s.turn + 1, costAtStart: total, total }))
250 await update($, isBusy, () => true)
251
252 return next(e)
253 })
254
255 on('turn.step', async function* ($, e, next) {
256 if (e.agentId !== undefined) return yield* next(e)
257
258 // Measured from the response's first item (its envelope, message_start), so
259 // time-to-first-token is excluded but thinking that streams hidden or in one
260 // late burst is still timed: output_tokens counts it.
261 let startedAt: number | null = null
262
263 for await (const chunk of next(e)) {
264 if (startedAt === null) {
265 startedAt = await $.clock.now()
266 }
267
268 if (chunk.kind === 'stop' && chunk.usage !== null) {
269 const tokens = chunk.usage.output_tokens
270 const seconds = ((await $.clock.now()) - startedAt) / 1000
271 const speed = tokens / seconds
272 if (tokens >= MIN_TOKENS && seconds >= MIN_SECONDS && speed <= MAX_SPEED) {
273 await update($, stats, s => ({ ...s, speed: Math.round(speed) }))
274 }
275 }
276
277 yield chunk
278 }
279
280 await refresh($)
281 })
282
283 on('turn.complete', async ($, e, next) => {
284 const result = await next(e)
285 await refresh($)
286
287 if (e.agentId === undefined) {
288 const now = await $.clock.now()
289 await update($, cache, () => ({ expiresAt: now + CACHE_TTL_MS, secondsLeft: CACHE_TTL_MS / 1000 }))
290 await update($, isBusy, () => false)
291 await update($, warned, () => 0)
292 }
293
294 return result
295 })
296
297 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
298 const s = await read($, stats)
299 if (e.props.hasSurvey || s.turn === 0 || (await read($, isHidden))) return next(e)
300
301 const ui = $.ui.resolve(e)
302 const { Box, Button, Text } = ui
303 const Svg = 'Svg' in ui ? ui.Svg : null
304 const speed = s.speed === null ? '—' : `${s.speed} tok/s`
305 const turnCost = s.turnCost === null ? '—' : usd(s.turnCost)
306
307 const { secondsLeft } = await read($, cache)
308 const level =
309 e.props.isWorking || secondsLeft === null
310 ? null
311 : secondsLeft <= CACHE_ALERT_S
312 ? ALERT
313 : secondsLeft <= CACHE_WARN_S
314 ? WARN
315 : null
316 const cacheColor = level ?? CACHE
317 const cacheText =
318 e.props.isWorking ? '…'
319 : secondsLeft === null ? '—'
320 : secondsLeft === 0 ? 'expired'
321 : `${pad(Math.floor(secondsLeft / 60))}:${pad(secondsLeft % 60)}`
322
323 lastColumns = e.props.bodyColumns
324 const tier = tierFor(e.props.bodyColumns)
325 const icon = tier >= 3 ? 18 : 22
326
327 return (
328 <Box
329 flexDirection="row"
330 alignItems="center"
331 columnGap={GAPS[tier]}
332 flexWrap="nowrap"
333 overflow="hidden"
334 paddingX={1}
335 {...(level ? { borderStyle: 'round', borderColor: level } : {})}
336 >
337 {tier <= 2 ? (
338 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
339 {tier <= 1 ? <Text dimColor>Turn</Text> : <Text dimColor>#</Text>}
340 <Text bold>{String(s.turn)}</Text>
341 </Box>
342 ) : null}
343 {tier <= 3 ? (
344 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
345 {Svg ? <Svg source={SPEED_ICON} alt="speed" width={icon} height={icon} /> : <Text color={SPEED}>⚡</Text>}
346 <Text bold wrap="truncate-end">{tier <= 1 || s.speed === null ? speed : `${s.speed}/s`}</Text>
347 </Box>
348 ) : null}
349 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
350 {Svg ? <Svg source={MONEY_ICON} alt="cost" width={icon} height={icon} /> : <Text color={MONEY}>$</Text>}
351 <Text bold color={MONEY}>{turnCost}</Text>
352 {tier <= 2 ? <Text dimColor>|</Text> : null}
353 {tier <= 2 ? <Text bold>{usd(s.total)}</Text> : null}
354 </Box>
355 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
356 {Svg ? <Svg source={cacheIcon(cacheColor)} alt="cache" width={icon} height={icon} /> : <Text color={cacheColor}>⧗</Text>}
357 <Text bold color={level ?? undefined}>{cacheText}</Text>
358 </Box>
359 <Box flexGrow={1} flexShrink={1} />
360 <Box flexDirection="row" alignItems="center" columnGap={1} flexShrink={0}>
361 {Svg ? <Svg source={contextIcon(s.context)} alt="context" width={icon} height={icon} /> : null}
362 {Svg && tier === 0 ? (
363 <Svg source={contextBar(s.context)} alt={`context ${s.context}%`} width={80} height={6} />
364 ) : null}
365 <Text bold>{`${s.context}%`}</Text>
366 </Box>
367 <Button key="hide" label="×" role="dismiss" plain dimColor onPress={() => update($, isHidden, () => true)} />
368 </Box>
369 )
370 })
371}
372types/index.d.ts 20 lines1export type Stats = {
2 turn: number
3 speed: number | null
4 turnCost: number | null
5 total: number
6 context: number
7 costAtStart: number
8}
9
10export type Cache = {
11 expiresAt: number | null
12 secondsLeft: number | null
13}
14
15declare module 'claude-code' {
16 interface PluginState {
17 'session-band': { stats: Stats; cache: Cache; isHidden: boolean; isBusy: boolean; warned: number }
18 }
19}
20