SLOPSHOPPER

session-band

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

newbandcommandtoastprocesstimer
★ 201v0.2.0MITupdated 2026-10-07JohnnyVizz/claude-kit/plugins/session-band
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 › /session_data ⎿ session-band: Session band hidden. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

session-band

A Claude Code mod: one band above the prompt with the session at a glance.

  • Band: turn number, output speed of the last response (tok/s), cost of the last turn and of the session, a countdown of the 1-hour prompt cache since the last answer, and context fill. The band gets an amber frame 20 minutes before the cache expires and a red one at 10. The row drops details as the window narrows instead of wrapping; × hides it for the rest of the session.
  • Cache warning: at 10 and 5 minutes left, a system notification with Claude's icon, titled <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 notifier (macOS)

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.

Commands

  • /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.

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