SLOPSHOPPER

cache-guard

Stops you once before a message that would re-write a cold prompt cache, shows the price, and can keep the cache warm with /keepwarm.

newcommandtoaststatuspromptmodel
★ 1v0.6.2MITupdated 2026-10-14samuelbfernandes/moddeck/mods/cache-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-guard
› 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-guard: Keep-warm is off · /keepwarm 90m to start (default 6h), /keepwarm off to stop ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cache-guard

Install (Claude Code 2.1.287 or later): /plugin install cache-guard --marketplace samuelbfernandes/moddeck

What it does

Claude's prompt cache goes cold after its TTL; the next message then re-writes the whole conversation at the cache-write price. cache-guard asks you once before such a message and shows the price. When idle > TTL, the context is above minContextTokens and the cold write costs at least minExtraUsd more than a warm read, one dialog appears (no second send): Cache went cold · this message ~$0.46 (warm: ~$0.02), idle time and tokens, then a suggested way forward, listed first and marked: Compact first for a big context (≥100k tokens), Start fresh after 6 hours away, else Send anyway; Hold it is always there (Esc holds; the text is echoed back). Compact first holds the message, compacts the conversation at once and then sends the same text for you (no second question; if the compaction cannot run, /compact is put in the prompt box instead). Start fresh holds the message and puts /clear in the prompt box, so one Enter runs it. A plugin cannot delete or re-warm the cache itself; the thresholds are rules of thumb, not measured prices. Slash commands, mid-turn typing and scripted prompts are never asked about; if the guard throws, the message goes through. The first such dialog of a session also ends with Keep it warm next time: /keepwarm 90m (once; shown as plain text, the dialog cannot dim it). Nothing starts by itself: pings cost tokens.

Use

  • /cache-guard: a local report, never sent to the model, no tokens. At most six lines: `` Cache warm · ~58 min left · next message ~$0.02 Context ~77k tokens · last turn 46k (1.2k out) Cache hits 93% (est.) · 1.2M read · 85k written Keep-warm off (/keepwarm 90m to start) Cold writes this session 0 · ~$0.00 Estimates · Sonnet · 60 min cache (subscription) `` Hit ratio = cache-read tokens / (read + written) over this session's completed turns, from the usage each turn reports; the percentage and dollar figures are estimates. Last turn = its four token counts summed (a multi-step turn re-reads the cache each step).
  • /keepwarm [90m|2h|off] (explicit only): a tiny model call every TTL minus 5 min keeps the cache warm. Default 6h, max 24h, no argument = status. None runs during a turn or onto an already cold cache; three failures stop it.
  • Refreshes that cost nothing extra are used first: any request that reads the cache resets its lifetime (Anthropic's prompt-caching docs), so each step of a turn, a side question or another plugin's fork counts as a refresh, and a keep-warm ping goes out only when the cache has gone TTL minus 5 min without one. A ping is still a cache read (about a tenth of the input price): cheap, never free. Expiry is counted from the start of the last request that used the cache, as the docs say, not from the end of the turn.
  • onCold = stop gives the older one-line stop (send the same text again to pay); off never stops.

Expiry clock and hit rate

/cache-guard ends its last line with Cache until 14:30 · hit 87% (local time; expired once past; unknown when not known) once a turn has reported cache tokens; set showCacheClock off to drop it, hourCycle24 off for 2:30 pm. The same numbers are mirrored for other mods in the state atom { plugin: 'cache-guard', key: 'cache' }: { expiresAt: number | null, hitRate: number | null }. expiresAt is the last response (or keep-warm ping) plus the cache TTL, in $.clock.now() milliseconds; hitRate is cache-read / (cache-read + cache-written + uncached input) over the session, 0-100 with one decimal. Both are null before the first response and after /clear. Why a clock time, not a countdown: the screen does not repaint between turns, which is exactly when the cache drains, so a clock time stays correct however old the drawing is; a "58 min left" would be stale.

Turn off

/plugin disable cache-guard

Settings (/config)

cacheTtlMinutes (0 = follow Claude Code's rules, in order: FORCE_PROMPT_CACHING_5M, CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H, then the account: 60 min on a subscription within its plan usage, 5 on usage credits or an API key, told apart by the rate-limit windows the last response reported; the report names the source; any other value pins it; the rules are adapted from claude-code-templates' prompt-cache-control, MIT), minContextTokens (20000), minExtraUsd (0.25), onCold (ask, stop, off), showCacheClock (true), hourCycle24 (true).

Limits

  • Clock times use the machine's local zone through JavaScript Date; the plugin API has no timezone call, so a sandbox without a zone shows UTC. A resumed session shows no expiry until its first response.
  • Prices are a built-in table (hooks/lib.ts), est. only; unknown models use Sonnet rates; the 1M-context surcharge is not modelled.
  • Hit ratio and last-turn figures start at zero in a resumed session and ignore subagents and keep-warm pings.
  • Keep-warm runs only while Claude Code is open and is not restored after a restart. Context size can be stale right after a compaction.
  • Not verified live: dialogs, timers, pings and the report (mocks only). Nothing run on Windows.
Source 6 files
hooks/register.tsx 663 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { CacheTaxActivity, CacheTaxCache, CacheTaxKeepwarm, CacheTaxTotals } from '../types'
5import {
6  DEFAULT_KEEPWARM_MS,
7  cacheClockLine,
8  expiryAt,
9  hitRate,
10  MAX_KEEPWARM_MS,
11  coldUsd,
12  fmtApprox,
13  fmtDuration,
14  fmtLeft,
15  fmtTokens,
16  hitPercent,
17  parseDuration,
18  pingIntervalMs,
19  plural,
20  priceFor,
21  usageUsd,
22  warmUsd,
23} from './lib'
24import type { Usage } from './lib'
25import { dropWith } from './reply'
26import { accountOf, decideTtl } from './ttl'
27import type { CacheEnv } from './ttl'
28import { bestMove } from './suggest'
29import type { Move } from './suggest'
30
31type Dollar = EngineInterface
32/** The user's tunables, resolved once in register(). */
33type Cfg = { ttlMinutes: number; ttlMs: number; ttlSource: string; ttlPinned: number; minTokens: number; minExtraUsd: number; onCold: 'ask' | 'stop' | 'off'; showCacheClock: boolean; hourCycle24: boolean }
34
35const MINUTE = 60_000
36const MAX_STORED_SESSIONS = 50
37const PING_PROMPT = 'Cache keepalive. Reply with the single word: ok'
38
39const NO_ACTIVITY: CacheTaxActivity = { at: 0, pingAt: 0, tokens: 0 }
40const activity = atom({ plugin: 'cache-guard', key: 'activity' } as const, NO_ACTIVITY)
41const isBusy = atom({ plugin: 'cache-guard', key: 'isBusy' } as const, false)
42const held = atom({ plugin: 'cache-guard', key: 'held' } as const, null)
43const cold = atom({ plugin: 'cache-guard', key: 'cold' } as const, null)
44const paid = atom({ plugin: 'cache-guard', key: 'paid' } as const, { count: 0, usd: 0 })
45const NO_TOTALS: CacheTaxTotals = { read: 0, write: 0, lastTokens: 0, lastOut: 0, input: 0 }
46const totals = atom({ plugin: 'cache-guard', key: 'totals' } as const, NO_TOTALS)
47const NO_CACHE: CacheTaxCache = { expiresAt: null, hitRate: null }
48const cacheAtom = atom({ plugin: 'cache-guard', key: 'cache' } as const, NO_CACHE)
49const hinted = atom({ plugin: 'cache-guard', key: 'hinted' } as const, false)
50const keepwarm = atom({ plugin: 'cache-guard', key: 'keepwarm' } as const, null)
51
52// Timer handles of this module instance only (not redraw state). The truth is `keepwarm` in
53// $.state; a hot reload drops the timers and ensureTimers() restarts them on the next hook.
54const timers: { ping?: Timer; tick?: Timer } = {}
55/** When the main loop's last request that used the cache started: the cache's lifetime counts from a request's start. */
56let lastStepStart = 0
57
58/** Bookkeeping must never break the session: swallow and carry on. */
59async function quietly(fn: () => Promise<unknown>): Promise<void> {
60  try {
61    await fn()
62  } catch {
63    // fail-open by design
64  }
65}
66
67const lastTouch = (a: CacheTaxActivity): number => Math.max(a.at, a.pingAt)
68
69// ---- state + store ---------------------------------------------------------
70
71async function remember($: Dollar, next: CacheTaxActivity): Promise<void> {
72  await update($, activity, () => next)
73  await quietly(async () => {
74    const sid = await $.session.id()
75    const all = ((await $.store.get('sessions')) ?? {}) as Record<string, CacheTaxActivity>
76    all[sid] = next
77    const newest = Object.entries(all)
78      .sort(([, a], [, b]) => lastTouch(b) - lastTouch(a))
79      .slice(0, MAX_STORED_SESSIONS)
80    await $.store.set('sessions', Object.fromEntries(newest))
81  })
82}
83
84async function touch($: Dollar, patch: Partial<CacheTaxActivity>): Promise<void> {
85  const current = await read($, activity)
86  await remember($, { ...current, ...patch })
87}
88
89/** Context size in tokens, best source first; 0 when nothing knows. */
90async function estimateTokens($: Dollar): Promise<number> {
91  try {
92    const live = (await $.session.usage()).context.tokens
93    if (live) return live
94  } catch {
95    // fall through to the next source
96  }
97  const known = (await read($, activity)).tokens
98  if (known) return known
99  try {
100    const { context } = await $.session.usage({ breakdown: 'summary' })
101    return context.breakdown?.totalTokens ?? 0
102  } catch {
103    return 0
104  }
105}
106
107// ---- the guard -------------------------------------------------------------
108
109/** A message the person typed at an idle session: the only kind worth stopping. */
110const isGuarded = (e: { text: string; origin: { kind: string }; turnId?: string }): boolean =>
111  (e.origin.kind === 'composer' || e.origin.kind === 'bridge') &&
112  e.turnId === undefined &&
113  e.text.trim() !== '' &&
114  !e.text.startsWith('/')
115
116type Cold = { headline: string; detail: string; tokens: number; idleMs: number }
117
118/** What a cold cache costs this message: a one-line headline and one detail line. */
119async function coldNotice($: Dollar, cfg: Cfg, idleMs: number, tokens: number): Promise<Cold> {
120  const { rate } = priceFor(await $.session.model())
121  return {
122    headline: `Cache went cold · this message ${fmtApprox(coldUsd(rate, cfg.ttlMinutes, tokens))} (warm: ${fmtApprox(warmUsd(rate, tokens))})`,
123    detail: `Idle ${fmtDuration(idleMs)} · ~${fmtTokens(tokens)} tokens to re-write · /keepwarm holds the cache · /clear starts fresh`,
124    tokens,
125    idleMs,
126  }
127}
128
129const KEEP_WARM_HINT = 'Keep it warm next time: /keepwarm 90m'
130
131/**
132 * Mention /keepwarm once per session, only inside a cold-cache question, and only when keep-warm is off, no turn
133 * is running and the person has been idle past 70% of the TTL. Nothing is ever started here: pings cost tokens.
134 */
135async function keepwarmHint(cfg: Cfg, $: Dollar, idleMs: number): Promise<string | undefined> {
136  if ((await read($, hinted)) || (await read($, keepwarm)) || (await read($, isBusy))) return undefined
137  if (idleMs < cfg.ttlMs * 0.7) return undefined
138  await update($, hinted, () => true)
139  return KEEP_WARM_HINT
140}
141
142/** Is this message cold and costly enough to stop on? Undefined lets it through. */
143async function judge(cfg: Cfg, $: Dollar, text: string): Promise<Cold | undefined> {
144  if (cfg.onCold === 'off') return undefined
145  const now = await $.clock.now()
146  const touched = lastTouch(await read($, activity))
147  if (touched === 0 || now - touched <= cfg.ttlMs) {
148    if (await read($, held)) await update($, held, () => null)
149    return undefined
150  }
151  const tokens = await estimateTokens($)
152  if (tokens <= cfg.minTokens) return undefined
153  const { rate } = priceFor(await $.session.model())
154  if (coldUsd(rate, cfg.ttlMinutes, tokens) - warmUsd(rate, tokens) < cfg.minExtraUsd) return undefined // too cheap to bother with
155
156  const stopped = await read($, held)
157  if (stopped?.text === text) {
158    // Second send of the same text: the person chose to pay. Reset the guard.
159    await update($, held, () => null)
160    await update($, cold, () => ({ tokens }))
161    return undefined
162  }
163  await update($, held, () => ({ text }))
164  return coldNotice($, cfg, now - touched, tokens)
165}
166
167const SEND = 'Send anyway'
168const COMPACT = 'Compact first'
169const CLEAR = 'Start fresh'
170const HOLD = 'Hold it'
171const SUGGESTED = ' (suggested)'
172const LABEL: Record<Move, string> = { send: SEND, compact: COMPACT, clear: CLEAR }
173const COMMAND: Record<Move, string> = { send: '', compact: '/compact', clear: '/clear' }
174
175/** Put a command in the prompt box so one Enter runs it; false when the box could not take it. */
176async function fillPrompt($: Dollar, text: string): Promise<boolean> {
177  try {
178    const out = (await $.prompt.fill({ text } as never)) as { isFilled?: boolean } | undefined
179    return out?.isFilled !== false
180  } catch {
181    return false
182  }
183}
184
185/** Compact, then send `text` again; when the compaction cannot run, leave `/compact` in the prompt box instead. */
186async function compactThenSend($: Dollar, text: string): Promise<void> {
187  let isDone = false
188  try {
189    isDone = (await $.session.compact()).skip === undefined
190  } catch {
191    isDone = false
192  }
193  if (isDone) return void (await $.prompt.submit({ text }))
194  await update($, held, () => null)
195  const isFilled = await fillPrompt($, '/compact')
196  $.ui.toast(isFilled ? 'cache-guard · compaction did not run: press Enter for /compact, then send again' : 'cache-guard · compaction did not run: type /compact, then send again', { timeoutMs: 8000 })
197}
198
199/**
200 * `ask` (default): one small dialog, answered in place, so there is no second send. Esc holds the message.
201 * `stop`: the older behaviour, a one-line stop and the same text sent again to pay.
202 * Returns a drop, or undefined to let the message through.
203 */
204async function guard(cfg: Cfg, $: Dollar, text: string): Promise<{ drop: string } | undefined> {
205  const c = await judge(cfg, $, text)
206  if (c === undefined) return undefined
207  if (cfg.onCold === 'stop') return dropWith(note => $.ui.log(note), `cache-guard blocked your prompt · ${c.headline} · send the same text again to continue\n${c.detail}`)
208  let answer: string
209  const hint = await keepwarmHint(cfg, $, c.idleMs).catch(() => undefined)
210  const best = bestMove(c.tokens, c.idleMs)
211  const first = LABEL[best.move]
212  const options = [first, ...[SEND, COMPACT, CLEAR].filter(x => x !== first), HOLD].map(x => (x === first ? `${x}${SUGGESTED}` : x))
213  const advice = `Suggested: ${first.toLowerCase()} · ${best.why}`
214  try {
215    answer = await $.ui.ask([c.headline, c.detail, advice, hint].filter(Boolean).join('\n'), { options, header: 'Cache cold' })
216  } catch {
217    answer = HOLD // Esc or an interrupt: nothing is sent
218  }
219  const picked = answer.endsWith(SUGGESTED) ? answer.slice(0, -SUGGESTED.length) : answer
220  if (picked === SEND) {
221    await update($, held, () => null)
222    await update($, cold, () => ({ tokens: c.tokens }))
223    return undefined
224  }
225  const shown = text.length > 2000 ? `${text.slice(0, 2000)}...` : text
226  if (picked === COMPACT) {
227    // The engine refuses a compaction under a prompt.submit hook, so hold the message, compact on the next tick and
228    // send the same text again (marked as already judged). If the compaction fails, the one-Enter way below applies.
229    await update($, held, () => ({ text }))
230    $.ui.toast('cache-guard · compacting, then sending your message', { timeoutMs: 6000 })
231    $.clock.after(0, () => void quietly(() => compactThenSend($, text)))
232    return dropWith(note => $.ui.log(note), `cache-guard held your prompt · compacting first, it is sent right after\n${text.length > 2000 ? `${text.slice(0, 2000)}...` : text}`)
233  }
234  if (picked === CLEAR) {
235    const command = COMMAND.clear
236    const isFilled = await fillPrompt($, command)
237    const how = isFilled ? `press Enter to run ${command}` : `type ${command}`
238    return dropWith(note => $.ui.log(note), `cache-guard blocked your prompt · ${how}, then send your message again\n${shown}`)
239  }
240  return dropWith(note => $.ui.log(note), `cache-guard blocked your prompt · nothing was sent. Send it again to choose.\n${shown}`)
241}
242
243/** Add a completed turn's real usage to the session totals (cache reads vs writes, last turn). */
244async function recordUsage($: Dollar, usage: Usage | undefined): Promise<void> {
245  if (!usage) return // interrupted or failed before any request: nothing to count
246  await update($, totals, t => ({
247    read: t.read + usage.cache_read_input_tokens,
248    write: t.write + usage.cache_creation_input_tokens,
249    lastTokens: usage.input_tokens + usage.output_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens,
250    lastOut: usage.output_tokens,
251    input: (t.input ?? 0) + usage.input_tokens,
252  }))
253}
254
255/** Mirror the expiry clock time and session hit rate for display; observe-only, never throws. */
256async function publishCache(cfg: Cfg, $: Dollar, clear = false): Promise<void> {
257  await quietly(async () => {
258    if (clear) return void (await update($, cacheAtom, () => NO_CACHE))
259    const t = await read($, totals)
260    const next: CacheTaxCache = {
261      expiresAt: expiryAt(lastTouch(await read($, activity)), cfg.ttlMs),
262      hitRate: hitRate(t.read, t.write, t.input ?? 0),
263    }
264    await update($, cacheAtom, () => next)
265  })
266}
267
268/** A prompt passed after the TTL: price the write the turn actually made. */
269async function payColdWrite(cfg: Cfg, $: Dollar, usage: (Usage & { model: string }) | undefined): Promise<void> {
270  if (!(await read($, cold))) return
271  await update($, cold, () => null)
272  if (!usage) return // interrupted before any request: nothing was paid
273  const usd = coldUsd(priceFor(usage.model).rate, cfg.ttlMinutes, usage.cache_creation_input_tokens)
274  await update($, paid, p => ({ count: p.count + 1, usd: p.usd + usd }))
275}
276
277// ---- keepwarm --------------------------------------------------------------
278
279function stopTimers(): void {
280  timers.ping?.cancel()
281  timers.tick?.cancel()
282  timers.ping = undefined
283  timers.tick = undefined
284}
285
286const statusLine = (k: CacheTaxKeepwarm, now: number): string =>
287  `Keep-warm · ${fmtDuration(k.until - now)} left · ${plural(k.pings, 'ping')} · ${fmtApprox(k.usd)}` +
288  (k.lastWasCold ? ' · last ping re-wrote the cache' : '')
289
290async function endKeepwarm($: Dollar, k: CacheTaxKeepwarm, reason: string): Promise<void> {
291  stopTimers()
292  await update($, keepwarm, () => null)
293  $.ui.status(undefined)
294  $.ui.toast(`Keep-warm ${reason} · ${plural(k.pings, 'ping')} · ${fmtApprox(k.usd)}`, { timeoutMs: 8000 })
295}
296
297/** Every minute: the status countdown and the end of the duration. */
298async function housekeeping($: Dollar): Promise<void> {
299  const k = await read($, keepwarm)
300  if (!k) return stopTimers()
301  const now = await $.clock.now()
302  if (now >= k.until) return endKeepwarm($, k, 'ended')
303  $.ui.status(statusLine(k, now))
304}
305
306async function recordPing(cfg: Cfg, $: Dollar, usage: Usage, startedAt: number): Promise<void> {
307  const now = startedAt // the refresh counts from the ping's start
308  const { rate } = priceFor(await $.session.model())
309  await update($, keepwarm, cur =>
310    cur
311      ? {
312          ...cur,
313          pings: cur.pings + 1,
314          usd: cur.usd + usageUsd(rate, cfg.ttlMinutes, usage),
315          failures: 0,
316          lastCacheRead: usage.cache_read_input_tokens,
317          lastWasCold: usage.cache_creation_input_tokens > usage.cache_read_input_tokens,
318        }
319      : cur,
320  )
321  if (usage.cache_read_input_tokens + usage.cache_creation_input_tokens > 0) await touch($, { pingAt: now })
322  const k = await read($, keepwarm)
323  if (k) $.ui.status(statusLine(k, now))
324  await publishCache(cfg, $)
325}
326
327/** One keepalive: a cache-read fork, only while idle, never during a turn, never onto a cold cache. */
328async function ping(cfg: Cfg, $: Dollar): Promise<void> {
329  const k = await read($, keepwarm)
330  if (!k) return stopTimers()
331  const now = await $.clock.now()
332  if (now >= k.until) return endKeepwarm($, k, 'ended')
333  if (await read($, isBusy)) return
334  const touched = lastTouch(await read($, activity))
335  const idle = now - touched
336  // No history, or already lapsed: a ping would only pay a write. Not due yet: the last request that read the cache (a turn,
337  // a side question, another plugin's fork, or the last ping) refreshed it, so the ping waits a full interval from there.
338  if (touched === 0 || idle >= cfg.ttlMs || idle < pingIntervalMs(cfg.ttlMinutes)) return
339
340  const startedAt = now
341  const r = await $.model.fork({ prompt: PING_PROMPT })
342  if (r.isAnswered) return recordPing(cfg, $, r.usage, startedAt)
343  if (r.reason === 'nothing-to-fork') return
344  const failures = k.failures + 1
345  if (failures >= 3) return endKeepwarm($, k, 'stopped after 3 failed pings')
346  await update($, keepwarm, cur => (cur ? { ...cur, failures } : cur))
347}
348
349function startTimers(cfg: Cfg, $: Dollar, intervalMs: number): void {
350  stopTimers()
351  // Checked every minute rather than on a fixed beat, so a refresh by any other request pushes the next ping back.
352  void intervalMs
353  timers.ping = $.clock.every(MINUTE, () => void quietly(() => ping(cfg, $)))
354  timers.tick = $.clock.every(MINUTE, () => void quietly(() => housekeeping($)))
355}
356
357async function ensureTimers(cfg: Cfg, $: Dollar): Promise<void> {
358  const k = await read($, keepwarm)
359  if (k && !timers.ping) startTimers(cfg, $, k.intervalMs)
360}
361
362async function startKeepwarm(cfg: Cfg, $: Dollar, durationMs: number): Promise<string> {
363  const now = await $.clock.now()
364  const capped = Math.min(durationMs, MAX_KEEPWARM_MS)
365  const intervalMs = pingIntervalMs(cfg.ttlMinutes)
366  const k: CacheTaxKeepwarm = {
367    startedAt: now, until: now + capped, intervalMs,
368    pings: 0, usd: 0, failures: 0, lastCacheRead: null, lastWasCold: false,
369  }
370  await update($, keepwarm, () => k)
371  startTimers(cfg, $, intervalMs)
372  $.ui.status(statusLine(k, now))
373
374  const touched = lastTouch(await read($, activity))
375  const tokens = await estimateTokens($)
376  const { rate } = priceFor(await $.session.model())
377  const pings = Math.floor(capped / intervalMs)
378  const isCold = touched === 0 || now - touched >= cfg.ttlMs
379  return [
380    `Keep-warm on for ${fmtDuration(capped)}${capped < durationMs ? ' (the maximum)' : ''} · a ping every ${fmtDuration(intervalMs)}`,
381    tokens > 0
382      ? `About ${pings} pings · ${fmtApprox(pings * warmUsd(rate, tokens))} at ~${fmtTokens(tokens)} tokens of context`
383      : `About ${pings} pings, each a cheap read of your context`,
384    isCold
385      ? 'The cache is cold: pings wait until your next message'
386      : 'Runs only while Claude Code is open · /keepwarm off to stop',
387  ].join('\n')
388}
389
390/** One line for the report: what keep-warm is doing, or how to start it. */
391async function keepwarmLine($: Dollar): Promise<string> {
392  const k = await read($, keepwarm)
393  if (!k) return 'Keep-warm  off  (/keepwarm 90m to start)'
394  const now = await $.clock.now()
395  return `Keep-warm  on · ${fmtDuration(Math.max(k.until - now, 0))} left · ${plural(k.pings, 'ping')} · ${fmtApprox(k.usd)}`
396}
397
398async function keepwarmStatus($: Dollar): Promise<string> {
399  const k = await read($, keepwarm)
400  if (!k) return 'Keep-warm is off · /keepwarm 90m to start (default 6h), /keepwarm off to stop'
401  const now = await $.clock.now()
402  const last = k.lastCacheRead === null ? 'no ping yet' : `last one read ${fmtTokens(k.lastCacheRead)} tokens from cache`
403  return [
404    `Keep-warm on · ${fmtDuration(Math.max(k.until - now, 0))} left · ${plural(k.pings, 'ping')} · ${fmtApprox(k.usd)}`,
405    `A ping every ${fmtDuration(k.intervalMs)} · ${last}`,
406  ].join('\n')
407}
408
409async function runKeepwarm(cfg: Cfg, $: Dollar, args: string): Promise<string> {
410  const arg = args.trim().toLowerCase()
411  if (arg === '') return keepwarmStatus($)
412  if (arg === 'off' || arg === 'stop') {
413    const k = await read($, keepwarm)
414    if (!k) return 'Keep-warm was not running'
415    await endKeepwarm($, k, 'stopped')
416    return 'Keep-warm stopped'
417  }
418  const ms = arg === 'on' ? DEFAULT_KEEPWARM_MS : parseDuration(arg)
419  if (ms === null) return `Couldn't read "${args.trim()}" · try 90m, 2h, 1h30m, off, or nothing for status`
420  return startKeepwarm(cfg, $, ms)
421}
422
423// ---- /cache-guard ------------------------------------------------------------
424
425/**
426 * ' · Cache until 14:30 · hit 87%': only once a turn has reported cache tokens (no cache, no expiry to show),
427 * and only with showCacheClock on. Rides the last line so the report stays at six lines.
428 */
429async function clockSuffix(cfg: Cfg, $: Dollar, now: number, hit: number | null): Promise<string> {
430  try {
431    if (!cfg.showCacheClock || hit === null) return ''
432    const c = await read($, cacheAtom)
433    return ` · ${cacheClockLine(c.expiresAt, c.hitRate, now, cfg.hourCycle24)}`
434  } catch {
435    return ''
436  }
437}
438
439/** Headline first, then a few plain lines, then one dim line of estimates. */
440async function report(cfg: Cfg, $: Dollar): Promise<string> {
441  const now = await $.clock.now()
442  const a = await read($, activity)
443  const tokens = await estimateTokens($)
444  const { label, rate } = priceFor(await $.session.model())
445  const touched = lastTouch(a)
446  const idle = now - touched
447  const isCold = touched !== 0 && idle > cfg.ttlMs
448  const total = await read($, paid)
449  const t = await read($, totals)
450  const next = (usd: number): string => (tokens > 0 ? ` · next message ${fmtApprox(usd)}` : '')
451  const headline =
452    touched === 0
453      ? 'No activity yet · the cache starts with your first message'
454      : isCold
455        ? `Cache cold${next(coldUsd(rate, cfg.ttlMinutes, tokens))}`
456        : `Cache warm · ~${fmtLeft(cfg.ttlMs - idle)} left${next(warmUsd(rate, tokens))}`
457  const last = t.lastTokens > 0 ? ` · last turn ${fmtTokens(t.lastTokens)} (${fmtTokens(t.lastOut)} out)` : ''
458  const hit = hitPercent(t.read, t.write)
459  // At most six lines: the hold note rides on the cold-writes line.
460  return [
461    headline,
462    (tokens > 0 ? `Context  ~${fmtTokens(tokens)} tokens` : 'Context  unknown yet') + last,
463    hit === null ? '' : `Cache hits  ${hit}% (est.) · ${fmtTokens(t.read)} read · ${fmtTokens(t.write)} written`,
464    await keepwarmLine($),
465    `Cold writes this session  ${total.count} · ${fmtApprox(total.usd)}` +
466      ((await read($, held)) ? ' · on hold: send the same text again to continue' : ''),
467    `Estimates · ${label} · ${cfg.ttlMinutes} min cache (${cfg.ttlSource})` + (await clockSuffix(cfg, $, now, hit)),
468  ]
469    .filter(line => line !== '')
470    .join('\n')
471}
472
473// ---- the cache's lifetime --------------------------------------------------------------------------------------------
474let ttlInputs: { env: CacheEnv; setting: unknown } | undefined
475
476/** The promptCacheTtl setting, from the files that can carry it (local over project over user). */
477async function readTtlSetting($: Dollar): Promise<unknown> {
478  const home = await $.env.get('HOME').catch(() => undefined)
479  const cwd = await $.session.cwd().catch(() => undefined)
480  for (const file of [cwd && `${cwd}/.claude/settings.local.json`, cwd && `${cwd}/.claude/settings.json`, home && `${home}/.claude/settings.json`]) {
481    if (!file) continue
482    try {
483      const v = JSON.parse(await $.fs.read(file)).promptCacheTtl
484      if (v === '5m' || v === '1h') return v
485    } catch {
486      // missing or unreadable: the next file
487    }
488  }
489  return undefined
490}
491
492/** Works out the TTL as Claude Code does; the environment and settings are read once per session, the account each turn. */
493async function refreshTtl(cfg: Cfg, $: Dollar, isStart: boolean): Promise<void> {
494  if (cfg.ttlPinned > 0) return
495  if (isStart || ttlInputs === undefined) {
496    const none = () => undefined
497    ttlInputs = {
498      env: {
499        force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(none),
500        ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(none),
501        enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(none),
502      },
503      setting: await readTtlSetting($),
504    }
505  }
506  const windows = (await $.session.usage().catch(() => undefined))?.rateLimits ?? []
507  const choice = decideTtl(0, ttlInputs.env, ttlInputs.setting, accountOf(windows))
508  cfg.ttlMinutes = choice.minutes
509  cfg.ttlMs = choice.minutes * MINUTE
510  cfg.ttlSource = choice.source
511}
512
513// ---- wiring ----------------------------------------------------------------
514
515export const register: Register = (on, options) => {
516  // 0 (the default) = follow Claude Code's own rules (env, settings, account); any other value pins the TTL.
517  const ttlPinned = Math.max(Math.floor(Number(options.cacheTtlMinutes) || 0), 0)
518  const ttlMinutes = ttlPinned > 0 ? ttlPinned : 60
519  const onCold = options.onCold === 'stop' || options.onCold === 'off' ? options.onCold : 'ask'
520  const cfg: Cfg = {
521    ttlMinutes,
522    ttlMs: ttlMinutes * MINUTE,
523    ttlSource: ttlPinned > 0 ? 'the cacheTtlMinutes setting' : 'not known yet',
524    ttlPinned,
525    minTokens: Number(options.minContextTokens ?? 20_000),
526    minExtraUsd: Math.max(Number(options.minExtraUsd ?? 0.25) || 0, 0),
527    onCold,
528    showCacheClock: options.showCacheClock !== false,
529    hourCycle24: options.hourCycle24 !== false,
530  }
531
532  // Fail-open: a cost guard must never block work. A throw before the verdict lets the prompt through.
533  on('prompt.submit', async ($, e, next) => {
534    if (isGuarded(e)) {
535      await ensureTimers(cfg, $)
536      const stop = await guard(cfg, $, e.text)
537      if (stop !== undefined) return stop
538    }
539    return next(e)
540  }).catch(($, e, next) => next(e))
541
542  on('turn.start', async ($, e, next) => {
543    await quietly(async () => {
544      await update($, isBusy, () => true)
545      await touch($, { at: await $.clock.now() })
546    })
547    return next(e)
548  })
549
550  // Every main-loop request that reads the cache refreshes it at no extra charge: note when the last one started.
551  on('turn.step', async function* ($, e, next) {
552    if (e.agentId !== undefined) return yield* next(e) // a subagent has a prefix of its own
553    const startedAt = await $.clock.now().catch(() => 0)
554    const r = yield* next(e)
555    if (startedAt > 0 && r.usage && r.usage.cache_read_input_tokens + r.usage.cache_creation_input_tokens > 0) lastStepStart = startedAt
556    return r
557  })
558
559  // So does any fork of the conversation another plugin runs (a side question, a review): it reads the same cache, so it
560  // counts as a refresh, and keep-warm skips its own ping until one is due again.
561  on('model.fork', async ($, e, next) => {
562    const startedAt = await $.clock.now().catch(() => 0)
563    const r = await next(e)
564    const v = (r as { value?: { isAnswered?: boolean; usage?: Usage } }).value
565    if (startedAt > 0 && v?.isAnswered && v.usage && v.usage.cache_read_input_tokens > 0) {
566      await quietly(async () => {
567        const a = await read($, activity)
568        if (startedAt > a.pingAt) await touch($, { pingAt: startedAt })
569      })
570    }
571    return r
572  })
573
574  on('turn.complete', async ($, e, next) => {
575    if (e.agentId !== undefined) return next(e) // a subagent has a prefix of its own
576    await quietly(async () => {
577      await update($, isBusy, () => false)
578      const now = await $.clock.now()
579      let tokens = (await read($, activity)).tokens
580      try {
581        tokens = (await $.session.usage()).context.tokens ?? tokens
582      } catch {
583        // keep the last known size
584      }
585      // The lifetime runs from the start of the last request that read or wrote the cache, not from the turn's end.
586      await refreshTtl(cfg, $, false) // a subscription that runs out of plan usage moves to usage credits: 5 min
587      await touch($, { at: lastStepStart > 0 ? lastStepStart : now, tokens })
588      lastStepStart = 0
589      await payColdWrite(cfg, $, e.usage)
590      await recordUsage($, e.usage)
591      await ensureTimers(cfg, $)
592    })
593    await publishCache(cfg, $)
594    return next(e)
595  })
596
597  on('session.start', async ($, e, next) => {
598    await quietly(() => refreshTtl(cfg, $, true))
599    await quietly(() =>
600      $.command.register({
601        name: 'keepwarm',
602        description: 'Keep the prompt cache warm with cheap pings: /keepwarm [90m|2h|off]',
603        argumentHint: '[duration|off]',
604        immediate: true,
605      }),
606    )
607    await quietly(() =>
608      $.command.register({
609        name: 'cache-guard',
610        description: 'Show whether the cache is warm and what the next message costs',
611        immediate: true,
612      }),
613    )
614    await publishCache(cfg, $, true)
615    await quietly(async () => {
616      if ((await read($, activity)).at !== 0) return
617      const all = ((await $.store.get('sessions')) ?? {}) as Record<string, CacheTaxActivity>
618      const mine = all[await $.session.id()]
619      if (mine) await update($, activity, () => mine)
620    })
621    return next(e)
622  })
623
624  // A resumed session has no live usage until its first response: the engine's own
625  // seconds_since_last_response / context_tokens are the best seed, and beat the store.
626  on('classic.SessionStart', async ($, e, next) => {
627    await quietly(async () => {
628      const idleS = e.seconds_since_last_response
629      if ((e.source !== 'resume' && e.source !== 'fork') || idleS === undefined) return
630      const now = await $.clock.now()
631      await remember($, { at: now - idleS * 1000, pingAt: 0, tokens: e.context_tokens ?? 0 })
632    })
633    return next(e)
634  }).catch(($, e, next) => next(e))
635
636  on('session.end', async ($, e, next) => {
637    await quietly(async () => {
638      await update($, held, () => null)
639      await update($, cold, () => null)
640      await update($, isBusy, () => false)
641      if (e.reason === 'clear') {
642        // New empty conversation: nothing cached to lose until the first reply.
643        await update($, activity, () => NO_ACTIVITY)
644        await update($, totals, () => NO_TOTALS)
645      } else {
646        stopTimers()
647        await update($, keepwarm, () => null)
648        $.ui.status(undefined)
649      }
650    })
651    await publishCache(cfg, $, true)
652    return next(e)
653  })
654
655  on('command.run', { command: 'keepwarm' }, async ($, e) => ({ text: await runKeepwarm(cfg, $, e.args) })).catch(
656    () => ({ text: "Keep-warm couldn't run · nothing was changed" }),
657  )
658
659  on('command.run', { command: 'cache-guard' }, async $ => ({ text: await report(cfg, $) })).catch(() => ({
660    text: "Couldn't read the cache state",
661  }))
662}
663
hooks/lib.ts 181 lines
1// Pure helpers: prices, duration parsing, formatting. No engine access.
2
3const MIN = 60_000
4const HOUR = 60 * MIN
5
6export const MAX_KEEPWARM_MS = 24 * HOUR
7export const DEFAULT_KEEPWARM_MS = 6 * HOUR
8/** Margin kept between a ping and the cache's expiry. */
9export const SAFETY_MS = 5 * MIN
10
11/** USD per million tokens. */
12export type Rate = { in: number; out: number; write5m: number; write1h: number; read: number }
13
14/** A cache read is 0.1x the input price, 0.05x on Opus 5.5 and Sonnet 5.5, 0.025x on Fable 5.1 (Anthropic's pricing page). */
15const rate = (input: number, output: number, readMul = 0.1): Rate => ({
16  in: input,
17  out: output,
18  write5m: input * 1.25,
19  write1h: input * 2,
20  read: input * readMul,
21})
22
23// Built-in list prices (est.): they drift, so every figure shown is labelled "est.".
24// First match wins; an unknown model falls back to Sonnet rates and says so.
25// Checked against the pricing page on 2026-10-12. Haiku 5.5 is priced for prompts up to 100K tokens (5x above that).
26const PRICES: ReadonlyArray<readonly [RegExp, string, Rate]> = [
27  [/fable|mythos/, 'Fable', rate(10, 50, 0.025)],
28  [/opus-5-5/, 'Opus 5.5', rate(4, 20, 0.05)],
29  [/opus-4(-1)?(-\d{8})?$/, 'Opus 4/4.1', rate(15, 75)],
30  [/opus/, 'Opus', rate(5, 25)],
31  [/haiku-5-5/, 'Haiku 5.5', rate(0.1, 0.5)],
32  [/haiku-3/, 'Haiku 3.x', rate(0.8, 4)],
33  [/haiku/, 'Haiku', rate(1, 5)],
34  [/sonnet-5-5/, 'Sonnet 5.5', rate(2, 10, 0.05)],
35  [/sonnet-5/, 'Sonnet 5', rate(2, 10, 0.1)],
36  [/sonnet/, 'Sonnet', rate(3, 15)],
37]
38const FALLBACK = { label: 'Sonnet rates (model unknown)', rate: rate(3, 15) }
39
40export function priceFor(model: string): { label: string; rate: Rate } {
41  const id = model.toLowerCase()
42  const hit = PRICES.find(([re]) => re.test(id))
43  return hit ? { label: hit[1], rate: hit[2] } : FALLBACK
44}
45
46/** Rate of a cache write: the 5-minute tier for a 5-minute TTL, else the 1-hour tier. */
47export const writeRate = (r: Rate, ttlMinutes: number): number =>
48  ttlMinutes <= 5 ? r.write5m : r.write1h
49
50export type Usage = {
51  input_tokens: number
52  output_tokens: number
53  cache_read_input_tokens: number
54  cache_creation_input_tokens: number
55}
56
57export function usageUsd(r: Rate, ttlMinutes: number, u: Usage): number {
58  return (
59    (u.input_tokens * r.in +
60      u.output_tokens * r.out +
61      u.cache_read_input_tokens * r.read +
62      u.cache_creation_input_tokens * writeRate(r, ttlMinutes)) /
63    1e6
64  )
65}
66
67export const coldUsd = (r: Rate, ttlMinutes: number, tokens: number): number =>
68  (tokens * writeRate(r, ttlMinutes)) / 1e6
69
70export const warmUsd = (r: Rate, tokens: number): number => (tokens * r.read) / 1e6
71
72/** Ping period: the TTL less the safety margin, never under one minute. */
73export const pingIntervalMs = (ttlMinutes: number): number =>
74  Math.max(ttlMinutes * MIN - SAFETY_MS, MIN)
75
76/** A ping that comes sooner than this after the last touch is skipped: the next one still lands in time. */
77export const skipBelowMs = (ttlMinutes: number): number =>
78  Math.max(ttlMinutes * MIN - pingIntervalMs(ttlMinutes) - MIN, 0)
79
80const UNITS: Record<string, number> = {
81  m: MIN, min: MIN, mins: MIN, minute: MIN, minutes: MIN,
82  h: HOUR, hr: HOUR, hrs: HOUR, hour: HOUR, hours: HOUR,
83  d: 24 * HOUR, day: 24 * HOUR, days: 24 * HOUR,
84}
85
86/** "90m", "2h", "1h30m", "1.5h", "45" (minutes) to milliseconds; null when unreadable or zero. */
87export function parseDuration(input: string): number | null {
88  const text = input.trim().toLowerCase().replace(/\s+/g, '')
89  const parts = text.match(/\d+(?:\.\d+)?[a-z]*/g)
90  if (!parts || parts.join('') !== text) return null
91  let total = 0
92  for (const part of parts) {
93    const m = /^(\d+(?:\.\d+)?)([a-z]*)$/.exec(part)
94    const unit = m?.[2] === '' ? (parts.length === 1 ? 'm' : '') : (m?.[2] ?? '')
95    const ms = UNITS[unit]
96    if (!m || ms === undefined) return null
97    total += Number(m[1]) * ms
98  }
99  return total > 0 ? Math.round(total) : null
100}
101
102export function fmtDuration(ms: number): string {
103  const s = Math.max(Math.round(ms / 1000), 0)
104  if (s < 60) return `${s}s`
105  const m = Math.round(s / 60)
106  if (m < 60) return `${m}m`
107  const h = Math.floor(m / 60)
108  const rest = m % 60
109  return rest === 0 ? `${h}h` : `${h}h ${rest}m`
110}
111
112export function fmtTokens(n: number): string {
113  if (n < 1000) return String(Math.round(n))
114  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
115  return n < 10_000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`
116}
117
118/** Time left on a clock, in the words of a headline: "58 min", "1h 5m". */
119export function fmtLeft(ms: number): string {
120  const m = Math.max(Math.round(ms / MIN), 0)
121  if (m < 1) return '<1 min'
122  return m < 60 ? `${m} min` : fmtDuration(m * MIN)
123}
124
125/** "~$0.02", or "<$0.01" when it rounds to nothing. */
126export function fmtApprox(n: number): string {
127  return n > 0 && n < 0.01 ? '<$0.01' : `~${fmtUsd(n)}`
128}
129
130export const plural = (n: number, noun: string): string => `${n} ${noun}${n === 1 ? '' : 's'}`
131
132export function fmtUsd(n: number): string {
133  if (n > 0 && n < 0.01) return '<$0.01'
134  return `$${n.toFixed(n < 10 ? 2 : 1)}`
135}
136
137/** Share of cached tokens that were read rather than written, in whole percent; null when none were counted. */
138export function hitPercent(read: number, write: number): number | null {
139  const total = read + write
140  return total > 0 ? Math.round((read / total) * 100) : null
141}
142
143/** Share of all input tokens read from cache, 0-100 with one decimal; null when no input was counted. */
144export function hitRate(read: number, write: number, uncached: number): number | null {
145  const total = read + write + uncached
146  if (!(total > 0) || !Number.isFinite(total)) return null
147  return Math.round((read / total) * 1000) / 10
148}
149
150/** When the cache goes cold: the last response plus the TTL; null before any response (at = 0) or on bad input. */
151export function expiryAt(lastResponseAt: number, ttlMs: number): number | null {
152  return lastResponseAt > 0 && Number.isFinite(lastResponseAt) && ttlMs > 0 ? lastResponseAt + ttlMs : null
153}
154
155const two = (n: number): string => String(n).padStart(2, '0')
156
157/**
158 * Local wall-clock time of an epoch in ms ("14:30", or "2:30 pm"). Uses the JS Date local getters, so it is
159 * the machine's own zone; the plugin API has no timezone call, and a sandbox without a zone shows UTC.
160 */
161export function fmtClock(ms: number, hour24 = true): string {
162  const d = new Date(ms)
163  if (Number.isNaN(d.getTime())) return 'unknown'
164  const h = d.getHours()
165  if (hour24) return `${two(h)}:${two(d.getMinutes())}`
166  return `${h % 12 === 0 ? 12 : h % 12}:${two(d.getMinutes())} ${h < 12 ? 'am' : 'pm'}`
167}
168
169/** "14:30", "expired" once past, "unknown" when null. */
170export function fmtExpiry(expiresAt: number | null, now: number, hour24 = true): string {
171  if (expiresAt === null) return 'unknown'
172  return expiresAt <= now ? 'expired' : fmtClock(expiresAt, hour24)
173}
174
175/** "Cache until 14:30 · hit 87%"; "Cache expired", "Cache until unknown"; hit omitted when null. */
176export function cacheClockLine(expiresAt: number | null, hit: number | null, now: number, hour24 = true): string {
177  const when = fmtExpiry(expiresAt, now, hour24)
178  const head = when === 'expired' ? 'Cache expired' : `Cache until ${when}`
179  return hit === null ? head : `${head} · hit ${hit % 1 === 0 ? hit : hit.toFixed(1)}%`
180}
181
hooks/reply.ts 33 lines
1/**
2 * A `prompt.submit` drop reason must be ONE line: the engine draws a "\n" in it as a stray "?" mark.
3 * `dropWith` splits a multi-line text: the first line becomes the reason, the rest goes to the transcript
4 * as dim notes (`$.ui.log`, never sent to the model), one call per line: `$.ui.log` draws one transcript line, where
5 * a newline also shows as a stray glyph. The note is usually your held prompt, so each line keeps its indentation and
6 * can be copied back.
7 */
8
9// Control characters (tabs, carriage returns, escapes, ...) that would break a one-line reason.
10const CONTROL = /[\u0000-\u001f\u007f-\u009f]+/g
11
12/** A single line with control characters turned into spaces and the ends trimmed. */
13export const oneLine = (text: string): string => text.replace(CONTROL, ' ').replace(/ {2,}/g, ' ').trim()
14
15/** One note line: indentation kept (a tab is two spaces), other control characters turned into a space, the end trimmed. */
16const noteLine = (line: string): string => line.replace(/\t/g, '  ').replace(CONTROL, ' ').trimEnd()
17
18/** `log` is the caller's `(note) => $.ui.log(note)`: the engine refuses `$.ui` handed around as a value. */
19export function dropWith(log: (text: string) => void, text: string): { drop: string } {
20  const lines = text.split(/\r\n|\r|\n/).filter(line => oneLine(line) !== '')
21  const drop = oneLine(lines[0] ?? '') || 'cache-guard'
22  // Called in order, without awaiting: the hook returns its drop at once.
23  for (const line of lines.slice(1)) {
24    try {
25      // A note is a courtesy: a failing log must never undo the drop.
26      void Promise.resolve(log(noteLine(line))).catch(() => undefined)
27    } catch {
28      // keep the drop
29    }
30  }
31  return { drop }
32}
33
hooks/ttl.ts 37 lines
1// Which prompt-cache lifetime the main conversation gets, by Claude Code's documented order. Adapted from
2// davila7/claude-code-templates, mods/observability/prompt-cache-control (MIT): decideTtl and accountOf.
3
4export type Ttl = '5m' | '1h'
5export type Account = 'subscription' | 'credits' | 'other'
6export type CacheEnv = { force5m?: string; ttlVar?: string; enable1h?: string }
7export type TtlChoice = { minutes: number; source: string }
8
9const isOn = (v: string | undefined): boolean => v === '1' || v?.toLowerCase() === 'true'
10const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
11const minutesOf = (t: Ttl): number => (t === '1h' ? 60 : 5)
12
13/**
14 * First match wins: the cacheTtlMinutes option (any value above 0 pins it), FORCE_PROMPT_CACHING_5M,
15 * CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting, ENABLE_PROMPT_CACHING_1H, then the account: one hour on a
16 * Claude subscription within its plan usage, five minutes on usage credits, an API key or a cloud provider.
17 */
18export function decideTtl(pinnedMinutes: number, env: CacheEnv, setting?: unknown, account?: Account): TtlChoice {
19  if (pinnedMinutes > 0) return { minutes: pinnedMinutes, source: 'the cacheTtlMinutes setting' }
20  if (isOn(env.force5m)) return { minutes: 5, source: 'FORCE_PROMPT_CACHING_5M' }
21  const fromVar = asTtl(env.ttlVar)
22  if (fromVar) return { minutes: minutesOf(fromVar), source: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
23  const fromSetting = asTtl(setting)
24  if (fromSetting) return { minutes: minutesOf(fromSetting), source: 'the promptCacheTtl setting' }
25  if (isOn(env.enable1h)) return { minutes: 60, source: 'ENABLE_PROMPT_CACHING_1H' }
26  if (account === 'subscription') return { minutes: 60, source: 'subscription' }
27  if (account === 'credits') return { minutes: 5, source: 'usage credits' }
28  return { minutes: 5, source: 'API key or unknown account' }
29}
30
31/** A five-hour or seven-day window means a subscription; a full one means usage credits; none says nothing. */
32export function accountOf(windows: readonly { kind: string; percentUsed: number }[]): Account {
33  const plan = windows.filter(w => w.kind === 'five_hour' || w.kind === 'seven_day')
34  if (plan.length === 0) return 'other'
35  return plan.some(w => w.percentUsed >= 100) ? 'credits' : 'subscription'
36}
37
hooks/suggest.ts 16 lines
1// Which way forward costs least once the cache has gone cold. A plugin cannot delete or warm the cache after the
2// fact; what it can do is point at the cheapest next step. These are rules of thumb, not measured prices.
3
4export type Move = 'send' | 'compact' | 'clear'
5
6/** A context this big is worth shrinking: /compact reads it once, and every later message re-writes far less. */
7export const COMPACT_AT_TOKENS = 100_000
8/** After this long away the next message is most likely a new task, and a fresh conversation re-writes nothing. */
9export const FRESH_AFTER_MS = 6 * 60 * 60 * 1000
10
11export function bestMove(tokens: number, idleMs: number): { move: Move; why: string } {
12  if (idleMs >= FRESH_AFTER_MS) return { move: 'clear', why: 'after this long it is likely a new task, and a fresh start re-writes nothing' }
13  if (tokens >= COMPACT_AT_TOKENS) return { move: 'compact', why: 'a big context: compacting pays the re-read once, then every message is cheaper' }
14  return { move: 'send', why: 'a small context: compacting would cost about as much as sending' }
15}
16
types/index.d.ts 62 lines
1export type CacheTaxActivity = {
2  /** Last turn.start / turn.complete of the main loop, ms; 0 = unknown. */
3  at: number
4  /** Last keepwarm ping that touched the cache, ms; 0 = none. */
5  pingAt: number
6  /** Last known context size in tokens; 0 = unknown. */
7  tokens: number
8}
9
10export type CacheTaxKeepwarm = {
11  startedAt: number
12  until: number
13  intervalMs: number
14  pings: number
15  /** Estimated cost of the pings so far, USD. */
16  usd: number
17  /** Consecutive failed pings. */
18  failures: number
19  /** Cache-read tokens the last ping reported; null before the first. */
20  lastCacheRead: number | null
21  /** The last ping paid a cache write instead of a read (the cache had lapsed). */
22  lastWasCold: boolean
23}
24
25export type CacheTaxTotals = {
26  /** Cache-read and cache-write tokens summed over completed main-loop turns (from turn.complete usage). */
27  read: number
28  write: number
29  /** Last completed turn: all four counts summed, and its output tokens; 0 = no turn yet. */
30  lastTokens: number
31  lastOut: number
32  /** Uncached input tokens summed over the same turns (for the session hit rate). */
33  input: number
34}
35
36export type CacheTaxCache = {
37  /** When the main conversation's prompt cache goes cold, in $.clock.now() ms; null when unknown or before the first response. */
38  expiresAt: number | null
39  /** Cache-read share of all input tokens this session, 0-100, one decimal; null before the first response. */
40  hitRate: number | null
41}
42
43declare module 'claude-code' {
44  interface PluginState {
45    'cache-guard': {
46      activity: CacheTaxActivity
47      isBusy: boolean
48      /** The prompt that was stopped once; the same text again passes. */
49      held: { text: string } | null
50      /** Set when a prompt passed after the TTL: the next turn.complete prices the write. */
51      cold: { tokens: number } | null
52      paid: { count: number; usd: number }
53      totals: CacheTaxTotals
54      /** Display mirror for other mods: the cache's expiry clock time and the hit rate. */
55      cache: CacheTaxCache
56      /** The '/keepwarm 90m' hint was shown in a cold-cache question this session. */
57      hinted: boolean
58      keepwarm: CacheTaxKeepwarm | null
59    }
60  }
61}
62