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…

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.
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).
/cache-alert setup
A setup pane opens:
/newbot, and paste the token it gives you into Bot token, then press Enter. The mod checks the token with Telegram before saving it.Settings are saved across sessions. Without Telegram you still get the toast and the desktop notification.
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.
| Shown | Meaning |
|---|---|
Cache (1h): Status 🟢 active | A turn is running, so the cache is being refreshed |
Cache (1h): Status 🟢 58:58 ; Alert 🔔 in 47 m | Cache expires in 58:58; the alert fires in 47 minutes |
Cache (1h): Status 🟡 25:10 ; Alert 🔔 in 13 m | Less than half the TTL is left |
Cache (1h): Status 🔴 11:54 ; Alert 🔔 sent | Inside the alert window; the alert went out |
Cache (1h): Status ❄️ COLD | The cache has expired |
Cache (1h): Status ♻️ reset by /compact | /compact replaced the cached prefix; the next turn starts a new one |
| Command | Does |
|---|---|
/cache-alert | Shows the cache status and the Telegram setup |
/cache-alert setup | Opens the setup pane |
/cache-alert test | Sends a test Telegram message |
/compact and /clear cancel the alert.api.telegram.org.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.
turn.complete), to know when the cache was last refreshed. It reads no message text.classic.SessionStart), for the alert message. The hook changes nothing and passes the event on unchanged.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):
prompt.submit). Nothing else is put in it.command.run).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.
claude --plugin-dir . # run it from this folder; edits hot-reload
claude plugin validate .
claude plugin test .
MIT © candiesdoodle
hooks/register.tsx 531 lines1import { 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}
531types/index.d.ts 60 lines1/** 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