SLOPSHOPPER

usage-band-plus

Context fill, session cost and prompt-cache countdown in the prompt footer, a 5-minute cache warning with Compact or Let it expire, /usage-band to explain the…

bandspinnercommandtoastmodelprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · usage-band-plus
› fix the failing auth test and add an audit log call ╭──────────────────╮ │ usage-band-plus │ ⏺ Read(src/auth.ts) │ Writing handoff… │ ⎿ Read 6 lines ╰──────────────────╯ ⏺ Update(src/auth.ts) ╭────────────────────────────────────────────╮ ⎿ Added 2 lines, removed 1 line │ usage-band-plus │ ⏺ Bash(bun test) │ New session started. The handoff is in the │ ⎿ 3 pass, 1 fail │ prompt box; press Enter to send. │ ╰────────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /handoff ⎿ usage-band-plus: Writing a handoff note… Context is 49% full. A handoff note carries this work into a fresh session. [ Writing… ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts 49% · ~$0.42 · auto-accept edits

Draws

Band
Context is 49% full. A handoff note carries this work into a fresh session. [ Writing… ]
README

usage-band-plus

Context, prompt cache and cost for Claude Code.

<img width="1648" height="272" alt="Screeshot-2026-10-16 at 19 05 22@2x" src="https://github.com/user-attachments/assets/40dbb58d-5741-429e-8f62-c4d5b3a75795" />

Three numbers in the prompt footer, beside the permission mode and the model: 34% · ~$13.52 · 59m.

Number/usage-band explains
34% contextTokens used of the auto-compact window (as the app's Context panel counts), and when Handoff is offered. Amber from 50%, red from 80%.
~$13.52 costSession cost at API prices and the last turn's cost. ~ means an estimate on a subscription.
59m cacheWhen the warm cache expires (clock time), the TTL, and the last request's hit rate. ≈ means the TTL is assumed until the turn ends; cold means expired or a new model.

Type /usage-band for what each number means, with current values. The desktop footer draws text only (no Svg, no hover), so it cannot carry tooltips.

Five minutes before a warm cache expires (contexts of 20k tokens or more), you get a toast and a macOS notification, and a row above the prompt offers Compact or Let it expire. On desktop, $.ui.notify has no channel, so the notification goes through terminal-notifier when it is installed. A click on it opens the session that sent it. Without terminal-notifier, the notification goes through osascript, and macOS credits it to Script Editor, so a click opens Script Editor.

Handoff appears above the prompt when context passes 80%. /handoff works at any time: it writes a handoff note, runs /clear, and puts the note (and your draft) in the prompt box.

Install

At the prompt of a terminal Claude Code session:

/plugin install usage-band-plus --marketplace lucasproko/claude-code-mod-cache-cost-bar

Answer y to add the marketplace, then pick the user scope.

From a shell:

claude plugin install usage-band-plus --marketplace lucasproko/claude-code-mod-cache-cost-bar

In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with lucasproko/claude-code-mod-cache-cost-bar, then Add plugin.

On macOS, also install terminal-notifier, so a click on the cache notification opens its session:

brew install terminal-notifier

The first notification asks to allow notifications from terminal-notifier. Set its alert style to Banners or Alerts in System Settings > Notifications.

Start a new session afterwards. Settings are in the config menu under usage-band-plus.

Update

claude plugin marketplace update lucasproko-plugins
claude plugin update usage-band-plus@lucasproko-plugins

What changed from usage-band

  • Cache hit rate and timing come from the engine's turn.step usage, not a shell read of the transcript. Subagent requests are ignored.
  • Usage is pushed by session.measure. A 30s timer moves the reset countdowns and picks up /model switches.
  • "cold, new model" compares canonical model ids, so a [1m] session no longer shows it by mistake.
  • Timers are cancelled with Timer.cancel(). On this build's API, $.clock.every returns a Timer object; the original calls it as a function, which would throw on a second session.start.
  • Handoff uses $.model.fork by default (full cached transcript, the session's model) and falls back to Haiku on an API error. The haiku setting is cheaper and now includes tool calls and file paths.
  • After /clear (typed or from Handoff), the cache segment and last-turn cost reset.
  • Handoff keeps your prompt draft and asks for confirmation.
  • The readout moved to the prompt footer as three numbers; /usage-band explains them.
  • Five minutes before a warm cache expires: a toast, a macOS notification, and Compact or Let it expire.
  • The Handoff button is highlighted, and a toast shows once, when context passes 80%.

Settings

All in the config menu (userConfig): placement (footer or band), context thresholds, whether cache and cost show, cache TTL (auto, 1h, 5m), the expiry warning in minutes, the handoff writer (fork or haiku), the context % that highlights Handoff, and handoff confirmation.

auto TTL reads the transcript once per turn with sh, so it is macOS and Linux only. On Windows, set 1h or 5m.

Develop

claude plugin validate .
claude plugin test .
Source 2 files
hooks/register.tsx 683 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, SessionUsage, Timer } from 'claude-code'
3
4import type { Cache, Usage } from '../types'
5
6const usageAtom = atom({ plugin: 'usage-band-plus', key: 'usage' } as const, null)
7const cacheAtom = atom({ plugin: 'usage-band-plus', key: 'cache' } as const, null)
8const nowAtom = atom({ plugin: 'usage-band-plus', key: 'now' } as const, 0)
9const lastTurnUsdAtom = atom({ plugin: 'usage-band-plus', key: 'lastTurnUsd' } as const, null)
10const handoffArmedUntilAtom = atom({ plugin: 'usage-band-plus', key: 'handoffArmedUntil' } as const, 0)
11const isHandoffBusyAtom = atom({ plugin: 'usage-band-plus', key: 'isHandoffBusy' } as const, false)
12const dismissedCacheAtAtom = atom({ plugin: 'usage-band-plus', key: 'dismissedCacheAt' } as const, null)
13const isCompactingAtom = atom({ plugin: 'usage-band-plus', key: 'isCompacting' } as const, false)
14
15type $ = EngineInterface
16
17const TTL_MS = { '5m': 5 * 60 * 1000, '1h': 60 * 60 * 1000 }
18const USAGE_REFRESH_MS = 30_000
19const SECONDS_COUNTDOWN_MS = 10 * 60 * 1000
20const HANDOFF_CONFIRM_MS = 5000
21// rebuilding a small context is cheap, so the expiry warning skips it
22const NUDGE_MIN_CONTEXT_TOKENS = 20_000
23type Settings = {
24  placement: 'footer' | 'band'
25  contextWarnPercent: number
26  contextCritPercent: number
27  showCache: boolean
28  showCost: boolean
29  cacheTtl: 'auto' | '1h' | '5m'
30  cacheNudgeMinutes: number
31  handoffMode: 'fork' | 'haiku'
32  handoffAtPercent: number
33  confirmHandoff: boolean
34}
35
36const readSettings = (options: PluginOptions): Settings => {
37  const num = (key: string, fallback: number) =>
38    typeof options[key] === 'number' ? (options[key] as number) : fallback
39  const bool = (key: string, fallback: boolean) =>
40    typeof options[key] === 'boolean' ? (options[key] as boolean) : fallback
41  return {
42    placement: options.placement === 'band' ? 'band' : 'footer',
43    contextWarnPercent: num('contextWarnPercent', 50),
44    contextCritPercent: num('contextCritPercent', 80),
45    showCache: bool('showCache', true),
46    showCost: bool('showCost', true),
47    cacheTtl: options.cacheTtl === '1h' || options.cacheTtl === '5m' ? options.cacheTtl : 'auto',
48    cacheNudgeMinutes: num('cacheNudgeMinutes', 5),
49    handoffMode: options.handoffMode === 'haiku' ? 'haiku' : 'fork',
50    handoffAtPercent: num('handoffAtPercent', 80),
51    confirmHandoff: bool('confirmHandoff', true),
52  }
53}
54
55// One id per cache: drops a [1m] suffix, a date and a provider prefix. An alias
56// such as "opus" gives null, because it cannot be compared with an API id.
57const cacheModelKey = (id: string) =>
58  id.toLowerCase().match(/claude-(?:opus|sonnet|haiku|fable)-\d+(?:-\d{1,2}(?!\d))?/)?.[0] ?? null
59
60type Tone = 'normal' | 'warning' | 'error' | 'muted'
61
62// theme keys, so the readout follows the app's light or dark theme
63const TONE_COLOR: Record<Tone, string> = {
64  normal: 'inactive',
65  warning: 'warning',
66  error: 'error',
67  muted: 'subtle',
68}
69
70const tok = (n: number) =>
71  n >= 1e6 ? `${+(n / 1e6).toFixed(1)}M` : n >= 1e3 ? `${+(n / 1e3).toFixed(1)}k` : `${Math.round(n)}`
72
73const dur = (sec: number) => {
74  const s = Math.max(0, Math.floor(sec))
75  const d = Math.floor(s / 86400)
76  const h = Math.floor((s % 86400) / 3600)
77  const m = Math.floor((s % 3600) / 60)
78  return d ? `${d}d ${h}h` : h ? `${h}h ${m}m` : m ? `${m}m` : `${s}s`
79}
80
81const clock = (sec: number) => {
82  const s = Math.max(0, Math.floor(sec))
83  // whole minutes up to the 1-hour TTL, so a fresh cache reads 60m, not 1h 0m
84  if (s * 1000 >= SECONDS_COUNTDOWN_MS) return s <= 3600 ? `${Math.floor(s / 60)}m` : dur(s)
85  return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
86}
87
88const usd = (amount: number) => `$${amount.toFixed(2)}`
89
90const toUsage = (u: Pick<SessionUsage, 'context' | 'rateLimits' | 'cost'>, model: string | null): Usage => {
91  const window = compactWindowTokens ?? u.context.window
92  const tokens = u.context.tokens ?? null
93  return {
94    model,
95    ctxPercent: tokens !== null && window ? Math.min(100, (tokens / window) * 100) : (u.context.percent ?? null),
96    ctxTokens: tokens,
97    ctxWindow: window,
98    modelWindow: u.context.window,
99    usd: u.cost?.usd ?? null,
100    limits: u.rateLimits.map(l => ({ ...l })),
101  }
102}
103
104async function markNow($: $) {
105  const now = await $.clock.now()
106  await update($, nowAtom, () => now)
107}
108
109async function readModel($: $) {
110  try {
111    return await $.session.model()
112  } catch {
113    return null
114  }
115}
116
117// The plugin API does not report the cache TTL, but each API response in the
118// transcript does. A pure cache hit writes nothing, so the latest response that
119// wrote decides. POSIX only: on Windows this rejects and the default stands.
120async function readTtlFromTranscript($: $): Promise<number | null> {
121  const id = await $.session.id()
122  if (!/^[0-9a-f-]{36}$/.test(id)) return null
123  const r = await $.process.run([
124    'sh',
125    '-c',
126    `tail -n 400 "\${CLAUDE_CONFIG_DIR:-$HOME/.claude}"/projects/*/${id}.jsonl 2>/dev/null | grep '"cache_creation"' | tail -n 30`,
127  ])
128  for (const line of r.stdout.split('\n').reverse()) {
129    let entry: any
130    try {
131      entry = JSON.parse(line)
132    } catch {
133      continue
134    }
135    const written = entry?.message?.usage?.cache_creation
136    if (entry.type !== 'assistant' || entry.isSidechain || !written) continue
137    if (written.ephemeral_1h_input_tokens > 0) return TTL_MS['1h']
138    if (written.ephemeral_5m_input_tokens > 0) return TTL_MS['5m']
139  }
140  return null
141}
142
143type Item = { key: string; value: string; tone: Tone; detail: string }
144
145const ttlLabel = (ttlMs: number) => (ttlMs === TTL_MS['5m'] ? '5-minute' : '1-hour')
146
147const clockTime = (ms: number) => {
148  try {
149    return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
150  } catch {
151    return new Date(ms).toISOString().slice(11, 16)
152  }
153}
154
155// The footer shows three bare numbers; each `detail` says what one means, for
156// /usage-band (the desktop footer draws text only, so it has no tooltips).
157function buildItems(usage: Usage, cache: Cache | null, now: number, lastTurnUsd: number | null): Item[] {
158  const items: Item[] = []
159
160  if (usage.ctxPercent !== null) {
161    const pct = usage.ctxPercent
162    const tone: Tone = pct >= settings.contextCritPercent ? 'error' : pct >= settings.contextWarnPercent ? 'warning' : 'normal'
163    items.push({
164      key: 'context',
165      value: `${Math.round(pct)}%`,
166      tone,
167      detail: [
168        `Context: ${tok(usage.ctxTokens ?? 0)} of ${tok(usage.ctxWindow)} tokens (${Math.round(pct)}%)`,
169        ...(usage.ctxWindow < usage.modelWindow
170          ? [`Measured against the auto-compact window; the model allows ${tok(usage.modelWindow)}.`]
171          : []),
172        settings.handoffAtPercent > 0
173          ? `At ${settings.handoffAtPercent}% a Handoff prompt appears. /handoff works at any time.`
174          : '/handoff starts a fresh session with a handoff note.',
175      ].join('\n'),
176    })
177  }
178
179  if (settings.showCost && usage.usd !== null) {
180    const isEstimate = usage.limits.length > 0
181    items.push({
182      key: 'cost',
183      value: `${isEstimate ? '~' : ''}${usd(usage.usd)}`,
184      tone: 'normal',
185      detail: [
186        `Session cost at API prices: ${usd(usage.usd)}`,
187        ...(lastTurnUsd !== null && lastTurnUsd > 0 ? [`Last turn: +${usd(lastTurnUsd)}`] : []),
188        ...(isEstimate ? ['On a subscription this is an estimate, not a charge.'] : []),
189      ].join('\n'),
190    })
191  }
192
193  if (settings.showCache && cache) {
194    // `now` can lag the request that wrote the cache, so cap at the TTL
195    const leftSec = Math.min(cache.ttlMs, cache.at + cache.ttlMs - now) / 1000
196    const sessionKey = usage.model ? cacheModelKey(usage.model) : null
197    const cacheKey = cacheModelKey(cache.model)
198    const isSwitched = sessionKey !== null && cacheKey !== null && sessionKey !== cacheKey
199    const hitLine = `The last request read ${Math.round(cache.hitPct)}% of its input from cache.`
200    const ttlLine = cache.isTtlKnown
201      ? `${ttlLabel(cache.ttlMs)} cache; each request restarts it.`
202      : `${ttlLabel(cache.ttlMs)} cache assumed until this turn ends; each request restarts it.`
203    if (isSwitched) {
204      items.push({
205        key: 'cache',
206        value: 'cold',
207        tone: 'warning',
208        detail: 'Prompt cache: cold\nYou switched models. Each model has its own cache, so the next message rebuilds it at full input price.',
209      })
210    } else if (leftSec > 0) {
211      items.push({
212        key: 'cache',
213        value: `${cache.isTtlKnown ? '' : '≈'}${clock(leftSec)}`,
214        tone: leftSec * 1000 <= settings.cacheNudgeMinutes * 60 * 1000 ? 'warning' : 'normal',
215        detail: [`Prompt cache: warm until ${clockTime(cache.at + cache.ttlMs)}`, ttlLine, hitLine].join('\n'),
216      })
217    } else {
218      items.push({
219        key: 'cache',
220        value: 'cold',
221        tone: 'muted',
222        detail: `Prompt cache: expired at ${clockTime(cache.at + cache.ttlMs)}\nThe next message rebuilds it at full input price.\n${hitLine}`,
223      })
224    }
225  }
226
227  return items
228}
229
230const DOT = '\u2002·\u2002'
231
232const HANDOFF_PROMPT =
233  'Write a hand-off note so a fresh coding session can continue this work. Cover the goal, what was done (files changed, decisions and why), the current state, open issues, and the next steps. Be specific and brief. Reply with the note only.'
234
235async function writeHandoff($: $, settings: Settings): Promise<string | null | 'empty'> {
236  if (settings.handoffMode === 'fork') {
237    const r = await $.model.fork({ prompt: HANDOFF_PROMPT })
238    if (r.isAnswered) return r.text
239    if (r.reason === 'nothing-to-fork') return 'empty'
240    // a near-full context is when the fork is most likely to fail
241    if (r.reason !== 'api-error') return null
242  }
243  return writeHandoffWithHaiku($)
244}
245
246async function writeHandoffWithHaiku($: $): Promise<string | null | 'empty'> {
247  const messages = await $.session.messages()
248  const transcript = messages
249    .map(m => {
250      const tools = m.toolUses
251        .map(t => {
252          const target = t.input.file_path ?? t.input.path ?? t.input.command
253          return typeof target === 'string' ? `${t.tool}(${target.slice(0, 200)})` : t.tool
254        })
255        .join(', ')
256      const speaker = m.role === 'user' ? 'User' : 'Assistant'
257      return `${speaker}: ${m.text}${tools ? `\n[tools: ${tools}]` : ''}`
258    })
259    .filter(line => line.length > 12)
260    .join('\n\n')
261    .slice(-60000)
262  if (!transcript) return 'empty'
263  const r = await $.model.complete({
264    model: 'haiku',
265    maxTokens: 1500,
266    timeoutMs: 90000,
267    system: 'You write concise hand-off notes so a fresh coding session can continue seamlessly.',
268    prompt: `${HANDOFF_PROMPT}\n\n${transcript}`,
269  })
270  return r.isAnswered ? r.text : null
271}
272
273async function runHandoff($: $, settings: Settings) {
274  if (await read($, isHandoffBusyAtom)) return
275  await update($, isHandoffBusyAtom, () => true)
276  try {
277    $.ui.toast('Writing handoff…')
278    const note = await writeHandoff($, settings)
279    if (note === 'empty') {
280      $.ui.toast('Nothing to hand off yet')
281      return
282    }
283    if (!note) {
284      $.ui.toast('Handoff failed; session kept')
285      return
286    }
287    // /clear empties the prompt box, so keep what the person had typed
288    const draft = (await $.prompt.read()).text.trim()
289    await $.command.run({ command: 'clear' })
290    await resetConversationState($)
291    await $.prompt.fill({
292      text: `Handoff from my previous session:\n\n${note}\n\nContinue from here.${draft ? `\n\n${draft}` : ''}`,
293    })
294    $.ui.toast('New session started. The handoff is in the prompt box; press Enter to send.')
295  } catch {
296    $.ui.toast('Handoff failed; session kept')
297  } finally {
298    await update($, isHandoffBusyAtom, () => false)
299  }
300}
301
302async function pressHandoff($: $, settings: Settings) {
303  if (!settings.confirmHandoff) return runHandoff($, settings)
304  const now = await $.clock.now()
305  if ((await read($, handoffArmedUntilAtom)) > now) {
306    await update($, handoffArmedUntilAtom, () => 0)
307    return runHandoff($, settings)
308  }
309  await update($, handoffArmedUntilAtom, () => now + HANDOFF_CONFIRM_MS)
310  await markNow($)
311}
312
313// module state: a reload runs this file again, so it starts over
314let settings: Settings = readSettings({})
315let fixedTtlMs: number | null = null
316let refreshTimer: Timer | undefined
317let tickTimer: Timer | undefined
318let isTurnRunning = false
319let turnStartUsd: number | null = null
320let nudgedCacheAt: number | null = null
321let hasSuggestedHandoff = false
322let compactWindowTokens: number | null = null
323
324
325// /clear starts a new conversation, but $.state and module state outlive it
326async function resetConversationState($: $) {
327  turnStartUsd = null
328  nudgedCacheAt = null
329  hasSuggestedHandoff = false
330  await update($, cacheAtom, () => null)
331  await update($, lastTurnUsdAtom, () => null)
332}
333
334async function storeUsage($: $, usage: Usage) {
335  await update($, usageAtom, () => usage)
336  const isPastHandoff =
337    settings.handoffAtPercent > 0 && (usage.ctxPercent ?? 0) >= settings.handoffAtPercent
338  if (isPastHandoff && !hasSuggestedHandoff) {
339    hasSuggestedHandoff = true
340    $.ui.toast(`Context is ${Math.round(usage.ctxPercent ?? 0)}% full. Handoff is ready above the prompt, or type /handoff.`, {
341      timeoutMs: 8000,
342    })
343  }
344}
345
346async function refreshUsage($: $) {
347  try {
348    // "summary" estimates locally and sends nothing; only its window is used
349    const u = await $.session.usage({ breakdown: 'summary' })
350    compactWindowTokens = u.context.breakdown?.rawMaxTokens ?? null
351    await storeUsage($, toUsage(u, await readModel($)))
352    await markNow($)
353  } catch {
354    // session not bound yet; the next refresh tries again
355  }
356}
357
358const appleScriptString = (text: string) => `"${text.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
359
360// The desktop app's deep link accepts only its own local_ session id, not the CLI's.
361async function readHostSessionId($: $) {
362  try {
363    const hostSessionId = await $.env.get('CLAUDE_CODE_HOST_SESSION_ID')
364    if (hostSessionId && /^local_[A-Za-z0-9-]{1,64}$/.test(hostSessionId)) return hostSessionId
365  } catch {}
366  return null
367}
368
369// The desktop app has no notification channel for plugins ($.ui.notify answers
370// no-surface there). terminal-notifier can open this session on click; an
371// osascript notification belongs to Script Editor, so a click opens that instead.
372// terminal-notifier is started through LaunchServices: run as a child of the
373// Claude process, macOS refuses its notification permission.
374async function sendNotification($: $, title: string, text: string) {
375  try {
376    const sent = await $.ui.notify(text, { title })
377    if (sent.isSent) return
378  } catch {}
379  const hostSessionId = await readHostSessionId($)
380  const clickArgs = hostSessionId
381    ? ['-open', `claude://code/continue?session=${hostSessionId}`, '-group', `usage-band-plus-${hostSessionId}`]
382    : ['-activate', 'com.anthropic.claudefordesktop']
383  try {
384    const r = await $.process.run([
385      'open', '-n', '-g', '-b', 'fr.julienxx.oss.terminal-notifier',
386      '--args', '-title', title, '-message', text, ...clickArgs,
387    ])
388    if (r.exitCode === 0) return
389  } catch {}
390  try {
391    await $.process.run([
392      'osascript',
393      '-e',
394      `display notification ${appleScriptString(text)} with title ${appleScriptString(title)}`,
395    ])
396  } catch {}
397}
398
399const isCacheNudgeWindow = (leftMs: number, usage: Usage) => {
400  const nudgeMs = settings.cacheNudgeMinutes * 60 * 1000
401  return nudgeMs > 0 && leftMs > 0 && leftMs <= nudgeMs && (usage.ctxTokens ?? 0) >= NUDGE_MIN_CONTEXT_TOKENS
402}
403
404async function compactForCache($: $) {
405  if (await read($, isCompactingAtom)) return
406  await update($, isCompactingAtom, () => true)
407  try {
408    const cacheAt = (await read($, cacheAtom))?.at ?? null
409    let result: Awaited<ReturnType<typeof $.session.compact>>
410    try {
411      result = await $.session.compact()
412    } catch {
413      // An SDK-hosted session (the desktop app) rejects $.session.compact:
414      // there, compaction runs only as the /compact command.
415      void $.command
416        .run({ command: 'compact' })
417        .catch(err => $.ui.toast(`Compact failed; the session is unchanged: ${String(err)}`))
418      await update($, dismissedCacheAtAtom, () => cacheAt)
419      return
420    }
421    if ('skip' in result) $.ui.toast(`Compact skipped: ${result.skip}`)
422    else await update($, dismissedCacheAtAtom, () => cacheAt)
423  } catch (err) {
424    $.ui.toast(`Compact failed; the session is unchanged: ${String(err)}`)
425  } finally {
426    await update($, isCompactingAtom, () => false)
427  }
428}
429
430async function tick($: $) {
431  try {
432    const now = await $.clock.now()
433    const cache = await read($, cacheAtom)
434    const usage = await read($, usageAtom)
435    const armedUntil = await read($, handoffArmedUntilAtom)
436    const leftMs = cache ? cache.at + cache.ttlMs - now : -Infinity
437    const isCountingSeconds = leftMs > -1000 && leftMs < SECONDS_COUNTDOWN_MS
438    if (armedUntil && armedUntil <= now) await update($, handoffArmedUntilAtom, () => 0)
439    if (isCountingSeconds || armedUntil) await update($, nowAtom, () => now)
440
441    const isNudgeDue =
442      cache !== null &&
443      usage !== null &&
444      !isTurnRunning &&
445      isCacheNudgeWindow(leftMs, usage) &&
446      nudgedCacheAt !== cache.at
447    if (isNudgeDue) {
448      nudgedCacheAt = cache.at
449      const minutes = Math.max(1, Math.round(leftMs / 60000))
450      const text = `Prompt cache expires in about ${minutes} min. Compact from the prompt, or let it expire.`
451      // macOS often hides a system notification while the app is in front, so toast too
452      $.ui.toast(text, { timeoutMs: 10000 })
453      void sendNotification($, 'Prompt cache', text)
454    }
455  } catch {}
456}
457
458export const register: Register = (on, options) => {
459  settings = readSettings(options)
460  fixedTtlMs = settings.cacheTtl === 'auto' ? null : TTL_MS[settings.cacheTtl]
461
462  on('session.start', async ($, e, next) => {
463    refreshTimer?.cancel()
464    tickTimer?.cancel()
465    hasSuggestedHandoff = false
466    nudgedCacheAt = null
467    // session.measure pushes usage changes; this slow timer moves the reset
468    // countdowns and picks up a /model switch, which measures nothing
469    refreshTimer = $.clock.every(USAGE_REFRESH_MS, () => refreshUsage($))
470    tickTimer = $.clock.every(1000, () => tick($))
471    try {
472      await $.command.register({
473        name: 'handoff',
474        description: 'Write a handoff note, clear the session, and put the note in the prompt box',
475      })
476      await $.command.register({
477        name: 'usage-band',
478        description: 'Explain the footer numbers: context, cost and prompt cache, with current values',
479      })
480    } catch {}
481    await markNow($)
482    await refreshUsage($)
483    return next(e)
484  })
485
486  on('session.end', async ($, e, next) => {
487    if (e.reason === 'clear') {
488      try {
489        await resetConversationState($)
490      } catch {}
491    }
492    return next(e)
493  })
494
495  on('session.measure', async ($, e, next) => {
496    try {
497      await storeUsage($, toUsage(e, await readModel($)))
498      // measure may land after turn.complete, with the turn's final request counted
499      if (e.cost && turnStartUsd !== null) {
500        const turnUsd = e.cost.usd - turnStartUsd
501        await update($, lastTurnUsdAtom, () => turnUsd)
502      }
503      await markNow($)
504    } catch {}
505    return next(e)
506  })
507
508  on('turn.start', async ($, e, next) => {
509    if (e.agentId === undefined) {
510      isTurnRunning = true
511      try {
512        turnStartUsd = (await $.session.usage()).cost?.usd ?? null
513      } catch {
514        turnStartUsd = null
515      }
516    }
517    return next(e)
518  })
519
520  on('turn.step', async function* ($, e, next) {
521    const result = yield* next(e)
522    if (e.agentId !== undefined || !result.usage) return result
523    const u = result.usage
524    const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
525    const at = await $.clock.now()
526    await update($, cacheAtom, (prev: Cache | null) => ({
527      at,
528      model: u.model,
529      hitPct: total ? (u.cache_read_input_tokens / total) * 100 : 0,
530      ttlMs: fixedTtlMs ?? prev?.ttlMs ?? TTL_MS['1h'],
531      isTtlKnown: fixedTtlMs !== null || (prev?.isTtlKnown ?? false),
532    }))
533    return result
534  })
535
536  on('turn.complete', async ($, e, next) => {
537    const result = await next(e)
538    if (e.agentId !== undefined) return result
539    isTurnRunning = false
540    try {
541      const endUsd = (await $.session.usage()).cost?.usd ?? null
542      if (endUsd !== null && turnStartUsd !== null) {
543        const turnUsd = endUsd - turnStartUsd
544        await update($, lastTurnUsdAtom, () => turnUsd)
545      }
546    } catch {}
547    if (fixedTtlMs === null) {
548      try {
549        const ttlMs = await readTtlFromTranscript($)
550        if (ttlMs !== null) {
551          await update($, cacheAtom, (cur: Cache | null) => (cur ? { ...cur, ttlMs, isTtlKnown: true } : cur))
552        }
553      } catch {}
554    }
555    return result
556  })
557
558  on('command.run', { command: 'usage-band' }, async $ => {
559    const usage = await read($, usageAtom)
560    if (usage === null) return { text: 'No usage yet: the footer fills in after the first response.' }
561    const now = await $.clock.now()
562    const items = buildItems(usage, await read($, cacheAtom), now, await read($, lastTurnUsdAtom))
563    const footer = items.map(item => item.value).join(' · ')
564    return { text: [`Footer: ${footer}`, ...items.map(item => item.detail)].join('\n\n') }
565  })
566
567  on('command.run', { command: 'handoff' }, async $ => {
568    void runHandoff($, settings)
569    return { text: 'Writing a handoff note…' }
570  })
571
572  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
573    if (settings.placement !== 'footer') return next(e)
574    const usage = await read($, usageAtom)
575    if (usage === null) return next(e)
576    const now = (await read($, nowAtom)) || (await $.clock.now())
577    const items = buildItems(usage, await read($, cacheAtom), now, await read($, lastTurnUsdAtom))
578    if (!items.length) return next(e)
579
580    const { Text } = $.ui.resolve(e)
581    return (
582      <Text>
583        {items.flatMap((item, index) => {
584          const drawn = (
585            <Text key={`ubp-${item.key}`} color={TONE_COLOR[item.tone]}>
586              {item.value}
587            </Text>
588          )
589          return index
590            ? [
591                <Text key={`ubp-dot-${item.key}`} color="subtle">
592                  {DOT}
593                </Text>,
594                drawn,
595              ]
596            : [drawn]
597        })}
598        {e.props.modes.length ? <Text dimColor>{`${DOT}${e.props.modes.join(' & ')}`}</Text> : null}
599      </Text>
600    )
601  })
602
603  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
604    const usage = await read($, usageAtom)
605    if (e.props.hasSurvey || usage === null) return next(e)
606
607    const { Box, Button, Text } = $.ui.resolve(e)
608    const now = (await read($, nowAtom)) || (await $.clock.now())
609    const cache = await read($, cacheAtom)
610
611    const leftMs = cache ? cache.at + cache.ttlMs - now : -Infinity
612    const isExpiring =
613      cache !== null &&
614      !e.props.isWorking &&
615      isCacheNudgeWindow(leftMs, usage) &&
616      (await read($, dismissedCacheAtAtom)) !== cache.at
617    if (isExpiring && cache) {
618      const isCompacting = await read($, isCompactingAtom)
619      return (
620        <Box justifyContent="space-between" alignItems="center">
621          <Text dimColor wrap="truncate-end">
622            <Text color="warning">{`Prompt cache expires in ${clock(leftMs / 1000)}. `}</Text>
623            Compact so the next message rebuilds a smaller cache, or let it expire.
624          </Text>
625          <Box columnGap={1}>
626            <Button
627              key="compact"
628              label={isCompacting ? 'Compacting…' : 'Compact'}
629              variant="primary"
630              onPress={() => compactForCache($)}
631            />
632            <Button
633              key="let-expire"
634              label="Let it expire"
635              variant="secondary"
636              dimColor
637              onPress={() => update($, dismissedCacheAtAtom, () => cache.at)}
638            />
639          </Box>
640        </Box>
641      )
642    }
643
644    const isArmed = (await read($, handoffArmedUntilAtom)) > now
645    const isBusy = await read($, isHandoffBusyAtom)
646    const isSuggested =
647      settings.handoffAtPercent > 0 && (usage.ctxPercent ?? 0) >= settings.handoffAtPercent
648    const items =
649      settings.placement === 'band' ? buildItems(usage, cache, now, await read($, lastTurnUsdAtom)) : []
650    if (!items.length && !isSuggested && !isArmed && !isBusy) return next(e)
651
652    const isProminent = isSuggested || isArmed
653    const label = isBusy ? 'Writing…' : isArmed ? 'Confirm handoff' : 'Handoff'
654    const message = isArmed
655      ? 'Press again to clear this session and start fresh with a handoff note.'
656      : `Context is ${Math.round(usage.ctxPercent ?? 0)}% full. A handoff note carries this work into a fresh session.`
657
658    return (
659      <Box justifyContent="space-between" alignItems="center">
660        {items.length ? (
661          <Text>
662            {items.flatMap((item, index) => {
663              const drawn = <Text color={TONE_COLOR[item.tone]}>{item.value}</Text>
664              return index ? [<Text color="subtle">{DOT}</Text>, drawn] : [drawn]
665            })}
666          </Text>
667        ) : (
668          <Text dimColor wrap="truncate-end">
669            {message}
670          </Text>
671        )}
672        <Button
673          key="handoff"
674          label={label}
675          variant={isProminent ? 'primary' : 'secondary'}
676          dimColor={!isProminent}
677          onPress={() => pressHandoff($, settings)}
678        />
679      </Box>
680    )
681  })
682}
683
types/index.d.ts 38 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3// the status line's figures, from session.measure or $.session.usage()
4export type Usage = {
5  model: string | null
6  ctxPercent: number | null
7  ctxTokens: number | null
8  // the compaction window when known, as the app's Context panel measures
9  ctxWindow: number
10  modelWindow: number
11  usd: number | null
12  limits: Limit[]
13}
14
15// the last main-thread API request, from its turn.step result
16export type Cache = {
17  at: number
18  model: string
19  hitPct: number
20  ttlMs: number
21  isTtlKnown: boolean
22}
23
24declare module 'claude-code' {
25  interface PluginState {
26    'usage-band-plus': {
27      usage: Usage | null
28      cache: Cache | null
29      now: number
30      lastTurnUsd: number | null
31      handoffArmedUntil: number
32      isHandoffBusy: boolean
33      dismissedCacheAt: number | null
34      isCompacting: boolean
35    }
36  }
37}
38