Keeps the main thread's prompt cache warm while you are idle: one cheap fork ping just before the TTL runs out, capped, verified on every hit.

Keeps the main thread's prompt cache warm while you are away. About five minutes before the cache TTL runs out, it sends one tool-less fork of the conversation ($.model.fork) that re-reads the cached prefix and restarts the TTL. The ping never lands in the transcript, so nothing needs rewinding.
By default it pings at most twice per idle stretch, so a lunch or a meeting comes back to a warm cache. Every ping is verified: if the fork does not read the prefix from cache, keepalive turns itself off for the session.
Requires Claude Code 2.1.289 or later (hook-module plugin API). The status widget targets ccstatusline 2.2.30.
Nothing is required: once the plugin loads, it keeps the cache warm. The rest is optional.
| You want | Set up | |
|---|---|---|
| Keepalive itself | Load the plugin (below) | required |
| The state always on screen | A status line field and a refresh interval (docs/status-line.md) | optional; /keepalive status works without it |
| A Telegram question once the pings run out | Bot token and telegramChatId (docs/telegram.md) | optional; both are needed, or nothing is sent |
| Only your own answers to count | telegramUserId | optional; without it anyone in the chat can answer |
Load the plugin, one of:
# every session: add the folder to CLAUDE_CODE_PLUGIN_DIRS in ~/.claude/settings.json ("env" block),
# separated by ":" from any folders already listed
"CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/plugins/cache-keepalive"
# one session only
claude --plugin-dir /absolute/path/to/plugins/cache-keepalive
Then set options with /plugin configure cache-keepalive (sensitive ones included) or /config (search for keep). A session started with --plugin-dir reads them from pluginConfigs["cache-keepalive"].options in settings.
Check it: /keepalive status shows the phase, the TTL it read, and telegram: on when Telegram is set up.
| Command | What it does |
|---|---|
/keepalive status | Phase, TTL, context size, pings so far, next ping and expiry times |
/keepalive brb <minutes or hours> | Keep warm for longer this idle stretch, e.g. brb 180 allows 4 pings and brb 24h 27. Resets at your next prompt |
/keepalive brb reset | Back to the configured maxPings for this idle stretch. Pings already sent are not taken back |
/keepalive done | Stop pinging for this idle stretch. Resets at your next prompt |
/keepalive compact | Arm a one-time compact for when keepalive runs out (below). compact off cancels |
In a terminal the replies are coloured (a past-break-even warning in yellow, errors in red, the phase in status by state); other surfaces get plain text. Typing /keepalive brb offers 180 as a dim completion; Right arrow accepts. When you come back to an expired cache, a one-line band above the prompt says how much the next request will rewrite; your next prompt or Dismiss clears it.
Set in /config (or pluginConfigs["cache-keepalive"].options in settings):
| Option | Default | |
|---|---|---|
enabled | true | Master switch |
leadMinutes | 5 | Ping this many minutes before the TTL runs out |
maxPings | 2 | Pings per idle stretch |
minContextTokens | 50000 | Smaller contexts are not kept warm |
compactLeadMinutes | 10 | An armed compact starts this many minutes before the TTL runs out |
compactBeforeExpiry | false | Compact in that window every idle stretch, without arming each time |
engineStatus | false | Show the state as a status entry under the prompt, for sessions without a status line |
telegramBotToken | empty | Bot token (sensitive); see Telegram |
telegramChatId | empty | Chat that receives the question |
telegramUserId | empty | Only this user's answers count |
Run /keepalive compact before you leave, or before a /goal you will not watch. Keepalive pings as usual; once the pings run out, it compacts the conversation compactLeadMinutes (10) before the cache expires, while it is still warm. The summary request reads the cache instead of rewriting the whole prefix (98% of a 142k prefix read from cache, measured once), and your next prompt rewrites a small summary instead of the old context.
/keepalive compact off, or when you type a plain prompt (a toast says so). A slash command or a /goal continuation does not clear it./keepalive done skips the pings: the compact then runs about 50 minutes after the last turn on a 1h TTL. maxPings: 0 does the same for a /goal, which done would not survive.Flow chart, timeline and the edge cases: docs/compact.md.
A ping re-reads the cached prefix at the cache-read price, a small fraction of one rewrite. The default two pings cost about 5% of a rewrite on Opus 5.5 (2.5% on Fable 5.1, 10% on Sonnet 5.5), so keeping warm pays off if you are even slightly likely to come back. Past the break-even (20 pings, about 18 h, on most models; 40 and 37 h on Opus 5.5; 80 and 73 h on Fable 5.1) the pings cost more than the one rewrite they avoid. Sessions under 50k tokens are not kept warm, and a 5-minute TTL turns keepalive off. Prices and the arithmetic: docs/cost.md.
When the pings run out while you are away, keepalive can ask on Telegram whether to keep going: +1h, +3h, or let it expire, by button or by replying with minutes (120, 2h). It needs a bot token and a chat id. Setup: docs/telegram.md.
Keepalive works without a status line: /keepalive status, the expired band and a toast when it turns itself off. For an always-visible state, bin/keepalive-cache is a drop-in for ccstatusline's Cache Timer, or a status line command of its own:
🟢58:54 kp 1/2 ✂03:12
The countdown, kp used/cap and ✂ (an armed compact, with the time it is due) are explained in docs/status-line.md, with the ccstatusline setup and the engineStatus option for sessions with no status line. Set statusLine.refreshInterval (30 seconds is enough), or the field only redraws on events.
error or refusal reads no usage and runs no pings, but an armed compact is still scheduled from the last good response, so a /goal that dies on an API error does not lose it./model, a plugin reload that changes the system prompt or tools) the next ping misses once, and keepalive turns off for the session.| docs/cost.md | Prices per model, ping vs rewrite, break-even |
| docs/compact.md | Flow chart, timeline and edge cases of the armed compact |
| docs/telegram.md | The Telegram question and its setup |
| docs/status-line.md | ccstatusline, your own status line, engineStatus |
| docs/PRD.md | Design, decisions and their evidence |
claude plugin validate plugins/cache-keepalive
claude --plugin-dir plugins/cache-keepalive # once, so the engine lays .claude-plugin/types/ for tsc
tsc -p plugins/cache-keepalive
claude plugin test plugins/cache-keepalive # hook tests (tests/*.test.ts)
node --test plugins/cache-keepalive/tests/widget.test.cjs # status widget testshooks/register.tsx 904 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface as Engine, Register, Timer } from 'claude-code'
3
4import type { KeepaliveSession } from '../types'
5import { header, htmlEscape, keyboard, parseUpdate } from './telegram'
6
7// Keeps the main thread's prompt cache warm while the user is idle: one fork
8// ping shortly before the TTL runs out, capped per idle stretch, verified on
9// every hit. Display lives in the status line (bin/keepalive-cache reads the
10// per-session state file). $.ui.status is used only when engineStatus is on:
11// the engine draws it as its own warning-prefixed row under the prompt.
12
13const PING_PROMPT = 'Cache keep-alive ping, not a task. Reply with exactly: ok'
14const HIT_RATIO = 0.8
15const TAIL_BYTES = 262144
16const RETRY_MS = 30_000
17const STALE_FILE_MS = 7 * 24 * 60 * 60 * 1000
18const TTL_1H = 3600
19const TTL_5M = 300
20// /keepalive brb completions, the first one offered on an empty argument.
21const BRB_PRESETS = ['180', '60', '480']
22// Pings that cost as much as one 1h-cache rewrite: the 1h write price (2x input)
23// over the cache-read price, per the pricing page (checked 2026-10-07). Reads
24// are 0.1x input on every model but Claude Fable 5.1 / Mythos 5.1 (0.025x) and
25// Claude Opus 5.5 (0.05x).
26function breakEvenPings(model: string): number | null {
27 if (/(fable|mythos)-5-1/.test(model)) return 80
28 if (/opus-5-5/.test(model)) return 40
29 return /(fable|mythos|opus|sonnet|haiku)/.test(model) ? 20 : null
30}
31const TG_POLL_MS = 10_000
32const TG_API = 'https://api.telegram.org'
33const KEYCHAIN_SERVICE = 'claude-code.cache-keepalive'
34const KEYCHAIN_ACCOUNT = 'telegram-bot-token'
35
36const INITIAL: KeepaliveSession = {
37 phase: 'active',
38 offReason: null,
39 lastActivityAt: 0,
40 lastPingAt: 0,
41 pingsSent: 0,
42 maxPings: 2,
43 ttlSec: null,
44 contextTokens: 0,
45 transcriptPath: null,
46 isBandDismissed: false,
47 isOffToastShown: false,
48 tgAskMessageId: null,
49 isCompactArmed: false,
50}
51
52const session = atom({ plugin: 'cache-keepalive', key: 'session' } as const, INITIAL)
53
54type Config = {
55 enabled: boolean
56 leadMinutes: number
57 compactLeadMinutes: number
58 maxPings: number
59 minContextTokens: number
60 engineStatus: boolean
61 compactBeforeExpiry: boolean
62 telegramChatId: string
63 telegramUserId: string
64}
65
66// Module variables start over on a hot reload; the engine drops the old
67// timers with them, and session.start re-arms from $.state.
68let config: Config = {
69 enabled: true,
70 leadMinutes: 5,
71 compactLeadMinutes: 10,
72 maxPings: 2,
73 minContextTokens: 50000,
74 engineStatus: false,
75 compactBeforeExpiry: false,
76 telegramChatId: '',
77 telegramUserId: '',
78}
79let pingTimer: Timer | undefined
80let expiryTimer: Timer | undefined
81let compactTimer: Timer | undefined
82let isCompacting = false
83let isRetrying = false
84// ANSI colour in a command's reply: the terminal draws it (checked: bold, dim,
85// 16 colours, backgrounds and truecolor), other surfaces may show the codes.
86let isTerminal = false
87let sessionId = ''
88let stateDir = ''
89let projectsDir = ''
90// The dim completion tail this module last put after the prompt draft.
91let brbTail = ''
92let cwd = ''
93// Telegram: the resolved bot token (never written anywhere) and the poller.
94let tgTokenOption = ''
95let tgToken = ''
96let tgPoll: Timer | undefined
97let isPolling = false
98
99export const register: Register = (on, options) => {
100 config = {
101 enabled: options.enabled !== false,
102 leadMinutes: positiveNumber(options.leadMinutes, 5),
103 compactLeadMinutes: positiveNumber(options.compactLeadMinutes, 10),
104 maxPings: Math.max(0, Math.floor(positiveNumber(options.maxPings, 2, true))),
105 minContextTokens: positiveNumber(options.minContextTokens, 50000, true),
106 engineStatus: options.engineStatus === true,
107 compactBeforeExpiry: options.compactBeforeExpiry === true,
108 telegramChatId: String(options.telegramChatId ?? '').trim(),
109 telegramUserId: String(options.telegramUserId ?? '').trim(),
110 }
111 tgTokenOption = String(options.telegramBotToken ?? '').trim()
112
113 on('session.start', async ($, e, next) => {
114 await $.command.register({
115 name: 'keepalive',
116 description: 'Prompt-cache keepalive: done, brb <minutes|hours|reset>, compact [off], status',
117 argumentHint: 'done | brb <minutes|hours|reset> | compact [off] | status',
118 })
119 const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? `${(await $.env.get('HOME')) ?? ''}/.claude`
120 stateDir = `${configDir}/keepalive`
121 projectsDir = `${configDir}/projects/${e.cwd.replace(/[^A-Za-z0-9]/g, '-')}`
122 cwd = e.cwd
123 isTerminal = e.surface === 'terminal'
124 tgToken = await resolveTelegramToken($)
125 await bindSession($)
126 await rearm($)
127 // After a reload, keep listening for the answer to a question still open.
128 if ((await read($, session)).tgAskMessageId !== null && isTelegramOn()) startPolling($)
129 await sweepStaleFiles($)
130 return next(e)
131 })
132
133 // The settings hook envelope carries the real transcript path; the one
134 // derived from cwd in bindSession is only the fallback.
135 on('classic.UserPromptSubmit', async ($, e, next) => {
136 const s = await read($, session)
137 if (e.transcript_path && e.transcript_path !== s.transcriptPath) {
138 await update($, session, v => ({ ...v, transcriptPath: e.transcript_path }))
139 }
140 // You are back: an armed compact was for the time you are away. Only a prompt
141 // you typed counts. `source` tells it from the engine's own turns (a /goal
142 // continuation is `system`) where the engine sends it; this build leaves it
143 // out (measured: undefined on typed prompts, and no event at all on a /goal
144 // continuation), so an absent source counts as typed. A slash command does
145 // not count: /goal and /keepalive status are typed too, and arming comes
146 // before /goal.
147 const isFromYou = e.source === undefined || e.source === 'user'
148 const isTyped = isFromYou && e.prompt.trim() !== '' && !e.prompt.trimStart().startsWith('/')
149 if (isTyped && s.isCompactArmed) {
150 await save($, v => ({ ...v, isCompactArmed: false }))
151 $.ui.toast('Armed compact cancelled: you are back. /keepalive compact arms it again.')
152 }
153 return next(e)
154 })
155
156 // Only the main loop raises turn.start; a subagent's run does not.
157 on('turn.start', async ($, e, next) => {
158 await bindSession($)
159 cancelTimers()
160 isRetrying = false
161 await closeAsk($, '↩️ Back at the keyboard.')
162 await save($, v => ({
163 ...v,
164 // Off stays off for the session, except when the off switch itself was
165 // turned back on (a config change reloads the module with new options).
166 ...(v.phase === 'off' && !(v.offReason === 'disabled' && config.enabled)
167 ? {}
168 : { phase: 'active' as const, offReason: null }),
169 pingsSent: 0,
170 maxPings: config.maxPings,
171 lastPingAt: 0,
172 isBandDismissed: false,
173 }))
174 return next(e)
175 })
176
177 on('turn.complete', async ($, e, next) => {
178 const result = await next(e)
179 if (e.agentId !== undefined) return result
180 await bindSession($)
181 await onMainTurnComplete($, e.reason)
182 return result
183 })
184
185 on('session.end', async ($, e, next) => {
186 cancelTimers()
187 await closeAsk($, 'Session ended.')
188 if (sessionId) {
189 await $.process.run(['rm', '-f', stateFile()]).catch(() => undefined)
190 }
191 return next(e)
192 })
193
194 // Fish-style completion for /keepalive brb: a dim tail after the draft,
195 // Right arrow to accept. Tab and Up/Down never reach prompt.edit (the
196 // editor keeps them), and Enter runs what the box shows, tail included.
197 on('prompt.edit', async ($, e, next) => {
198 const shown = brbTail
199 brbTail = ''
200 const hasTail = shown !== '' && e.text.endsWith(shown) && e.cursor === e.text.length - shown.length
201 if (hasTail && e.key?.key === 'right') {
202 return { text: e.text, cursor: e.text.length }
203 }
204 let draft = e
205 if (hasTail) {
206 // Take the tail back out so the edit applies to what was typed.
207 const text = e.text.slice(0, -shown.length)
208 draft = { ...e, text, start: Math.min(e.start, text.length), end: Math.min(e.end, text.length) }
209 }
210 const box = await next(draft)
211 const tail = brbCompletion(box.text, box.cursor)
212 if (!tail) return box
213 brbTail = tail
214 const at = box.text.length
215 return {
216 ...box,
217 text: box.text + tail,
218 decorations: [...(box.decorations ?? []), { start: at, end: at + tail.length, dimColor: true }],
219 }
220 })
221
222 on('command.run', { command: 'keepalive' }, async ($, e) => ({ text: await runCommand($, e.args) }))
223
224 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
225 const s = await read($, session)
226 if (e.props.hasSurvey || s.phase !== 'expired' || s.isBandDismissed) {
227 return next(e)
228 }
229 const { Box, Text, Button } = $.ui.resolve(e)
230 const rate = s.ttlSec === TTL_5M ? 1.25 : 2
231 const tokens = s.contextTokens
232 return (
233 <Box>
234 <Text color="cyan">
235 ❄ Cache expired at {clockTime(cacheStart(s) + (s.ttlSec ?? TTL_1H) * 1000)} · next request rewrites ~
236 {kilo(tokens)} (≈ {kilo(tokens * rate)} at the write rate){' '}
237 </Text>
238 <Button
239 key="dismiss"
240 label="Dismiss"
241 role="dismiss"
242 onPress={() => update($, session, v => ({ ...v, isBandDismissed: true }))}
243 />
244 </Box>
245 )
246 })
247}
248
249async function onMainTurnComplete($: Engine, reason: string) {
250 const s = await read($, session)
251 if (s.phase === 'off') return writeStateFile($, s)
252 if (!config.enabled) {
253 await save($, v => ({ ...v, phase: 'off', offReason: 'disabled' }))
254 return
255 }
256 const now = await $.clock.now()
257 if (reason === 'error' || reason === 'refusal') {
258 // An armed compact must still run (a /goal can end on an error while you
259 // sleep). No usage is read and no pings run, since what the cache holds is
260 // uncertain; the clock stays at the last good response so the lead window is
261 // not pushed past the real expiry.
262 const isArmed = s.isCompactArmed && s.ttlSec !== null && s.lastActivityAt > 0 && s.contextTokens >= config.minContextTokens
263 if (isArmed) {
264 await save($, v => ({ ...v, phase: 'capped' }))
265 await rearm($)
266 } else {
267 await save($, v => ({ ...v, phase: 'active', lastActivityAt: now }))
268 }
269 return
270 }
271 // An aborted turn may carry no usage: keep the last known values then.
272 const usage = await $.session.usage().catch(() => undefined)
273 const contextTokens = usage?.context?.tokens ?? s.contextTokens
274 const ttlSec = (await readTtl($, s.transcriptPath)) ?? s.ttlSec
275 const base = { ...s, lastActivityAt: now, contextTokens, ttlSec }
276
277 if (ttlSec === TTL_5M) {
278 // 13 pings an hour at 0.1x cost more than one 1.25x rewrite.
279 await save($, () => ({ ...base, phase: 'off', offReason: '5m-ttl' }))
280 return
281 }
282 if (contextTokens < config.minContextTokens) {
283 await save($, () => ({ ...base, phase: 'small' }))
284 return
285 }
286 if (ttlSec === null) {
287 // Not read yet: no schedule. The next turn tries again.
288 await save($, () => ({ ...base, phase: 'active' }))
289 return
290 }
291 await save($, () => ({ ...base, phase: base.maxPings > 0 ? 'armed' : 'capped' }))
292 await rearm($)
293}
294
295// Schedules the next ping (armed) and the expiry (armed or capped) from state.
296async function rearm($: Engine) {
297 cancelTimers()
298 const s = await read($, session)
299 if (s.ttlSec === null) return
300 const now = await $.clock.now()
301 const expiresAt = cacheStart(s) + s.ttlSec * 1000
302 // /keepalive done stops the pings but an armed compact still runs, once, in
303 // the first lead window.
304 if (s.phase === 'stopped' && s.isCompactArmed) {
305 compactTimer = $.clock.after(Math.max(0, expiresAt - config.compactLeadMinutes * 60_000 - now), () => {
306 void compact($)
307 })
308 return
309 }
310 if (s.phase !== 'armed' && s.phase !== 'capped') return
311 if (s.phase === 'armed') {
312 const pingAt = expiresAt - config.leadMinutes * 60_000
313 pingTimer = $.clock.after(Math.max(0, pingAt - now), () => {
314 void ping($)
315 })
316 }
317 expiryTimer = $.clock.after(Math.max(0, expiresAt - now), () => {
318 void expire($)
319 })
320 // Pings used up: compact while the cache is still warm, so the summary
321 // request reads it instead of rewriting the whole prefix.
322 if (s.phase === 'capped' && (config.compactBeforeExpiry || s.isCompactArmed)) {
323 compactTimer = $.clock.after(Math.max(0, expiresAt - config.compactLeadMinutes * 60_000 - now), () => {
324 void compact($)
325 })
326 }
327}
328
329// Fires from the lead window, only while capped and still warm. It cannot run
330// from a command (the host refuses it under the command's turn), which is why
331// /keepalive compact only arms it.
332async function compact($: Engine) {
333 if (isCompacting) return
334 const s = await read($, session)
335 const isStoppedArmed = s.phase === 'stopped' && s.isCompactArmed
336 if ((s.phase !== 'capped' && !isStoppedArmed) || s.ttlSec === null) return
337 if ((await $.clock.now()) >= cacheStart(s) + s.ttlSec * 1000) {
338 if (!isStoppedArmed) await expire($) // fired late (the Mac slept): the cache is gone
339 return
340 }
341 isCompacting = true
342 let text: string
343 try {
344 const r = await $.session.compact()
345 if (r.skip !== undefined) {
346 text = `Compact skipped: ${r.skip}`
347 } else {
348 const tokens = r.tokensAfter ?? s.contextTokens
349 cancelTimers()
350 // The new prefix is not cached yet: nothing to keep warm until the next turn.
351 await save($, v =>
352 v.phase === 'armed' || v.phase === 'capped' || v.phase === 'expired'
353 ? { ...v, phase: 'active', contextTokens: tokens, isCompactArmed: false }
354 : { ...v, contextTokens: tokens, isCompactArmed: false },
355 )
356 text = `Compacted before the cache expired: ${kilo(r.tokensBefore ?? s.contextTokens)} → ${kilo(tokens)} tokens.`
357 }
358 } catch (err) {
359 // A cancel or a refusal is final: no retry, the cache just expires.
360 text = `Compact did not run: ${String(err).replace(/^.*?session\.compact: /, '')}`
361 } finally {
362 isCompacting = false
363 }
364 await update($, session, v => ({ ...v, isCompactArmed: false }))
365 $.ui.toast(text)
366}
367
368async function ping($: Engine) {
369 const s = await read($, session)
370 if (s.phase !== 'armed' || s.ttlSec === null) return
371 const startedAt = await $.clock.now()
372 if (startedAt >= cacheStart(s) + s.ttlSec * 1000) {
373 // The timer fired late (the Mac slept): the cache is already gone, and a
374 // ping now would pay a full rewrite and read as a miss.
375 await expire($)
376 return
377 }
378 const r = await $.model.fork({ prompt: PING_PROMPT })
379 const after = await read($, session)
380 if (after.phase !== 'armed') return // a turn started while the fork ran
381
382 if (!r.isAnswered && r.reason === 'nothing-to-fork') {
383 cancelTimers()
384 await save($, v => ({ ...v, phase: 'active' }))
385 return
386 }
387 const usage = 'usage' in r ? r.usage : undefined
388 const prefix = usage ? usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens : 0
389 if (!usage || prefix === 0) {
390 // api-error or aborted with nothing sent: retry once, then give up.
391 if (!isRetrying) {
392 isRetrying = true
393 pingTimer = $.clock.after(RETRY_MS, () => {
394 void ping($)
395 })
396 return
397 }
398 await turnOff($, 'api-error')
399 return
400 }
401 isRetrying = false
402 const isHit = usage.cache_read_input_tokens / prefix >= HIT_RATIO
403 await recordStats($, isHit)
404 if (!isHit) {
405 await turnOff($, 'ttl-mismatch')
406 return
407 }
408 await save($, v => {
409 const pingsSent = v.pingsSent + 1
410 return { ...v, pingsSent, lastPingAt: startedAt, phase: pingsSent >= v.maxPings ? 'capped' : 'armed' }
411 })
412 await rearm($)
413 if ((await read($, session)).phase === 'capped') await askOnTelegram($)
414}
415
416async function expire($: Engine) {
417 const s = await read($, session)
418 if (s.phase !== 'armed' && s.phase !== 'capped') return
419 cancelTimers()
420 await save($, v => ({ ...v, phase: 'expired' }))
421 if (s.ttlSec !== null) await closeAsk($, `❄️ Cache expired at ${clockTime(cacheStart(s) + s.ttlSec * 1000)}.`)
422}
423
424async function turnOff($: Engine, offReason: 'api-error' | 'ttl-mismatch') {
425 cancelTimers()
426 const s = await save($, v => ({ ...v, phase: 'off', offReason }))
427 if (!s.isOffToastShown) {
428 const why =
429 offReason === 'ttl-mismatch'
430 ? 'the ping missed the cache, so this session’s cache lasts less than the ping interval'
431 : 'the ping failed twice with an API error'
432 $.ui.toast(`cache-keepalive off for this session: ${why}.`)
433 await update($, session, v => ({ ...v, isOffToastShown: true }))
434 }
435}
436
437async function runCommand($: Engine, args: string): Promise<string> {
438 const [verb = 'status', value] = args.trim().split(/\s+/)
439 const s = await read($, session)
440
441 if (verb === 'done') {
442 cancelTimers()
443 if (s.phase === 'off') return paint('Keepalive is already off for this session.', 'red')
444 await save($, v => ({ ...v, phase: 'stopped' }))
445 await closeAsk($, '💤 Stopped with /keepalive done.')
446 await rearm($) // an armed compact stays scheduled
447 return s.isCompactArmed && s.ttlSec !== null
448 ? `Pings stopped until your next prompt; the armed compact still runs at ${clockTime(compactAt({ ...s, phase: 'stopped' }))}.`
449 : 'Keepalive stopped until your next prompt.'
450 }
451
452 if (verb === 'brb') {
453 const isReset = value?.toLowerCase() === 'reset'
454 const minutes = isReset ? 0 : parseDuration(value)
455 if (!isReset && (!Number.isFinite(minutes) || minutes <= 0)) {
456 return paint('Usage: /keepalive brb <minutes or hours> | reset, for example /keepalive brb 180, /keepalive brb 24h or /keepalive brb reset', 'red')
457 }
458 if (s.phase === 'off') return paint(`Keepalive is off for this session (${s.offReason}).`, 'red')
459 if (s.phase === 'small') return paint('This session’s context is small enough that a rewrite is cheap; not keeping it warm.', 'yellow')
460 if (s.ttlSec === null || s.phase === 'active') return paint('Nothing to keep warm yet: wait for the current turn to finish.', 'yellow')
461 if (s.phase === 'expired') return paint('The cache has already expired; your next prompt rewrites it.', 'yellow')
462 const intervalSec = s.ttlSec - config.leadMinutes * 60
463 // reset: the configured cap again (the pings already sent stay sent).
464 const maxPings = isReset ? Math.max(config.maxPings, s.pingsSent) : Math.max(s.pingsSent + 1, Math.ceil((minutes * 60) / intervalSec))
465 await save($, v => ({ ...v, maxPings, phase: v.pingsSent < maxPings ? 'armed' : 'capped' }))
466 await rearm($)
467 const now = await $.clock.now()
468 const next = Math.max(now, cacheStart(s) + s.ttlSec * 1000 - config.leadMinutes * 60_000)
469 if (isReset) {
470 if (maxPings === 0) return 'Back to the default: no pings this idle stretch.'
471 return s.pingsSent >= maxPings
472 ? `Back to the default of ${pingCount(config.maxPings)}: the ${pingCount(s.pingsSent)} already sent use it up, so the cache expires at ${clockTime(cacheStart(s) + s.ttlSec * 1000)}.`
473 : `Back to the default: up to ${pingCount(maxPings)} this idle stretch, next at ${clockTime(next)}.`
474 }
475 const model = (await $.session.model().catch(() => '')) ?? ''
476 const breakEven = breakEvenPings(model)
477 // Only a heads-up: pings past the break-even cost more than the one rewrite they avoid.
478 const warning =
479 breakEven !== null && maxPings > breakEven
480 ? `\n${paint(`⚠ Past the break-even for ${model} (about ${breakEven} pings, ${Math.round((breakEven * intervalSec) / 3600)} h): the pings cost more than the one rewrite they avoid, so it only pays if you are sure to come back.`, 'yellow')}`
481 : ''
482 return `Keeping the cache warm for about ${minutes} min: up to ${pingCount(maxPings)} this idle stretch, next at ${clockTime(next)}.${warning}`
483 }
484
485 if (verb === 'compact') {
486 // Arms the compact for when keepalive runs out; the built-in /compact does it now.
487 if (value === 'off') {
488 cancelTimers()
489 await save($, v => ({ ...v, isCompactArmed: false })) // save, not update: the state file feeds the status line
490 await rearm($)
491 return 'Compact-before-expiry disarmed.'
492 }
493 if (s.phase === 'off') return paint(`Keepalive is off for this session (${s.offReason}).`, 'red')
494 if (s.phase === 'expired') return paint('The cache has already expired; use the built-in /compact if you still want one.', 'yellow')
495 if (s.phase === 'small') return paint('This session’s context is small, so it is not kept warm and nothing is compacted. To compact anyway, use the built-in /compact.', 'yellow')
496 await save($, v => ({ ...v, isCompactArmed: true }))
497 await rearm($)
498 const tail = ' One time; it survives /goal turns. /keepalive compact off cancels. To compact now, use the built-in /compact.'
499 if (s.ttlSec === null || s.phase === 'active') {
500 return `Armed: it is scheduled when the current turn ends, after the pings run out.${tail}`
501 }
502 return `Armed: compact at about ${clockTime(compactAt(s))}${s.phase === 'armed' ? ' (after the remaining pings)' : s.phase === 'stopped' ? ' (no pings, you ran /keepalive done)' : ''}.${tail}`
503 }
504
505 if (verb === 'status') {
506 const stats = ((await $.store.get('stats')) as Stats | undefined) ?? { pings: 0, hits: 0 }
507 const lines = [
508 `phase: ${paintPhase(s.phase)}${s.offReason ? ` (${s.offReason})` : ''}`,
509 `ttl: ${s.ttlSec === null ? 'not read yet' : `${s.ttlSec / 60} min`}`,
510 `context: ${kilo(s.contextTokens)} tokens (minimum ${kilo(config.minContextTokens)})`,
511 `pings this idle stretch: ${s.pingsSent}/${s.maxPings}`,
512 ]
513 if (s.ttlSec !== null && (s.phase === 'armed' || s.phase === 'capped')) {
514 const expiresAt = cacheStart(s) + s.ttlSec * 1000
515 if (s.phase === 'armed') lines.push(`next ping: ${clockTime(expiresAt - config.leadMinutes * 60_000)}`)
516 lines.push(`cache expires: ${clockTime(expiresAt)}`)
517 }
518 if (config.compactBeforeExpiry || s.isCompactArmed) {
519 const due = compactDueAt(s)
520 const at = due === null ? null : clockTime(due)
521 lines.push(`compact before expiry: ${s.isCompactArmed ? 'armed' : 'on'}${at ? `, at ${at}` : ''}`)
522 } else {
523 lines.push('compact before expiry: off')
524 }
525 lines.push(`telegram: ${isTelegramOn() ? `on (chat ${config.telegramChatId}${s.tgAskMessageId !== null ? ', question open' : ''})` : 'off'}`)
526 lines.push(`all sessions: ${stats.pings} pings, ${stats.hits} hits`)
527 return lines.join('\n')
528 }
529
530 return paint('Usage: /keepalive done | brb <minutes|hours|reset> | compact [off] | status', 'red')
531}
532
533type Stats = { pings: number; hits: number }
534
535// --- Telegram: ask once the pings run out, read the answer by polling ---
536
537function isTelegramOn(): boolean {
538 return tgToken !== '' && config.telegramChatId !== ''
539}
540
541async function resolveTelegramToken($: Engine): Promise<string> {
542 if (tgTokenOption) return tgTokenOption
543 const fromEnv = (await $.env.get('CLAUDE_KEEPALIVE_TELEGRAM_BOT_TOKEN'))?.trim()
544 if (fromEnv) return fromEnv
545 const r = await $.process
546 .run(['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', KEYCHAIN_ACCOUNT, '-w'])
547 .catch(() => undefined)
548 return r && r.exitCode === 0 ? r.stdout.trim() : ''
549}
550
551// One Bot API call; undefined on any failure (network, HTTP, ok: false).
552async function telegram($: Engine, method: string, body: Record<string, unknown>): Promise<unknown> {
553 if (!tgToken) return undefined
554 const res = await $.http
555 .fetch(`${TG_API}/bot${tgToken}/${method}`, {
556 method: 'POST',
557 headers: { 'content-type': 'application/json' },
558 body: JSON.stringify(body),
559 })
560 .catch(() => undefined)
561 if (!res) return undefined
562 try {
563 const data = JSON.parse(res.text) as { ok?: boolean; result?: unknown }
564 return data.ok ? data.result : undefined
565 } catch {
566 return undefined
567 }
568}
569
570async function askOnTelegram($: Engine) {
571 const s = await read($, session)
572 if (!isTelegramOn() || s.tgAskMessageId !== null || s.ttlSec === null) return
573 const expiresAt = cacheStart(s) + s.ttlSec * 1000
574 const rate = s.ttlSec === TTL_5M ? 1.25 : 2
575 const text = [
576 await sessionHeader($),
577 `🧊 Cache expires at <b>${clockTime(expiresAt)}</b> · ${kilo(s.contextTokens)} context`,
578 `Rewrite ≈ ${kilo(s.contextTokens * rate)} · one more hour warm ≈ ${kilo(s.contextTokens * 0.1)}`,
579 ...(config.compactBeforeExpiry || s.isCompactArmed
580 ? [`Will compact at ${clockTime(compactAt(s))} unless you keep it warm.`]
581 : []),
582 'Tap a button, or reply with minutes (e.g. 120).',
583 ].join('\n')
584 const sent = (await telegram($, 'sendMessage', {
585 chat_id: config.telegramChatId,
586 text,
587 parse_mode: 'HTML',
588 reply_markup: keyboard(sessionId.slice(0, 8)),
589 })) as { message_id?: number } | undefined
590 if (typeof sent?.message_id !== 'number') return
591 await update($, session, v => ({ ...v, tgAskMessageId: sent.message_id as number }))
592 startPolling($)
593}
594
595function startPolling($: Engine) {
596 stopPolling()
597 tgPoll = $.clock.every(TG_POLL_MS, () => {
598 void pollOnce($)
599 })
600}
601
602function stopPolling() {
603 tgPoll?.cancel()
604 tgPoll = undefined
605}
606
607// Reads pending updates without confirming an offset: other sessions and
608// machines share the bot, and confirming would delete their answers. With
609// the bot's privacy mode on, only button presses and replies are pending,
610// and Telegram drops them after 24 hours.
611async function pollOnce($: Engine) {
612 if (isPolling) return
613 isPolling = true
614 try {
615 const s = await read($, session)
616 if (s.tgAskMessageId === null) {
617 stopPolling()
618 return
619 }
620 const updates = await telegram($, 'getUpdates', { timeout: 0, limit: 100 })
621 if (!Array.isArray(updates)) return
622 const target = {
623 chatId: config.telegramChatId,
624 userId: config.telegramUserId,
625 messageId: s.tgAskMessageId,
626 sessionTag: sessionId.slice(0, 8),
627 }
628 const handled = ((await $.store.get('tgHandled')) as number[] | undefined) ?? []
629 for (const u of updates) {
630 const answer = parseUpdate(u, target)
631 if (!answer || handled.includes(answer.updateId)) continue
632 // Updates stay pending (no offset is confirmed), so remember which ones
633 // were already answered: one press must never count twice.
634 await $.store.set('tgHandled', [...handled, answer.updateId].slice(-200))
635 await applyAnswer($, answer.minutes, answer.callbackId)
636 return
637 }
638 } finally {
639 isPolling = false
640 }
641}
642
643async function applyAnswer($: Engine, minutes: number, callbackId: string | undefined) {
644 const s = await read($, session)
645 let line: string
646 if (s.phase !== 'armed' && s.phase !== 'capped') {
647 line = s.phase === 'expired' ? '❄️ Too late: the cache already expired.' : 'Nothing to keep warm now.'
648 } else if (s.ttlSec === null) {
649 line = 'Nothing to keep warm now.'
650 } else if (minutes === 0) {
651 cancelTimers()
652 await save($, v => ({ ...v, phase: 'stopped' }))
653 line = `💤 Letting it expire at ${clockTime(cacheStart(s) + s.ttlSec * 1000)}.`
654 } else {
655 const intervalMs = (s.ttlSec - config.leadMinutes * 60) * 1000
656 // Each ping adds one interval (TTL − lead, 55 min on a 1h TTL): "+1h" is
657 // one more ping, not two.
658 const extra = Math.max(1, Math.round((minutes * 60_000) / intervalMs))
659 await save($, v => ({ ...v, maxPings: v.pingsSent + extra, phase: 'armed' }))
660 await rearm($)
661 const until = cacheStart(s) + extra * intervalMs + s.ttlSec * 1000
662 line = `✅ Keeping it warm until about ${clockTime(until)} (${extra} more ping${extra > 1 ? 's' : ''}).`
663 }
664 if (callbackId) await telegram($, 'answerCallbackQuery', { callback_query_id: callbackId, text: line })
665 await closeAsk($, line)
666}
667
668// Ends the open question: the message shows the outcome and loses its buttons.
669async function closeAsk($: Engine, line: string) {
670 const s = await read($, session)
671 if (s.tgAskMessageId === null) return
672 stopPolling()
673 await update($, session, v => ({ ...v, tgAskMessageId: null }))
674 await telegram($, 'editMessageText', {
675 chat_id: config.telegramChatId,
676 message_id: s.tgAskMessageId,
677 text: `${await sessionHeader($)}\n${htmlEscape(line)}`,
678 parse_mode: 'HTML',
679 })
680}
681
682// "<project> · <session title>", the header session-notifier uses.
683async function sessionHeader($: Engine): Promise<string> {
684 const project = cwd.split('/').filter(Boolean).pop() ?? 'claude'
685 const s = await read($, session)
686 let title: string | null = null
687 if (s.transcriptPath) {
688 const r = await $.process
689 .run(['grep', '-h', '-E', '^\\{"type":"(custom-title|ai-title)"', s.transcriptPath])
690 .catch(() => undefined)
691 const rows = (r?.stdout ?? '').split('\n').filter(Boolean)
692 title = lastTitle(rows, 'custom-title', 'customTitle') ?? lastTitle(rows, 'ai-title', 'aiTitle')
693 }
694 return header(project, title ?? sessionId.slice(0, 8))
695}
696
697function lastTitle(rows: string[], type: string, field: string): string | null {
698 for (let i = rows.length - 1; i >= 0; i--) {
699 try {
700 const row = JSON.parse(rows[i] ?? '') as Record<string, unknown>
701 if (row.type === type && typeof row[field] === 'string' && row[field]) return row[field] as string
702 } catch {
703 continue
704 }
705 }
706 return null
707}
708
709async function recordStats($: Engine, isHit: boolean) {
710 const stats = ((await $.store.get('stats')) as Stats | undefined) ?? { pings: 0, hits: 0 }
711 await $.store.set('stats', { pings: stats.pings + 1, hits: stats.hits + (isHit ? 1 : 0) })
712}
713
714// Follows the session id: after /clear the engine raises session.end and no
715// session.start, and the next turn runs under a new id with a fresh state.
716async function bindSession($: Engine) {
717 const id = await $.session.id()
718 if (id === sessionId) return
719 const wasBound = sessionId !== ''
720 sessionId = id
721 if (wasBound) {
722 cancelTimers()
723 await update($, session, () => ({ ...INITIAL, maxPings: config.maxPings }))
724 }
725 const s = await read($, session)
726 if (!s.transcriptPath || wasBound) {
727 await update($, session, v => ({ ...v, transcriptPath: `${projectsDir}/${id}.jsonl` }))
728 }
729}
730
731async function save($: Engine, change: (v: KeepaliveSession) => KeepaliveSession): Promise<KeepaliveSession> {
732 const s = await update($, session, v => change(v ?? INITIAL))
733 await writeStateFile($, s)
734 return s
735}
736
737// The file bin/keepalive-cache reads; it holds data, and the script draws it.
738async function writeStateFile($: Engine, s: KeepaliveSession) {
739 if (!sessionId || !stateDir) return
740 const data = {
741 v: 1,
742 phase: s.phase,
743 offReason: s.offReason,
744 ttlSec: s.ttlSec,
745 lastPingAt: s.lastPingAt,
746 pings: s.pingsSent,
747 max: s.maxPings,
748 contextTokens: s.contextTokens,
749 // Armed by /keepalive compact, and the epoch ms it is due (null while
750 // nothing is scheduled, e.g. during a turn): the status line shows both.
751 compactArmed: s.isCompactArmed,
752 compactAt: compactDueAt(s),
753 }
754 await $.fs.write(stateFile(), JSON.stringify(data) + '\n')
755 if (config.engineStatus) $.ui.status(engineStatusText(s))
756}
757
758// Pushed only on state changes, never on a clock, so it names times rather
759// than counting down.
760function engineStatusText(s: KeepaliveSession): string | undefined {
761 const expiresAt = cacheStart(s) + (s.ttlSec ?? TTL_1H) * 1000
762 switch (s.phase) {
763 case 'armed':
764 return `keep ${s.pingsSent}/${s.maxPings} · ping ${clockTime(expiresAt - config.leadMinutes * 60_000)}`
765 case 'capped':
766 return config.compactBeforeExpiry || s.isCompactArmed
767 ? `keep ${s.pingsSent}/${s.maxPings} ⏸ · compact ${clockTime(compactAt(s))}`
768 : `keep ${s.pingsSent}/${s.maxPings} ⏸ · expires ${clockTime(expiresAt)}`
769 case 'stopped':
770 return `keep ⏸ · expires ${clockTime(expiresAt)}`
771 case 'expired':
772 return 'cache expired'
773 case 'off':
774 return s.offReason === 'disabled' ? undefined : `keep off · ${s.offReason}`
775 default:
776 return undefined // active, small
777 }
778}
779
780// The TTL is not in the turn's usage; the transcript's assistant rows carry
781// the 1h/5m split. The latest row that wrote to the cache decides, so a row
782// not yet flushed when turn.complete fires costs nothing.
783async function readTtl($: Engine, transcriptPath: string | null): Promise<number | null> {
784 if (!transcriptPath) return null
785 const r = await $.process.run(['tail', '-c', String(TAIL_BYTES), transcriptPath]).catch(() => undefined)
786 if (!r || r.exitCode !== 0) return null
787 const lines = r.stdout.split('\n')
788 for (let i = lines.length - 1; i >= 0; i--) {
789 const ttl = ttlOfLine(lines[i] ?? '')
790 if (ttl !== null) return ttl
791 }
792 return null
793}
794
795async function sweepStaleFiles($: Engine) {
796 const entries = await $.fs.list(stateDir).catch(() => [])
797 const now = await $.clock.now()
798 for (const entry of entries) {
799 if (entry.kind === 'file' && entry.name.endsWith('.json') && now - entry.mtimeMs > STALE_FILE_MS) {
800 await $.process.run(['rm', '-f', `${stateDir}/${entry.name}`]).catch(() => undefined)
801 }
802 }
803}
804
805const ANSI = { red: '31', green: '32', yellow: '33', dim: '2' } as const
806function paint(text: string, colour: keyof typeof ANSI): string {
807 return isTerminal ? `\u001b[${ANSI[colour]}m${text}\u001b[0m` : text
808}
809
810// armed is working, capped has run out of pings, expired and off need your attention.
811function paintPhase(phase: KeepaliveSession['phase']): string {
812 const colour = { armed: 'green', capped: 'yellow', expired: 'red', off: 'red', stopped: 'dim' } as const
813 return phase in colour ? paint(phase, colour[phase as keyof typeof colour]) : phase
814}
815
816function pingCount(n: number): string {
817 return `${n} ${n === 1 ? 'ping' : 'pings'}`
818}
819
820// "180" and "180m" are minutes, "24h" is hours (the Telegram answer takes both too).
821function parseDuration(value: string | undefined): number {
822 const m = /^(\d+(?:\.\d+)?)([hm])?$/i.exec(value ?? '')
823 if (!m) return NaN
824 return m[2]?.toLowerCase() === 'h' ? Number(m[1]) * 60 : Number(m[1])
825}
826
827function brbCompletion(text: string, cursor: number): string {
828 if (cursor !== text.length) return ''
829 const m = /^\/keepalive\s+brb\s+(\d*)$/.exec(text)
830 if (!m) return ''
831 const typed = m[1] ?? ''
832 const preset = BRB_PRESETS.find(p => p.startsWith(typed) && p.length > typed.length)
833 return preset ? preset.slice(typed.length) : ''
834}
835
836function ttlOfLine(line: string): number | null {
837 if (!line.includes('ephemeral_')) return null
838 try {
839 const entry = JSON.parse(line)
840 if (entry.type !== 'assistant' || entry.isSidechain === true) return null
841 const split = entry.message?.usage?.cache_creation
842 if (!split) return null
843 if ((split.ephemeral_5m_input_tokens ?? 0) > 0) return TTL_5M
844 if ((split.ephemeral_1h_input_tokens ?? 0) > 0) return TTL_1H
845 return null
846 } catch {
847 return null // the tail's first line is usually cut mid-row
848 }
849}
850
851function stateFile(): string {
852 return `${stateDir}/${sessionId}.json`
853}
854
855// When the armed compact fires: compactLeadMinutes before the cache would
856// expire after the last ping, longer ahead than a ping because a compaction
857// takes minutes on a big context. Every ping moves the cache start one
858// interval (TTL minus lead) on.
859function compactAt(s: KeepaliveSession): number {
860 const ttl = s.ttlSec ?? TTL_1H
861 const intervalMs = (ttl - config.leadMinutes * 60) * 1000
862 const pingsLeft = s.phase === 'armed' ? Math.max(0, s.maxPings - s.pingsSent) : 0
863 return cacheStart(s) + pingsLeft * intervalMs + (ttl - config.compactLeadMinutes * 60) * 1000
864}
865
866// When a compact is scheduled for this state, or null: the status line and
867// /keepalive status show it. The option applies to every idle stretch; the
868// flag only to the one it was armed for, and it also survives /keepalive done.
869function compactDueAt(s: KeepaliveSession): number | null {
870 if (s.ttlSec === null) return null
871 const isOn = s.isCompactArmed || config.compactBeforeExpiry
872 const isScheduled = s.phase === 'capped' || s.phase === 'armed' || (s.phase === 'stopped' && s.isCompactArmed)
873 return isOn && isScheduled ? compactAt(s) : null
874}
875
876function cacheStart(s: KeepaliveSession): number {
877 return Math.max(s.lastActivityAt, s.lastPingAt)
878}
879
880function cancelTimers() {
881 // The Telegram poller is separate: a question stays open across re-arms.
882 pingTimer?.cancel()
883 expiryTimer?.cancel()
884 compactTimer?.cancel()
885 pingTimer = undefined
886 expiryTimer = undefined
887 compactTimer = undefined
888}
889
890function clockTime(ms: number): string {
891 const d = new Date(ms)
892 return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
893}
894
895function kilo(tokens: number): string {
896 return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : String(Math.round(tokens))
897}
898
899function positiveNumber(value: unknown, fallback: number, allowZero = false): number {
900 const n = typeof value === 'number' ? value : Number(value)
901 if (!Number.isFinite(n)) return fallback
902 return allowZero ? (n >= 0 ? n : fallback) : n > 0 ? n : fallback
903}
904hooks/telegram.ts 101 lines1// Telegram message building and update parsing for the keepalive question.
2// Pure functions: no `$`, so register.tsx owns every call to the Bot API.
3
4export type TelegramTarget = {
5 chatId: string
6 /** Only this user's presses and replies count; empty accepts anyone in the chat. */
7 userId: string
8 /** The message that carries the question. */
9 messageId: number
10 /** First 8 characters of the session id, carried in callback_data. */
11 sessionTag: string
12}
13
14/** What the person answered: minutes to keep warm, 0 to let the cache expire. */
15export type TelegramAnswer = {
16 updateId: number
17 minutes: number
18 callbackId?: string
19}
20
21const PREFIX = 'ka'
22export const CHOICES = [
23 { label: '+1h', minutes: 60 },
24 { label: '+3h', minutes: 180 },
25 { label: 'Let it expire', minutes: 0 },
26] as const
27
28export function htmlEscape(text: string): string {
29 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
30}
31
32/** Same header as session-notifier: bold project, italic session title. */
33export function header(project: string, title: string | null): string {
34 const head = `<b>${htmlEscape(project)}</b>`
35 return title ? `${head} · <i>${htmlEscape(title)}</i>` : head
36}
37
38export function keyboard(sessionTag: string) {
39 return {
40 inline_keyboard: [
41 CHOICES.map(c => ({ text: c.label, callback_data: `${PREFIX}:${sessionTag}:${c.minutes}` })),
42 ],
43 }
44}
45
46/**
47 * Reads one getUpdates entry. Answers only a button press on the question
48 * message, or a text reply to it, from the configured chat and user.
49 */
50export function parseUpdate(update: unknown, target: TelegramTarget): TelegramAnswer | null {
51 const u = update as {
52 update_id?: number
53 callback_query?: {
54 id?: string
55 data?: string
56 from?: { id?: number }
57 message?: { message_id?: number; chat?: { id?: number } }
58 }
59 message?: {
60 text?: string
61 from?: { id?: number }
62 chat?: { id?: number }
63 reply_to_message?: { message_id?: number }
64 }
65 }
66 if (typeof u?.update_id !== 'number') return null
67
68 const cb = u.callback_query
69 if (cb) {
70 if (!isTarget(cb.message?.chat?.id, cb.from?.id, cb.message?.message_id, target)) return null
71 const m = new RegExp(`^${PREFIX}:([^:]+):(\\d+)$`).exec(cb.data ?? '')
72 if (!m || m[1] !== target.sessionTag) return null
73 return { updateId: u.update_id, minutes: Number(m[2]), callbackId: cb.id }
74 }
75
76 const msg = u.message
77 if (msg) {
78 if (!isTarget(msg.chat?.id, msg.from?.id, msg.reply_to_message?.message_id, target)) return null
79 const minutes = minutesFromText(msg.text ?? '')
80 return minutes === null ? null : { updateId: u.update_id, minutes }
81 }
82 return null
83}
84
85/** "120", "+90", "2h", "+1.5h", "45m", "stop" → minutes; anything else → null. */
86export function minutesFromText(text: string): number | null {
87 const t = text.trim().toLowerCase()
88 if (/^(stop|expire|no|0)$/.test(t)) return 0
89 const m = /^\+?\s*(\d+(?:\.\d+)?)\s*(h|hr|hours?|m|min|mins|minutes?)?$/.exec(t)
90 if (!m) return null
91 const n = Number(m[1])
92 const minutes = m[2]?.startsWith('h') ? n * 60 : n
93 return minutes > 0 && minutes <= 24 * 60 ? Math.round(minutes) : null
94}
95
96function isTarget(chatId: number | undefined, fromId: number | undefined, messageId: number | undefined, target: TelegramTarget): boolean {
97 if (String(chatId) !== target.chatId) return false
98 if (target.userId && String(fromId) !== target.userId) return false
99 return messageId === target.messageId
100}
101types/index.d.ts 41 lines1export type KeepalivePhase =
2 | 'active' // a turn is running, or no idle stretch is being kept
3 | 'small' // context under minContextTokens: not kept
4 | 'armed' // a ping is scheduled
5 | 'capped' // pings used up; the cache expires on its own
6 | 'stopped' // /keepalive done
7 | 'expired' // the cache TTL ran out while idle
8 | 'off' // disabled for the rest of the session (offReason says why)
9
10export type KeepaliveOffReason = '5m-ttl' | 'ttl-mismatch' | 'api-error' | 'disabled'
11
12export type KeepaliveSession = {
13 phase: KeepalivePhase
14 offReason: KeepaliveOffReason | null
15 /** Epoch ms of the last main-thread response. */
16 lastActivityAt: number
17 /** Epoch ms of the last ping that hit, or 0. */
18 lastPingAt: number
19 pingsSent: number
20 maxPings: number
21 /** Cache TTL in seconds read from the transcript, or null until read. */
22 ttlSec: number | null
23 /** Input tokens of the last main-thread response (the cached prefix). */
24 contextTokens: number
25 transcriptPath: string | null
26 /** The expired band was dismissed for this idle stretch. */
27 isBandDismissed: boolean
28 /** The OFF toast was already shown this session. */
29 isOffToastShown: boolean
30 /** message_id of the open Telegram question, or null when none is open. */
31 tgAskMessageId: number | null
32 /** /keepalive compact: compact once, when the pings run out. Survives turns (a /goal's continuations); cleared when it fires or on `compact off`. */
33 isCompactArmed: boolean
34}
35
36declare module 'claude-code' {
37 interface PluginState {
38 'cache-keepalive': { session: KeepaliveSession }
39 }
40}
41