SLOPSHOPPER

keepwarm

Keeps an idle interactive session's prompt cache warm with one tool-less fork just before the 1-hour cache lapses; /keepwarm on|off|status per session.

newguardcommandstatuspromptmodel
v0.3.1MITupdated 2026-10-07declanbx/keepwarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keepwarm
› 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 › /keepwarm ⎿ keepwarm: on. Last request 0 min ago, next refresh in 57 min (09:50); stops 20 h 00 min from now without a prompt. No re ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ keepwarm: on, no refresh yet
README

keepwarm

A Claude Code mod that keeps an idle interactive session's prompt cache warm, so coming back after a long pause reads the conversation from cache (0.05× input on Opus 5.5) instead of rewriting it (2× input on the 1-hour TTL).

Inspired by cachebeat by ARahim3, a Claude Code skill that keeps the prompt cache warm with an inactivity-triggered heartbeat. keepwarm does the same job as a mod: the refresh is a tool-less copy of the session sent beside it, so nothing is added to the conversation.

Early access. Mods (plugins of function hooks) are an early-access Claude Code feature: the interface may change between releases, and a mod loads only where Claude Code has the feature switched on. A session that shows no keepwarm: status row under the prompt is not running mods.

Install

  1. Clone this repository, e.g. git clone https://github.com/declanbx/keepwarm ~/.claude/mods/keepwarm.
  2. Load it in every session: add the folder's absolute path to CLAUDE_CODE_PLUGIN_DIRS in the env block of ~/.claude/settings.json (several folders are separated by :):
   { "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/absolute/path/to/keepwarm" } }
  1. Start a new session. The status row under the prompt reads keepwarm: on, no refresh yet.

Cost: read before installing

  • Each refresh reads the whole conversation from cache. At Opus 5.5 API prices ($0.20 per million cached tokens) that is about $0.02 for a 100k-token conversation and $0.16 for an 800k one, plus a few output tokens. On a subscription plan it counts against your usage instead.
  • A session left idle refreshes about once an hour for up to 20 h after your last prompt: about 21 refreshes, ~$3.40 for an 800k-token conversation, against ~$6.40 to rewrite those 800k tokens once when you come back. Lower KEEPWARM_MAX_HOURS if you leave sessions open overnight and rarely return to them.
  • Occasionally a refresh cannot reach the session's cache and pays a full write of the conversation at 2× input; keepwarm then turns itself off for that session. 6 of the 132 refreshes on record did this, on both a Team-plan and a Max-plan account (see below); the largest wrote 737,647 tokens, about $5.90 at Opus 5.5 API prices. If /keepwarm status keeps reporting off for this session: a refresh inside the hour read … and had to write … on your account, set KEEPWARM_OFF=1 in the same env block.

How it works

  • How: once the main thread has sent no model request for 57 min, one tool-less $.model.fork of the session's own transcript, prompt Cache keep-alive ping, not a task. Reply with exactly: .. The API serves the prefix from cache, which restarts its hour. Nothing joins the conversation; no shell task, no permission prompt.
  • Clock: restarts whenever a request starts: a prompt entering, a MAIN-thread tool returning, or a refresh. A subagent's requests carry its own transcript and do not count.
  • A refresh that gets no reply (an API error such as 429/529, a dropped connection, an abort) did not touch the cache, so the idle clock is left alone and the next 30 s tick tries again, until the hour is up.
  • Stops until your next prompt: 20 h after your last own prompt; when the gap already reached the hour (laptop asleep, or every retry failed: the pause names the last error); when a refresh reads under 1,000 cached tokens; or when there is no reply to fork yet.
  • Turns itself off for the session when a refresh inside the hour had to write more of the conversation than it could read: the fork could not reach this session's own cache, so refreshing only adds cost. /keepwarm on retries. Of the 132 refreshes on record (2026-10-02 to 10-07, the last 10 kept per session), 6 did this: all 5 refreshes on a Team-plan account between 11:37 and 12:55 UTC on 2026-10-02, and 1 on a Max-plan account on 2026-10-06 (28,649 read, 737,647 written). Since 2026-10-03, 68 refreshes in 15 Team-plan sessions read the whole conversation, as Max-plan refreshes do (e.g. 430,620 read, 356 written), so it is not a property of the plan; the cause is not known.
  • The 1-hour cache it relies on, checked (575,082 API responses Claude Code logged on one machine, Apr-Oct 2026, on a Max-plan and a Team-plan account): Claude Code writes the main conversation's cache at the 1-hour TTL (100% of write tokens on Max, 93% on Team, the rest on one day) and subagents' at the 5-minute TTL (100% on both). After an idle gap of 5-60 min the next main-thread request read the conversation back from cache 99.6% of the time on Max (236 gaps) and 95.3% on Team (215); past 65 min with no keepwarm running, 1 of 19 (Max). keepwarm refreshes only the main conversation; a subagent's cache lapses after 5 idle minutes regardless.
  • Never headless (session.start's isInteractive is false for claude -p and SDK runs).
  • Never in the way: if its bookkeeping after a prompt or a tool call fails, the failure goes to the debug log and the prompt or tool result passes through unchanged.

Commands, settings and where to see it

  • /keepwarm off | on | status, per session. status names the last request, the next refresh time, the last refresh with its cached read and write, and the last failure.
  • Status row under the prompt: keepwarm: on, no refresh yet, keepwarm: last refresh 20:19, read 50k cached, keepwarm: retrying (…), keepwarm: paused: ….
  • State file ~/.claude/state/keepwarm/<session id>.json, rewritten at session start, on every refresh, failure, pause and /keepwarm command, and every 5 min while the session works. It stays on your machine.
  • Environment: KEEPWARM_OFF (any value) disables it; KEEPWARM_IDLE_MIN (57), KEEPWARM_TTL_MIN (60), KEEPWARM_MAX_HOURS (20) and KEEPWARM_PROMPT (the ping text) override the defaults. KEEPWARM_IDLE_MIN=1 shows a refresh within a minute.

Development

  • claude plugin validate . reads the manifest and the hooks module the way the engine will; claude plugin test . runs tests/. If claude is a shell alias that puts flags before the subcommand, call command claude plugin test .: the test runner refuses --dangerously-skip-permissions.
  • Type check: npx -p typescript@5 tsc -p .. It needs .claude-plugin/types/, which Claude Code writes beside the mod the first time a session loads it (git-ignored, rewritten after each Claude Code update), so load the mod in one session before the first type check.

Tests

0.3.1 (2026-10-07)

  • claude plugin validate: passed. tsc: 0 errors. claude plugin test: 25 pass, 0 fail (new: a failure in the bookkeeping after a prompt or a tool call is logged and the prompt and tool result pass).
  • README rewritten for publication: install, cost, development.

0.3.0 (2026-10-02)

  • claude plugin validate: passed. tsc: 0 errors. claude plugin test: 24 pass, 0 fail (new: a refresh that wrote more than it read turns keepwarm off until /keepwarm on; the state file follows a working session).

0.2.0 (2026-10-02)

  • claude plugin validate: passed. tsc: 0 errors. claude plugin test: 21 pass, 0 fail (new: a 529 and an abort are retried at the next tick; failures until the hour pause naming the error; nothing-to-fork; a reply with no text counts; status row, /keepwarm status and state file show the last refresh).
  • Fixed: 0.1.0 read the zeroed usage of a failed refresh as "cache lapsed" and paused for the whole idle stretch.

0.1.0 (2026-10-02, Claude Code 2.1.287, Team-plan account)

  • claude plugin validate: passed. tsc (bundled API types): 0 errors. claude plugin test: 11 pass, 0 fail.
  • Live, interactive, Opus 5.5, repo session (~105k-token prefix), timer cut to 1 min (debug log):
  • ping above: 4 refreshes, one mid-turn during a 100 s command; each read 102,565–105,497 cached tokens, 30 uncached input, 3 output tokens, cache write 0–240.
  • alternative ping "This is just to keep the cache fresh, no action required from you.": 3 refreshes, 25 input, 6–69 output tokens (the mid-turn one wrote two messages). Kept the first.
  • Cost per refresh at API prices for that session: ≈ 105,500 × $0.20/M ≈ $0.021; output and input add < 1%.
Source 2 files
hooks/register.ts 421 lines
1// keepwarm — keeps an idle interactive session's prompt cache warm.
2//
3// The prompt cache of a session lives one hour from the START of the last request that read it.
4// While the session works, every request refreshes it; only idle gaps (you away, or the main thread
5// blocked past the hour on one long subagent) let it lapse, and the next message then rewrites the
6// whole transcript at the cache-write price (2x input on the 1-hour TTL) instead of reading it
7// (0.05x on Opus 5.5). So, once the main thread has sent nothing for IDLE minutes, this mod sends one
8// tool-less fork of the session's own transcript ($.model.fork): the API serves that prefix from the
9// cache, which restarts its hour, and nothing joins the conversation.
10//
11//   prompt.submit   → a request is about to start (and, for the person's own prompt, the 20 h clock restarts)
12//   tool.call       → after a MAIN-thread tool returns, the next request starts at once
13//   session.start   → interactive sessions only: the 30 s tick, the /keepwarm command, the status line
14//   command.run     → /keepwarm on | off | status, per session
15//
16// A refresh that gets no reply (an API error such as 429/529, a dropped connection, an abort) did not
17// touch the cache: the idle clock is left where it was, so the next tick (30 s on) tries again, until
18// the TTL is reached. Stops (until the next prompt) when: the person has not prompted for MAX hours;
19// the idle gap already reached the TTL (the laptop slept, or every retry failed), so a refresh would
20// pay a full write; a refresh reports the cache had lapsed (it read almost nothing); or there is no
21// reply to fork yet. Turns itself off for the rest of the session when a refresh inside the hour had to
22// write more than it read: the fork cannot reach this session's cache (measured on threaded requests,
23// where the server keeps the conversation), so refreshing would only add a write. Never runs headless.
24//
25// Visible without debug mode: the status line under the prompt (last refresh, retrying, paused),
26// /keepwarm status, and ~/.claude/state/keepwarm/<session id>.json (rewritten at session start, on every
27// refresh, failure, pause and /keepwarm command, and every 5 min while the session works).
28// Env: KEEPWARM_OFF (any value) disables; KEEPWARM_IDLE_MIN (57), KEEPWARM_TTL_MIN (60),
29// KEEPWARM_MAX_HOURS (20), KEEPWARM_PROMPT (the ping text) override the defaults.
30
31import type { EngineInterface, HookFailure, Register } from 'claude-code'
32
33import type { KeepwarmBeat, KeepwarmFailure, KeepwarmSession } from '../types'
34
35type Engine = EngineInterface
36
37const SESSION = { plugin: 'keepwarm', key: 'session' } as const
38const MIN = 60_000
39const VERSION = '0.3.1'
40
41export const PING = 'Cache keep-alive ping, not a task. Reply with exactly: .'
42export const DEFAULTS = { idleMin: 57, ttlMin: 60, maxHours: 20, tickMs: 30_000, deadRead: 1_000, keptBeats: 50 }
43
44export type Config = { idleMs: number; ttlMs: number; maxMs: number; prompt: string; isOff: boolean }
45export type Due = { kind: 'wait' } | { kind: 'beat' } | { kind: 'stop'; why: string }
46
47function positive(raw: string | undefined, fallback: number): number {
48  const n = raw === undefined ? Number.NaN : Number(raw)
49  return Number.isFinite(n) && n > 0 ? n : fallback
50}
51
52export async function config($: Engine): Promise<Config> {
53  const off = await $.env.get('KEEPWARM_OFF')
54  const prompt = await $.env.get('KEEPWARM_PROMPT')
55  return {
56    idleMs: positive(await $.env.get('KEEPWARM_IDLE_MIN'), DEFAULTS.idleMin) * MIN,
57    ttlMs: positive(await $.env.get('KEEPWARM_TTL_MIN'), DEFAULTS.ttlMin) * MIN,
58    maxMs: positive(await $.env.get('KEEPWARM_MAX_HOURS'), DEFAULTS.maxHours) * 60 * MIN,
59    prompt: prompt !== undefined && prompt !== '' ? prompt : PING,
60    isOff: off !== undefined && off !== '',
61  }
62}
63
64/** A new session's state; also the defaults a state written by an older version is filled from. */
65export function fresh(now: number, isInteractive: boolean): KeepwarmSession {
66  return {
67    isEnabled: true,
68    isInteractive,
69    lastRequestAt: now,
70    lastPromptAt: now,
71    stoppedBecause: '',
72    offBecause: '',
73    beats: [],
74    failStreak: 0,
75    lastFailure: null,
76  }
77}
78
79/**
80 * The state session.start keeps: a new session's, or the held one with any field an older version of
81 * this mod did not write filled in (a hot reload keeps $.state). Pure, for the tests.
82 */
83export function upgrade(held: Partial<KeepwarmSession> | undefined, now: number, isInteractive: boolean): KeepwarmSession {
84  return { ...fresh(now, isInteractive), ...held, isInteractive }
85}
86
87/** "API error 529 overloaded", "aborted". Pure, for the tests. */
88export function describeFailure(f: KeepwarmFailure): string {
89  if (f.reason !== 'api-error') return f.reason
90  return `API error ${f.status ?? '(no response)'}${f.error !== '' ? ` ${f.error}` : ''}`
91}
92
93/** What the tick should do now. Pure, for the tests. */
94export function decide(s: KeepwarmSession, now: number, cfg: Config): Due {
95  if (!s.isInteractive || !s.isEnabled || cfg.isOff || s.offBecause !== '' || s.stoppedBecause !== '') return { kind: 'wait' }
96  if (now - s.lastPromptAt >= cfg.maxMs) {
97    return { kind: 'stop', why: `no prompt from you for ${Math.round(cfg.maxMs / (60 * MIN))} h` }
98  }
99  const idle = now - s.lastRequestAt
100  if (idle < cfg.idleMs) return { kind: 'wait' }
101  if (idle >= cfg.ttlMs) {
102    const why =
103      s.failStreak > 0 && s.lastFailure !== null
104        ? `${s.failStreak} refresh${s.failStreak === 1 ? '' : 'es'} failed (last: ${describeFailure(s.lastFailure)}) and the cache lapsed`
105        : 'the cache had already lapsed before a refresh could run'
106    return { kind: 'stop', why }
107  }
108  return { kind: 'beat' }
109}
110
111async function load($: Engine): Promise<KeepwarmSession | undefined> {
112  return (await $.state.get(SESSION)).value
113}
114
115async function change($: Engine, fn: (s: KeepwarmSession) => KeepwarmSession): Promise<KeepwarmSession | undefined> {
116  const held = await $.state.get(SESSION)
117  if (held.value === undefined) return undefined
118  const next = fn(held.value)
119  await $.state.set(SESSION, next)
120  return next
121}
122
123// ---- visibility ---------------------------------------------------------------------------------
124
125function hhmm(ms: number): string {
126  const d = new Date(ms)
127  return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
128}
129
130function kTokens(n: number): string {
131  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
132}
133
134/**
135 * The status line under the prompt (the engine shows it after "keepwarm: "); undefined shows none.
136 * Pure, for the tests.
137 */
138export function statusBar(s: KeepwarmSession, cfg: Config): string | undefined {
139  if (!s.isInteractive) return undefined
140  if (cfg.isOff) return 'off (KEEPWARM_OFF)'
141  if (!s.isEnabled) return 'off'
142  if (s.offBecause !== '') return "off: this session's cache can't be refreshed"
143  if (s.stoppedBecause !== '') return `paused: ${s.stoppedBecause}`
144  if (s.failStreak > 0 && s.lastFailure !== null) {
145    return `retrying (${describeFailure(s.lastFailure)} at ${hhmm(s.lastFailure.at)})`
146  }
147  const last = s.beats.at(-1)
148  if (last === undefined) return 'on, no refresh yet'
149  return `last refresh ${hhmm(last.at)}, read ${kTokens(last.cacheRead)} cached`
150}
151
152let shownBar: string | undefined | null = null // null: nothing shown yet by this environment
153
154function showBar($: Engine, s: KeepwarmSession, cfg: Config): void {
155  const text = statusBar(s, cfg)
156  if (text === shownBar) return
157  shownBar = text
158  $.ui.status(text)
159}
160
161let sessionId = ''
162let statePath = ''
163let cwd = ''
164// the request time and clock time the state file was last written with, so a tick can bring a stale file
165// up to date (at most every REWRITE_MS) without a write on every prompt or tool call
166let persistedRequestAt = 0
167let persistedAt = 0
168const REWRITE_MS = 5 * MIN
169
170async function persist($: Engine, s: KeepwarmSession, cfg: Config, now: number): Promise<void> {
171  if (statePath === '') return
172  const record = {
173    version: VERSION,
174    sessionId,
175    cwd,
176    updatedAt: new Date(now).toISOString(),
177    status: statusLine(s, now, cfg),
178    nextRefreshAt:
179      s.stoppedBecause === '' && s.offBecause === '' && s.isEnabled && !cfg.isOff ? new Date(s.lastRequestAt + cfg.idleMs).toISOString() : null,
180    config: { idleMin: cfg.idleMs / MIN, ttlMin: cfg.ttlMs / MIN, maxHours: cfg.maxMs / (60 * MIN) },
181    lastRefresh: s.beats.at(-1) ?? null,
182    lastFailure: s.lastFailure,
183    session: { ...s, beats: s.beats.slice(-10) },
184  }
185  persistedRequestAt = s.lastRequestAt
186  persistedAt = now
187  await $.fs
188    .write(statePath, `${JSON.stringify(record, null, 2)}\n`)
189    .catch(err => $.ui.log(`keepwarm: state file not written: ${String(err)}`, { to: 'debug' }))
190}
191
192/** Status line + state file, after anything that changed what they show. */
193async function publish($: Engine, s: KeepwarmSession | undefined, cfg: Config, now: number): Promise<void> {
194  if (s === undefined) return
195  showBar($, s, cfg)
196  await persist($, s, cfg, now)
197}
198
199// ---- the tick -----------------------------------------------------------------------------------
200
201let isRefreshing = false
202
203async function tick($: Engine): Promise<void> {
204  if (isRefreshing) return
205  const s = await load($)
206  if (s === undefined) return
207  const cfg = await config($)
208  const now = await $.clock.now()
209  const due = decide(s, now, cfg)
210  if (due.kind === 'wait') {
211    // the session is working: keep the state file's last request and next refresh within REWRITE_MS of true
212    if (s.lastRequestAt !== persistedRequestAt && now - persistedAt >= REWRITE_MS) await persist($, s, cfg, now)
213    return
214  }
215  if (due.kind === 'stop') {
216    const after = await change($, x => ({ ...x, stoppedBecause: due.why }))
217    $.ui.log(`keepwarm: paused until your next prompt: ${due.why}`, { to: 'debug' })
218    await publish($, after, cfg, now)
219    return
220  }
221  isRefreshing = true
222  try {
223    const r = await $.model.fork({ prompt: cfg.prompt })
224    if (!r.isAnswered && r.reason === 'nothing-to-fork') {
225      const why = 'nothing to keep warm yet (this conversation has no reply)'
226      const after = await change($, x => ({ ...x, stoppedBecause: why }))
227      $.ui.log(`keepwarm: paused until your next prompt: ${why}`, { to: 'debug' })
228      await publish($, after, cfg, now)
229      return
230    }
231    if (!r.isAnswered && (r.reason === 'api-error' || r.reason === 'aborted')) {
232      // no reply, so no proof the cache was read: leave the idle clock alone and retry at the next tick
233      const failure: KeepwarmFailure =
234        r.reason === 'api-error'
235          ? { at: now, reason: r.reason, status: r.status, error: String(r.error) }
236          : { at: now, reason: r.reason, status: null, error: '' }
237      const after = await change($, x => ({ ...x, failStreak: x.failStreak + 1, lastFailure: failure }))
238      $.ui.log(
239        `keepwarm: refresh failed (${describeFailure(failure)}); try ${after?.failStreak ?? '?'}, retrying at the next tick`,
240        { to: 'debug' },
241      )
242      await publish($, after, cfg, now)
243      return
244    }
245    // answered, or a reply with no text: the request reached the API and its usage is the cache's
246    const usage = r.usage
247    const beat: KeepwarmBeat = {
248      at: now,
249      cacheRead: usage.cache_read_input_tokens,
250      input: usage.input_tokens,
251      cacheWrite: usage.cache_creation_input_tokens,
252      output: usage.output_tokens,
253    }
254    const hasLapsed = beat.cacheRead < DEFAULTS.deadRead
255    const after = await change($, x => afterRefresh(x, beat, now))
256    $.ui.log(
257      `keepwarm: refresh cache_read=${beat.cacheRead} input=${beat.input} cache_write=${beat.cacheWrite} ` +
258        `output=${beat.output} answered=${r.isAnswered}${cannotRead(beat) ? ' (could not reach the session cache: off for this session)' : hasLapsed ? ' (cache had lapsed: paused)' : ''}`,
259      { to: 'debug' },
260    )
261    await publish($, after, cfg, now)
262  } finally {
263    isRefreshing = false
264  }
265}
266
267/** The state after a refresh that reached the API (its request started at `sentAt`). Pure, for the tests. */
268export function afterRefresh(x: KeepwarmSession, beat: KeepwarmBeat, sentAt: number): KeepwarmSession {
269  return {
270    ...x,
271    // a prompt that entered while the fork ran is newer: keep it
272    lastRequestAt: Math.max(x.lastRequestAt, sentAt),
273    failStreak: 0,
274    beats: [...x.beats, beat].slice(-DEFAULTS.keptBeats),
275    ...(cannotRead(beat)
276      ? {
277          offBecause:
278            `a refresh inside the hour read ${beat.cacheRead.toLocaleString('en-US')} cached tokens and had to write ` +
279            `${beat.cacheWrite.toLocaleString('en-US')}: it could not reach this session's own cache, so refreshing only adds cost`,
280        }
281      : beat.cacheRead < DEFAULTS.deadRead && { stoppedBecause: 'a refresh found the cache already lapsed' }),
282  }
283}
284
285/**
286 * A refresh that wrote more of the conversation than it read did not find this session's cache. Every
287 * session shares a warm system-and-tools prefix (~26-29k tokens), so a miss reads that much and writes the
288 * rest; a refresh that found the session's cache writes only the last turn (0-1,300 tokens measured).
289 * Pure, for the tests.
290 */
291export function cannotRead(beat: KeepwarmBeat): boolean {
292  return beat.cacheWrite > beat.cacheRead
293}
294
295function ago(ms: number): string {
296  const m = Math.max(0, Math.round(ms / MIN))
297  return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${String(m % 60).padStart(2, '0')} min`
298}
299
300/** What /keepwarm status answers with. Pure, for the tests. */
301export function statusLine(s: KeepwarmSession, now: number, cfg: Config): string {
302  const read = s.beats.reduce((n, b) => n + b.cacheRead, 0)
303  const tally = `${s.beats.length} refresh${s.beats.length === 1 ? '' : 'es'} this session, ${read.toLocaleString('en-US')} cached tokens read`
304  const last = s.beats.at(-1)
305  const lastText =
306    last === undefined
307      ? 'No refresh yet'
308      : `Last refresh ${hhmm(last.at)} (${ago(now - last.at)} ago): read ${last.cacheRead.toLocaleString('en-US')} cached tokens, wrote ${last.cacheWrite.toLocaleString('en-US')}`
309  const failText =
310    s.failStreak > 0 && s.lastFailure !== null
311      ? ` Last failure ${hhmm(s.lastFailure.at)}: ${describeFailure(s.lastFailure)} (${s.failStreak} in a row; retrying every ${DEFAULTS.tickMs / 1000} s until ${hhmm(s.lastRequestAt + cfg.ttlMs)}).`
312      : ''
313  const facts = `${lastText}.${failText} ${tally}.`
314  if (!s.isInteractive) return 'off (not an interactive session).'
315  if (cfg.isOff) return `off (KEEPWARM_OFF is set). ${facts}`
316  if (!s.isEnabled) return `off for this session (/keepwarm on to resume). ${facts}`
317  if (s.offBecause !== '') return `off for this session: ${s.offBecause}. /keepwarm on to try again. ${facts}`
318  if (s.stoppedBecause !== '') return `paused until your next prompt (${s.stoppedBecause}). ${facts}`
319  const dueIn = s.lastRequestAt + cfg.idleMs - now
320  const next = dueIn > 0 ? `next refresh in ${ago(dueIn)} (${hhmm(s.lastRequestAt + cfg.idleMs)})` : 'refresh due now'
321  return `on. Last request ${ago(now - s.lastRequestAt)} ago, ${next}; stops ${ago(s.lastPromptAt + cfg.maxMs - now)} from now without a prompt. ${facts}`
322}
323
324let timer: { cancel: () => void } | undefined
325
326/**
327 * The bookkeeping after `next` in prompt.submit and tool.call must never stand in for the prompt or the
328 * tool. The engine already skips a failed hook and keeps what `next` settled to, but reports the failure
329 * on every prompt or tool call; the .catch on each logs it to the debug log instead and passes `next` on.
330 */
331function logSkipped($: Engine, hook: string, error: HookFailure): void {
332  // a re-entry ran nothing of ours, and its $ calls reject
333  if (error.kind === 're-entry') return
334  const why = error.message !== undefined ? `${error.kind}: ${error.message}` : error.kind
335  $.ui.log(`keepwarm: ${hook} bookkeeping skipped (${why})`, { to: 'debug' })
336}
337
338export const register: Register = on => {
339  on('session.start', async ($, e, next) => {
340    const now = await $.clock.now()
341    const held = await load($)
342    const s = upgrade(held, now, e.isInteractive)
343    await $.state.set(SESSION, s)
344    if (e.isInteractive) {
345      await $.command
346        .register({ name: 'keepwarm', description: "Keep this idle session's prompt cache warm: /keepwarm on | off | status" })
347        .catch(err => $.ui.log(`keepwarm: command not registered: ${String(err)}`, { to: 'debug' }))
348      timer?.cancel()
349      timer = $.clock.every(DEFAULTS.tickMs, () => {
350        void tick($).catch(err => $.ui.log(`keepwarm: tick failed: ${String(err)}`, { to: 'debug' }))
351      })
352      try {
353        cwd = e.cwd
354        sessionId = await $.session.id()
355        const home = await $.env.get('HOME')
356        statePath = home !== undefined && sessionId !== '' ? `${home}/.claude/state/keepwarm/${sessionId}.json` : ''
357      } catch (err) {
358        $.ui.log(`keepwarm: no state file: ${String(err)}`, { to: 'debug' })
359      }
360      shownBar = null
361      await publish($, s, await config($), now)
362    }
363    return next(e)
364  })
365
366  on('prompt.submit', async ($, e, next) => {
367    const r = await next(e)
368    if (r.drop === undefined) {
369      const now = await $.clock.now()
370      const isPerson = e.origin === undefined || e.origin.kind === 'composer'
371      // any prompt that enters starts a request, which re-warms the cache, so a pause lifts
372      const before = await load($)
373      const after = await change($, s => ({
374        ...s,
375        lastRequestAt: now,
376        stoppedBecause: '',
377        failStreak: 0,
378        ...(isPerson && { lastPromptAt: now }),
379      }))
380      if (after !== undefined && before !== undefined && (before.stoppedBecause !== '' || before.failStreak > 0)) {
381        await publish($, after, await config($), now)
382      }
383    }
384    return r
385  }).catch(($, e, next) => {
386    logSkipped($, 'prompt.submit', next.error)
387    return next(e)
388  })
389
390  on('tool.call', async ($, e, next) => {
391    const ran = await next(e)
392    // a subagent's requests carry its own transcript and never refresh the main thread's cache
393    if (e.agentId === undefined) {
394      const now = await $.clock.now()
395      await change($, s => ({ ...s, lastRequestAt: now }))
396    }
397    return ran
398  }).catch(($, e, next) => {
399    logSkipped($, 'tool.call', next.error)
400    return next(e)
401  })
402
403  on('command.run', { command: 'keepwarm' }, async ($, e) => {
404    const s = await load($)
405    if (s === undefined) return { text: 'not active in this session.' }
406    const word = e.args.trim().toLowerCase()
407    const now = await $.clock.now()
408    const cfg = await config($)
409    if (word === 'off') {
410      await publish($, await change($, x => ({ ...x, isEnabled: false })), cfg, now)
411      return { text: 'off for this session.' }
412    }
413    if (word === 'on') {
414      await publish($, await change($, x => ({ ...x, isEnabled: true, stoppedBecause: '', offBecause: '' })), cfg, now)
415      return { text: 'on for this session.' }
416    }
417    const where = statePath !== '' ? ` State file: ${statePath}` : ''
418    return { text: `${statusLine(s, now, cfg)}${where}` }
419  })
420}
421
types/index.d.ts 55 lines
1// Session-scoped state of the keepwarm mod (survives hot reloads, not sessions).
2export type KeepwarmBeat = {
3  /** Epoch ms when the refresh request was sent. */
4  at: number
5  /** Prompt-cache tokens the fork read: the transcript the cache still held. */
6  cacheRead: number
7  /** Uncached input, cache writes and generated tokens of the fork. */
8  input: number
9  cacheWrite: number
10  output: number
11}
12
13/** A refresh that reached no reply: an API error (with its HTTP status and kind) or an abort. */
14export type KeepwarmFailure = {
15  /** Epoch ms when the failed refresh was sent. */
16  at: number
17  reason: string
18  /** HTTP status of an API error; null when no response arrived or not an API error. */
19  status: number | null
20  /** Claude Code's word for the API error (`overloaded`, `rate_limit`, ...); '' when not an API error. */
21  error: string
22}
23
24export type KeepwarmSession = {
25  /** /keepwarm off sets false for this session; default true. */
26  isEnabled: boolean
27  /** session.start's isInteractive; headless sessions never refresh. */
28  isInteractive: boolean
29  /** Epoch ms when the main thread last SENT a model request (prompt entered, a main tool result returned, or a refresh that reached the API). */
30  lastRequestAt: number
31  /** Epoch ms of the person's last own prompt; the 20 h deadline counts from here. */
32  lastPromptAt: number
33  /** Why refreshing stopped until the next prompt, or '' while active. */
34  stoppedBecause: string
35  /**
36   * Why keepwarm turned itself off for the rest of this session, or ''. Set when a refresh inside the hour
37   * had to write more of the conversation than it could read: the session's own cache is not one a fork
38   * can reach (seen on threaded requests), so refreshing only adds cost. /keepwarm on clears it.
39   */
40  offBecause: string
41  beats: KeepwarmBeat[]
42  /** Failed refreshes since the last request that reached the API; each is retried at the next tick. */
43  failStreak: number
44  /** The most recent failed refresh, kept for /keepwarm status. */
45  lastFailure: KeepwarmFailure | null
46}
47
48declare module 'claude-code' {
49  interface PluginState {
50    keepwarm: {
51      session: KeepwarmSession
52    }
53  }
54}
55