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…

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.
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 message | Cache written | ||
|---|---|---|---|
| No keep-warm | 16,532 (the shared system prompt only) | 50,632 | expired: the conversation was written again |
| One keep-warm at 50 minutes | 62,931 | 3,953 | still warm |
The keep-warm itself read 62,931 cached tokens and wrote 177.
/config)| Setting | Values | Default |
|---|---|---|
| Keep the cache warm while you're away | on / off | on |
| Keep it warm for | hours after Claude's last answer | 2 |
| Before the cache finally expires | nothing (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 lifetime | minutes: 60 on Claude subscriptions, 5 for most API-key sessions | 60 |
| Act this many minutes before expiry | minutes, at most half the lifetime | 5 |
| Before a costly cold send | refuse (hold it with its price and the choices), warn, off | refuse |
| Guard conversations of at least | tokens | 50,000 |
| Show the cache row above the prompt | on / off | on |
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:
/keepwarm 90m shows beside them)./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.
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 ] │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
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.
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.
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.
/model, the next keep-warm writes the conversation again for the new model.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.
claude --plugin-dir plugins/cache-keeper
claude plugin validate plugins/cache-keeper
claude plugin test plugins/cache-keeperhooks/register.tsx 848 lines1import 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