SLOPSHOPPER

cache-alert

Warns before this session's prompt cache expires (a countdown band above the prompt, a toast, desktop and Telegram alerts), and can keep the cache warm…

newpanebandcommandtoaststatus
v0.1.0MITupdated 2026-10-09candiesdoodle/cache-alert-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-alert
│ ┃ cache-alert-setup ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Telegram │ cache-alert │ │ ┃ 1. In Telegram, message @BotFather, send ⏺ Read(src/auth.ts) │ cache-alert: run /cache-alert setup to get │ │ ┃ /newbot, and paste the token it gives you ⎿ Read 6 lines │ Telegram alerts before the prompt cache │ │ ┃ below. ⏺ Update(src/auth.ts) │ expires. │ │ ┃ Bot token : paste the token from @BotFather ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ ⏺ Bash(bun test) │ ┃ 2. Send your bot any message, then press ⎿ 3 pass, 1 fail │ ┃ Detect chat (or type a chat ID). │ ┃ Chat ID : not set ⏎ save [ Detect chat ] ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Cache ✻ Worked for 42s · done 4:20 PM │ ┃ Cache TTL │ ┃ [ (•) 1 hour (extended cache) ] [ ( ) 5 › /cache-alert │ ┃ Alert when left ⎿ cache-alert: Cache (1h): Status 🟢 59:55 ; Alert 🔔 in 48 m │ ┃ [ ( ) 10% (6m) ] [ ( ) 15% (9m) ] [ (• ⎿ cache-alert: Action: Notifications armed for Desktop, then "be r │ ┃ ⎿ cache-alert: Telegram: not set up (/cache-alert setup) · TTL 1h │ ┃ When the alert fires │ ┃ Notify, do one thing in the chat, or both. │ ┃ [ [x] Notify ] │ ┃ [ [x] Desktop notification ] [ [x] Teleg │ ┃ │ ┃ In the chat (pick one): │ ┃ [ ( ) Nothing ] │ ┃ [ (•) Send "be right back" (keeps the cache │ ┃ [ ( ) Run /compact (summarises while the cac Cache (1h): Status 🟢 59:55 ; Alert 🔔 in 48 m Action: Notifications armed for Desktop, then "be right back" (/cache-alert setup to configure) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Cache (1h): Status 🟢 59:55 ; Alert 🔔 in 48 m Action: Notifications armed for Desktop, then "be right back" (/cache-alert setup to configure)
Pane · cache-alert-setup
Telegram 1. In Telegram, message @BotFather, send /newbot, and paste the token it gives you below. Bot token : paste the token from @BotFather ⏎ save 2. Send your bot any message, then press Detect chat (or type a chat ID). Chat ID : not set ⏎ save [ Detect chat ] Cache Cache TTL [ (•) 1 hour (extended cache) ] [ ( ) 5 minutes (defaul Alert when left [ ( ) 10% (6m) ] [ ( ) 15% (9m) ] [ (•) 20% (12m) ] [ When the alert fires Notify, do one thing in the chat, or both. [ [x] Notify ] [ [x] Desktop notification ] [ [x] Telegram sound ] In the chat (pick one): [ ( ) Nothing ] [ (•) Send "be right back" (keeps the cache warm; default) ] [ ( ) Run /compact (summarises while the cache is warm) ] [ ( ) Run /clear (drops the conversation) ] [ Send test ] [ Done ]
README

cache-alert 🔔

A Claude Code mod that warns you before your session's prompt cache expires, and automatically can send a message to keep it warm, or compact or clear the session, instead of paying to rebuild the cache.

It shows a live countdown in a one-line band just above the prompt. When the alert fires you get an in-terminal toast, a desktop notification, and (optionally) a Telegram message on your phone.

Install

At a Claude Code prompt:

/plugin install cache-alert --marketplace candiesdoodle/cache-alert-mod

Answer y to add the marketplace, then pick a scope (user scope loads it in every session).

Set up Telegram

/cache-alert setup

A setup pane opens:

  1. In Telegram, message @BotFather, send /newbot, and paste the token it gives you into Bot token, then press Enter. The mod checks the token with Telegram before saving it.
  2. Send your new bot any message in Telegram, then press Detect chat. You can also type a chat ID (a group or channel ID works too).
  3. Under Cache, pick the Cache TTL (1 hour or 5 minutes) and when to alert (10–50% of the TTL left; 20% of 1h is 12 minutes).
  4. Under When the alert fires, tick Notify (the toast, plus Desktop and Telegram where set up), pick one thing to do in the chat:
  5. Nothing: no change to the chat.
  6. Send "be right back" (the default): sends that message as a prompt, so the reply refreshes the cache. The reply re-arms the alert, so while you are away it repeats on every alert and keeps the cache warm.
  7. Run /compact: summarises the conversation while the cache is still warm, so the summary is cheaper to make.
  8. Run /clear: drops the conversation, so there is no cache left to lose. This cannot be undone.
  9. Press Send test.

Settings are saved across sessions. Without Telegram you still get the toast and the desktop notification.

The band

The time left is coloured green, yellow or red to match the icon. Collapse the band with its [-] mark.

Until the alert goes out, the band also says where it will go, for example Action: Notifications armed for Telegram, Desktop, then /compact (/cache-alert setup to configure). On a narrow terminal that moves to a second row.

ShownMeaning
Cache (1h): Status 🟢 activeA turn is running, so the cache is being refreshed
Cache (1h): Status 🟢 58:58 ; Alert 🔔 in 47 mCache expires in 58:58; the alert fires in 47 minutes
Cache (1h): Status 🟡 25:10 ; Alert 🔔 in 13 mLess than half the TTL is left
Cache (1h): Status 🔴 11:54 ; Alert 🔔 sentInside the alert window; the alert went out
Cache (1h): Status ❄️ COLDThe cache has expired
Cache (1h): Status ♻️ reset by /compact/compact replaced the cached prefix; the next turn starts a new one

Commands

CommandDoes
/cache-alertShows the cache status and the Telegram setup
/cache-alert setupOpens the setup pane
/cache-alert testSends a test Telegram message

How it works

  • Each main-conversation turn that reads or writes the prompt cache restarts the countdown. Subagent turns are ignored, since they use their own cache.
  • A new turn cancels the pending alert. An interrupted turn keeps the earlier countdown running.
  • /compact and /clear cancel the alert.
  • A failed Telegram send is retried every 20 seconds until the cache goes cold.

Things to know

  • Alerts come from the running Claude Code session. If you close the session, no alert is sent.
  • Set the TTL to match your cache. The mod counts from the time of the last turn; it does not ask Anthropic when your cache expires.
  • The bot token is kept in Claude Code's plugin store on your machine. The only network calls go to api.telegram.org.

Privacy and permissions

What the mod reads, sends, runs and hooks, in full.

Network. The only host it contacts is https://api.telegram.org, and only once you have set up Telegram. It calls three Bot API methods:

  • getMe, when you save a bot token, to check the token and read the bot's name.
  • getUpdates, when you press Detect chat, to find the chat you messaged the bot from.
  • sendMessage, for the test message and for each alert. An alert carries the project folder's name, the session's name, the time left on the cache, the TTL, and the chat action it is about to take. It never carries conversation text, prompts, code or file contents.

The bot token. It is your own Telegram bot's token, typed into the setup pane. It is stored in Claude Code's plugin store on your machine and sent only to api.telegram.org, as the Bot API requires (in the request path). The mod reads no token from your environment or files.

What it stores. In Claude Code's plugin store on your machine: the bot token, the bot's username, your Telegram chat ID and your setup choices. Nothing is stored anywhere else. When you press Detect chat it also reads the name on the chat it finds (your first name or username, or a group's title) to show you which chat it picked; that name is not saved.

What it reads.

  • Token usage counts from each finished turn (turn.complete), to know when the cache was last refreshed. It reads no message text.
  • The session title from Claude Code's session start (classic.SessionStart), for the alert message. The hook changes nothing and passes the event on unchanged.
  • Your theme setting (config.list), every 5 seconds, to colour the band to match. It is not sent anywhere.

Programs it runs. None.

What it does in your session (the chat action you pick in setup):

  • "be right back": submits that fixed text as a prompt (prompt.submit). Nothing else is put in it.
  • /compact or /clear: runs that built-in command (command.run).
  • Nothing: no change to the chat.

Other hooks. It registers the /cache-alert command and handles it. It hooks session.compact and session.end only to cancel a pending alert and reset the band; both pass the event on unchanged. It draws the band above the prompt and the setup pane (ui.render). Desktop alerts use Claude Code's own notification call (ui.notify). It sets no permissions and changes no settings.

Develop

claude --plugin-dir .            # run it from this folder; edits hot-reload
claude plugin validate .
claude plugin test .

License

MIT © candiesdoodle

Source 2 files
hooks/register.tsx 531 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { AlertAction, AlertSettings, CacheLine, CacheTrack, SetupNote } from '../types'
5
6const EMPTY: CacheTrack = { mode: 'none', lastRequestAt: 0, isAlerted: false }
7const DEFAULTS: AlertSettings = { botToken: '', botName: '', chatId: '', ttl: '1h', thresholdPercent: 20, notify: true, sound: true, nativeNotify: true, onAlert: 'brb' }
8const THRESHOLDS = [10, 15, 20, 25, 30, 50]
9const TTLS: readonly { value: AlertSettings['ttl']; label: string }[] = [
10  { value: '1h', label: '1 hour (extended cache)' },
11  { value: '5m', label: '5 minutes (default cache)' },
12]
13const ACTIONS: readonly { value: AlertAction; label: string }[] = [
14  { value: 'none', label: 'Nothing' },
15  { value: 'brb', label: 'Send "be right back" (keeps the cache warm; default)' },
16  { value: 'compact', label: 'Run /compact (summarises while the cache is warm)' },
17  { value: 'clear', label: 'Run /clear (drops the conversation)' },
18]
19const BRB_TEXT = 'be right back'
20
21const track = atom({ plugin: 'cache-alert', key: 'track' } as const, EMPTY)
22const info = atom({ plugin: 'cache-alert', key: 'info' } as const, { title: '' })
23const settings = atom({ plugin: 'cache-alert', key: 'settings' } as const, DEFAULTS)
24const note = atom({ plugin: 'cache-alert', key: 'note' } as const, null as SetupNote)
25const line = atom({ plugin: 'cache-alert', key: 'line' } as const, null as CacheLine)
26// The /theme setting as /config holds it ('dark', 'light-daltonized', 'auto', ...); '' until read
27const theme = atom({ plugin: 'cache-alert', key: 'theme' } as const, '')
28
29const STORE_KEY = 'settings'
30const SETUP = 'cache-alert-setup'
31// Treat the cache as gone a little before the TTL says
32const SAFETY_MARGIN_MS = 5_000
33const RETRY_MS = 20_000
34// The band shows a mm:ss clock; it is only redrawn when its text changes
35const TICK_MS = 1_000
36// /theme raises no event a mod can see: look again this often
37const THEME_POLL_MS = 5_000
38
39// Module variables start over on each reload, as these should
40let alertTimer: Timer | undefined
41// False under `claude -p` and the SDK: no one is at the prompt to act on an alert
42let isInteractive = true
43
44type TelegramReply = { ok: boolean; description?: string; result?: unknown }
45
46// The band's fill, end to end whatever it shows; none where the theme follows the terminal
47function bandFill(themeName: string): string | undefined {
48  if (themeName.startsWith('dark')) return '#303030'
49  if (themeName.startsWith('light')) return '#e4e4e4'
50  return undefined
51}
52
53async function readTheme($: EngineInterface) {
54  const rows = await $.config.list().catch(() => [])
55  const value = rows.find(row => row.key === 'theme')?.value
56  const next = typeof value === 'string' ? value : ''
57  if ((await read($, theme)) !== next) await update($, theme, () => next)
58}
59
60function fmtClock(ms: number): string {
61  const total = Math.ceil(ms / 1000)
62  return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`
63}
64
65// Green with more than half the TTL left, yellow down to the alert threshold, red inside it
66function warmth(left: number, s: AlertSettings): { icon: string; tone: 'success' | 'warning' | 'error' } {
67  if (left <= leadMs(s)) return { icon: '🔴', tone: 'error' }
68  return left <= ttlMs(s) / 2 ? { icon: '🟡', tone: 'warning' } : { icon: '🟢', tone: 'success' }
69}
70
71function lineText(l: NonNullable<CacheLine>): string {
72  return `Cache (${l.ttl}): Status ${l.icon} ${l.state}${l.alert === null ? '' : ` ; Alert 🔔 ${l.alert}`}`
73}
74
75function actionText(channels: readonly string[], followUp: string | null, notify: boolean): string {
76  const where = !notify ? null : channels.length ? `Notifications armed for ${channels.join(', ')}` : 'In-app toast only'
77  const what = where && followUp ? `${where}, then ${followUp}` : (where ?? followUp ?? 'none, the alert is off')
78  return `Action: ${what} (/cache-alert setup to configure)`
79}
80
81// The setup pane's checkbox and radio marks, drawn on its buttons
82function checkbox(isOn: boolean): string {
83  return isOn ? '[x]' : '[ ]'
84}
85
86function radio(isOn: boolean): string {
87  return isOn ? '(•)' : '( )'
88}
89
90// Terminal cells a text takes: an emoji or other astral character takes two
91function cells(text: string): number {
92  return [...text].reduce((n, ch) => n + ((ch.codePointAt(0) ?? 0) > 0xffff ? 2 : 1), 0)
93}
94
95function fmt(ms: number): string {
96  return ms >= 60_000 ? `${Math.round(ms / 60_000)}m` : `${Math.max(1, Math.ceil(ms / 1000))}s`
97}
98
99function basename(p: string): string {
100  return p.replace(/\/+$/, '').split('/').pop() || p
101}
102
103function ttlMs(s: AlertSettings): number {
104  return (s.ttl === '5m' ? 300 : 3600) * 1000
105}
106
107function leadMs(s: AlertSettings): number {
108  return (ttlMs(s) * s.thresholdPercent) / 100
109}
110
111function expiresAt(t: CacheTrack, s: AlertSettings): number {
112  return t.lastRequestAt + ttlMs(s) - SAFETY_MARGIN_MS
113}
114
115function isTelegramReady(s: AlertSettings): boolean {
116  return s.botToken !== '' && s.chatId !== ''
117}
118
119// Whatever the store holds, from any earlier version, read as complete AlertSettings
120function normalize(raw: unknown): AlertSettings {
121  const r = (raw && typeof raw === 'object' ? raw : {}) as Partial<AlertSettings>
122  return {
123    botToken: typeof r.botToken === 'string' ? r.botToken : '',
124    botName: typeof r.botName === 'string' ? r.botName : '',
125    chatId: typeof r.chatId === 'string' ? r.chatId : '',
126    ttl: r.ttl === '5m' ? '5m' : '1h',
127    thresholdPercent: THRESHOLDS.includes(Number(r.thresholdPercent)) ? Number(r.thresholdPercent) : DEFAULTS.thresholdPercent,
128    notify: r.notify !== false,
129    sound: r.sound !== false,
130    nativeNotify: r.nativeNotify !== false,
131    onAlert: ACTIONS.some(a => a.value === r.onAlert) ? (r.onAlert as AlertAction) : DEFAULTS.onAlert,
132  }
133}
134
135function cancelAlert() {
136  alertTimer?.cancel()
137  alertTimer = undefined
138}
139
140async function telegram($: EngineInterface, token: string, method: string, body?: object): Promise<TelegramReply> {
141  try {
142    const res = await $.http.fetch(`https://api.telegram.org/bot${token}/${method}`, {
143      method: body ? 'POST' : 'GET',
144      headers: { 'Content-Type': 'application/json' },
145      body: body ? JSON.stringify(body) : undefined,
146    })
147    return JSON.parse(res.text) as TelegramReply
148  } catch (err) {
149    return { ok: false, description: err instanceof Error ? err.message : String(err) }
150  }
151}
152
153async function sendMessage($: EngineInterface, s: AlertSettings, text: string): Promise<TelegramReply> {
154  const base = { chat_id: s.chatId, disable_notification: !s.sound }
155  const reply = await telegram($, s.botToken, 'sendMessage', { ...base, text, parse_mode: 'Markdown' })
156  // Unescaped Markdown in a project or session name: resend as plain text
157  if (!reply.ok && /parse/i.test(reply.description ?? '')) {
158    return telegram($, s.botToken, 'sendMessage', { ...base, text: text.replace(/[*_`]/g, '') })
159  }
160  return reply
161}
162
163async function saveSettings($: EngineInterface, change: (s: AlertSettings) => AlertSettings): Promise<AlertSettings> {
164  const next = await update($, settings, change)
165  await $.store.set(STORE_KEY, next)
166  // A new TTL or threshold moves the pending alert
167  await arm($)
168  await refreshStatus($)
169  return next
170}
171
172async function setNote($: EngineInterface, text: string, isError = false) {
173  await update($, note, () => ({ text, isError }))
174}
175
176async function saveToken($: EngineInterface, value: string) {
177  const token = value.trim()
178  if (!token) return setNote($, 'Paste the token @BotFather gave you, then press Enter.', true)
179  await setNote($, 'Checking the token…')
180  const me = await telegram($, token, 'getMe')
181  if (!me.ok) return setNote($, `Telegram rejected the token: ${me.description ?? 'unknown error'}`, true)
182  const botName = (me.result as { username?: string } | undefined)?.username ?? ''
183  await saveSettings($, s => ({ ...s, botToken: token, botName }))
184  await setNote($, `Connected to @${botName}. Send it any message in Telegram, then press Detect chat.`)
185}
186
187async function saveChatId($: EngineInterface, value: string) {
188  const chatId = value.trim()
189  // A numeric user, group or channel id, or a public channel's @name
190  if (!/^(-?\d+|@\w{4,})$/.test(chatId)) return setNote($, 'A chat ID is a number (e.g. 123456789, or -100… for a group) or a channel @name.', true)
191  await saveSettings($, cur => ({ ...cur, chatId }))
192  await setNote($, `Chat ID ${chatId} saved. Press Send test to check.`)
193}
194
195async function detectChat($: EngineInterface) {
196  const s = await read($, settings)
197  if (!s.botToken) return setNote($, 'Save a bot token first.', true)
198  const updates = await telegram($, s.botToken, 'getUpdates')
199  if (!updates.ok) return setNote($, `Could not read the bot's messages: ${updates.description ?? 'unknown error'}`, true)
200  type Chat = { id: number | string; title?: string; username?: string; first_name?: string }
201  const list = (updates.result ?? []) as { message?: { chat?: Chat }; channel_post?: { chat?: Chat } }[]
202  const chat = list
203    .map(u => u.message?.chat ?? u.channel_post?.chat)
204    .filter((c): c is Chat => c !== undefined)
205    .at(-1)
206  if (!chat) return setNote($, `No messages yet. Send @${s.botName || 'your bot'} any message in Telegram, then press Detect chat again.`, true)
207  await saveSettings($, cur => ({ ...cur, chatId: String(chat.id) }))
208  await setNote($, `Chat found: ${chat.title ?? chat.username ?? chat.first_name ?? chat.id}. Press Send test to check.`)
209}
210
211async function sendTest($: EngineInterface) {
212  const s = await read($, settings)
213  if (!isTelegramReady(s)) return setNote($, 'Save a bot token and a chat first.', true)
214  const reply = await sendMessage($, s, '🔔 *cache-alert* is connected.\nYou will get prompt cache expiry warnings here.')
215  await setNote($, reply.ok ? 'Test message sent.' : `Telegram send failed: ${reply.description ?? 'unknown error'}`, !reply.ok)
216}
217
218// The session's title at start, else its id
219async function sessionLabel($: EngineInterface): Promise<string> {
220  return (await read($, info)).title || (await $.session.id()).slice(0, 8)
221}
222
223async function cacheLine($: EngineInterface): Promise<CacheLine> {
224  const t = await read($, track)
225  const s = await read($, settings)
226  if (t.mode === 'none') return null
227  const at = { ttl: s.ttl, alert: null, channels: null, followUp: null, notify: s.notify }
228  const followUp = await nextAction($, s)
229  const channels = [...(isTelegramReady(s) ? ['Telegram'] : []), ...(s.nativeNotify ? ['Desktop'] : [])]
230  if (t.mode === 'working') return { ...at, icon: '🟢', state: 'active', tone: 'success', channels, followUp }
231  if (t.mode === 'compacted') return { ...at, icon: '♻️', state: 'reset by /compact', tone: 'subtle' }
232  const left = expiresAt(t, s) - (await $.clock.now())
233  if (left <= 0) return { ...at, icon: '❄️', state: 'COLD', tone: 'subtle' }
234  const alertIn = left - leadMs(s)
235  const alert = t.isAlerted ? 'sent' : alertIn > 0 ? `in ${fmt(alertIn).replace(/(\d)([ms])$/, '$1 $2')}` : 'due'
236  return { ...at, ...warmth(left, s), state: fmtClock(left), alert, ...(t.isAlerted ? {} : { channels, followUp }) }
237}
238
239async function statusText($: EngineInterface): Promise<string | undefined> {
240  const l = await cacheLine($)
241  return l ? lineText(l) + (l.channels ? `\n${actionText(l.channels, l.followUp, l.notify)}` : '') : undefined
242}
243
244async function refreshStatus($: EngineInterface) {
245  const next = await cacheLine($)
246  // Written only on a change, so the band redraws once a second at most
247  await update($, line, cur => (JSON.stringify(cur) === JSON.stringify(next) ? cur : next))
248}
249
250// What the alert will do in the chat, as the band and the messages name it; null for nothing
251async function nextAction($: EngineInterface, s: AlertSettings): Promise<string | null> {
252  if (s.onAlert === 'compact') return '/compact'
253  if (s.onAlert === 'clear') return '/clear'
254  if (s.onAlert === 'brb') return `"${BRB_TEXT}"`
255  return null
256}
257
258// Queued, never awaited: each runs once the session is idle, after this alert's own work
259async function runAction($: EngineInterface, s: AlertSettings) {
260  const failed = (err: unknown) => $.ui.log(`cache-alert: the on-alert action failed: ${err instanceof Error ? err.message : String(err)}`)
261  if (s.onAlert === 'compact') void $.command.run({ command: 'compact', args: '' }).catch(failed)
262  if (s.onAlert === 'clear') void $.command.run({ command: 'clear', args: '' }).catch(failed)
263  // Its reply refreshes the cache and re-arms the alert, so this repeats on every alert
264  if (s.onAlert === 'brb') void $.prompt.submit({ text: BRB_TEXT }).catch(failed)
265}
266
267async function fire($: EngineInterface, forRequestAt: number, isRetry: boolean) {
268  alertTimer = undefined
269  const t = await read($, track)
270  const s = await read($, settings)
271  if (t.mode !== 'idle' || t.isAlerted || t.lastRequestAt !== forRequestAt) return
272  const left = expiresAt(t, s) - (await $.clock.now())
273  if (left <= 0) return
274
275  const followUp = isRetry ? null : await nextAction($, s)
276  if (!isRetry && s.notify) {
277    $.ui.toast(`Prompt cache expires in ~${fmt(left)}. ${followUp ? `Sending ${followUp}.` : 'Send a message to keep it warm.'}`)
278    if (s.nativeNotify) $.ui.notify(`Prompt cache expires in ~${fmt(left)}`, { title: 'cache-alert' }).catch(() => undefined)
279  }
280
281  let isDelivered = true
282  if (s.notify && isTelegramReady(s)) {
283    const text =
284      `⚠️ *Claude Code Prompt Cache Expiring Soon!*\n\n` +
285      `📁 *Project:* \`${basename(await $.session.cwd())}\`\n` +
286      `💬 *Session:* \`${await sessionLabel($)}\`\n` +
287      `⏳ *Time Remaining:* *~${fmt(left)}* (of ${s.ttl} cache TTL)\n\n` +
288      (followUp ? `▶️ *Then:* sending ${followUp} to the session` : `💬 _Send a quick reply to your session to refresh the prompt cache._`)
289    const reply = await sendMessage($, s, text)
290    if (!reply.ok) {
291      isDelivered = false
292      // Retry until the cache goes cold; each attempt recomputes the time left
293      if (left > RETRY_MS) alertTimer = $.clock.after(RETRY_MS, () => void fire($, forRequestAt, true))
294      if (!isRetry) $.ui.log(`cache-alert: Telegram delivery failed (${reply.description ?? 'unknown'}), retrying every ${RETRY_MS / 1000}s`)
295    }
296  }
297
298  // The action goes ahead with or without Telegram, and whether or not the message got through
299  if (followUp) await runAction($, s)
300  if (!isDelivered) return
301  await update($, track, cur => (cur.lastRequestAt === forRequestAt ? { ...cur, isAlerted: true } : cur))
302  await refreshStatus($)
303}
304
305async function arm($: EngineInterface) {
306  cancelAlert()
307  const t = await read($, track)
308  const s = await read($, settings)
309  if (t.mode !== 'idle' || t.isAlerted || !t.lastRequestAt) return
310  const now = await $.clock.now()
311  if (expiresAt(t, s) <= now) return
312  // Past the threshold already (a reload or a new threshold late in the window): fire at once
313  const delay = Math.max(1_000, expiresAt(t, s) - leadMs(s) - now)
314  const forRequestAt = t.lastRequestAt
315  alertTimer = $.clock.after(delay, () => void fire($, forRequestAt, false))
316}
317
318async function openSetup($: EngineInterface) {
319  await update($, note, () => null)
320  return $.ui.open({ id: SETUP, title: 'cache-alert setup', focus: true, closeOnEscape: true })
321}
322
323export const register: Register = on => {
324  on('session.start', async ($, e, next) => {
325    // The command is a convenience: never let it keep the timers from starting
326    await $.command
327      .register({
328        name: 'cache-alert',
329        description: 'Prompt cache status; `setup` to configure Telegram alerts, `test` to send a test message.',
330        argumentHint: '[setup|test]',
331      })
332      .catch(() => undefined)
333    const stored = await $.store.get(STORE_KEY).catch(() => undefined)
334    await update($, settings, () => normalize(stored))
335    isInteractive = e.isInteractive
336    if (!isInteractive) return next(e)
337    if (stored === undefined) $.ui.toast('cache-alert: run /cache-alert setup to get Telegram alerts before the prompt cache expires.')
338    // Earlier versions pinned a status line; the band replaces it
339    $.ui.status(undefined)
340    $.clock.every(TICK_MS, () => void refreshStatus($))
341    await readTheme($)
342    $.clock.every(THEME_POLL_MS, () => void readTheme($))
343    // A hot reload keeps $.state but drops timers: re-arm from what was tracked
344    await arm($)
345    await refreshStatus($)
346    return next(e)
347  })
348
349  on('classic.SessionStart', async ($, e, next) => {
350    await update($, info, () => ({ title: e.session_title ?? '' }))
351    return next(e)
352  })
353
354  // Subagents raise no turn.start: this is the main loop starting a request
355  on('turn.start', async ($, e, next) => {
356    if (!isInteractive) return next(e)
357    cancelAlert()
358    await update($, track, (cur): CacheTrack => ({ ...cur, mode: 'working' }))
359    await refreshStatus($)
360    return next(e)
361  })
362
363  on('turn.complete', async ($, e, next) => {
364    if (!isInteractive || e.agentId !== undefined) return next(e)
365    const u = e.usage
366    const now = await $.clock.now()
367    await update($, track, (cur): CacheTrack => {
368      // A request that read or wrote the cache refreshed it now
369      if (u && u.cache_read_input_tokens + u.cache_creation_input_tokens > 0) return { mode: 'idle', lastRequestAt: now, isAlerted: false }
370      // A request ran but caching is off
371      if (u) return EMPTY
372      // Interrupted or failed before any request counted: the previous refresh still stands
373      return cur.lastRequestAt ? { ...cur, mode: 'idle' } : EMPTY
374    })
375    await arm($)
376    await refreshStatus($)
377    return next(e)
378  })
379
380  on('session.compact', async ($, e, next) => {
381    const result = await next(e)
382    if (isInteractive && e.agentId === undefined && 'messages' in result) {
383      cancelAlert()
384      await update($, track, () => ({ mode: 'compacted', lastRequestAt: 0, isAlerted: false }))
385      await refreshStatus($)
386    }
387    return result
388  })
389
390  // /clear lands here too, and the process carries on under a new session
391  on('session.end', async ($, e, next) => {
392    cancelAlert()
393    await update($, track, () => EMPTY)
394    await update($, line, () => null)
395    return next(e)
396  })
397
398  on('command.run', { command: 'cache-alert' }, async ($, e) => {
399    const arg = e.args.trim()
400    if (arg === 'setup') {
401      const opened = await openSetup($)
402      return { text: opened.isPlaced ? 'Opened cache-alert setup.' : 'cache-alert setup is waiting for room: widen the terminal.' }
403    }
404    if (arg === 'test') {
405      const s = await read($, settings)
406      if (!isTelegramReady(s)) return { text: 'Telegram is not set up yet: run /cache-alert setup.' }
407      const reply = await sendMessage($, s, '🔔 *cache-alert test notification*\n\nEverything is working.')
408      return { text: reply.ok ? 'Test alert sent to Telegram.' : `Telegram send failed: ${reply.description ?? 'unknown error'}` }
409    }
410    const s = await read($, settings)
411    const status = (await statusText($)) ?? 'No cached turn yet in this session.'
412    const telegramLine = isTelegramReady(s) ? `Telegram: @${s.botName || 'bot'} → chat ${s.chatId}` : 'Telegram: not set up (/cache-alert setup)'
413    return { text: `${status}\n${telegramLine} · TTL ${s.ttl} · alert at ${s.thresholdPercent}% left` }
414  })
415
416  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
417    const l = await read($, line)
418    if (l === null || e.props.hasSurvey) return next(e)
419    const fill = bandFill(await read($, theme))
420    // On a fill, every text takes the theme's colours: the terminal's own foreground may not suit it
421    const plain = fill === undefined ? {} : { color: 'text' }
422    const muted = fill === undefined ? { dimColor: true } : { color: 'inactive' }
423    const { Box, Text } = $.ui.resolve(e)
424    const action = l.channels ? actionText(l.channels, l.followUp, l.notify) : null
425    // The action shares the row while it fits, else takes a row of its own
426    const isOneRow = action === null || cells(`${lineText(l)} ; ${action}`) + 2 <= e.props.bodyColumns
427    return (
428      <Box marginTop={1}>
429        <Box width={e.props.bodyColumns} backgroundColor={fill} paddingX={1} flexDirection={isOneRow ? 'row' : 'column'}>
430          <Box>
431            <Text {...muted}>Cache ({l.ttl}): </Text>
432            <Text {...plain}>Status {l.icon} </Text>
433            {/* COLD and reset by /compact read like the Action text, not fainter */}
434            {l.tone === 'subtle' ? (
435              <Text {...muted}>{l.state}</Text>
436            ) : (
437              <Text color={l.tone} bold>
438                {l.state}
439              </Text>
440            )}
441            {l.alert === null ? null : <Text {...muted}> ; </Text>}
442            {l.alert === null ? null : <Text {...plain}>Alert 🔔 {l.alert}</Text>}
443          </Box>
444          {action === null ? null : <Text {...muted}>{isOneRow ? ` ; ${action}` : action}</Text>}
445        </Box>
446      </Box>
447    )
448  })
449
450  on('ui.render', { component: 'Pane', requestId: SETUP }, async ($, e) => {
451    const s = await read($, settings)
452    const n = await read($, note)
453    if (e.surface === 'mobile') {
454      const { Text } = $.ui.resolve(e)
455      return <Text>Run /cache-alert setup from a terminal or desktop session.</Text>
456    }
457    const { Box, Text, Input, Button } = $.ui.resolve(e)
458    const tokenHint = s.botToken ? `saved ••••${s.botToken.slice(-4)}${s.botName ? ` (@${s.botName})` : ''}; paste to replace` : 'paste the token from @BotFather'
459    const leadMin = (p: number) => fmt((ttlMs(s) * p) / 100)
460
461    return (
462      <Box flexDirection="column">
463        <Text bold>Telegram</Text>
464        <Text dimColor>1. In Telegram, message @BotFather, send /newbot, and paste the token it gives you below.</Text>
465        <Input key="token" label="Bot token " placeholder={tokenHint} value="" submitLabel="save" onSubmit={v => void saveToken($, v)} />
466        <Box marginTop={1}>
467          <Text dimColor>2. Send your bot any message, then press Detect chat (or type a chat ID).</Text>
468        </Box>
469        <Box>
470          <Input key="chat" label="Chat ID " placeholder={s.chatId ? `saved ${s.chatId}; type to replace` : 'not set'} value="" submitLabel="save" onSubmit={v => void saveChatId($, v)} />
471          <Text> </Text>
472          <Button key="detect" label="Detect chat" onPress={() => void detectChat($)} />
473        </Box>
474        <Box flexDirection="column" marginTop={1}>
475          <Text bold>Cache</Text>
476          <Text>Cache TTL</Text>
477          <Box marginLeft={4} flexWrap="wrap" columnGap={2}>
478            {TTLS.map(t => (
479              <Button key={`ttl-${t.value}`} label={`${radio(s.ttl === t.value)} ${t.label}`} onPress={() => void saveSettings($, cur => ({ ...cur, ttl: t.value }))} />
480            ))}
481          </Box>
482          <Text>Alert when left</Text>
483          <Box marginLeft={4} flexWrap="wrap" columnGap={2}>
484            {THRESHOLDS.map(p => (
485              <Button key={`threshold-${p}`} label={`${radio(s.thresholdPercent === p)} ${p}% (${leadMin(p)})`} onPress={() => void saveSettings($, cur => ({ ...cur, thresholdPercent: p }))} />
486            ))}
487          </Box>
488        </Box>
489        <Box flexDirection="column" marginTop={1}>
490          <Text bold>When the alert fires</Text>
491          <Text dimColor>Notify, do one thing in the chat, or both.</Text>
492          <Button key="notify" label={`${checkbox(s.notify)} Notify`} onPress={() => void saveSettings($, cur => ({ ...cur, notify: !cur.notify }))} />
493          {s.notify ? (
494            <Box marginLeft={4}>
495              <Button key="desktop" label={`${checkbox(s.nativeNotify)} Desktop notification`} onPress={() => void saveSettings($, cur => ({ ...cur, nativeNotify: !cur.nativeNotify }))} />
496              <Text> </Text>
497              <Button key="sound" label={`${checkbox(s.sound)} Telegram sound`} onPress={() => void saveSettings($, cur => ({ ...cur, sound: !cur.sound }))} />
498            </Box>
499          ) : null}
500          <Box marginTop={1}>
501            <Text dimColor>In the chat (pick one):</Text>
502          </Box>
503          {ACTIONS.map(a => (
504            <Button key={`onAlert-${a.value}`} label={`${radio(s.onAlert === a.value)} ${a.label}`} onPress={() => void saveSettings($, cur => ({ ...cur, onAlert: a.value }))} />
505          ))}
506        </Box>
507        <Box marginTop={1}>
508          <Button key="test" variant="primary" label="Send test" onPress={() => void sendTest($)} />
509          <Text> </Text>
510          {s.botToken ? (
511            <Button
512              key="disconnect"
513              label="Disconnect Telegram"
514              onPress={() =>
515                void saveSettings($, cur => ({ ...cur, botToken: '', botName: '', chatId: '' })).then(() => setNote($, 'Telegram disconnected.'))
516              }
517            />
518          ) : null}
519          <Text> </Text>
520          <Button key="done" role="dismiss" label="Done" onPress={() => void $.ui.close({ id: SETUP })} />
521        </Box>
522        {n ? (
523          <Text color={n.isError ? 'error' : 'success'}>
524            {n.text}
525          </Text>
526        ) : null}
527      </Box>
528    )
529  })
530}
531
types/index.d.ts 60 lines
1/** Where this session's main-loop prompt cache stands. */
2export type CacheMode = 'none' | 'working' | 'idle' | 'compacted'
3
4export type CacheTrack = {
5  mode: CacheMode
6  /** ms epoch of the last main-loop model turn that touched the cache; 0 when none. */
7  lastRequestAt: number
8  /** The alert for lastRequestAt was delivered. */
9  isAlerted: boolean
10}
11
12export type SessionInfo = {
13  title: string
14}
15
16/** What the mod does in the chat when the alert fires, with or without the notifications. */
17export type AlertAction = 'none' | 'brb' | 'compact' | 'clear'
18
19/** What /cache-alert setup writes; kept in $.store across sessions. */
20export type AlertSettings = {
21  botToken: string
22  /** The bot's username, as getMe answered when the token was saved. */
23  botName: string
24  chatId: string
25  ttl: '1h' | '5m'
26  /** Alert when this share of the TTL remains. */
27  thresholdPercent: number
28  /** Notify at all: the toast, plus Desktop and Telegram where set up. */
29  notify: boolean
30  sound: boolean
31  nativeNotify: boolean
32  onAlert: AlertAction
33}
34
35/** What the band above the prompt shows; null when there is no cached turn. */
36export type CacheLine = {
37  ttl: '1h' | '5m'
38  icon: string
39  /** The time left (mm:ss), or active, COLD, reset by /compact. */
40  state: string
41  tone: 'success' | 'warning' | 'error' | 'subtle'
42  /** in 47 m, in 30 s, due, sent; null when there is nothing to alert about. */
43  alert: string | null
44  /** Where the coming alert goes (Telegram, Desktop); null once there is no alert to come. */
45  channels: string[] | null
46  /** The notifications are on; when off, only the chat action runs. */
47  notify: boolean
48  /** What follows the notifications in the chat (/compact, "be right back"); null for nothing. */
49  followUp: string | null
50} | null
51
52/** The setup pane's last result line. */
53export type SetupNote = { text: string; isError: boolean } | null
54
55declare module 'claude-code' {
56  interface PluginState {
57    'cache-alert': { track: CacheTrack; info: SessionInfo; settings: AlertSettings; note: SetupNote; line: CacheLine; theme: string }
58  }
59}
60