SLOPSHOPPER

cache-keeper

Shows when the prompt cache lapses, keeps it warm with a few pings while idle, then compacts once and waits

newbandcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
› 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 › /cache-keeper ⎿ cache-keeper: cache 5:00 · ping 0/3 · compact in 17:41 ⎿ cache-keeper: TTL 5m (assumed) · model ? · context 0 · ping ≈ one cache read · cold rebuild 1.25× input ⎿ cache-keeper: (ping 30s before it lapses, 3 pings then compact) Cache Keeper idle · keeping the cache warm idle 1s ping ○○○ 0/3 → compact ○ next ping 1/3 in 4m 11s · cache lapses in 5m 00s ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% of the idle run to auto-compact TTL 5m (assumed) · transcript unreadable: ENOENT: transcript ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Cache Keeper idle · keeping the cache warm idle 1s ping ○○○ 0/3 → compact ○ next ping 1/3 in 4m 11s · cache lapses in 5m 00s ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% of the idle run to auto-compact TTL 5m (assumed) · transcript unreadable: ENOENT: transcript
README

claude-cache-keeper

A Claude Code plugin that keeps the prompt cache warm while you step away, then compacts the conversation once and waits for you.

Claude Code caches your conversation after each request, for 5 minutes or 1 hour depending on how it runs. Come back after the cache lapses and the next prompt writes the whole context into the cache again. One keep-alive ping only reads the cache, a small fraction of that price, and restarts the timer.

The plugin detects which TTL your session actually uses and times everything to it.

Cache Keeper idle · keeping the cache warm  idle 2m 15s
ping ●○○ 1/3 → compact ○  next ping 2/3 in 42s  · cache lapses in 1m 12s
███████░░░░░░░░░░░░░░░░░░░░░░░░░ 22% of the idle run to auto-compact
TTL 1h (auto) · opus-5-5 · context 305k · ping ≈ $0.06 · cold rebuild ≈ $2.44 (2× write)
cache hit 99.8% last request · 98.6% this session (119 requests)

5 minutes or 1 hour: what it costs

Claude Code picks the cache TTL itself (there is no setting for it). Subscription sessions have been seen on 1 hour, and the default API cache is 5 minutes. When it loads and after every turn, the plugin reads the newest responses in the session transcript: usage.cache_creation splits each write into ephemeral_5m_input_tokens and ephemeral_1h_input_tokens. A response with any 5-minute write counts as 5m, because its tail lapses first. Until a write has been seen, the plugin assumes 5m. Being wrong in that direction only costs an early ping, while assuming 1h too early would compact a cache that is about to lapse.

5-minute TTL1-hour TTL
Cache write1.25× input2× input
Cache read (a ping)0.1× input (0.05× Opus 5.5, 0.025× Fable 5.1)same
Lapses after5 min idle60 min idle

The 1-hour TTL pays more on every turn, but only on the new tokens that turn writes. In exchange it survives breaks up to an hour without any ping. With the defaults, the plugin pings at 4m 30s / 59m 30s, and the auto-compact lands about 18 min (5m) or about 4 h (1h) into an idle stretch.

The band's last row prices one ping and one cold rebuild of your current context, at first-party API list prices. On a subscription you pay in usage limits instead of dollars, but the ratio between ping and rebuild is the same.

What it does

With the defaults (5 min TTL, act 30 s before it lapses, 3 pings):

turn ends  → cache 5:00, idle starts
 4:30      → ping 1/3   (one tool-less request over the cached transcript)
 9:00      → ping 2/3
13:30      → ping 3/3
18:00      → auto-compact, while the cache is still warm (cheap to read)
           → "compacted · waiting for you": counts the new cache down,
             never pings or compacts again until you do something
  • Never compacts twice. After the idle compaction, or your own /compact, it goes dormant. Only your next prompt or turn re-arms it, so a long absence can't summarise the context away.
  • Holds compaction while you have a draft typed in the prompt box, an agent is running, or background tasks (shells included) were still in flight when the last turn stopped. It keeps pinging instead, up to 6 more times.
  • Shows your cache hit rate, for the last request and for the whole session. Each API response is counted once, though Claude Code writes one response over several transcript rows. A cache collapse heals by the next turn, so it is easy to miss. When a request reads back less than half of the prompt before it and writes the rest anew, the band warns, with what that cost: ⚠ cache rebuilt by the last request: read 0 / wrote 203k (≈ $1.62). The rebuild right after a compaction is expected and not flagged.
  • Pings slightly off the beat: each window acts a random 0–20 s earlier (at most 10% of the TTL), never later.
  • Stops when pinging is pointless. If the cache has already lapsed (laptop asleep, /model switched) or pings keep failing, it shows cache cold and does nothing. A ping then would only pay for a full re-cache.
  • Ignores its own requests, so pings and compactions never re-arm the timer. Subagent turns don't reset it either: they use their own transcript, not the main thread's cache.

Install

Inside Claude Code:

/plugin marketplace add sorajate/claude-cache-keeper
/plugin install cache-keeper@claude-cache-keeper

Or from a terminal:

claude plugin marketplace add sorajate/claude-cache-keeper
claude plugin install cache-keeper@claude-cache-keeper

Start a new session. The band appears above the prompt after the first reply.

Update later with claude plugin marketplace update claude-cache-keeper and then claude plugin update cache-keeper@claude-cache-keeper.

Try it without installing

git clone https://github.com/sorajate/claude-cache-keeper
claude --plugin-dir ./claude-cache-keeper/plugins/cache-keeper

Use

Command
/cache-keeperCurrent state and settings
/cache-keeper off / onPause or resume pinging and compaction

Settings live under /config → cache-keeper, or /plugin configure cache-keeper@claude-cache-keeper:

OptionDefault
ttlSeconds00 detects the TTL from the session. Any other value forces that many seconds.
leadSeconds30How long before the cache lapses to ping or compact
jitterSeconds20Act up to this much earlier still, at random (capped at 10% of the TTL; 0 for exact timing)
maxPings3Pings per idle stretch before compacting
compactInstructionsemptyWhat the idle compaction's summary should keep
displaybandband (above the prompt), status (one line), or both

Tip: set ttlSeconds to 60 for a few minutes to watch a whole cycle quickly, then set it back to 0.

Requirements and caveats

  • Claude Code with function-hook plugins (built and tested on 2.1.287). That plugin API is early access and may change between releases.
  • Each ping is a real API request: it reads the cached context (about 0.1× input price) plus a tiny reply.
  • The countdown after a compaction is for information only. The compacted conversation is cached by your next request.
  • TTL detection reads the session transcript after each turn. A plugin can read at most 4 MiB at once, so for a longer transcript the plugin reads its last 3 MiB with tail, or with PowerShell on Windows. The hit rate then covers the last N requests, not the whole session. If the plugin can't read the transcript, the band shows TTL 5m (assumed) and the reason.
  • Prices are a built-in table of first-party list prices. An unknown model shows multipliers instead of dollars.

Develop

cd plugins/cache-keeper
claude plugin validate .
claude plugin test .

Claude Code writes the API typings to .claude-plugin/types/ the first time it loads the plugin from disk, and after that tsc -p . type-checks it. That folder is git-ignored on purpose: its MCP typings list the tools of whoever loaded the plugin.

BUILD.md is a complete brief for another Claude Code agent to rebuild or extend this plugin.

License

MIT

Source 2 files
hooks/register.tsx 752 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { CacheInfo, CacheRebuild, CacheTtl, KeeperState } from '../types'
5
6type $ = EngineInterface
7type CompactResult = Awaited<ReturnType<$['session']['compact']>>
8
9const PING_PROMPT =
10  'Prompt-cache keep-alive from the cache-keeper plugin. Reply with exactly: ok'
11const TICK_MS = 1000
12const RETRY_MS = 10_000
13// Pings allowed past maxPings while compaction is held (draft typed, agents running).
14const HOLD_PING_CAP = 6
15// Until a cache write shows which TTL the session uses, assume the shorter one:
16// pinging a 1-hour cache early costs a cheap read, compacting it early costs the context.
17const DEFAULT_TTL_MS = 300_000
18const TTL_MS: Record<CacheTtl, number> = { '5m': 300_000, '1h': 3_600_000 }
19// $.fs.read rejects past 4 MiB: a longer transcript has only its tail read, by a
20// child process, whose output stays under the same 4 MiB cap.
21const READ_LIMIT_BYTES = 4 * 1024 * 1024
22const TAIL_BYTES = 3 * 1024 * 1024
23// The tail on Windows, where no `tail` ships: the path and size come in through the
24// environment, so nothing of them is ever parsed as script.
25const TAIL_SCRIPT = [
26  '$n = [int64]$env:CACHE_KEEPER_TAIL',
27  "$f = [IO.File]::Open($env:CACHE_KEEPER_PATH, 'Open', 'Read', 'ReadWrite')",
28  'try {',
29  '  $start = [Math]::Max([int64]0, $f.Length - $n)',
30  "  [void]$f.Seek($start, 'Begin')",
31  '  $b = New-Object byte[] ($f.Length - $start)',
32  '  $t = 0',
33  '  while ($t -lt $b.Length) { $k = $f.Read($b, $t, $b.Length - $t); if ($k -le 0) { break }; $t += $k }',
34  '  $o = [Console]::OpenStandardOutput(); $o.Write($b, 0, $t); $o.Flush()',
35  '} finally { $f.Close() }',
36].join('\n')
37
38const INITIAL: KeeperState = {
39  phase: 'unknown',
40  isOff: false,
41  pings: 0,
42  holdPings: 0,
43  expiresAt: 0,
44  epoch: 0,
45  turnId: '',
46  retryAt: 0,
47  note: '',
48  idleSince: 0,
49  activeSince: 0,
50  cache: { ttl: '', model: '', contextTokens: 0, detail: '', hitLast: -1, hitSession: -1, requests: 0, isTail: false, rebuild: null },
51  transcriptPath: '',
52  backgroundTasks: 0,
53  jitterMs: 0,
54}
55
56const keeper = atom({ plugin: 'cache-keeper', key: 'keeper' } as const, INITIAL)
57// Written every tick so the band redraws each second.
58const ticker = atom({ plugin: 'cache-keeper', key: 'now' } as const, 0)
59
60// ttlMs and leadMs are the effective values: the override when set, else what was detected.
61const config = {
62  ttlMs: DEFAULT_TTL_MS,
63  leadMs: 30_000,
64  overrideTtlMs: 0,
65  leadSettingMs: 30_000,
66  jitterSettingMs: 20_000,
67  maxPings: 3,
68  instructions: '',
69  hasBand: true,
70  hasStatus: false,
71}
72
73// Our own fork or compaction in flight: events it raises are not the person's activity.
74let selfBusy = 0
75let isActing = false
76let expectTurn = false
77
78function positive(value: unknown, fallback: number) {
79  return typeof value === 'number' && value > 0 ? value : fallback
80}
81
82function configure(options: PluginOptions) {
83  config.overrideTtlMs = positive(options.ttlSeconds, 0) * 1000
84  config.leadSettingMs = positive(options.leadSeconds, 30) * 1000
85  const jitter = options.jitterSeconds
86  config.jitterSettingMs = typeof jitter === 'number' && jitter >= 0 ? jitter * 1000 : 20_000
87  useTtl(config.overrideTtlMs || DEFAULT_TTL_MS)
88  config.maxPings = Math.floor(positive(options.maxPings, 3))
89  const text = options.compactInstructions
90  config.instructions = typeof text === 'string' ? text.trim() : ''
91  const display = options.display
92  config.hasBand = display !== 'status'
93  config.hasStatus = display === 'status' || display === 'both'
94}
95
96function useTtl(ms: number) {
97  config.ttlMs = ms
98  config.leadMs = Math.min(config.leadSettingMs, ms / 2)
99}
100
101// A fresh random head start for the next window: pings off an exact beat.
102function drawJitter() {
103  return Math.random() * Math.min(config.jitterSettingMs, config.ttlMs / 10)
104}
105
106// When this window's ping or compaction is due.
107function actAt(s: KeeperState) {
108  return Math.max(s.expiresAt - config.leadMs - s.jitterMs, s.retryAt)
109}
110
111// The effective TTL for what the transcript showed, unless the person forced one.
112function applyCache(cache: CacheInfo) {
113  if (config.overrideTtlMs > 0) return
114  useTtl(cache.ttl === '' ? DEFAULT_TTL_MS : TTL_MS[cache.ttl])
115}
116
117function ttlLabel(cache: CacheInfo) {
118  if (config.overrideTtlMs > 0) return `TTL ${span(config.ttlMs)} (set)`
119  return cache.ttl === '' ? 'TTL 5m (assumed)' : `TTL ${cache.ttl} (auto)`
120}
121
122type UsageRow = {
123  type?: string
124  isSidechain?: boolean
125  message?: {
126    id?: string
127    model?: string
128    usage?: {
129      input_tokens?: number
130      cache_read_input_tokens?: number
131      cache_creation_input_tokens?: number
132      cache_creation?: { ephemeral_5m_input_tokens?: number; ephemeral_1h_input_tokens?: number }
133    }
134  }
135}
136
137type Request = {
138  model: string
139  read: number
140  wrote: number
141  uncached: number
142  ttl: CacheTtl | ''
143  // A compaction came between this request and the one before: its rebuild is expected.
144  isAfterCompaction: boolean
145}
146
147// A rebuild worth a warning: the previous prompt was sizeable, this one read back
148// less than half of it and wrote at least that much anew.
149const REBUILD_MIN_TOKENS = 20_000
150
151function promptOf(request: Request) {
152  return request.read + request.wrote + request.uncached
153}
154
155// Reads a session transcript's main-thread responses, one per API response
156// (Claude Code splits a response over several rows that share its message id):
157// the last one's model and context, the TTL of the latest that wrote cache (any
158// 5-minute write counts as 5m: its tail lapses first), and the hit rates.
159export function sampleTranscript(text: string): CacheInfo | undefined {
160  const requests: Request[] = []
161  const seen = new Set<string>()
162  let isAfterCompaction = false
163  for (const line of text.split('\n')) {
164    if (line.includes('"compact_boundary"')) isAfterCompaction = true
165    if (!line.includes('"usage"')) continue
166    let row: UsageRow
167    try {
168      row = JSON.parse(line) as UsageRow
169    } catch {
170      continue
171    }
172    const usage = row.message?.usage
173    if (row.type !== 'assistant' || row.isSidechain === true || usage === undefined) continue
174    const read = usage.cache_read_input_tokens ?? 0
175    const wrote = usage.cache_creation_input_tokens ?? 0
176    const uncached = usage.input_tokens ?? 0
177    const id = row.message?.id ?? `${read}/${wrote}/${uncached}`
178    if (seen.has(id)) continue
179    seen.add(id)
180    const split = usage.cache_creation
181    const ttl = (split?.ephemeral_5m_input_tokens ?? 0) > 0 ? '5m' : (split?.ephemeral_1h_input_tokens ?? 0) > 0 ? '1h' : ''
182    requests.push({ model: row.message?.model ?? '', read, wrote, uncached, ttl, isAfterCompaction })
183    isAfterCompaction = false
184  }
185
186  const last = requests.at(-1)
187  if (last === undefined) return undefined
188  let read = 0
189  let total = 0
190  let ttl: CacheTtl | '' = ''
191  for (const request of requests) {
192    read += request.read
193    total += promptOf(request)
194    if (request.ttl !== '') ttl = request.ttl
195  }
196  const previous = requests.at(-2)
197  const isRebuild =
198    previous !== undefined &&
199    !last.isAfterCompaction &&
200    promptOf(previous) >= REBUILD_MIN_TOKENS &&
201    last.read < promptOf(previous) / 2 &&
202    last.wrote >= promptOf(previous) / 2
203  const rebuild: CacheRebuild | null = isRebuild ? { read: last.read, wrote: last.wrote } : null
204  const context = promptOf(last)
205  return {
206    ttl,
207    model: last.model,
208    contextTokens: context,
209    detail: '',
210    hitLast: context > 0 ? last.read / context : -1,
211    hitSession: total > 0 ? read / total : -1,
212    requests: requests.length,
213    isTail: false,
214    rebuild,
215  }
216}
217
218type Rate = { input: number; read: number }
219
220// First-party $/MTok (cached 2026-09-25). Longest matching prefix wins.
221const RATES: [string, Rate][] = [
222  ['claude-fable-5-1', { input: 10, read: 0.25 }],
223  ['claude-mythos-5-1', { input: 10, read: 0.25 }],
224  ['claude-fable-5', { input: 10, read: 1 }],
225  ['claude-mythos-5', { input: 10, read: 1 }],
226  ['claude-opus-5-5', { input: 4, read: 0.2 }],
227  ['claude-opus-5', { input: 5, read: 0.5 }],
228  ['claude-opus-4', { input: 5, read: 0.5 }],
229  ['claude-sonnet-5-5', { input: 2, read: 0.2 }],
230  ['claude-sonnet-5', { input: 2, read: 0.2 }],
231  ['claude-sonnet-4', { input: 3, read: 0.3 }],
232  ['claude-haiku-4', { input: 1, read: 0.1 }],
233]
234
235function rateOf(model: string) {
236  const id = model.replace(/^(us\.)?anthropic\./, '')
237  let best: [string, Rate] | undefined
238  for (const entry of RATES) {
239    if (id.startsWith(entry[0]) && (best === undefined || entry[0].length > best[0].length)) best = entry
240  }
241  return best?.[1]
242}
243
244function dollars(amount: number) {
245  return amount < 0.01 ? '<$0.01' : `$${amount.toFixed(2)}`
246}
247
248function thousands(tokens: number) {
249  return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(tokens)
250}
251
252function percent(share: number) {
253  return share < 0 ? '-' : `${(share * 100).toFixed(1)}%`
254}
255
256export function hitLine(cache: CacheInfo) {
257  if (cache.requests === 0) return undefined
258  const scope = cache.isTail ? `the last ${cache.requests} requests` : `this session (${cache.requests} requests)`
259  return `cache hit ${percent(cache.hitLast)} last request · ${percent(cache.hitSession)} ${scope}`
260}
261
262// The warning for a request that rebuilt the cache, priced as a write at the session's TTL.
263export function rebuildLine(cache: CacheInfo) {
264  if (cache.rebuild === null) return undefined
265  const { read, wrote } = cache.rebuild
266  const rate = rateOf(cache.model)
267  const writeRate = cache.ttl === '1h' ? 2 : 1.25
268  const cost = rate === undefined ? '' : ` (≈ ${dollars((wrote * rate.input * writeRate) / 1e6)})`
269  return `⚠ cache rebuilt by the last request: read ${thousands(read)} / wrote ${thousands(wrote)}${cost}`
270}
271
272// What one keep-alive and one cold rebuild of this context cost, as the band shows them.
273export function priceLine(cache: CacheInfo) {
274  const isHour = config.overrideTtlMs > 0 ? config.ttlMs > 300_000 : cache.ttl === '1h'
275  const writeRate = isHour ? 2 : 1.25
276  const model = cache.model.replace(/^claude-/, '') || 'model ?'
277  const head = `${ttlLabel(cache)} · ${model} · context ${thousands(cache.contextTokens)}`
278  const rate = rateOf(cache.model)
279  if (rate === undefined || cache.contextTokens === 0) {
280    return `${head} · ping ≈ one cache read · cold rebuild ${writeRate}× input`
281  }
282  const pingCost = (cache.contextTokens * rate.read) / 1e6
283  const rebuildCost = (cache.contextTokens * rate.input * writeRate) / 1e6
284  return `${head} · ping ≈ ${dollars(pingCost)} · cold rebuild ≈ ${dollars(rebuildCost)} (${writeRate}× write)`
285}
286
287// Where Claude Code keeps this session's transcript, for a load before any Stop
288// event has named it: <config dir>/projects/<root, non-alphanumerics as '-'>/<id>.jsonl.
289async function guessTranscript($: $) {
290  const configured = await $.env.get('CLAUDE_CONFIG_DIR')
291  const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
292  const configDir = configured ?? (home === undefined ? undefined : `${home}/.claude`)
293  if (configDir === undefined) return ''
294  const project = (await $.session.root()).replace(/[^A-Za-z0-9]/g, '-')
295  return `${configDir}/projects/${project}/${await $.session.id()}.jsonl`
296}
297
298async function noteDetail($: $, detail: string) {
299  await update($, keeper, (s): KeeperState =>
300    s.cache.model === '' ? { ...s, cache: { ...s.cache, detail } } : s,
301  )
302}
303
304// The transcript's last TAIL_BYTES, from its first whole line on.
305async function readTail($: $, transcriptPath: string) {
306  const isWindows = /^[A-Za-z]:|\\/.test(transcriptPath)
307  const argv = isWindows
308    ? ['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', TAIL_SCRIPT]
309    : ['tail', '-c', String(TAIL_BYTES), transcriptPath]
310  const env = { CACHE_KEEPER_PATH: transcriptPath, CACHE_KEEPER_TAIL: String(TAIL_BYTES) }
311  const result = await $.process.run(argv, { env, timeoutMs: 15_000 })
312  if (result.exitCode !== 0) throw new Error(result.stderr.trim().split('\n')[0] || `tail exited ${result.exitCode}`)
313  return result.stdout.slice(result.stdout.indexOf('\n') + 1)
314}
315
316async function detect($: $, transcriptPath: string) {
317  let text: string
318  let isTail = false
319  try {
320    const stat = await $.fs.stat(transcriptPath)
321    if (stat.kind !== 'file') return noteDetail($, 'checked after the next reply')
322    isTail = stat.size > READ_LIMIT_BYTES
323    text = isTail ? await readTail($, transcriptPath) : await $.fs.read(transcriptPath)
324  } catch (error) {
325    const reason = (error instanceof Error ? error.message : String(error)).replace(transcriptPath, 'transcript')
326    return noteDetail($, `transcript unreadable: ${reason.slice(0, 100)}`)
327  }
328  const sampled = sampleTranscript(text)
329  if (sampled === undefined) return noteDetail($, 'checked after the next reply')
330  const cache = { ...sampled, isTail }
331  const before = config.ttlMs
332  applyCache(cache)
333  const after = config.ttlMs
334  await update($, keeper, (s): KeeperState => {
335    const isRetimed = s.phase === 'warm' && after !== before && s.idleSince > 0
336    // Re-time the countdown the last turn started under the old TTL.
337    const next = { ...s, cache, transcriptPath }
338    return isRetimed ? { ...next, expiresAt: s.idleSince + after } : next
339  })
340}
341
342function clock(ms: number) {
343  const total = Math.max(0, Math.ceil(ms / 1000))
344  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
345}
346
347async function markBusy($: $, turnId?: string) {
348  const now = await $.clock.now()
349  await update($, keeper, (s): KeeperState => ({
350    ...s,
351    phase: 'busy',
352    activeSince: s.phase === 'busy' ? s.activeSince : now,
353    pings: 0,
354    holdPings: 0,
355    retryAt: 0,
356    epoch: s.epoch + 1,
357    turnId: turnId ?? s.turnId,
358    note: '',
359  }))
360}
361
362async function markWarm($: $) {
363  const now = await $.clock.now()
364  await update($, keeper, (s): KeeperState => ({
365    ...s,
366    phase: 'warm',
367    pings: 0,
368    holdPings: 0,
369    expiresAt: now + config.ttlMs,
370    jitterMs: drawJitter(),
371    retryAt: 0,
372    epoch: s.epoch + 1,
373    idleSince: now,
374    note: '',
375  }))
376}
377
378// After any compaction: count the new cache down, but never ping or compact it again
379// until the person does something.
380async function markDormant($: $, note: string, isActivity: boolean) {
381  const now = await $.clock.now()
382  await update($, keeper, (s): KeeperState => ({
383    ...s,
384    phase: 'dormant',
385    pings: isActivity ? 0 : s.pings,
386    holdPings: 0,
387    expiresAt: now + config.ttlMs,
388    retryAt: 0,
389    epoch: isActivity ? s.epoch + 1 : s.epoch,
390    idleSince: isActivity ? now : s.idleSince,
391    note,
392  }))
393}
394
395// Why compaction should wait even though the pings are spent.
396async function holdReason($: $, s: KeeperState) {
397  const box = await $.prompt.read()
398  if (box.text.trim() !== '') return 'draft typed'
399  const agents = await $.agent.list()
400  if (agents.some(agent => agent.status === 'running')) return 'agents running'
401  // Shells and agents in flight at the last stop: each one's end wakes the session.
402  if (s.backgroundTasks > 0) return 'background tasks running'
403  return undefined
404}
405
406async function ping($: $, from: KeeperState, hold?: string) {
407  const isHeld = hold !== undefined
408  await update($, keeper, (s): KeeperState => ({ ...s, phase: 'pinging' }))
409  const sentAt = await $.clock.now()
410  selfBusy += 1
411  const reply = await $.model.fork({ prompt: PING_PROMPT }).finally(() => {
412    selfBusy -= 1
413  })
414  const now = await $.clock.now()
415
416  await update($, keeper, (s): KeeperState => {
417    if (s.epoch !== from.epoch) return s
418    if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
419      return { ...s, phase: 'unknown', note: '' }
420    }
421    const served = reply.usage.cache_read_input_tokens
422    const written = reply.usage.cache_creation_input_tokens
423    const isSent = reply.isAnswered || reply.reason === 'empty-reply'
424    if (isSent && served > 0 && served >= written) {
425      return {
426        ...s,
427        phase: 'warm',
428        pings: isHeld ? s.pings : s.pings + 1,
429        holdPings: isHeld ? s.holdPings + 1 : s.holdPings,
430        expiresAt: sentAt + config.ttlMs,
431        jitterMs: drawJitter(),
432        retryAt: 0,
433        note: isHeld ? `compact held: ${hold}` : '',
434      }
435    }
436    if (isSent) {
437      // The prefix was written afresh: the entry had already lapsed (a /model, a sleep).
438      return { ...s, phase: 'cold', note: 'ping missed the cache; stopped' }
439    }
440    return { ...s, phase: 'warm', retryAt: now + RETRY_MS, note: `ping failed (${reply.reason})` }
441  })
442}
443
444async function compact($: $, from: KeeperState) {
445  await update($, keeper, (s): KeeperState => ({ ...s, phase: 'compacting' }))
446  selfBusy += 1
447  let result: CompactResult | undefined
448  try {
449    result = await $.session.compact(
450      config.instructions === '' ? undefined : { instructions: config.instructions },
451    )
452  } catch {
453    result = undefined
454  } finally {
455    selfBusy -= 1
456  }
457
458  const current = await read($, keeper)
459  if (current.epoch !== from.epoch) return
460  if (result === undefined) {
461    // Never retried: a loop of compactions is what this plugin must not cause.
462    await update($, keeper, (s): KeeperState => ({ ...s, phase: 'cold', note: 'auto-compact failed' }))
463    return
464  }
465  await markDormant($, result.skip === undefined ? '' : `compact skipped: ${result.skip}`, false)
466  $.ui.toast('cache-keeper: idle, conversation compacted; waiting for you')
467}
468
469async function act($: $, s: KeeperState, now: number) {
470  if (now >= s.expiresAt) {
471    await update($, keeper, (cur): KeeperState =>
472      cur.epoch === s.epoch ? { ...cur, phase: 'cold', note: 'lapsed before a ping' } : cur,
473    )
474    return
475  }
476  if (now < actAt(s)) return
477
478  if (s.pings < config.maxPings) return ping($, s)
479  const hold = await holdReason($, s)
480  if (hold === undefined) return compact($, s)
481  if (s.holdPings < HOLD_PING_CAP) return ping($, s, hold)
482  // Held too long: let it lapse rather than ping forever.
483}
484
485function describe(s: KeeperState, now: number) {
486  if (s.isOff) return 'cache-keeper off'
487  const left = s.expiresAt - now
488  switch (s.phase) {
489    case 'unknown':
490      return undefined
491    case 'busy':
492      return 'cache: active'
493    case 'pinging':
494      return 'cache: pinging…'
495    case 'compacting':
496      return 'cache: compacting…'
497    case 'cold':
498      return `cache cold${s.note === '' ? '' : ` · ${s.note}`}`
499    case 'dormant':
500      return left > 0
501        ? `cache ${clock(left)} · compacted, waiting for you`
502        : 'cache cold · compacted, waiting for you'
503    case 'warm': {
504      const { leadMs, ttlMs, maxPings } = config
505      const spent = Math.min(s.pings, maxPings)
506      const compactAt = actAt(s) + (maxPings - spent) * (ttlMs - leadMs)
507      const tail = s.holdPings > 0 ? '' : ` · compact in ${clock(compactAt - now)}`
508      const note = s.note === '' ? '' : ` · ${s.note}`
509      return `cache ${clock(left)} · ping ${spent}/${maxPings}${tail}${note}`
510    }
511  }
512}
513
514async function tick($: $) {
515  const now = await $.clock.now()
516  const s = await read($, keeper)
517  if (!s.isOff && !isActing && s.phase === 'warm') {
518    isActing = true
519    try {
520      await act($, s, now)
521    } finally {
522      isActing = false
523    }
524  }
525  const after = await $.clock.now()
526  await update($, ticker, () => after)
527  if (config.hasStatus) $.ui.status(describe(await read($, keeper), after))
528}
529
530type Tone = 'green' | 'yellow' | 'red' | 'cyan' | 'gray'
531
532type BandView = {
533  tone: Tone
534  title: string
535  idle: string
536  steps: string
537  next?: { label: string; left: string; tone: Tone }
538  cache?: string
539  progress?: number
540  price?: string
541  hits?: string
542  rebuild?: string
543  note: string
544}
545
546// A time this plugin recorded: absent, zero or NaN means it never saw it.
547function known(at: number | undefined): at is number {
548  return typeof at === 'number' && Number.isFinite(at) && at > 0
549}
550
551function span(ms: number) {
552  if (!Number.isFinite(ms)) return '-'
553  const total = Math.max(0, Math.ceil(ms / 1000))
554  if (total < 60) return `${total}s`
555  const hours = Math.floor(total / 3600)
556  const minutes = Math.floor((total % 3600) / 60)
557  const rest = `${minutes}m ${String(total % 60).padStart(2, '0')}s`
558  return hours > 0 ? `${hours}h ${rest}` : rest
559}
560
561function urgency(ms: number): Tone {
562  return ms <= 15_000 ? 'red' : ms <= 60_000 ? 'yellow' : 'green'
563}
564
565function steps(s: KeeperState) {
566  const { maxPings } = config
567  const spent = Math.min(s.pings, maxPings)
568  const dots = Array.from({ length: maxPings }, (_, i) => (i < spent ? '●' : '○')).join('')
569  const compacted = s.phase === 'dormant' ? '●' : '○'
570  return `ping ${dots} ${spent}/${maxPings} → compact ${compacted}`
571}
572
573function bandView(s: KeeperState, now: number): BandView | undefined {
574  const { leadMs, ttlMs, maxPings } = config
575  const idle = known(s.idleSince) && s.phase !== 'busy' ? span(now - s.idleSince) : '-'
576  const note = s.note
577  const price =
578    s.cache.model === '' ? `${ttlLabel(s.cache)} · ${s.cache.detail || 'checked after the next reply'}` : priceLine(s.cache)
579  const base = { idle, steps: steps(s), note, price, hits: hitLine(s.cache), rebuild: rebuildLine(s.cache) }
580  const cache = s.expiresAt > now ? span(s.expiresAt - now) : 'lapsed'
581
582  if (s.isOff) return { ...base, tone: 'gray', title: 'off · /cache-keeper on to resume' }
583  switch (s.phase) {
584    case 'unknown':
585      return undefined
586    case 'busy':
587      return { ...base, tone: 'cyan', title: `working${known(s.activeSince) ? ` ${span(now - s.activeSince)}` : ''} · every request refreshes the cache` }
588    case 'pinging':
589      return { ...base, tone: 'cyan', title: `pinging ${Math.min(s.pings + 1, maxPings)}/${maxPings}…`, cache }
590    case 'compacting':
591      return { ...base, tone: 'cyan', title: 'compacting…', cache }
592    case 'cold':
593      return { ...base, tone: 'gray', title: 'cache cold · next prompt re-caches the context' }
594    case 'dormant':
595      return { ...base, tone: 'gray', title: 'compacted · waiting for you, no more pings', cache }
596    case 'warm': {
597      const dueAt = actAt(s)
598      const spent = Math.min(s.pings, maxPings)
599      const isHeld = s.holdPings > 0
600      const label = spent < maxPings ? `ping ${spent + 1}/${maxPings}` : isHeld ? 'ping (compact held)' : 'auto-compact'
601      const compactAt = dueAt + (maxPings - spent) * (ttlMs - leadMs)
602      const fraction = (now - s.idleSince) / Math.max(1, compactAt - s.idleSince)
603      const progress =
604        isHeld || !known(s.idleSince) || !Number.isFinite(fraction)
605          ? undefined
606          : Math.min(1, Math.max(0, fraction))
607      return {
608        ...base,
609        tone: 'green',
610        title: 'idle · keeping the cache warm',
611        next: { label, left: span(dueAt - now), tone: urgency(dueAt - now) },
612        cache,
613        progress,
614      }
615    }
616  }
617}
618
619function bar(fraction: number, width: number) {
620  const filled = Math.round(fraction * width)
621  return '█'.repeat(filled) + '░'.repeat(width - filled)
622}
623
624export const register: Register = (on, options) => {
625  configure(options)
626
627  on('session.start', async ($, e, next) => {
628    const started = await next(e)
629    if (!e.isInteractive) return started
630
631    await $.command.register({
632      name: 'cache-keeper',
633      description: 'Prompt-cache keeper: status, on or off',
634      argumentHint: '[status|on|off]',
635    })
636    // A reload keeps the state an older version wrote and drops the old module
637    // mid-action: fill in the fields it lacked and settle what it left behind.
638    await update($, keeper, (saved): KeeperState => {
639      const s = { ...INITIAL, ...saved, cache: { ...INITIAL.cache, ...saved?.cache } }
640      applyCache(s.cache)
641      return s.phase === 'pinging'
642        ? { ...s, phase: 'warm' }
643        : s.phase === 'compacting'
644          ? { ...s, phase: 'cold', note: 'reloaded mid-compact' }
645          : s
646    })
647    $.clock.every(TICK_MS, () => void tick($))
648    // Detect at once rather than after the next reply: a reload, a resumed session.
649    try {
650      const known = (await read($, keeper)).transcriptPath
651      const path = known !== '' ? known : await guessTranscript($)
652      if (path !== '') await detect($, path)
653    } catch {
654      // No guess: the first Stop event names the transcript.
655    }
656    return started
657  })
658
659  on('prompt.submit', async ($, e, next) => {
660    if (e.origin.kind !== 'plugin') {
661      expectTurn = true
662      await markBusy($)
663    }
664    return next(e)
665  })
666
667  on('turn.start', async ($, e, next) => {
668    if (selfBusy === 0 || expectTurn) {
669      expectTurn = false
670      await markBusy($, e.turnId)
671    }
672    return next(e)
673  })
674
675  on('turn.complete', async ($, e, next) => {
676    const done = await next(e)
677    const s = await read($, keeper)
678    if (e.agentId === undefined && e.turnId === s.turnId) await markWarm($)
679    return done
680  })
681
682  on('classic.Stop', async ($, e, next) => {
683    const result = await next(e)
684    if (e.agent_id !== undefined) return result
685    const inFlight = e.background_tasks?.length ?? 0
686    await update($, keeper, (s): KeeperState => ({ ...s, backgroundTasks: inFlight }))
687    if (e.transcript_path !== '') await detect($, e.transcript_path)
688    return result
689  })
690
691  on('session.compact', async ($, e, next) => {
692    const result = await next(e)
693    if (e.agentId === undefined && e.trigger === 'manual' && result.skip === undefined) {
694      await markDormant($, '', true)
695    }
696    return result
697  })
698
699  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
700    if (!config.hasBand || e.props.hasSurvey) return next(e)
701    const ticked = await read($, ticker)
702    const view = bandView(await read($, keeper), ticked > 0 ? ticked : await $.clock.now())
703    if (view === undefined) return next(e)
704
705    const { Box, Text } = $.ui.resolve(e)
706    const width = Math.max(10, Math.min(40, e.props.bodyColumns - 34))
707    const compactAt = view.progress === undefined ? undefined : `${Math.round(view.progress * 100)}% of the idle run to auto-compact`
708
709    return (
710      <Box flexDirection="column">
711        <Box flexDirection="row">
712          <Text bold>Cache Keeper </Text>
713          <Text color={view.tone} wrap="truncate-end">{view.title}</Text>
714          <Text dimColor>{`  idle ${view.idle}`}</Text>
715        </Box>
716        {e.props.maxRows >= 2 && (
717          <Box flexDirection="row">
718            <Text>{view.steps}</Text>
719            {view.next !== undefined && <Text>{`  next ${view.next.label} in `}</Text>}
720            {view.next !== undefined && <Text bold color={view.next.tone}>{view.next.left}</Text>}
721            {view.cache !== undefined && <Text dimColor wrap="truncate-end">{`  · cache lapses in ${view.cache}`}</Text>}
722          </Box>
723        )}
724        {e.props.maxRows >= 3 && compactAt !== undefined && view.progress !== undefined && (
725          <Text dimColor wrap="truncate-end">{`${bar(view.progress, width)} ${compactAt}`}</Text>
726        )}
727        {e.props.maxRows >= 4 && view.price !== undefined && <Text dimColor wrap="truncate-end">{view.price}</Text>}
728        {e.props.maxRows >= 5 && view.hits !== undefined && <Text dimColor wrap="truncate-end">{view.hits}</Text>}
729        {e.props.maxRows >= 3 && view.rebuild !== undefined && <Text color="yellow" wrap="truncate-end">{view.rebuild}</Text>}
730        {e.props.maxRows >= 3 && view.note !== '' && <Text color="yellow" wrap="truncate-end">{view.note}</Text>}
731      </Box>
732    )
733  })
734
735  on('command.run', { command: 'cache-keeper' }, async ($, e) => {
736    const arg = e.args.trim().toLowerCase()
737    if (arg === 'on' || arg === 'off') {
738      await update($, keeper, (s): KeeperState => ({ ...s, isOff: arg === 'off' }))
739      await tick($)
740      return { text: `cache-keeper ${arg}` }
741    }
742    const line = describe(await read($, keeper), await $.clock.now()) ?? 'cache: no response yet'
743    const s = await read($, keeper)
744    const { leadMs, maxPings } = config
745    return {
746      text: [line, priceLine(s.cache), hitLine(s.cache), rebuildLine(s.cache), `(ping ${leadMs / 1000}s before it lapses, ${maxPings} pings then compact)`]
747        .filter(part => part !== undefined)
748        .join('\n'),
749    }
750  })
751}
752
types/index.d.ts 72 lines
1export type KeeperPhase =
2  | 'unknown'
3  | 'busy'
4  | 'warm'
5  | 'pinging'
6  | 'compacting'
7  | 'dormant'
8  | 'cold'
9
10export type CacheTtl = '5m' | '1h'
11
12/** What the last main-thread request showed: read from the session transcript after each turn. */
13export type CacheInfo = {
14  /** The TTL its cache writes used; '' until a write has been seen. */
15  ttl: CacheTtl | ''
16  model: string
17  /** Prompt tokens the next request re-sends (input + cache read + cache write). */
18  contextTokens: number
19  /** Why nothing was detected yet ('' once it was): no reply yet, or the transcript unreadable. */
20  detail: string
21  /** Share of the last request's prompt served from cache, 0 to 1; -1 when unknown. */
22  hitLast: number
23  /** Share of all main-thread prompt tokens this session served from cache, 0 to 1; -1 when unknown. */
24  hitSession: number
25  /** Main-thread API requests counted (one per response, however many transcript rows it spans). */
26  requests: number
27  /** Only the transcript's tail was read (it is past what one read takes): the rate covers those requests. */
28  isTail: boolean
29  /** The last request rebuilt most of a prompt the one before had cached; null when it did not. */
30  rebuild: CacheRebuild | null
31}
32
33/** A request that wrote back most of what the previous one had read: a collapse or a lapse. */
34export type CacheRebuild = {
35  read: number
36  wrote: number
37}
38
39export type KeeperState = {
40  phase: KeeperPhase
41  isOff: boolean
42  /** Keep-alive pings sent in this idle stretch. */
43  pings: number
44  /** Extra pings sent because compaction was held (draft typed, agents running). */
45  holdPings: number
46  /** When the cache entry lapses, ms since the epoch; 0 when unknown. */
47  expiresAt: number
48  /** Bumped on every real activity, so a ping or compaction that lands late is dropped. */
49  epoch: number
50  /** The main turn this plugin saw start outside its own requests. */
51  turnId: string
52  retryAt: number
53  note: string
54  /** When the person last left the session idle (the main turn ended); 0 when unknown. */
55  idleSince: number
56  /** When the running turn began. */
57  activeSince: number
58  cache: CacheInfo
59  /** The session transcript, as the last Stop event named it; '' until then. */
60  transcriptPath: string
61  /** Background tasks (shells, agents) in flight when the last main turn stopped. */
62  backgroundTasks: number
63  /** How much earlier than `expiresAt - lead` this window acts: a random share of the jitter. */
64  jitterMs: number
65}
66
67declare module 'claude-code' {
68  interface PluginState {
69    'cache-keeper': { keeper: KeeperState; now: number }
70  }
71}
72