Keeps the prompt cache warm while a session idles: every 50 minutes (configurable) one tiny request over the conversation prefix, so the entry does not lapse…

Keeps the prompt cache from lapsing while you are away from the keyboard.
Every request Claude Code sends to the API carries a long prefix: the system prompt, the tool definitions, the whole conversation. That prefix is written to the prompt cache, and later requests that hit it pay a fraction of the price and come back much faster. But the entry lives one hour: an hour after it was last used it is gone, and the next turn has to rewrite the entire context, slowly and at full price.
cache-keeper waits for the session to idle, then every so often (default 50 minutes) sends one line, "reply ok", over the same conversation prefix. The cache gets used once more and its lifetime extends by another hour.
♨ cache warm · next in 38m · 3 pokes · last hit 98k tok
Install it and it runs; nothing to configure. The countdown starts when the first turn ends. It never pokes during a turn, and every real request restarts the countdown from the moment the request went out (the cache lifetime counts from the request's start, not from the end of the reply).
| Command | What it does |
|---|---|
/cache-keeper | Status: interval, time to the next poke, pokes so far, last cache hit size |
/cache-keeper now | Poke immediately (refused while a turn runs, since that turn refreshes the cache itself) |
/cache-keeper off | Pause warming for this session |
/cache-keeper on | Resume |
Change them on the /plugin settings page or under pluginConfigs in ~/.claude/settings.json:
| Field | Default | Meaning |
|---|---|---|
interval_minutes | 50 | How long the session may idle before a poke. The cache lapses after an hour, so this must be under 60; the mod clamps it to 5–59, leaving ten minutes for clock drift and API latency |
max_idle_hours | 4 | Stop warming once this long has passed since the last real turn. 0 means never stop (see the cost section) |
enabled | true | Off means the mod sends nothing at all |
show_band | true | Draw the countdown line above the prompt, in its own rounded frame stacked with the other mods' frames |
language | auto | UI language: auto reads LC_ALL, LC_MESSAGES, then LANG (zh* → Traditional Chinese, ja* → Japanese, anything else → English); or set en, zh-TW or ja explicitly |
band_style | box | How the line is framed: box (a rounded frame, dim normally and yellow while backing off after failed pokes), rule (a thin line beneath it), plain (text only) |
Mechanism. The mod calls $.model.fork, which re-sends the main loop's last request exactly (same model, system prompt, tools and conversation) with one very short user message appended and no tools enabled. Because the prefix is byte-identical, the API serves it from the cache and resets the entry's one-hour timer. The reply's cache_read_input_tokens says how much was served; near zero means we were late, the entry had already lapsed, and this request rewrote it.
What one poke costs. The whole prefix at cache-read price, plus a few output tokens. The bigger the context, the dearer the poke.
Price ratios (Anthropic list prices, the model's base input price = 1):
| Item | Multiplier |
|---|---|
| Cache read (a hit) | 0.1× on most models; 0.05× on Claude Opus 5.5 ($0.20/MTok); 0.025× on Claude Fable 5.1 ($0.25/MTok) |
| Cache write, 5-minute TTL | 1.25× |
| Cache write, 1-hour TTL (what Claude Code uses) | 2× |
A hit resets the entry's timer at no extra charge, and the lifetime counts from the start of that request.
Worked example. A 100k-token context, a 50-minute interval, Claude Opus 5.5 (input $4/MTok, cache read $0.20/MTok).
max_idle_hours exists, and why it stops after four hours by default.Worth it when the context is large (tens of thousands of tokens or more) and you often step away for ten minutes to an hour (reading, a meeting, waiting on CI). Not worth it when the context is small (there is little to save) or you leave for hours at a time (lower max_idle_hours, or turn it off).
Timeline (50-minute interval, 4-hour cap):
turn ends ─50m─▶ poke ─50m─▶ poke ─50m─▶ poke ─50m─▶ poke ─40m─▶ 4h reached, stop
any new turn pulls this line back to its start
On failure. An API error (overload, rate limit) backs off: retry after a minute, then two, then four… up to one interval. The first failure shows a toast; later ones only go to the debug log. When there is no reply yet (a new session, or right after /clear) there is nothing to warm and the poke is skipped quietly.
/cache-keeper now runs one at a time and never stacks.claude -p and SDK sessions have nobody waiting, so they are never warmed.claude plugin validate ./plugins/cache-keeper
Result (v0.1.0):
hooks: session.start, command.run{command=cache-keeper}, turn.start, turn.step, turn.complete,
session.end, ui.render{component=AbovePrompt}
calls: $.clock.every, $.clock.now, $.command.register, $.env.get (via resolveLanguage),
$.model.fork (via poke), $.state.get, $.state.set, $.ui.log (via log), $.ui.resolve,
$.ui.toast (via toast)
env reads: LANG, LC_ALL, LC_MESSAGES
env writes: nothing
state: cache-keeper.state, cache-keeper.tickAt, cache-keeper.lang
$.model.fork: the one poke request; the only thing here that costs moneyturn.step: read only, to learn that a real request just went out; no chunk or reply is changed$.env.get: the three locale variables, to pick the UI language$.fs, $.process, $.http/model), changing the system prompt, or installing a plugin that changes the tool list all change the prefix, so the next poke is a rewrite and the band's "last hit" drops to near zero.$.state: /clear resets it, a hot reload keeps it, closing Claude Code drops it, and a fresh session counts from its first turn.claude --plugin-dir ./plugins/cache-keeper # load once
claude plugin test ./plugins/cache-keeper # run the tests (27, including the timeline, backoff and languages)
tsconfig.json depends on .claude-plugin/types/, the type declarations Claude Code writes when it loads the mod; they are not committed. UI strings live in hooks/i18n.ts, one dictionary per language, and a test checks that every language has every message.
hooks/register.tsx 277 lines1// cache-keeper: while a session idles, periodically send one tiny request over the
2// conversation prefix so the prompt cache does not lapse.
3//
4// - The cache lives one hour past its last use. Once the session has idled for one
5// interval (default 50 minutes) we run `$.model.fork` with a one-word prompt: a fork
6// re-sends the main loop's last request prefix, the API serves it from cache, and the
7// entry's timer resets.
8// - Only while idle: never during a turn, every real request restarts the countdown, and
9// we stop past the idle cap.
10// - Failures back off: 1m, 2m, 4m… up to one interval; `nothing-to-fork` (no reply yet)
11// is skipped quietly.
12// - No files, no shell, no network: the one outward action is that model request, made
13// through the session's own client.
14// - UI language: the `language` option, or `auto` from LC_ALL / LC_MESSAGES / LANG.
15
16import { atom, read, update } from 'claude-code'
17import type { Register } from 'claude-code'
18
19import { DEFAULT_LANG, resolveLang, t } from './i18n'
20import type { Lang } from './i18n'
21import {
22 EMPTY_STATE,
23 POKE_PROMPT,
24 TICK_MS,
25 TOAST_MS,
26 bandText,
27 isCacheMiss,
28 isDue,
29 isPastIdleCap,
30 nextBackoff,
31 parseConfig,
32 pokeText,
33 statusText,
34} from './logic'
35import type { BandStyle, KeeperConfig, KeeperState } from './logic'
36
37const state = atom({ plugin: 'cache-keeper', key: 'state' } as const, EMPTY_STATE)
38const tickAt = atom({ plugin: 'cache-keeper', key: 'tickAt' } as const, 0)
39const langState = atom({ plugin: 'cache-keeper', key: 'lang' } as const, DEFAULT_LANG)
40
41// Module-level: reset on hot reload. `register` re-reads the config; `inFlight` keeps one
42// environment from poking twice at once.
43let config: KeeperConfig = parseConfig(undefined)
44let lang: Lang = DEFAULT_LANG
45let inFlight = false
46
47function toast($: any, text: string): void {
48 try {
49 $.ui.toast(text, { timeoutMs: TOAST_MS })
50 } catch {}
51}
52
53function log($: any, text: string, toDebug = true): void {
54 try {
55 $.ui.log(text, toDebug ? { to: 'debug' } : undefined)
56 } catch {}
57}
58
59async function patch($: any, fn: (s: KeeperState) => Partial<KeeperState>): Promise<void> {
60 await update($, state, s => ({ ...s, ...fn(s) }))
61}
62
63type PokeOutcome = { ok: true; text: string } | { ok: false; text: string }
64
65/** One poke. Returns the text shown by /cache-keeper now. */
66async function poke($: any, origin: 'timer' | 'command'): Promise<PokeOutcome> {
67 if (inFlight) return { ok: false, text: t(lang, 'poke.busy') }
68 inFlight = true
69 const startedAt: number = await $.clock.now()
70 await patch($, () => ({ isPoking: true, lastAttemptAt: startedAt }))
71 try {
72 let r: any
73 try {
74 r = await $.model.fork({ prompt: POKE_PROMPT })
75 } catch (err) {
76 r = { isAnswered: false, reason: 'rejected', error: String(err) }
77 }
78 if (r?.isAnswered) {
79 const u = r.usage
80 const text = pokeText(u, lang)
81 // The cache lifetime counts from the request's start, so that is the time we record
82 await patch($, s => ({
83 lastRequestAt: startedAt,
84 pokes: s.pokes + 1,
85 lastCacheRead: u.cache_read_input_tokens,
86 lastInput: u.input_tokens + u.cache_creation_input_tokens,
87 backoffMs: 0,
88 }))
89 log($, `cache-keeper: ${origin} poke: ${text}`)
90 if (isCacheMiss(u) && origin === 'timer') log($, t(lang, 'log.miss'), false)
91 return { ok: true, text }
92 }
93 if (r?.reason === 'nothing-to-fork') {
94 // No reply yet (new session or right after /clear): nothing to warm, wait for a turn
95 await patch($, () => ({ lastRequestAt: null }))
96 return { ok: false, text: t(lang, 'poke.nothing') }
97 }
98 const why = r?.reason === 'api-error' ? t(lang, 'poke.apiError', { status: r.status ?? '', error: String(r.error) }) : String(r?.reason ?? 'unknown')
99 let firstFailure = false
100 await patch($, s => {
101 firstFailure = s.failures === 0
102 return { failures: s.failures + 1, backoffMs: nextBackoff(s.backoffMs, config.intervalMs) }
103 })
104 log($, `cache-keeper: poke failed: ${why}`)
105 if (firstFailure && origin === 'timer') toast($, t(lang, 'toast.failed', { why }))
106 return { ok: false, text: t(lang, 'poke.failed', { why }) }
107 } finally {
108 inFlight = false
109 await patch($, () => ({ isPoking: false }))
110 }
111}
112
113async function onTick($: any): Promise<void> {
114 const s = await read($, state)
115 if (!config.enabled || s.isPaused || s.lastRequestAt === null) return
116 const now: number = await $.clock.now()
117 if (config.showBand) await update($, tickAt, () => now)
118 if (s.isTurnRunning || s.isPoking || inFlight) return
119 if (isPastIdleCap(s.lastRealTurnAt, now, config.idleCapMs)) {
120 if (!s.isCapNoticed) {
121 await patch($, () => ({ isCapNoticed: true }))
122 log($, t(lang, 'log.cap'), false)
123 }
124 return
125 }
126 if (isDue(now, s, config.intervalMs)) await poke($, 'timer')
127}
128
129async function resolveLanguage($: any): Promise<Lang> {
130 const env: { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string } = {}
131 try {
132 env.LC_ALL = await $.env.get('LC_ALL')
133 env.LC_MESSAGES = await $.env.get('LC_MESSAGES')
134 env.LANG = await $.env.get('LANG')
135 } catch {}
136 return resolveLang(config.language, env)
137}
138
139async function onSessionStart($: any, e: any, next: any) {
140 const out = await next(e)
141 lang = await resolveLanguage($)
142 await update($, langState, () => lang)
143 await $.command.register({
144 name: 'cache-keeper',
145 description: 'Prompt cache warming: show status; /cache-keeper now pokes once, off pauses, on resumes',
146 argumentHint: '[now|off|on]',
147 })
148 // A hot reload can leave isPoking stuck (the promise is gone): clear it. isTurnRunning stays; turn.complete ends it.
149 await patch($, () => ({ isPoking: false }))
150 // Nobody is waiting in `claude -p` or an SDK session: no warming there
151 if (e.isInteractive) {
152 $.clock.every(TICK_MS, () => {
153 void onTick($).catch(() => {})
154 })
155 }
156 return out
157}
158
159async function onCommand($: any, e: any) {
160 const arg = String(e.args ?? '').trim()
161 const now: number = await $.clock.now()
162 if (arg === '') return { text: statusText(await read($, state), config, now, lang) }
163 if (arg === 'off') {
164 await patch($, () => ({ isPaused: true }))
165 return { text: t(lang, 'cmd.off') }
166 }
167 if (arg === 'on') {
168 await patch($, () => ({ isPaused: false, isCapNoticed: false }))
169 return { text: t(lang, 'cmd.on') }
170 }
171 if (arg === 'now') {
172 if (!config.enabled) return { text: t(lang, 'cmd.disabled') }
173 const s = await read($, state)
174 if (s.isTurnRunning) return { text: t(lang, 'cmd.turnRunning') }
175 const r = await poke($, 'command')
176 return { text: t(lang, 'cmd.result', { text: r.text }) }
177 }
178 return { text: t(lang, 'cmd.usage') }
179}
180
181async function onAbovePrompt($: any, e: any, next: any) {
182 if (e.props?.hasSurvey || e.props?.isWorking) return next(e)
183 await read($, tickAt) // subscribe: redraw the countdown on every tick
184 const l = (await read($, langState)) as Lang
185 const text = bandText(await read($, state), config, await $.clock.now(), l)
186 if (text === null) return next(e)
187 const ui = $.ui.resolve(e)
188 const { Text } = ui
189 // AbovePrompt is a hook chain: draw our line in its frame, then what the plugins beneath drew
190 const below = await next(e)
191 const s = await read($, state)
192 return frameBand(
193 ui,
194 config.bandStyle,
195 s.backoffMs > 0,
196 e.props?.bodyColumns,
197 <Text wrap="truncate-end" dimColor>
198 {text}
199 </Text>,
200 below,
201 )
202}
203
204/**
205 * Frames this mod's band content per `band_style` and stacks the plugins beneath under it.
206 * `box`: a rounded frame (yellow when `isWarning`, here while backing off after failures);
207 * `rule`: a dim line beneath, only when another plugin drew something below; `plain`: the bare text.
208 */
209function frameBand(ui: { Box: any; Text: any }, style: BandStyle, isWarning: boolean, bodyColumns: number | undefined, content: any, below: any) {
210 const { Box, Text } = ui
211 const hasBelow = below !== null && below !== undefined && (below as { type?: string }).type !== 'engine'
212 const own =
213 style === 'box' ? (
214 <Box key="frame" flexDirection="column" borderStyle="round" borderDimColor={isWarning ? undefined : true} borderColor={isWarning ? 'yellow' : undefined} paddingX={1}>
215 {content}
216 </Box>
217 ) : style === 'rule' ? (
218 <Box key="frame" flexDirection="column">
219 {content}
220 {hasBelow ? <Text key="rule" dimColor>{'─'.repeat(Math.max(8, Math.min(bodyColumns ?? 60, 200)))}</Text> : null}
221 </Box>
222 ) : (
223 <Box key="frame" flexDirection="column">
224 {content}
225 </Box>
226 )
227 return (
228 <Box flexDirection="column">
229 {own}
230 {below}
231 </Box>
232 )
233}
234
235export const register: Register = (on, options) => {
236 config = parseConfig(options as Record<string, unknown> | undefined)
237
238 on('session.start', onSessionStart)
239 on('command.run', { command: 'cache-keeper' }, onCommand)
240
241 // A main-loop turn starts: note the "real activity" time; the idle cap counts from here
242 on('turn.start', async ($, e, next) => {
243 const now = await $.clock.now()
244 await patch($, () => ({ isTurnRunning: true, lastRealTurnAt: now, isCapNoticed: false }))
245 return next(e)
246 })
247
248 // Every real model request refreshes the cache: restart the countdown. The cache lifetime
249 // counts from the request's START, so we record when it went out, not when the reply ended
250 // (a long reply can take minutes). Subagent requests do not share main's prefix: skipped.
251 on('turn.step', async function* ($, e, next) {
252 if (e.agentId !== undefined) return yield* next(e)
253 const startedAt = await $.clock.now()
254 await patch($, () => ({ lastRequestAt: startedAt }))
255 return yield* next(e)
256 })
257
258 on('turn.complete', async ($, e, next) => {
259 if (e.agentId === undefined) {
260 const now = await $.clock.now()
261 // Only when turn.step saw no request (interrupted before the first one) fill in the end time
262 await patch($, s => ({ isTurnRunning: false, lastRequestAt: s.lastRequestAt ?? now }))
263 }
264 return next(e)
265 })
266
267 // /clear: the conversation is gone, and so is the cached prefix; counters stay, timing resets
268 on('session.end', async ($, e, next) => {
269 if (e.reason === 'clear') {
270 await patch($, () => ({ lastRequestAt: null, lastAttemptAt: null, backoffMs: 0, isTurnRunning: false, isCapNoticed: false }))
271 }
272 return next(e)
273 })
274
275 on('ui.render', { component: 'AbovePrompt' }, onAbovePrompt)
276}
277hooks/i18n.ts 185 lines1// cache-keeper UI strings in English, Traditional Chinese and Japanese.
2// Pure: no `$`. The language is resolved once at session.start (see register.tsx)
3// and passed into every text function in logic.ts.
4
5export type Lang = 'en' | 'zh-TW' | 'ja'
6export const LANGS: readonly Lang[] = ['en', 'zh-TW', 'ja']
7export const DEFAULT_LANG: Lang = 'en'
8
9export type Params = Record<string, string | number>
10type Msg = string | ((p: Params) => string)
11
12export type Messages = {
13 'band.warm': Msg
14 'band.next': Msg
15 'band.pokes': Msg
16 'band.lastHit': Msg
17 'band.backoff': Msg
18 'band.poking': Msg
19 'band.capped': Msg
20 'countdown.under1m': Msg
21 'status.enabled': Msg
22 'status.disabled': Msg
23 'status.paused': Msg
24 'status.interval': Msg
25 'status.noCap': Msg
26 'status.noRequest': Msg
27 'status.capped': Msg
28 'status.next': Msg
29 'status.counts': Msg
30 'status.lastHit': Msg
31 'poke.miss': Msg
32 'poke.hit': Msg
33 'poke.busy': Msg
34 'poke.nothing': Msg
35 'poke.failed': Msg
36 'poke.apiError': Msg
37 'toast.failed': Msg
38 'log.cap': Msg
39 'log.miss': Msg
40 'cmd.off': Msg
41 'cmd.on': Msg
42 'cmd.disabled': Msg
43 'cmd.turnRunning': Msg
44 'cmd.result': Msg
45 'cmd.usage': Msg
46}
47export type MessageKey = keyof Messages
48
49const en: Messages = {
50 'band.warm': '♨ cache warm',
51 'band.next': p => `next in ${p.t}`,
52 'band.pokes': p => `${p.n} ${p.n === 1 ? 'poke' : 'pokes'}`,
53 'band.lastHit': p => `last hit ${p.tok} tok`,
54 'band.backoff': p => `backing off (${p.n} failed)`,
55 'band.poking': '♨ warming…',
56 'band.capped': p => `♨ idle for ${p.d}, warming stopped (/cache-keeper now to poke by hand)`,
57 'countdown.under1m': 'under 1m',
58 'status.enabled': 'cache-keeper: on',
59 'status.disabled': 'cache-keeper: disabled (enabled is false in settings)',
60 'status.paused': 'cache-keeper: paused (/cache-keeper on to resume)',
61 'status.interval': p => `interval ${p.interval}; idle cap ${p.cap}`,
62 'status.noCap': 'none',
63 'status.noRequest': 'no model request yet; the countdown starts after the first turn ends',
64 'status.capped': 'idle cap reached, automatic warming stopped; /cache-keeper now pokes once by hand',
65 'status.next': p => `last request ${p.ago} ago; next poke in ${p.next}`,
66 'status.counts': p => `${p.pokes} pokes, ${p.failures} failed`,
67 'status.lastHit': p => `; last hit ${p.hit} tok (missed ${p.miss} tok)`,
68 'poke.miss': p => `cache miss (entry probably expired), rewrote ${p.tok} tok`,
69 'poke.hit': p => `cache hit ${p.hit} tok, missed ${p.miss} tok`,
70 'poke.busy': 'already warming',
71 'poke.nothing': 'nothing to warm yet (no reply in this conversation)',
72 'poke.failed': p => `poke failed: ${p.why}`,
73 'poke.apiError': p => `API error${p.status ? ` ${p.status}` : ''} (${p.error})`,
74 'toast.failed': p => `cache-keeper: poke failed (${p.why}), will retry later`,
75 'log.cap': 'cache-keeper: idle cap reached, warming stopped; /cache-keeper now pokes by hand',
76 'log.miss': 'cache-keeper: that poke missed the cache (entry probably expired); the prefix was rewritten',
77 'cmd.off': 'cache-keeper paused: no more pokes in this session. /cache-keeper on to resume.',
78 'cmd.on': 'cache-keeper resumed.',
79 'cmd.disabled': 'cache-keeper is disabled in settings; not poking.',
80 'cmd.turnRunning': 'A turn is running; not poking (the turn itself refreshes the cache).',
81 'cmd.result': p => `cache-keeper: ${p.text}`,
82 'cmd.usage': 'Usage: /cache-keeper (status), /cache-keeper now (poke now), /cache-keeper off / on (pause / resume)',
83}
84
85const zhTW: Messages = {
86 'band.warm': '♨ cache 保溫',
87 'band.next': p => `下次 ${p.t} 後`,
88 'band.pokes': p => `已戳 ${p.n} 次`,
89 'band.lastHit': p => `上次命中 ${p.tok} tok`,
90 'band.backoff': p => `退避中(失敗 ${p.n} 次)`,
91 'band.poking': '♨ 保溫中…',
92 'band.capped': p => `♨ 已閒置 ${p.d},停止保溫(/cache-keeper now 可手動)`,
93 'countdown.under1m': '不到 1m',
94 'status.enabled': 'cache-keeper:啟用中',
95 'status.disabled': 'cache-keeper:已停用(設定 enabled 為 false)',
96 'status.paused': 'cache-keeper:已暫停(/cache-keeper on 恢復)',
97 'status.interval': p => `保溫間隔 ${p.interval};閒置上限 ${p.cap}`,
98 'status.noCap': '無',
99 'status.noRequest': '還沒有任何模型請求,第一個 turn 結束後開始計時',
100 'status.capped': '已達閒置上限,停止自動保溫;/cache-keeper now 可手動戳一次',
101 'status.next': p => `上次請求 ${p.ago} 前;下次保溫 ${p.next} 後`,
102 'status.counts': p => `已戳 ${p.pokes} 次,失敗 ${p.failures} 次`,
103 'status.lastHit': p => `;上次命中 ${p.hit} tok(未命中 ${p.miss} tok)`,
104 'poke.miss': p => `這次沒命中快取(可能已過期),已重新寫入 ${p.tok} tok`,
105 'poke.hit': p => `快取命中 ${p.hit} tok,未命中 ${p.miss} tok`,
106 'poke.busy': '已經在保溫中',
107 'poke.nothing': '目前沒有可保溫的對話(還沒有任何回覆)',
108 'poke.failed': p => `保溫失敗:${p.why}`,
109 'poke.apiError': p => `API 錯誤${p.status ? ` ${p.status}` : ''}(${p.error})`,
110 'toast.failed': p => `cache-keeper:保溫失敗(${p.why}),稍後再試`,
111 'log.cap': 'cache-keeper:已達閒置上限,停止保溫;/cache-keeper now 可手動',
112 'log.miss': 'cache-keeper:這次沒命中快取(可能已過期),已重新寫入',
113 'cmd.off': 'cache-keeper 已暫停(本 session 不再保溫)。/cache-keeper on 恢復。',
114 'cmd.on': 'cache-keeper 已恢復。',
115 'cmd.disabled': 'cache-keeper 已在設定中停用,不戳。',
116 'cmd.turnRunning': '有 turn 進行中,不戳(它自己就會刷新快取)。',
117 'cmd.result': p => `cache-keeper:${p.text}`,
118 'cmd.usage': '用法:/cache-keeper(狀態)、/cache-keeper now(立刻戳)、/cache-keeper off / on(暫停/恢復)',
119}
120
121const ja: Messages = {
122 'band.warm': '♨ キャッシュ保温',
123 'band.next': p => `次は${p.t}後`,
124 'band.pokes': p => `${p.n}回送信`,
125 'band.lastHit': p => `前回ヒット ${p.tok} tok`,
126 'band.backoff': p => `バックオフ中(失敗${p.n}回)`,
127 'band.poking': '♨ 保温中…',
128 'band.capped': p => `♨ ${p.d} アイドルのため停止(/cache-keeper now で手動送信)`,
129 'countdown.under1m': '1m未満',
130 'status.enabled': 'cache-keeper:有効',
131 'status.disabled': 'cache-keeper:無効(設定の enabled が false)',
132 'status.paused': 'cache-keeper:一時停止中(/cache-keeper on で再開)',
133 'status.interval': p => `保温間隔 ${p.interval};アイドル上限 ${p.cap}`,
134 'status.noCap': 'なし',
135 'status.noRequest': 'まだモデルへのリクエストがありません。最初のターン終了後にカウント開始',
136 'status.capped': 'アイドル上限に達したため自動保温を停止。/cache-keeper now で手動送信できます',
137 'status.next': p => `前回のリクエストは${p.ago}前;次の保温は${p.next}後`,
138 'status.counts': p => `${p.pokes}回送信、失敗${p.failures}回`,
139 'status.lastHit': p => `;前回ヒット ${p.hit} tok(ミス ${p.miss} tok)`,
140 'poke.miss': p => `キャッシュミス(期限切れの可能性)、${p.tok} tok を再書き込み`,
141 'poke.hit': p => `キャッシュヒット ${p.hit} tok、ミス ${p.miss} tok`,
142 'poke.busy': 'すでに保温中です',
143 'poke.nothing': '保温できる会話がまだありません(応答がまだありません)',
144 'poke.failed': p => `保温失敗:${p.why}`,
145 'poke.apiError': p => `APIエラー${p.status ? ` ${p.status}` : ''}(${p.error})`,
146 'toast.failed': p => `cache-keeper:保温に失敗(${p.why})、後で再試行します`,
147 'log.cap': 'cache-keeper:アイドル上限に達したため保温を停止。/cache-keeper now で手動送信',
148 'log.miss': 'cache-keeper:キャッシュミス(期限切れの可能性)、プレフィックスを再書き込みしました',
149 'cmd.off': 'cache-keeper を一時停止しました(このセッションでは保温しません)。/cache-keeper on で再開。',
150 'cmd.on': 'cache-keeper を再開しました。',
151 'cmd.disabled': 'cache-keeper は設定で無効です。送信しません。',
152 'cmd.turnRunning': 'ターン実行中のため送信しません(ターン自体がキャッシュを更新します)。',
153 'cmd.result': p => `cache-keeper:${p.text}`,
154 'cmd.usage': '使い方:/cache-keeper(状態)、/cache-keeper now(今すぐ送信)、/cache-keeper off / on(一時停止/再開)',
155}
156
157export const MESSAGES: Record<Lang, Messages> = { en, 'zh-TW': zhTW, ja }
158
159/** Looks up a message in `lang`, falling back to English. */
160export function t(lang: Lang, key: MessageKey, params: Params = {}): string {
161 const m: Msg = MESSAGES[lang]?.[key] ?? MESSAGES.en[key]
162 return typeof m === 'function' ? m(params) : m
163}
164
165function fromLocale(value: string | undefined): Lang | null {
166 const v = (value ?? '').trim()
167 if (!v || v === 'C' || v === 'POSIX') return null
168 if (/^zh/i.test(v)) return 'zh-TW'
169 if (/^ja/i.test(v)) return 'ja'
170 return 'en'
171}
172
173/**
174 * The UI language: an explicit option wins; `auto` (or anything else) reads
175 * LC_ALL, then LC_MESSAGES, then LANG; nothing usable means English.
176 */
177export function resolveLang(option: unknown, env: { LC_ALL?: string; LC_MESSAGES?: string; LANG?: string }): Lang {
178 if (option === 'en' || option === 'zh-TW' || option === 'ja') return option
179 for (const v of [env.LC_ALL, env.LC_MESSAGES, env.LANG]) {
180 const found = fromLocale(v)
181 if (found) return found
182 }
183 return DEFAULT_LANG
184}
185hooks/logic.ts 193 lines1// cache-keeper pure functions: settings parsing, when to poke, backoff, the
2// idle cap, and the band / status texts. No `$` here; shared with the tests.
3
4import type { KeeperState } from '../types'
5import { t } from './i18n'
6import type { Lang } from './i18n'
7
8export type { KeeperState }
9export type { Lang }
10
11export const PLUGIN = 'cache-keeper'
12/** The timer looks every 30 seconds */
13export const TICK_MS = 30_000
14/** The prompt cache lives one hour past its last use */
15export const CACHE_TTL_MIN = 60
16export const DEFAULT_INTERVAL_MIN = 50
17export const MIN_INTERVAL_MIN = 5
18export const MAX_INTERVAL_MIN = 59
19export const DEFAULT_IDLE_CAP_HOURS = 4
20/** First failure waits a minute, then doubles, up to one interval */
21export const FIRST_BACKOFF_MS = 60_000
22export const TOAST_MS = 8000
23/** The one line sent to the model: short, and it does not invite a long reply */
24export const POKE_PROMPT = 'Reply with exactly the single word: ok'
25
26export const EMPTY_STATE: KeeperState = {
27 lastRequestAt: null,
28 lastRealTurnAt: null,
29 lastAttemptAt: null,
30 pokes: 0,
31 failures: 0,
32 lastCacheRead: null,
33 lastInput: null,
34 isPaused: false,
35 backoffMs: 0,
36 isPoking: false,
37 isTurnRunning: false,
38 isCapNoticed: false,
39}
40
41// ── Settings ────────────────────────────────────────────────────────────────
42
43export type KeeperConfig = {
44 enabled: boolean
45 intervalMs: number
46 /** 0 = no cap */
47 idleCapMs: number
48 showBand: boolean
49 /** The raw `language` option; resolved against the environment at session.start */
50 language: unknown
51 /** How the band line is framed (`band_style`) */
52 bandStyle: BandStyle
53}
54
55function num(v: unknown, fallback: number): number {
56 const n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() !== '' ? Number(v) : NaN
57 return Number.isFinite(n) ? n : fallback
58}
59
60function bool(v: unknown, fallback: boolean): boolean {
61 if (typeof v === 'boolean') return v
62 if (v === 'true') return true
63 if (v === 'false') return false
64 return fallback
65}
66
67/** The interval is held to 5–59 minutes: past 60 the cache is already gone, under 5 is just spending */
68export function clampInterval(minutes: number): number {
69 if (!Number.isFinite(minutes)) return DEFAULT_INTERVAL_MIN
70 return Math.min(MAX_INTERVAL_MIN, Math.max(MIN_INTERVAL_MIN, Math.round(minutes)))
71}
72
73export function parseConfig(options: Readonly<Record<string, unknown>> | undefined): KeeperConfig {
74 const o = options ?? {}
75 const capHours = Math.max(0, num(o.max_idle_hours, DEFAULT_IDLE_CAP_HOURS))
76 return {
77 enabled: bool(o.enabled, true),
78 intervalMs: clampInterval(num(o.interval_minutes, DEFAULT_INTERVAL_MIN)) * 60_000,
79 idleCapMs: Math.round(capHours * 3_600_000),
80 showBand: bool(o.show_band, true),
81 language: o.language,
82 bandStyle: parseBandStyle(o.band_style),
83 }
84}
85
86// ── Timing ──────────────────────────────────────────────────────────────────
87
88/**
89 * When the next poke is due: normally one interval after the last request;
90 * while backing off, the backoff after the last attempt (whichever is later).
91 */
92export function nextPokeAt(lastRequestAt: number, lastAttemptAt: number | null, intervalMs: number, backoffMs: number): number {
93 const regular = lastRequestAt + intervalMs
94 if (backoffMs <= 0 || lastAttemptAt === null) return regular
95 return Math.max(regular, lastAttemptAt + backoffMs)
96}
97
98export function isDue(now: number, s: Pick<KeeperState, 'lastRequestAt' | 'lastAttemptAt' | 'backoffMs'>, intervalMs: number): boolean {
99 if (s.lastRequestAt === null) return false
100 return now >= nextPokeAt(s.lastRequestAt, s.lastAttemptAt, intervalMs, s.backoffMs)
101}
102
103/** Past the idle cap since the last real turn; a cap of 0 means no cap */
104export function isPastIdleCap(lastRealTurnAt: number | null, now: number, capMs: number): boolean {
105 if (capMs <= 0 || lastRealTurnAt === null) return false
106 return now - lastRealTurnAt >= capMs
107}
108
109/** Backoff after a failure: 1m, 2m, 4m… up to one interval */
110export function nextBackoff(prev: number, intervalMs: number): number {
111 const next = prev <= 0 ? FIRST_BACKOFF_MS : prev * 2
112 return Math.min(next, intervalMs)
113}
114
115export type UsageLike = { input_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
116
117/** The poke missed the cache: under a quarter of the input was served from it (usually the entry had lapsed) */
118export function isCacheMiss(u: UsageLike): boolean {
119 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
120 if (total <= 0) return false
121 return u.cache_read_input_tokens * 4 < total
122}
123
124// ── Text ────────────────────────────────────────────────────────────────────
125
126export function fmtTokens(n: number): string {
127 if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
128 if (n >= 10_000) return `${Math.round(n / 1000)}k`
129 if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
130 return `${n}`
131}
132
133export function duration(ms: number): string {
134 const s = Math.max(0, Math.floor(ms / 1000))
135 if (s < 60) return `${s}s`
136 if (s < 3600) return `${Math.floor(s / 60)}m`
137 return `${Math.floor(s / 3600)}h${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`
138}
139
140/** For countdowns: under a minute reads "under 1m" */
141export function countdown(ms: number, lang: Lang): string {
142 if (ms < 60_000) return t(lang, 'countdown.under1m')
143 return duration(ms)
144}
145
146/** The band line; null means no line */
147export function bandText(s: KeeperState, cfg: KeeperConfig, now: number, lang: Lang): string | null {
148 if (!cfg.enabled || !cfg.showBand || s.isPaused) return null
149 if (s.lastRequestAt === null) return null
150 if (s.isPoking) return t(lang, 'band.poking')
151 if (isPastIdleCap(s.lastRealTurnAt, now, cfg.idleCapMs)) return t(lang, 'band.capped', { d: duration(cfg.idleCapMs) })
152 const at = nextPokeAt(s.lastRequestAt, s.lastAttemptAt, cfg.intervalMs, s.backoffMs)
153 const parts = [t(lang, 'band.warm'), t(lang, 'band.next', { t: countdown(at - now, lang) })]
154 if (s.pokes > 0) parts.push(t(lang, 'band.pokes', { n: s.pokes }))
155 if (s.lastCacheRead !== null) parts.push(t(lang, 'band.lastHit', { tok: fmtTokens(s.lastCacheRead) }))
156 if (s.backoffMs > 0) parts.push(t(lang, 'band.backoff', { n: s.failures }))
157 return parts.join(' · ')
158}
159
160/** The /cache-keeper status text */
161export function statusText(s: KeeperState, cfg: KeeperConfig, now: number, lang: Lang): string {
162 const lines: string[] = []
163 lines.push(!cfg.enabled ? t(lang, 'status.disabled') : s.isPaused ? t(lang, 'status.paused') : t(lang, 'status.enabled'))
164 lines.push(t(lang, 'status.interval', { interval: duration(cfg.intervalMs), cap: cfg.idleCapMs > 0 ? duration(cfg.idleCapMs) : t(lang, 'status.noCap') }))
165 if (s.lastRequestAt === null) lines.push(t(lang, 'status.noRequest'))
166 else if (isPastIdleCap(s.lastRealTurnAt, now, cfg.idleCapMs)) lines.push(t(lang, 'status.capped'))
167 else {
168 const at = nextPokeAt(s.lastRequestAt, s.lastAttemptAt, cfg.intervalMs, s.backoffMs)
169 lines.push(t(lang, 'status.next', { ago: duration(now - s.lastRequestAt), next: countdown(at - now, lang) }))
170 }
171 const hit = s.lastCacheRead !== null ? t(lang, 'status.lastHit', { hit: fmtTokens(s.lastCacheRead), miss: fmtTokens(s.lastInput ?? 0) }) : ''
172 lines.push(`${t(lang, 'status.counts', { pokes: s.pokes, failures: s.failures })}${hit}`)
173 return lines.join('\n')
174}
175
176/** One poke's outcome (for /cache-keeper now and the log) */
177export function pokeText(u: UsageLike, lang: Lang): string {
178 return isCacheMiss(u)
179 ? t(lang, 'poke.miss', { tok: fmtTokens(u.cache_creation_input_tokens + u.input_tokens) })
180 : t(lang, 'poke.hit', { hit: fmtTokens(u.cache_read_input_tokens), miss: fmtTokens(u.input_tokens + u.cache_creation_input_tokens) })
181}
182
183
184// ── Band framing ───────────────────────────────────────────────────────────
185
186/** How the mod's line above the prompt is framed: a rounded box, a thin rule beneath, or bare text. */
187export type BandStyle = 'box' | 'rule' | 'plain'
188
189/** The `band_style` option; anything but `rule` or `plain` is the default box. */
190export function parseBandStyle(v: unknown): BandStyle {
191 return v === 'rule' || v === 'plain' ? v : 'box'
192}
193types/index.d.ts 42 lines1// cache-keeper data types and its $.state contract.
2
3/** This session's warming state; kept across hot reloads, partly reset on /clear */
4export type KeeperState = {
5 /** When the last real model request went out (main-loop turn.step, turn.complete, or our own poke); null = none yet */
6 lastRequestAt: number | null
7 /** When the last real turn started; the idle cap counts from here */
8 lastRealTurnAt: number | null
9 /** When we last tried to poke (success or failure); backoff counts from here */
10 lastAttemptAt: number | null
11 /** Successful pokes */
12 pokes: number
13 /** Failed pokes */
14 failures: number
15 /** Cache-read tokens of the last successful poke */
16 lastCacheRead: number | null
17 /** Uncached input tokens of the last successful poke (input + cache_creation) */
18 lastInput: number | null
19 /** True after /cache-keeper off */
20 isPaused: boolean
21 /** Current backoff in milliseconds; 0 = none */
22 backoffMs: number
23 /** A poke is in flight */
24 isPoking: boolean
25 /** A main-loop turn is running */
26 isTurnRunning: boolean
27 /** The idle-cap notice has been shown once */
28 isCapNoticed: boolean
29}
30
31declare module 'claude-code' {
32 interface PluginState {
33 'cache-keeper': {
34 state: KeeperState
35 /** Written on every timer tick, only so the countdown redraws */
36 tickAt: number
37 /** The resolved UI language: 'en', 'zh-TW' or 'ja' */
38 lang: string
39 }
40 }
41}
42