SLOPSHOPPER

cache-keeper

Context, quota and prompt-cache meters above the prompt; while the session is idle on a 1-hour cache, keeps it alive and compacts before it lapses, holding off…

newpanebandcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
│ ┃ cache-keeper ✕ › fix the failing auth test and add an audit log call │ ┃ CACHE KEEPER · 1h lifetime (subscription) │ ┃ ● cache-keeper: loaded: 1h cache (subscription), sleep hold unsupport │ ┃ cache ⏺ Read(src/auth.ts) │ ┃ no cached request yet ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ context ⎿ Added 2 lines, removed 1 line │ ┃ window ███████████████░░░░░░░░░░░░░░░ 97 ⏺ Bash(bun test) │ ┃ auto unknown ⎿ 3 pass, 1 fail │ ┃ 5h █████████░░░░░░░░░░░░░░░░░░░░░ 31 │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ keeper │ ┃ state idle (nothing cached) ✻ Worked for 42s · done 4:20 PM │ ┃ keep-alive 0/1 this idle │ ┃ sleep not held: no sleep inhibitor on t › /ttl │ ┃ platform ⎿ cache-keeper: 1h cache (subscription) · /ttl pause|resume|now|co │ ┃ │ ┃ [ keep-alive now ] [ compact now ] [ pause ] │ ┃ │ ┃ turn steps read wrote new hit │ ┃ no requests yet │ ctx █████░░░░░ 49% 97.4k/200k · 5h 31% Opus 5.5 ○ cache -- 1h · idle (nothing cached) ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
ctx █████░░░░░ 49% 97.4k/200k · 5h 31% Opus 5.5 ○ cache -- 1h · idle (nothing cached) ⟨Claude Code's own drawing⟩
Pane · cache-keeper
CACHE KEEPER · 1h lifetime (subscription) cache no cached request yet context window ███████████████░░░░░░░░░░░░░░░ 97.4k/200k auto unknown 5h █████████░░░░░░░░░░░░░░░░░░░░░ 31% keeper state idle (nothing cached) keep-alive 0/1 this idle sleep not held: no sleep inhibitor on this platform [ keep-alive now ] [ compact now ] [ pause ] [ close ] turn steps read wrote new hit no requests yet
README

cache-keeper

A Claude Code mod that draws two rows of meters above the prompt. When a session on a 1-hour prompt cache goes idle, it keeps the cache alive and then compacts the session before the cache lapses.

ctx ░░░░░░░░░░ 2% 20.5k/1M ▸auto 967k · 5h 28% ↻2h31m · 7d 67% ↻2d5h    Opus 5.5 1M
● cache 60% ⏱ 59:41 1h · keep-alive 54:41 → compact 1:49:41 · sleep held     high
  • Row 1 shows how full the context window is and the token count at which Claude Code compacts on its own (▸auto). It also shows how much of the 5-hour and 7-day plan windows is used and when each resets.
  • Row 2 shows the cache hit rate, the time left before the cache expires, the cache lifetime (5m or 1h) and the keeper's schedule. sleep held means system sleep is blocked.
  • At the right end, row 1 shows the main session's model and row 2 its effort (xhigh and max in yellow). Both come from the last request. A pick in /model or /effort shows within a second, read from the session's model and the effort saved for that model. An effort given at launch (--effort) is not saved, so it shows after the first response.

The band keeps up while you are away. After a keep-alive the countdown starts again. After a compact, whether the keeper's, /compact or Claude Code's own, the band says so until the next response brings real figures:

ctx -- (compacted 24.6k → 3.2k) ▸auto 167k · 5h 9% ↻3h51m · 7d 72% ↻1d15h
○ cache -- 1h · compacted while idle 12m ago

/ttl opens a pane with the full details: cache, context, quota and keeper state, a table with one row per turn, and buttons.

CommandWhat it does
/ttlOpens the pane (buttons: k keep-alive now, c compact now, p pause/resume)
/ttl pause / /ttl resumePauses or resumes the keeper for this session
/ttl nowRuns a keep-alive right away
/ttl compactCompacts right away
/ttl stopCloses the pane

How the keeper works

The keeper arms when a main turn completes. The cache lifetime counts from the start of the last request that read or wrote the cache.

  1. marginMinutes before the cache expires (5 by default), it sends one tool-less, one-character $.model.fork() request. The request reads the same conversation prefix, so the cache lives another hour from that point.
  2. After keepAlives keep-alives (1 by default), it compacts the session with $.session.compact() before the next expiry.
  3. A prompt from you cancels whatever is pending.

It does nothing in two cases:

  • When the cache lifetime is 5 minutes (an API key, a cloud provider or usage credits). A keep-alive every 5 minutes costs more than it saves.
  • When the cache has already expired by the time the keeper gets to run (for example, the machine was asleep). A keep-alive then would only pay to write the whole cache again.

Sleep hold

While a keep-alive or compact is pending, the mod keeps one platform-specific process running through $.process.spawn. When nothing is pending or the session ends, it ends the process, and the hold goes with it.

OSHowCheck
Linuxsystemd-inhibit --what=idle:sleep --mode=blockClaude Code cache-keeper in systemd-inhibit --list
Windows`SetThreadExecutionState(ES_CONTINUOUS \ES_SYSTEM_REQUIRED)` from PowerShellpowercfg /requests in an elevated PowerShell
macOScaffeinate -ipmset -g assertions

On Windows and macOS this blocks idle sleep only. Closing the lid or putting the machine to sleep by hand still works. On Linux, --mode=block also blocks a manual suspend.

How the cache lifetime is decided

With ttl set to auto, the mod applies Claude Code's own rules in this order:

  1. FORCE_PROMPT_CACHING_5M → 5 minutes
  2. CLAUDE_CODE_PROMPT_CACHE_TTL (5m / 1h)
  3. The promptCacheTtl setting
  4. ENABLE_PROMPT_CACHING_1H → 1 hour
  5. A subscription (the account has 5-hour or 7-day plan windows) → 1 hour; anything else → 5 minutes

A new session has no quota data until its first response arrives. Until then, the mod uses the plan windows an earlier session saw, which it keeps in $.store.

After that, the gaps between requests also correct the lifetime. A cache hit after a gap of more than 5 minutes confirms 1 hour. A miss after a gap of 5 to 60 minutes means 5 minutes.

Options

Change these in Claude Code's config menu (the plugin's rows under /config). A change reloads the mod.

OptionDefaultMeaning
ttlautoauto, 5m or 1h
marginMinutes5How many minutes before expiry the keep-alive or compact runs
keepAlives1Keep-alives before the compact (0 compacts at the first deadline)
keepertrueRun keep-alives and compacts (false leaves only the meters)
inhibitSleeptrueBlock idle sleep while a keep-alive or compact is pending
bandtrueShow the two rows above the prompt
quotatrueShow the plan windows in the band
statusfalseAlso show a short entry in the status line

Verified / not yet verified

Verified on 2026-10-05 (Claude Code 2.1.289, Manjaro):

  • claude plugin validate passes, and all 25 tests in claude plugin test pass. The tests run on a mocked clock and cover:
  • a keep-alive, then a compact, over 1 hour 50 minutes idle
  • the band after that compact, and how long ago it ran
  • /ttl compact
  • the countdown starting again with no session.start (a reload)
  • your return cancelling the keeper
  • skipping a cache that already expired
  • the countdown and keeper continuing after the mod reloads
  • drawing the band and pane on the terminal and the desktop
  • the model and effort at the right of the band, and their switch after /model and /effort
  • In a live session:
  • the band and pane draw
  • /ttl now really refreshes the cache (read 20.2k tokens, 98% hit)
  • after /ttl compact and /compact, the band switches to -- and the compact at once, and the elapsed time advances while idle (Claude Code 2.1.289, 2026-10-06)
  • the Linux sleep hold is taken while waiting and released when the session ends
  • the model and effort show at the right of the band, and a pick in /effort shows within a second (Claude Code 2.1.291, 2026-10-06)

Not yet verified:

  • The sleep hold on Windows and macOS (check powercfg /requests or pmset -g assertions on real machines)
  • The automatic keep-alive and compact after a real 55- and 110-minute wait

License

MIT

Source 4 files
hooks/register.tsx 888 lines
1/**
2 * cache-keeper — Claude Code mod
3 *
4 * Meters: the context window (with the point where Claude Code compacts on its
5 * own), the plan's rate-limit windows, and the prompt cache (hit rate and the
6 * countdown to its expiry), as two rows above the prompt and a /ttl pane.
7 *
8 * Keeper: once a main turn completes on a 1-hour cache, it refreshes the cache
9 * with a tool-less `$.model.fork()` shortly before it lapses (`keepAlives`
10 * times), then compacts the session before the next lapse. The person coming
11 * back cancels it. While something is pending, a child process holds off idle
12 * sleep (systemd-inhibit, SetThreadExecutionState through PowerShell, or
13 * caffeinate). A cache that lapsed anyway (the machine slept) is left alone:
14 * a keep-alive then would only pay to write it again.
15 *
16 * Every function that takes `$` is declared at the top of this file, as the
17 * engine requires; the session's values live in one Runtime object.
18 */
19import { atom, read, update } from 'claude-code'
20import type {
21  EngineInterface,
22  HookStream,
23  PluginOptions,
24  ProcessSpawnChunk,
25  ProcessSpawnResult,
26  Register,
27  SessionCompacted,
28  SessionContextUsage,
29  SessionRateLimit,
30} from 'claude-code'
31import type {
32  CacheKeeperContext as Context,
33  CacheKeeperLimit as Limit,
34  CacheKeeperSample as Sample,
35  CacheKeeperSleep as Sleep,
36  CacheKeeperTtl as Ttl,
37  CacheKeeperView as View,
38} from '../types'
39import {
40  MINUTE,
41  TTL_MS,
42  accountOf,
43  bar,
44  byTurn,
45  contextTone,
46  decide,
47  decideTtl,
48  effortLabel,
49  fmtClock,
50  fmtShrink,
51  fmtTokens,
52  freshKeeper,
53  hitRatio,
54  isCached,
55  lifeTone,
56  limitLabel,
57  modelLabel,
58  savedEffort,
59  observeTtl,
60  percentTone,
61  positive,
62  promptTokens,
63  remainingMs,
64  resetsIn,
65  wantsAwake,
66} from './logic.ts'
67import type { KeeperPolicy, KeeperState, TtlEnv } from './logic.ts'
68import { inhibitorArgv, platformOf } from './inhibitor.ts'
69
70const PANE = 'cache-keeper'
71const COMMAND = 'ttl'
72const KEEP = 200
73const KEEP_ALIVE_PROMPT = 'Reply with exactly one character: .'
74const MAX_SLEEP_FAILURES = 3
75// The 1 s timer counts as stopped after this long without a call
76const STALE_MS = 5_000
77const STUCK_MS = 60_000
78
79const viewAtom = atom({ plugin: 'cache-keeper', key: 'view' } as const, null)
80const samplesAtom = atom({ plugin: 'cache-keeper', key: 'samples' } as const, [])
81
82const TONE = { ok: 'green', warn: 'yellow', bad: 'red', dim: undefined } as const
83
84type Child = HookStream<ProcessSpawnChunk, ProcessSpawnResult>
85
86type Runtime = {
87  policy: KeeperPolicy
88  ttlOption: unknown
89  holdSleep: boolean
90  showStatus: boolean
91  samples: Sample[]
92  lastKeepAlive?: Sample
93  keeper: KeeperState
94  /** Bumped whenever the keeper starts over, so late answers of an old run are dropped. */
95  generation: number
96  keeperError?: string
97  env: TtlEnv
98  setting: unknown
99  limits: Limit[]
100  context?: Context
101  observed?: Ttl
102  ttl: Ttl
103  ttlSource: string
104  platform: Sleep['platform']
105  child?: Child
106  sleepFailures: number
107  sleepError?: string
108  timer?: { cancel: () => void }
109  /** When the timer last called, and when the tick now running began. */
110  lastBeatAt: number
111  tickStartedAt: number
112  isTicking: boolean
113  /** startSession ran (or is running) in this load; isEnded: the session is over. */
114  isStarted: boolean
115  isEnded: boolean
116  lastKey: string
117  lastStatus: string
118  /** The main loop's model and effort, from its last request (the model from /model before any). */
119  model?: string
120  effort?: string
121  /** What /model and the saved effortLevel said at the last look; a change is a switch. */
122  seenModel?: string
123  seenEffort?: unknown
124  /** The full id the last main request named; /effort saves per model under it. */
125  requestModel?: string
126}
127
128function createRuntime(options: PluginOptions): Runtime {
129  const policy: KeeperPolicy = {
130    isEnabled: options.keeper !== false,
131    marginMs: positive(options.marginMinutes, 5) * MINUTE,
132    keepAlives: Math.min(5, Math.floor(positive(options.keepAlives, 1))),
133  }
134  return {
135    policy,
136    ttlOption: options.ttl,
137    holdSleep: options.inhibitSleep !== false && policy.isEnabled,
138    showStatus: options.status === true,
139    samples: [],
140    keeper: freshKeeper(),
141    generation: 0,
142    env: {},
143    setting: undefined,
144    limits: [],
145    ttl: '5m',
146    ttlSource: 'default',
147    platform: 'unsupported',
148    sleepFailures: 0,
149    lastBeatAt: 0,
150    tickStartedAt: 0,
151    isTicking: false,
152    isStarted: false,
153    isEnded: false,
154    lastKey: '',
155    lastStatus: '',
156  }
157}
158
159const toLimit = (l: SessionRateLimit): Limit => ({ kind: l.kind, percentUsed: l.percentUsed, resetsAt: l.resetsAt })
160
161// The last plan windows any session saw: a new session reads them until its
162// first response, so a subscription starts on its 1-hour lifetime
163const LIMITS_KEY = 'limits'
164
165function isLimitList(v: unknown): v is Limit[] {
166  return Array.isArray(v) && v.every(l => typeof l?.kind === 'string' && typeof l?.percentUsed === 'number')
167}
168
169async function setLimits($: EngineInterface, rt: Runtime, limits: Limit[]) {
170  rt.limits = limits
171  if (limits.length > 0) await $.store.set(LIMITS_KEY, limits).catch(() => undefined)
172}
173
174// The host puts the plugin's name before each line
175function log($: EngineInterface, text: string, to: 'transcript' | 'debug' = 'transcript') {
176  $.ui.log(text, { to })
177}
178
179function settleTtl(rt: Runtime) {
180  const choice = decideTtl(rt.ttlOption, rt.env, rt.setting, accountOf(rt.limits))
181  if (choice.source === 'option' || rt.observed === undefined || rt.observed === choice.ttl) {
182    rt.ttl = choice.ttl
183    rt.ttlSource = choice.source
184  } else {
185    rt.ttl = rt.observed
186    rt.ttlSource = `observed; ${choice.source} says ${choice.ttl}`
187  }
188}
189
190function setContext(rt: Runtime, c: SessionContextUsage) {
191  rt.context = { ...rt.context, tokens: c.tokens, window: c.window, percent: c.percent }
192}
193
194function forgetFill(rt: Runtime) {
195  if (rt.context) rt.context = { ...rt.context, tokens: undefined, percent: undefined }
196}
197
198function startOver(rt: Runtime) {
199  rt.generation += 1
200  rt.keeper = { ...freshKeeper(), isPaused: rt.keeper.isPaused }
201  rt.keeperError = undefined
202}
203
204// The plain figures, plus where Claude Code compacts on its own (a local estimate)
205async function refreshUsage($: EngineInterface, rt: Runtime) {
206  const usage = await $.session.usage({ breakdown: 'summary' }).catch(() => undefined)
207  if (!usage) return
208  setContext(rt, usage.context)
209  const b = usage.context.breakdown
210  if (b && rt.context) {
211    rt.context.isAutoOn = b.isAutoCompactEnabled
212    rt.context.autoAt = b.isAutoCompactEnabled ? b.autoCompactThreshold : undefined
213  }
214  if (usage.rateLimits.length > 0) await setLimits($, rt, usage.rateLimits.map(toLimit))
215  else if (rt.limits.length === 0) {
216    const saved = await $.store.get(LIMITS_KEY).catch(() => undefined)
217    if (isLimitList(saved)) rt.limits = saved
218  }
219  settleTtl(rt)
220}
221
222function record($: EngineInterface, rt: Runtime, s: Sample) {
223  const prev = rt.samples[rt.samples.length - 1]
224  rt.samples.push(s)
225  if (rt.samples.length > KEEP) rt.samples = rt.samples.slice(-KEEP)
226  void saveSamples($, rt)
227  const seen = observeTtl(prev, s, rt.observed)
228  if (seen !== rt.observed) {
229    rt.observed = seen
230    log($, `cache lifetime looks like ${seen} from request timing`, 'debug')
231  }
232  settleTtl(rt)
233}
234
235async function saveSamples($: EngineInterface, rt: Runtime) {
236  const samples = [...rt.samples]
237  await update($, samplesAtom, () => samples).catch(() => undefined)
238}
239
240// A reload of the mod keeps the session's $.state: pick the requests up from
241// there, so the countdown and the keeper go on instead of waiting for a request
242async function restoreSamples($: EngineInterface, rt: Runtime) {
243  const saved = await read($, samplesAtom).catch(() => [])
244  if (saved.length === 0) return
245  rt.samples = saved.slice(-KEEP)
246  rt.lastKeepAlive = [...rt.samples].reverse().find(s => s.kind === 'keep-alive')
247  for (let i = 1; i < rt.samples.length; i++) {
248    const cur = rt.samples[i]
249    if (cur) rt.observed = observeTtl(rt.samples[i - 1], cur, rt.observed)
250  }
251  // Reloads come between turns, so the session is idle
252  rt.keeper.isArmed = true
253}
254
255// ------------------------------------------------------------ sleep hold
256
257async function detectPlatform($: EngineInterface): Promise<Sleep['platform']> {
258  const osVar = await $.env.get('OS').catch(() => undefined)
259  if (osVar === 'Windows_NT') return 'windows'
260  const uname = await $.process
261    .run(['uname', '-s'], { timeoutMs: 3_000 })
262    .then(r => r.stdout)
263    .catch(() => undefined)
264  return platformOf(osVar, uname)
265}
266
267async function holdSleep($: EngineInterface, rt: Runtime, isWanted: boolean) {
268  if (!isWanted) {
269    const mine = rt.child
270    if (!mine) return
271    rt.child = undefined
272    await mine.return(undefined as never).catch(() => undefined)
273    return
274  }
275  const argv = inhibitorArgv(rt.platform)
276  if (rt.child || !argv || rt.sleepFailures >= MAX_SLEEP_FAILURES) return
277  const mine = $.process.spawn({ argv })
278  rt.child = mine
279  rt.sleepError = undefined
280  void watchHold($, rt, mine, argv[0] ?? 'inhibitor')
281}
282
283// Drains the child's output; a child that ends while still held has failed
284async function watchHold($: EngineInterface, rt: Runtime, mine: Child, name: string) {
285  let stderr = ''
286  try {
287    for await (const piece of mine) {
288      if (piece.stream === 'stderr') stderr = (stderr + piece.text).slice(-300)
289    }
290    if (rt.child === mine) {
291      const { code, signal } = await mine.result
292      rt.sleepFailures += 1
293      rt.sleepError = `${name} exited (${signal ?? code})${stderr ? `: ${stderr.trim()}` : ''}`
294    }
295  } catch (err) {
296    if (rt.child === mine) {
297      rt.sleepFailures += 1
298      rt.sleepError = `${name} did not start: ${err}`
299    }
300  }
301  if (rt.child === mine) {
302    rt.child = undefined
303    if (rt.sleepError) log($, rt.sleepError, 'debug')
304    await publish($, rt)
305  }
306}
307
308// ------------------------------------------------------------ the keeper
309
310async function publish($: EngineInterface, rt: Runtime) {
311  const now = await $.clock.now()
312  const last = rt.samples[rt.samples.length - 1]
313  const { action: _, ...status } = decide(rt.keeper, rt.policy, rt.ttl, last, now)
314  if (rt.holdSleep) await holdSleep($, rt, wantsAwake({ ...status, action: 'none' }))
315  // Redraw by the tick only while something counts down
316  const isCounting = remainingMs(last, rt.ttl, now) > 0 || status.dueAt !== undefined
317  const sleep: Sleep = rt.holdSleep
318    ? { platform: rt.platform, isActive: rt.child !== undefined, error: rt.sleepError ?? (inhibitorArgv(rt.platform) ? undefined : 'no sleep inhibitor on this platform') }
319    : { platform: rt.platform, isActive: false, error: 'off' }
320  const view: View = {
321    now: isCounting ? now : Math.floor(now / MINUTE) * MINUTE,
322    ttl: rt.ttl,
323    ttlSource: rt.ttlSource,
324    last,
325    turns: byTurn(rt.samples).slice(-20),
326    context: rt.context,
327    limits: rt.limits,
328    keeper: status,
329    sleep,
330    lastKeepAlive: rt.lastKeepAlive,
331    model: rt.model,
332    effort: rt.effort,
333  }
334  const key = JSON.stringify(view)
335  if (key !== rt.lastKey) {
336    rt.lastKey = key
337    await update($, viewAtom, () => view)
338  }
339  if (rt.showStatus) {
340    const text = statusLine(view, now)
341    if (text !== rt.lastStatus) {
342      rt.lastStatus = text
343      $.ui.status(text)
344    }
345  }
346}
347
348async function keepAlive($: EngineInterface, rt: Runtime, isScheduled: boolean) {
349  if (rt.keeper.busy) return
350  const mine = rt.generation
351  rt.keeper.busy = 'keep-alive'
352  const at = await $.clock.now()
353  const model = rt.samples[rt.samples.length - 1]?.model ?? ''
354  try {
355    const r = await $.model.fork({ prompt: KEEP_ALIVE_PROMPT })
356    if (mine !== rt.generation) return
357    if (!r.isAnswered) throw new Error(`keep-alive not answered (${r.reason})`)
358    const s: Sample = {
359      turnId: `keep-alive-${at}`,
360      kind: 'keep-alive',
361      at,
362      model,
363      read: r.usage.cache_read_input_tokens,
364      write: r.usage.cache_creation_input_tokens,
365      fresh: r.usage.input_tokens,
366      output: r.usage.output_tokens,
367    }
368    record($, rt, s)
369    rt.lastKeepAlive = s
370    if (isScheduled) rt.keeper.done += 1
371    log($, `keep-alive: read ${fmtTokens(s.read)}, wrote ${fmtTokens(s.write)}, new ${fmtTokens(s.fresh)} tokens`)
372  } catch (err) {
373    if (mine !== rt.generation) return
374    rt.keeper.outcome = 'failed'
375    rt.keeperError = String(err)
376    log($, rt.keeperError, 'debug')
377  } finally {
378    if (mine === rt.generation) rt.keeper.busy = undefined
379    await publish($, rt)
380  }
381}
382
383// The conversation the compaction left has nothing cached and no measured size
384// until its next response: a marker says so, kept with the requests
385async function noteCompact($: EngineInterface, rt: Runtime, r: SessionCompacted) {
386  const at = await $.clock.now()
387  record($, rt, {
388    turnId: `compact-${at}`,
389    kind: 'compact',
390    at,
391    model: rt.samples[rt.samples.length - 1]?.model ?? '',
392    read: 0,
393    write: 0,
394    fresh: 0,
395    output: 0,
396    // $.state holds JSON: no undefined fields
397    ...(r.tokensBefore !== undefined ? { before: r.tokensBefore } : {}),
398    ...(r.tokensAfter !== undefined ? { after: r.tokensAfter } : {}),
399  })
400  // Inside a session.compact hook, usage still reads the size before
401  await refreshUsage($, rt)
402  forgetFill(rt)
403}
404
405async function compact($: EngineInterface, rt: Runtime) {
406  if (rt.keeper.busy) return
407  const mine = rt.generation
408  rt.keeper.busy = 'compact'
409  try {
410    const r = await $.session.compact()
411    // A plugin's own call skips its own session.compact hook: note it here
412    if (r.skip === undefined) await noteCompact($, rt, r)
413    if (mine !== rt.generation) return
414    if (r.skip !== undefined) throw new Error(`compact skipped: ${r.skip}`)
415    rt.keeper.outcome = 'compacted'
416    log($, `compacted (${fmtTokens(r.tokensBefore ?? 0)} -> ${fmtTokens(r.tokensAfter ?? 0)} tokens)`)
417  } catch (err) {
418    if (mine !== rt.generation) return
419    rt.keeper.outcome = 'failed'
420    rt.keeperError = String(err)
421    log($, rt.keeperError, 'debug')
422  } finally {
423    if (mine === rt.generation) rt.keeper.busy = undefined
424    await publish($, rt)
425  }
426}
427
428// The engine refuses a compact from inside a command or a press, under the
429// turn that hook holds: run it from a timer of its own
430function laterCompact($: EngineInterface, rt: Runtime) {
431  $.clock.after(0, () => void compact($, rt))
432}
433
434// /model and /effort pick in a dialog that outlives their command: watch what they
435// change instead. /effort saves the level per model; a launch's --effort is only known from
436// a request, so only a change of the saved level counts
437async function watchSwitch($: EngineInterface, rt: Runtime) {
438  const model = await $.session.model().catch(() => undefined)
439  if (model && model !== rt.seenModel) {
440    rt.seenModel = model
441    rt.model = model
442    // The last request's model is not the session's any more
443    rt.requestModel = undefined
444  }
445  const settings = (await $.settings.read().catch(() => undefined)) as Record<string, unknown> | undefined
446  const level = settings && savedEffort(settings, [rt.requestModel, rt.model])
447  if (settings && level !== rt.seenEffort) {
448    rt.seenEffort = level
449    if (typeof level === 'string') rt.effort = level
450  }
451}
452
453async function tick($: EngineInterface, rt: Runtime) {
454  const now = await $.clock.now()
455  rt.lastBeatAt = now
456  // A tick stuck on a call that never settles must not hold the rest off
457  if (rt.isTicking && now - rt.tickStartedAt < STUCK_MS) return
458  rt.isTicking = true
459  rt.tickStartedAt = now
460  try {
461    const d = decide(rt.keeper, rt.policy, rt.ttl, rt.samples[rt.samples.length - 1], now)
462    if (d.action === 'keep-alive') void keepAlive($, rt, true)
463    else if (d.action === 'compact') void compact($, rt)
464    else if (d.action === 'expired') {
465      rt.keeper.outcome = 'expired'
466      log($, 'the cache lapsed while away (the machine slept?); keep-alive and compact skipped')
467    }
468    await watchSwitch($, rt)
469    await publish($, rt)
470  } finally {
471    rt.isTicking = false
472  }
473}
474
475function startTimer($: EngineInterface, rt: Runtime, now: number) {
476  rt.timer?.cancel()
477  rt.timer = $.clock.every(1000, () => void tick($, rt))
478  rt.lastBeatAt = now
479}
480
481// The timer that redraws the band can stop unseen: a refused period ends
482// $.clock.every, and a reload of unchanged code brings no session.start. Any
483// hook that runs, the band's drawing among them, puts it back
484async function ensureTicking($: EngineInterface, rt: Runtime) {
485  if (rt.isEnded) return
486  if (!rt.isStarted) {
487    rt.isStarted = true
488    $.clock.after(0, () => void startSession($, rt))
489    return
490  }
491  const now = await $.clock.now()
492  if (rt.timer && now - rt.lastBeatAt < STALE_MS) return
493  log($, 'timer restarted', 'debug')
494  startTimer($, rt, now)
495}
496
497async function startSession($: EngineInterface, rt: Runtime) {
498  rt.isStarted = true
499  rt.isEnded = false
500  rt.samples = []
501  rt.lastKeepAlive = undefined
502  rt.keeper = freshKeeper()
503  rt.generation += 1
504  rt.observed = undefined
505  await restoreSamples($, rt)
506  // Until the first request names them: /model's model, and the effort a reload kept
507  const before = await read($, viewAtom).catch(() => null)
508  rt.model = (await $.session.model().catch(() => undefined)) ?? before?.model
509  rt.effort = before?.effort
510  rt.seenModel = rt.model
511  const none = () => undefined
512  rt.env = {
513    force5m: await $.env.get('FORCE_PROMPT_CACHING_5M').catch(none),
514    ttlVar: await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL').catch(none),
515    enable1h: await $.env.get('ENABLE_PROMPT_CACHING_1H').catch(none),
516  }
517  const settings = (await $.settings.read().catch(() => ({}))) as Record<string, unknown>
518  rt.setting = settings.promptCacheTtl
519  rt.seenEffort = savedEffort(settings, [rt.requestModel, rt.model])
520  await refreshUsage($, rt)
521  settleTtl(rt)
522  if (rt.holdSleep) rt.platform = await detectPlatform($)
523
524  await $.command
525    .register({
526      name: COMMAND,
527      description: 'Cache, context and keeper details (args: pause, resume, now, compact, stop)',
528      argumentHint: '[pause|resume|now|compact|stop]',
529      immediate: true,
530    })
531    .catch(err => log($, `/${COMMAND} not registered: ${err}`, 'debug'))
532
533  startTimer($, rt, await $.clock.now())
534  log($, `loaded: ${rt.ttl} cache (${rt.ttlSource}), sleep hold ${rt.holdSleep ? rt.platform : 'off'}`, 'debug')
535  await publish($, rt)
536}
537
538async function runCommand($: EngineInterface, rt: Runtime, args: string): Promise<string> {
539  const arg = args.trim().toLowerCase()
540  if (arg === 'stop' || arg === 'close') {
541    await $.ui.close({ id: PANE }).catch(() => undefined)
542    return 'pane closed'
543  }
544  if (arg === 'pause' || arg === 'resume') {
545    rt.keeper.isPaused = arg === 'pause'
546    await publish($, rt)
547    return arg === 'pause' ? 'paused for this session' : 'resumed'
548  }
549  if (arg === 'now') {
550    void keepAlive($, rt, false)
551    return 'keep-alive started'
552  }
553  if (arg === 'compact') {
554    laterCompact($, rt)
555    return 'compact started'
556  }
557  await $.ui.open({ id: PANE, title: 'cache-keeper', focus: true })
558  return `${rt.ttl} cache (${rt.ttlSource}) · /${COMMAND} pause|resume|now|compact|stop`
559}
560
561function togglePause($: EngineInterface, rt: Runtime) {
562  rt.keeper.isPaused = !rt.keeper.isPaused
563  void publish($, rt)
564}
565
566// ------------------------------------------------------------ hooks
567
568export const register: Register = (on, options) => {
569  const rt = createRuntime(options)
570  const showBand = options.band !== false
571  const showQuota = options.quota !== false
572
573  on('session.start', async ($, e, next) => {
574    const started = await next(e)
575    await startSession($, rt)
576    return started
577  })
578
579  on('session.end', async ($, e, next) => {
580    if (e.reason === 'clear') {
581      // A new conversation in the same process: its cache is a new one
582      rt.samples = []
583      rt.lastKeepAlive = undefined
584      rt.observed = undefined
585      await saveSamples($, rt)
586      startOver(rt)
587      forgetFill(rt)
588      settleTtl(rt)
589      await publish($, rt)
590      return next(e)
591    }
592    rt.isEnded = true
593    rt.timer?.cancel()
594    rt.timer = undefined
595    rt.generation += 1
596    await holdSleep($, rt, false)
597    return next(e)
598  })
599
600  // The person is back: whatever the keeper had pending is off
601  on('prompt.submit', async ($, e, next) => {
602    await ensureTicking($, rt)
603    startOver(rt)
604    await publish($, rt)
605    return next(e)
606  })
607
608  on('turn.start', async ($, e, next) => {
609    if (!rt.keeper.busy) {
610      startOver(rt)
611      await publish($, rt)
612    }
613    return next(e)
614  })
615
616  on('turn.complete', async ($, e, next) => {
617    const result = await next(e)
618    await ensureTicking($, rt)
619    if (e.agentId === undefined && !rt.keeper.busy) {
620      rt.keeper.isArmed = true
621      await publish($, rt)
622    }
623    return result
624  })
625
626  // Each main-loop request: what the cache did with it
627  on('turn.step', async function* ($, e, next) {
628    if (e.agentId || rt.keeper.busy === 'keep-alive') return yield* next(e)
629    rt.model = e.model
630    rt.requestModel = e.model
631    rt.effort = effortLabel(e.effort)
632    const at = await $.clock.now()
633    const r = yield* next(e)
634    if (r.usage) {
635      record($, rt, {
636        turnId: e.turnId,
637        kind: 'turn',
638        at,
639        model: r.usage.model || e.model,
640        read: r.usage.cache_read_input_tokens,
641        write: r.usage.cache_creation_input_tokens,
642        fresh: r.usage.input_tokens,
643        output: r.usage.output_tokens,
644      })
645      await publish($, rt)
646    }
647    return r
648  })
649
650  on('session.measure', async ($, e, next) => {
651    await ensureTicking($, rt)
652    const window = rt.context?.window
653    setContext(rt, e.context)
654    if (e.changed.includes('rateLimits')) await setLimits($, rt, e.rateLimits.map(toLimit))
655    settleTtl(rt)
656    // A new model may bring a new window and a new auto-compact point
657    if (window !== undefined && window !== e.context.window) await refreshUsage($, rt)
658    await publish($, rt)
659    return next(e)
660  })
661
662  on('session.compact', async ($, e, next) => {
663    const result = await next(e)
664    // Another's compaction (/compact, Claude Code's own); the keeper's own does not come through here
665    if (e.agentId === undefined && e.trigger !== 'precompute' && result.skip === undefined) {
666      await noteCompact($, rt, result)
667      await publish($, rt)
668    }
669    return result
670  })
671
672  on('command.run', { command: COMMAND }, async ($, e) => {
673    await ensureTicking($, rt)
674    return { text: await runCommand($, rt, e.args) }
675  })
676
677  // ------------------------------------------------------------ the band
678
679  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
680    await ensureTicking($, rt)
681    if (!showBand || e.props.hasSurvey) return next(e)
682    const view = await read($, viewAtom)
683    if (!view) return next(e)
684    const { Box, Text } = $.ui.resolve(e)
685    const columns = e.props.bodyColumns ?? e.viewport?.columns ?? 100
686    const isWide = columns >= 100
687    const now = view.now
688
689    const c = view.context
690    const pct = c?.tokens !== undefined ? (c.percent ?? Math.round((c.tokens / c.window) * 100)) : undefined
691    const ctxTone = c ? contextTone(c.tokens, c.window, c.autoAt) : 'green'
692    const ctxRow = (
693      <Box key="ctx" flexDirection="row" columnGap={1}>
694        <Box flexDirection="row" flexGrow={1} columnGap={1}>
695          <Text bold color="cyan">ctx</Text>
696          {c && pct !== undefined ? (
697            <Box flexDirection="row" columnGap={1}>
698              <Text color={ctxTone}>{bar(pct / 100, isWide ? 10 : 6)}</Text>
699              <Text bold color={ctxTone}>{`${pct}%`}</Text>
700              {isWide ? <Text dimColor>{`${fmtTokens(c.tokens ?? 0)}/${fmtTokens(c.window)}`}</Text> : null}
701            </Box>
702          ) : (
703            <Text dimColor>{view.last?.kind === 'compact' && fmtShrink(view.last.before, view.last.after) ? `-- (compacted ${fmtShrink(view.last.before, view.last.after)})` : '--'}</Text>
704          )}
705          {c?.autoAt !== undefined ? <Text dimColor>{`▸auto ${fmtTokens(c.autoAt)}`}</Text> : c?.isAutoOn === false ? <Text dimColor>auto off</Text> : null}
706          {showQuota
707            ? view.limits.map(l => (
708                <Text key={`q:${l.kind}`} color={percentTone(l.percentUsed)}>
709                  {`· ${limitLabel(l.kind)} ${Math.round(l.percentUsed)}%${isWide && resetsIn(l.resetsAt, now) ? ` ↻${resetsIn(l.resetsAt, now)}` : ''}`}
710                </Text>
711              ))
712            : null}
713        </Box>
714        {view.model ? (
715          <Box flexShrink={0}>
716            <Text bold color="magenta">{modelLabel(view.model)}</Text>
717          </Box>
718        ) : null}
719      </Box>
720    )
721
722    const last = view.last
723    const left = remainingMs(last, view.ttl, now)
724    const cacheColor = left > 0 ? lifeTone(left, view.ttl) : 'red'
725    const icon = !isCached(last) ? '○' : left > 0 ? '●' : '✖'
726    const k = view.keeper
727    const cacheRow = (
728      <Box key="cache" flexDirection="row" columnGap={1}>
729        <Box flexDirection="row" flexGrow={1} flexShrink={1} columnGap={1}>
730          <Text bold color={isCached(last) ? cacheColor : undefined}>{icon}</Text>
731          <Text bold color="cyan">cache</Text>
732          {last && isCached(last) ? (
733            <Box flexDirection="row" columnGap={1}>
734              {isWide ? <Text bold>{`${Math.round(hitRatio(last) * 100)}%`}</Text> : null}
735              <Text bold color={cacheColor}>{left > 0 ? `⏱ ${fmtClock(left)}` : 'expired'}</Text>
736            </Box>
737          ) : (
738            <Text dimColor>--</Text>
739          )}
740          <Text dimColor>{view.ttl}</Text>
741          <Text color={TONE[k.tone]} dimColor={k.tone === 'dim'} wrap="truncate-end">{`· ${k.label}${view.sleep.isActive ? ' · sleep held' : ''}`}</Text>
742        </Box>
743        {view.effort ? (
744          <Box flexShrink={0}>
745            <Text bold color={view.effort === 'xhigh' || view.effort === 'max' ? 'yellow' : undefined}>{view.effort}</Text>
746          </Box>
747        ) : null}
748      </Box>
749    )
750
751    const mine = (
752      <Box key="cache-keeper" flexDirection="column" width={columns}>
753        {ctxRow}
754        {cacheRow}
755      </Box>
756    )
757    // Keep what the mods after this one draw in the band
758    const rest = await next(e)
759    if (!rest) return mine
760    return (
761      <Box flexDirection="column">
762        {mine}
763        {rest}
764      </Box>
765    )
766  })
767
768  // ------------------------------------------------------------ the pane
769
770  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
771    const { Box, Text, Button } = $.ui.resolve(e)
772    const view = await read($, viewAtom)
773    // HTML collapses runs of spaces; a no-break space keeps the columns
774    const sp = (t: string) => (e.surface === 'terminal' ? t : t.replace(/ /g, ' '))
775    if (!view) return <Text dimColor>no data yet</Text>
776    const width = Math.max(30, (e.props.bodyColumns ?? 60) - 1)
777    const barW = Math.min(width - 24, 30)
778    const now = view.now
779    const last = view.last
780    const left = remainingMs(last, view.ttl, now)
781    const k = view.keeper
782    const c = view.context
783    const row = (key: string, label: string, value: string, color?: string) => (
784      <Box key={key} flexDirection="row" columnGap={1}>
785        <Box width={10} flexShrink={0}>
786          <Text dimColor>{sp(label)}</Text>
787        </Box>
788        <Text color={color}>{sp(value)}</Text>
789      </Box>
790    )
791    const cell = (key: string, w: number, text: string, color?: string) => (
792      <Box key={key} width={w} flexShrink={0} justifyContent="flex-end">
793        <Text color={color} dimColor={!color}>{sp(text)}</Text>
794      </Box>
795    )
796    const rows = view.turns.slice(-Math.max(3, (e.viewport?.rows ?? 30) - 24))
797
798    return (
799      <Box flexDirection="column">
800        <Text bold color="cyan">{sp(`CACHE KEEPER · ${view.ttl} lifetime (${view.ttlSource})`)}</Text>
801
802        <Box key="cache" flexDirection="column" marginTop={1}>
803          <Text bold>cache</Text>
804          {last && isCached(last) ? (
805            <Box flexDirection="column">
806              {row('left', 'left', `${bar(left / TTL_MS[view.ttl], barW)} ${left > 0 ? fmtClock(left) : 'expired'}`, left > 0 ? lifeTone(left, view.ttl) : 'red')}
807              {row('last', 'last req', `${Math.round(hitRatio(last) * 100)}% hit · read ${fmtTokens(last.read)} · wrote ${fmtTokens(last.write)} · new ${fmtTokens(last.fresh)} · ${fmtTokens(promptTokens(last))} prompt`)}
808            </Box>
809          ) : (
810            <Text dimColor>{last?.kind === 'compact' ? 'compacted: the next request writes a new cache' : 'no cached request yet'}</Text>
811          )}
812        </Box>
813
814        <Box key="context" flexDirection="column" marginTop={1}>
815          <Text bold>context</Text>
816          {c && c.tokens !== undefined
817            ? row('ctx', 'window', `${bar(c.tokens / c.window, barW)} ${fmtTokens(c.tokens)}/${fmtTokens(c.window)}`, contextTone(c.tokens, c.window, c.autoAt))
818            : row('ctx', 'window', c ? `-- of ${fmtTokens(c.window)} (until the next response)` : '--')}
819          {row('auto', 'auto', c?.autoAt !== undefined ? `Claude Code compacts at ${fmtTokens(c.autoAt)}${c.tokens !== undefined ? ` (${fmtTokens(Math.max(0, c.autoAt - c.tokens))} left)` : ''}` : c?.isAutoOn === false ? 'off' : 'unknown')}
820          {view.limits.map(l =>
821            row(`q:${l.kind}`, limitLabel(l.kind), `${bar(l.percentUsed / 100, barW)} ${Math.round(l.percentUsed)}%${resetsIn(l.resetsAt, now) ? ` · resets in ${resetsIn(l.resetsAt, now)}` : ''}`, percentTone(l.percentUsed)),
822          )}
823        </Box>
824
825        <Box key="keeper" flexDirection="column" marginTop={1}>
826          <Text bold>keeper</Text>
827          {row('state', 'state', k.label, TONE[k.tone])}
828          {k.dueAt !== undefined ? row('next', 'next', `${k.next} in ${fmtClock(k.dueAt - now)}`) : null}
829          {k.compactAt !== undefined ? row('compact', 'compact', `in ${fmtClock(k.compactAt - now)}`) : null}
830          {row('done', 'keep-alive', `${k.done}/${k.planned} this idle${view.lastKeepAlive ? ` · last read ${fmtTokens(view.lastKeepAlive.read)}, wrote ${fmtTokens(view.lastKeepAlive.write)}` : ''}`)}
831          {row('sleep', 'sleep', view.sleep.isActive ? `held (${view.sleep.platform})` : view.sleep.error ? `not held: ${view.sleep.error}` : `not held (${view.sleep.platform})`, view.sleep.isActive ? 'green' : undefined)}
832          {rt.keeperError ? row('error', 'error', rt.keeperError, 'red') : null}
833          <Box key="buttons" flexDirection="row" columnGap={1} marginTop={1}>
834            <Button key="now" label="keep-alive now" hotkey="k" onPress={() => void keepAlive($, rt, false)} />
835            <Button key="compact" label="compact now" hotkey="c" onPress={() => laterCompact($, rt)} />
836            <Button key="pause" label={k.isPaused ? 'resume' : 'pause'} hotkey="p" onPress={() => togglePause($, rt)} />
837            <Button key="close" label="close" role="dismiss" onPress={() => void $.ui.close({ id: PANE })} />
838          </Box>
839        </Box>
840
841        <Box key="table" flexDirection="column" marginTop={1}>
842          <Box key="head" flexDirection="row" columnGap={1}>
843            {cell('h:n', 4, 'turn', 'cyan')}
844            {cell('h:steps', 5, 'steps', 'cyan')}
845            {cell('h:read', 7, 'read', 'green')}
846            {cell('h:wrote', 7, 'wrote', 'yellow')}
847            {cell('h:new', 6, 'new', 'cyan')}
848            {cell('h:hit', 5, 'hit', 'magenta')}
849          </Box>
850          {rows.length === 0 ? <Text dimColor>no requests yet</Text> : null}
851          {rows.map((t, i) => {
852            if (t.kind === 'compact') {
853              return (
854                <Box key={`t:${t.turnId}`} flexDirection="row" columnGap={1}>
855                  {cell(`n:${t.turnId}`, 4, 'cmp')}
856                  <Text dimColor>{sp(`compacted ${fmtShrink(t.before, t.after)}`.trim())}</Text>
857                </Box>
858              )
859            }
860            const total = t.read + t.write + t.fresh
861            const hit = total > 0 ? Math.round((t.read / total) * 100) : 0
862            return (
863              <Box key={`t:${t.turnId}`} flexDirection="row" columnGap={1}>
864                {cell(`n:${t.turnId}`, 4, t.kind === 'keep-alive' ? 'ka' : String(view.turns.length - rows.length + i + 1))}
865                {cell(`s:${t.turnId}`, 5, String(t.steps))}
866                {cell(`r:${t.turnId}`, 7, fmtTokens(t.read), 'green')}
867                {cell(`w:${t.turnId}`, 7, fmtTokens(t.write), 'yellow')}
868                {cell(`f:${t.turnId}`, 6, fmtTokens(t.fresh), 'cyan')}
869                {cell(`h:${t.turnId}`, 5, `${hit}%`, percentTone(100 - hit))}
870              </Box>
871            )
872          })}
873        </Box>
874      </Box>
875    )
876  })
877}
878
879function statusLine(view: View, now: number): string {
880  const parts: string[] = []
881  const c = view.context
882  if (c?.tokens !== undefined) parts.push(`ctx ${c.percent ?? Math.round((c.tokens / c.window) * 100)}%`)
883  const left = remainingMs(view.last, view.ttl, now)
884  parts.push(left > 0 ? `cache ${fmtClock(left)}` : 'cache --')
885  if (view.keeper.dueAt !== undefined) parts.push(`${view.keeper.next} ${fmtClock(view.keeper.dueAt - now)}`)
886  return parts.join(' · ')
887}
888
hooks/logic.ts 257 lines
1// Pure logic for cache-keeper: no `$`, so the tests call it directly.
2import type {
3  CacheKeeperLimit as Limit,
4  CacheKeeperSample as Sample,
5  CacheKeeperStatus as Status,
6  CacheKeeperTtl as Ttl,
7  CacheKeeperTurnRow as TurnRow,
8} from '../types'
9
10export const MINUTE = 60_000
11export const TTL_MS: Record<Ttl, number> = { '5m': 5 * MINUTE, '1h': 60 * MINUTE }
12
13// ---------------------------------------------------------------- lifetime
14
15export type Account = 'subscription' | 'credits' | 'other'
16
17export type TtlEnv = {
18  force5m?: string
19  ttlVar?: string
20  enable1h?: string
21}
22
23const isOn = (v: string | undefined) => v !== undefined && /^(1|true|yes|on)$/i.test(v.trim())
24const asTtl = (v: unknown): Ttl | undefined => (v === '5m' || v === '1h' ? v : undefined)
25
26/** A plan window says subscription; one used up means requests draw on usage credits. */
27export function accountOf(limits: readonly Limit[]): Account {
28  const plan = limits.filter(l => l.kind === 'five_hour' || l.kind === 'seven_day')
29  if (plan.length === 0) return 'other'
30  return plan.some(l => l.percentUsed >= 100) ? 'credits' : 'subscription'
31}
32
33/** The lifetime Claude Code asks for, in the order its documentation gives. */
34export function decideTtl(option: unknown, env: TtlEnv, setting: unknown, account: Account): { ttl: Ttl; source: string } {
35  const pinned = asTtl(option)
36  if (pinned) return { ttl: pinned, source: 'option' }
37  if (isOn(env.force5m)) return { ttl: '5m', source: 'FORCE_PROMPT_CACHING_5M' }
38  const fromVar = asTtl(env.ttlVar?.trim())
39  if (fromVar) return { ttl: fromVar, source: 'CLAUDE_CODE_PROMPT_CACHE_TTL' }
40  const fromSetting = asTtl(setting)
41  if (fromSetting) return { ttl: fromSetting, source: 'promptCacheTtl setting' }
42  if (isOn(env.enable1h)) return { ttl: '1h', source: 'ENABLE_PROMPT_CACHING_1H' }
43  if (account === 'subscription') return { ttl: '1h', source: 'subscription' }
44  return { ttl: '5m', source: account === 'credits' ? 'usage credits' : 'API key or provider' }
45}
46
47export const promptTokens = (s: Sample) => s.read + s.write + s.fresh
48export const isCached = (s: Sample | undefined) => !!s && s.read + s.write > 0
49export const hitRatio = (s: Sample) => (promptTokens(s) > 0 ? s.read / promptTokens(s) : 0)
50
51/**
52 * What the gap between two requests proves about the lifetime. A hit after
53 * more than 5 minutes proves 1 hour and sticks (a later miss may be a changed
54 * prefix); a miss 5 to 60 minutes later on the same model, with a prompt that
55 * did not shrink, says 5 minutes until a hit overrules it.
56 */
57export function observeTtl(prev: Sample | undefined, cur: Sample, seen: Ttl | undefined): Ttl | undefined {
58  if (!prev || !isCached(prev) || prev.model !== cur.model) return seen
59  const gap = cur.at - prev.at
60  if (gap <= TTL_MS['5m']) return seen
61  const before = promptTokens(prev)
62  if (before === 0) return seen
63  if (cur.read >= before * 0.5) return '1h'
64  const isMiss = cur.read < before * 0.1 && promptTokens(cur) >= before * 0.9
65  if (isMiss && gap < TTL_MS['1h'] && seen !== '1h') return '5m'
66  return seen
67}
68
69export function remainingMs(last: Sample | undefined, ttl: Ttl, now: number): number {
70  if (!last || !isCached(last)) return 0
71  return Math.max(0, last.at + TTL_MS[ttl] - now)
72}
73
74// ---------------------------------------------------------------- keeper
75
76export type KeeperPolicy = {
77  isEnabled: boolean
78  marginMs: number
79  keepAlives: number
80}
81
82export type KeeperState = {
83  /** Set when a main turn completed; cleared when the person is back. */
84  isArmed: boolean
85  busy?: 'keep-alive' | 'compact'
86  done: number
87  outcome?: 'compacted' | 'expired' | 'failed'
88  isPaused: boolean
89}
90
91export type Decision = Status & { action: 'none' | 'keep-alive' | 'compact' | 'expired' }
92
93export const freshKeeper = (): KeeperState => ({ isArmed: false, done: 0, isPaused: false })
94
95/** The margin, kept between 30 s and half the lifetime. */
96export function marginFor(policy: KeeperPolicy, ttl: Ttl): number {
97  return Math.min(Math.max(policy.marginMs, 30_000), TTL_MS[ttl] / 2)
98}
99
100/** What the keeper should do now, and what the band says about it. */
101export function decide(k: KeeperState, policy: KeeperPolicy, ttl: Ttl, last: Sample | undefined, now: number): Decision {
102  const base = { isBusy: !!k.busy, isPaused: k.isPaused, done: k.done, planned: policy.keepAlives }
103  const say = (label: string, tone: Status['tone'], action: Decision['action'] = 'none'): Decision => ({ ...base, label, tone, action })
104
105  // How long ago the last compaction ran, while nothing came after it
106  const ago = last?.kind === 'compact' ? ` ${fmtAgo(now - last.at)}` : ''
107  if (!policy.isEnabled) return say('keeper off', 'dim')
108  if (k.busy === 'keep-alive') return say('keep-alive running', 'warn')
109  if (k.busy === 'compact') return say('compacting', 'warn')
110  if (k.outcome === 'compacted') return say(`compacted while idle${ago}`, 'ok')
111  if (k.outcome === 'expired') return say('expired while away', 'bad')
112  if (k.outcome === 'failed') return say('keeper failed (see /ttl)', 'bad')
113  if (k.isPaused) return say('paused', 'dim')
114  if (ttl !== '1h') return say('idle (5m cache: keeper off)', 'dim')
115  if (ago) return say(`compacted${ago}`, 'dim')
116  if (!isCached(last)) return say(k.isArmed ? 'idle (nothing cached)' : 'ready', 'dim')
117  if (!k.isArmed) return say('ready', 'dim')
118
119  const ttlMs = TTL_MS[ttl]
120  const margin = marginFor(policy, ttl)
121  const expiresAt = last!.at + ttlMs
122  if (now >= expiresAt) return say('expired while away', 'bad', 'expired')
123  const dueAt = expiresAt - margin
124  const left = Math.max(0, policy.keepAlives - k.done)
125  const next = left > 0 ? 'keep-alive' : 'compact'
126  const compactAt = dueAt + left * (ttlMs - margin)
127  const status = { ...base, next, dueAt, compactAt } as const
128  if (now >= dueAt) return { ...status, label: next === 'keep-alive' ? 'keep-alive due' : 'compact due', tone: 'warn', action: next }
129  const label = next === 'keep-alive'
130    ? `keep-alive ${fmtClock(dueAt - now)} → compact ${fmtClock(compactAt - now)}`
131    : `compact ${fmtClock(dueAt - now)}`
132  return { ...status, label, tone: 'ok', action: 'none' }
133}
134
135/** Whether the machine should be kept awake for what the keeper has pending. */
136export const wantsAwake = (d: Decision) => d.isBusy || d.dueAt !== undefined
137
138// ---------------------------------------------------------------- tables
139
140export function byTurn(samples: readonly Sample[]): TurnRow[] {
141  const rows: TurnRow[] = []
142  for (const s of samples) {
143    const row = rows[rows.length - 1]
144    if (row && row.turnId === s.turnId) {
145      row.steps += 1
146      row.read += s.read
147      row.write += s.write
148      row.fresh += s.fresh
149    } else {
150      rows.push({ turnId: s.turnId, kind: s.kind, steps: 1, read: s.read, write: s.write, fresh: s.fresh, before: s.before, after: s.after })
151    }
152  }
153  return rows
154}
155
156// ---------------------------------------------------------------- formatting
157
158export function fmtClock(ms: number): string {
159  const total = Math.max(0, Math.ceil(ms / 1000))
160  const h = Math.floor(total / 3600)
161  const m = Math.floor((total % 3600) / 60)
162  const s = total % 60
163  const ss = String(s).padStart(2, '0')
164  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
165}
166
167/** `just now`, `12m ago`, `2h05m ago`. */
168export function fmtAgo(ms: number): string {
169  const m = Math.floor(Math.max(0, ms) / MINUTE)
170  if (m < 1) return 'just now'
171  const h = Math.floor(m / 60)
172  return h > 0 ? `${h}h${String(m % 60).padStart(2, '0')}m ago` : `${m}m ago`
173}
174
175/** A compaction's sizes, `24.6k → 3.4k`, or '' when Claude Code recorded neither. */
176export function fmtShrink(before: number | undefined, after: number | undefined): string {
177  if (before === undefined && after === undefined) return ''
178  const f = (n: number | undefined) => (n === undefined ? '?' : fmtTokens(n))
179  return `${f(before)} → ${f(after)}`
180}
181
182export function fmtTokens(n: number): string {
183  if (n >= 1_000_000) return `${trim(n / 1_000_000)}M`
184  if (n >= 1_000) return `${trim(n / 1_000)}k`
185  return String(Math.round(n))
186}
187const trim = (v: number) => (v >= 100 ? String(Math.round(v)) : v.toFixed(1).replace(/\.0$/, ''))
188
189export function bar(ratio: number, width: number): string {
190  const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
191  return '█'.repeat(filled) + '░'.repeat(width - filled)
192}
193
194/** `claude-opus-5-5[1m]` → `Opus 5.5 1M`; an alias or any other name stays as given. */
195export function modelLabel(model: string): string {
196  const m = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(\[1m\])?$/i.exec(model)
197  if (!m) return model
198  const [, family = '', major, minor, oneM] = m
199  return `${family.charAt(0).toUpperCase()}${family.slice(1)} ${major}${minor ? `.${minor}` : ''}${oneM ? ' 1M' : ''}`
200}
201
202/**
203 * The effort /effort saved for this model (`modelSettings.<id>.effortLevel`), else
204 * the shared `effortLevel`. Ids or aliases are tried in order, a `[1m]` suffix dropped.
205 */
206export function savedEffort(settings: Record<string, unknown>, models: readonly (string | undefined)[]): unknown {
207  const per = settings.modelSettings as Record<string, { effortLevel?: unknown } | undefined> | undefined
208  for (const m of models) {
209    if (!m || !per) continue
210    const id = m.replace(/\[[^\]]*\]$/, '')
211    // An alias (`opus`) names the newest saved id of its family
212    const key = id in per ? id : Object.keys(per).sort().reverse().find(k => k.startsWith(`claude-${id}-`))
213    const v = key ? per[key]?.effortLevel : undefined
214    if (v !== undefined) return v
215  }
216  return settings.effortLevel
217}
218
219/** A level as is, a token budget as its number. */
220export const effortLabel = (effort: string | number | undefined) => (effort === undefined ? undefined : String(effort))
221
222export const LIMIT_LABEL: Record<string, string> = { five_hour: '5h', seven_day: '7d', spend_limit: 'spend' }
223export const limitLabel = (kind: string) => LIMIT_LABEL[kind] ?? kind
224
225/** Green well below where Claude Code compacts on its own, yellow nearing it, red at it. */
226export function contextTone(tokens: number | undefined, window: number, autoAt: number | undefined): 'green' | 'yellow' | 'red' {
227  if (tokens === undefined) return 'green'
228  const limit = autoAt ?? window
229  const r = tokens / limit
230  return r >= 0.9 ? 'red' : r >= 0.7 ? 'yellow' : 'green'
231}
232
233export const percentTone = (p: number): 'green' | 'yellow' | 'red' => (p >= 90 ? 'red' : p >= 70 ? 'yellow' : 'green')
234
235/** Green, then yellow below 40% of the lifetime, red inside the last minute. */
236export function lifeTone(left: number, ttl: Ttl): 'green' | 'yellow' | 'red' {
237  if (left <= 60_000) return 'red'
238  return left / TTL_MS[ttl] < 0.4 ? 'yellow' : 'green'
239}
240
241export function resetsIn(resetsAt: string | undefined, now: number): string | undefined {
242  if (!resetsAt) return undefined
243  const at = Date.parse(resetsAt)
244  if (!Number.isFinite(at)) return undefined
245  const ms = at - now
246  if (ms <= 0) return 'now'
247  const h = Math.floor(ms / 3_600_000)
248  if (h >= 24) return `${Math.floor(h / 24)}d${h % 24}h`
249  const m = Math.ceil((ms % 3_600_000) / 60_000)
250  return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`
251}
252
253export function positive(value: unknown, fallback: number): number {
254  const n = Number(value)
255  return Number.isFinite(n) && n >= 0 ? n : fallback
256}
257
hooks/inhibitor.ts 40 lines
1// The command that holds off idle sleep for as long as it lives, per platform.
2// The module spawns it with `$.process.spawn`; ending the loop, or the module
3// unloading, kills it, and the OS drops the hold with the process.
4import type { CacheKeeperSleep as Sleep } from '../types'
5
6export type Platform = Sleep['platform']
7
8const WHO = 'Claude Code cache-keeper'
9const WHY = 'Keep the prompt cache alive before compacting'
10
11// ES_CONTINUOUS | ES_SYSTEM_REQUIRED, held by this PowerShell thread until it exits
12const WINDOWS_SCRIPT = [
13  "$sig = '[DllImport(\"kernel32.dll\")] public static extern uint SetThreadExecutionState(uint esFlags);'",
14  '$t = Add-Type -MemberDefinition $sig -Name Power -Namespace CacheKeeper -PassThru',
15  'if ($t::SetThreadExecutionState([uint32]2147483649) -eq 0) { exit 2 }',
16  'while ($true) { Start-Sleep -Seconds 3600 }',
17].join('; ')
18
19export function inhibitorArgv(platform: Platform): string[] | undefined {
20  switch (platform) {
21    case 'linux':
22      return ['systemd-inhibit', '--what=idle:sleep', `--who=${WHO}`, `--why=${WHY}`, '--mode=block', 'sleep', 'infinity']
23    case 'windows':
24      return ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', WINDOWS_SCRIPT]
25    case 'macos':
26      return ['caffeinate', '-i']
27    default:
28      return undefined
29  }
30}
31
32/** `uname -s` output to a platform; Windows is told by `OS=Windows_NT` first. */
33export function platformOf(osVar: string | undefined, uname: string | undefined): Platform {
34  if (osVar === 'Windows_NT') return 'windows'
35  const name = uname?.trim()
36  if (name === 'Linux') return 'linux'
37  if (name === 'Darwin') return 'macos'
38  return 'unsupported'
39}
40
types/index.d.ts 94 lines
1export type CacheKeeperTtl = '5m' | '1h';
2
3/**
4 * One model request of the main loop, or a keep-alive; or a compaction, which
5 * leaves nothing cached for the conversation it leaves behind.
6 */
7export type CacheKeeperSample = {
8  turnId: string;
9  kind: 'turn' | 'keep-alive' | 'compact';
10  /** When the request started (a compaction: when it ended): the cache lifetime counts from here. */
11  at: number;
12  model: string;
13  read: number;
14  write: number;
15  fresh: number;
16  output: number;
17  /** A compaction's conversation size before and after, in tokens, when Claude Code recorded it. */
18  before?: number;
19  after?: number;
20};
21
22export type CacheKeeperTurnRow = {
23  turnId: string;
24  kind: CacheKeeperSample['kind'];
25  steps: number;
26  read: number;
27  write: number;
28  fresh: number;
29  before?: number;
30  after?: number;
31};
32
33export type CacheKeeperContext = {
34  tokens?: number;
35  window: number;
36  percent?: number;
37  /** Tokens at which Claude Code compacts on its own; absent when unknown or off. */
38  autoAt?: number;
39  /** Whether Claude Code compacts on its own; absent until known. */
40  isAutoOn?: boolean;
41};
42
43export type CacheKeeperLimit = {
44  kind: string;
45  percentUsed: number;
46  resetsAt?: string;
47};
48
49export type CacheKeeperStatus = {
50  /** What the keeper is doing, as the band says it. */
51  label: string;
52  tone: 'ok' | 'warn' | 'bad' | 'dim';
53  next?: 'keep-alive' | 'compact';
54  dueAt?: number;
55  compactAt?: number;
56  isBusy: boolean;
57  isPaused: boolean;
58  done: number;
59  planned: number;
60};
61
62export type CacheKeeperSleep = {
63  platform: 'linux' | 'windows' | 'macos' | 'unsupported';
64  isActive: boolean;
65  error?: string;
66};
67
68/** Everything the band and the pane draw, refreshed by the module. */
69export type CacheKeeperView = {
70  now: number;
71  ttl: CacheKeeperTtl;
72  ttlSource: string;
73  last?: CacheKeeperSample;
74  turns: CacheKeeperTurnRow[];
75  context?: CacheKeeperContext;
76  limits: CacheKeeperLimit[];
77  keeper: CacheKeeperStatus;
78  sleep: CacheKeeperSleep;
79  lastKeepAlive?: CacheKeeperSample;
80  /** The main loop's model and effort, as its last request named them. */
81  model?: string;
82  effort?: string;
83};
84
85declare module 'claude-code' {
86  interface PluginState {
87    'cache-keeper': {
88      view: CacheKeeperView | null;
89      /** The session's requests, kept here so a reload of the mod picks them up. */
90      samples: CacheKeeperSample[];
91    };
92  }
93}
94