SLOPSHOPPER

cache-keepalive

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.

newbandcommandtoaststatusprompt
★ 2v0.3.2MITupdated 2026-10-07shdennlin/agent-plugins/plugins/cache-keepalive
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keepalive
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /keepalive ⎿ cache-keepalive: Usage: /keepalive done | brb <minutes|hours|reset> | compact [off] | status ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cache-keepalive

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.

Quick setup

Nothing is required: once the plugin loads, it keeps the cache warm. The rest is optional.

You wantSet up
Keepalive itselfLoad the plugin (below)required
The state always on screenA status line field and a refresh interval (docs/status-line.md)optional; /keepalive status works without it
A Telegram question once the pings run outBot token and telegramChatId (docs/telegram.md)optional; both are needed, or nothing is sent
Only your own answers to counttelegramUserIdoptional; 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.

Commands

CommandWhat it does
/keepalive statusPhase, 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 resetBack to the configured maxPings for this idle stretch. Pings already sent are not taken back
/keepalive doneStop pinging for this idle stretch. Resets at your next prompt
/keepalive compactArm 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.

Options

Set in /config (or pluginConfigs["cache-keepalive"].options in settings):

OptionDefault
enabledtrueMaster switch
leadMinutes5Ping this many minutes before the TTL runs out
maxPings2Pings per idle stretch
minContextTokens50000Smaller contexts are not kept warm
compactLeadMinutes10An armed compact starts this many minutes before the TTL runs out
compactBeforeExpiryfalseCompact in that window every idle stretch, without arming each time
engineStatusfalseShow the state as a status entry under the prompt, for sessions without a status line
telegramBotTokenemptyBot token (sensitive); see Telegram
telegramChatIdemptyChat that receives the question
telegramUserIdemptyOnly this user's answers count

Compact before expiry

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.

  • One time. It clears when it fires, with /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.
  • A late timer (the Mac slept), a cancelled compaction or a ping that misses the cache means no compact; the cache just expires.

Flow chart, timeline and the edge cases: docs/compact.md.

Cost

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.

Telegram (optional)

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.

Seeing the state

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.

Limits

  • No pings while the Mac sleeps: the process is asleep too. A timer that fires after the cache already expired skips the ping.
  • A turn that ends in an 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.
  • A turn waiting on a permission prompt has not finished, so no ping is scheduled during it.
  • After a prefix change (/model, a plugin reload that changes the system prompt or tools) the next ping misses once, and keepalive turns off for the session.

More

docs/cost.mdPrices per model, ping vs rewrite, break-even
docs/compact.mdFlow chart, timeline and edge cases of the armed compact
docs/telegram.mdThe Telegram question and its setup
docs/status-line.mdccstatusline, your own status line, engineStatus
docs/PRD.mdDesign, decisions and their evidence

Development

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 tests
Source 3 files
hooks/register.tsx 904 lines
1import { 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}
904
hooks/telegram.ts 101 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
101
types/index.d.ts 41 lines
1export 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