SLOPSHOPPER

cache-caffeine

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…

newbandcommandtoaststatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-caffeine
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /caffeine ⎿ cache-caffeine: Caffeine on: waits for the first reply · until you turn it off (or 8h idle). c[_] --°F caffeine on · waits for the first reply · until you turn it off (or 8h idle) t: turn off e: message ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
c[_] --°F caffeine on · waits for the first reply · until you turn it off (or 8h idle) t: turn off ⟨Claude Code's own drawing⟩
README

cache-caffeine

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

Install

/plugin marketplace add lperezmo/cache-caffeine
/plugin install cache-caffeine@cache-caffeine

Needs Claude Code 2.1.287 or newer (mods on by default).

Use

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.

CommandWhat it does
/caffeineon (until you turn it off) or off, for this session
/caffeine warm 2hkeep the cache warm for 2 hours, then off (alone: 1 hour)
/caffeine until 18:00on, then off at 18:00
/caffeine pokepoke now
/caffeine message <text>what the poke says (reset for the default, "poke, just say okay")
/caffeine every 10mhow 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 1hpin the cache TTL (auto to go back to detecting it)
/caffeine idle 8hturn off after this long without a turn of your own
/caffeine foreverno 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 offno row; shows in the status line instead, for when other mods use the band
/caffeine auto 100kturn on by itself once the context passes 100k tokens (off for never, the default)
/caffeine costwhat a poke costs against one cache rewrite, and how long caffeine pays off for
/caffeine wakewake Claude just after the usage limit resets
/caffeine 1 minwake 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 builda 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 autobook the wake by itself whenever the limit hits
/caffeine statuswhat it is doing and whether the last poke found the cache warm

What it does and does not do

  • It sends prompts on your behalf. Each poke is a real turn: it uses your plan's usage and adds a short exchange to the conversation. A poke is exactly the message you set (default "poke, just say okay"), and a wake is exactly the text you gave that wake (/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.
  • It is off until you turn it on, and only for the session you turn it on in. It waits while Claude is working and pauses when the 5-hour window is over 90% or the weekly one over 95%.
  • It skips the poke once the cache has already expired, since that would only write it again. If a poke finds the cache cold anyway (it writes more than a tenth of what it reads), caffeine turns itself off and says so. A compaction or a /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.
  • It turns itself off after 8 hours without a turn of your own, unless you confirm /caffeine forever for that run (change the length with /caffeine idle).
  • What it reads: the session's own usage figures from Claude Code (token counts per request, the running cost estimate, the 5-hour and weekly limit windows) and three environment variables, 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.
  • Tools it calls itself: none. A wake is caffeine's own timer: at the wake time it sends the wake prompt the same way it sends a poke. /caffeine forever asks you to confirm in Claude Code's own question dialog.
  • Programs it starts: none. It does not keep the computer awake: a wake (and the pokes) need it on and the session open, so set your power settings to stay awake if you rely on them.
  • Hooks that see other events: 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.
  • The session has to stay open. caffeine keeps a booked wake's time in its own storage, so after claude --resume it picks the wake back up.

License

MIT

Source 4 files
hooks/register.tsx 967 lines
1import { 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}
967
hooks/brew.ts 256 lines
1// 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}
256
hooks/wake.ts 77 lines
1// 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}
77
types/index.d.ts 13 lines
1// 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