SLOPSHOPPER

cache-keeper

Keeps an idle session's prompt cache warm for as long as you choose, then compacts or hands off before it expires; holds a costly cold send with its price and…

newpanebandcommandstatusprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-keeper
│ ┃ Cache keeper ✕ › fix the failing auth test and add an audit log call │ ┃ ● cache warm · 1h left │ ┃ 97k tokens on claude-opus-5-5 · a cold send ⏺ Read(src/auth.ts) │ ┃ $0.78, a warm one $0.02 ⎿ Read 6 lines │ ┃ Keep warm for ⏺ Update(src/auth.ts) │ ┃ [ Off ] [ 1h ] [ 2h ] [ 4h ] [ 8h ] ⎿ Added 2 lines, removed 1 line │ ┃ after Claude's last answer ⏺ Bash(bun test) │ ┃ Before it expires ⎿ 3 pass, 1 fail │ ┃ [ Let it expire ] [ Compact ] [ Hand off ] │ ┃ the next message re-writes the whole ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ conversation │ ┃ A cold send over 50k tokens ✻ Worked for 42s · done 4:20 PM │ ┃ [ Hold it ] [ Warn ] [ Off ] │ ┃ held above the prompt with its price and › /keepwarm │ ┃ your choices ⎿ cache-keeper: Cache keeper settings are open in the pane. │ ┃ │ ┃ [ Keep warm now ] │ ┃ This session │ ┃ No keep-warms yet │ ┃ No cold writes paid │ ┃ 40 keep-warms cost one cold write │ ┃ │ ┃ This session only · defaults in /config │ ⟨Claude Code's own drawing⟩ ● cache warm · 1h left ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ● cache warm · 1h left
Pane · Cache keeper
● cache warm · 1h left 97k tokens on claude-opus-5-5 · a cold send $0.78, a warm one $0.02 Keep warm for [ Off ] [ 1h ] [ 2h ] [ 4h ] [ 8h ] after Claude's last answer Before it expires [ Let it expire ] [ Compact ] [ Hand off ] the next message re-writes the whole conversation A cold send over 50k tokens [ Hold it ] [ Warn ] [ Off ] held above the prompt with its price and your choices [ Keep warm now ] This session No keep-warms yet No cold writes paid 40 keep-warms cost one cold write This session only · defaults in /config
README

cache-keeper

Keeps an idle session's prompt cache warm while you're away, for as long as you choose, then lets it expire, compacts it, or clears it with a handoff, before the cache goes cold. When a big conversation has gone cold anyway, it holds the message that would re-write it, shows what that costs, and lets you send it anyway, compact first, clear, or discard it.

/plugin install cache-keeper --marketplace bbarney7/claude-code-mods

Claude Code caches the conversation on Anthropic's side. A message within the cache's lifetime (an hour on Claude subscriptions) reads it for a small fraction of the input price (a twentieth on Opus 5.5, a tenth on most models). A message after it expires writes the whole conversation again at twice the input price. Come back from lunch to a 300k-token session and the first message is the most expensive one of the day.

Cache-keeper watches the clock. Shortly before the cache would expire, and while the session is idle, it asks Claude a one-word side question over the conversation. That request reads the cache, and a read restarts its lifetime.

Tested

One session per arm on Haiku 4.5, the second message sent 65 minutes after the first, on a 63k-token conversation:

Cache read on the 2nd messageCache written
No keep-warm16,532 (the shared system prompt only)50,632expired: the conversation was written again
One keep-warm at 50 minutes62,9313,953still warm

The keep-warm itself read 62,931 cached tokens and wrote 177.

Settings (/config)

SettingValuesDefault
Keep the cache warm while you're awayon / offon
Keep it warm forhours after Claude's last answer2
Before the cache finally expiresnothing (let it expire), compact (compact while the cache is still warm, so the next message re-caches only the summary), handoff (Claude writes a handoff, the conversation is cleared, and the handoff is kept for your next message)nothing
Cache lifetimeminutes: 60 on Claude subscriptions, 5 for most API-key sessions60
Act this many minutes before expiryminutes, at most half the lifetime5
Before a costly cold sendrefuse (hold it with its price and the choices), warn, offrefuse
Guard conversations of at leasttokens50,000
Show the cache row above the prompton / offon

With keep-warm on for 2 hours and compact, a session you walk away from stays warm for at least 2 hours, then compacts a few minutes before the cache would have expired. With keep-warm off, the expiry action runs before the first expiry.

/keepwarm

/keepwarm opens the cache keeper pane (beside the conversation on the desktop, in a pane in the terminal). It changes this session only:

  • Keep warm for: Off, 1h, 2h, 4h or 8h after Claude's last answer (a window set with /keepwarm 90m shows beside them).
  • Before it expires: Let it expire, Compact, or Hand off.
  • A cold send over 50k tokens: Hold it, Warn, or Off.
  • Keep warm now, while the cache is warm, and what it read and cost.
  • The cache's state, what a cold or warm send of it costs, this session's keep-warms, cold writes paid and the break-even.
  • Reset returns to your /config defaults.

With arguments it changes the same settings from the prompt and answers in text:

/keepwarm off              stop keeping this session warm
/keepwarm 4h               keep it warm for 4 hours
/keepwarm 90m handoff      keep it warm for 90 minutes, then clear with a handoff
/keepwarm now              keep-warm right away and show what it read from the cache
/keepwarm guard warn       show a cold send's price instead of stopping it once
/keepwarm status           the card below, as text (where no pane is drawn, as on a phone)

Above the prompt (terminal and desktop), one row shows the cache: ● cache warm · 42m left · kept warm 2× · last ping read 75k $0.02 · then compact, or ● cache cold · for 2h · the next send re-writes 330k ($2.64). With Show the cache row off, the status line shows 🔥 cache 42m · kept warm 2× instead.

/keepwarm status is the card: the context size and what a cold or warm send of it costs on your model, the keep-warms and their receipts, the break-even (how many keep-warms cost one cold write), and the cold writes this session has paid.

The cold-send guard

When the cache has expired and the conversation is at least 50,000 tokens, the next message re-writes all of it at the cache-write price. The guard holds that message in a card above the prompt (terminal and desktop):

╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ Message held: the cache went cold 37m ago. Sending it re-writes 547k tokens ≈ $4.37           │
│ (a warm cache would read it for $0.11).                                                       │
│ “this part of the script is kinda confusing”                                                  │
│ [ Send anyway · $4.37 ]  [ Compact, then send ]  [ Clear, keep draft ]  [ Discard ]           │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
  • Send anyway sends it as you wrote it and pays the write. A message with pasted images goes back in the prompt box instead, and Enter sends it.
  • Compact, then send compacts the conversation, then sends it; it writes the summary instead of the whole conversation.
  • Clear, keep draft clears the conversation and puts the message back in the box to edit.
  • Discard drops it; the next message is checked again.

In the terminal, click one, or ctrl+x tab then 1–4. Sending the same message again also goes through, which is how it works from your phone, where the card isn't drawn. /keepwarm guard warn shows the price and sends; /keepwarm guard off says nothing (or set Before a costly cold send in /config). The guard only stops what a person typed: background tasks, schedules, other sessions and -p runs pass. A resumed session is checked from its first message, from the age and size Claude Code reports on resume, and its first line says when it resumes cold.

After a cold write is paid, keep-warm holds that cache for at least 3 hours, so it isn't paid twice in a day (unless keep-warm is off).

A compaction, yours or the mod's, resets the guard: the next message writes the summary whatever the clock says, so it isn't held, and the row reads ● cache unknown · compacted · the next message caches the summary (10k) until Claude answers.

Prices

Dollar figures use Anthropic's list prices per model (Opus 5.5: $4 input, $0.20 cache reads, $8 for a 1-hour cache write; Fable 5.1, Opus 5, Sonnet 5.x, Sonnet 4.x and Haiku 4.5 have their own rows). A model without a row shows tokens without dollars. On a subscription the dollars are a yardstick, not your bill.

What it costs

A keep-warm reads the conversation from the cache (0.05× the input price on Opus 5.5, 0.1× on most models) plus a few output tokens. Letting a 1-hour cache expire means writing the conversation again at 2× the input price: 20 to 40 times as much. At Opus 5.5's API prices ($4 per million input tokens) a 300k-token session costs about $0.06 per keep-warm against $2.40 for the cold restart. On a subscription it is a small amount of usage instead.

Keep-warms are only worth it for a session you will come back to, which is why they stop after the hours you choose.

Limits

  • A keep-warm that finds the cache already gone stops. It reads the conversation and writes only its own few tokens when the cache is warm. If it reads nothing, or writes a tenth of what it read or more, something broke the cache, and keep-warm waits for your next message instead of paying that write again.
  • A resumed session can't be kept warm until its first message. There is no request of the new process to fork yet; the guard still knows when it goes cold.
  • It only counts requests it can see. A turn or a keep-warm restarts its timer. A side request another mod makes also refreshes the cache but isn't counted, so cache-keeper may keep-warm a little earlier than it needed to.
  • A sleeping computer misses its keep-warm. If the machine sleeps through the expiry, the cache expires and nothing happens; the status line shows it cold.
  • A model switch starts a new cache. After /model, the next keep-warm writes the conversation again for the new model.
  • A plugin reload forgets the clock until the next answer.

Credit

The cold-send guard, the stop rule for a keep-warm that finds the cache gone, the receipts, the break-even and the 3-hour hold after a cold write follow Karan Bansal's cache-tax (MIT), which does keep-warm with a guard. cache-keeper adds what happens when you stop paying to keep it warm: compact or hand off before it expires.

Development

claude --plugin-dir plugins/cache-keeper
claude plugin validate plugins/cache-keeper
claude plugin test plugins/cache-keeper
Source 1 files
hooks/register.tsx 848 lines
1import type { EngineInterface, ModelUsage, Register, RenderInput } from 'claude-code'
2
3const TICK_MS = 60_000
4const HOUR = 60 * 60_000
5// After a cold write was paid, keep the cache warm at least this long, so it isn't paid twice.
6const AFTER_COLD_WRITE_MS = 3 * HOUR
7const KEEP_WARM_PROMPT = 'Automated cache keep-alive from the cache-keeper mod; nothing needs doing. Reply with the single word: ok'
8const HANDOFF_PROMPT =
9  'This conversation is about to be cleared to save re-caching it, and you will continue the work in a fresh one. Write a handoff to yourself: ' +
10  'the goal, what is done, what is in progress, decisions made and why, open problems with exact errors and file paths, and the next step. ' +
11  'At most 300 words. Reply with the handoff only.'
12
13// ---------- settings ----------
14
15export type AtExpiry = 'nothing' | 'compact' | 'handoff'
16// refuse: drop a costly cold send once with its price, the resend goes through; warn: say the price and send.
17export type Guard = 'refuse' | 'warn' | 'off'
18export type Config = {
19  keepWarm: boolean
20  keepWarmHours: number
21  atExpiry: AtExpiry
22  cacheTtlMinutes: number
23  leadMinutes: number
24  coldGuard: Guard
25  guardMinTokens: number
26  showRow: boolean
27}
28export const DEFAULT_CONFIG: Config = {
29  keepWarm: true,
30  keepWarmHours: 2,
31  atExpiry: 'nothing',
32  cacheTtlMinutes: 60,
33  leadMinutes: 5,
34  coldGuard: 'refuse',
35  guardMinTokens: 50_000,
36  showRow: true,
37}
38
39export function readConfig(options: Readonly<Record<string, unknown>>): Config {
40  const num = (v: unknown, fallback: number) => (typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : fallback)
41  const ttl = Math.min(24 * 60, Math.max(1, num(options.cacheTtlMinutes, DEFAULT_CONFIG.cacheTtlMinutes)))
42  const atExpiry = options.atExpiry as AtExpiry
43  const guard = options.coldGuard as Guard
44  return {
45    keepWarm: typeof options.keepWarm === 'boolean' ? options.keepWarm : DEFAULT_CONFIG.keepWarm,
46    keepWarmHours: Math.min(24, num(options.keepWarmHours, DEFAULT_CONFIG.keepWarmHours)),
47    atExpiry: ['nothing', 'compact', 'handoff'].includes(atExpiry) ? atExpiry : DEFAULT_CONFIG.atExpiry,
48    cacheTtlMinutes: ttl,
49    leadMinutes: Math.min(ttl / 2, Math.max(0.5, num(options.leadMinutes, DEFAULT_CONFIG.leadMinutes))),
50    coldGuard: ['refuse', 'warn', 'off'].includes(guard) ? guard : DEFAULT_CONFIG.coldGuard,
51    guardMinTokens: num(options.guardMinTokens, DEFAULT_CONFIG.guardMinTokens),
52    showRow: typeof options.showRow === 'boolean' ? options.showRow : DEFAULT_CONFIG.showRow,
53  }
54}
55
56// ---------- prices ----------
57
58// $ per million tokens: input, cache read, output; Anthropic's list prices, October 2026. A cache
59// write costs twice the input price on the 1-hour cache and 1.25 times on the 5-minute one.
60// Longer family names first: a model id matches the first row it contains.
61const PRICES: [family: string, input: number, read: number, output: number][] = [
62  ['fable-5-1', 10, 0.25, 50],
63  ['mythos-5-1', 10, 0.25, 50],
64  ['fable-5', 10, 1, 50],
65  ['mythos-5', 10, 1, 50],
66  ['opus-5-5', 4, 0.2, 20],
67  ['opus-5', 5, 0.5, 25],
68  ['opus-4-1', 15, 1.5, 75],
69  ['opus-4-2025', 15, 1.5, 75],
70  ['opus-4', 5, 0.5, 25],
71  ['sonnet-5', 2, 0.2, 10],
72  ['sonnet-4', 3, 0.3, 15],
73  ['haiku-4', 1, 0.1, 5],
74]
75
76export type Price = { input: number; read: number; write: number; output: number }
77
78export function priceOf(model: string | undefined, ttlMinutes: number): Price | undefined {
79  const id = (model ?? '').toLowerCase().replace(/[\s.]+/g, '-')
80  const row = PRICES.find(([family]) => id.includes(family))
81  if (!row) return undefined
82  const [, input, read, output] = row
83  return { input, read, write: input * (ttlMinutes > 5 ? 2 : 1.25), output }
84}
85
86// What one keep-warm cost: the cache read, any write, its own uncached tokens and its answer.
87export function pingUsd(u: ModelUsage, price: Price): number {
88  return (u.cache_read_input_tokens * price.read + u.cache_creation_input_tokens * price.write + u.input_tokens * price.input + u.output_tokens * price.output) / 1e6
89}
90
91// How many keep-warms, at the read price alone, cost as much as one cold write: an upper bound.
92export function breakEven(price: Price): number {
93  return Math.floor(price.write / price.read)
94}
95
96export function usd(n: number | undefined): string {
97  if (n === undefined) return 'n/a'
98  if (n > 0 && n < 0.01) return '<$0.01'
99  return `$${n >= 100 ? n.toFixed(0) : n.toFixed(2)}`
100}
101
102export function tokens(n: number): string {
103  return n >= 1e6 ? `${(n / 1e6).toFixed(1)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
104}
105
106// ---------- the decision ----------
107
108// lastWarmAt: when the last main-thread request (a turn's step or a keep-warm) started; the cache's
109// lifetime counts from there. lastActiveAt: when Claude last answered; the keep-warm window counts
110// from it. holdUntil: keep warm at least until then (a cold write was just paid). stopped: why
111// keep-warms stopped until the next turn (one found the cache already gone).
112export type Clock = { lastWarmAt?: number; lastActiveAt?: number; busy: boolean; settled: boolean; holdUntil?: number; stopped?: string }
113export type Action = 'wait' | 'keep-warm' | 'compact' | 'handoff' | 'let-expire' | 'expired'
114
115export function decide(s: Clock, cfg: Config, now: number): Action {
116  if (s.lastWarmAt === undefined || s.busy || s.settled) return 'wait'
117  const expiresAt = s.lastWarmAt + cfg.cacheTtlMinutes * 60_000
118  // Past expiry already (the machine slept, say): nothing is left to save.
119  if (now >= expiresAt) return 'expired'
120  if (now < expiresAt - cfg.leadMinutes * 60_000) return 'wait'
121  // Keep warm while the window the person asked for reaches past this expiry.
122  const windowEnd = Math.max((s.lastActiveAt ?? s.lastWarmAt) + cfg.keepWarmHours * HOUR, s.holdUntil ?? 0)
123  if (cfg.keepWarm && !s.stopped && windowEnd > expiresAt) return 'keep-warm'
124  return cfg.atExpiry === 'nothing' ? 'let-expire' : cfg.atExpiry
125}
126
127export function isCold(s: Clock, cfg: Config, now: number): boolean {
128  return s.lastWarmAt !== undefined && now - s.lastWarmAt >= cfg.cacheTtlMinutes * 60_000
129}
130
131// ---------- the cold-send guard ----------
132
133// pass: send it; warn: send it and say the price; refuse: drop it once with the price; resend: the
134// person sent it again after a refusal, so it goes through.
135export type Verdict = 'pass' | 'warn' | 'refuse' | 'resend'
136
137export function judgeSend(s: Clock, cfg: Config, now: number, context: number, ackedAt: number | undefined): Verdict {
138  if (cfg.coldGuard === 'off' || !isCold(s, cfg, now) || context < cfg.guardMinTokens) return 'pass'
139  if (cfg.coldGuard === 'warn') return 'warn'
140  return ackedAt === s.lastWarmAt ? 'resend' : 'refuse'
141}
142
143export function coldText(s: Clock, cfg: Config, now: number, context: number, price: Price | undefined): string {
144  const ago = duration(now - (s.lastWarmAt ?? now) - cfg.cacheTtlMinutes * 60_000)
145  const rate = price ? ` at $${price.write}/MTok ≈ ${usd((context * price.write) / 1e6)}` : ''
146  const warm = price ? ` (a warm turn would have read it for ${usd((context * price.read) / 1e6)})` : ''
147  return `the prompt cache went cold ${ago} ago. Sending this re-writes up to ${context.toLocaleString('en-US')} tokens${rate}${warm}.`
148}
149
150// ---------- /keepwarm arguments ----------
151
152export type Patch = Partial<Pick<Config, 'keepWarm' | 'keepWarmHours' | 'atExpiry' | 'coldGuard'>>
153
154const USAGE = '/keepwarm [on|off] [<hours>h|<minutes>m] [nothing|compact|handoff] [guard refuse|warn|off] [now]'
155
156export function parseArgs(args: string): { patch: Patch; now: boolean } | { error: string } {
157  const patch: Patch = {}
158  let now = false
159  const words = args.trim().toLowerCase().split(/\s+/).filter(Boolean)
160  for (let i = 0; i < words.length; i += 1) {
161    const word = words[i] ?? ''
162    const hours = /^(\d+(?:\.\d+)?)h?$/.exec(word)
163    const minutes = /^(\d+)m$/.exec(word)
164    if (word === 'on' || word === 'off') patch.keepWarm = word === 'on'
165    else if (hours) Object.assign(patch, { keepWarm: true, keepWarmHours: Math.min(24, Number(hours[1])) })
166    else if (minutes) Object.assign(patch, { keepWarm: true, keepWarmHours: Number(minutes[1]) / 60 })
167    else if (word === 'compact' || word === 'handoff' || word === 'nothing') patch.atExpiry = word
168    else if (word === 'now') now = true
169    else if (word === 'status') continue
170    else if (word === 'guard') {
171      const mode = words[i + 1]
172      if (mode !== 'refuse' && mode !== 'warn' && mode !== 'off') return { error: `guard takes refuse, warn or off. Usage: ${USAGE}` }
173      patch.coldGuard = mode
174      i += 1
175    } else return { error: `Unknown option "${word}". Usage: ${USAGE}` }
176  }
177  return { patch, now }
178}
179
180// ---------- drawing ----------
181
182export function duration(ms: number): string {
183  const m = Math.max(0, Math.round(ms / 60_000))
184  if (m < 60) return `${m}m`
185  const h = Math.floor(m / 60)
186  return m % 60 ? `${h}h${String(m % 60).padStart(2, '0')}m` : `${h}h`
187}
188
189export type Outcome = 'expired' | 'let-expire' | 'compacted' | 'handed-off'
190
191export function statusText(s: Clock, cfg: Config, now: number, keeps: number, outcome?: Outcome): string | undefined {
192  if (outcome === 'compacted') return 'cache: compacted'
193  if (outcome === 'handed-off') return 'cache: cleared with a handoff'
194  if (s.lastWarmAt === undefined) return undefined
195  const left = s.lastWarmAt + cfg.cacheTtlMinutes * 60_000 - now
196  if (outcome === 'expired' || left <= 0) return '❄ cache cold'
197  const parts = [`🔥 cache ${duration(left)}`]
198  if (keeps > 0) parts.push(`kept warm ${keeps}×`)
199  if (s.stopped) parts.push('keep-warm stopped')
200  else if (!cfg.keepWarm || cfg.keepWarmHours === 0) parts.push('keep-warm off')
201  if (cfg.atExpiry !== 'nothing') parts.push(`then ${cfg.atExpiry}`)
202  return parts.join(' · ')
203}
204
205// The row above the prompt: the cache's state word and what is known about it.
206export type RowState = 'warm' | 'cold' | 'unknown'
207export function rowParts(
208  s: Clock,
209  cfg: Config,
210  now: number,
211  info: { keeps: number; lastPing?: { read: number; usd?: number }; context: number; price?: Price; outcome?: Outcome },
212): { state: RowState; text: string } | undefined {
213  if (info.outcome === 'compacted') return { state: 'unknown', text: `compacted · the next message caches the summary${info.context ? ` (${tokens(info.context)})` : ''}` }
214  if (info.outcome === 'handed-off') return { state: 'unknown', text: 'cleared with a handoff' }
215  if (s.lastWarmAt === undefined) return undefined
216  if (isCold(s, cfg, now) || info.outcome === 'expired') {
217    const cost = info.price && info.context ? ` · the next send re-writes ${tokens(info.context)} (${usd((info.context * info.price.write) / 1e6)})` : ''
218    return { state: 'cold', text: `for ${duration(now - s.lastWarmAt - cfg.cacheTtlMinutes * 60_000)}${cost}` }
219  }
220  const parts = [`${duration(s.lastWarmAt + cfg.cacheTtlMinutes * 60_000 - now)} left`]
221  if (info.keeps > 0) parts.push(`kept warm ${info.keeps}×`)
222  if (info.lastPing) parts.push(`last ping read ${tokens(info.lastPing.read)}${info.lastPing.usd !== undefined ? ` ${usd(info.lastPing.usd)}` : ''}`)
223  if (s.stopped) parts.push(`keep-warm stopped: ${s.stopped}`)
224  else if (!cfg.keepWarm || cfg.keepWarmHours === 0) parts.push('keep-warm off')
225  if (cfg.atExpiry !== 'nothing') parts.push(`then ${cfg.atExpiry}`)
226  return { state: 'warm', text: parts.join(' · ') }
227}
228
229// ---------- state ----------
230
231type Keep = { at: number; usage: ModelUsage; usd?: number; read: number }
232type ColdWrite = { at: number; tokens: number; usd?: number }
233
234let config: Config = DEFAULT_CONFIG
235let overrides: Patch = {}
236const clock: Clock = { busy: false, settled: false }
237let acting = false
238let outcome: Outcome | undefined
239let keeps: Keep[] = []
240let ticker: { cancel: () => void } | undefined
241// The main conversation: its model, the live context size, and the session's paid cold writes.
242let model: string | undefined
243let context = 0
244let coldWrites: ColdWrite[] = []
245// The lastWarmAt the guard already refused a send for; a send under the same clock goes through.
246let ackedAt: number | undefined
247let coldWritePending = false
248// The message the guard refused, held above the prompt with its choices until one is picked or
249// another message goes through. doing: the choice running (a compaction takes a minute or two);
250// note: why the last choice failed.
251type Held = { text: string; canResend: boolean; doing?: string; note?: string }
252let held: Held | undefined
253let compactions = 0
254// The settings pane: a keep-warm it started is running; what the last one said.
255let pinging = false
256let paneNote: string | undefined
257
258function effective(): Config {
259  return { ...config, ...overrides }
260}
261
262function price(): Price | undefined {
263  return priceOf(model, effective().cacheTtlMinutes)
264}
265
266// Redraw the row (or the status line where the row is off) and the settings pane.
267async function refresh($: EngineInterface) {
268  const cfg = effective()
269  $.ui.status(cfg.showRow ? undefined : statusText(clock, cfg, await $.clock.now(), keeps.length, outcome))
270  $.ui.invalidate('ui.render')
271}
272
273// ---------- actions ----------
274
275async function keepWarm($: EngineInterface): Promise<string> {
276  const reply = await $.model.fork({ prompt: KEEP_WARM_PROMPT })
277  const now = await $.clock.now()
278  // A resumed session has no request of this process to fork until its first turn; its clock (from
279  // the resume) still tells the guard when the cache goes cold.
280  if (!reply.isAnswered && reply.reason === 'nothing-to-fork') {
281    if (clock.lastWarmAt === undefined) return 'Nothing to keep warm yet.'
282    clock.stopped = 'nothing to fork before the first message of this session'
283    return `Keep-warm waits for your next message: ${clock.stopped}.`
284  }
285  if (!('usage' in reply)) return 'Keep-warm failed: no usage came back.'
286  const { cache_read_input_tokens: read, cache_creation_input_tokens: written } = reply.usage
287  const p = price()
288  const cost = p ? pingUsd(reply.usage, p) : undefined
289  // Any request that went out restarted the timer, even one whose reply came back empty.
290  clock.lastWarmAt = now
291  clock.settled = false
292  outcome = undefined
293  keeps.push({ at: now, usage: reply.usage, usd: cost, read })
294  const paid = cost !== undefined ? ` (${usd(cost)})` : ''
295  // A warm keep-warm reads the conversation and writes only its own few tokens. Writing a tenth of
296  // what it read or more means the cache was already gone; stop until the next turn rather than
297  // pay that write again on every keep-warm.
298  if (read === 0 || written >= 0.1 * read) {
299    clock.stopped = `it read ${tokens(read)} and wrote ${tokens(written)}${paid}: the cache was already gone`
300    return `Keep-warm stopped until your next message: ${clock.stopped}.`
301  }
302  return `Kept warm: read ${tokens(read)} cached tokens, wrote ${tokens(written)}${paid}.${reply.isAnswered ? '' : ` (${reply.reason})`}`
303}
304
305async function compact($: EngineInterface): Promise<string> {
306  const before = compactions
307  // Headless sessions (-p, the SDK) compact only through the /compact command for now.
308  const result: { skip?: string; tokensAfter?: number } = await $.session.compact().catch(async () => {
309    await $.command.run({ command: 'compact' })
310    return {}
311  })
312  if (result.skip) return `Compaction skipped: ${result.skip}`
313  // This plugin's session.compact hook may not see its own compaction: settle it here then.
314  if (compactions === before) afterCompaction(result.tokensAfter)
315  return 'Compacted the conversation before its cache expired.'
316}
317
318// A compaction replaces the conversation with its summary: the old cache has nothing left to save,
319// and the next message writes the summary whatever the clock says. The live usage still reads the
320// size before compaction until the next answer, so the size is the compaction's own count.
321function afterCompaction(tokensAfter: number | undefined) {
322  compactions += 1
323  context = tokensAfter ?? 0
324  ackedAt = undefined
325  held = undefined
326  outcome = 'compacted'
327  Object.assign(clock, { lastWarmAt: undefined, stopped: undefined, settled: false })
328}
329
330async function handoff($: EngineInterface): Promise<string> {
331  const reply = await $.model.fork({ prompt: HANDOFF_PROMPT })
332  if (!reply.isAnswered) return `Could not write a handoff (${reply.reason}); the conversation was left as it is.`
333  const text = reply.text.trim()
334  await $.command.run({ command: 'clear' })
335  outcome = 'handed-off'
336  clock.lastWarmAt = undefined
337  await $.session.append({
338    message: {
339      type: 'user',
340      content: [{ type: 'text', text: `The cache-keeper mod cleared the previous conversation while the user was away, before its prompt cache expired. This is the handoff you wrote before it was cleared:\n\n<handoff>\n${text}\n</handoff>` }],
341    },
342  })
343  await $.session.append({ message: { type: 'system', content: [{ type: 'text', text: `Cache keeper cleared the conversation before its cache expired. Claude has this handoff:\n\n${text}` }] } })
344  return 'Cleared the conversation with a handoff.'
345}
346
347// ---------- the held message ----------
348
349export type HeldChoice = 'send' | 'compact' | 'clear' | 'discard'
350
351// What the card above the prompt says of a held message: why it was held, what sending it costs.
352export function heldCard(
353  h: Pick<Held, 'text' | 'canResend' | 'doing' | 'note'>,
354  s: Clock,
355  cfg: Config,
356  now: number,
357  context: number,
358  price: Price | undefined,
359): { reason: string; preview: string; sendLabel: string; doing?: string; note?: string } {
360  const ago = s.lastWarmAt !== undefined ? ` ${duration(now - s.lastWarmAt - cfg.cacheTtlMinutes * 60_000)} ago` : ''
361  const cost = price ? usd((context * price.write) / 1e6) : undefined
362  const warm = price ? ` (a warm cache would read it for ${usd((context * price.read) / 1e6)})` : ''
363  const line = h.text.trim().split('\n')[0] ?? ''
364  return {
365    reason: `Message held: the cache went cold${ago}. Sending it re-writes ${tokens(context)} tokens${cost ? ` ≈ ${cost}` : ''}${warm}.`,
366    preview: line.length > 120 ? `${line.slice(0, 119)}…` : line,
367    sendLabel: h.canResend ? `Send anyway${cost ? ` · ${cost}` : ''}` : 'Put it back to send',
368    ...(h.doing ? { doing: h.doing } : {}),
369    ...(h.note ? { note: h.note } : {}),
370  }
371}
372
373// Takes the held text out of the prompt box, where a host that keeps a refused draft left it.
374async function takeDraft($: EngineInterface, text: string) {
375  const box = await $.prompt.read()
376  if (box.text.trim() === text.trim()) await $.prompt.fill({ text: '' })
377}
378
379async function chooseHeld($: EngineInterface, choice: HeldChoice) {
380  const h = held
381  if (!h || h.doing) return
382  try {
383    if (choice === 'discard') {
384      held = undefined
385      // The next message is guarded again.
386      ackedAt = undefined
387      await takeDraft($, h.text)
388    } else if (choice === 'send' && h.canResend) {
389      held = undefined
390      ackedAt = undefined
391      coldWritePending = true
392      await takeDraft($, h.text)
393      // This plugin's own guard never sees its own submission.
394      void $.prompt.submit({ text: h.text, asUser: true })
395    } else if (choice === 'send') {
396      // Images can't be submitted again from here: the text goes back in the box, and the guard
397      // lets the next Enter through.
398      held = undefined
399      const box = await $.prompt.read()
400      if (box.text.trim() !== h.text.trim()) await $.prompt.fill({ text: h.text })
401      $.ui.log('cache-keeper: your message is back in the prompt box; press Enter to send it (attach its images again if they are gone).')
402    } else if (choice === 'compact') {
403      held = { ...h, doing: 'Compacting… a minute or two for a long conversation.', note: undefined }
404      await refresh($)
405      const before = compactions
406      const said = await compact($)
407      if (compactions === before) {
408        held = { ...h, doing: undefined, note: said }
409      } else {
410        await takeDraft($, h.text)
411        void $.prompt.submit({ text: h.text, asUser: true })
412      }
413    } else {
414      held = { ...h, doing: 'Clearing…' }
415      await refresh($)
416      await $.command.run({ command: 'clear' })
417      held = undefined
418      await $.prompt.fill({ text: h.text })
419    }
420  } catch (error) {
421    held = { ...h, doing: undefined, note: `That failed: ${error instanceof Error ? error.message : String(error)}` }
422  }
423  await refresh($)
424}
425
426async function tick($: EngineInterface) {
427  const cfg = effective()
428  const now = await $.clock.now()
429  const action = acting ? 'wait' : decide(clock, cfg, now)
430  if (action !== 'wait') {
431    acting = true
432    try {
433      if (action === 'expired' || action === 'let-expire') {
434        clock.settled = true
435        outcome = action
436      } else {
437        const said = action === 'keep-warm' ? await keepWarm($) : action === 'compact' ? await compact($) : await handoff($)
438        if (action !== 'keep-warm') clock.settled = true
439        $.ui.log(`cache-keeper: ${said}`)
440      }
441    } catch (error) {
442      $.ui.log(`cache-keeper: ${action} failed: ${error instanceof Error ? error.message : String(error)}`)
443    } finally {
444      acting = false
445    }
446  }
447  await refresh($)
448}
449
450function report(now: number): string {
451  const cfg = effective()
452  const p = price()
453  const lines = [statusText(clock, cfg, now, keeps.length, outcome) ?? 'No conversation cached yet.']
454  if (context > 0) {
455    const costs = p ? `: a cold send re-writes it for about ${usd((context * p.write) / 1e6)}, a warm one reads it for ${usd((context * p.read) / 1e6)}` : ''
456    lines.push(`Context ${context.toLocaleString('en-US')} tokens on ${model ?? 'a model not seen yet'}${costs}.`)
457  }
458  lines.push(
459    `Keep warm: ${cfg.keepWarm && cfg.keepWarmHours > 0 ? `on, for ${duration(cfg.keepWarmHours * HOUR)} after Claude's last answer` : 'off'}${clock.holdUntil && clock.holdUntil > now && cfg.keepWarm ? ` (at least ${duration(clock.holdUntil - now)} more after a cold write)` : ''}. Before the cache finally expires: ${cfg.atExpiry}.`,
460  )
461  if (clock.stopped) lines.push(`Keep-warm stopped until your next message: ${clock.stopped}.`)
462  const guard = cfg.coldGuard === 'refuse' ? 'refuse once with the price; the resend goes through' : cfg.coldGuard === 'warn' ? 'show the price and send' : 'off'
463  lines.push(`Cold-send guard (contexts over ${tokens(cfg.guardMinTokens)}): ${guard}.`)
464  lines.push(`Cache lifetime ${cfg.cacheTtlMinutes}m; acting ${cfg.leadMinutes}m before expiry.`)
465  if (keeps.length) {
466    const read = keeps.reduce((sum, k) => sum + k.read, 0)
467    const spent = keeps.every(k => k.usd !== undefined) ? `, ${usd(keeps.reduce((sum, k) => sum + (k.usd ?? 0), 0))}` : ''
468    const last = keeps.at(-1)
469    lines.push(`Keep-warms this session: ${keeps.length}, reading ${tokens(read)} cached tokens in all${spent}; the last read ${tokens(last?.read ?? 0)}${last?.usd !== undefined ? ` for ${usd(last.usd)}` : ''}.`)
470  }
471  if (p) {
472    const pings = breakEven(p)
473    lines.push(`Break-even: up to ${pings} keep-warms cost one cold write, about ${duration(pings * (cfg.cacheTtlMinutes - cfg.leadMinutes) * 60_000)} of being away.`)
474  }
475  const paid = coldWrites.reduce((sum, w) => sum + (w.usd ?? 0), 0)
476  lines.push(`Cold writes paid this session: ${coldWrites.length}${coldWrites.length ? ` (${tokens(coldWrites.reduce((sum, w) => sum + w.tokens, 0))} tokens, ${usd(paid)})` : ''}.`)
477  return lines.join('\n')
478}
479
480// ---------- the settings pane ----------
481
482const PANE = 'cache-keeper'
483const KEEP_HOURS = [0, 1, 2, 4, 8]
484const EXPIRY_LABELS: Record<AtExpiry, [label: string, hint: string]> = {
485  nothing: ['Let it expire', 'the next message re-writes the whole conversation'],
486  compact: ['Compact', 'compacts while warm; the next message caches only the summary'],
487  handoff: ['Hand off', 'Claude writes a handoff, then the conversation is cleared'],
488}
489const GUARD_LABELS: Record<Guard, [label: string, hint: string]> = {
490  refuse: ['Hold it', 'held above the prompt with its price and your choices'],
491  warn: ['Warn', 'shows the price and sends'],
492  off: ['Off', 'sends without a word'],
493}
494
495// The keep-warm windows offered, with this session's own (from /keepwarm 90m, say) among them.
496export function keepChoices(cfg: Config): number[] {
497  const hours = cfg.keepWarm ? cfg.keepWarmHours : 0
498  return KEEP_HOURS.includes(hours) ? KEEP_HOURS : [...KEEP_HOURS, hours].sort((a, b) => a - b)
499}
500
501async function setFromPane($: EngineInterface, patch: Patch) {
502  overrides = { ...overrides, ...patch }
503  paneNote = undefined
504  await refresh($)
505}
506
507async function keepWarmFromPane($: EngineInterface) {
508  if (pinging || acting) return
509  pinging = acting = true
510  paneNote = undefined
511  await refresh($)
512  try {
513    paneNote = await keepWarm($)
514  } catch (error) {
515    paneNote = `Keep-warm failed: ${error instanceof Error ? error.message : String(error)}`
516  } finally {
517    pinging = acting = false
518  }
519  await refresh($)
520}
521
522// The pane /keepwarm opens: the cache now, this session's settings as buttons, and the receipts.
523// Its Buttons are the surface's own (a desktop's native ones); a press that crosses a redraw can be
524// lost, and this pane redraws about once a minute while idle.
525async function renderPane($: EngineInterface, e: RenderInput<'Pane'>) {
526  const { Box, Text, Button } = $.ui.resolve(e)
527  const cfg = effective()
528  const now = await $.clock.now()
529  const p = price()
530  const last = keeps.at(-1)
531  const row = rowParts(clock, cfg, now, { keeps: keeps.length, lastPing: last ? { read: last.read, usd: last.usd } : undefined, context, price: p, outcome })
532  const color = row?.state === 'warm' ? 'success' : row?.state === 'cold' ? 'warning' : 'subtle'
533  const hours = cfg.keepWarm ? cfg.keepWarmHours : 0
534  const changed = (Object.keys(overrides) as (keyof Patch)[]).some(k => overrides[k] !== config[k])
535  const chip = (key: string, label: string, on: boolean, patch: Patch) => (
536    <Button key={key} label={label} variant={on ? 'primary' : 'secondary'} onPress={() => void setFromPane($, patch)} />
537  )
538  const keepHint = [
539    hours > 0 ? "after Claude's last answer" : `the cache lapses ${duration(cfg.cacheTtlMinutes * 60_000)} after its last use`,
540    clock.holdUntil && clock.holdUntil > now && cfg.keepWarm ? `at least ${duration(clock.holdUntil - now)} more after a cold write` : undefined,
541  ].filter(Boolean)
542  const read = keeps.reduce((sum, k) => sum + k.read, 0)
543  const spent = keeps.length && keeps.every(k => k.usd !== undefined) ? ` · ${usd(keeps.reduce((sum, k) => sum + (k.usd ?? 0), 0))}` : ''
544  const paid = coldWrites.reduce((sum, w) => sum + (w.usd ?? 0), 0)
545  return (
546    <Box flexDirection="column">
547      <Box>
548        <Text color={color}>●</Text>
549        <Text dimColor> cache </Text>
550        <Text color={color} bold>
551          {row?.state ?? 'idle'}
552        </Text>
553        <Text dimColor>{` · ${row?.text ?? "nothing cached yet; the clock starts at Claude's first answer"}`}</Text>
554      </Box>
555      {context > 0 ? (
556        <Text dimColor>
557          {`${tokens(context)} tokens${model ? ` on ${model}` : ''}${p ? ` · a cold send ${usd((context * p.write) / 1e6)}, a warm one ${usd((context * p.read) / 1e6)}` : ''}`}
558        </Text>
559      ) : null}
560
561      <Text bold>{'\nKeep warm for'}</Text>
562      <Box gap={1} flexWrap="wrap">
563        {keepChoices(cfg).map(h => chip(`keep-${h}`, h === 0 ? 'Off' : duration(h * HOUR), h === hours, h === 0 ? { keepWarm: false } : { keepWarm: true, keepWarmHours: h }))}
564      </Box>
565      <Text dimColor>{keepHint.join(' · ')}</Text>
566      {clock.stopped ? <Text color="warning">{`Stopped until your next message: ${clock.stopped}.`}</Text> : null}
567
568      <Text bold>{'\nBefore it expires'}</Text>
569      <Box gap={1} flexWrap="wrap">
570        {(['nothing', 'compact', 'handoff'] as const).map(a => chip(`expiry-${a}`, EXPIRY_LABELS[a][0], cfg.atExpiry === a, { atExpiry: a }))}
571      </Box>
572      <Text dimColor>{EXPIRY_LABELS[cfg.atExpiry][1]}</Text>
573
574      <Text bold>{`\nA cold send over ${tokens(cfg.guardMinTokens)} tokens`}</Text>
575      <Box gap={1} flexWrap="wrap">
576        {(['refuse', 'warn', 'off'] as const).map(g => chip(`guard-${g}`, GUARD_LABELS[g][0], cfg.coldGuard === g, { coldGuard: g }))}
577      </Box>
578      <Text dimColor>{GUARD_LABELS[cfg.coldGuard][1]}</Text>
579
580      {row?.state === 'warm' || pinging ? (
581        <Box marginTop={1}>
582          {pinging ? <Text color="warning">Keeping warm…</Text> : <Button key="keep-now" label="Keep warm now" onPress={() => void keepWarmFromPane($)} />}
583        </Box>
584      ) : null}
585      {paneNote ? <Text dimColor>{paneNote}</Text> : null}
586
587      <Text bold>{'\nThis session'}</Text>
588      <Text dimColor>{keeps.length ? `${keeps.length} keep-warm${keeps.length === 1 ? '' : 's'} · read ${tokens(read)}${spent}` : 'No keep-warms yet'}</Text>
589      <Text dimColor>
590        {coldWrites.length
591          ? `${coldWrites.length} cold write${coldWrites.length === 1 ? '' : 's'} paid · ${tokens(coldWrites.reduce((sum, w) => sum + w.tokens, 0))} · ${usd(paid)}`
592          : 'No cold writes paid'}
593      </Text>
594      {p ? <Text dimColor>{`${breakEven(p)} keep-warms cost one cold write`}</Text> : null}
595
596      <Box marginTop={1} gap={1} alignItems="center">
597        <Text dimColor>This session only · defaults in /config</Text>
598        {changed ? (
599          <Button
600            key="reset"
601            label="Reset"
602            variant="secondary"
603            onPress={() => {
604              overrides = {}
605              void setFromPane($, {})
606            }}
607          />
608        ) : null}
609      </Box>
610    </Box>
611  )
612}
613
614function startTicker($: EngineInterface) {
615  if (ticker) return
616  ticker = $.clock.every(TICK_MS, () => void tick($))
617}
618
619// /clear starts a new conversation in the same process: nothing priced before it exists any more.
620function resetForClear() {
621  context = 0
622  coldWrites = []
623  ackedAt = undefined
624  coldWritePending = false
625  held = undefined
626  Object.assign(clock, { lastWarmAt: undefined, lastActiveAt: undefined, holdUntil: undefined, stopped: undefined, settled: false })
627}
628
629// Who is at this prompt: a person typing (in the terminal, the desktop app, on a phone), not a task,
630// a schedule, another session or a plugin. An SDK prompt counts only where a surface is attached
631// (the desktop app); a plain -p run has none, and nobody there could send it again.
632async function isPersonSending($: EngineInterface, kind: string | undefined): Promise<boolean> {
633  if (kind === undefined || kind === 'composer' || kind === 'bridge') return true
634  if (kind === 'sdk') return (await $.session.surfaces()).length > 0
635  return false
636}
637
638// ---------- registration ----------
639
640// Each command is also declared in commands/, so a host lists it as `/cache-keeper:<name>` before
641// any session exists (the desktop app's empty chat); the hooks answer both spellings.
642export const register: Register = (on, options) => {
643  config = readConfig(options)
644
645  on('session.start', async ($, e, next) => {
646    const started = await next(e)
647    await $.command
648      .register({
649        name: 'keepwarm',
650        description: "Cache keeper: open this session's cache settings, or change them with arguments",
651        argumentHint: '[on|off] [2h|90m] [nothing|compact|handoff] [guard refuse|warn|off] [now]',
652        immediate: true,
653      })
654      .catch(() => undefined)
655    const live = (await $.session.usage().catch(() => undefined))?.context.tokens
656    if (live) context = live
657    startTicker($)
658    await refresh($)
659    return started
660  })
661
662  // A resumed session says how long ago it last answered and how big it is, so the guard knows
663  // before the first message whether that message would re-write it.
664  on('classic.SessionStart', async ($, e, next) => {
665    const result = await next(e)
666    if (e.source === 'clear') {
667      resetForClear()
668      await refresh($)
669      return result
670    }
671    if (e.source !== 'resume' && e.source !== 'fork') return result
672    const now = await $.clock.now()
673    if (typeof e.context_tokens === 'number' && e.context_tokens > 0) context = e.context_tokens
674    if (typeof e.seconds_since_last_response === 'number') {
675      clock.lastWarmAt = now - e.seconds_since_last_response * 1000
676      clock.lastActiveAt = clock.lastWarmAt
677      clock.settled = isCold(clock, effective(), now)
678    }
679    model = typeof e.model === 'string' ? e.model : (model ?? (await $.session.model()))
680    if (e.prompt_cache_likely_expired === true && context >= effective().guardMinTokens) {
681      const cost = typeof e.estimated_cache_write_usd === 'number' ? e.estimated_cache_write_usd : price() ? (context * (price()?.write ?? 0)) / 1e6 : undefined
682      $.ui.log(`cache-keeper: resuming cold. The first message re-writes ${context.toLocaleString('en-US')} tokens, about ${usd(cost)}. /compact or /clear first if you only need the conclusions.`)
683    }
684    await refresh($)
685    return result
686  })
687
688  on('session.end', async ($, e, next) => {
689    if (e.reason !== 'clear') ticker?.cancel()
690    return next(e)
691  })
692
693  on('turn.start', async ($, e, next) => {
694    clock.busy = true
695    return next(e)
696  })
697
698  // The cache's lifetime counts from the start of each request: stamp every main-thread step.
699  on('turn.step', async function* ($, e, next) {
700    if (e.agentId === undefined) clock.lastWarmAt = await $.clock.now()
701    yield* next(e)
702  })
703
704  on('turn.complete', async ($, e, next) => {
705    const done = await next(e)
706    if (e.agentId !== undefined) return done
707    const now = await $.clock.now()
708    // A turn none of whose steps was stamped started its last request no earlier than its end.
709    if (clock.lastWarmAt === undefined || now - clock.lastWarmAt > e.durationMs) clock.lastWarmAt = now
710    Object.assign(clock, { busy: false, settled: false, lastActiveAt: now, stopped: undefined })
711    // The first turn after a compaction writes the summary: no cold write, whatever it wrote.
712    const afterCompact = outcome === 'compacted'
713    outcome = undefined
714    ackedAt = undefined
715    held = undefined
716    const u = e.usage
717    if (u) {
718      if (u.model) model = u.model
719      const before = context
720      const live = (await $.session.usage().catch(() => undefined))?.context.tokens
721      context = live && live > 0 ? live : u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens
722      const written = u.cache_creation_input_tokens
723      // A cold write: the guard let one through, or this turn wrote half of a big context again.
724      if (coldWritePending || (!afterCompact && before > 20_000 && written >= 0.5 * before)) {
725        const p = price()
726        const cost = p ? (written * p.write) / 1e6 : undefined
727        coldWrites.push({ at: now, tokens: written, usd: cost })
728        if (effective().keepWarm) {
729          clock.holdUntil = now + AFTER_COLD_WRITE_MS
730          $.ui.log(`cache-keeper: a cold write of ${tokens(written)} tokens was paid${cost !== undefined ? ` (${usd(cost)})` : ''}. Keeping the cache warm for at least ${duration(AFTER_COLD_WRITE_MS)} so it isn't paid again; /keepwarm off to stop.`)
731        }
732      }
733    }
734    coldWritePending = false
735    await refresh($)
736    return done
737  })
738
739  // A precompute installs nothing, and a skip leaves the conversation as it was.
740  on('session.compact', async ($, e, next) => {
741    const done = await next(e)
742    if (e.agentId === undefined && e.trigger !== 'precompute' && done.skip === undefined) {
743      afterCompaction(done.tokensAfter)
744      await refresh($)
745    }
746    return done
747  })
748
749  // The message that pays for a cold cache. Only whether it is a slash command is read of its text.
750  on('prompt.submit', async ($, e, next) => {
751    if (!(await isPersonSending($, e.origin?.kind)) || e.text.trimStart().startsWith('/')) return next(e)
752    const cfg = effective()
753    const now = await $.clock.now()
754    const verdict = judgeSend(clock, cfg, now, context, ackedAt)
755    if (verdict === 'pass') return next(e)
756    const said = coldText(clock, cfg, now, context, price())
757    const hold = cfg.keepWarm ? ` Keep-warm then holds the cache for ${duration(AFTER_COLD_WRITE_MS)}.` : ''
758    if (verdict === 'warn') {
759      $.ui.log(`cache-keeper: ${said} Sending it.${hold}`)
760      coldWritePending = true
761      return next(e)
762    }
763    if (verdict === 'resend') {
764      ackedAt = undefined
765      coldWritePending = true
766      held = undefined
767      await refresh($)
768      return next(e)
769    }
770    ackedAt = clock.lastWarmAt
771    held = { text: e.text, canResend: !e.attachments?.length }
772    await refresh($)
773    return { drop: `cache-keeper: ${said} Your message is held above the prompt: send it anyway, compact or clear first, or discard it. Sending it again also pays it.${hold}` }
774  })
775
776  // Bare, it opens the settings pane; with arguments (or `status`), it answers in text, which is
777  // also what a surface that places no pane (a phone, a -p run) gets.
778  on('command.run', { command: ['keepwarm', 'cache-keeper:keepwarm'] }, async ($, e) => {
779    if (!e.args.trim()) {
780      const opened = await $.ui.open({ id: PANE, title: 'Cache keeper' }).catch((error: unknown) => ({ isPlaced: false as const, reason: String(error) }))
781      if (opened.isPlaced) return { text: 'Cache keeper settings are open in the pane.' }
782      return { text: report(await $.clock.now()) }
783    }
784    const parsed = parseArgs(e.args)
785    if ('error' in parsed) return { text: parsed.error }
786    overrides = { ...overrides, ...parsed.patch }
787    let said = ''
788    if (parsed.now) said = `${await keepWarm($)}\n`
789    const now = await $.clock.now()
790    await refresh($)
791    return { text: `${said}${report(now)}` }
792  })
793
794  // Above the prompt: one row with the cache's state, its time left, the keep-warms and their cost;
795  // and while the guard holds a message, a card with its choices.
796  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
797    const rest = await next(e)
798    const cfg = effective()
799    if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) return rest
800    const now = await $.clock.now()
801    const last = keeps.at(-1)
802    const row = cfg.showRow
803      ? rowParts(clock, cfg, now, { keeps: keeps.length, lastPing: last ? { read: last.read, usd: last.usd } : undefined, context, price: price(), outcome })
804      : undefined
805    const card = held ? heldCard(held, clock, cfg, now, context, price()) : undefined
806    if (!row && !card) return rest
807    const { Box, Text, Button } = $.ui.resolve(e)
808    const color = row?.state === 'warm' ? 'success' : row?.state === 'cold' ? 'warning' : 'subtle'
809    return (
810      <Box flexDirection="column">
811        {rest}
812        {row ? (
813          <Box>
814            <Text color={color}>●</Text>
815            <Text dimColor> cache </Text>
816            <Text color={color} bold>
817              {row.state}
818            </Text>
819            <Text dimColor>{` · ${row.text}`}</Text>
820          </Box>
821        ) : null}
822        {card ? (
823          <Box flexDirection="column" width="100%" borderStyle="round" borderColor="warning" paddingX={1}>
824            <Text>{card.reason}</Text>
825            <Text dimColor wrap="truncate-end">{`“${card.preview}”`}</Text>
826            {card.note ? <Text color="error">{card.note}</Text> : null}
827            {card.doing ? (
828              <Text color="warning">{card.doing}</Text>
829            ) : (
830              <Box flexDirection="column">
831                <Box gap={1} flexWrap="wrap">
832                  <Button key="held-send" hotkey="1" label={card.sendLabel} variant="primary" onPress={() => chooseHeld($, 'send')} />
833                  <Button key="held-compact" hotkey="2" label="Compact, then send" onPress={() => chooseHeld($, 'compact')} />
834                  <Button key="held-clear" hotkey="3" label="Clear, keep draft" onPress={() => chooseHeld($, 'clear')} />
835                  <Button key="held-discard" hotkey="4" label="Discard" dimColor onPress={() => chooseHeld($, 'discard')} />
836                </Box>
837                {e.surface === 'terminal' ? <Text dimColor>click one, or ctrl+x tab then 1–4</Text> : null}
838              </Box>
839            )}
840          </Box>
841        ) : null}
842      </Box>
843    )
844  })
845
846  on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => renderPane($, e))
847}
848