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…

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
▸auto). It also shows how much of the 5-hour and 7-day plan windows is used and when each resets.sleep held means system sleep is blocked.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.
| Command | What it does |
|---|---|
/ttl | Opens the pane (buttons: k keep-alive now, c compact now, p pause/resume) |
/ttl pause / /ttl resume | Pauses or resumes the keeper for this session |
/ttl now | Runs a keep-alive right away |
/ttl compact | Compacts right away |
/ttl stop | Closes the pane |
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.
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.keepAlives keep-alives (1 by default), it compacts the session with $.session.compact() before the next expiry.It does nothing in two cases:
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.
| OS | How | Check | |
|---|---|---|---|
| Linux | systemd-inhibit --what=idle:sleep --mode=block | Claude Code cache-keeper in systemd-inhibit --list | |
| Windows | `SetThreadExecutionState(ES_CONTINUOUS \ | ES_SYSTEM_REQUIRED)` from PowerShell | powercfg /requests in an elevated PowerShell |
| macOS | caffeinate -i | pmset -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.
With ttl set to auto, the mod applies Claude Code's own rules in this order:
FORCE_PROMPT_CACHING_5M → 5 minutesCLAUDE_CODE_PROMPT_CACHE_TTL (5m / 1h)promptCacheTtl settingENABLE_PROMPT_CACHING_1H → 1 hourA 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.
Change these in Claude Code's config menu (the plugin's rows under /config). A change reloads the mod.
| Option | Default | Meaning |
|---|---|---|
ttl | auto | auto, 5m or 1h |
marginMinutes | 5 | How many minutes before expiry the keep-alive or compact runs |
keepAlives | 1 | Keep-alives before the compact (0 compacts at the first deadline) |
keeper | true | Run keep-alives and compacts (false leaves only the meters) |
inhibitSleep | true | Block idle sleep while a keep-alive or compact is pending |
band | true | Show the two rows above the prompt |
quota | true | Show the plan windows in the band |
status | false | Also show a short entry in the status line |
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:/ttl compactsession.start (a reload)/model and /effort/ttl now really refreshes the cache (read 20.2k tokens, 98% hit)/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)/effort shows within a second (Claude Code 2.1.291, 2026-10-06)Not yet verified:
powercfg /requests or pmset -g assertions on real machines)hooks/register.tsx 888 lines1/**
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}
888hooks/logic.ts 257 lines1// 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}
257hooks/inhibitor.ts 40 lines1// 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}
40types/index.d.ts 94 lines1export 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