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

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)
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 TTL | 1-hour TTL | |
|---|---|---|
| Cache write | 1.25× input | 2× input |
| Cache read (a ping) | 0.1× input (0.05× Opus 5.5, 0.025× Fable 5.1) | same |
| Lapses after | 5 min idle | 60 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.
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
/compact, it goes dormant. Only your next prompt or turn re-arms it, so a long absence can't summarise the context away.⚠ cache rebuilt by the last request: read 0 / wrote 203k (≈ $1.62). The rebuild right after a compaction is expected and not flagged./model switched) or pings keep failing, it shows cache cold and does nothing. A ping then would only pay for a full re-cache.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.
git clone https://github.com/sorajate/claude-cache-keeper
claude --plugin-dir ./claude-cache-keeper/plugins/cache-keeper
| Command | |
|---|---|
/cache-keeper | Current state and settings |
/cache-keeper off / on | Pause or resume pinging and compaction |
Settings live under /config → cache-keeper, or /plugin configure cache-keeper@claude-cache-keeper:
| Option | Default | |
|---|---|---|
ttlSeconds | 0 | 0 detects the TTL from the session. Any other value forces that many seconds. |
leadSeconds | 30 | How long before the cache lapses to ping or compact |
jitterSeconds | 20 | Act up to this much earlier still, at random (capped at 10% of the TTL; 0 for exact timing) |
maxPings | 3 | Pings per idle stretch before compacting |
compactInstructions | empty | What the idle compaction's summary should keep |
display | band | band (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.
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.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.
MIT
hooks/register.tsx 752 lines1import { 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}
752types/index.d.ts 72 lines1export 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