SLOPSHOPPER

cache-timer

A countdown in the prompt footer: under the hint line on the terminal, left of the model picker on the desktop: time left on the prompt cache and how much of…

newspinnertoastprocesstimer
★ 1v0.5.0MITupdated 2026-10-07Sanexxxx777/claude-cache-timer
A shopper browsing a rack in a slop shop
README

cache-timer

How long until Claude Code's prompt cache goes cold, in the prompt footer of the terminal and the desktop app.

cache ━━━━━━━━━━ 47 min · 98%

The bar shrinks as the cache ages. The percent is the share of the last request the cache served. With plenty of time left the line is light olive. It turns khaki at 10 minutes left, amber at 5 and terracotta at 2. Once the cache expires the bar empties, the line goes grey and says how many tokens the next turn will write again, with /compact from 100k tokens up.

Where it sits:

  • Terminal: on the row right under Claude Code's hint line (auto mode on (shift+tab to cycle)), which stays as Claude Code draws it. On a narrow window the percent goes first, then bar cells.
  • Desktop app: under the prompt box, left of the model picker, with a shorter bar (5 cells) because the app draws ━ wider than a letter. The desktop has no hint line to sit beside.

This is a Claude Code mod: hooks that run inside Claude Code itself. A one-second clock redraws the line, so the countdown moves while you're idle. It only repaints when the text or colour changes, which is once a minute until the last 5 minutes.

Why it usually says an hour

On a Claude subscription Claude Code asks for the 1-hour cache by itself. API keys, cloud providers and usage credits get 5 minutes. The mod follows Claude Code's documented order (FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H, then the account), notices when a subscription runs out of plan usage and falls back to credits, and checks itself against request timing: a cache hit 20 minutes after the previous request proves the hour.

On a 5-minute cache the colour steps scale down to 50, 25 and 10 seconds.

When the cache is rebuilt early

The countdown assumes the next request reads what the last one cached. Some changes break that before the time runs out, and the line says so.

  • Model switch. Each model has its own cache. Right after /model or the desktop picker, before you send anything, the line turns amber: cache ━━━━━━━━━━ other model · rewrites 81k. The number is what the other model will write, less anything it still holds from earlier in the conversation. Switch back and the timer returns.
  • A rebuild the countdown did not predict. When a request writes again more than 5% and at least 2,000 tokens of what the warm cache held (the rule Claude Code uses for misses in /usage), the percent gives way to the cause for the rest of that turn: · rebuilt: model when the engine changed the model itself (a fallback, a skill's model), · rebuilt: effort when the effort level changed (on most models each level has its own cache), or · rebuilt when the cause is out of a mod's sight: fast mode turned on, tools changed, an early eviction. A prompt that shrank (/compact, cleared tool results) or went back with /rewind does not count.
  • /compact. The line clears until the next request, because the old size no longer applies.
  • Usage credits. When a subscription runs out of plan usage, Claude Code drops to the 5-minute cache. The mod drops an hour it had proven from timing and counts five minutes.

Claude Code works out the likely cause of a miss itself, for /usage and status line scripts (prompt_cache.last_miss_cause), but it does not pass it to mods, so the mod reads it from the token counts and the events it can see.

A notification in Ghostty

In Ghostty on macOS, a big cache also raises a desktop notification, so a session in a background tab is not missed:

  • 2 minutes before it expires (10 seconds on a 5-minute cache), the same moment as the toast: cache expires in 0:10: any message refreshes it (170k tokens);
  • when it is written again and nobody asked for that: the engine changed the model by itself, or the cause is out of sight. Your own /model switch and an effort change stay quiet.

By default only prompts of 100k tokens or more raise one; the notify option changes that. Other terminals and the desktop app get none, and Ghostty may hold one back while you are looking at that window.

The mod passes the text to a small script that finds the session's terminal and writes the OSC 777 sequence Ghostty turns into a notification. Put the script where the mod looks for it:

mkdir -p ~/.claude/hooks
cp ~/claude-cache-timer/scripts/cache-notify.sh ~/.claude/hooks/

Without it nothing is sent. If no banner shows up, open System Settings → Notifications → Ghostty: notifications can be allowed there with the Desktop box unticked.

Install

Tested on Claude Code 2.1.289 and 2.1.290. Mods are early access, and their $ API may change between releases.

git clone https://github.com/Sanexxxx777/claude-cache-timer ~/claude-cache-timer

Try it for one session:

claude --plugin-dir ~/claude-cache-timer

Load it in every session (the desktop app included) through the env block of ~/.claude/settings.json:

{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/claude-cache-timer" } }

The line appears after the first request of a session.

Options

Set them in ~/.claude/settings.json:

{ "pluginConfigs": { "cache-timer": { "options": { "lang": "ru" } } } }
OptionValuesDefault
langauto reads LANG (ru_* gives Russian), or ru / enauto
ttlauto, or pin 5m / 1hauto
toastone toast when the last stage starts, for prompts of 20k tokens or moretrue
notifythe Ghostty notification: for prompts of 100k tokens or more, all, or off100k

What it touches

Hooks: session.start, session.end, turn.step (main-loop requests only, subagents have their own cache), classic.PostModelSwitch (which model the next request goes to), classic.PostCompact, and ui.render on PromptHint (terminal) and SessionMode (desktop). It reads HOME, LANG, TERM_PROGRAM and the three cache variables above, plus promptCacheTtl from your settings files. It makes no network calls and writes no files. The one process it starts is the notification script, and only in Ghostty: bash ~/.claude/hooks/cache-notify.sh <title> <body>, which writes one escape sequence to the session's terminal and nothing else. claude plugin validate . prints the same list.

Tests

claude plugin test .
bash tests/cache-notify-test.sh

38 tests: colour stages for both lifetimes, time format, lifetime rules, when a rebuild counts and what caused it, how the line fits a narrow terminal, the line itself on the terminal and desktop surfaces, with a model switch, a rebuild and a compaction, and which rebuilds raise a Ghostty notification, from what size, never outside Ghostty or on the desktop. The script test writes to a temp file instead of a terminal: the sequence, its sanitising, silence outside Ghostty, exit 0 when it cannot write.

Credits

The lifetime rules and the timing check are adapted from prompt-cache-control in claude-code-templates by Daniel Ávila (MIT). This mod keeps only the countdown and draws it in the footer.

MIT, see LICENSE. Made by Aleksandr_NFA (Telegram) · Sanexxxx777 (GitHub).

Source 2 files
hooks/register.tsx 361 lines
1/**
2 * cache-timer: a cache countdown in the prompt footer, right after the
3 * engine's hint line ("auto mode on ...") on the terminal; on the desktop in
4 * the slot left of the model picker.
5 *
6 *   кэш ━━━━━━━━━━ 47 мин · 98%
7 *
8 * The bar and the time are the cache's life left; the dim percent is how much
9 * of the last request the cache served. Light olive while calm, warmer at 10,
10 * 5 and 2 minutes left, dimmed once expired with what the next turn will cost.
11 * A rebuild the countdown did not predict puts its cause in the percent's
12 * place (· сброс: модель) for the rest of its turn and until the next one
13 * reads the cache; after a model switch, before anything is sent, the line
14 * says what the other model will write (другая модель · перезапишет 120k).
15 * In Ghostty a big cache also raises a macOS notification, through
16 * ~/.claude/hooks/cache-notify.sh: 2 minutes before it expires, and on a
17 * rebuild nobody asked for, so a session in a background tab is not missed.
18 *
19 *   - turn.step: each main-loop request's usage (subagents have their own cache)
20 *   - classic.PostModelSwitch: the model the next request goes to
21 *   - classic.PostCompact: a new, shorter history, nothing cached for it yet
22 *   - clock.every(1000): redraws only when the line changes, so from 5 minutes
23 *     up it redraws once a minute
24 *   - ui.render on PromptHint: the engine's line kept whole, the timer after
25 *     it; on a narrow terminal the percent goes first, then bar cells
26 *   - ui.render on SessionMode (desktop): the engine's mode labels, then ours
27 *
28 * Adapted from prompt-cache-control by claude-code-templates (MIT).
29 */
30import type { EngineInterface, Register } from 'claude-code'
31import {
32  accountOf,
33  baseModel,
34  decideTtl,
35  filledCells,
36  fitBar,
37  fmtLeft,
38  fmtTokens,
39  hitRatio,
40  keepObserved,
41  langOf,
42  missCause,
43  notifyMin,
44  notifyRebuild,
45  observeTtl,
46  promptTokens,
47  remainingMs,
48  STAGE_COLOR,
49  stageOf,
50  touchedCache,
51  WORDS,
52} from './cache.ts'
53import type { Account, CacheEnv, Cause, Fit, Lang, Sample, Stage, Ttl } from './cache.ts'
54
55const BAR = 10
56// an expired cache this big is worth a /compact before the next turn rewrites it
57const COMPACT_AT = 100_000
58// below this a lapsing cache costs too little to interrupt anyone about
59const TOAST_MIN_TOKENS = 20_000
60// a rebuild and a switch's pending rewrite: amber, whatever the time left
61const ALERT = STAGE_COLOR.five
62
63let last: Sample | undefined
64let prev: Sample | undefined
65// each model's own cache: its last main-loop request, by baseModel
66const seen = new Map<string, Sample>()
67// the model a switch named, until a request goes out
68let nextModel: string | undefined
69// the model the person last switched to, by baseModel: its rebuild was asked for,
70// even when a request already in flight came back on the old one first
71let chosenModel: string | undefined
72// a rebuild the countdown did not predict, kept while its turn lasts
73let reset: { cause: Cause; turnId: string } | undefined
74let ttl: Ttl = '5m'
75let observed: Ttl | undefined
76let account: Account | undefined
77let env: CacheEnv = {}
78let setting: unknown
79let lang: Lang = 'en'
80let timer: { cancel: () => void } | undefined
81let lastKey = ''
82let toastedFor = 0
83let notifiedFor = 0
84// where a notification can go: Ghostty's tab of this session, by the notifier script
85let notifier: { script: string; title: string } | undefined
86
87// the conversation starts over (a new session, /clear, a compaction): nothing of it cached yet
88function forget() {
89  last = undefined
90  prev = undefined
91  seen.clear()
92  nextModel = undefined
93  reset = undefined
94  lastKey = ''
95}
96
97// fire and forget: the script finds the session's tab itself and stays silent without one
98function notify($: EngineInterface, body: string) {
99  if (!notifier) return
100  void $.process.run(['bash', notifier.script, notifier.title, body], { timeoutMs: 5000 }).catch(() => undefined)
101}
102
103// the promptCacheTtl setting: local over project over user settings
104async function readSetting($: EngineInterface): Promise<unknown> {
105  const home = await $.env.get('HOME').catch(() => undefined)
106  const cwd = await $.session.cwd().catch(() => undefined)
107  const files = [cwd && `${cwd}/.claude/settings.local.json`, cwd && `${cwd}/.claude/settings.json`, home && `${home}/.claude/settings.json`]
108  for (const file of files) {
109    if (!file) continue
110    try {
111      const value = JSON.parse(await $.fs.read(file)).promptCacheTtl
112      if (value === '5m' || value === '1h') return value
113    } catch {
114      // missing or unreadable: the next file
115    }
116  }
117  return undefined
118}
119
120async function refreshTtl($: EngineInterface, option: unknown) {
121  // a subscription that runs out of plan usage moves to credits mid-session
122  const now = accountOf((await $.session.usage().catch(() => undefined))?.rateLimits ?? [])
123  observed = keepObserved(observed, account, now)
124  if (now !== 'other') account = now
125  const base = decideTtl(option, env, setting, now)
126  const pinned = option === '5m' || option === '1h'
127  ttl = pinned ? base : (observed ?? base)
128}
129
130function view(now: number) {
131  if (!last || !touchedCache(last)) return undefined
132  const left = remainingMs(last, ttl, now)
133  const stage: Stage = stageOf(left, ttl)
134  const size = promptTokens(last)
135  // after a switch the next request reads only what the other model cached itself, if it still holds
136  let rewrite: number | undefined
137  if (nextModel !== undefined && baseModel(nextModel) !== baseModel(last.model) && stage !== 'cold') {
138    const own = seen.get(baseModel(nextModel))
139    rewrite = Math.max(0, size - (own && remainingMs(own, ttl, now) > 0 ? promptTokens(own) : 0))
140  }
141  return { left, stage, size, hit: Math.round(hitRatio(last) * 100), rewrite, reset: reset?.cause }
142}
143
144const keyOf = (v: ReturnType<typeof view>) =>
145  v ? `${v.stage}|${v.stage === 'cold' ? '' : fmtLeft(v.left, lang)}|${v.hit}|${v.rewrite}|${v.reset}` : ''
146
147export const register: Register = (on, options) => {
148  const wantToast = options.toast !== false
149  const minNotify = notifyMin(options.notify)
150
151  on('session.start', async ($, e, next) => {
152    const r = await next(e)
153    forget()
154    observed = undefined
155    account = undefined
156    toastedFor = 0
157    notifiedFor = 0
158    chosenModel = undefined
159    const none = () => undefined
160    env = {
161      enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(none),
162      force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(none),
163      ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(none),
164    }
165    lang = langOf(options.lang, await $.env.get('LANG').catch(none))
166    setting = await readSetting($)
167    const home = await $.env.get('HOME').catch(none)
168    const inGhostty = e.surface === 'terminal' && (await $.env.get('TERM_PROGRAM').catch(none)) === 'ghostty'
169    notifier =
170      inGhostty && home && minNotify !== undefined
171        ? { script: `${home}/.claude/hooks/cache-notify.sh`, title: `Claude Code · ${e.cwd.split('/').filter(Boolean).pop() ?? e.cwd}` }
172        : undefined
173    await refreshTtl($, options.ttl)
174    $.ui.log(`cache-timer loaded: ${ttl} cache, lang ${lang}`, { to: 'debug' })
175
176    timer?.cancel()
177    timer = $.clock.every(1000, () => {
178      const v = view(Date.now())
179      const key = keyOf(v)
180      if (key !== lastKey) {
181        lastKey = key
182        $.ui.invalidate('ui.render')
183      }
184      // one toast per cache entry, on entering the last stage; after a switch no message refreshes it
185      if (wantToast && v && last && v.stage === 'two' && v.rewrite === undefined && v.size >= TOAST_MIN_TOKENS && toastedFor !== last.startedAt) {
186        toastedFor = last.startedAt
187        $.ui.toast(WORDS[lang].toast(fmtLeft(v.left, lang), fmtTokens(v.size)))
188      }
189      // the same moment for a tab out of sight, from the notify option's size up
190      if (notifier && minNotify !== undefined && v && last && v.stage === 'two' && v.rewrite === undefined && v.size >= minNotify && notifiedFor !== last.startedAt) {
191        notifiedFor = last.startedAt
192        notify($, WORDS[lang].toast(fmtLeft(v.left, lang), fmtTokens(v.size)))
193      }
194    })
195    return r
196  })
197
198  on('session.end', async ($, e, next) => {
199    // /clear starts a new conversation in the same process, and a new cache
200    if (e.reason === 'clear') {
201      forget()
202      observed = undefined
203      $.ui.invalidate('ui.render')
204      return next(e)
205    }
206    timer?.cancel()
207    timer = undefined
208    return next(e)
209  })
210
211  on('turn.step', async function* ($, e, next) {
212    if (e.agentId) return yield* next(e)
213    const startedAt = Date.now()
214    const r = yield* next(e)
215    if (r.usage) {
216      prev = last
217      last = {
218        // the engine's id, as a model switch names it; the API's only when it gave none
219        model: e.model || r.usage.model,
220        effort: e.effort,
221        startedAt,
222        read: r.usage.cache_read_input_tokens,
223        write: r.usage.cache_creation_input_tokens,
224        fresh: r.usage.input_tokens,
225      }
226      observed = observeTtl(prev, last, observed)
227      await refreshTtl($, options.ttl)
228      const cause = missCause(prev, last, ttl)
229      if (cause) reset = { cause, turnId: e.turnId }
230      else if (reset?.turnId !== e.turnId) reset = undefined
231      const size = promptTokens(last)
232      const byUser = nextModel !== undefined || baseModel(last.model) === chosenModel
233      if (cause && minNotify !== undefined && size >= minNotify && notifyRebuild(cause, byUser)) {
234        notify($, WORDS[lang].rebuilt(fmtTokens(size), cause))
235      }
236      seen.set(baseModel(last.model), last)
237      nextModel = undefined
238      lastKey = ''
239      $.ui.invalidate('ui.render')
240    }
241    return r
242  })
243
244  // /model, the desktop picker, the SDK: the next request reads only the new model's own cache.
245  // Ours runs before next, and a failure of it passes the event on untouched
246  on('classic.PostModelSwitch', ($, e, next) => {
247    nextModel = e.to_model
248    chosenModel = baseModel(e.to_model)
249    lastKey = ''
250    $.ui.invalidate('ui.render')
251    return next(e)
252  }).catch(($, e, next) => next(e))
253
254  // the history is now a summary: the next request caches it afresh, by design
255  on('classic.PostCompact', ($, e, next) => {
256    forget()
257    $.ui.invalidate('ui.render')
258    return next(e)
259  }).catch(($, e, next) => next(e))
260
261  // Terminal: after the engine's hint line ("auto mode on (shift+tab to
262  // cycle) · ← 1 agent"), kept whole as the engine draws it; the terminal puts
263  // the pair on two rows. The desktop draws no hint line (tried 04.10.2026).
264  on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
265    const v = view(Date.now())
266    if (!v || e.surface !== 'terminal') return next(e)
267    const fit = fitTerminal(e.viewport?.columns ?? 100, e.props.hint.length, v)
268    if (!fit) return next(e)
269    const engineLine = await next(e)
270    const kit = $.ui.resolve(e)
271    return (
272      <kit.Box flexDirection="row" columnGap={2}>
273        {engineLine}
274        {drawTimer(kit, v, fit)}
275      </kit.Box>
276    )
277  })
278
279  // Desktop only: the slot left of the model picker, the engine's mode labels first.
280  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
281    const v = view(Date.now())
282    if (!v || e.surface !== 'desktop') return next(e)
283    const kit = $.ui.resolve(e)
284    const modes = e.props.modes
285    return (
286      <kit.Box flexDirection="row" columnGap={1}>
287        {modes.length > 0 ? <kit.Text dimColor>{`${modes.join(' & ')} ·`}</kit.Text> : null}
288        {drawTimer(kit, v, DESKTOP_FIT)}
289      </kit.Box>
290    )
291  })
292}
293
294type View = NonNullable<ReturnType<typeof view>>
295type Kit = ReturnType<EngineInterface['ui']['resolve']>
296
297// the desktop draws ━ about twice as wide as a letter and cuts the slot at
298// about 10 of them plus a word: 5 keep the time and percent whole
299const DESKTOP_FIT: Fit = { bar: 5, hit: true }
300
301// after the time: the percent, or a rebuild's cause in its place
302const tailOf = (v: View) => (v.reset ? `· ${WORDS[lang].reset[v.reset]}` : `· ${v.hit}%`)
303
304// the columns left of the terminal row after the hint line and the gap, and what the timer needs besides its bar
305function fitTerminal(columns: number, hintLen: number, v: View): Fit | undefined {
306  const words = WORDS[lang]
307  const head = v.stage === 'cold' ? words.cold : v.rewrite !== undefined ? words.other : fmtLeft(v.left, lang)
308  // cold and switched lines truncate their own tail; a live one makes room for the percent or the cause
309  const tail = v.stage === 'cold' || v.rewrite !== undefined ? undefined : tailOf(v).length + 1
310  return fitBar(columns - hintLen - 2, words.cache.length + 2 + head.length, BAR, tail)
311}
312
313/**
314 * label, bar, time and percent (or a rebuild's cause); once cold an empty dim
315 * bar and what the next turn rewrites; after a model switch an empty bar and
316 * what the other model will write.
317 */
318function drawTimer({ Box, Text }: Kit, v: View, fit: Fit) {
319  const w = WORDS[lang]
320  if (v.stage === 'cold') {
321    const tail = v.size >= COMPACT_AT ? ` · ${w.compact}` : ''
322    return (
323      <Box flexDirection="row" columnGap={1}>
324        <Text dimColor>{w.cache}</Text>
325        <Text dimColor>{'━'.repeat(fit.bar)}</Text>
326        <Text dimColor wrap="truncate-end">{`${w.cold} · ${w.rewrite(fmtTokens(v.size))}${tail}`}</Text>
327      </Box>
328    )
329  }
330  if (v.rewrite !== undefined) {
331    return (
332      <Box flexDirection="row" columnGap={1}>
333        <Text color={ALERT}>{w.cache}</Text>
334        <Text dimColor>{'━'.repeat(fit.bar)}</Text>
335        <Text color={ALERT} wrap="truncate-end">{`${w.other} · ${w.rewrite(fmtTokens(v.rewrite))}`}</Text>
336      </Box>
337    )
338  }
339  const color = STAGE_COLOR[v.stage]
340  const filled = filledCells(v.left, ttl, fit.bar)
341  return (
342    <Box flexDirection="row" columnGap={1}>
343      <Text color={color}>{w.cache}</Text>
344      <Box flexDirection="row">
345        {filled > 0 ? <Text color={color}>{'━'.repeat(filled)}</Text> : null}
346        {filled < fit.bar ? <Text dimColor>{'━'.repeat(fit.bar - filled)}</Text> : null}
347      </Box>
348      <Text color={color}>{fmtLeft(v.left, lang)}</Text>
349      {fit.hit ? (
350        v.reset ? (
351          <Text color={ALERT} wrap="truncate-end">
352            {tailOf(v)}
353          </Text>
354        ) : (
355          <Text dimColor>{tailOf(v)}</Text>
356        )
357      ) : null}
358    </Box>
359  )
360}
361
hooks/cache.ts 261 lines
1/**
2 * cache.ts: the pure half of cache-timer (no `$`, no engine), so it is tested
3 * without one.
4 *
5 * The lifetime rules (decideTtl, accountOf) and the timing check (observeTtl)
6 * are adapted from prompt-cache-control by claude-code-templates, MIT,
7 * https://github.com/davila7/claude-code-templates. See LICENSE.
8 *
9 * From Anthropic's prompt-caching docs: the cache lives 5 minutes, or 1 hour
10 * when asked for; every read refreshes it for free; the lifetime counts from
11 * the START of the request that wrote or read it. A prompt is input_tokens
12 * (uncached) + cache_read_input_tokens + cache_creation_input_tokens.
13 */
14
15export type Ttl = '5m' | '1h'
16export type Account = 'subscription' | 'credits' | 'other'
17
18export type CacheEnv = {
19  enable1h?: string
20  force5m?: string
21  /** CLAUDE_CODE_PROMPT_CACHE_TTL */
22  ttlVar?: string
23}
24
25/** One main-loop request as the API reported it. */
26export type Sample = {
27  model: string
28  /** the effort the request asked for; absent on a model without one */
29  effort?: string | number
30  /** ms since the epoch when the request started */
31  startedAt: number
32  read: number
33  write: number
34  fresh: number
35}
36
37const isOn = (v: string | undefined) => v === '1' || v?.toLowerCase() === 'true'
38const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
39
40/**
41 * The lifetime Claude Code asks for on the main conversation, first match wins
42 * (code.claude.com/docs/en/prompt-caching): the mod's own option,
43 * FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl
44 * setting, ENABLE_PROMPT_CACHING_1H, then the account: 1 hour on a
45 * subscription within plan usage, 5 minutes otherwise.
46 */
47export function decideTtl(option: unknown, env: CacheEnv, setting?: unknown, account?: Account): Ttl {
48  return (
49    asTtl(option) ??
50    (isOn(env.force5m) ? '5m' : undefined) ??
51    asTtl(env.ttlVar) ??
52    asTtl(setting) ??
53    (isOn(env.enable1h) ? '1h' : undefined) ??
54    (account === 'subscription' ? '1h' : '5m')
55  )
56}
57
58/**
59 * The account from the rate-limit windows of the last response: a five-hour
60 * or seven-day window means a subscription; one at 100% means requests now
61 * draw on usage credits (5-minute cache). No window says nothing.
62 */
63export function accountOf(windows: readonly { kind: string; percentUsed: number }[]): Account {
64  const plan = windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
65  if (plan.length === 0) return 'other'
66  return plan.some(w => w.percentUsed >= 100) ? 'credits' : 'subscription'
67}
68
69/**
70 * What the traffic proved holds for one way of billing: a plan that runs out
71 * onto usage credits (5 minutes) or a new window back onto it starts over.
72 */
73export const keepObserved = (observed: Ttl | undefined, was: Account | undefined, now: Account): Ttl | undefined =>
74  was === undefined || was === 'other' || now === 'other' || was === now ? observed : undefined
75
76export const ttlMs = (ttl: Ttl) => (ttl === '1h' ? 3_600_000 : 300_000)
77export const promptTokens = (s: Sample) => s.read + s.write + s.fresh
78
79/** Share of the prompt the cache served, 0 to 1. */
80export function hitRatio(s: Sample): number {
81  const total = promptTokens(s)
82  return total === 0 ? 0 : s.read / total
83}
84
85/** A request that read and wrote nothing touched no cache entry: nothing to count down. */
86export const touchedCache = (s: Sample) => s.read + s.write > 0
87
88export function remainingMs(s: Sample, ttl: Ttl, now: number): number {
89  return touchedCache(s) ? Math.max(0, s.startedAt + ttlMs(ttl) - now) : 0
90}
91
92// requests are timed from their start; slack keeps a hit landing just inside
93// 5 minutes from reading as proof of the hour
94const SLACK_MS = 10_000
95
96/**
97 * What the traffic says about the lifetime. The mod API passes only token
98 * counts, not the TTL of a write, so: a hit more than 5 minutes after the
99 * previous request proves 1 hour (sticky); a same-model miss 5 to 60 minutes
100 * later, on a prompt that did not shrink, says 5 minutes (a later hit wins).
101 */
102export function observeTtl(prev: Sample | undefined, cur: Sample, known: Ttl | undefined): Ttl | undefined {
103  if (!prev || !touchedCache(prev) || cur.model !== prev.model) return known
104  const gap = cur.startedAt - prev.startedAt
105  const before = promptTokens(prev)
106  if (gap <= ttlMs('5m') + SLACK_MS) return known
107  if (cur.read >= before * 0.5) return '1h'
108  if (known === '1h') return known
109  const lapsed = cur.write > 0 && promptTokens(cur) >= before * 0.7 && gap < ttlMs('1h') + SLACK_MS
110  return lapsed ? '5m' : known
111}
112
113/** Why a cache that should still have been warm was written again. */
114export type Cause = 'model' | 'effort' | 'other'
115
116// a model id without its context tag (`[1m]`), so the engine's and a hook's spellings compare
117export const baseModel = (m: string) => m.replace(/\[.*\]$/, '').toLowerCase()
118
119/**
120 * A rebuild the countdown did not predict, by the rule Claude Code counts its
121 * /usage misses with: the request wrote again more than 5% and at least 2,000
122 * tokens of what the warm cache held; what it did not read but did not write
123 * either was cut away (/rewind). A prompt that shrank (a compaction, cleared
124 * tool results) rebuilds by design, and a lapsed cache already showed as
125 * cold. Each model has its own cache; so, on most models, does each effort
126 * level (Opus 5.5, Sonnet 5.5 and Fable 5.1 keep theirs). Anything else is a
127 * cause the API does not report: fast mode turned on, tools changed, an early
128 * eviction.
129 */
130export function missCause(prev: Sample | undefined, cur: Sample, ttl: Ttl): Cause | undefined {
131  if (!prev || !touchedCache(prev)) return undefined
132  if (cur.startedAt - prev.startedAt >= ttlMs(ttl) - SLACK_MS) return undefined
133  const held = promptTokens(prev)
134  if (promptTokens(cur) < held * 0.7) return undefined
135  const lost = Math.min(held - cur.read, cur.write + cur.fresh)
136  if (lost <= held * 0.05 || lost < 2_000) return undefined
137  if (baseModel(prev.model) !== baseModel(cur.model)) return 'model'
138  return prev.effort !== cur.effort ? 'effort' : 'other'
139}
140
141/**
142 * Colour stage by time left. On a 1-hour cache the steps are 10, 5 and 2
143 * minutes; a 5-minute cache gets the same fractions of its life (50, 25, 10 s).
144 */
145export type Stage = 'calm' | 'ten' | 'five' | 'two' | 'cold'
146
147const STEPS_1H_MS = { ten: 600_000, five: 300_000, two: 120_000 }
148
149export function stageOf(leftMs: number, ttl: Ttl): Stage {
150  if (leftMs <= 0) return 'cold'
151  const k = ttlMs(ttl) / ttlMs('1h')
152  if (leftMs <= STEPS_1H_MS.two * k) return 'two'
153  if (leftMs <= STEPS_1H_MS.five * k) return 'five'
154  if (leftMs <= STEPS_1H_MS.ten * k) return 'ten'
155  return 'calm'
156}
157
158/** Light olive while calm, then khaki, amber, terracotta; the expired line is dimmed instead. */
159export const STAGE_COLOR: Record<Exclude<Stage, 'cold'>, string> = {
160  calm: '#A4AE6B',
161  ten: '#C6B55E',
162  five: '#D79A4C',
163  two: '#CF6A4A',
164}
165
166export type Lang = 'ru' | 'en'
167
168export const WORDS: Record<
169  Lang,
170  {
171    cache: string
172    min: string
173    cold: string
174    other: string
175    rewrite: (t: string) => string
176    reset: Record<Cause, string>
177    compact: string
178    toast: (left: string, t: string) => string
179    rebuilt: (t: string, cause: Cause) => string
180  }
181> = {
182  ru: {
183    cache: 'кэш',
184    min: 'мин',
185    cold: 'остыл',
186    other: 'другая модель',
187    rewrite: t => `перезапишет ${t}`,
188    reset: { model: 'сброс: модель', effort: 'сброс: усилие', other: 'сброс' },
189    compact: '/compact',
190    toast: (left, t) => `кэш остынет через ${left}: любое сообщение продлит его (${t} токенов)`,
191    rebuilt: (t, cause) =>
192      `кэш записан заново (${t} токенов): ${cause === 'model' ? 'модель сменилась сама' : 'причина не видна: быстрый режим, инструменты или сервер'}`,
193  },
194  en: {
195    cache: 'cache',
196    min: 'min',
197    cold: 'expired',
198    other: 'other model',
199    rewrite: t => `rewrites ${t}`,
200    reset: { model: 'rebuilt: model', effort: 'rebuilt: effort', other: 'rebuilt' },
201    compact: '/compact',
202    toast: (left, t) => `cache expires in ${left}: any message refreshes it (${t} tokens)`,
203    rebuilt: (t, cause) =>
204      `cache written again (${t} tokens): ${cause === 'model' ? 'the model changed on its own' : 'no visible cause: fast mode, tools or the server'}`,
205  },
206}
207
208/**
209 * The smallest prompt a desktop notification is worth, from the `notify`
210 * option: 100k by default, any size, or none.
211 */
212export const notifyMin = (option: unknown): number | undefined =>
213  option === 'off' ? undefined : option === 'all' ? 0 : 100_000
214
215/**
216 * A rebuild worth a notification is one nobody asked for: a model the engine
217 * changed by itself (a fallback, a skill's model) or no visible cause. A
218 * /model switch and an effort change were the person's own doing.
219 */
220export const notifyRebuild = (cause: Cause, switchedByUser: boolean) =>
221  cause === 'other' || (cause === 'model' && !switchedByUser)
222
223export const langOf = (option: unknown, envLang: string | undefined): Lang =>
224  option === 'ru' || option === 'en' ? option : envLang?.toLowerCase().startsWith('ru') ? 'ru' : 'en'
225
226/** Whole minutes from 5 minutes up (calm, redraws once a minute), m:ss below. */
227export function fmtLeft(ms: number, lang: Lang): string {
228  const secs = Math.max(0, Math.ceil(ms / 1000))
229  if (secs >= 300) return `${Math.ceil(secs / 60)} ${WORDS[lang].min}`
230  return `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`
231}
232
233export function fmtTokens(n: number): string {
234  if (n < 1000) return String(n)
235  if (n < 1_000_000) return `${Math.round(n / 1000)}k`
236  return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
237}
238
239/** Filled cells for the part of the lifetime left. */
240export const filledCells = (leftMs: number, ttl: Ttl, width: number) =>
241  Math.round(Math.min(1, Math.max(0, leftMs / ttlMs(ttl))) * width)
242
243export type Fit = { bar: number; hit: boolean }
244
245// the dim "· 98%" and the gap before it
246const HIT_COLS = 6
247const MIN_BAR = 4
248
249/**
250 * How the timer fits `free` columns when `fixed` go to its label, time and
251 * gaps: the tail ("· 98%", or a rebuild's cause, `tail` columns with its gap)
252 * goes first, then bar cells down to MIN_BAR; undefined when even that does
253 * not fit.
254 */
255export function fitBar(free: number, fixed: number, max: number, tail = HIT_COLS): Fit | undefined {
256  const withHit = Math.min(max, free - fixed - tail)
257  if (withHit >= MIN_BAR) return { bar: withHit, hit: true }
258  const bare = Math.min(max, free - fixed)
259  return bare >= MIN_BAR ? { bar: bare, hit: false } : undefined
260}
261