SLOPSHOPPER

cache-meter

Band: time left on the warm cache, hit rate, context as weather with its trend over 12 turns, plan limits, compact and keep-warm buttons; stops one send to a…

newbandcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-meter
› 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 › /keepwarm ⎿ cache-meter: keepwarm on for 6h00m, a ping 50m after each idle stretch keeps the cache read, not re-written ⟨Claude Code's own drawing⟩ Cache warm 1h00 left · 🔥 kept warm 6h00 · ping in 50 min · hits 93% · ⛅ context 49% (97k/200k) · plan 5h 31% ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Cache warm 1h00 left · 🔥 kept warm 6h00 · ping in 50 min · hits 93% · ⛅ context 49% Compac (97k/200k) · plan 5h 31%
README

Cache Meter

Band: time left on the warm cache, hit rate, context as weather with its trend over 12 turns, plan limits, compact and keep-warm buttons; stops one send to a cold cache and names its cost (Cache Tax runs inside).

Part of the Mod Box, which bundles it with other mods and one window to switch and configure them all. This is the same mod on its own.

Contains a fork. The cold-send guard and keep-warm are Cache Tax by Karan Bansal (MIT), embedded and modified (guard and auto-warm options, a compact-first hint, token usage); see mods/cache-tax/ and its LICENSE.

Install

In a terminal Claude Code session (2.1.287 or newer):

/plugin install cache-meter --marketplace LupusMalusDeviant/claude-mods

Commands

CommandWhat it does
`/keepwarm <6h\90m\always\off\status>`keep the cache warm
/cache-taxcache costs of this session

Options

Set them in /config (or when installing).

OptionDefaultWhat it does
ttlMinutes6060 on a subscription (Pro/Max), 5 for the API's default cache.
limitsonUsage of the 5-hour and the weekly window.
guardrefuserefuse: stop one send to a cold cache with a large context and name the cost (send again to pay). warn: only show the cost. off.
keepWarmHours3How long the keep-warm button keeps the cache warm (one small request over the cache about every 50 minutes of idle).
autoWarmoffon: after a paid cold write, keep the cache warm for 3 hours by itself (costs tokens).
autoCompactautoSets Claude Code's autoCompactWindow. On 1M-context models Claude only compacts by itself at about 967k tokens, and every turn re-reads the whole context until then. auto = Claude's default.
languageenLanguage of the texts this mod shows and writes.

Token cost

keep-warm requests, only when switched on

License

MIT, see LICENSE.

Source 7 files
hooks/entry.tsx 30 lines
1// Entry of the standalone Cache Meter. The mod hands its display to a band (in the Mod Box the
2// band manager draws it); here this hook, registered first and so the outermost, draws it below
3// whatever else is above the prompt.
4import type { Register } from 'claude-code'
5
6import { register as inner } from '../mods/cache-meter/hooks/register'
7import { openBand, takeBand } from '../mods/settings/hooks/band'
8
9export const register: Register = (on, options) => {
10  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
11    openBand(e)
12    const rest = await next(e)
13    const parts = takeBand(e)
14    if (e.props.hasSurvey || parts.length === 0) return rest
15    const { Box, Text } = $.ui.resolve(e)
16    return (
17      <Box flexDirection="column">
18        {rest}
19        {parts.map(p => (
20          <Box key={`band-${p.id}`} flexDirection="row" paddingX={1}>
21            <Box flexShrink={1}>{p.line as any}</Box>
22            {(p.actions as any[]).flatMap((a, i) => [<Text key={`gap-${i}`}>{'  '}</Text>, a])}
23          </Box>
24        ))}
25      </Box>
26    )
27  })
28  ;(inner as any)(on, options)
29}
30
mods/cache-meter/hooks/register.tsx 318 lines
1// Cache Meter: one line above the prompt (through the Mod Box band manager) with
2// 1. how long the prompt cache stays warm (countdown from the last request),
3// 2. the last turn's hit rate (how much came from the cache),
4// 3. how full the context window is, as weather (sun to thunderstorm), with its trend over the
5//    last 12 turns as a sparkline (idea from token-weather in anthropics/claude-code-playground),
6// 4. the plan's rate-limit windows (5 h / week),
7// 5. keep-warm and the cold-send guard of Cache Tax, which runs embedded here (no band of its own):
8//    a "keep warm" button, the cost of a cold resend, and the guard that stops one cold send.
9// Plus a compact button that does what /compact does.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import { register as cacheTax, taxInfo } from '../../cache-tax/hooks/register'
14import { offer as offerBand } from '../../settings/hooks/band'
15import type { BandPart } from '../../settings/hooks/band'
16import type { MeterInfo } from '../types'
17
18const info = atom({ plugin: 'cache-meter', key: 'info' } as const, {
19  lastAt: 0,
20  hit: null,
21  percent: null,
22  tokens: null,
23  window: null,
24  limits: [],
25  trend: [],
26} as MeterInfo)
27// A tick so the countdown moves without a new turn.
28const tick = atom({ plugin: 'cache-meter', key: 'tick' } as const, 0)
29
30const TICK_MS = 15_000
31const TREND_TURNS = 12
32
33// Display language and options (module level: band() runs from the band manager).
34let lang: 'de' | 'en' = 'en'
35const t = (de: string, en: string) => (lang === 'en' ? en : de)
36let ttlMs = 60 * 60_000
37let showLimits = true
38let keepWarmMs = 3 * 60 * 60_000
39
40const STORE_AUTOCOMPACT = 'autocompact-by-box'
41
42/** Tokens for Claude Code's autoCompactWindow setting, or null for its own default. */
43export function autoCompactTokens(choice: string): number | null {
44  const n = Number(choice.replace(/k$/i, '')) * 1000
45  return Number.isFinite(n) && n >= 100000 && n <= 1000000 ? n : null
46}
47
48// Writes the chosen window into ~/.claude/settings.json (Claude Code watches that file). Only a
49// value the box itself wrote is ever removed again, so a hand-set one stays.
50async function applyAutoCompact($: EngineInterface, choice: string) {
51  const home = ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '').replace(/\\/g, '/')
52  if (!home) return
53  const path = `${home}/.claude/settings.json`
54  const s = JSON.parse(String(await $.fs.read(path))) as Record<string, unknown>
55  const want = autoCompactTokens(choice)
56  const ours = await $.store.get(STORE_AUTOCOMPACT)
57  if (want === null) {
58    if (ours === null || ours === undefined || s.autoCompactWindow !== ours) return
59    delete s.autoCompactWindow
60    await $.store.delete(STORE_AUTOCOMPACT)
61  } else {
62    if (s.autoCompactWindow === want) return
63    s.autoCompactWindow = want
64    await $.store.set(STORE_AUTOCOMPACT, want)
65  }
66  await $.fs.write(path, JSON.stringify(s, null, 2) + '\n')
67}
68
69export const register: Register = (on, options) => {
70  // Band manager: hand this mod’s display to the Mod Box (it draws, orders and places it).
71  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72    const rest = await next(e)
73    if (!e.props.hasSurvey) {
74      const part = await band($, e).catch(() => null)
75      if (part) offerBand(e, part)
76    }
77    return rest
78  })
79
80  ttlMs = (Number(options.ttlMinutes) || 60) * 60_000
81  showLimits = options.limits !== 'off'
82  keepWarmMs = (Number(options.keepWarmHours) || 3) * 60 * 60_000
83  lang = options.language === 'de' ? 'de' : 'en'
84
85  // Cache Tax runs inside: the guard (stop|warn|off) and auto keep-warm after a paid cold write.
86  const guard = options.guard === 'warn' ? 'warn' : options.guard === 'off' ? 'off' : 'refuse'
87  cacheTax(on, { embedded: true, guard, autoWarm: options.autoWarm === 'on' })
88
89  on('session.start', {}, async ($, e, next) => {
90    const r = await next(e)
91    await refreshContext($)
92    await applyAutoCompact($, String(options.autoCompact ?? 'auto')).catch(() => {})
93    $.clock.every(TICK_MS, () => void update($, tick, n => (n ?? 0) + 1).catch(() => {}))
94    return r
95  })
96
97  // Every request of the main loop writes (or reads) the cache: the clock starts here.
98  on('turn.step', {}, async function* ($, e, next) {
99    if (!e.agentId) {
100      const now = await $.clock.now()
101      await update($, info, i => ({ ...i, lastAt: now }))
102    }
103    yield* next(e)
104  })
105
106  on('turn.complete', {}, async ($, e, next) => {
107    const r = await next(e)
108    if (e.agentId) return r
109    const u = e.usage
110    const now = await $.clock.now()
111    let hit: number | null = null
112    if (u) {
113      const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
114      hit = total > 0 ? Math.round((u.cache_read_input_tokens / total) * 100) : null
115    }
116    await update($, info, i => ({ ...i, hit, lastAt: i.lastAt || now }))
117    await refreshContext($)
118    await addTrendPoint($)
119    return r
120  })
121
122  // After a compact the old cache is worthless: clock back, re-read the context.
123  on('session.compact', {}, async ($, e, next) => {
124    const r = await next(e)
125    if (!e.agentId) {
126      await update($, info, i => ({ ...i, lastAt: 0, hit: null }))
127      await refreshContext($)
128      await addTrendPoint($)
129    }
130    return r
131  })
132}
133
134// For the Mod Box band manager (instead of an own AbovePrompt hook).
135export async function band($: EngineInterface, e: any): Promise<BandPart | null> {
136  const i = await read($, info)
137  await read($, tick) // subscribes to the tick
138  const now = await $.clock.now()
139  const tax = taxInfo(now)
140  const { Text, Button } = $.ui.resolve(e)
141
142  const left = i.lastAt ? i.lastAt + ttlMs - now : 0
143  const isCold = !!i.lastAt && left <= 0
144  const cacheColor = !i.lastAt ? undefined : isCold ? 'red' : left < 10 * 60_000 ? 'yellow' : 'green'
145  const cacheWord = !i.lastAt ? '—' : isCold ? t('kalt', 'cold') : `${fmtMin(left)}`
146  const coldCost = isCold && tax?.coldUsd != null && tax.ctx > 0 ? ` ≈ ${fmtUsd(tax.coldUsd)}` : ''
147
148  const cache = (
149    <Text>
150      {'Cache '}
151      <Text color={cacheColor} bold>{!i.lastAt ? '—' : isCold ? t('kalt', 'cold') : 'warm'}</Text>
152      {!i.lastAt ? null : isCold ? (
153        <Text dimColor>{t(` · nächste Nachricht liest alles neu${coldCost}`, ` · next message re-reads everything${coldCost}`)}</Text>
154      ) : (
155        <Text dimColor>{t(` noch ${fmtMin(left)}`, ` ${fmtMin(left)} left`)}</Text>
156      )}
157    </Text>
158  )
159  const warmKept = tax?.isWarmKept ? (
160    <Text>
161      {'  ·  '}
162      <Text color="cyan">{t(`🔥 warm gehalten ${fmtMin(tax.keptLeftMs)}`, `🔥 kept warm ${fmtMin(tax.keptLeftMs)}`)}</Text>
163      {tax.nextPingMs != null ? <Text dimColor>{t(` · Ping in ${fmtMin(tax.nextPingMs)}`, ` · ping in ${fmtMin(tax.nextPingMs)}`)}</Text> : null}
164    </Text>
165  ) : tax?.stopped ? (
166    <Text dimColor>{t(`  ·  Warmhalten gestoppt: ${tax.stopped}`, `  ·  keep-warm stopped: ${tax.stopped}`)}</Text>
167  ) : null
168  const hit = i.hit == null ? null : (
169    <Text>
170      {t('  ·  Treffer ', '  ·  hits ')}
171      <Text color={i.hit >= 80 ? 'green' : i.hit >= 40 ? 'yellow' : 'red'}>{`${i.hit}%`}</Text>
172    </Text>
173  )
174  const pct = i.percent
175  const ctx = pct == null ? null : (
176    <Text>
177      {`  ·  ${weather(pct)} ${t('Kontext', 'context')} `}
178      <Text color={pct < 50 ? 'green' : pct < 75 ? 'yellow' : 'red'} bold>{`${pct}%`}</Text>
179      {i.tokens != null && i.window ? <Text dimColor>{` (${fmtTok(i.tokens)}/${fmtTok(i.window)})`}</Text> : null}
180      {(i.trend ?? []).length >= 2 ? <Text color={pct < 50 ? 'green' : pct < 75 ? 'yellow' : 'red'} dimColor>{` ${sparkline(i.trend)}`}</Text> : null}
181      {trendText(i.trend ?? []) ? <Text dimColor>{` ${trendText(i.trend ?? [])}`}</Text> : null}
182    </Text>
183  )
184  const limits = showLimits && i.limits?.length ? (
185    <Text>
186      {t('  ·  Abo ', '  ·  plan ')}
187      {i.limits.map((l, n) => (
188        <Text key={`cm-l-${n}`}>
189          {n > 0 ? ' ' : ''}
190          <Text dimColor>{`${limitName(l.kind)} `}</Text>
191          <Text color={l.percentUsed < 60 ? 'green' : l.percentUsed < 85 ? 'yellow' : 'red'}>{`${Math.round(l.percentUsed)}%`}</Text>
192        </Text>
193      ))}
194    </Text>
195  ) : null
196
197  const actions: unknown[] = []
198  if (!e.props?.isWorking && (pct ?? 0) >= 30) actions.push(<Button key="cm-compact" label="Compact" plain onPress={() => void compact($)} />)
199  if (tax && !e.props?.isWorking && i.lastAt) {
200    actions.push(
201      tax.isWarmKept ? (
202        <Button key="cm-warm-off" label={t('Warmhalten aus', 'Stop keep-warm')} plain onPress={() => void keepWarm($, 'off')} />
203      ) : (
204        <Button key="cm-warm" label={t(`Warm halten ${fmtMin(keepWarmMs)}`, `Keep warm ${fmtMin(keepWarmMs)}`)} plain onPress={() => void keepWarm($, `${Math.round(keepWarmMs / 3_600_000)}h`)} />
205      ),
206    )
207  }
208
209  const shortWeather = pct == null ? '' : ` ${weather(pct)} ${pct}%`
210  return {
211    id: 'cacheMeter',
212    urgent: false,
213    short: (
214      <Text>
215        <Text color={cacheColor} bold>{`⏱ ${cacheWord}`}</Text>
216        {tax?.isWarmKept ? <Text color="cyan">{' 🔥'}</Text> : null}
217        <Text>{shortWeather}</Text>
218      </Text>
219    ),
220    line: (
221      <Text>
222        {cache}
223        {warmKept}
224        {hit}
225        {ctx}
226        {limits}
227      </Text>
228    ),
229    actions,
230    status: `Cache ${cacheWord}${tax?.isWarmKept ? ' 🔥' : ''}${shortWeather}`,
231  }
232}
233
234// Keep-warm runs in the embedded Cache Tax; its /keepwarm command switches it ($ may not cross an import).
235async function keepWarm($: EngineInterface, args: string) {
236  await $.command.run({ command: 'keepwarm', args }).catch(() => $.ui.toast(t(`Bitte /keepwarm ${args} eingeben.`, `Please type /keepwarm ${args}.`)))
237}
238
239async function refreshContext($: EngineInterface) {
240  const usage = await $.session.usage().catch(() => null)
241  if (!usage) return
242  const { context, rateLimits } = usage
243  await update($, info, i => ({
244    ...i,
245    percent: context.percent ?? null,
246    tokens: context.tokens ?? null,
247    window: context.window || null,
248    limits: (rateLimits ?? []).map(l => ({ kind: l.kind, percentUsed: l.percentUsed, ...(l.resetsAt ? { resetsAt: l.resetsAt } : {}) })),
249  }))
250}
251
252async function addTrendPoint($: EngineInterface) {
253  await update($, info, i => (i.percent == null ? i : { ...i, trend: [...(i.trend ?? []), i.percent].slice(-TREND_TURNS) }))
254}
255
256const BARS = '▁▂▃▄▅▆▇█'
257/** The context fill of the last turns as one bar each, on a fixed 0–100 % scale. */
258export function sparkline(points: number[]): string {
259  return points.map(p => BARS[Math.min(BARS.length - 1, Math.max(0, Math.floor((p / 100) * BARS.length)))]).join('')
260}
261
262/** Average growth per turn over the trend, and how many turns are left at that pace (no compact in between). */
263export function trendText(points: number[]): string {
264  const steps = points.slice(1).map((p, n) => p - points[n]!).filter(d => d >= 0)
265  if (steps.length < 2) return ''
266  const per = steps.reduce((a, b) => a + b, 0) / steps.length
267  if (per < 0.5) return t('· stabil', '· steady')
268  const left = Math.floor((100 - points[points.length - 1]!) / per)
269  return t(`· +${per.toFixed(1)}%/Runde, voll in ~${left}`, `· +${per.toFixed(1)}%/turn, full in ~${left}`)
270}
271
272// five_hour -> 5h, seven_day -> week, everything else stays readable.
273export function limitName(kind: string): string {
274  if (/five_hour|5h/i.test(kind)) return '5h'
275  if (/seven_day_opus/i.test(kind)) return t('Woche Opus', 'week Opus')
276  if (/seven_day_sonnet/i.test(kind)) return t('Woche Sonnet', 'week Sonnet')
277  if (/seven_day|week/i.test(kind)) return t('Woche', 'week')
278  return kind.replace(/_/g, ' ')
279}
280
281async function compact($: EngineInterface) {
282  $.ui.toast(t('Sitzung wird komprimiert …', 'Compacting the session …'))
283  try {
284    const before = (await read($, info)).tokens
285    const r = await $.session.compact()
286    if ('skip' in r && r.skip) {
287      $.ui.toast(t('Compact wurde von einem Hook abgelehnt.', 'A hook refused the compact.'))
288      return
289    }
290    await refreshContext($)
291    const after = (await read($, info)).tokens
292    $.ui.toast(
293      before != null && after != null
294        ? t(`Komprimiert: ${fmtTok(Math.max(0, before - after))} Token gespart.`, `Compacted: ${fmtTok(Math.max(0, before - after))} tokens saved.`)
295        : t('Komprimiert.', 'Compacted.'),
296    )
297  } catch {
298    $.ui.toast(t('Compact geht nur zwischen zwei Runden.', 'Compact only works between turns.'))
299  }
300}
301
302export function weather(pct: number): string {
303  return pct < 40 ? '☀' : pct < 60 ? '⛅' : pct < 80 ? '🌧' : '⛈'
304}
305
306export function fmtMin(ms: number): string {
307  const m = Math.max(0, Math.ceil(ms / 60_000))
308  return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m} min`
309}
310
311function fmtTok(n: number): string {
312  return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
313}
314
315function fmtUsd(usd: number): string {
316  return '$' + (usd >= 100 ? usd.toFixed(0) : usd.toFixed(2))
317}
318
mods/settings/hooks/band.ts 44 lines
1// The band manager's mailbox. A band mod computes its display in its own AbovePrompt hook (with its
2// own $; the validator does not let $ cross an import) and drops it here with offer(). The settings
3// hook, registered first and so the outermost, runs after all of them, takes the parts and draws
4// them ordered, compact or one line each, or sends them to the status line.
5export type BandPart = {
6  /** The mod's switch key, e.g. "cacheMeter". */
7  id: string
8  /** Wants attention (CI red, sign offer …): always its own line with its buttons. */
9  urgent: boolean
10  /** One short element for the compact line. */
11  short: unknown
12  /** The full element for its own line. */
13  line: unknown
14  /** Buttons that belong to the part. */
15  actions: unknown[]
16  /** Plain text for the status line. */
17  status: string
18}
19
20const parts = new Map<string, BandPart[]>()
21
22function keyOf(e: { requestId?: string }): string {
23  return e.requestId ?? 'band'
24}
25
26/** Called by the manager before the inner hooks run. */
27export function openBand(e: { requestId?: string }) {
28  parts.set(keyOf(e), [])
29}
30
31/** Called by a band mod with its display for this render. */
32export function offer(e: { requestId?: string }, part: BandPart) {
33  const list = parts.get(keyOf(e))
34  if (list) list.push(part)
35  else parts.set(keyOf(e), [part])
36}
37
38/** Called by the manager after the inner hooks ran. */
39export function takeBand(e: { requestId?: string }): BandPart[] {
40  const list = parts.get(keyOf(e)) ?? []
41  parts.delete(keyOf(e))
42  return list
43}
44
mods/cache-tax/hooks/register.ts 602 lines
1// Modified for mod-box (https://github.com/LupusMalusDeviant/mod-box): embedded into Cache Meter (embed options, exported helpers, guard/auto-warm options, token usage), state/matchers rewritten.
2// Original: https://github.com/karanb192/cache-tax (commit 06cac47).
3import type { EngineInterface, Register } from 'claude-code'
4import { statusIcons } from './status-icons'
5import { update } from 'claude-code'
6import { addUsage, USAGE_STORE } from '../../settings/hooks/usage'
7import type { UsageBook } from '../../settings/hooks/usage'
8
9const TTL_MS = 60 * 60 * 1000
10const PING_AFTER_MS = 50 * 60 * 1000
11const MIN_PING_MS = 60 * 1000
12const AUTO_WARM_MS = 3 * 60 * 60 * 1000
13const DEFAULT_WINDOW_MS = 6 * 60 * 60 * 1000
14const BIG_TOKENS = 50000
15const PING_PROMPT = 'Reply with the single word: warm'
16const KEY_DEADLINE = 'deadline'
17const KEY_EVERY = 'every'
18const KEY_GUARD = 'guard'
19const KEY_ALWAYS = 'always'
20
21// $ per million tokens, [cache read, 1h cache write, output], list prices September 2026.
22// Longer family names first: a model id matches the first row it contains.
23const PRICES: Array<[string, number, number, number]> = [
24  ['fable-5-1', 0.25, 20, 50],
25  ['fable-5', 1, 20, 50],
26  ['opus-5', 0.5, 10, 25],
27  ['opus-4', 0.5, 10, 25],
28  ['sonnet-5', 0.2, 4, 10],
29  ['sonnet', 0.3, 6, 15],
30  ['haiku', 0.1, 2, 5],
31]
32
33type PingRecord = { at: number; read: number; write: number; usd: number | null; warm: boolean }
34type Miss = { at: number; tokens: number; usd: number | null }
35type GuardMode = 'refuse' | 'warn'
36
37export type State = {
38  hasBand: boolean
39  sid: string
40  deadline: number
41  every: number
42  always: boolean
43  lastRequestAt: number
44  lastModel: string | null
45  ctx: number
46  compacted: boolean
47  guard: GuardMode
48  ackedAt: number
49  coldWritePending: boolean
50  misses: Miss[]
51  pending: { cancel: () => void } | null
52  last: PingRecord | null
53  stopped: string | null
54}
55
56function priceOf(model: string | null): [number, number, number] | null {
57  const m = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
58  for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
59  return null
60}
61
62export function parseDuration(text: string): number | null {
63  const m = /^(?:(\d+)h)?(?:(\d+)m)?$/.exec(text.trim())
64  if (!m || (m[1] === undefined && m[2] === undefined)) return null
65  return (Number(m[1] ?? 0) * 60 + Number(m[2] ?? 0)) * 60 * 1000
66}
67
68export function fmtDuration(ms: number): string {
69  const total = Math.max(0, Math.round(ms / 60000))
70  const h = Math.floor(total / 60)
71  const m = total % 60
72  if (h >= 48) return `${Math.floor(h / 24)}d ${h % 24}h`
73  return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
74}
75
76function fmtUsd(usd: number | null): string {
77  return usd == null ? 'n/a' : '$' + (usd >= 100 ? usd.toFixed(0) : usd.toFixed(2))
78}
79
80function fmtTok(n: number): string {
81  return n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1000 ? Math.round(n / 1000) + 'k' : String(n)
82}
83
84function coldUsd(s: State): number | null {
85  const price = priceOf(s.lastModel)
86  return price ? s.ctx * price[1] / 1e6 : null
87}
88
89function warmUsd(s: State): number | null {
90  const price = priceOf(s.lastModel)
91  return price ? s.ctx * price[0] / 1e6 : null
92}
93
94/** Everything a fork bills: the cache read, any cache write, uncached input at the base rate (half the 1h write rate), and the output. */
95function pingUsd(u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }, price: [number, number, number]): number {
96  return (u.cache_read_input_tokens * price[0] + u.cache_creation_input_tokens * price[1] + u.input_tokens * price[1] / 2 + u.output_tokens * price[2]) / 1e6
97}
98
99/** The read-only upper bound: pings at the cache-read rate that cost as much as one cold write of the same context. */
100function breakEvenPings(s: State): number | null {
101  const price = priceOf(s.lastModel)
102  if (!price || s.ctx <= 0) return null
103  return Math.floor(price[1] / price[0])
104}
105
106function isCold(s: State, now: number): boolean {
107  return s.lastRequestAt > 0 && !s.compacted && now - s.lastRequestAt >= TTL_MS
108}
109
110function guardText(s: State, now: number): string {
111  const price = priceOf(s.lastModel)
112  const rate = price ? `$${price[1]}/MTok` : 'the cache-write rate'
113  const warm = warmUsd(s)
114  return `the prompt cache went cold ${fmtDuration(now - s.lastRequestAt - TTL_MS)} ago. Sending this re-writes ` +
115    `up to ${s.ctx.toLocaleString('en-US')} tokens at ${rate} = ${fmtUsd(coldUsd(s))}` +
116    (warm == null ? '' : ` (a warm turn would have cost ${fmtUsd(warm)})`) + '.'
117}
118
119// Mod Box: the third way out of a cold send. Compacting cold writes about as much now, but every
120// later turn then carries a small context instead of the whole one.
121const COMPACT_AFTER = 20000
122const COMPACT_OUTPUT = 4000
123
124export function compactHint(ctx: number, model: string | null): string {
125  const price = priceOf(model)
126  if (!price || ctx < BIG_TOKENS) return ''
127  const now = (ctx * price[1] + COMPACT_OUTPUT * price[2]) / 1e6
128  const perTurn = ((ctx - COMPACT_AFTER) * price[0]) / 1e6
129  return ` Or /compact first: about ${fmtUsd(now)} now, then each later turn reads about ${fmtTok(COMPACT_AFTER)} instead of ${fmtTok(ctx)} tokens (about ${fmtUsd(perTurn)} less per turn; compaction loses detail).`
130}
131
132export type ResumeFields = {
133  source: string
134  model?: string
135  context_tokens?: number
136  seconds_since_last_response?: number
137  prompt_cache_likely_expired?: boolean
138  estimated_cache_write_usd?: number
139}
140
141/** /clear starts a new conversation in the same process; nothing priced before it still exists. */
142export function resetForClear(s: State) {
143  s.ctx = 0
144  s.lastRequestAt = 0
145  s.compacted = false
146  s.ackedAt = 0
147  s.coldWritePending = false
148  s.misses = []
149  disarm(s)
150}
151
152/** Applies a resumed session's fields to the state; returns the line to log, if any. */
153export function seedFromResume(s: State, e: ResumeFields, now: number): string | null {
154  if (e.source !== 'resume' && e.source !== 'fork') return null
155  if (typeof e.context_tokens === 'number' && e.context_tokens > 0) s.ctx = e.context_tokens
156  if (typeof e.seconds_since_last_response === 'number') s.lastRequestAt = now - e.seconds_since_last_response * 1000
157  if (typeof e.model === 'string') s.lastModel = e.model
158  s.compacted = false
159  if (e.prompt_cache_likely_expired !== true || s.ctx < BIG_TOKENS) return null
160  const usd = typeof e.estimated_cache_write_usd === 'number' ? fmtUsd(e.estimated_cache_write_usd) : fmtUsd(coldUsd(s))
161  return `resuming cold. The first message re-writes ${s.ctx.toLocaleString('en-US')} tokens, about ${usd}. /clear and paste a summary if you only need the conclusions.`
162}
163
164function statusText(s: State, now: number): string | undefined {
165  if (s.stopped) return `keepwarm stopped: ${s.stopped}`
166  if (!s.deadline) return undefined
167  const pingText = s.last ? ` · last ping read ${fmtTok(s.last.read)} ${fmtUsd(s.last.usd)}` : ''
168  const nextText = !s.lastRequestAt ? ' · waiting for the first turn'
169    : s.compacted ? ' · waiting for the first turn after compaction'
170    : isCold(s, now) ? ` · cold now, first ping ${fmtDuration(s.every)} after the next turn`
171    : ` · ping in ${fmtDuration(s.lastRequestAt + s.every - now)}`
172  return `keepwarm ${fmtDuration(s.deadline - now)} left${nextText}${pingText}`
173}
174
175function updateStatus($: EngineInterface, s: State, now: number) {
176  if (s.hasBand) $.ui.invalidate('ui.render')
177  else $.ui.status(statusText(s, now))
178}
179
180function disarm(s: State) {
181  if (s.pending) s.pending.cancel()
182  s.pending = null
183}
184
185// The window and its ping period belong to the session that armed them, so a
186// second session, or one resumed from another transcript, never inherits them
187// and cannot turn them off. The always switch and the guard mode stay global.
188function deadlineKey(s: State): string {
189  return `${KEY_DEADLINE}:${s.sid}`
190}
191
192function everyKey(s: State): string {
193  return `${KEY_EVERY}:${s.sid}`
194}
195
196/** Clears this session's own dead window and the bare keys a store written before 2.1.1 still holds. Other sessions' keys are never touched: a read followed by a delete cannot be made atomic against their renewal. */
197async function prune($: EngineInterface, s: State, now: number) {
198  for (const key of [KEY_DEADLINE, deadlineKey(s)]) {
199    const deadline = await $.store.get(key)
200    if (deadline === undefined) continue
201    if (typeof deadline === 'number' && deadline > now) continue
202    await $.store.delete(key)
203    await $.store.delete(KEY_EVERY + key.slice(KEY_DEADLINE.length))
204  }
205}
206
207async function stop($: EngineInterface, s: State, why: string | null, forgetAlways = false) {
208  s.deadline = 0
209  s.every = PING_AFTER_MS
210  s.stopped = why
211  disarm(s)
212  await $.store.delete(deadlineKey(s))
213  await $.store.delete(everyKey(s))
214  if (forgetAlways) {
215    s.always = false
216    await $.store.delete(KEY_ALWAYS)
217  }
218  updateStatus($, s, await $.clock.now())
219}
220
221async function arm($: EngineInterface, s: State) {
222  disarm(s)
223  if (!s.deadline) return
224  const now = await $.clock.now()
225  if (now >= s.deadline) return stop($, s, null)
226  // A cold window still needs expiry cleanup, but must not send a model request.
227  if (s.lastRequestAt && !s.compacted && !isCold(s, now)) {
228    const untilCold = s.lastRequestAt + TTL_MS - now
229    const delay = Math.min(s.deadline - now, untilCold, Math.max(1000, s.lastRequestAt + s.every - now))
230    s.pending = $.clock.after(delay, () => { void ping($, s) })
231  } else {
232    s.pending = $.clock.after(s.deadline - now, () => { void arm($, s) })
233  }
234  updateStatus($, s, now)
235}
236
237// Mod Box: token usage of this mod's own model calls, for the window (session in $.state,
238// total in $.store). $ stays in this file; usage.ts only does the arithmetic.
239const USAGE = { plugin: 'cache-meter', key: 'usage' } as const
240async function recordUsage($: EngineInterface, model: string, u: Parameters<typeof addUsage>[3] | undefined) {
241  if (!u) return
242  await update($, USAGE, b => addUsage(b, 'cacheMeter', model, u)).catch(() => {})
243  const total = await $.store.get(USAGE_STORE).catch(() => null)
244  await $.store.set(USAGE_STORE, addUsage(total as UsageBook | null, 'cacheMeter', model, u)).catch(() => {})
245}
246
247async function ping($: EngineInterface, s: State) {
248  s.pending = null
249  if (!s.deadline) return
250  const now = await $.clock.now()
251  if (now >= s.deadline) return arm($, s)
252  // A turn in the meantime re-armed the timer; this callback is stale.
253  if (isCold(s, now)) return arm($, s)
254  if (now - s.lastRequestAt < s.every - 1000) return
255  let reply
256  try {
257    reply = await $.model.fork({ prompt: PING_PROMPT })
258  } catch (err) {
259    return stop($, s, `the ping failed, ${err instanceof Error ? err.message : String(err)}`)
260  }
261  if (reply === null) return stop($, s, 'the engine did not send the ping, either the snapshot was cold or the API call failed')
262  if (reply.isAnswered === false) {
263    const reason = reply.reason === 'nothing-to-fork' ? 'no conversation to warm yet'
264      : reply.reason === 'api-error' ? `the API call failed${reply.status === null ? '' : ` (${reply.status})`}`
265      : reply.reason === 'aborted' ? 'the ping was interrupted'
266      : 'the ping returned no text'
267    return stop($, s, reason)
268  }
269  const u = reply.usage
270  await recordUsage($, s.lastModel ?? '', u)
271  const price = priceOf(s.lastModel)
272  // A warm ping reads the prefix and writes only its own message; a write of a tenth of the read or more means the prefix broke.
273  const warm = u.cache_read_input_tokens > 0 && u.cache_creation_input_tokens < 0.1 * u.cache_read_input_tokens
274  const usd = price ? pingUsd(u, price) : null
275  s.last = { at: now, read: u.cache_read_input_tokens, write: u.cache_creation_input_tokens, usd, warm }
276  if (!warm) return stop($, s, `the ping read ${fmtTok(u.cache_read_input_tokens)} and wrote ${fmtTok(u.cache_creation_input_tokens)} tokens (${fmtUsd(usd)}), the cache was already gone`)
277  s.lastRequestAt = now
278  await arm($, s)
279}
280
281/** The reply to an arming command; on a cold cache it says when the first ping can come. */
282function armedText(s: State, now: number, windowMs: number): string {
283  if (isCold(s, now)) return `keepwarm on for ${fmtDuration(windowMs)}. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn`
284  return `keepwarm on for ${fmtDuration(windowMs)}, a ping ${fmtDuration(s.every)} after each idle stretch keeps the cache read, not re-written`
285}
286
287async function startWindow($: EngineInterface, s: State, windowMs: number, every: number) {
288  const now = await $.clock.now()
289  s.every = every
290  if (every === PING_AFTER_MS) await $.store.delete(everyKey(s))
291  else await $.store.set(everyKey(s), every)
292  s.deadline = now + windowMs
293  s.stopped = null
294  await $.store.set(deadlineKey(s), s.deadline)
295  await arm($, s)
296}
297
298function card(s: State, now: number): string {
299  const lines: string[] = []
300  lines.push(`${s.lastModel ?? 'model not seen yet'}`)
301  if (s.compacted) lines.push('state       reset by compaction, waiting for the first turn')
302  else if (!s.lastRequestAt) lines.push('state       no request yet this session')
303  else if (isCold(s, now)) lines.push(`state       COLD, last request ${fmtDuration(now - s.lastRequestAt)} ago`)
304  else lines.push(`state       warm, ${fmtDuration(s.lastRequestAt + TTL_MS - now)} left`)
305  lines.push(`context     ${s.ctx.toLocaleString('en-US')} tokens`)
306  lines.push(`cold cost   ${fmtUsd(coldUsd(s))} to re-write it (warm turn ${fmtUsd(warmUsd(s))})`)
307  const always = s.always ? ' (always)' : ''
308  const idle = s.always ? 'off until the next session start, which arms 6h00m (always)' : 'off (/keepwarm to arm it for 6h00m)'
309  lines.push(`keepwarm    ${s.deadline ? (statusText(s, now) ?? '').replace(/^keepwarm /, 'on, ') + always : s.stopped ? `stopped, ${s.stopped}${always}` : idle}`)
310  const pings = breakEvenPings(s)
311  if (pings != null) lines.push(`break-even  up to ${pings} pings at the read rate cost one cold write, about ${fmtDuration(pings * s.every)} of idle at one ping per ${fmtDuration(s.every)}`)
312  lines.push(`guard       ${s.guard === 'refuse' ? 'refuse once (/cache-tax guard warn to only show the price)' : 'warn only (/cache-tax guard refuse to be stopped once)'}`)
313  const paid = s.misses.reduce((a, m) => a + (m.usd ?? 0), 0)
314  lines.push(`session     ${s.misses.length} cold write${s.misses.length === 1 ? '' : 's'} paid, ${fmtUsd(paid)}`)
315  return lines.join('\n')
316}
317
318export function freshState(): State {
319  return {
320    hasBand: false, sid: '', deadline: 0, every: PING_AFTER_MS, always: false, lastRequestAt: 0, lastModel: null, ctx: 0, compacted: false,
321    guard: 'refuse', ackedAt: 0, coldWritePending: false, misses: [], pending: null, last: null, stopped: null,
322  }
323}
324
325// Mod Box: embedded in the Cache Meter. The Cache Meter shows the state (no band of its own),
326// sets the guard and whether to keep warm automatically after a paid cold write.
327type Embed = { embedded: boolean; guard: GuardMode | 'off' | null; autoWarm: boolean }
328let current: State | null = null
329let embed: Embed = { embedded: false, guard: null, autoWarm: true }
330
331export type TaxInfo = {
332  isWarmKept: boolean
333  keptLeftMs: number
334  nextPingMs: number | null
335  stopped: string | null
336  coldUsd: number | null
337  ctx: number
338  guard: GuardMode | 'off'
339  last: { read: number; usd: number | null } | null
340}
341
342/** State for the Cache Meter, null while Cache Tax is not loaded. */
343export function taxInfo(now: number): TaxInfo | null {
344  const s = current
345  if (!s) return null
346  const kept = s.deadline > now
347  return {
348    isWarmKept: kept,
349    keptLeftMs: kept ? s.deadline - now : 0,
350    nextPingMs: kept && s.lastRequestAt && !isCold(s, now) ? Math.max(0, s.lastRequestAt + s.every - now) : null,
351    stopped: s.stopped,
352    coldUsd: coldUsd(s),
353    ctx: s.ctx,
354    guard: embed.guard === 'off' ? 'off' : s.guard,
355    last: s.last ? { read: s.last.read, usd: s.last.usd } : null,
356  }
357}
358
359/** Start keeping warm for windowMs (like /keepwarm 3h). */
360export async function startKeepWarm($: EngineInterface, windowMs: number) {
361  if (current) await startWindow($, current, windowMs, PING_AFTER_MS)
362}
363
364/** Stop keeping warm (like /keepwarm off). */
365export async function stopKeepWarm($: EngineInterface) {
366  if (current) await stop($, current, null)
367}
368
369export const register: Register = (on, options) => {
370  const s = freshState()
371  current = s
372  embed = {
373    embedded: options?.embedded === true,
374    guard: options?.guard === 'off' ? 'off' : options?.guard === 'warn' ? 'warn' : options?.guard === 'refuse' ? 'refuse' : null,
375    autoWarm: options?.autoWarm !== false,
376  }
377  let light = false
378  let noColor = false
379
380  on('config.set', { key: 'theme' }, async ($, e, next) => {
381    const result = await next(e)
382    if (result.deny === undefined) {
383      light = typeof result.value === 'string' && result.value.startsWith('light')
384      if (s.hasBand) $.ui.invalidate('ui.render')
385    }
386    return result
387  })
388
389  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
390    const rest = await next(e)
391    if (embed.embedded || !s.hasBand || e.props.hasSurvey) return rest
392    const now = await $.clock.now()
393    const text = statusText(s, now)
394    if (!text || (s.deadline && now >= s.deadline)) return rest
395    const state = s.stopped ? 'stopped' : !s.lastRequestAt || s.compacted ? 'unknown' : isCold(s, now) ? 'cold' : 'warm'
396    const color = noColor ? undefined : state === 'warm' ? 'success' : state === 'cold' ? (light ? '#c15f3c' : '#d97757') : undefined
397    const { Box, Text } = $.ui.resolve(e)
398    const asset = statusIcons[light ? 'light' : 'dark'][state === 'warm' || state === 'cold' ? state : 'neutral']
399    const icon = noColor ? Text({ bold: true, children: ['[>]'] })
400      : e.surface === 'terminal' ? $.ui.resolve(e).Image({ source: { png: asset.png }, columns: 3, rows: 1, alt: '[>]' })
401      : e.surface === 'desktop' ? $.ui.resolve(e).Svg({ source: asset.svg, width: 18, height: 18, alt: 'Cache Tax' })
402      : Text({ color, bold: true, children: ['[>]'] })
403    return Box({ flexDirection: 'column', children: [
404      rest,
405      Box({ flexDirection: 'row', alignItems: 'center', children: [
406        Box({ flexShrink: 0, children: [icon] }),
407        Text({ children: [
408          ' cache-tax · ',
409          Text({ color, bold: true, children: [state] }),
410          ` · ${text}`,
411        ] }),
412      ] }),
413    ] })
414  })
415
416  on('session.start', {}, async ($, e, next) => {
417    const r = await next(e)
418    s.hasBand = e.surface === 'terminal' || e.surface === 'desktop'
419    if (s.hasBand) {
420      const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
421      light = typeof theme === 'string' && theme.startsWith('light')
422      noColor = Boolean(await $.env.get('NO_COLOR'))
423      $.ui.status(undefined)
424    }
425    s.sid = await $.session.id()
426    const now = await $.clock.now()
427    await prune($, s, now)
428    const saved = await $.store.get(deadlineKey(s))
429    const savedEvery = await $.store.get(everyKey(s))
430    const savedGuard = await $.store.get(KEY_GUARD)
431    s.deadline = typeof saved === 'number' && saved > now ? saved : 0
432    s.every = typeof savedEvery === 'number' && savedEvery >= MIN_PING_MS ? savedEvery : PING_AFTER_MS
433    s.guard = savedGuard === 'warn' ? 'warn' : 'refuse'
434    if (embed.guard === 'warn' || embed.guard === 'refuse') s.guard = embed.guard
435    s.always = (await $.store.get(KEY_ALWAYS)) === true
436    // Always means a fresh default window every session, whatever the last one left behind.
437    if (s.always) await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
438    const usage = await $.session.usage()
439    if (usage.context.tokens) s.ctx = usage.context.tokens
440    await $.command.register({
441      name: 'keepwarm',
442      description: 'Keep the prompt cache warm: bare for 6h, a window such as 90m, always, off, or status (cache-tax)',
443      argumentHint: '[6h | always | off | status]',
444      immediate: true,
445    })
446    await $.command.register({
447      name: 'cache-tax',
448      description: 'Prompt cache state, cold price and this session\'s cold writes; guard warn|refuse (cache-tax, the Mod)',
449      argumentHint: '[status | guard warn | guard refuse]',
450      immediate: true,
451    })
452    // The hook form of cache-tax ships a /cache-tax:status skill; both installed means two guards.
453    const commands = await $.command.list()
454    if (commands.some(c => c.name === 'cache-tax:status')) {
455      $.ui.log('the hook form (cache-tax@claude-code-hooks) is also installed, so a cold send is warned about or refused twice. Uninstall it, or /cache-tax guard warn here.')
456    }
457    updateStatus($, s, now)
458    return r
459  })
460
461  // Resume fields seed the guard before any turn of the resumed session has run.
462  on('classic.SessionStart', {}, async ($, e, next) => {
463    const r = await next(e)
464    if (e.source === 'clear') {
465      await stop($, s, null)
466      s.stopped = null
467      resetForClear(s)
468      s.sid = await $.session.id()
469      updateStatus($, s, await $.clock.now())
470      return r
471    }
472    const line = seedFromResume(s, e, await $.clock.now())
473    // The resume payload may omit the model; without it the guard cannot price the cold write.
474    if (!s.lastModel) s.lastModel = await $.session.model()
475    if (line) $.ui.log(line)
476    // The seeded clock decides whether a restored or always window pings before the first turn: never when it is cold.
477    await arm($, s)
478    return r
479  })
480
481  on('command.run', { command: 'keepwarm' }, async ($, e) => {
482    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
483    const now = await $.clock.now()
484    if (words[0] === 'off') {
485      const wasAlways = s.always
486      await stop($, s, null, true)
487      return { text: wasAlways ? 'keepwarm is off, and no longer arms itself at session start' : 'keepwarm is off' }
488    }
489    if (words[0] === 'always') {
490      s.always = true
491      await $.store.set(KEY_ALWAYS, true)
492      await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
493      const cold = isCold(s, now) ? `. The cache is cold now, so the first ping comes ${fmtDuration(s.every)} after the next turn` : ''
494      return { text: `keepwarm always on: every session starts with a ${fmtDuration(DEFAULT_WINDOW_MS)} window; /keepwarm off turns it off for good${cold}` }
495    }
496    if (!words.length) {
497      await startWindow($, s, DEFAULT_WINDOW_MS, PING_AFTER_MS)
498      return { text: armedText(s, now, DEFAULT_WINDOW_MS) }
499    }
500    if (words[0] !== 'status') {
501      const window = parseDuration(words[0]!)
502      if (window == null) return { text: 'keepwarm takes a window such as 6h or 90m, or always, off, or status' }
503      // "every 2m" is a testing knob and lasts only for the window it was given with.
504      let every = PING_AFTER_MS
505      if (words[1] === 'every') {
506        const period = parseDuration(words[2] ?? '')
507        if (period == null || period < MIN_PING_MS) return { text: 'every takes a period of at least 1m' }
508        every = period
509      }
510      await startWindow($, s, window, every)
511      return { text: armedText(s, now, window) }
512    }
513    return { text: statusText(s, now) ?? 'keepwarm is off' }
514  })
515
516  on('command.run', { command: 'cache-tax' }, async ($, e) => {
517    const words = String(e.args ?? '').trim().split(/\s+/).filter(Boolean)
518    const now = await $.clock.now()
519    if (words[0] === 'guard') {
520      if (words[1] !== 'warn' && words[1] !== 'refuse') return { text: '/cache-tax guard takes warn or refuse' }
521      s.guard = words[1]
522      await $.store.set(KEY_GUARD, s.guard)
523      return { text: s.guard === 'refuse' ? 'guard set to refuse once: a cold send is dropped with its price, the resend goes through' : 'guard set to warn: a cold send goes through with its price shown' }
524    }
525    return { text: card(s, now) }
526  })
527
528  // The message that pays. Only its first character is read.
529  on('prompt.submit', {}, async ($, e, next) => {
530    if (embed.guard === 'off') return next(e)
531    if (e.origin.kind === 'plugin') return next(e)
532    if (typeof e.text !== 'string' || e.text.trimStart().startsWith('/')) return next(e)
533    const now = await $.clock.now()
534    if (!isCold(s, now) || s.ctx < BIG_TOKENS) return next(e)
535    if (s.guard === 'warn') {
536      $.ui.log(`${guardText(s, now)} Sending anyway; keepwarm will hold the cache for ${fmtDuration(AUTO_WARM_MS)} once it lands.${compactHint(s.ctx, s.lastModel)}`)
537      s.coldWritePending = true
538      return next(e)
539    }
540    if (s.ackedAt === s.lastRequestAt) {
541      s.ackedAt = 0
542      s.coldWritePending = true
543      return next(e)
544    }
545    s.ackedAt = s.lastRequestAt
546    return { drop: `cache-tax: ${guardText(s, now)} Send it again to pay it, and keepwarm will then hold the cache for ${fmtDuration(AUTO_WARM_MS)}.${compactHint(s.ctx, s.lastModel)} Or /clear and start from a note.` }
547  })
548
549  on('turn.step', {}, async function* ($, e, next) {
550    if (!e.agentId) s.lastRequestAt = await $.clock.now()
551    yield* next(e)
552  })
553
554  on('turn.complete', {}, async ($, e, next) => {
555    const r = await next(e)
556    if (e.agentId) return r
557    const now = await $.clock.now()
558    // A sleeping host may deliver this turn before the expired window's timer.
559    if (s.deadline && now >= s.deadline) await stop($, s, null)
560    // turn.step stamps the exact request time; when no step of this turn did, the turn's end is the floor.
561    if (now - s.lastRequestAt > e.durationMs) s.lastRequestAt = now
562    s.compacted = false
563    s.ackedAt = 0
564    const u = e.usage
565    if (u) {
566      if (u.model) s.lastModel = u.model
567      const prev = s.ctx
568      // A turn's usage is its responses summed, so a ten-step turn reports ten
569      // reads of the context. The live window is the engine's figure; the sum
570      // is only the fallback for a host that reports no tokens.
571      const write = u.cache_creation_input_tokens
572      const live = (await $.session.usage()).context.tokens
573      s.ctx = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + write
574      const full = prev > 20000 && write >= 0.5 * prev
575      if (full || s.coldWritePending) {
576        const price = priceOf(s.lastModel)
577        const usd = price ? write * price[1] / 1e6 : null
578        s.misses.push({ at: now, tokens: write, usd })
579        if (embed.autoWarm && s.deadline < now + AUTO_WARM_MS) {
580          await startWindow($, s, AUTO_WARM_MS, s.every)
581          $.ui.log(`cold write of ${fmtTok(write)} tokens paid (${fmtUsd(usd)}). Keeping the cache warm for ${fmtDuration(AUTO_WARM_MS)} so it is not paid again today; /keepwarm off to stop.`)
582        }
583      }
584    }
585    s.coldWritePending = false
586    await arm($, s)
587    return r
588  })
589
590  on('session.compact', {}, async ($, e, next) => {
591    const r = await next(e)
592    if (!e.agentId) {
593      s.compacted = true
594      s.ctx = 0
595      s.ackedAt = 0
596      disarm(s)
597      await arm($, s)
598    }
599    return r
600  })
601}
602
mods/cache-tax/hooks/status-icons.ts 32 lines
1// Generated by tools/generate-status-icons.py from the official SVG assets.
2export const statusIcons = {
3  "dark": {
4    "warm": {
5      "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n  <title>Cache Tax</title>\n  <g stroke=\"#faf9f5\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n    <path fill=\"#78b85a\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n    <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n    <path d=\"m40 32 8 5-8 5\"/>\n  </g>\n</svg>\n",
6      "png": "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"
7    },
8    "cold": {
9      "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n  <title>Cache Tax</title>\n  <g stroke=\"#faf9f5\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n    <path fill=\"#d97757\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n    <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n    <path d=\"m40 32 8 5-8 5\"/>\n  </g>\n</svg>\n",
10      "png": "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"
11    },
12    "neutral": {
13      "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n  <title>Cache Tax</title>\n  <g stroke=\"#faf9f5\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n    <path fill=\"#92928b\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n    <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n    <path d=\"m40 32 8 5-8 5\"/>\n  </g>\n</svg>\n",
14      "png": "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"
15    }
16  },
17  "light": {
18    "warm": {
19      "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n  <title>Cache Tax</title>\n  <g stroke=\"#141413\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n    <path fill=\"#397527\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n    <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n    <path d=\"m40 32 8 5-8 5\"/>\n  </g>\n</svg>\n",
20      "png": "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"
21    },
22    "cold": {
23      "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n  <title>Cache Tax</title>\n  <g stroke=\"#141413\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n    <path fill=\"#c15f3c\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n    <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n    <path d=\"m40 32 8 5-8 5\"/>\n  </g>\n</svg>\n",
24      "png": "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"
25    },
26    "neutral": {
27      "svg": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"64\" height=\"64\" viewBox=\"0 0 64 64\" fill=\"none\">\n  <title>Cache Tax</title>\n  <g stroke=\"#141413\" stroke-width=\"4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\n    <path fill=\"#686861\" d=\"M8 18 32 5 56 18 32 31Z\"/>\n    <path d=\"M8 18v28l24 13 24-13V18M32 31v28\"/>\n    <path d=\"m40 32 8 5-8 5\"/>\n  </g>\n</svg>\n",
28      "png": "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"
29    }
30  }
31} as const
32
mods/settings/hooks/usage.ts 89 lines
1// Token usage of the mods that call a model themselves (Next Steps, Heads-up, Catch Me Up,
2// Handoff Note, keep-warm in the Cache Meter). Pure data and arithmetic: each of those mods records
3// its calls with its own $ (session in $.state "mb_usage", total in $.store "usage-total"),
4// the window only reads. Costs are an estimate from list prices.
5
6export type ModUsage = {
7  calls: number
8  input: number
9  output: number
10  cacheRead: number
11  cacheWrite: number
12  /** Estimated US dollars of the calls whose model had a price. */
13  usd: number
14  /** Calls whose model had no price in the table. */
15  unpriced: number
16}
17
18export type UsageBook = Record<string, ModUsage>
19
20export const USAGE_STORE = 'usage-total'
21
22type Usage = {
23  input_tokens: number
24  output_tokens: number
25  cache_read_input_tokens?: number | null
26  cache_creation_input_tokens?: number | null
27}
28
29// $ per million tokens: [cache read, cache write (1 h), output]; base input is half the 1 h write.
30// List prices as Cache Tax keeps them (September 2026). Longer family names first.
31const PRICES: Array<[string, number, number, number]> = [
32  ['fable-5-1', 0.25, 20, 50],
33  ['fable-5', 1, 20, 50],
34  ['opus-5', 0.5, 10, 25],
35  ['opus-4', 0.5, 10, 25],
36  ['opus', 0.5, 10, 25],
37  ['sonnet-5', 0.2, 4, 10],
38  ['sonnet', 0.3, 6, 15],
39  ['haiku', 0.1, 2, 5],
40]
41
42export function priceOf(model: string): [number, number, number] | null {
43  const m = model.toLowerCase().replace(/[\s.]+/g, '-')
44  for (const [family, read, write, output] of PRICES) if (m.includes(family)) return [read, write, output]
45  return null
46}
47
48export function costOf(model: string, u: Usage): number | null {
49  const p = priceOf(model)
50  if (!p) return null
51  const [read, write, output] = p
52  return ((u.cache_read_input_tokens ?? 0) * read + (u.cache_creation_input_tokens ?? 0) * write + u.input_tokens * (write / 2) + u.output_tokens * output) / 1e6
53}
54
55export function addUsage(book: UsageBook | null | undefined, id: string, model: string, u: Usage): UsageBook {
56  const prev = book?.[id] ?? { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, unpriced: 0 }
57  const usd = costOf(model, u)
58  return {
59    ...(book ?? {}),
60    [id]: {
61      calls: prev.calls + 1,
62      input: prev.input + u.input_tokens,
63      output: prev.output + u.output_tokens,
64      cacheRead: prev.cacheRead + (u.cache_read_input_tokens ?? 0),
65      cacheWrite: prev.cacheWrite + (u.cache_creation_input_tokens ?? 0),
66      usd: prev.usd + (usd ?? 0),
67      unpriced: prev.unpriced + (usd == null ? 1 : 0),
68    },
69  }
70}
71
72export function tokensOf(m: ModUsage): number {
73  return m.input + m.output + m.cacheRead + m.cacheWrite
74}
75
76export function sumBook(book: UsageBook | null | undefined): ModUsage {
77  const out = { calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, usd: 0, unpriced: 0 }
78  for (const m of Object.values(book ?? {})) {
79    out.calls += m.calls
80    out.input += m.input
81    out.output += m.output
82    out.cacheRead += m.cacheRead
83    out.cacheWrite += m.cacheWrite
84    out.usd += m.usd
85    out.unpriced += m.unpriced
86  }
87  return out
88}
89
mods/cache-meter/types/contract.d.ts 31 lines
1/** Model calls the mod made itself: counted per session, priced at list price. */
2export type ModUsage = {
3  calls: number
4  input: number
5  output: number
6  cacheRead: number
7  cacheWrite: number
8  usd: number
9  unpriced: number
10}
11
12export type MeterInfo = {
13  /** Time of the main loop's last request (ms), 0 = unknown/cold. */
14  lastAt: number
15  /** Share of the last turn served from the cache, in percent. */
16  hit: number | null
17  percent: number | null
18  tokens: number | null
19  window: number | null
20  /** The subscription's rate-limit windows (5 hours, week), as the status line has them. */
21  limits: Array<{ kind: string; percentUsed: number; resetsAt?: string }>
22  /** Context fill after each of the last turns (percent, oldest first), for the trend line. */
23  trend: number[]
24}
25
26declare module 'claude-code' {
27  interface PluginState {
28    'cache-meter': { usage: Record<string, ModUsage>; info: MeterInfo; tick: number }
29  }
30}
31