Keeps the prompt cache warm while you step away: a short poke before the cache expires, so the next turn reads the cache instead of writing it again. A row…

A Claude Code mod that keeps the prompt cache warm while you step away, and wakes Claude up when the usage limit resets.
Claude Code caches the conversation for 1 hour on a Claude subscription and 5 minutes on an API key. Come back after it expires and the next turn writes the whole context to the cache again. With caffeine on, a short poke goes out before the cache expires, so your next turn reads the cache instead.
≋≋≋
c[_] 98°F caffeine on · poke at 14:32 (in 40m) · until you turn it off (or 8h idle) t: turn off e: message
The cup is the cache: 100°F and steaming right after a request, cooling to 40°F as the cache runs out, red through blue. It shows while caffeine is off too, so you can watch the cache go cold.
Near the usage limit a second row offers the wake:
5h limit 96% · resets 14:10 (in 2h 05m) u: wake Claude at 14:12 n: not now
/plugin marketplace add lperezmo/cache-caffeine
/plugin install cache-caffeine@cache-caffeine
Needs Claude Code 2.1.287 or newer (mods on by default).
The row above the prompt has the switch (t) and the message editor (e), and says how long caffeine keeps warm: until you turn it off, or to the end of a warm or until you set; the wake row books the wake (u) or puts it off (n). Focus the row with ctrl+x tab or a click.
| Command | What it does |
|---|---|
/caffeine | on (until you turn it off) or off, for this session |
/caffeine warm 2h | keep the cache warm for 2 hours, then off (alone: 1 hour) |
/caffeine until 18:00 | on, then off at 18:00 |
/caffeine poke | poke now |
/caffeine message <text> | what the poke says (reset for the default, "poke, just say okay") |
/caffeine every 10m | how long after the last request to poke (auto: at 80% of the cache's life, 48m on a 1h cache, 4m on 5m) |
/caffeine ttl 1h | pin the cache TTL (auto to go back to detecting it) |
/caffeine idle 8h | turn off after this long without a turn of your own |
/caffeine forever | no end this time: asks you to confirm, then keeps the cache warm until you turn it off; the next turn-on has the idle stop again (idle off does the same) |
/caffeine band off | no row; shows in the status line instead, for when other mods use the band |
/caffeine auto 100k | turn on by itself once the context passes 100k tokens (off for never, the default) |
/caffeine cost | what a poke costs against one cache rewrite, and how long caffeine pays off for |
/caffeine wake | wake Claude just after the usage limit resets |
/caffeine 1 min | wake Claude at a time: in 20 minutes, 2 hours, 14:30, at 3pm, noon (wake 1 min works too); wake off cancels |
/caffeine 20 min check the build | a wake that tells Claude exactly that |
/caffeine wake prompt <text> | what Claude is told on every wake (wake prompt default goes back to the defaults) |
/caffeine wake auto | book the wake by itself whenever the limit hits |
/caffeine status | what it is doing and whether the last poke found the cache warm |
/caffeine 20 min check the build), else the wake prompt you set, else the default ("The usage limit has reset. Pick up where you left off." after a limit reset, "Time's up. Pick up where you left off." at a time you picked), with [caffeine] in front. Nothing read from the conversation, a file or anywhere else goes into either./clear empties the cache, so pokes wait for your next message to write it again; a poke that had to write it anyway (the first after a compaction or a model switch) never counts as cold./caffeine forever for that run (change the length with /caffeine idle).CLAUDE_CODE_PROMPT_CACHE_TTL, FORCE_PROMPT_CACHING_5M and ENABLE_PROMPT_CACHING_1H. From those it works out the cache TTL: what a request cost (a 1-hour write costs 2x input, a 5-minute one 1.25x), else the variables, else request timing (the cache still read after more than 5 minutes idle means 1 hour), else Claude Code's default for your plan. It reads no files and makes no network requests of its own./caffeine forever asks you to confirm in Claude Code's own question dialog.prompt.submit only clears the "limit hit" flag when you send a prompt; it changes nothing. classic.StopFailure only notices a reply that failed on the usage limit, to offer the wake. turn.step reads each request's usage and leaves the request as it is. session.compact and session.end only note that a compaction or a /clear happened, to hold the pokes.claude --resume it picks the wake back up.MIT
hooks/register.tsx 967 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Brew } from '../types'
5import {
6 clock,
7 DEFAULT_IDLE,
8 DEFAULT_MESSAGE,
9 defaultEvery,
10 dollars,
11 equivalents,
12 nextPokeAt,
13 overLine,
14 parseClock,
15 parseDuration,
16 pokeGuess,
17 pokeWasCold,
18 readTiming,
19 readRate,
20 sized,
21 span,
22 TTL_1H,
23 TTL_5M,
24 ttlByPlan,
25 ttlFromCost,
26 ttlFromEnv,
27 warmth,
28 writeRate,
29} from './brew'
30import type { Limit, Usage, Warmth } from './brew'
31import { blocking, DEFAULT_TIMER_WAKE, DEFAULT_WAKE, isNear, MARK, parseWake, wakeAfter } from './wake'
32import type { Block } from './wake'
33
34// The session's switch, kept by the host so a reload of the mod finds it.
35const brew = atom({ plugin: 'cache-caffeine', key: 'brew' } as const, {
36 isOn: false,
37 until: 0,
38 lastAt: 0,
39 activeAt: 0,
40 isAutoDeclined: false,
41 isForever: false,
42})
43
44// A booked wake: when it fires, and what it waits out.
45type Armed = { at: number; label: string; text: string }
46
47// The booked wake as the store keeps it, so `--resume` finds it again.
48type KeptWake = { session: string; at: number; label: string; text?: string }
49
50type Caffeine = Brew & {
51 // a main-thread turn is running; its own requests keep the cache warm
52 isBusy: boolean
53 // a poke submitted and waiting for its turn, then running in it
54 poke: 'none' | 'sent' | 'running'
55 pokeTurnId: string
56 sentAt: number
57 pokes: number
58 // what the pokes cost this session, in input-token equivalents
59 spent: number
60 lastPoke: Usage | null
61 // whether the last poke found the cache still warm
62 lastWarm: boolean | null
63 limits: Limit[]
64 context: number
65 model: string
66 // the TTL: the person's override, Claude Code's environment, and what the
67 // session's own requests cost; else Claude Code's automatic choice by plan
68 ttl: number | null
69 envTtl: number | null
70 measuredTtl: number | null
71 // what request timing says, for models caffeine has no price for
72 timedTtl: number | null
73 // what emptied the cache (a compaction, a /clear): pokes wait for the next request to write it again
74 heldBy: string
75 // the running poke was a first request, after a compaction or on another model,
76 // so it wrote the cache whatever the pokes before it did
77 isPokeExempt: boolean
78 // the last time a request found the cache cold, and the gap before it
79 coldAt: number
80 coldGap: number
81 // settings kept across sessions
82 message: string
83 every: number | null
84 isBanded: boolean
85 idle: number
86 // turn on by itself once the context passes this many tokens; 0 is never
87 autoAt: number
88 // the band's message field is open
89 isEditing: boolean
90 // the steam's shimmer
91 frame: number
92 bandId: string
93 // the wake
94 armed: Armed | null
95 // the last reply failed on the usage limit
96 isHit: boolean
97 // the reset someone said "not now" to; the wake row stays down until it passes
98 dismissed: number
99 // caffeine was off when the wake was booked, and comes off with it
100 isWakeLit: boolean
101}
102
103const MINUTE = 60_000
104// `/caffeine warm` alone
105const WARM = 60 * MINUTE
106
107// what the requests cost is the truth; the environment and the plan are what Claude Code should pick
108const ttlOf = (c: Caffeine) => c.ttl ?? c.measuredTtl ?? c.envTtl ?? c.timedTtl ?? ttlByPlan(c.limits)
109
110// Where the TTL caffeine works with comes from, for /caffeine status.
111function ttlSource(c: Caffeine): string {
112 if (c.ttl !== null) return 'set with /caffeine ttl'
113 if (c.measuredTtl !== null) return 'measured from what requests cost'
114 if (c.envTtl !== null) return 'from the environment'
115 if (c.timedTtl !== null) return 'measured from request timing'
116 if (c.limits.length) return 'the subscription default'
117 return c.lastAt ? 'the API-key default' : 'a guess until the first reply shows the plan'
118}
119const everyOf = (c: Caffeine) => c.every ?? defaultEvery(ttlOf(c))
120const ttlLabel = (ms: number) => (ms >= TTL_1H ? '1h' : '5m')
121
122// Says how it went: a toast, and a line in the transcript that outlasts it.
123function tell($: EngineInterface, text: string): void {
124 $.ui.toast(`caffeine: ${text}`)
125 $.ui.log(`caffeine: ${text}`)
126}
127
128async function setting<T>($: EngineInterface, key: string, fallback: T): Promise<T> {
129 try {
130 const value = await $.store.get(key)
131 return value === undefined || value === null ? fallback : (value as T)
132 } catch {
133 return fallback
134 }
135}
136
137async function load($: EngineInterface, c: Caffeine): Promise<void> {
138 c.message = await setting($, 'message', DEFAULT_MESSAGE)
139 c.every = (await setting($, 'every', null)) as number | null
140 c.isBanded = await setting($, 'band', true)
141 c.idle = (await setting($, 'idle', DEFAULT_IDLE)) || DEFAULT_IDLE
142 c.ttl = (await setting($, 'ttl', null)) as number | null
143 c.autoAt = await setting($, 'autoAt', 0)
144 try {
145 c.envTtl = ttlFromEnv(
146 await $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'),
147 await $.env.get('FORCE_PROMPT_CACHING_5M'),
148 await $.env.get('ENABLE_PROMPT_CACHING_1H'),
149 )
150 } catch {
151 c.envTtl = null
152 }
153}
154
155async function keep($: EngineInterface, c: Caffeine): Promise<void> {
156 const kept: Brew = { isOn: c.isOn, until: c.until, lastAt: c.lastAt, activeAt: c.activeAt, isAutoDeclined: c.isAutoDeclined, isForever: c.isForever }
157 await update($, brew, () => kept).catch(() => undefined)
158}
159
160// How long caffeine keeps the cache warm: to a set time, or until it is turned off.
161function howLong(c: Caffeine, now: number): string {
162 if (c.until) {
163 return `until ${clock(c.until, now)} (${span(c.until - now)} left)`
164 }
165 return c.isForever || c.armed ? 'until you turn it off' : `until you turn it off (or ${span(c.idle).replace(' 00m', '')} idle)`
166}
167
168// One line for the band, the status line and /caffeine status.
169function describe(c: Caffeine, now: number): { text: string; color?: string; isDim?: boolean } {
170 if (!c.isOn) {
171 return { text: 'caffeine off', isDim: true }
172 }
173 const tail = [c.pokes ? `${c.pokes} poke${c.pokes === 1 ? '' : 's'}` : '', howLong(c, now)]
174 .filter(Boolean)
175 .map(t => ` · ${t}`)
176 .join('')
177 const over = overLine(c.limits)
178 if (over) {
179 return { text: `caffeine paused · ${over.label} limit ${over.percent}%${tail}`, color: 'yellow' }
180 }
181 if (c.lastAt === 0) {
182 const why = c.heldBy ? `waits for your next message after the ${c.heldBy}` : 'waits for the first reply'
183 return { text: `caffeine on · ${why}${tail}`, color: 'green' }
184 }
185 if (c.isBusy || c.poke !== 'none') {
186 return { text: `caffeine on · cache warm${tail}`, color: 'green' }
187 }
188 if (now - c.lastAt >= ttlOf(c)) {
189 return { text: `caffeine on · cache went cold, waits for your next turn${tail}`, color: 'yellow' }
190 }
191 const at = nextPokeAt(c.lastAt, everyOf(c))!
192 return { text: `caffeine on · poke at ${clock(at, now)} (in ${span(at - now)})${tail}`, color: 'green' }
193}
194
195async function costNow($: EngineInterface): Promise<number | null> {
196 try {
197 const usd = (await $.session.usage()).cost?.usd
198 return typeof usd === 'number' ? usd : null
199 } catch {
200 return null
201 }
202}
203
204function cup(c: Caffeine, now: number): Warmth | null {
205 return c.lastAt ? warmth(c.lastAt + ttlOf(c) - now, ttlOf(c), c.frame) : null
206}
207
208async function refresh($: EngineInterface, c: Caffeine): Promise<void> {
209 if (c.isBanded || !c.isOn) {
210 $.ui.status(undefined)
211 } else {
212 const now = await $.clock.now()
213 const w = cup(c, now)
214 $.ui.status(`${w ? `${w.degrees}°F ` : ''}${describe(c, now).text}`)
215 }
216 $.ui.invalidate('ui.render')
217}
218
219async function switchOn($: EngineInterface, c: Caffeine, isOn: boolean, until = 0): Promise<void> {
220 c.isOn = isOn
221 c.until = isOn ? until : 0
222 c.isWakeLit = false
223 c.isForever = false
224 if (isOn) {
225 c.activeAt = await $.clock.now()
226 } else if (c.poke === 'sent') {
227 c.poke = 'none'
228 }
229 await keep($, c)
230 await refresh($, c)
231}
232
233// Sends the poke: a prompt of its own, run once the session is idle.
234function poke($: EngineInterface, c: Caffeine, now: number): void {
235 c.poke = 'sent'
236 c.sentAt = now
237 void $.prompt.submit({ text: c.message }).catch(() => {
238 c.poke = 'none'
239 })
240}
241
242async function tick($: EngineInterface, c: Caffeine): Promise<void> {
243 const now = await $.clock.now()
244 if (c.armed && now >= c.armed.at) {
245 await fire($, c, c.armed)
246 }
247 if (!c.isOn) {
248 return
249 }
250 if (c.until && now >= c.until) {
251 const wasWake = c.isWakeLit
252 await switchOn($, c, false)
253 if (!wasWake) tell($, 'wore off.')
254 return
255 }
256 if (!c.isForever && !c.armed && now - c.activeAt >= c.idle) {
257 await switchOn($, c, false)
258 tell($, `turned off after ${span(c.idle)} without a turn of your own.`)
259 return
260 }
261 if (c.poke !== 'none') {
262 // a poke that never got its turn (a dialog, a closed prompt) stops holding the rest up
263 if (c.poke === 'sent' && now - c.sentAt > 10 * MINUTE) c.poke = 'none'
264 return
265 }
266 // a cold cache gains nothing from a poke: it would only be written again
267 if (c.isBusy || c.lastAt === 0 || overLine(c.limits) || now - c.lastAt >= ttlOf(c)) {
268 return
269 }
270 const due = nextPokeAt(c.lastAt, everyOf(c))
271 if (due !== null && now >= due) {
272 poke($, c, now)
273 }
274}
275
276async function setMessage($: EngineInterface, c: Caffeine, text: string): Promise<string> {
277 c.message = text.trim() || DEFAULT_MESSAGE
278 c.isEditing = false
279 await $.store.set('message', c.message)
280 await refresh($, c)
281 return c.message
282}
283
284async function editInBand($: EngineInterface, c: Caffeine): Promise<void> {
285 c.isEditing = true
286 $.ui.invalidate('ui.render')
287 if (c.bandId) {
288 await $.ui.focus({ requestId: c.bandId, key: 'caffeine-message' }).catch(() => undefined)
289 }
290}
291
292// What the pokes cost against one cache rewrite.
293function costLines(c: Caffeine): string[] {
294 const ttl = ttlOf(c)
295 const model = c.model
296 if (!model || c.context === 0) {
297 return ['No reply yet this session: the cost comes with the first one.']
298 }
299 const poked = c.lastPoke ?? pokeGuess(c.context)
300 const each = equivalents(poked, model, ttl)
301 const rewrite = c.context * writeRate(ttl)
302 const ratio = rewrite / each
303 const usd = (n: number) => {
304 const d = dollars(n, model)
305 return d ? `, ${d}` : ''
306 }
307 return [
308 `Context ${sized(c.context)} tokens on ${model}, ${ttlLabel(ttl)} cache, a poke ${span(everyOf(c))} after the last request.`,
309 `One poke: ${sized(each)} input-token equivalents${usd(each)}${c.lastPoke ? ' (the last one, measured)' : ` (cache read at ${readRate(model)}x)`}.`,
310 `One cache rewrite: ${sized(rewrite)} (written again at ${writeRate(ttl)}x)${usd(rewrite)}.`,
311 `A rewrite costs about ${Math.round(ratio)} pokes: caffeine pays off if you are back within ${span(ratio * everyOf(c))}.`,
312 c.pokes ? `This session: ${c.pokes} poke${c.pokes === 1 ? '' : 's'}, ${sized(c.spent)}${usd(c.spent)}.` : '',
313 'At API list prices; on a Pro or Max plan the same tokens count against your usage limits instead.',
314 ].filter(Boolean)
315}
316
317// The wake: caffeine's own timer, which submits the wake prompt at a set
318// time (once the session is idle), the same way a poke goes out. The store
319// keeps it under the session's id, so `--resume` picks it up again.
320
321async function arm($: EngineInterface, c: Caffeine, at: number, label: string, text = ''): Promise<void> {
322 await disarm($, c)
323 c.armed = { at, label, text }
324 const kept: KeptWake = { session: await sessionKey($), at, label, text }
325 await $.store.set('wake', kept).catch(() => undefined)
326 // the cache stays warm until Claude picks the work back up
327 if (!c.isOn) {
328 await switchOn($, c, true, at)
329 c.isWakeLit = true
330 }
331 const now = await $.clock.now()
332 tell($, `Claude continues at ${clock(at, now)} (in ${span(at - now)})${label ? `, after the ${label} reset` : ''}, told: ${await wakeText($, c.armed)}. Keep this session open.`)
333 $.ui.invalidate('ui.render')
334}
335
336async function disarm($: EngineInterface, c: Caffeine): Promise<boolean> {
337 const armed = c.armed
338 c.armed = null
339 await $.store.delete('wake').catch(() => undefined)
340 if (c.isWakeLit) {
341 await switchOn($, c, false)
342 }
343 $.ui.invalidate('ui.render')
344 return armed !== null
345}
346
347// The session's id, which a kept wake belongs to; '' where there is none.
348async function sessionKey($: EngineInterface): Promise<string> {
349 try {
350 return await $.session.id()
351 } catch {
352 return ''
353 }
354}
355
356// What Claude is told on this wake: its own text, else the wake prompt you
357// set, else the default for its kind (after a limit reset, or at a time).
358async function wakeText($: EngineInterface, armed: Armed): Promise<string> {
359 const mine = (await setting($, 'wakePrompt', null)) as string | null
360 return armed.text || mine || (armed.label ? DEFAULT_WAKE : DEFAULT_TIMER_WAKE)
361}
362
363// Sends the wake prompt: the wake is spent.
364async function fire($: EngineInterface, c: Caffeine, armed: Armed): Promise<void> {
365 c.armed = null
366 c.isHit = false
367 await $.store.delete('wake').catch(() => undefined)
368 $.ui.invalidate('ui.render')
369 const prompt = `${MARK} ${await wakeText($, armed)}`
370 void $.prompt.submit({ text: prompt }).catch(() => undefined)
371}
372
373// After `--resume` (or a reload of the mod) the wake this session booked is
374// still ahead: take it back.
375async function adopt($: EngineInterface, c: Caffeine): Promise<void> {
376 try {
377 const kept = (await $.store.get('wake')) as KeptWake | null | undefined
378 if (!kept || c.armed || kept.session !== (await sessionKey($))) {
379 return
380 }
381 if (kept.at > (await $.clock.now())) {
382 c.armed = { at: kept.at, label: kept.label, text: kept.text ?? '' }
383 $.ui.invalidate('ui.render')
384 } else {
385 await $.store.delete('wake')
386 }
387 } catch {
388 // nothing kept; nothing booked
389 }
390}
391
392// The wake at the reset of whatever window is in the way.
393async function armAtReset($: EngineInterface, c: Caffeine): Promise<void> {
394 const block = blocking(c.limits)
395 if (!block) {
396 tell($, 'no reset time yet: it comes with the first reply on a Pro or Max plan. /caffeine wake 14:30 picks a time.')
397 return
398 }
399 await arm($, c, wakeAfter(block.resetsAt), block.label)
400}
401
402const NO_RESET = 'No reset time yet: it comes with the first reply on a Pro or Max plan. /caffeine wake 14:30 picks a time.'
403
404async function wakeCommand($: EngineInterface, c: Caffeine, args: string): Promise<{ text: string }> {
405 const [word = '', ...rest] = args.split(/\s+/)
406 const arg = word.toLowerCase()
407 const value = rest.join(' ').trim()
408 const now = await $.clock.now()
409 const flip = async (key: string, name: string, fallback: boolean) => {
410 const v = value.toLowerCase()
411 const next = v === 'on' ? true : v === 'off' ? false : !(await setting($, key, fallback))
412 await $.store.set(key, next)
413 return { text: `${name} ${next ? 'on' : 'off'}.` }
414 }
415 switch (arg) {
416 case '': {
417 if (!blocking(c.limits)) return { text: NO_RESET }
418 $.clock.after(50, () => void armAtReset($, c))
419 return { text: 'Booking the wake…' }
420 }
421 case 'off':
422 case 'cancel':
423 $.clock.after(50, () => void disarm($, c).then(was => tell($, was ? 'wake cancelled.' : 'no wake was booked.')))
424 return { text: 'Cancelling the wake…' }
425 case 'prompt': {
426 if (value.toLowerCase() === 'default') {
427 await $.store.delete('wakePrompt')
428 return { text: `Back to the defaults: "${DEFAULT_WAKE}" after a limit reset, "${DEFAULT_TIMER_WAKE}" at a time.` }
429 }
430 if (!value) {
431 const mine = (await setting($, 'wakePrompt', null)) as string | null
432 return {
433 text: mine
434 ? `On waking Claude is told: ${mine} (prompt default goes back to the defaults)`
435 : `On waking Claude is told "${DEFAULT_WAKE}" after a limit reset, "${DEFAULT_TIMER_WAKE}" at a time.`,
436 }
437 }
438 await $.store.set('wakePrompt', value)
439 return { text: `On waking Claude will be told: ${value}${c.armed ? ' (from the next wake you book)' : ''}` }
440 }
441 case 'auto':
442 return flip('wakeAuto', 'Booking a wake whenever the limit hits:', false)
443 default: {
444 const wake = parseWake(args, now)
445 if (wake === null) return { text: `Not a time I know: ${args}. Try 1 min, in 20 minutes, 14:30 or 3pm.` }
446 const { at, text } = wake
447 $.clock.after(50, () => void arm($, c, at, '', text))
448 return { text: `Booking the wake for ${clock(at, now)} (in ${span(at - now)})${text ? `, to say: ${text}` : ''}…` }
449 }
450 }
451}
452
453const FOREVER = 'Yes, no end'
454const KEEP_STOP = 'Keep the idle stop'
455
456// No end at all, but only once the person has said so: the dialog spells out
457// what it costs, and a run with no one to ask takes a trailing "yes" instead.
458async function forever($: EngineInterface, c: Caffeine, isSure: boolean): Promise<{ text: string }> {
459 const perDay = Math.round((24 * 60 * MINUTE) / everyOf(c))
460 if (!isSure) {
461 let answer: string
462 try {
463 answer = await $.ui.ask(
464 `Keep the cache warm with no end, this time? Caffeine will poke ${span(everyOf(c))} after the last request until you turn it off, about ${perDay} pokes a day while you are away, and each one uses your plan's usage. Once you turn it off, the idle stop is back.`,
465 { header: 'caffeine', options: [FOREVER, KEEP_STOP] },
466 )
467 } catch {
468 return { text: 'No one to ask here. /caffeine forever yes confirms it.' }
469 }
470 if (answer !== FOREVER) {
471 return { text: `Kept: caffeine still turns itself off after ${span(c.idle)} without a turn of your own.` }
472 }
473 }
474 if (!c.isOn || c.until) {
475 await switchOn($, c, true)
476 }
477 c.isForever = true
478 await keep($, c)
479 await refresh($, c)
480 return { text: `Caffeine on with no end: it keeps the cache warm until you turn it off. The next time you turn it on, the ${span(c.idle)} idle stop is back.` }
481}
482
483const help = [
484 '/caffeine turn it on or off for this session',
485 '/caffeine on | off',
486 '/caffeine warm 1h keep the cache warm for 1h, then off (90m, 1h30m; alone: 1h)',
487 '/caffeine until 18:00 on, and off again at 18:00 (6pm)',
488 '/caffeine poke poke now',
489 '/caffeine message … what the poke says (alone: show it; "reset": the default)',
490 '/caffeine auto 100k turn on by itself once the context passes 100k tokens (off: never)',
491 '/caffeine cost what the pokes cost against one cache rewrite',
492 '/caffeine every 10m poke this long after the last request (alone: back to auto)',
493 '/caffeine ttl 1h|5m pin the cache TTL (auto: back to detecting it)',
494 '/caffeine forever on with no end this time, once you confirm (also: idle off); off brings the idle stop back',
495 '/caffeine idle 8h turn off after this long without a turn of your own',
496 '/caffeine band on|off the row above the prompt; off moves it to the status line',
497 '/caffeine wake wake Claude just after the usage limit resets',
498 '/caffeine 1 min wake Claude at a time: in 20 minutes, 2 hours, 14:30, 3pm (also: wake 1 min); wake off cancels',
499 '/caffeine 20 min check the build a wake that tells Claude exactly that',
500 '/caffeine wake prompt … what Claude is told on every wake; wake prompt default goes back',
501 '/caffeine wake auto book the wake by itself whenever the limit hits (on/off)',
502 '/caffeine status what it is doing',
503].join('\n')
504
505async function hold($: EngineInterface, c: Caffeine, by: string): Promise<void> {
506 c.lastAt = 0
507 c.heldBy = by
508 await keep($, c)
509 await refresh($, c)
510}
511
512export const register: Register = on => {
513 const c: Caffeine = {
514 isOn: false,
515 until: 0,
516 lastAt: 0,
517 activeAt: 0,
518 isAutoDeclined: false,
519 isForever: false,
520 isBusy: false,
521 poke: 'none',
522 pokeTurnId: '',
523 sentAt: 0,
524 pokes: 0,
525 spent: 0,
526 lastPoke: null,
527 lastWarm: null,
528 limits: [],
529 context: 0,
530 model: '',
531 ttl: null,
532 envTtl: null,
533 measuredTtl: null,
534 timedTtl: null,
535 heldBy: '',
536 isPokeExempt: false,
537 coldAt: 0,
538 coldGap: 0,
539 message: DEFAULT_MESSAGE,
540 every: null,
541 isBanded: true,
542 idle: DEFAULT_IDLE,
543 autoAt: 0,
544 isEditing: false,
545 frame: 0,
546 bandId: '',
547 armed: null,
548 isHit: false,
549 dismissed: 0,
550 isWakeLit: false,
551 }
552
553 on('session.start', async ($, e, next) => {
554 await $.command.register({
555 name: 'caffeine',
556 description: 'Keep the prompt cache warm while you step away, and wake Claude when the usage limit resets',
557 })
558 const started = await next(e)
559 await load($, c)
560 try {
561 Object.assign(c, await read($, brew))
562 } catch {
563 // a fresh session: off
564 }
565 try {
566 const usage = await $.session.usage()
567 c.limits = [...usage.rateLimits]
568 } catch {
569 // the first reply brings them
570 }
571 $.clock.after(50, () => void adopt($, c))
572 $.clock.every(5_000, () => void tick($, c))
573 // the countdowns, and the cup cooling
574 $.clock.every(30_000, () => {
575 if (c.isOn || c.armed || isNear(c.limits) || c.lastAt) void refresh($, c)
576 })
577 $.clock.every(1_200, () => {
578 if (!c.isBanded || c.isEditing || !c.lastAt) return
579 void $.clock.now().then(now => {
580 if (cup(c, now)?.steam.trim()) {
581 c.frame += 1
582 $.ui.invalidate('ui.render')
583 }
584 })
585 })
586 await refresh($, c)
587 return started
588 })
589
590 on('session.measure', async ($, e, next) => {
591 const wasNear = isNear(c.limits)
592 c.limits = [...e.rateLimits]
593 c.context = e.context?.tokens ?? c.context
594 if (c.autoAt && !c.isOn && !c.isAutoDeclined && c.context >= c.autoAt) {
595 await switchOn($, c, true)
596 tell($, `on: the context passed ${sized(c.autoAt)} tokens. /caffeine off turns it off for this session.`)
597 }
598 const block = blocking(c.limits)
599 if (block && block.percent >= 100 && !c.armed && (await setting($, 'wakeAuto', false))) {
600 await armAtReset($, c)
601 } else if (!wasNear && isNear(c.limits) && block) {
602 $.ui.toast(`caffeine: ${block.label} limit at ${block.percent}%, resets ${clock(block.resetsAt, await $.clock.now())}. /caffeine wake books a wake.`)
603 }
604 $.ui.invalidate('ui.render')
605 return next(e)
606 })
607
608 on('classic.StopFailure', async ($, e, next) => {
609 if (e.error === 'rate_limit') {
610 c.isHit = true
611 if (!c.armed && (await setting($, 'wakeAuto', false))) {
612 await armAtReset($, c)
613 }
614 $.ui.invalidate('ui.render')
615 }
616 return next(e)
617 })
618
619 // Each main-thread request reads the cached prompt and starts its TTL again.
620 // What it cost says which TTL it was written at.
621 on('turn.step', async function* ($, e, next) {
622 if (e.agentId !== undefined) {
623 return yield* next(e)
624 }
625 const at = await $.clock.now()
626 const gap = c.lastAt ? at - c.lastAt : 0
627 // a first request, a compaction or another model writes the cache whatever the TTL
628 // (a compaction and a /clear set lastAt back to 0)
629 const isExempt = c.lastAt === 0 || (c.model !== '' && c.model !== e.model)
630 if (isExempt && c.poke === 'running') c.isPokeExempt = true
631 c.model = e.model
632 c.lastAt = at
633 c.heldBy = ''
634 void keep($, c)
635 const before = await costNow($)
636 const result = yield* next(e)
637 const after = await costNow($)
638 if (result.usage) {
639 const priced = before !== null && after !== null ? ttlFromCost(after - before, result.usage, result.usage.model || e.model) : null
640 if (priced !== null) c.measuredTtl = priced
641 const timing = readTiming(gap, result.usage, isExempt)
642 if (timing.ttl !== null) c.timedTtl = timing.ttl
643 if (timing.isCold) {
644 c.coldAt = at
645 c.coldGap = gap
646 if (c.isOn && c.poke !== 'running') {
647 tell($, `the cache had gone cold after ${span(gap)} idle; this request wrote it again.`)
648 }
649 }
650 if (priced !== null || timing.ttl !== null) void refresh($, c)
651 }
652 return result
653 })
654
655 on('turn.start', async ($, e, next) => {
656 c.isBusy = true
657 if (c.poke === 'sent' && e.text.includes(c.message)) {
658 c.poke = 'running'
659 c.pokeTurnId = e.turnId
660 c.isPokeExempt = false
661 } else {
662 c.activeAt = await $.clock.now()
663 }
664 return next(e)
665 })
666
667 on('turn.complete', async ($, e, next) => {
668 if (e.agentId === undefined) {
669 c.isBusy = false
670 if (c.poke === 'running' && e.turnId === c.pokeTurnId) {
671 c.poke = 'none'
672 c.pokes += 1
673 if (e.usage) {
674 c.lastPoke = e.usage
675 c.spent += equivalents(e.usage, e.usage.model || c.model, ttlOf(c))
676 }
677 const isExempt = c.isPokeExempt
678 c.isPokeExempt = false
679 // a poke that had to write the cache anyway says nothing about the pokes before it
680 if (e.usage && !isExempt && pokeWasCold(e.usage)) {
681 c.lastWarm = false
682 await switchOn($, c, false)
683 tell(
684 $,
685 `turned off: its poke found the cache cold (it wrote ${sized(e.usage.cache_creation_input_tokens)} tokens again), so the pokes were not keeping it warm. /caffeine status says why it may be; turn it on again to retry.`,
686 )
687 } else if (e.usage && !isExempt) {
688 c.lastWarm = true
689 }
690 }
691 await keep($, c)
692 await refresh($, c)
693 }
694 return next(e)
695 })
696
697 on('prompt.submit', async ($, e, next) => {
698 if (e.origin.kind !== 'plugin') {
699 c.isHit = false
700 }
701 return next(e)
702 })
703
704 // A compaction or a /clear empties the cache: nothing pokes until a request
705 // of the person's own (or the turn carrying on) has written it again.
706 on('session.compact', async ($, e, next) => {
707 const done = await next(e)
708 if (e.agentId === undefined && e.trigger !== 'precompute' && !('skip' in done && done.skip)) {
709 await hold($, c, 'compaction')
710 }
711 return done
712 })
713
714 on('session.end', async ($, e, next) => {
715 if (e.reason === 'clear') await hold($, c, '/clear')
716 return next(e)
717 })
718
719 on('command.run', { command: 'caffeine' }, async ($, e) => {
720 const args = e.args.trim()
721 const [word = '', ...rest] = args.split(/\s+/)
722 const arg = word.toLowerCase()
723 const value = rest.join(' ').trim()
724 const now = await $.clock.now()
725 switch (arg) {
726 case 'help':
727 return { text: help }
728 case '':
729 if (c.isOn) c.isAutoDeclined = true
730 await switchOn($, c, !c.isOn)
731 return { text: c.isOn ? `Caffeine on: ${describe(c, now).text.replace(/^caffeine on · /, '')}.` : 'Caffeine off.' }
732 case 'on':
733 await switchOn($, c, true)
734 return { text: `Caffeine on until you turn it off, poking ${span(everyOf(c))} after the last request (${ttlLabel(ttlOf(c))} cache).` }
735 case 'off':
736 c.isAutoDeclined = true
737 await switchOn($, c, false)
738 return { text: 'Caffeine off.' }
739 case 'for':
740 case 'warm': {
741 const ms = value ? parseDuration(value) : WARM
742 if (ms === null) return { text: `Not a duration I know: ${value}. Try 2h, 90m or 1h30m.` }
743 await switchOn($, c, true, now + ms)
744 return { text: `Caffeine on, keeping the cache warm until ${clock(now + ms, now)}.` }
745 }
746 case 'until': {
747 const at = parseClock(value, now)
748 if (at === null) return { text: `Not a time I know: ${value || '(none)'}. Try 18:00 or 6pm.` }
749 await switchOn($, c, true, at)
750 return { text: `Caffeine on, keeping the cache warm until ${clock(at, now)}.` }
751 }
752 case 'poke':
753 if (c.poke !== 'none') return { text: 'A poke is already on its way.' }
754 // a prompt submitted from inside this command would wait on the turn it holds
755 $.clock.after(50, () => void $.clock.now().then(t => poke($, c, t)))
756 return { text: `Poking: ${c.message}` }
757 case 'message':
758 case 'say': {
759 if (!value) return { text: `The poke says: ${c.message}` }
760 const saved = await setMessage($, c, value.toLowerCase() === 'reset' ? '' : value)
761 return { text: `The poke now says: ${saved}` }
762 }
763 case 'auto': {
764 const v = value.toLowerCase()
765 if (!v) return { text: c.autoAt ? `Caffeine turns on by itself past ${sized(c.autoAt)} tokens of context.` : 'Auto is off. /caffeine auto 100k turns it on.' }
766 const n = v === 'off' ? 0 : v === 'on' ? 100_000 : /^(\d+(?:\.\d+)?)(k|m)?$/.test(v) ? Math.round(Number.parseFloat(v) * (v.endsWith('m') ? 1e6 : v.endsWith('k') ? 1e3 : 1)) : null
767 if (n === null || (n > 0 && n < 1000)) return { text: `Not a token count I know: ${value}. Try 100k, or off.` }
768 c.autoAt = n
769 await $.store.set('autoAt', n)
770 return { text: n ? `Caffeine turns on by itself once the context passes ${sized(n)} tokens (now ${sized(c.context)}).` : 'Auto off.' }
771 }
772 case 'cost':
773 return { text: costLines(c).join('\n') }
774 case 'every': {
775 if (!value || value === 'auto') {
776 c.every = null
777 await $.store.set('every', null)
778 await refresh($, c)
779 return { text: `Poking ${span(everyOf(c))} after the last request (auto, ${ttlLabel(ttlOf(c))} cache).` }
780 }
781 const ms = parseDuration(value)
782 if (ms === null || ms < 30_000) return { text: `Not a duration I know (30s or more): ${value}.` }
783 if (ms >= ttlOf(c)) return { text: `${span(ms)} is past the ${ttlLabel(ttlOf(c))} cache: it would be cold by then.` }
784 c.every = ms
785 await $.store.set('every', ms)
786 await refresh($, c)
787 return { text: `Poking ${span(ms)} after the last request.` }
788 }
789 case 'ttl': {
790 const v = value.toLowerCase()
791 const ttl = v === '1h' ? TTL_1H : v === '5m' ? TTL_5M : null
792 if (ttl === null && v && v !== 'auto') return { text: 'The TTL is 1h, 5m or auto.' }
793 c.ttl = ttl
794 await $.store.set('ttl', ttl)
795 await refresh($, c)
796 return { text: `Cache TTL ${ttlLabel(ttlOf(c))} (${ttlSource(c)}); poking ${span(everyOf(c))} after the last request.` }
797 }
798 case 'forever':
799 return forever($, c, value.toLowerCase() === 'yes')
800 case 'idle': {
801 const [first = '', second = ''] = value.toLowerCase().split(/\s+/)
802 if (first === 'off' || first === 'never') {
803 return forever($, c, second === 'yes')
804 }
805 const ms = parseDuration(value)
806 if (ms === null) return { text: `Not a duration I know: ${value || '(none)'}. Try 8h, or off for no end this time.` }
807 c.idle = ms
808 await $.store.set('idle', ms)
809 return { text: `Caffeine turns itself off after ${span(ms)} without a turn of your own.` }
810 }
811 case 'band': {
812 const v = value.toLowerCase()
813 c.isBanded = v === 'on' ? true : v === 'off' ? false : !c.isBanded
814 await $.store.set('band', c.isBanded)
815 await refresh($, c)
816 return { text: c.isBanded ? 'Caffeine shows in its row above the prompt.' : 'Caffeine shows in the status line while on; no row above the prompt.' }
817 }
818 case 'wake':
819 return wakeCommand($, c, value)
820 case 'status': {
821 const block = blocking(c.limits)
822 const lines = [
823 describe(c, now).text,
824 `Cache TTL ${ttlLabel(ttlOf(c))} (${ttlSource(c)}) · poke ${span(everyOf(c))} after the last request${c.every === null ? ' (auto)' : ''}`,
825 `${c.isForever ? 'No idle stop this time: on until you turn it off' : `Off by itself after ${span(c.idle)} idle`} · auto ${c.autoAt ? `past ${sized(c.autoAt)}` : 'off'} · band ${c.isBanded ? 'on' : 'off'}`,
826 c.lastWarm === null ? '' : `The last poke found the cache ${c.lastWarm ? 'warm' : 'cold'}.`,
827 c.coldAt ? `The cache last went cold before ${clock(c.coldAt, now)}, after ${span(c.coldGap)} idle.` : '',
828 `The poke says: ${c.message}`,
829 c.armed ? `Wake: Claude continues at ${clock(c.armed.at, now)} (in ${span(c.armed.at - now)}), told: ${await wakeText($, c.armed)}.` : 'Wake: none booked.',
830 block ? `${block.label} limit ${block.percent}%, resets ${clock(block.resetsAt, now)}.` : '',
831 ]
832 return { text: lines.filter(Boolean).join('\n') }
833 }
834 default: {
835 // a bare time is a wake: /caffeine 1 min, /caffeine at 3pm
836 if (parseWake(args, now) !== null) {
837 return wakeCommand($, c, args)
838 }
839 return { text: `Not a caffeine command: ${word}\n${help}` }
840 }
841 }
842 })
843
844 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
845 if ((e.surface !== 'terminal' && e.surface !== 'desktop') || e.props.hasSurvey) {
846 return next(e)
847 }
848 const { Box, Button, Input, Text } = $.ui.resolve(e)
849 const now = await $.clock.now()
850 c.bandId = e.requestId
851
852 // the wake row: near the limit an offer, once booked a countdown
853 const block: Block | null = blocking(c.limits)
854 const isOffered = !c.armed && block !== null && (isNear(c.limits) || c.isHit) && c.dismissed !== block.resetsAt
855 const armed = c.armed
856 const wakeRow = armed ? (
857 <Box key="caffeine-wake-row" flexDirection="row" columnGap={2}>
858 <Text key="wake-text" color="cyan" wrap="truncate-end">
859 {`caffeine · Claude continues at ${clock(armed.at, now)} (in ${span(armed.at - now)})`}
860 </Text>
861 <Button key="caffeine-wake-cancel" plain dimColor hotkey="n" onPress={() => void disarm($, c).then(() => tell($, 'wake cancelled.'))}>
862 cancel
863 </Button>
864 </Box>
865 ) : isOffered ? (
866 <Box key="caffeine-wake-row" flexDirection="row" columnGap={2}>
867 <Text key="wake-text" color={block!.percent >= 100 ? 'red' : 'yellow'} wrap="truncate-end">
868 {`${block!.label} limit ${block!.percent}% · resets ${clock(block!.resetsAt, now)} (in ${span(block!.resetsAt - now)})`}
869 </Text>
870 <Button key="caffeine-wake" plain hotkey="u" onPress={() => void armAtReset($, c)}>
871 {`wake Claude at ${clock(wakeAfter(block!.resetsAt), now)}`}
872 </Button>
873 <Button
874 key="caffeine-wake-dismiss"
875 plain
876 dimColor
877 hotkey="n"
878 onPress={() => {
879 c.dismissed = block!.resetsAt
880 $.ui.invalidate('ui.render')
881 }}
882 >
883 not now
884 </Button>
885 </Box>
886 ) : null
887
888 // the switch row, under its cup
889 const line = describe(c, now)
890 const warm = cup(c, now)
891 const row = !c.isBanded ? null : c.isEditing ? (
892 <Box key="caffeine" flexDirection="row" columnGap={2}>
893 <Input
894 key="caffeine-message"
895 label="caffeine says: "
896 value={c.message}
897 placeholder={DEFAULT_MESSAGE}
898 submitLabel="save"
899 autoFocus
900 onSubmit={text => void setMessage($, c, text).then(saved => tell($, `the poke now says: ${saved}`))}
901 />
902 <Button
903 key="caffeine-cancel"
904 plain
905 dimColor
906 hotkey="q"
907 onPress={() => {
908 c.isEditing = false
909 $.ui.invalidate('ui.render')
910 }}
911 >
912 cancel
913 </Button>
914 </Box>
915 ) : (
916 <Box key="caffeine" flexDirection="column">
917 <Text key="steam" color={warm?.color} dimColor={!warm}>
918 {` ${warm?.steam ?? ' '}`}
919 </Text>
920 <Box key="caffeine-row" flexDirection="row" columnGap={2}>
921 <Box key="caffeine-cup" flexDirection="row" columnGap={1} flexShrink={0}>
922 <Text key="cup" color={warm?.color} dimColor={!warm}>
923 c[_]
924 </Text>
925 <Text key="degrees" color={warm?.color} dimColor={!warm}>
926 {warm ? `${warm.degrees}°F` : '--°F'}
927 </Text>
928 </Box>
929 <Box key="caffeine-line" flexShrink={1} minWidth={0}>
930 <Text key="text" color={line.color} dimColor={line.isDim} wrap="truncate-end">
931 {line.text}
932 </Text>
933 </Box>
934 <Box key="caffeine-buttons" flexDirection="row" columnGap={2} flexShrink={0}>
935 <Button
936 key="caffeine-toggle"
937 plain
938 hotkey="t"
939 onPress={() => {
940 if (c.isOn) c.isAutoDeclined = true
941 void switchOn($, c, !c.isOn)
942 }}
943 >
944 {c.isOn ? 'turn off' : 'turn on'}
945 </Button>
946 <Button key="caffeine-edit" plain dimColor hotkey="e" onPress={() => void editInBand($, c)}>
947 message
948 </Button>
949 </Box>
950 </Box>
951 </Box>
952 )
953
954 if (!wakeRow && !row) {
955 return next(e)
956 }
957 const below = await next(e)
958 return (
959 <Box flexDirection="column">
960 {wakeRow}
961 {row}
962 {below}
963 </Box>
964 )
965 })
966}
967hooks/brew.ts 256 lines1// Pure parts of caffeine: how long the cache lives, when the next poke is
2// due, the durations and times it reads and writes, and the usage line it
3// will not cross.
4
5export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
6
7export const DEFAULT_MESSAGE = 'poke, just say okay'
8
9const second = 1000
10const minute = 60 * second
11const hour = 60 * minute
12
13export const TTL_5M = 5 * minute
14export const TTL_1H = hour
15
16// How long after the last request a poke goes out, unless set: at 80% of the
17// TTL (48m of 1h, 4m of 5m). Each poke starts the TTL again, so one poke per
18// lifetime, late in it, is all the cache needs; the rest is margin.
19export const POKE_AT = 0.8
20
21export function defaultEvery(ttl: number): number {
22 return Math.round(ttl * POKE_AT)
23}
24
25// Turned off by itself after this long without a turn that was not a poke.
26export const DEFAULT_IDLE = 8 * hour
27
28// Pokes stop at these usage lines: the window is better spent on work.
29export const LIMIT_LINE = { five_hour: 90, seven_day: 95 } as const
30const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'weekly' }
31
32// The window over its line, if one is: `{ label, percent }`.
33export function overLine(limits: readonly Limit[]): { label: string; percent: number } | null {
34 for (const l of limits) {
35 const line = LIMIT_LINE[l.kind as keyof typeof LIMIT_LINE]
36 if (line !== undefined && l.percentUsed >= line) {
37 return { label: LABELS[l.kind] ?? l.kind, percent: Math.round(l.percentUsed) }
38 }
39 }
40 return null
41}
42
43// The TTL Claude Code's environment pins, or null when it leaves the choice
44// automatic: CLAUDE_CODE_PROMPT_CACHE_TTL ("5m" or "1h") wins, then
45// FORCE_PROMPT_CACHING_5M, then ENABLE_PROMPT_CACHING_1H.
46export function ttlFromEnv(cacheTtl: string | undefined, force5m: string | undefined, enable1h: string | undefined): number | null {
47 const v = cacheTtl?.trim().toLowerCase()
48 if (v === '1h') return TTL_1H
49 if (v === '5m') return TTL_5M
50 if (isSet(force5m)) return TTL_5M
51 if (isSet(enable1h)) return TTL_1H
52 return null
53}
54
55const isSet = (v: string | undefined) => v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
56
57// Claude Code's automatic choice: 1 hour on a Claude subscription within its
58// usage limits (the only sessions with rate-limit windows), else 5 minutes.
59export function ttlByPlan(limits: readonly Limit[]): number {
60 return limits.length > 0 && limits.every(l => l.percentUsed < 100) ? TTL_1H : TTL_5M
61}
62
63// The TTL one request was written at, read off what it cost: the session's
64// cost went up by `delta` dollars for `u`, and only the cache write's rate is
65// unknown (2x for 1h, 1.25x for 5m). Null when the request wrote too little
66// to tell, the model's price is unknown, or the rate fits neither (another
67// request, a subagent's, landed in between).
68export function ttlFromCost(delta: number, u: Usage, model: string): number | null {
69 const price = inputPrice(model)
70 if (price === null || u.cache_creation_input_tokens < 1000 || !(delta > 0)) {
71 return null
72 }
73 const rest = u.input_tokens + u.output_tokens * 5 + u.cache_read_input_tokens * readRate(model)
74 const rate = ((delta * 1e6) / price - rest) / u.cache_creation_input_tokens
75 if (Math.abs(rate - 2) < 0.1) return TTL_1H
76 if (Math.abs(rate - 1.25) < 0.1) return TTL_5M
77 return null
78}
79
80// When the next poke goes out, given the last request, or null with none yet.
81export function nextPokeAt(lastAt: number, every: number): number | null {
82 return lastAt > 0 ? lastAt + every : null
83}
84
85// A cache read larger than what the request had to write again: still warm.
86export function wasWarm(usage: { cache_read_input_tokens: number; cache_creation_input_tokens: number } | undefined): boolean | null {
87 if (!usage) return null
88 return usage.cache_read_input_tokens > usage.cache_creation_input_tokens
89}
90
91const UNITS: Record<string, number> = { h: hour, m: minute, s: second }
92
93// A span of time as people type it: `2h`, `90m`, `1h30m`, `2.5m`, `150s`,
94// `1 min`, `5 minutes`, `2 hours`, `1 hour 30 min`, `in 20 minutes`, `+90m`.
95// Milliseconds, or null.
96export function parseDuration(text: string): number | null {
97 const t = text
98 .trim()
99 .toLowerCase()
100 .replace(/^(in|for)\s+/, '')
101 .replace(/^\+/, '')
102 .replace(/,|\band\b/g, ' ')
103 const part = /(\d+(?:\.\d+)?)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)(?![a-z])/g
104 let ms = 0
105 let rest = t
106 for (const m of t.matchAll(part)) {
107 ms += Number(m[1]) * UNITS[m[2]![0]!]!
108 rest = rest.replace(m[0], '')
109 }
110 return ms > 0 && rest.trim() === '' ? Math.round(ms) : null
111}
112
113// `18:00`, `6pm`, `6:30 pm`, `at 3pm`, `noon`, `midnight`: the next such time
114// after `now`, or null.
115export function parseClock(word: string, now: number): number | null {
116 const w = word
117 .trim()
118 .toLowerCase()
119 .replace(/^at\s+/, '')
120 .replace(/^noon$/, '12:00')
121 .replace(/^midnight$/, '0:00')
122 const time = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm|a\.m\.|p\.m\.)?$/.exec(w)
123 if (!time || (!time[2] && !time[3])) {
124 return null
125 }
126 let h = Number(time[1])
127 const m = Number(time[2] ?? 0)
128 if (time[3]) {
129 if (h < 1 || h > 12) return null
130 h = (h % 12) + (time[3].startsWith('p') ? 12 : 0)
131 }
132 if (h > 23 || m > 59) {
133 return null
134 }
135 const d = new Date(now)
136 d.setHours(h, m, 0, 0)
137 return d.getTime() <= now ? d.getTime() + 24 * hour : d.getTime()
138}
139
140const two = (n: number) => String(n).padStart(2, '0')
141const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
142
143// 14:12 today, `Sat 14:12` on another day.
144export function clock(at: number, now: number): string {
145 const d = new Date(at)
146 const time = `${two(d.getHours())}:${two(d.getMinutes())}`
147 return d.toDateString() === new Date(now).toDateString() ? time : `${DAYS[d.getDay()]} ${time}`
148}
149
150// 2h 05m, 12m, 2m 30s, 45s.
151export function span(ms: number): string {
152 const s = Math.max(0, Math.round(ms / second))
153 if (s >= 3600) return `${Math.floor(s / 3600)}h ${two(Math.floor((s % 3600) / 60))}m`
154 if (s >= 600) return `${Math.round(s / 60)}m`
155 if (s >= 60) return s % 60 ? `${Math.floor(s / 60)}m ${two(s % 60)}s` : `${s / 60}m`
156 return `${s}s`
157}
158
159// What a request costs, in input-token equivalents (base input price = 1),
160// by the API's own ratios: output 5x, cache writes 1.25x (5m) or 2x (1h),
161// cache reads 0.1x, except 0.05x on Opus 5.5 and 0.025x on Fable 5.1 and
162// Mythos 5.1.
163export function readRate(model: string): number {
164 if (/fable-5-1|mythos-5-1/.test(model)) return 0.025
165 if (/opus-5-5/.test(model)) return 0.05
166 return 0.1
167}
168
169export const writeRate = (ttl: number) => (ttl >= TTL_1H ? 2 : 1.25)
170
171// API list input price, $ per million tokens, for the models caffeine knows.
172const PRICES: [RegExp, number][] = [
173 [/fable-5|mythos-5/, 10],
174 [/opus-5-5/, 4],
175 [/opus-[45]/, 5],
176 [/sonnet-5/, 2],
177 [/sonnet-4-6/, 3],
178 [/haiku-4-5/, 1],
179]
180
181export function inputPrice(model: string): number | null {
182 return PRICES.find(([re]) => re.test(model))?.[1] ?? null
183}
184
185export type Usage = { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }
186
187export function equivalents(u: Usage, model: string, ttl: number): number {
188 return u.input_tokens + u.output_tokens * 5 + u.cache_read_input_tokens * readRate(model) + u.cache_creation_input_tokens * writeRate(ttl)
189}
190
191// A poke over `context` cached tokens: the read, plus a short exchange.
192export const pokeGuess = (context: number): Usage => ({ input_tokens: 40, output_tokens: 10, cache_read_input_tokens: context, cache_creation_input_tokens: 60 })
193
194// 7.3k, 284k, 1.2M.
195export function sized(n: number): string {
196 if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
197 if (n >= 1e4) return `${Math.round(n / 1e3)}k`
198 if (n >= 1e3) return `${(n / 1e3).toFixed(1)}k`
199 return String(Math.round(n))
200}
201
202export function dollars(equiv: number, model: string): string {
203 const price = inputPrice(model)
204 if (price === null) return ''
205 const usd = (equiv * price) / 1e6
206 return usd < 0.01 ? 'under $0.01' : `about $${usd.toFixed(2)}`
207}
208
209// What one main-thread request says about the cache from its timing alone, as
210// Cache Keeper reads it: the gap since the request before, and how the prompt
211// split between reading the cache and writing it. Past 5.5 minutes a 5-minute
212// cache has expired, so a mostly-read prompt proves the 1-hour TTL, and a
213// mostly-written one means the cache had gone cold (under an hour: a 5-minute
214// TTL, unless something else rewrote it, which `isExempt` says: the first
215// request after a compaction or a model switch). Small prompts say nothing.
216export type Reading = { isCold: boolean; ttl: number | null }
217
218export const GAP = 5.5 * minute
219
220export function readTiming(gap: number, u: Usage, isExempt: boolean): Reading {
221 const total = u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
222 if (total < 30_000 || gap < GAP) {
223 return { isCold: false, ttl: null }
224 }
225 if (u.cache_read_input_tokens / total > 0.8) {
226 return { isCold: false, ttl: TTL_1H }
227 }
228 if (u.cache_creation_input_tokens / total > 0.5 && !isExempt) {
229 return { isCold: true, ttl: gap < TTL_1H ? TTL_5M : null }
230 }
231 return { isCold: false, ttl: null }
232}
233
234// A poke that wrote more than a tenth of what it read found the cache cold:
235// the pokes are not keeping it warm.
236export function pokeWasCold(u: Usage): boolean {
237 return u.cache_creation_input_tokens > 0.1 * u.cache_read_input_tokens
238}
239
240// The cup on the row: how warm the cache is, as a coffee's temperature from
241// 100F (just written or read) down to 40F (expired), the steam over it
242// thinning as it cools, and a color from red to blue. `frame` shimmers the
243// steam.
244export type Warmth = { degrees: number; steam: string; color: string }
245
246const STEAM = [[' '], [' ~ ', ' ~'], ['≈ ≈', ' ≈≈'], ['≋≋≋', '≈≋≈']]
247
248export function warmth(left: number, ttl: number, frame = 0): Warmth {
249 const f = Math.max(0, Math.min(1, left / ttl))
250 const degrees = Math.round(40 + 60 * f)
251 const level = degrees >= 85 ? 3 : degrees >= 65 ? 2 : degrees >= 48 ? 1 : 0
252 const frames = STEAM[level]!
253 const color = degrees >= 85 ? '#ff5a36' : degrees >= 70 ? '#ff9a3c' : degrees >= 55 ? '#f2c94c' : degrees > 40 ? '#9cb8d8' : '#5b8fd9'
254 return { degrees, steam: frames[frame % frames.length]!, color }
255}
256hooks/wake.ts 77 lines1// Pure parts of the wake (what was wakey): which usage window is in the way,
2// when to wake, and what Claude is told then.
3
4import { parseClock, parseDuration } from './brew'
5import type { Limit } from './brew'
6
7// What a wake is waiting out: the window, how full it is, and when it resets.
8export type Block = { label: string; percent: number; resetsAt: number }
9
10const LABELS: Record<string, string> = { five_hour: '5h', seven_day: 'weekly' }
11
12// The wake row shows from here: close enough to the line to plan a wake.
13export const NEAR = { five_hour: 90, seven_day: 95 } as const
14
15// Every wake prompt starts with this, so it reads as caffeine's in the transcript.
16export const MARK = '[caffeine]'
17// What Claude is told on waking: after a limit reset, or at a time picked.
18export const DEFAULT_WAKE = 'The usage limit has reset. Pick up where you left off.'
19export const DEFAULT_TIMER_WAKE = "Time's up. Pick up where you left off."
20
21const minute = 60_000
22
23function window(l: Limit): Block | null {
24 const resetsAt = l.resetsAt ? Date.parse(l.resetsAt) : Number.NaN
25 if (Number.isNaN(resetsAt) || !(l.kind in LABELS)) {
26 return null
27 }
28 return { label: LABELS[l.kind]!, percent: Math.round(l.percentUsed), resetsAt }
29}
30
31// The window to wait out: of the full ones, the one that resets last (a full
32// week outlasts a 5-hour reset); with none full, the 5-hour one.
33export function blocking(limits: readonly Limit[]): Block | null {
34 const windows = limits.map(window).filter((w): w is Block => w !== null)
35 const full = windows.filter(w => w.percent >= 100).sort((a, b) => b.resetsAt - a.resetsAt)
36 return full[0] ?? windows.find(w => w.label === '5h') ?? null
37}
38
39export function isNear(limits: readonly Limit[]): boolean {
40 return limits.some(l => {
41 const line = NEAR[l.kind as keyof typeof NEAR]
42 return line !== undefined && l.percentUsed >= line
43 })
44}
45
46// A couple of minutes after the reset, so the window has surely turned over
47// (and never on :00 or :30, the busiest minutes).
48export function wakeAfter(resetsAt: number): number {
49 let at = Math.ceil((resetsAt + 2 * minute) / minute) * minute
50 const m = new Date(at).getMinutes()
51 if (m === 0 || m === 30) {
52 at += minute
53 }
54 return at
55}
56
57// When to wake, as people type it: a span from now (`1 min`, `in 20 minutes`,
58// `+90m`, `2 hours`) or a clock time (`14:30`, `2:30pm`, `at 3pm`, `noon`).
59export function parseWhen(text: string, now: number): number | null {
60 const ms = parseDuration(text)
61 return ms !== null ? now + ms : parseClock(text, now)
62}
63
64// A wake time and what to say then: `20 min check the build` is 20 minutes
65// from now, saying "check the build". The longest run of leading words that
66// reads as a time is the time; the rest (maybe nothing) is the text.
67export function parseWake(text: string, now: number): { at: number; text: string } | null {
68 const words = text.trim().split(/\s+/).filter(Boolean)
69 for (let k = words.length; k > 0; k--) {
70 const at = parseWhen(words.slice(0, k).join(' '), now)
71 if (at !== null) {
72 return { at, text: words.slice(k).join(' ') }
73 }
74 }
75 return null
76}
77types/index.d.ts 13 lines1// What caffeine keeps for the session, so a reload of the mod finds it as it
2// was: whether it is on, when it wears off (0 for never), when the last
3// main-thread request went out, when the last turn that was not a poke began,
4// whether the person turned off what auto turned on, and whether this run has
5// no idle stop (`/caffeine forever`, gone once caffeine turns off).
6export type Brew = { isOn: boolean; until: number; lastAt: number; activeAt: number; isAutoDeclined: boolean; isForever: boolean }
7
8declare module 'claude-code' {
9 interface PluginState {
10 'cache-caffeine': { brew: Brew }
11 }
12}
13