SLOPSHOPPER

keepcachewarm

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.

newbandspinnertoasttimeraudio
v1.0.0MITupdated 2026-10-09HitEmUp/keepcachewarm
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · keepcachewarm
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ⟨Claude Code's own drawing⟩ ● Cache 1:00 h │ Kontext ▰▰▰▰▰▰ 49% 97k/200k │ 5h ▰▰▰▰▰▰ 31% │ 0,42 $ [ Warmhalten: aus ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ● Cache 1:00 h │ Kontext ▰▰▰▰▰▰ 49% 97k/200k │ 5h ▰▰▰▰▰▰ 31% │ 0,42 $ [ Warmhalten: aus ]
README

keepcachewarm

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.

Warum

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.

Was der Mod macht

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.

  • Cache-Zustand: noch keine Antwort / Claude arbeitet / warm mit Restzeit / abgelaufen. Grün, gelb ab 15 Minuten Restzeit, rot ab 5 Minuten und nach Ablauf. Die Restzeit läuft sichtbar herunter.
  • Kontext: Balken, Prozent und belegte Tokens / Fenstergröße.
  • 5-Stunden-Limit mit Balken, Prozent und Zeit bis zum Zurücksetzen, Wochenlimit in Prozent und die Kosten der Session in US-Dollar, soweit Claude Code sie liefert. Balken: grün, gelb ab 50 %, rot ab 80 %.
  • Knöpfe: der Schalter fürs Warmhalten und „Komprimieren“ (führt /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:

  • Einmal warm halten schickt sofort einen Mini-Prompt, dessen Antwort ein neues Fenster öffnet.
  • Kontext komprimieren führt /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:

  • höchstens ein Ping je Cache-Fenster;
  • kein Ping, solange Claude arbeitet oder im Eingabefeld Text steht; sobald das Feld leer ist und noch Zeit bleibt, wird nachgeholt;
  • neben dem Schalter steht, wann gepingt wird; Ton, Einblendung und rote Warnzeile entfallen;
  • der Schalter geht von selbst aus (mit Grund daneben und einer Einblendung) nach 4 Stunden ab dem Einschalten, wenn das 5-Stunden-Limit 90 % erreicht, oder wenn der Cache schon abgelaufen ist (etwa nach dem Ruhezustand), denn dann würde ein Ping nur den vollen Preis zahlen.

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.

Installation

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.

Einstellungen

Alle Werte stehen in /config unter keepcachewarm:

EinstellungVorgabeBedeutung
Cache-Dauer (Minuten)60Wie lange der Cache nach der letzten Antwort hält; per API 5.
Ping-Vorlauf (Minuten)3So lange vor Ablauf pingt das automatische Warmhalten.
Höchstdauer Warmhalten (Stunden)4Danach schaltet sich das Warmhalten ab.
Limit-Grenze (%)90Ab diesem Stand des 5-Stunden-Limits schaltet es sich ab.
Warnvorlauf (Minuten)5So lange vor Ablauf warnen Ton, Einblendung und rote Zeile.

Die Vorgaben stehen auch als Konstanten oben in hooks/register.tsx.

Entwickeln

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

Lizenz

MIT, siehe LICENSE.

Source 3 files
hooks/register.tsx 471 lines
1import 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}
471
hooks/logic.ts 448 lines
1// 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}
448
types/index.d.ts 67 lines
1// 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