Zeigt über dem Eingabefeld, wie lange der Prompt-Cache noch warm ist, mit Kontext, Limits und Kosten, und hält ihn auf Wunsch in Pausen warm.

Ein Mod für Claude Code, der dir über dem Eingabefeld zeigt, ob der Prompt-Cache deiner Session noch warm ist, und ihn auf Wunsch in Pausen automatisch warm hält.
Nach jeder Antwort hält der Prompt-Cache bei Claude-Abos 60 Minuten (per API 5 Minuten). Solange er warm ist, wird der bisherige Kontext für etwa 10 % des Preises gelesen. Ist er kalt, kostet der nächste Prompt den vollen Preis für den ganzen Kontext. Bei langen Sessions mit großem Kontext lohnt es sich also, das Fenster nicht ungenutzt ablaufen zu lassen.
Eine Zeile über dem Eingabefeld, von links nach rechts:
● Cache 42 min │ Kontext ▰▰▰▰▰▰ 60% 120k/200k │ 5h ▰▰▰▰▰▰ 34% ↻1:52 h │ Woche 12% │ 1,24 $ [ Warmhalten: aus ] [ Komprimieren ]
Balken bestehen aus gleich großen Feldern: die vollen in der Ampelfarbe, die leeren abgeblendet.
/compact aus), sobald der Kontext mindestens halb voll ist oder weniger als 10 Minuten bleiben.Die Werte werden beim Start und nach jeder Antwort neu eingelesen. Wird das Fenster schmaler, wird die Zeile knapper statt umzubrechen: erst ohne Tokenzahlen und mit kurzen Knöpfen (Warm: aus, /compact), dann ohne Balken (● 42 min │ Ktx 60% │ 5h 34% ↻1:52 h │ Wo 12% │ 1,24 $). Erst wenn auch das nicht passt, teilt sie sich auf zwei Zeilen auf, nie auf mehr. Zeilen anderer Mods über dem Eingabefeld bleiben stehen, diese kommt darunter; läuft dort gerade eine Umfrage, zeichnet der Mod nichts. Er funktioniert im Terminal und im Code-Tab der Desktop-App.
Warnung vor Ablauf (nur bei ausgeschaltetem Warmhalten): 5 Minuten vor Ablauf, einmal je Cache-Fenster, ein kurzer Zweiklang, eine Einblendung und eine rote Zeile über der Statuszeile mit zwei Knöpfen:
/compact aus.Automatisch warm halten (Schalter Warmhalten: an/aus, sichtbar ab der ersten Antwort): 3 Minuten vor Ablauf schickt der Mod selbst einen Mini-Prompt, der Claude bittet, nur mit „ok“ zu antworten. Dabei gilt:
Der Zustand wird je Session gemerkt und übersteht ein Neuladen des Mods und das Fortsetzen der Session, solange die Höchstdauer nicht um ist. Antworten von Subagenten zählen nicht als letzte Antwort. Wenn das Band über dem Eingabefeld den Fokus hat (Klick oder ctrl+x tab), drücken w, p und k die Knöpfe.
Im Terminal in einer Claude-Code-Session:
/plugin marketplace add HitEmUp/keepcachewarm
/plugin install keepcachewarm@keepcachewarm
oder in einem Schritt:
/plugin install keepcachewarm --marketplace HitEmUp/keepcachewarm
Die Frage nach dem Marktplatz mit y bestätigen und den Bereich wählen (Vorgabe: für dich, in allen Projekten). Der Mod läuft danach sofort. Im Code-Tab der Desktop-App gibt es den Befehl nicht: einmal im Terminal für dich installieren, dann lädt der Mod auch dort.
Alle Werte stehen in /config unter keepcachewarm:
| Einstellung | Vorgabe | Bedeutung |
|---|---|---|
| Cache-Dauer (Minuten) | 60 | Wie lange der Cache nach der letzten Antwort hält; per API 5. |
| Ping-Vorlauf (Minuten) | 3 | So lange vor Ablauf pingt das automatische Warmhalten. |
| Höchstdauer Warmhalten (Stunden) | 4 | Danach schaltet sich das Warmhalten ab. |
| Limit-Grenze (%) | 90 | Ab diesem Stand des 5-Stunden-Limits schaltet es sich ab. |
| Warnvorlauf (Minuten) | 5 | So lange vor Ablauf warnen Ton, Einblendung und rote Zeile. |
Die Vorgaben stehen auch als Konstanten oben in hooks/register.tsx.
hooks/register.tsx Hooks, Zeichnung, Zustand
hooks/logic.ts Entscheidungen und Texte, ohne Engine-Zugriff
types/index.d.ts Vertrag aller Zustandswerte
sounds/warnung.wav Warnton, erzeugt von scripts/ton-erzeugen.py
tests/ Tests für `claude plugin test`
Prüfen und testen (Claude Code 2.1.293 oder neuer):
claude plugin validate .
claude plugin test .
Den Mod aus dem Arbeitsordner in einer Session laden (Änderungen werden beim Speichern neu geladen):
claude --plugin-dir .
Nach dem ersten Laden legt Claude Code die Typen unter .claude-plugin/types/ ab (nicht im Repo); dann prüft npx tsc -p . die Typen. Den Warnton neu erzeugen:
python3 scripts/ton-erzeugen.py
MIT, siehe LICENSE.
hooks/register.tsx 471 lines1import type { EngineInterface, Register, RenderElement, RenderSurface, Timer } from 'claude-code'
2
3import type { Auto, OffReason, SavedSession, Usage } from '../types'
4import {
5 isWarningVisible,
6 nextStep,
7 offToast,
8 readSettings,
9 remainingMs,
10 statusLayout,
11 toUsage,
12 warningLayout,
13 warningToast,
14} from './logic'
15import type { Control, Part, Segment, Settings, Snapshot, Tone } from './logic'
16
17// Vorgaben der Einstellungen. Ändern lassen sie sich in /config (userConfig des
18// Mods); diese Werte gelten, solange dort nichts eingestellt ist.
19const DEFAULTS = {
20 cacheMinutes: 60, // Cache-Dauer: 60 bei Claude-Abos, 5 per API
21 pingLeadMinutes: 3, // Vorlauf des automatischen Pings vor Ablauf
22 maxHours: 4, // Höchstdauer des Warmhaltens ab dem Einschalten
23 limitPercent: 90, // ab diesem Stand des 5-Stunden-Limits schaltet es sich aus
24 warnLeadMinutes: 5, // Vorlauf der Warnung vor Ablauf
25}
26
27const TICK_MS = 15_000
28const PING_PROMPT =
29 'Cache-Ping von keepcachewarm: Bitte bestätige nur mit dem Wort „ok“ und tu sonst nichts.'
30const SOUND = 'sounds/warnung.wav'
31const STORE_PREFIX = 'session:'
32const STORE_KEEP = 30
33const AUTO_OFF: Auto = { isOn: false, since: null, offReason: null }
34
35const NOW = { plugin: 'keepcachewarm', key: 'now' } as const
36const SESSION_ID = { plugin: 'keepcachewarm', key: 'sessionId' } as const
37const IS_WORKING = { plugin: 'keepcachewarm', key: 'isWorking' } as const
38const LAST_ANSWER_AT = { plugin: 'keepcachewarm', key: 'lastAnswerAt' } as const
39const PINGED_FOR = { plugin: 'keepcachewarm', key: 'pingedFor' } as const
40const WARNED_FOR = { plugin: 'keepcachewarm', key: 'warnedFor' } as const
41const IS_PING_WAITING = { plugin: 'keepcachewarm', key: 'isPingWaiting' } as const
42const AUTO = { plugin: 'keepcachewarm', key: 'auto' } as const
43const USAGE = { plugin: 'keepcachewarm', key: 'usage' } as const
44
45type Engine = EngineInterface
46
47/** Was der Mod über die laufende Session weiß; `$.state` spiegelt es fürs Zeichnen. */
48type Model = {
49 sessionId: string | null
50 isWorking: boolean
51 lastAnswerAt: number | null
52 pingedFor: number | null
53 warnedFor: number | null
54 isPingWaiting: boolean
55 auto: Auto
56 usage: Usage | null
57}
58
59const EMPTY: Model = {
60 sessionId: null,
61 isWorking: false,
62 lastAnswerAt: null,
63 pingedFor: null,
64 warnedFor: null,
65 isPingWaiting: false,
66 auto: AUTO_OFF,
67 usage: null,
68}
69
70// Was das Modul selbst hält; ein Neuladen beginnt hier neu, `$.state` behält den Rest.
71let cfg: Settings = readSettings({}, DEFAULTS)
72let model: Model | null = null
73let loading: Promise<Model> | null = null
74let evaluating: Promise<void> | null = null
75let isEvaluateAgain = false
76let clock: Timer | null = null
77// Ob im Eingabefeld Text steht, wie die Hinweiszeile unter dem Feld es je Oberfläche meldet.
78const drafts = new Map<RenderSurface, boolean>()
79
80// -------------------------------------------------------------------------
81// Zustand
82
83async function save($: Engine, patch: Partial<Model>): Promise<Model> {
84 const next: Model = { ...(model ?? EMPTY), ...patch }
85 model = next
86 if ('sessionId' in patch) await $.state.set(SESSION_ID, next.sessionId)
87 if ('isWorking' in patch) await $.state.set(IS_WORKING, next.isWorking)
88 if ('lastAnswerAt' in patch) await $.state.set(LAST_ANSWER_AT, next.lastAnswerAt)
89 if ('pingedFor' in patch) await $.state.set(PINGED_FOR, next.pingedFor)
90 if ('warnedFor' in patch) await $.state.set(WARNED_FOR, next.warnedFor)
91 if ('isPingWaiting' in patch) await $.state.set(IS_PING_WAITING, next.isPingWaiting)
92 if ('auto' in patch) await $.state.set(AUTO, next.auto)
93 if ('usage' in patch) await $.state.set(USAGE, next.usage)
94 return next
95}
96
97/** Nach einem Neuladen des Mods aus `$.state`, sonst aus dem Speicher der Session. */
98function ensureModel($: Engine): Promise<Model> {
99 if (model !== null) return Promise.resolve(model)
100 loading ??= (async () => {
101 const id = await $.session.id()
102 const { value: heldId = null } = await $.state.get(SESSION_ID)
103 if (heldId === id) {
104 model = {
105 sessionId: id,
106 isWorking: (await $.state.get(IS_WORKING)).value ?? false,
107 lastAnswerAt: (await $.state.get(LAST_ANSWER_AT)).value ?? null,
108 pingedFor: (await $.state.get(PINGED_FOR)).value ?? null,
109 warnedFor: (await $.state.get(WARNED_FOR)).value ?? null,
110 isPingWaiting: (await $.state.get(IS_PING_WAITING)).value ?? false,
111 auto: (await $.state.get(AUTO)).value ?? AUTO_OFF,
112 usage: (await $.state.get(USAGE)).value ?? null,
113 }
114 return model
115 }
116 return loadSession($, id)
117 })().finally(() => {
118 loading = null
119 })
120 return loading
121}
122
123async function loadSession($: Engine, id: string): Promise<Model> {
124 const saved = asSaved(await $.store.get(STORE_PREFIX + id))
125 const now = await $.clock.now()
126 let auto = saved?.auto ?? AUTO_OFF
127 // Das Warmhalten übersteht ein Fortsetzen nur, solange die Höchstdauer nicht um ist.
128 if (auto.isOn && (auto.since === null || now - auto.since >= cfg.maxOnMs)) {
129 auto = { isOn: false, since: null, offReason: 'max-duration' }
130 }
131 await save($, {
132 sessionId: id,
133 isWorking: model?.isWorking ?? false,
134 lastAnswerAt: saved?.lastAnswerAt ?? null,
135 pingedFor: saved?.pingedFor ?? null,
136 warnedFor: saved?.warnedFor ?? null,
137 isPingWaiting: false,
138 auto,
139 })
140 await refreshUsage($)
141 return model ?? EMPTY
142}
143
144async function persist($: Engine): Promise<void> {
145 if (model?.sessionId == null) return
146 const saved: SavedSession = {
147 lastAnswerAt: model.lastAnswerAt,
148 pingedFor: model.pingedFor,
149 warnedFor: model.warnedFor,
150 auto: model.auto,
151 savedAt: await $.clock.now(),
152 }
153 await $.store.set(STORE_PREFIX + model.sessionId, saved)
154
155 // Nur die jüngsten Sessions behalten, damit der Speicher nicht wächst.
156 const keys = (await $.store.keys()).filter(key => key.startsWith(STORE_PREFIX))
157 if (keys.length <= STORE_KEEP) return
158 const dated = await Promise.all(
159 keys.map(async key => ({ key, savedAt: asSaved(await $.store.get(key))?.savedAt ?? 0 })),
160 )
161 dated.sort((a, b) => a.savedAt - b.savedAt)
162 for (const { key } of dated.slice(0, dated.length - STORE_KEEP)) {
163 await $.store.delete(key)
164 }
165}
166
167async function refreshUsage($: Engine): Promise<void> {
168 try {
169 await save($, { usage: toUsage(await $.session.usage()) })
170 } catch {
171 // Ohne frische Werte bleiben die alten stehen.
172 }
173}
174
175/** Neue Session-ID (z. B. nach /resume): deren gemerkten Stand laden. */
176async function syncSession($: Engine): Promise<Model> {
177 const current = await ensureModel($)
178 const id = await $.session.id()
179 return current.sessionId === id ? current : loadSession($, id)
180}
181
182function snapshotOf(m: Model, now: number): Snapshot {
183 return {
184 now,
185 isWorking: m.isWorking,
186 lastAnswerAt: m.lastAnswerAt,
187 pingedFor: m.pingedFor,
188 warnedFor: m.warnedFor,
189 auto: m.auto,
190 usage: m.usage,
191 }
192}
193
194// -------------------------------------------------------------------------
195// Warnen, pingen, selbst abschalten
196
197/** Im Zweifel steht Text im Feld: dann wird nicht gepingt. */
198async function hasDraft($: Engine): Promise<boolean> {
199 try {
200 if ((await $.prompt.read()).text.trim() !== '') return true
201 const surfaces = await $.session.surfaces()
202 return surfaces.some(surface => drafts.get(surface) === true)
203 } catch {
204 return true
205 }
206}
207
208async function sendPing($: Engine, window: number | null): Promise<void> {
209 await save($, { pingedFor: window, isPingWaiting: false })
210 await persist($)
211 void $.prompt.submit({ text: PING_PROMPT }).catch(() => undefined)
212}
213
214async function switchOff($: Engine, reason: OffReason, s: Snapshot): Promise<void> {
215 const m = await save($, {
216 auto: { isOn: false, since: null, offReason: reason },
217 isPingWaiting: false,
218 })
219 await persist($)
220 $.ui.toast(offToast(reason, snapshotOf(m, s.now), cfg), { timeoutMs: 8000 })
221}
222
223async function warn($: Engine, s: Snapshot): Promise<void> {
224 await save($, { warnedFor: s.lastAnswerAt })
225 await persist($)
226 $.ui.toast(warningToast(remainingMs(s, cfg) ?? 0), { timeoutMs: 10_000 })
227 void $.audio.play({ asset: SOUND }).catch(() => undefined)
228}
229
230async function evaluateOnce($: Engine): Promise<void> {
231 let m = await ensureModel($)
232 const now = await $.clock.now()
233 // Nach einer Selbstabschaltung noch einmal fragen: dann kann die Warnung fällig sein.
234 for (let round = 0; round < 3; round += 1) {
235 const s = snapshotOf(m, now)
236 const step = nextStep(s, cfg)
237 if (step.kind === 'switch-off') {
238 await switchOff($, step.reason, s)
239 m = model ?? m
240 continue
241 }
242 if (step.kind === 'warn') {
243 await warn($, s)
244 } else if (step.kind === 'ping') {
245 if (await hasDraft($)) {
246 if (!m.isPingWaiting) await save($, { isPingWaiting: true })
247 } else {
248 await sendPing($, s.lastAnswerAt)
249 }
250 } else if (m.isPingWaiting) {
251 await save($, { isPingWaiting: false })
252 }
253 return
254 }
255}
256
257/** Eine Prüfung zur Zeit; was währenddessen angestoßen wird, läuft gleich danach. */
258function evaluate($: Engine): Promise<void> {
259 if (evaluating !== null) {
260 isEvaluateAgain = true
261 return evaluating
262 }
263 evaluating = (async () => {
264 do {
265 isEvaluateAgain = false
266 await evaluateOnce($)
267 } while (isEvaluateAgain)
268 })().finally(() => {
269 evaluating = null
270 })
271 return evaluating
272}
273
274async function tick($: Engine): Promise<void> {
275 await syncSession($)
276 await $.state.set(NOW, await $.clock.now())
277 await evaluate($)
278}
279
280// -------------------------------------------------------------------------
281// Knöpfe
282
283async function toggleAuto($: Engine): Promise<void> {
284 const m = await ensureModel($)
285 const auto: Auto = m.auto.isOn
286 ? AUTO_OFF
287 : { isOn: true, since: await $.clock.now(), offReason: null }
288 await save($, { auto, isPingWaiting: false })
289 await persist($)
290 await evaluate($)
291}
292
293async function pingOnce($: Engine): Promise<void> {
294 const m = await ensureModel($)
295 if (m.lastAnswerAt === null || m.isWorking) return
296 await sendPing($, m.lastAnswerAt)
297}
298
299function compact($: Engine): void {
300 void $.command.run({ command: 'compact', args: '' }).catch(() => undefined)
301}
302
303export const register: Register = (on, options) => {
304 cfg = readSettings(options, DEFAULTS)
305
306 // -------------------------------------------------------------------------
307 // Ereignisse
308
309 on('session.start', async ($, e, next) => {
310 const result = await next(e)
311 await ensureModel($)
312 await refreshUsage($)
313 await tick($)
314 clock?.cancel()
315 clock = $.clock.every(TICK_MS, () => void tick($).catch(() => undefined))
316 return result
317 })
318
319 on('session.end', async ($, e, next) => {
320 const result = await next(e)
321 // Nach /clear läuft der Prozess unter neuer Session-ID weiter, ohne Cache.
322 if (e.reason === 'clear') {
323 await save($, { ...EMPTY, usage: model?.usage ?? null })
324 }
325 return result
326 })
327
328 on('turn.start', async ($, e, next) => {
329 await ensureModel($)
330 await save($, { isWorking: true, isPingWaiting: false })
331 return next(e)
332 })
333
334 on('turn.complete', async ($, e, next) => {
335 const result = await next(e)
336 // Antworten von Subagenten öffnen kein Fenster für die Hauptsession.
337 if (e.agentId !== undefined) return result
338
339 await ensureModel($)
340 const now = await $.clock.now()
341 const isAnswered = e.reason === 'answer' || e.usage !== undefined
342 await save($, isAnswered ? { isWorking: false, lastAnswerAt: now } : { isWorking: false })
343 await refreshUsage($)
344 await persist($)
345 await $.state.set(NOW, now)
346 await evaluate($)
347 return result
348 })
349
350 // Nach dem Komprimieren ist der Kontext kleiner: Werte neu einlesen. Scheitert
351 // das, läuft das Komprimieren trotzdem durch.
352 on('session.compact', async ($, e, next) => {
353 const result = await next(e)
354 await ensureModel($)
355 await refreshUsage($)
356 return result
357 }).catch(($, e, next) => next(e))
358
359 on('ui.render', { component: 'PromptHint' }, ($, e, next) => {
360 drafts.set(e.surface, e.props.isDraft)
361 return next(e)
362 })
363
364 // -------------------------------------------------------------------------
365 // Zeichnen: eine Zeile über dem Eingabefeld, darüber bei Bedarf die Warnung
366
367 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
368 // Eine Umfrage hat Vorrang: dann nichts zusätzlich zeichnen.
369 if (e.props.hasSurvey) return next(e)
370 // Zeilen anderer Mods bleiben stehen, unsere kommt darunter.
371 const others = await next(e)
372
373 const held = (await $.state.get(NOW)).value ?? 0
374 const now = held > 0 ? held : await $.clock.now()
375 const s: Snapshot = {
376 now,
377 isWorking: e.props.isWorking || ((await $.state.get(IS_WORKING)).value ?? false),
378 lastAnswerAt: (await $.state.get(LAST_ANSWER_AT)).value ?? null,
379 pingedFor: (await $.state.get(PINGED_FOR)).value ?? null,
380 warnedFor: (await $.state.get(WARNED_FOR)).value ?? null,
381 auto: (await $.state.get(AUTO)).value ?? AUTO_OFF,
382 usage: (await $.state.get(USAGE)).value ?? null,
383 }
384 const isPingWaiting = (await $.state.get(IS_PING_WAITING)).value ?? false
385
386 const { Box, Button, Text } = $.ui.resolve(e)
387 const isWarning = isWarningVisible(s, cfg) && s.pingedFor !== s.lastAnswerAt
388 const layout = statusLayout(s, cfg, isPingWaiting, isWarning, e.props.bodyColumns)
389
390 const text = (part: Part) => (
391 <Text {...toneProps(part.tone)} wrap="truncate">
392 {part.text}
393 </Text>
394 )
395 const segment = (one: Segment, index: number) => {
396 const box = (
397 <Box key={one.key} flexShrink={0}>
398 {one.parts.map(text)}
399 </Box>
400 )
401 return index === 0 ? [box] : [<Text dimColor>{' │ '}</Text>, box]
402 }
403 const control = (one: Control) => (
404 <Box key={one.key === 'auto' ? 'schalter' : 'komprimieren'} marginLeft={2} flexShrink={0}>
405 <Button
406 key={one.key}
407 label={one.label}
408 hotkey={one.key === 'auto' ? 'w' : 'k'}
409 {...(one.isPrimary ? { variant: 'primary' as const } : {})}
410 onPress={() => (one.key === 'auto' ? toggleAuto($) : compact($))}
411 />
412 {one.hint !== null && <Text dimColor>{` ${one.hint}`}</Text>}
413 </Box>
414 )
415 const lines = layout.lines.map((line, index) => (
416 <Box key={layout.lines.length > 1 ? `zeile-${index + 1}` : 'zeile'} overflow="hidden">
417 {line.segments.flatMap(segment)}
418 {line.controls.map(control)}
419 </Box>
420 ))
421
422 let warning: RenderElement | null = null
423 if (isWarning) {
424 const form = warningLayout(remainingMs(s, cfg) ?? 0, e.props.bodyColumns)
425 warning = (
426 <Box key="warnung" overflow="hidden">
427 <Text color="error" bold wrap="truncate">
428 {form.text}
429 </Text>
430 <Box marginLeft={2} flexShrink={0}>
431 <Button
432 key="ping-once"
433 label={form.pingLabel}
434 hotkey="p"
435 variant="primary"
436 onPress={() => pingOnce($)}
437 />
438 </Box>
439 <Box marginLeft={2} flexShrink={0}>
440 <Button key="compact" label={form.compactLabel} hotkey="k" onPress={() => compact($)} />
441 </Box>
442 </Box>
443 )
444 }
445
446 return (
447 <Box flexDirection="column">
448 {others}
449 {warning}
450 <Box key="statuszeile" flexDirection="column">
451 {lines}
452 </Box>
453 </Box>
454 )
455 })
456}
457
458function toneProps(tone: Tone): { color?: string; dimColor?: boolean } {
459 if (tone === 'plain') return {}
460 if (tone === 'dim') return { dimColor: true }
461 return { color: tone }
462}
463
464function asSaved(value: unknown): SavedSession | undefined {
465 if (typeof value !== 'object' || value === null) return undefined
466 const saved = value as Partial<SavedSession>
467 return typeof saved.savedAt === 'number' && typeof saved.auto === 'object' && saved.auto !== null
468 ? (saved as SavedSession)
469 : undefined
470}
471hooks/logic.ts 448 lines1// Reine Rechen- und Textfunktionen: kein `$`, damit Entscheidungen und
2// Beschriftungen an einer Stelle stehen und leicht zu prüfen sind.
3import type { PluginOptions, SessionUsage } from 'claude-code'
4
5import type { Auto, OffReason, Usage } from '../types'
6
7export const MINUTE = 60_000
8export const HOUR = 60 * MINUTE
9
10/** Die Einstellungen in Millisekunden bzw. Prozent. */
11export type Settings = {
12 cacheMs: number
13 pingLeadMs: number
14 maxOnMs: number
15 limitPercent: number
16 warnLeadMs: number
17}
18
19export type SettingDefaults = {
20 cacheMinutes: number
21 pingLeadMinutes: number
22 maxHours: number
23 limitPercent: number
24 warnLeadMinutes: number
25}
26
27function positive(value: unknown, fallback: number): number {
28 const n = typeof value === 'number' ? value : Number(value)
29 return Number.isFinite(n) && n > 0 ? n : fallback
30}
31
32export function readSettings(options: PluginOptions, defaults: SettingDefaults): Settings {
33 const cacheMinutes = positive(options['cacheMinutes'], defaults.cacheMinutes)
34 // Ein Vorlauf, der so lang ist wie das ganze Fenster, ergäbe Dauerpings bzw. Dauerwarnung.
35 const lead = (key: string, fallback: number) =>
36 Math.min(positive(options[key], fallback), Math.max(cacheMinutes - 0.5, 0.5))
37
38 return {
39 cacheMs: cacheMinutes * MINUTE,
40 pingLeadMs: lead('pingLeadMinutes', defaults.pingLeadMinutes) * MINUTE,
41 maxOnMs: positive(options['maxHours'], defaults.maxHours) * HOUR,
42 limitPercent: Math.min(positive(options['limitPercent'], defaults.limitPercent), 100),
43 warnLeadMs: lead('warnLeadMinutes', defaults.warnLeadMinutes) * MINUTE,
44 }
45}
46
47/** Was die Engine liefert, auf die Werte der Zeile verdichtet. */
48export function toUsage(usage: SessionUsage): Usage {
49 const fiveHour = usage.rateLimits.find(limit => limit.kind === 'five_hour')
50 const weekly = usage.rateLimits.find(limit => limit.kind === 'seven_day')
51 const resetsAt = fiveHour?.resetsAt === undefined ? NaN : Date.parse(fiveHour.resetsAt)
52
53 return {
54 contextTokens: usage.context.tokens ?? null,
55 contextWindow: usage.context.window > 0 ? usage.context.window : null,
56 contextPercent: usage.context.percent ?? null,
57 fiveHourPercent: fiveHour?.percentUsed ?? null,
58 fiveHourResetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
59 weeklyPercent: weekly?.percentUsed ?? null,
60 costUsd: usage.cost?.usd ?? null,
61 }
62}
63
64// ---------------------------------------------------------------------------
65// Entscheidungen
66
67/** Alles, was über Warnung, Ping und Selbstabschaltung entscheidet. */
68export type Snapshot = {
69 now: number
70 isWorking: boolean
71 lastAnswerAt: number | null
72 pingedFor: number | null
73 warnedFor: number | null
74 auto: Auto
75 usage: Usage | null
76}
77
78export type Step =
79 | { kind: 'none' }
80 | { kind: 'switch-off'; reason: OffReason }
81 | { kind: 'ping' }
82 | { kind: 'warn' }
83
84export function remainingMs(s: Pick<Snapshot, 'now' | 'lastAnswerAt'>, cfg: Settings): number | null {
85 return s.lastAnswerAt === null ? null : s.lastAnswerAt + cfg.cacheMs - s.now
86}
87
88/** Der nächste fällige Schritt; nach einer Selbstabschaltung erneut fragen. */
89export function nextStep(s: Snapshot, cfg: Settings): Step {
90 const remaining = remainingMs(s, cfg)
91 if (remaining === null) {
92 return { kind: 'none' }
93 }
94
95 if (s.auto.isOn) {
96 if (s.auto.since !== null && s.now - s.auto.since >= cfg.maxOnMs) {
97 return { kind: 'switch-off', reason: 'max-duration' }
98 }
99 if ((s.usage?.fiveHourPercent ?? 0) >= cfg.limitPercent) {
100 return { kind: 'switch-off', reason: 'limit' }
101 }
102 if (s.isWorking) {
103 return { kind: 'none' }
104 }
105 if (remaining <= 0) {
106 return { kind: 'switch-off', reason: 'expired' }
107 }
108 if (remaining <= cfg.pingLeadMs && s.pingedFor !== s.lastAnswerAt) {
109 return { kind: 'ping' }
110 }
111 return { kind: 'none' }
112 }
113
114 if (!s.isWorking && remaining > 0 && remaining <= cfg.warnLeadMs && s.warnedFor !== s.lastAnswerAt) {
115 return { kind: 'warn' }
116 }
117 return { kind: 'none' }
118}
119
120/** Die rote Zeile steht, solange für dieses Fenster gewarnt wurde und es noch läuft. */
121export function isWarningVisible(s: Snapshot, cfg: Settings): boolean {
122 const remaining = remainingMs(s, cfg)
123 return (
124 !s.auto.isOn &&
125 !s.isWorking &&
126 remaining !== null &&
127 remaining > 0 &&
128 s.warnedFor === s.lastAnswerAt
129 )
130}
131
132export function isCompactOffered(s: Snapshot, cfg: Settings): boolean {
133 if (s.isWorking || s.lastAnswerAt === null) {
134 return false
135 }
136 const remaining = remainingMs(s, cfg) ?? 0
137 return (
138 isWarningVisible(s, cfg) ||
139 (s.usage?.contextPercent ?? 0) >= 50 ||
140 (remaining > 0 && remaining < 10 * MINUTE)
141 )
142}
143
144// ---------------------------------------------------------------------------
145// Darstellung
146//
147// Die Zeile wird als Daten beschrieben (Teile mit Text und Ton), damit sich ihre
148// Breite vorab berechnen lässt. In Stufen wird sie knapper, bis sie in eine Zeile
149// passt; erst wenn auch die knappste nicht passt, kommen zwei Zeilen.
150
151export type Level = 'success' | 'warning' | 'error'
152
153/** Wie ein Teil gezeichnet wird: in einer Ampelfarbe, in Claudes Farbe, abgeblendet oder normal. */
154export type Tone = Level | 'claude' | 'dim' | 'plain'
155
156export type Part = { text: string; tone: Tone }
157
158/** Ein Abschnitt der Zeile (Cache, Kontext, 5h, Woche, Kosten). */
159export type Segment = { key: string; parts: Part[] }
160
161/** Ein Knopf mit kurzem Text daneben. */
162export type Control = {
163 key: 'auto' | 'compact'
164 label: string
165 hint: string | null
166 isPrimary: boolean
167}
168
169/** 0 ausführlich, 1 knapp (ohne Tokenzahlen, kurze Knöpfe), 2 minimal (ohne Balken). */
170export type Density = 0 | 1 | 2
171
172export type StatusLine = { segments: Segment[]; controls: Control[] }
173
174export type StatusLayout = {
175 density: Density
176 /** Eine Zeile, oder zwei: Cache und Knöpfe oben, Werte unten. */
177 lines: StatusLine[]
178}
179
180export type WarningLayout = { text: string; pingLabel: string; compactLabel: string }
181
182const SEPARATOR = ' │ '
183const GAP = ' '
184/** Spielraum, weil nicht jede Oberfläche Zeichen exakt gleich breit setzt. */
185const SLACK = 2
186
187export function remainingLevel(ms: number): Level {
188 if (ms <= 5 * MINUTE) return 'error'
189 if (ms <= 15 * MINUTE) return 'warning'
190 return 'success'
191}
192
193export function percentLevel(percent: number): Level {
194 if (percent >= 80) return 'error'
195 if (percent >= 50) return 'warning'
196 return 'success'
197}
198
199/** Minuten, aufgerundet; ab 60 als Stunden:Minuten („1:05 h“). */
200export function formatDuration(ms: number): string {
201 const minutes = Math.max(0, Math.ceil(ms / MINUTE))
202 if (minutes < 60) {
203 return `${minutes} min`
204 }
205 return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')} h`
206}
207
208export function formatPercent(percent: number): string {
209 return `${Math.round(percent)}%`
210}
211
212export function formatTokens(tokens: number): string {
213 if (tokens < 1000) {
214 return String(Math.round(tokens))
215 }
216 if (tokens < 1_000_000) {
217 return `${Math.round(tokens / 1000)}k`
218 }
219 return `${(Math.round(tokens / 100_000) / 10).toString().replace('.', ',')}M`
220}
221
222export function formatUsd(usd: number): string {
223 return `${usd.toFixed(2).replace('.', ',')} $`
224}
225
226export function formatHours(ms: number): string {
227 const hours = Math.round((ms / HOUR) * 10) / 10
228 return `${hours.toString().replace('.', ',')} h`
229}
230
231/** Ein Balken aus gleich großen Zeichen: der volle Teil farbig, der leere abgeblendet. */
232export function bar(percent: number, level: Level, width: number): Part[] {
233 const filled = Math.round((Math.min(Math.max(percent, 0), 100) / 100) * width)
234 return [
235 { text: '▰'.repeat(filled), tone: level },
236 { text: '▰'.repeat(width - filled), tone: 'dim' },
237 ].filter(part => part.text !== '') as Part[]
238}
239
240export type CacheView =
241 | { kind: 'none' }
242 | { kind: 'working' }
243 | { kind: 'warm'; remaining: number }
244 | { kind: 'expired' }
245
246export function cacheView(s: Snapshot, cfg: Settings): CacheView {
247 const remaining = remainingMs(s, cfg)
248 if (s.isWorking) return { kind: 'working' }
249 if (remaining === null) return { kind: 'none' }
250 if (remaining <= 0) return { kind: 'expired' }
251 return { kind: 'warm', remaining }
252}
253
254function cacheSegment(view: CacheView, d: Density): Segment {
255 const label = d < 2 ? 'Cache ' : ''
256 const part: Part =
257 view.kind === 'none'
258 ? { text: `◌ ${label}noch keine Antwort`, tone: 'dim' }
259 : view.kind === 'working'
260 ? { text: `◐ ${label}Claude arbeitet`, tone: 'claude' }
261 : view.kind === 'expired'
262 ? { text: `○ ${label}abgelaufen`, tone: 'error' }
263 : { text: `● ${label}${formatDuration(view.remaining)}`, tone: remainingLevel(view.remaining) }
264 return { key: 'cache', parts: [part] }
265}
266
267function metricSegments(usage: Usage | null, now: number, d: Density): Segment[] {
268 const segments: Segment[] = []
269 const barWidth = d === 0 ? 6 : 4
270 const metric = (label: string, percent: number): Part[] => [
271 { text: label, tone: 'dim' },
272 ...(d < 2 ? [...bar(percent, percentLevel(percent), barWidth), { text: ' ', tone: 'plain' as const }] : []),
273 { text: formatPercent(percent), tone: percentLevel(percent) },
274 ]
275
276 if (usage?.contextPercent != null) {
277 const parts = metric(d < 2 ? 'Kontext ' : 'Ktx ', usage.contextPercent)
278 if (d === 0 && usage.contextTokens != null && usage.contextWindow != null) {
279 parts.push({
280 text: ` ${formatTokens(usage.contextTokens)}/${formatTokens(usage.contextWindow)}`,
281 tone: 'dim',
282 })
283 }
284 segments.push({ key: 'kontext', parts })
285 }
286 if (usage?.fiveHourPercent != null) {
287 const parts = metric('5h ', usage.fiveHourPercent)
288 if (usage.fiveHourResetsAt != null) {
289 const left = usage.fiveHourResetsAt - now
290 parts.push({ text: ` ↻${left > 0 ? formatDuration(left) : 'gleich'}`, tone: 'dim' })
291 }
292 segments.push({ key: 'fuenf-stunden', parts })
293 }
294 if (usage?.weeklyPercent != null) {
295 segments.push({
296 key: 'woche',
297 parts: [
298 { text: d < 2 ? 'Woche ' : 'Wo ', tone: 'dim' },
299 { text: formatPercent(usage.weeklyPercent), tone: percentLevel(usage.weeklyPercent) },
300 ],
301 })
302 }
303 if (usage?.costUsd != null) {
304 segments.push({ key: 'kosten', parts: [{ text: formatUsd(usage.costUsd), tone: 'dim' }] })
305 }
306 return segments
307}
308
309/** Kurztext neben dem Schalter: wann gepingt wird, oder warum er aus ist. */
310export function autoHint(s: Snapshot, cfg: Settings, isPingWaiting: boolean, d: Density = 0): string | null {
311 const isLong = d === 0
312 if (!s.auto.isOn) {
313 switch (s.auto.offReason) {
314 case 'max-duration':
315 return isLong ? `nach ${formatHours(cfg.maxOnMs)} abgeschaltet` : `aus nach ${formatHours(cfg.maxOnMs)}`
316 case 'limit': {
317 const percent = formatPercent(s.usage?.fiveHourPercent ?? cfg.limitPercent)
318 return isLong ? `abgeschaltet: 5h-Limit ${percent}` : `aus: 5h-Limit ${percent}`
319 }
320 case 'expired':
321 return isLong ? 'abgeschaltet: Cache war abgelaufen' : 'aus: Cache war kalt'
322 default:
323 return null
324 }
325 }
326 if (s.isWorking) return null
327 if (s.lastAnswerAt !== null && s.pingedFor === s.lastAnswerAt) return 'Ping läuft'
328 if (isPingWaiting) return isLong ? 'Ping wartet aufs leere Eingabefeld' : 'Ping wartet (Eingabe)'
329 const remaining = remainingMs(s, cfg)
330 if (remaining === null) return null
331 const untilPing = remaining - cfg.pingLeadMs
332 if (untilPing <= 0) return 'Ping gleich'
333 return isLong ? `Ping in ${formatDuration(untilPing)}` : `Ping ${formatDuration(untilPing)}`
334}
335
336function controlsFor(s: Snapshot, cfg: Settings, isPingWaiting: boolean, isWarning: boolean, d: Density): Control[] {
337 const controls: Control[] = []
338 if (s.lastAnswerAt !== null) {
339 const state = s.auto.isOn ? 'an' : 'aus'
340 controls.push({
341 key: 'auto',
342 label: d === 0 ? `Warmhalten: ${state}` : `Warm: ${state}`,
343 hint: autoHint(s, cfg, isPingWaiting, d),
344 isPrimary: s.auto.isOn,
345 })
346 }
347 // Während der Warnung steht der Knopf in der roten Zeile.
348 if (!isWarning && isCompactOffered(s, cfg)) {
349 controls.push({ key: 'compact', label: d === 0 ? 'Komprimieren' : '/compact', hint: null, isPrimary: false })
350 }
351 return controls
352}
353
354export function textWidth(text: string): number {
355 return [...text].length
356}
357
358function segmentsWidth(segments: Segment[], isAfterCache: boolean): number {
359 return segments.reduce(
360 (sum, segment, index) =>
361 sum +
362 (index > 0 || isAfterCache ? SEPARATOR.length : 0) +
363 segment.parts.reduce((n, part) => n + textWidth(part.text), 0),
364 0,
365 )
366}
367
368/** Ein Knopf ist `[ label ]` breit, sein Kurztext steht mit einem Leerzeichen daneben. */
369function controlsWidth(controls: Control[]): number {
370 return controls.reduce(
371 (sum, control) =>
372 sum + GAP.length + textWidth(control.label) + 4 + (control.hint === null ? 0 : 1 + textWidth(control.hint)),
373 0,
374 )
375}
376
377export function lineWidth(line: StatusLine): number {
378 const [first, ...rest] = line.segments
379 const firstWidth = first === undefined ? 0 : segmentsWidth([first], false)
380 return firstWidth + segmentsWidth(rest, first !== undefined) + controlsWidth(line.controls)
381}
382
383/** Die ausführlichste Form, die in `columns` passt: erst einzeilig, dann zweizeilig. */
384export function statusLayout(
385 s: Snapshot,
386 cfg: Settings,
387 isPingWaiting: boolean,
388 isWarning: boolean,
389 columns: number,
390): StatusLayout {
391 const room = columns - SLACK
392 const DENSITIES: Density[] = [0, 1, 2]
393 const build = (d: Density) => ({
394 cache: cacheSegment(cacheView(s, cfg), d),
395 metrics: metricSegments(s.usage, s.now, d),
396 controls: controlsFor(s, cfg, isPingWaiting, isWarning, d),
397 })
398
399 for (const d of DENSITIES) {
400 const { cache, metrics, controls } = build(d)
401 const line: StatusLine = { segments: [cache, ...metrics], controls }
402 if (lineWidth(line) <= room) {
403 return { density: d, lines: [line] }
404 }
405 }
406 for (const d of DENSITIES) {
407 const { cache, metrics, controls } = build(d)
408 const top: StatusLine = { segments: [cache], controls }
409 const bottom: StatusLine = { segments: metrics, controls: [] }
410 if (d === 2 || Math.max(lineWidth(top), lineWidth(bottom)) <= room) {
411 return { density: d, lines: metrics.length > 0 ? [top, bottom] : [top] }
412 }
413 }
414 return { density: 2, lines: [] }
415}
416
417/** Die rote Zeile so ausführlich, wie sie in eine Zeile passt. */
418export function warningLayout(remaining: number, columns: number): WarningLayout {
419 const when = formatDuration(remaining)
420 const forms: WarningLayout[] = [
421 {
422 text: `⚠ Cache wird in ${when} kalt – dann zahlt der nächste Prompt den vollen Kontextpreis`,
423 pingLabel: 'Einmal warm halten',
424 compactLabel: 'Kontext komprimieren',
425 },
426 { text: `⚠ Cache wird in ${when} kalt`, pingLabel: 'Einmal warm halten', compactLabel: 'Komprimieren' },
427 { text: `⚠ kalt in ${when}`, pingLabel: 'Warm halten', compactLabel: '/compact' },
428 ]
429 const width = (form: WarningLayout) =>
430 textWidth(form.text) + 2 * GAP.length + textWidth(form.pingLabel) + 4 + textWidth(form.compactLabel) + 4
431 return forms.find(form => width(form) <= columns - SLACK) ?? forms[forms.length - 1]!
432}
433
434export function offToast(reason: OffReason, s: Snapshot, cfg: Settings): string {
435 switch (reason) {
436 case 'max-duration':
437 return `Warmhalten ausgeschaltet: Höchstdauer von ${formatHours(cfg.maxOnMs)} erreicht.`
438 case 'limit':
439 return `Warmhalten ausgeschaltet: 5-Stunden-Limit bei ${formatPercent(s.usage?.fiveHourPercent ?? 0)} (Grenze ${formatPercent(cfg.limitPercent)}).`
440 case 'expired':
441 return 'Warmhalten ausgeschaltet: Der Cache war schon kalt, ein Ping hätte den vollen Preis gekostet.'
442 }
443}
444
445export function warningToast(remaining: number): string {
446 return `Prompt-Cache wird in ${formatDuration(remaining)} kalt. „Einmal warm halten“ verlängert ihn.`
447}
448types/index.d.ts 67 lines1// Vertrag von keepcachewarm: jeder Wert, den der Mod in `$.state` hält.
2
3/**
4 * Was die Engine nach der letzten Antwort über die Session meldet; ein Wert,
5 * den sie nicht liefert, ist `null` (z. B. keine Limits bei API-Nutzung).
6 */
7export type Usage = {
8 /** Belegte Tokens im Kontextfenster. */
9 contextTokens: number | null
10 /** Größe des Kontextfensters in Tokens. */
11 contextWindow: number | null
12 /** Belegung des Kontextfensters, 0 bis 100. */
13 contextPercent: number | null
14 /** Verbrauch im 5-Stunden-Fenster, 0 bis 100. */
15 fiveHourPercent: number | null
16 /** Wann das 5-Stunden-Fenster zurückgesetzt wird, ms seit der Epoche. */
17 fiveHourResetsAt: number | null
18 /** Verbrauch im Wochenfenster, 0 bis 100. */
19 weeklyPercent: number | null
20 /** Kosten der Session in US-Dollar. */
21 costUsd: number | null
22}
23
24/** Warum sich das Warmhalten selbst ausgeschaltet hat. */
25export type OffReason = 'max-duration' | 'limit' | 'expired'
26
27/** Der Schalter „automatisch warm halten“. */
28export type Auto = {
29 isOn: boolean
30 /** Zeitpunkt des Einschaltens (ms), für die Höchstdauer. */
31 since: number | null
32 /** Grund der letzten Selbstabschaltung, bis der Schalter wieder betätigt wird. */
33 offReason: OffReason | null
34}
35
36/** Was je Session in `$.store` liegt, damit Fortsetzen und Neuladen es behalten. */
37export type SavedSession = {
38 lastAnswerAt: number | null
39 pingedFor: number | null
40 warnedFor: number | null
41 auto: Auto
42 savedAt: number
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 keepcachewarm: {
48 /** Uhr-Takt (ms); jeder Schlag zeichnet die Zeile neu. */
49 now: number
50 /** Die Session, zu der die Werte unten gehören. */
51 sessionId: string | null
52 /** Claude arbeitet gerade (Hauptschleife). */
53 isWorking: boolean
54 /** Zeitpunkt der letzten Antwort der Hauptschleife (ms); öffnet ein Cache-Fenster. */
55 lastAnswerAt: number | null
56 /** Das Fenster (`lastAnswerAt`), für das schon ein Ping verschickt wurde. */
57 pingedFor: number | null
58 /** Das Fenster (`lastAnswerAt`), für das schon gewarnt wurde. */
59 warnedFor: number | null
60 /** Ein fälliger Ping wartet, weil im Eingabefeld Text steht. */
61 isPingWaiting: boolean
62 auto: Auto
63 usage: Usage | null
64 }
65 }
66}
67