SLOPSHOPPER

cache-keepalive

Shows how long the prompt cache stays warm and refreshes it just before it expires

newpanespinnercommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keepalive
│ ┃ Prompt cache ✕ › fix the failing auth test and add an audit log call │ ┃ waiting for the first request │ ┃ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ ⏺ Read(src/auth.ts) │ ┃ ░░░░░░░░░░ ⎿ Read 6 lines │ ┃ TTL 1h (auto) ⏺ Update(src/auth.ts) │ ┃ ⎿ Added 2 lines, removed 1 line │ ┃ cached prefix 0 tokens ⏺ Bash(bun test) │ ┃ keep-alive on · asks 20s before expiry, once ⎿ 3 pass, 1 fail │ ┃ Claude is idle │ ┃ 0 ping(s) since your last prompt ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ recent requests ✻ Worked for 42s · done 4:20 PM │ ┃ none yet │ ┃ ● hit ● miss ◆ keep-alive › /keepalive │ ┃ ⎿ cache-keepalive: Prompt cache gauge opened. │ ┃ [ Ping now ] [ Pause ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Prompt cache
waiting for the first request ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ TTL 1h (auto) cached prefix 0 tokens keep-alive on · asks 20s before expiry, once Claude is idle 0 ping(s) since your last prompt recent requests none yet ● hit ● miss ◆ keep-alive [ Ping now ] [ Pause ]
README

catras-claude-code-collection

A Claude Code plugin marketplace with my mods and plugins.

Install

/plugin marketplace add CatraMyBeloved/catras-claude-code-collection
/plugin install agent-comic@catras-claude-code-collection
/plugin install cache-keepalive@catras-claude-code-collection

The repo is private, so git must be authenticated (e.g. gh auth login). If the SSH attempt fails, set CLAUDE_CODE_PLUGIN_PREFER_HTTPS=1.

Plugins

agent-comic

A pixel comic in the band above the prompt: a little Claude acts out what the agent is doing.

  • Home hub: each session opens at Claude's home: a hall with keepsakes from earlier sessions, a garden and a den. While nothing is happening he keeps himself busy at random, like a screensaver: he waters the flowers, watches a film on the couch, lies down in bed for a rest, or strolls past his keepsakes. On any new turn he drops everything and sprints through the door (there is one in every room) into the session's world; after a long idle he comes back home. Claude can add a keepsake (a trophy, a gem, a plant...) after a real milestone, with the mod's own tool (hub_add, deferred, so it costs no context until used); the hub keeps six. /comic-hub lists them.
  • Pet him: the ♥ beside the band (click it in fullscreen mode, or press ctrl+x tab, then p), or /comic-pet: he lights up, and a headpat is sent as your message ("Here, have a headpat. You are doing amazing!"), which Claude answers.
  • One world per session: the session's first turn sets up a place (a setting, a hat and four props) and every later turn plays in it, so the story can call back to earlier turns. The director owns it and changes it rarely, as part of a scene, when the work really shifts: he lifts a prop overhead and, with a puff, it becomes something new (up to 6 per session); a puff over his head brings a new hat (up to 4); or he summons a door and walks through it into new scenery (up to 2). Canned scenes never change it, and nothing is ever squashed away. After a long idle he goes home, and the next turn takes him back into the same world.
  • Needs you: when a permission prompt or a question is waiting, he stops, turns to you and waves.
  • Progress: the agent's task list shows as a trail along the ground, with a flag at the end.
  • Git: a commit plants a little flag, a push lets a balloon go.
  • Sky: the local hour (moon at night, low sun at dusk) and the session's weather (clouds after failures, rain after several), kept in the background.
  • Director: hybrid (default) has the model set up the session's world and stage each turn's opening and wrap-up in it, and plays canned scenes in between; full stages everything with the model; off uses no tokens at all. Set it with /config, along with the model, pace and whether the comic stays up between turns.
  • Places: other plugins can add places to Claude's world through $.comic (a garden, a pond, a minigame). Between turns, wooden signs in the hub's top corners lead to them (a/d once the band has focus); while Claude works the comic takes the band back. With no place installed nothing changes. How to build one: EXTENDING.md; a complete example is pond-place.

Commands: /comic (on/off), /comic-pet, /comic-hub, /comic-stats, /comic-feel <mood>, /comic-demo.

cache-keepalive

Keeps Claude Code's prompt cache warm while you think, so the next prompt reads the conversation from the cache instead of paying to write it again.

  • Countdown: the prompt footer shows how long the cache stays warm, e.g. cache ▰▰▰▰▰▰▱▱ 41:12 (1h), in calm tones: sage green, sand yellow for the last 10 minutes, dusty rose for the last 3 (a 5-minute cache keeps the same proportions). The timer restarts with every request of the main conversation.
  • Keep-alive: shortly before the cache expires (20s by default), and only while Claude is idle, it sends one message asking Claude for a minimal acknowledgement. That request re-reads the cache and restarts its lifetime. After 90 minutes without a prompt from you it lets the cache lapse.
  • Lifetime: auto follows Claude Code's own rules: FORCE_PROMPT_CACHING_5M, then CLAUDE_CODE_PROMPT_CACHE_TTL, the promptCacheTtl setting and ENABLE_PROMPT_CACHING_1H; otherwise 1 hour on a subscription within plan limits and 5 minutes on an API key, a cloud provider or usage credits. The mod cannot always tell when a subscription draws on credits; set the lifetime to 5m in /config then.
  • Gauge: /keepalive opens a pane with the bar, the expiry time, the cached prefix size, a timeline of hits, misses and keep-alives, and buttons to ping now (p) or pause (k).

Commands: /keepalive (gauge), /keepalive status, /keepalive on|off, /keepalive now. Set the mode (prompt or off), lifetime, lead and idle cutoff with /config.

Why a message and not an invisible side request: a side request ($.model.fork) re-sends the conversation, but Claude Code caches it as a separate entry, so it never kept the main conversation's cache warm in testing.

Development

claude plugin validate .
claude plugin test plugins/cache-keepalive
claude plugin test examples/pond-place

examples/ holds example plugins that are not in the marketplace; copy one as a starting point.

CACHE_KEEPALIVE_HEADLESS=1 lets cache-keepalive run under claude -p --plugin-dir plugins/cache-keepalive, for checking its cache behaviour without an interactive session.

License

GPL-3.0. Copyright (c) 2026 Ole Stein.

Source 3 files
hooks/register.tsx 304 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CacheEvent, Ttl } from '../types'
5import type { Verdict } from './keepalive'
6import {
7  ACK_PROMPT,
8  CALM,
9  TTL_MS,
10  bar,
11  decide,
12  emptyClock,
13  formatLeft,
14  formatTokens,
15  gaugeColor,
16  resolveTtl,
17  statusText,
18  withEvent,
19} from './keepalive'
20
21const PLUGIN = 'cache-keepalive'
22const PANE = 'cache-keepalive'
23const clock = atom({ plugin: 'cache-keepalive', key: 'clock' } as const, emptyClock)
24
25type Mode = 'prompt' | 'off'
26
27/** A queued acknowledgement prompt holds further asks this long while its turn gets going. */
28const ASK_HOLD_MS = 60_000
29
30/** Per load: what a reload loses here is re-learned from the next request. */
31type Live = {
32  mode: Mode
33  leadMs: number
34  maxIdleMs: number
35  configuredTtl: string
36  requests: number
37  isTurnRunning: boolean
38  isPinging: boolean
39  /** When the last prompt keep-alive was queued; its turn's request has not landed yet. */
40  askedAt: number | null
41  isCachingOff: boolean
42  ttlSources: { force5m?: string; envTtl?: string; settingsTtl?: unknown; enable1h?: string }
43}
44
45const EVENT_LOOK: Record<CacheEvent, { glyph: string; color: string }> = {
46  hit: { glyph: '●', color: CALM.green },
47  miss: { glyph: '●', color: CALM.red },
48  ping: { glyph: '◆', color: CALM.blue },
49}
50
51async function currentTtl($: EngineInterface, live: Live): Promise<Ttl> {
52  const { rateLimits } = await $.session.usage()
53  return resolveTtl({ configured: live.configuredTtl, rateLimits, ...live.ttlSources })
54}
55
56async function ping($: EngineInterface, live: Live): Promise<void> {
57  // Two ticks can both decide to ping before either starts: the second stands down.
58  if (live.isPinging) return
59  live.isPinging = true
60  try {
61    const startedAt = await $.clock.now()
62    live.askedAt = startedAt
63    await update($, clock, c => ({
64      ...c,
65      pings: c.pings + 1,
66      lastPing: `${new Date(startedAt).toLocaleTimeString()}: asked Claude for an acknowledgement`,
67      history: withEvent(c.history, 'ping'),
68    }))
69    // Its turn's request reads the cache and restarts the clock through the turn.step hook.
70    await $.prompt.submit({ text: ACK_PROMPT })
71  } finally {
72    live.isPinging = false
73  }
74}
75
76async function assess($: EngineInterface, live: Live): Promise<{ verdict: Verdict; ttl: Ttl }> {
77  const [now, c, ttl] = await Promise.all([$.clock.now(), read($, clock), currentTtl($, live)])
78  const verdict = decide({
79    clock: c,
80    now,
81    ttlMs: TTL_MS[ttl],
82    // A lead as long as the lifetime would ping right after every request, the acknowledgement's own included.
83    leadMs: Math.min(live.leadMs, TTL_MS[ttl] / 2),
84    maxIdleMs: live.maxIdleMs,
85    isRequesting: live.requests > 0,
86    isTurnRunning: live.isTurnRunning,
87    mode: live.mode,
88    isPinging: live.isPinging || (live.askedAt !== null && now - live.askedAt < ASK_HOLD_MS),
89  })
90  return { verdict, ttl }
91}
92
93async function tick($: EngineInterface, live: Live): Promise<void> {
94  if (live.isCachingOff) return
95  const { verdict } = await assess($, live)
96  // The footer bar and the pane read the clock, not state: redraw them each second.
97  $.ui.invalidate('ui.render')
98  if (verdict.kind === 'ping') await ping($, live)
99}
100
101async function statusLines($: EngineInterface, live: Live): Promise<string> {
102  const [now, c, ttl] = await Promise.all([$.clock.now(), read($, clock), currentTtl($, live)])
103  const left = c.refreshedAt === null ? null : c.refreshedAt + TTL_MS[ttl] - now
104  const lines = [
105    live.isCachingOff ? 'Prompt caching is disabled (DISABLE_PROMPT_CACHING).' : null,
106    `TTL: ${ttl}${live.configuredTtl === 'auto' ? ' (auto)' : ''} · keep-alive: ${live.mode === 'off' ? 'off' : 'prompt'} · lead: ${live.leadMs / 1000}s`,
107    left === null ? 'Cache: no request yet' : left > 0 ? `Cache: warm, ${formatLeft(left)} left` : 'Cache: cold',
108    `Keep-alive: ${c.isPaused ? 'paused' : 'on'} · ${c.pings} ping(s) since your last prompt`,
109    c.lastPing ? `Last ping: ${c.lastPing}` : null,
110  ]
111  return lines.filter(Boolean).join('\n')
112}
113
114export const register: Register = (on, options) => {
115  const live: Live = {
116    mode: options.mode === 'off' ? 'off' : 'prompt',
117    leadMs: Math.max(5, Number(options.leadSeconds) || 20) * 1000,
118    maxIdleMs: Math.max(0, Number(options.maxIdleMinutes) || 0) * 60_000,
119    configuredTtl: String(options.ttl ?? 'auto'),
120    requests: 0,
121    isTurnRunning: false,
122    isPinging: false,
123    askedAt: null,
124    isCachingOff: false,
125    ttlSources: {},
126  }
127
128  on('session.start', async ($, e, next) => {
129    const result = await next(e)
130    // Headless runs (claude -p) only for testing the mod itself.
131    if (!e.isInteractive && (await $.env.get('CACHE_KEEPALIVE_HEADLESS')) !== '1') return result
132
133    const [force5m, envTtl, enable1h, disabled, settings] = await Promise.all([
134      $.env.get('FORCE_PROMPT_CACHING_5M'),
135      $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
136      $.env.get('ENABLE_PROMPT_CACHING_1H'),
137      $.env.get('DISABLE_PROMPT_CACHING'),
138      $.settings.read(),
139    ])
140    live.ttlSources = { force5m, envTtl, enable1h, settingsTtl: (settings as Record<string, unknown>).promptCacheTtl }
141    live.isCachingOff = disabled !== undefined && disabled !== '' && disabled !== '0'
142    // State outlives a reload: give a value an older version wrote the fields this one added.
143    // An activity time from the start, so the idle cutoff holds even if the person never prompts.
144    const startedAt = await $.clock.now()
145    await update($, clock, c => ({ ...emptyClock, ...c, activeAt: c.activeAt ?? startedAt }))
146    // The engine draws plugin status lines in its own color; the bar lives in the footer instead.
147    $.ui.status(undefined)
148
149    await $.command.register({
150      name: 'keepalive',
151      description: 'Prompt cache keep-alive: open the gauge, or status, on, off, now',
152      argumentHint: '[status|on|off|now]',
153    })
154    $.clock.every(1000, () => {
155      tick($, live).catch(err => $.ui.log(`keep-alive tick failed: ${String(err)}`, { to: 'debug' }))
156    })
157
158    return result
159  })
160
161  on('session.end', async ($, e, next) => {
162    if (e.reason === 'clear') await update($, clock, c => ({ ...emptyClock, isPaused: c.isPaused }))
163    return next(e)
164  })
165
166  on('prompt.submit', async ($, e, next) => {
167    const isOurs = e.origin.kind === 'plugin' && e.origin.name === PLUGIN
168    if (!isOurs) {
169      const now = await $.clock.now()
170      await update($, clock, c => ({ ...c, activeAt: now, pings: 0 }))
171    }
172    return next(e)
173  })
174
175  on('turn.start', async ($, e, next) => {
176    live.isTurnRunning = true
177    return next(e)
178  })
179
180  on('turn.complete', async ($, e, next) => {
181    if (e.agentId === undefined) live.isTurnRunning = false
182    return next(e)
183  })
184
185  // Every main-thread request reads and extends the cache: its start is when the lifetime restarts.
186  on('turn.step', async function* ($, e, next) {
187    if (e.agentId !== undefined) return yield* next(e)
188    const startedAt = await $.clock.now()
189    live.requests += 1
190    live.askedAt = null
191    try {
192      const result = yield* next(e)
193      const usage = result.usage
194      if (usage !== null) {
195        const { cache_read_input_tokens: hit, cache_creation_input_tokens: wrote } = usage
196        const isCached = hit > 0 || wrote > 0
197        await update($, clock, c => ({
198          ...c,
199          refreshedAt: isCached ? Math.max(c.refreshedAt ?? 0, startedAt) : null,
200          cachedTokens: hit + wrote,
201          // A request that mostly wrote found the prefix cold: a miss.
202          history: withEvent(c.history, hit >= wrote ? 'hit' : 'miss'),
203        }))
204      }
205      return result
206    } finally {
207      live.requests -= 1
208    }
209  })
210
211  on('command.run', { command: 'keepalive' }, async ($, e) => {
212    const arg = e.args.trim().toLowerCase()
213    if (arg === 'on' || arg === 'off') {
214      await update($, clock, c => ({ ...c, isPaused: arg === 'off' }))
215      return { text: `Cache keep-alive ${arg === 'off' ? 'paused' : 'resumed'}.` }
216    }
217    if (arg === 'now') {
218      if (live.isPinging) return { text: 'A keep-alive is already running.' }
219      await ping($, live)
220      return { text: `Keep-alive sent. ${(await read($, clock)).lastPing ?? ''}`.trim() }
221    }
222    if (arg === 'status') return { text: await statusLines($, live) }
223
224    const opened = await $.ui.open({ id: PANE, title: 'Prompt cache' })
225    return { text: opened.isPlaced ? 'Prompt cache gauge opened.' : await statusLines($, live) }
226  })
227
228  // The mode labels at the right of the prompt footer: kept, with the cache bar beside them in a calm tone.
229  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
230    if (live.isCachingOff) return next(e)
231    const { verdict, ttl } = await assess($, live)
232    const label = statusText(verdict, ttl)
233    if (label === undefined) return next(e)
234    const { Box, Text } = $.ui.resolve(e)
235    const color = verdict.kind === 'cold' ? CALM.red : verdict.kind === 'unknown' ? CALM.green : gaugeColor(verdict.leftMs, TTL_MS[ttl])
236
237    return (
238      <Box flexDirection="row">
239        {e.props.modes.length > 0 && <Text dimColor>{e.props.modes.join(' & ')} · </Text>}
240        <Text color={color}>{label}</Text>
241      </Box>
242    )
243  })
244
245  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
246    const { Box, Text, Button } = $.ui.resolve(e)
247    const [now, c, ttl] = await Promise.all([$.clock.now(), read($, clock), currentTtl($, live)])
248    const ttlMs = TTL_MS[ttl]
249    const left = c.refreshedAt === null ? null : c.refreshedAt + ttlMs - now
250    const width = Math.max(10, Math.min(60, (e.props.bodyColumns ?? 40) - 2))
251    const color = gaugeColor(left ?? 0, ttlMs)
252    const state = live.isCachingOff
253      ? 'caching disabled'
254      : left === null
255        ? 'waiting for the first request'
256        : left <= 0
257          ? 'cold: the next request re-writes it'
258          : `warm · ${formatLeft(left)} left`
259    const expires = left !== null && left > 0 ? new Date(now + left).toLocaleTimeString() : null
260    const keepalive = c.isPaused
261      ? 'paused'
262      : live.mode === 'off'
263        ? 'off (timer only)'
264        : `on · asks ${live.leadMs / 1000}s before expiry, once Claude is idle`
265
266    return (
267      <Box flexDirection="column">
268        <Text bold>{state}</Text>
269        <Text color={left !== null && left > 0 ? color : 'inactive'}>{bar(left ?? 0, ttlMs, width, '█', '░')}</Text>
270        <Text dimColor>{`TTL ${ttl}${live.configuredTtl === 'auto' ? ' (auto)' : ''}${expires ? ` · expires ${expires}` : ''}`}</Text>
271        <Text> </Text>
272        <Text>
273          cached prefix <Text bold>{formatTokens(c.cachedTokens)}</Text> tokens
274        </Text>
275        <Text>
276          keep-alive <Text bold>{keepalive}</Text>
277        </Text>
278        <Text dimColor>{c.pings} ping(s) since your last prompt</Text>
279        {c.lastPing && <Text dimColor wrap="truncate-end">last: {c.lastPing}</Text>}
280        <Text> </Text>
281        <Text dimColor>recent requests</Text>
282        <Text>
283          {c.history.length === 0 ? <Text dimColor>none yet</Text> : c.history.slice(-width).map(ev => (
284            <Text color={EVENT_LOOK[ev].color}>{EVENT_LOOK[ev].glyph}</Text>
285          ))}
286        </Text>
287        <Text dimColor>
288          <Text color={CALM.green}>●</Text> hit  <Text color={CALM.red}>●</Text> miss  <Text color={CALM.blue}>◆</Text> keep-alive
289        </Text>
290        <Text> </Text>
291        <Box flexDirection="row" gap={1}>
292          <Button key="now" hotkey="p" label="Ping now" onPress={() => void ping($, live)} />
293          <Button
294            key="pause"
295            hotkey="k"
296            label={c.isPaused ? 'Resume' : 'Pause'}
297            onPress={() => update($, clock, s => ({ ...s, isPaused: !s.isPaused }))}
298          />
299        </Box>
300      </Box>
301    )
302  })
303}
304
hooks/keepalive.ts 130 lines
1import type { CacheClock, CacheEvent, Ttl } from '../types'
2
3export const TTL_MS: Record<Ttl, number> = { '5m': 5 * 60_000, '1h': 60 * 60_000 }
4
5export const ACK_PROMPT =
6  '[cache keep-alive] Automated ping to keep the prompt cache warm. ' +
7  'Respond with only a minimal acknowledgement (one word, e.g. "ok"). ' +
8  'Do not use tools and do not continue any previous task.'
9
10export const emptyClock: CacheClock = {
11  refreshedAt: null,
12  activeAt: null,
13  pings: 0,
14  isPaused: false,
15  lastPing: null,
16  cachedTokens: 0,
17  history: [],
18}
19
20export const withEvent = (history: readonly CacheEvent[], event: CacheEvent): CacheEvent[] => [...history, event].slice(-40)
21
22/** A bar of `width` cells, filled by the share of the lifetime left. */
23export function bar(leftMs: number, ttlMs: number, width: number, full = '▰', empty = '▱'): string {
24  const filled = Math.round(Math.min(1, Math.max(0, leftMs / ttlMs)) * width)
25  return full.repeat(filled) + empty.repeat(width - filled)
26}
27
28/** Muted tones that read on dark and light themes alike: sage, sand, dusty rose, slate blue. */
29export const CALM = { green: '#8DB596', yellow: '#D4BE84', red: '#C98C8C', blue: '#8FA8C2' } as const
30
31/**
32 * The gauge's tone by time left: green, then yellow for the last 10 minutes,
33 * red for the last 3; a shorter lifetime keeps the same proportions of an hour.
34 */
35export function gaugeColor(leftMs: number, ttlMs: number): string {
36  const scale = Math.min(1, ttlMs / TTL_MS['1h'])
37  if (leftMs <= 3 * 60_000 * scale) return CALM.red
38  if (leftMs <= 10 * 60_000 * scale) return CALM.yellow
39  return CALM.green
40}
41
42export function formatTokens(n: number): string {
43  return n >= 1000 ? `${(n / 1000).toFixed(n >= 100_000 ? 0 : 1)}k` : String(n)
44}
45
46export type TtlSources = {
47  configured: string
48  force5m?: string
49  envTtl?: string
50  settingsTtl?: unknown
51  enable1h?: string
52  /** The rate-limit windows the last response reported; empty off a subscription. */
53  rateLimits: readonly { kind: string; percentUsed: number }[]
54}
55
56const isTtl = (v: unknown): v is Ttl => v === '5m' || v === '1h'
57const isOn = (v?: string) => v !== undefined && v !== '' && v !== '0' && v !== 'false'
58
59/** Claude Code's own precedence: FORCE_5M > env > setting > ENABLE_1H > default by account. */
60export function resolveTtl(s: TtlSources): Ttl {
61  if (isTtl(s.configured)) return s.configured
62  if (isOn(s.force5m)) return '5m'
63  if (isTtl(s.envTtl)) return s.envTtl
64  if (isTtl(s.settingsTtl)) return s.settingsTtl
65  if (isOn(s.enable1h)) return '1h'
66  const plan = s.rateLimits.filter(r => r.kind === 'five_hour' || r.kind === 'seven_day')
67  // A subscription gets 1h within plan usage; once it draws on credits (a window past 100) it drops to 5m.
68  return plan.length > 0 && plan.every(r => r.percentUsed < 100) ? '1h' : '5m'
69}
70
71export type Situation = {
72  clock: CacheClock
73  now: number
74  ttlMs: number
75  leadMs: number
76  maxIdleMs: number
77  /** A main-thread request is in flight right now (it refreshes the cache itself). */
78  isRequesting: boolean
79  /** A turn is running; a keep-alive prompt would only queue behind it. */
80  isTurnRunning: boolean
81  mode: 'prompt' | 'off'
82  isPinging: boolean
83}
84
85export type Verdict =
86  | { kind: 'unknown' }
87  | { kind: 'cold' }
88  | { kind: 'warm'; leftMs: number; held?: 'paused' | 'idle' | 'off' }
89  | { kind: 'ping'; leftMs: number }
90
91export function decide(s: Situation): Verdict {
92  if (s.clock.refreshedAt === null) return { kind: 'unknown' }
93  const leftMs = s.clock.refreshedAt + s.ttlMs - s.now
94  if (leftMs <= 0) return { kind: 'cold' }
95  if (s.mode === 'off') return { kind: 'warm', leftMs, held: 'off' }
96  if (s.clock.isPaused) return { kind: 'warm', leftMs, held: 'paused' }
97  const isIdleTooLong =
98    s.maxIdleMs > 0 && s.clock.activeAt !== null && s.now - s.clock.activeAt >= s.maxIdleMs
99  if (isIdleTooLong) return { kind: 'warm', leftMs, held: 'idle' }
100  // A prompt only queues behind a running turn, and the turn's own requests refresh the cache.
101  const isBlocked = s.isPinging || s.isRequesting || s.isTurnRunning
102  if (leftMs <= s.leadMs && !isBlocked) return { kind: 'ping', leftMs }
103
104  return { kind: 'warm', leftMs }
105}
106
107export function formatLeft(ms: number): string {
108  const total = Math.max(0, Math.ceil(ms / 1000))
109  const h = Math.floor(total / 3600)
110  const m = Math.floor((total % 3600) / 60)
111  const sec = String(total % 60).padStart(2, '0')
112
113  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${sec}` : `${m}:${sec}`
114}
115
116export function statusText(v: Verdict, ttl: Ttl): string | undefined {
117  switch (v.kind) {
118    case 'unknown':
119      return undefined
120    case 'cold':
121      return `cache ${bar(0, 1, 8)} cold (${ttl})`
122    case 'ping':
123      return `cache ${bar(v.leftMs, TTL_MS[ttl], 8)} ${formatLeft(v.leftMs)} · refreshing…`
124    case 'warm': {
125      const held = v.held === 'idle' ? ' · idle, letting it lapse' : v.held === 'paused' ? ' · keep-alive off' : ''
126      return `cache ${bar(v.leftMs, TTL_MS[ttl], 8)} ${formatLeft(v.leftMs)} (${ttl})${held}`
127    }
128  }
129}
130
types/index.d.ts 28 lines
1export type Ttl = '5m' | '1h'
2
3/** One main-thread request or keep-alive, as the pane's timeline draws it. */
4export type CacheEvent = 'hit' | 'miss' | 'ping'
5
6export type CacheClock = {
7  /** When the last request that read or wrote the main cache started (clock ms). */
8  refreshedAt: number | null
9  /** When the person last prompted (clock ms). */
10  activeAt: number | null
11  /** Keep-alives sent since the person last prompted. */
12  pings: number
13  /** Paused with /keepalive off. */
14  isPaused: boolean
15  /** What the last keep-alive came to, for /keepalive. */
16  lastPing: string | null
17  /** Tokens the last main-thread request read from or wrote to the cache: the warm prefix. */
18  cachedTokens: number
19  /** The last requests and keep-alives, oldest first, at most 40. */
20  history: CacheEvent[]
21}
22
23declare module 'claude-code' {
24  interface PluginState {
25    'cache-keepalive': { clock: CacheClock }
26  }
27}
28