A band above the Claude Code prompt: prompt-cache countdown and bar, an opt-in keep-warm that refreshes the cache before it runs out, cache hit rate, context…

Deutsch: README.de.md
A Claude Code mod: one slim band directly above the prompt that tells you how warm the prompt cache still is and how full the context is, with a Compact button, a Pause / Resume button that has Claude finish what is running, save its state and wait, and a Keep-warm checkbox that refreshes the cache shortly before it runs out.


Cache 12 m. Send your next message before it reaches “expired”, and the request reads the conversation from the cache instead of paying to write all of it again; Hits shows how much came from the cache.Ctx 87 % / Max 97 %). Press Compact between two tasks instead of letting an auto-compact cut into one; the band then logs the summary Claude carries on with.You can also type: “pause” pauses, “continue” resumes (in German “pausieren” and „weitermachen“).
| Part | What it shows |
|---|---|
| Cache | Minutes left (m) of the prompt cache since the last response or keep-warm refresh; green, yellow from 60 % of the lifetime left, red from 20 %, “expired” at 0. Redrawn every minute. |
| Keep-warm | A checkbox, [ ] / [✓] in a terminal, ☐ / ☑ in the desktop app: refreshes the cache shortly before it runs out (below). ↻2 counts the refreshes since the last request; beside it why keep-warm waits, when it does. |
| Hits | The share of the last response's input tokens that came from the cache. |
| Weather | ☀️ up to half the auto-compact threshold, 🌧️ up to 80 % of it, ⛈️ above. In a terminal ☼ (yellow), ≋ (light blue) and ↯ (red). |
| Ctx / Max | Context fill in percent and the auto-compact threshold (*: the fallback of 83 %, when the session reports none). |
| Compact | Same as /compact; highlighted during ⛈️. In the desktop app, or while Claude is working, /compact is queued. |
| ⏸ Pause / ▶ Resume | Asks Claude to pause the session, and carries on (below). In a terminal ❚❚ Pause and ► Resume. |
| Cache bar | A line of its own below the band, over its width, coloured as the cache time: in a terminal a line in the middle of the row, in the desktop app a bar a few pixels high. |
Once the cache has expired, Hits, Ctx and Max are hidden until the next response: they describe a request that no longer reads from the cache.
A terminal gets narrow signs instead of emoji: terminals count an emoji's width in their own way (some one column, Claude Code two), and the band would keep leftovers of an earlier frame. The desktop app draws the emoji.
After /compact (or the button) the mod writes the token count before and after and the summary Claude carries on with as log lines, which the model does not read.
| State | Band | What happens |
|---|---|---|
| working | ⏸ Pause | A press hands Claude the pause request. |
| pause requested | ⏳ Pause requested · 2 tasks still running | Claude lets running work finish, asks about long tasks and saves the state. Typing “continue” takes the request back. |
| paused | ▶ Resume · paused for 12 m | Claude waits. A press sends “continue”, exactly as if you had typed it. |
You can also type: a message that is only “pause” (or “pausieren”) pauses; one that starts with “continue” or “resume” (or “weitermachen”) carries on. Both languages are understood whatever the mod's language is.
The request Claude gets (full text in hooks/pause.ts):
askAboveMinutes) or has no foreseeable end, ask you — as a dialog where available, with each task's remaining time — whether to stop it now and restart it after resuming (only if it can be restarted) or to let it finish. Shorter tasks are simply waited for./loop) do not run during the pause.On “continue” Claude is told to carry on from the saved state and restart what was stopped for the pause. A message written during the pause is answered without picking the paused work back up.
How the mod tracks the state:
~/.claude/cache-weather-and-pause-resume/state.json, so a session reopened while paused (an app restart, a resumed session) shows Resume again. /clear ends the pause.The prompt cache holds the conversation for 5 minutes or 1 hour after the last request reads it. When it has run out, the next request writes all of it again: 1.25 times (5 minutes) or 2 times (1 hour) the normal input price, where a read costs a tenth of it or less. With Keep-warm checked the mod refreshes the cache shortly before it runs out:
$.model.fork, through the session's own client and login). The API reads all of it from the cache, which keeps the cache for another full lifetime. The question is fixed text (“reply with OK”, in hooks/register.tsx); it may call no tool, and question and answer stay out of the conversation. Each refresh writes a dim line: the tokens read from the cache and the tokens of the reply./compact or a switch to another model there is nothing to keep warm: the next request writes the new cache, and keep-warm waits for it (“waiting for the next request”)./clear start without.A refresh is a request of its own and is billed or counted like one: it reads the whole context from the cache, plus a few tokens of reply. It pays off when the next request comes later than the cache would have lasted.
The mod is listed in the marketplace darthsonic, the catalog DarthSonic/claude-plugins; each plugin there lives in a repository of its own. At the prompt of a Claude Code session in a terminal:
/plugin marketplace add DarthSonic/claude-plugins
/plugin install cache-weather-and-pause-resume@darthsonic
Pick a scope (user: every project). The same from a shell:
claude plugin marketplace add DarthSonic/claude-plugins
claude plugin install cache-weather-and-pause-resume@darthsonic --scope user
A mod installed at the user scope from a terminal also loads in the Claude desktop app's Code tab.
Coming from cache-wetter (the name up to 0.5.0): remove it first, with claude plugin uninstall cache-wetter@cache-wetter and claude plugin marketplace remove cache-wetter. Its state file (~/.claude/cache-wetter/) is not carried over and can be deleted.
After an update, open a new session. The desktop app hands the mod to a session when the session starts; /reload-plugins does not swap it there. Which version a session runs is written to codeVersion in ~/.claude/cache-weather-and-pause-resume/state.json at every load.
| Option | Default | What it does |
|---|---|---|
language | auto | de or en for the band, the toasts and the texts Claude gets. auto follows LC_ALL / LC_MESSAGES / LANG, then the system locale (on Windows, where LANG is usually unset). |
askAboveMinutes | 5 | From which remaining time of a running task Claude asks at a pause. |
Set them with /plugin configure cache-weather-and-pause-resume@darthsonic, or in ~/.claude/settings.json:
"pluginConfigs": {
"cache-weather-and-pause-resume@darthsonic": { "options": { "language": "en", "askAboveMinutes": 10 } }
}
The space above the prompt is one site with a chain of mods. A mod that returns only its own band hides every mod beneath it in the chain, and the load order — not the mod — decides which comes first. cache-weather-and-pause-resume calls the rest of the chain and stacks what comes back below its own row, with a row gap that only appears between the two. Another mod that does the same (for example savvy-progress since 1.2.1) shows together with it in either order.
ephemeral_5m_input_tokens, ephemeral_1h_input_tokens); the latest write that wrote anything decides. A transcript over 3.5 MiB is not read (since Claude Code 2.1.300 a transcript also records each request's system prompt and tools, so a long session gets there early). On a model switch the switch's own report decides, and a resumed session's start tells it when its last response is 5 to 60 minutes old (whether Claude Code calls the cache likely expired). Until a reading of its own, a session shows the lifetime last seen: plainly when a session read it within the last day (it follows your account's setting, not the session), else with ?.$.session.usage(); the threshold from the local estimate (no API request).cache-weather-and-pause-resume starts no programs and makes no network requests of its own, but for keep-warm's refreshes, which you switch on per session. What it hands Claude enters the conversation like anything else you send:
| What | When | Exactly |
|---|---|---|
| a prompt in your name | you press Pause while Claude is idle | the pause request: fixed text from hooks/pause.ts, with your askAboveMinutes |
| a prompt in your name | you press Resume | “continue” (in German „weitermachen“) |
| a row in the conversation | you press Pause while Claude works, or Resume | the pause request; a fixed note that the pause is over, with its length in minutes |
| a note added to your prompt | a pause is requested or on, or you type “pause” or “continue” | a fixed note (the request, “the session is paused”, “the pause is over”); the text you typed is never changed, and no prompt is held back |
the slash command /compact | you press Compact while Claude works, or in the desktop app | queued like a typed /compact; otherwise the button compacts through the session's own compaction call |
| the Stop hook's answer | Claude stops before it read the pause request | Claude goes on once, with the pause request as the reason |
| a question beside the conversation | Keep-warm is checked and the cache is about to run out | a model request over the conversation as its last request sent it, through the session's own client: fixed text asking for “OK”, no tools; the answer is discarded and stays out of the conversation |
No file content, transcript text or setting goes into these texts. Keep-warm's request carries the conversation the session sends with every request anyway, to the same API.
Files. The mod writes one file, ~/.claude/cache-weather-and-pause-resume/state.json (below), and no other: no settings, instructions, hooks or build files. It reads that file, the session's transcript when it is at most 3.5 MiB (for the cache lifetime and the time of the last response), and the file names — not the contents — in ~/.claude/projects/*/, to drop the pauses of deleted sessions.
Environment and settings. It reads USERPROFILE / HOME (where the home folder is) and LC_ALL / LC_MESSAGES / LANG (the language). It reads no Claude Code settings and no credentials.
Hooks. The prompt.submit and classic.Stop hooks act as in the table above. Every other hook only reads and passes its event on unchanged: classic.SessionStart (transcript path, session id, time since the last response), classic.Stop (transcript path, running background tasks), classic.PostModelSwitch (the cache lifetime, and whether the model changed), classic.Notification (a permission prompt waits for you), tool.call (when a call of the conversation has run, and when a question to you is answered), turn.step (token counts, and when a request reads the cache) and session.measure (token counts), turn.start, turn.complete, session.end, and session.compact, after which it logs the summary as lines the model does not read. The ui.render hook draws the band and stacks the bands of other mods beneath it.
~/.claude/cache-weather-and-pause-resume/state.json holds the paused sessions and the sessions with keep-warm checked (by session id), the cache lifetime last seen and the version last loaded. It keeps at most the 20 latest of each, and ten seconds after a session starts it drops what it holds of deleted sessions (no transcript left under ~/.claude/projects/); when in doubt it drops nothing. Only the mod reads the file — it never reaches the model and costs no tokens.
Claude Code with mods (function-hook plugins), early access, checked with 2.1.295 (desktop app) and 2.1.300 (CLI). The mod API moves between releases; the CI runs validation and the tests against a pinned version.
Where an organization manages Claude Code, the hooks on classic.SessionStart, classic.Stop, classic.Notification, classic.PostModelSwitch and session.compact run only for plugins the organization lists in managed settings. A self-installed copy then never sees Claude stop, so the pause stays at “Pause requested”, the cache lifetime is not read off the transcript, no summary is logged after /compact, and keep-warm counts an open permission prompt as work and goes on after a compaction or a switch of model until a refresh finds the cache cold.
claude plugin validate .
claude plugin test .
The tests (tests/) run in Claude Code's own test kit on the terminal and desktop surfaces and pin the language, so they do not depend on the machine's locale. A change reaches your own sessions after a version bump in .claude-plugin/plugin.json, claude plugin update cache-weather-and-pause-resume@darthsonic and a new session.
Questions, bugs and ideas: GitHub issues. The mod needs no account and no sample data: install it and open a session.
cache-weather-and-pause-resume is based on an idea by Julian Ivanov (KI-Automatisierungs-Community). Some of keep-warm's rules follow cachebeat by ARahim3, which keeps the cache warm with a Stop hook that wakes Claude.
Written with Claude Code (Claude Opus 5.5); reviewed, tested and maintained by Sven Moderow.
MIT © 2026 Sven Moderow
hooks/register.tsx 943 lines1import { atom, read, update } from 'claude-code'
2import type { ClassicResultOf, EngineInterface, Register } from 'claude-code'
3
4import type { ContextFill, KeepWarmNote, PausePhase, Threshold, Ttl } from '../types'
5import { langOf, runtimeLocale, strings } from './i18n'
6import type { Lang } from './i18n'
7import {
8 FALLBACK_THRESHOLD_PERCENT,
9 SVG_BAR_HEIGHT,
10 cacheTone,
11 compactionLines,
12 groupDigits,
13 hitRate,
14 isWarmRead,
15 keepWarmSpanText,
16 keepWarmStep,
17 lastResponseFromTranscript,
18 lifetimeFromResume,
19 pauseSigns,
20 remainingMinutes,
21 remainingShare,
22 svgBar,
23 terminalBar,
24 terminalWeather,
25 thresholdPercent,
26 ttlFromTranscript,
27 weather,
28} from './logic'
29import {
30 askAboveMinutes,
31 inFlight,
32 isOwnOrigin,
33 isPauseCommand,
34 isPersonOrigin,
35 isResume,
36 minutesBetween,
37 pauseRequest,
38 pausedMachineNote,
39 pausedPersonNote,
40 pausingNote,
41 resumeContext,
42 resumeText,
43 sinceText,
44} from './pause'
45
46// Written at every load: which code a session runs, read off the state file on disk.
47const CODE_VERSION = '0.8.1'
48
49const lastResponseAt = atom({ plugin: 'cache-weather-and-pause-resume', key: 'lastResponseAt' } as const, null)
50const ttl = atom({ plugin: 'cache-weather-and-pause-resume', key: 'ttl' } as const, null)
51const lastHitRate = atom({ plugin: 'cache-weather-and-pause-resume', key: 'hitRate' } as const, null)
52const context = atom({ plugin: 'cache-weather-and-pause-resume', key: 'context' } as const, null)
53const threshold = atom({ plugin: 'cache-weather-and-pause-resume', key: 'threshold' } as const, null)
54const now = atom({ plugin: 'cache-weather-and-pause-resume', key: 'now' } as const, 0)
55const transcriptPath = atom({ plugin: 'cache-weather-and-pause-resume', key: 'transcriptPath' } as const, null)
56const isCompacting = atom({ plugin: 'cache-weather-and-pause-resume', key: 'isCompacting' } as const, false)
57const pausePhase = atom({ plugin: 'cache-weather-and-pause-resume', key: 'pausePhase' } as const, 'running')
58const pauseRequestedAt = atom({ plugin: 'cache-weather-and-pause-resume', key: 'pauseRequestedAt' } as const, null)
59const pausedAt = atom({ plugin: 'cache-weather-and-pause-resume', key: 'pausedAt' } as const, null)
60const pauseDelivered = atom({ plugin: 'cache-weather-and-pause-resume', key: 'pauseDelivered' } as const, true)
61const pauseTasksLeft = atom({ plugin: 'cache-weather-and-pause-resume', key: 'pauseTasksLeft' } as const, null)
62const turnRunning = atom({ plugin: 'cache-weather-and-pause-resume', key: 'turnRunning' } as const, false)
63const sessionId = atom({ plugin: 'cache-weather-and-pause-resume', key: 'sessionId' } as const, null)
64const language = atom({ plugin: 'cache-weather-and-pause-resume', key: 'language' } as const, null)
65const keepWarm = atom({ plugin: 'cache-weather-and-pause-resume', key: 'keepWarm' } as const, false)
66const cacheUsedAt = atom({ plugin: 'cache-weather-and-pause-resume', key: 'cacheUsedAt' } as const, null)
67const activeAt = atom({ plugin: 'cache-weather-and-pause-resume', key: 'activeAt' } as const, null)
68const keepWarmNote = atom({ plugin: 'cache-weather-and-pause-resume', key: 'keepWarmNote' } as const, null)
69const keepWarmRefreshes = atom({ plugin: 'cache-weather-and-pause-resume', key: 'keepWarmRefreshes' } as const, 0)
70const isAwaitingPerson = atom({ plugin: 'cache-weather-and-pause-resume', key: 'isAwaitingPerson' } as const, false)
71
72// The language before session.start has read the locale: the option, else the runtime's locale. Set by `register`.
73let fallbackLang: Lang = 'en'
74
75// What outlives a session (paused and kept-warm sessions by id, the cache lifetime last seen) lives in one file,
76// ~/.claude/cache-weather-and-pause-resume/state.json, not in `$.store`: the store is keyed by how the mod was loaded
77// (handed in by the desktop app „inline“, or by the CLI's own loader), so a pause kept there was gone after a restart
78// that loaded the mod the other way (2026-10-13). Each call spells its path out under the home folder, so the source
79// shows which files the mod touches.
80const SESSIONS_KEEP = 20
81// The pruning waits so it never holds up the session's start.
82const PRUNE_AFTER_MS = 10_000
83
84type Persisted = {
85 ttlMinutes?: number
86 /** When a session last read the lifetime itself (a transcript, a model switch, a resumed session's start). */
87 ttlConfirmedAt?: number
88 pausedSessions?: Record<string, number>
89 /** The sessions keep-warm is switched on in, by id, with the time it was. */
90 keepWarmSessions?: Record<string, number>
91 codeVersion?: string
92}
93
94// $.fs.read refuses files over 4 MiB: a longer transcript is not read, and the values remembered stand. (The mod
95// starts no program to read its end.) Since Claude Code 2.1.300 a transcript also records each request's system prompt
96// and tools, so a long session passes the limit early.
97const FS_READ_LIMIT = 3.5 * 1024 * 1024
98
99// The lifetime follows the account's setting, not the session: one a session read within the last day counts as
100// confirmed in another session that cannot read its own (a transcript over the limit), and shows without „?“.
101const CONFIRMED_FOR_MS = 24 * 60 * 60 * 1000
102
103async function tick($: EngineInterface): Promise<void> {
104 const at = await $.clock.now()
105 await update($, now, () => at)
106}
107
108// The mod's language: the `language` option, else the locale variables, else the runtime's locale (on Windows the
109// system language; LANG is unset there), else English. Claude Code's settings are not read: they can hold keys and
110// helper commands, and the mod needs none of that.
111async function detectLang($: EngineInterface, option: unknown): Promise<Lang> {
112 const chosen = langOf(option)
113 if (chosen !== null) return chosen
114 try {
115 const fromEnv =
116 langOf(await $.env.get('LC_ALL')) ?? langOf(await $.env.get('LC_MESSAGES')) ?? langOf(await $.env.get('LANG'))
117 if (fromEnv !== null) return fromEnv
118 } catch {
119 // No environment: the runtime's locale decides.
120 }
121 return langOf(runtimeLocale()) ?? 'en'
122}
123
124async function currentLang($: EngineInterface): Promise<Lang> {
125 return (await read($, language)) ?? fallbackLang
126}
127
128async function refreshContext($: EngineInterface): Promise<void> {
129 const { context: c } = await $.session.usage()
130 const fill: ContextFill = { percent: c.percent ?? null, tokens: c.tokens ?? null, window: c.window }
131 await update($, context, () => fill)
132}
133
134// The session's own auto-compact threshold (a local estimate, no API request); 83 % when it gives none.
135async function refreshThreshold($: EngineInterface): Promise<void> {
136 let next: Threshold = { percent: FALLBACK_THRESHOLD_PERCENT, isFallback: true }
137 try {
138 const usage = await $.session.usage({ breakdown: 'summary' })
139 const b = usage.context.breakdown
140 const percent = b?.isAutoCompactEnabled ? thresholdPercent(b.autoCompactThreshold, usage.context.window) : null
141 if (percent !== null) next = { percent, isFallback: false }
142 } catch {
143 // No session bound yet: keep the fallback and try again after the next turn.
144 }
145 await update($, threshold, () => next)
146}
147
148// The session's transcript, when small enough for one read.
149async function readTranscript($: EngineInterface, path: string): Promise<string | null> {
150 try {
151 const stat = await $.fs.stat(path)
152 return stat.size <= FS_READ_LIMIT ? await $.fs.read(path) : null
153 } catch {
154 return null
155 }
156}
157
158// A lifetime this session read itself: shown, and kept on disk with the time it was confirmed.
159async function settleTtl($: EngineInterface, found: Ttl): Promise<void> {
160 await update($, ttl, () => found)
161 const at = await $.clock.now()
162 await writePersisted($, p => ({ ...p, ttlMinutes: found.minutes, ttlConfirmedAt: at }))
163}
164
165// The lifetime last seen, for a session that knows none yet (a new one, or one Claude Code started over after
166// /compact or /clear): confirmed when read within the last day, else shown with „?“.
167async function applyRememberedTtl($: EngineInterface): Promise<void> {
168 if ((await read($, ttl)) !== null) return
169 const persisted = await readPersisted($)
170 const minutes = persisted.ttlMinutes
171 if (minutes !== 5 && minutes !== 60) return
172 const at = await $.clock.now()
173 const confirmedAt = persisted.ttlConfirmedAt
174 const isConfirmed = typeof confirmedAt === 'number' && at - confirmedAt <= CONFIRMED_FOR_MS
175 const guess: Ttl = { minutes, source: isConfirmed ? 'confirmed' : 'remembered' }
176 await update($, ttl, () => guess)
177}
178
179// A lifetime taken from an earlier session, which a reading in this one replaces.
180const isCarriedOver = (life: Ttl | null): boolean => life === null || life.source === 'remembered' || life.source === 'confirmed'
181
182// Reads the lifetime the session's cache writes actually use, and the last response's time when none is known yet.
183async function detectFromTranscript($: EngineInterface): Promise<void> {
184 const known = await read($, ttl)
185 const hasResponse = (await read($, lastResponseAt)) !== null
186 if (!isCarriedOver(known) && hasResponse) return
187
188 const path = await read($, transcriptPath)
189 if (!path) return
190 const text = await readTranscript($, path)
191 if (!text) return
192
193 const minutes = ttlFromTranscript(text)
194 if (minutes !== null && isCarriedOver(known)) await settleTtl($, { minutes, source: 'transcript' })
195 if (!hasResponse) {
196 const at = lastResponseFromTranscript(text)
197 if (at !== null) await update($, lastResponseAt, prev => prev ?? at)
198 }
199}
200
201async function queueCompact($: EngineInterface): Promise<void> {
202 const t = strings(await currentLang($))
203 $.ui.toast(t.compactQueued)
204 try {
205 await $.command.run({ command: 'compact' })
206 } catch (error) {
207 $.ui.toast(t.compactQueueFailed(String(error)))
208 }
209}
210
211// ── What outlives a session ───────────────────────────────────────────────────────────────────────────────────────
212
213// The user's home folder without a trailing separator. The paths under it go with forward slashes, which Windows
214// takes as well.
215async function homeDir($: EngineInterface): Promise<string | null> {
216 try {
217 const home = (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME'))
218 return home ? home.replace(/[\\/]+$/, '') : null
219 } catch {
220 return null
221 }
222}
223
224// A map of session ids to times with one session set (a time) or taken out (null); the latest SESSIONS_KEEP stay.
225function withSession(all: Record<string, number> | undefined, id: string, at: number | null): Record<string, number> {
226 const next = { ...(all ?? {}) }
227 if (at === null) delete next[id]
228 else next[id] = at
229 const kept = Object.entries(next)
230 .filter((entry): entry is [string, number] => typeof entry[1] === 'number')
231 .sort((a, b) => b[1] - a[1])
232 .slice(0, SESSIONS_KEEP)
233 return Object.fromEntries(kept)
234}
235
236// Drops the remembered pauses and keep-warms of sessions that are gone: no transcript `<id>.jsonl` in any project
237// folder (the session was deleted). Unsure is never gone: an unreadable or empty projects folder drops nothing, and
238// this session is kept.
239async function pruneSessions($: EngineInterface): Promise<void> {
240 const persisted = await readPersisted($)
241 const keep = await read($, sessionId)
242 const ids = [...new Set([...Object.keys(persisted.pausedSessions ?? {}), ...Object.keys(persisted.keepWarmSessions ?? {})])].filter(
243 id => id !== keep,
244 )
245 if (ids.length === 0) return
246 const home = await homeDir($)
247 if (home === null) return
248 const transcripts = new Set<string>()
249 try {
250 const dirs = (await $.fs.list(`${home}/.claude/projects`)).filter(entry => entry.kind === 'dir')
251 if (dirs.length === 0) return
252 for (const dir of dirs) {
253 for (const entry of await $.fs.list(`${home}/.claude/projects/${dir.name}`)) {
254 if (entry.kind === 'file' && entry.name.endsWith('.jsonl')) transcripts.add(entry.name.slice(0, -'.jsonl'.length))
255 }
256 }
257 } catch {
258 return
259 }
260 if (transcripts.size === 0) return
261 const gone = ids.filter(id => !transcripts.has(id))
262 if (gone.length === 0) return
263 await writePersisted($, p => {
264 const paused = { ...(p.pausedSessions ?? {}) }
265 const kept = { ...(p.keepWarmSessions ?? {}) }
266 for (const id of gone) {
267 delete paused[id]
268 delete kept[id]
269 }
270 return { ...p, pausedSessions: paused, keepWarmSessions: kept }
271 })
272}
273
274async function readPersisted($: EngineInterface): Promise<Persisted> {
275 const home = await homeDir($)
276 if (home === null) return {}
277 try {
278 if (!(await $.fs.exists(`${home}/.claude/cache-weather-and-pause-resume/state.json`))) return {}
279 const value: unknown = JSON.parse(await $.fs.read(`${home}/.claude/cache-weather-and-pause-resume/state.json`))
280 return value !== null && typeof value === 'object' ? (value as Persisted) : {}
281 } catch {
282 return {}
283 }
284}
285
286// Read, change, write: another session may have written in between, so each write starts from the file.
287async function writePersisted($: EngineInterface, change: (p: Persisted) => Persisted): Promise<void> {
288 const home = await homeDir($)
289 if (home === null) return
290 try {
291 const text = `${JSON.stringify(change(await readPersisted($)), null, 2)}\n`
292 await $.fs.write(`${home}/.claude/cache-weather-and-pause-resume/state.json`, text)
293 } catch {
294 // Not writable: the value lives in this session's state alone.
295 }
296}
297
298// The session's id: from the classic hooks, which carry it, else asked for (it rejects until the session binds).
299async function currentSessionId($: EngineInterface): Promise<string | null> {
300 const known = await read($, sessionId)
301 if (known !== null) return known
302 try {
303 const id = await $.session.id()
304 await update($, sessionId, () => id)
305 return id
306 } catch {
307 return null
308 }
309}
310
311// ── Keep-warm ────────────────────────────────────────────────────────────────────────────────────────────────────
312
313// Shortly before the cache lapses, keep-warm asks one question over the conversation as its last request sent it
314// ($.model.fork): the API serves that from the cache, which keeps the cache warm for another lifetime. The question
315// and its answer stay out of the conversation, and the fork may call no tool. A refresh reads the whole context from
316// the cache, so a session nobody works in is kept warm for a while only (keepWarmSpanMs).
317const KEEP_WARM_PROMPT =
318 'Automated keep-warm request of the cache-weather-and-pause-resume mod: it only keeps the prompt cache warm, and ' +
319 'your reply is discarded. Reply with OK and nothing else.'
320// How often keep-warm looks whether a refresh is due; the earliest refresh is a minute before the cache lapses.
321const KEEP_WARM_CHECK_MS = 15_000
322// After a refresh that failed (an API error), the next try waits this long.
323const KEEP_WARM_RETRY_MS = 30_000
324// The notices of Claude Code (`notification_type`) that mean a dialog waits for the person.
325const WAITING_NOTICES = new Set(['permission_prompt', 'elicitation_dialog', 'elicitation_url_dialog'])
326
327// The module's own: a reload starts them over, and its timers go with the old module.
328let keepWarmStarted = false
329let isRefreshing = false
330let retryAt = 0
331
332function startKeepWarm($: EngineInterface): void {
333 if (keepWarmStarted) return
334 keepWarmStarted = true
335 $.clock.every(KEEP_WARM_CHECK_MS, () => {
336 void keepWarmTick($).catch(() => undefined)
337 })
338}
339
340// A response of the conversation's own, or the person's input (switching keep-warm on, answering a question): the
341// idle span starts over, and with a response the cache was read when its request went out.
342// Some of keep-warm's rules follow cachebeat (github.com/ARahim3/cachebeat): only answered requests count, nothing
343// is kept warm after a compaction or a switch of model, and waiting for the person is idle.
344async function markActive($: EngineInterface, at: number, isRequest: boolean): Promise<void> {
345 if (isRequest) await update($, cacheUsedAt, () => at)
346 await update($, activeAt, () => at)
347 await update($, keepWarmNote, () => null)
348 await update($, keepWarmRefreshes, () => 0)
349}
350
351async function keepWarmTick($: EngineInterface): Promise<void> {
352 if (isRefreshing || !(await read($, keepWarm)) || (await read($, keepWarmNote)) !== null) return
353 const at = await $.clock.now()
354 if (at < retryAt) return
355 const life = await read($, ttl)
356 const last = await read($, lastResponseAt)
357 // A resumed session knows its last response alone: the idle span starts there, and stays put as refreshes come.
358 if ((await read($, activeAt)) === null && last !== null) await update($, activeAt, () => last)
359 const step = keepWarmStep({
360 cacheUsedAt: (await read($, cacheUsedAt)) ?? last,
361 activeAt: (await read($, activeAt)) ?? last,
362 ttlMinutes: life?.minutes ?? null,
363 // Claude waiting for the person (a permission prompt, a question) is no work: left open overnight it would keep
364 // refreshing without end.
365 isWorking:
366 !(await read($, isAwaitingPerson)) &&
367 ((await read($, turnRunning)) || (await read($, pausePhase)) === 'pausing'),
368 now: at,
369 })
370 const lang = await currentLang($)
371 const t = strings(lang)
372 if (step === 'ended' && life !== null) {
373 await update($, keepWarmNote, () => 'ended')
374 $.ui.log(t.keepWarmEndedLog(keepWarmSpanText(life.minutes), life.minutes === 60 ? '1-h' : '5-m'))
375 return
376 }
377 if (step !== 'refresh') return
378
379 isRefreshing = true
380 try {
381 const result = await $.model.fork({ prompt: KEEP_WARM_PROMPT })
382 if (!result.isAnswered && result.reason === 'nothing-to-fork') {
383 await update($, keepWarmNote, () => 'missed')
384 return
385 }
386 const usage = result.usage
387 if (isWarmRead(usage)) {
388 await update($, cacheUsedAt, () => at)
389 await update($, lastResponseAt, () => at)
390 await update($, now, () => at)
391 await update($, keepWarmRefreshes, n => n + 1)
392 $.ui.log(t.keepWarmRefreshedLog(groupDigits(usage.cache_read_input_tokens, lang), groupDigits(usage.output_tokens, lang)))
393 } else if (usage.input_tokens + usage.cache_creation_input_tokens > 0) {
394 // Answered, but not from the cache: it had lapsed. The next request of the conversation builds it again.
395 await update($, keepWarmNote, () => 'missed')
396 $.ui.log(t.keepWarmMissedLog)
397 } else {
398 retryAt = at + KEEP_WARM_RETRY_MS
399 }
400 } finally {
401 isRefreshing = false
402 }
403}
404
405// The person's switch, for this session alone: kept under its id, so a restart or a resume keeps it, and a new
406// session (`/clear` too) starts without.
407async function setKeepWarm($: EngineInterface, value: boolean): Promise<void> {
408 await update($, keepWarm, () => value)
409 const at = await $.clock.now()
410 if (value) await markActive($, at, false)
411 const id = await currentSessionId($)
412 if (id === null) return
413 await writePersisted($, p => ({ ...p, keepWarmSessions: withSession(p.keepWarmSessions, id, value ? at : null) }))
414}
415
416async function restoreKeepWarm($: EngineInterface, id: string): Promise<void> {
417 const isOn = (await readPersisted($)).keepWarmSessions?.[id] !== undefined
418 await update($, keepWarm, () => isOn)
419}
420
421// ── The pause button ──────────────────────────────────────────────────────────────────────────────────────────────
422
423async function rememberPause($: EngineInterface, at: number | null): Promise<void> {
424 const id = await currentSessionId($)
425 if (id === null) return
426 if (at === null && (await readPersisted($)).pausedSessions?.[id] === undefined) return
427 await writePersisted($, p => ({ ...p, pausedSessions: withSession(p.pausedSessions, id, at) }))
428}
429
430// A session reopened while paused (the app restarted, the session resumed) shows „Weitermachen“ again.
431async function restorePause($: EngineInterface, id: string): Promise<void> {
432 if ((await read($, pausePhase)) !== 'running') return
433 const since = (await readPersisted($)).pausedSessions?.[id]
434 if (typeof since !== 'number') return
435 const at = await $.clock.now()
436 await update($, pausedAt, () => since)
437 await update($, now, () => at)
438 await setPausePhase($, 'paused')
439}
440
441async function setPausePhase($: EngineInterface, next: PausePhase): Promise<void> {
442 await update($, pausePhase, () => next)
443}
444
445async function enterPausing($: EngineInterface, isDelivered: boolean): Promise<void> {
446 const at = await $.clock.now()
447 await update($, pauseRequestedAt, () => at)
448 await update($, pauseTasksLeft, () => null)
449 await update($, pauseDelivered, () => isDelivered)
450 await update($, now, () => at)
451 await setPausePhase($, 'pausing')
452}
453
454async function enterPaused($: EngineInterface): Promise<void> {
455 const at = await $.clock.now()
456 await update($, pausedAt, () => at)
457 await update($, pauseTasksLeft, () => null)
458 await update($, now, () => at)
459 await setPausePhase($, 'paused')
460 await rememberPause($, at)
461 $.ui.toast(strings(await currentLang($)).pausedToast)
462}
463
464async function enterRunning($: EngineInterface): Promise<void> {
465 await setPausePhase($, 'running')
466 await update($, pauseRequestedAt, () => null)
467 await update($, pausedAt, () => null)
468 await update($, pauseTasksLeft, () => null)
469 await update($, pauseDelivered, () => true)
470 await rememberPause($, null)
471}
472
473// Hands the model a text now: folded into the running turn as a row it reads at its next request, or as a prompt
474// that starts a turn once the session is idle. Answers whether the text waits for the turn's next request.
475async function handToModel($: EngineInterface, text: string): Promise<'appended' | 'submitted'> {
476 if (await read($, turnRunning)) {
477 try {
478 const row = await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })
479 if (row.deny === undefined) return 'appended'
480 } catch {
481 // The run takes no plugin rows: queue the text as a prompt instead.
482 }
483 }
484 const t = strings(await currentLang($))
485 $.prompt.submit({ text, asUser: true }).catch(error => {
486 $.ui.toast(t.sendFailed(String(error)))
487 })
488 return 'submitted'
489}
490
491async function requestPause($: EngineInterface, askAbove: number): Promise<void> {
492 if ((await read($, pausePhase)) !== 'running') return
493 const lang = await currentLang($)
494 await enterPausing($, true)
495 // An appended row waits for the turn's next request; should the turn end first, the Stop hook hands it on.
496 // (Marked after the append: a request racing it at worst hands the model the request twice.)
497 if ((await handToModel($, pauseRequest(askAbove, lang))) === 'appended') await update($, pauseDelivered, () => false)
498 $.ui.toast(strings(lang).pauseRequestedToast)
499}
500
501async function requestResume($: EngineInterface): Promise<void> {
502 const current = await read($, pausePhase)
503 if (current === 'running') return
504 const since = current === 'paused' ? await read($, pausedAt) : await read($, pauseRequestedAt)
505 const minutes = minutesBetween(since, await $.clock.now())
506 const lang = await currentLang($)
507 await enterRunning($)
508 // The plugin's own prompt passes its own prompt.submit hook by, so the note goes ahead of it as a row of its own
509 // (idle: the row waits in the conversation for the turn „weitermachen“ starts).
510 try {
511 const note = resumeContext(minutes, lang)
512 await $.session.append({ message: { type: 'user', content: [{ type: 'text', text: note }] } })
513 } catch {
514 // Without the note „weitermachen“ still reads plainly after the pause report.
515 }
516 $.prompt.submit({ text: resumeText(lang), asUser: true }).catch(error => {
517 $.ui.toast(strings(lang).resumeSendFailed(String(error)))
518 })
519}
520
521// Claude stopped: had it not read the request yet, it does not stop but goes on with the request as the Stop hook's
522// reason (a settings hook's „decision: block“); with nothing in flight any more, the session is paused.
523async function pauseOnStop(
524 $: EngineInterface,
525 tasks: readonly { status: string }[] | undefined,
526 result: ClassicResultOf['classic.Stop'],
527 askAbove: number,
528): Promise<ClassicResultOf['classic.Stop']> {
529 await update($, turnRunning, () => false)
530 if ((await read($, pausePhase)) !== 'pausing') return result
531 if (!(await read($, pauseDelivered))) {
532 await update($, pauseDelivered, () => true)
533 if (result.block !== undefined) return result
534 return { ...result, block: pauseRequest(askAbove, await currentLang($)) }
535 }
536 const left = inFlight(tasks)
537 if (left > 0) {
538 await update($, pauseTasksLeft, () => left)
539 return result
540 }
541 await enterPaused($)
542 return result
543}
544
545export const register: Register = (on, options) => {
546 const askAbove = askAboveMinutes(options)
547 keepWarmStarted = false
548 isRefreshing = false
549 retryAt = 0
550 fallbackLang = langOf(options.language) ?? langOf(runtimeLocale()) ?? 'en'
551
552 on('session.start', async ($, e, next) => {
553 const started = await next(e)
554
555 const lang = await detectLang($, options.language)
556 await update($, language, () => lang)
557 await applyRememberedTtl($)
558 await update($, isCompacting, () => false)
559 await update($, turnRunning, () => false)
560 await $.store.set('codeVersion', CODE_VERSION)
561 await writePersisted($, p => ({ ...p, codeVersion: CODE_VERSION }))
562 const id = await currentSessionId($)
563 if (id !== null) {
564 await restorePause($, id)
565 await restoreKeepWarm($, id)
566 }
567
568 await refreshContext($)
569 await refreshThreshold($)
570 await tick($)
571 await detectFromTranscript($)
572
573 // Redraws the countdown once a minute.
574 $.clock.every(60_000, () => {
575 void tick($)
576 })
577 startKeepWarm($)
578 return started
579 })
580
581 // The four hooks below only observe: should their own work fail, `.catch` hands on what `next` settled to (or runs
582 // it, where it was not called yet), so nothing is refused and nothing runs twice.
583 on('classic.SessionStart', async ($, e, next) => {
584 await update($, transcriptPath, () => e.transcript_path)
585 // A resumed session carries its id here even where `$.session.id()` was not bound yet at session.start; after
586 // /clear the id is a new one, whose pause (none) is restored.
587 await update($, sessionId, () => e.session_id)
588 await restorePause($, e.session_id)
589 await restoreKeepWarm($, e.session_id)
590 // A resumed session says how long ago its last response was: the countdown needs no transcript for it.
591 const seconds = e.seconds_since_last_response
592 if (typeof seconds === 'number' && (await read($, lastResponseAt)) === null) {
593 const at = await $.clock.now()
594 await update($, lastResponseAt, () => at - seconds * 1000)
595 await update($, now, () => at)
596 }
597 // And whether the cache likely expired since, which tells the lifetime; else the one last seen. This start comes on
598 // every new start of the session (resume, /compact, /clear), where session.start does not.
599 const told = lifetimeFromResume(seconds, e.prompt_cache_likely_expired)
600 if (told !== null && isCarriedOver(await read($, ttl))) await settleTtl($, { minutes: told, source: 'session-start' })
601 await applyRememberedTtl($)
602 startKeepWarm($)
603 // Once per session start, a little later: what is remembered of deleted sessions goes.
604 $.clock.after(PRUNE_AFTER_MS, () => {
605 void pruneSessions($).catch(() => undefined)
606 })
607 return next(e)
608 }).catch(($, e, next) => next(e))
609
610 // Each response of the main conversation: the cache was read or written now, and its hit rate. A request that
611 // starts after the pause row was appended carries it.
612 on('turn.step', async function* ($, e, next) {
613 let sentAt: number | null = null
614 if (e.agentId === undefined) {
615 try {
616 if ((await read($, pausePhase)) === 'pausing' && !(await read($, pauseDelivered))) {
617 await update($, pauseDelivered, () => true)
618 }
619 // A request of the conversation's own reads the cache as it goes out. Should a refresh of an idle session come
620 // by here, no turn runs: it is not counted.
621 if (!isRefreshing || (await read($, turnRunning))) sentAt = await $.clock.now()
622 } catch {
623 // Never hold up a request over the band's bookkeeping.
624 }
625 }
626 const result = yield* next(e)
627 if (e.agentId === undefined && result.usage) {
628 const at = await $.clock.now()
629 const rate = hitRate(result.usage)
630 await update($, lastResponseAt, () => at)
631 await update($, lastHitRate, () => rate)
632 await update($, now, () => at)
633 // Answered: the idle span starts over from the request, and Claude waits for no one. A request that failed read
634 // nothing and counts for nothing.
635 if (sentAt !== null) await markActive($, sentAt, true)
636 await update($, isAwaitingPerson, () => false)
637 }
638 return result
639 })
640
641 on('session.measure', async ($, e, next) => {
642 const result = await next(e)
643 if (e.changed.includes('context')) {
644 const fill: ContextFill = {
645 percent: e.context.percent ?? null,
646 tokens: e.context.tokens ?? null,
647 window: e.context.window,
648 }
649 await update($, context, () => fill)
650 }
651 return result
652 })
653
654 on('classic.Stop', async ($, e, next) => {
655 const result = await next(e)
656 if (e.agent_id !== undefined) return result
657 await update($, sessionId, () => e.session_id)
658 try {
659 await update($, transcriptPath, () => e.transcript_path)
660 await detectFromTranscript($)
661 if ((await read($, threshold))?.isFallback !== false) await refreshThreshold($)
662 } catch {
663 // The readings wait for the next stop; the pause below must not.
664 }
665 return pauseOnStop($, e.background_tasks, result, askAbove)
666 }).catch(($, e, next) => next(e))
667
668 // Since Claude Code 2.1.299 turn.start also comes for subagents' turns (with `agentId`): only the main loop's counts.
669 on('turn.start', async ($, e, next) => {
670 if ((e as { agentId?: string }).agentId === undefined) await update($, turnRunning, () => true)
671 return next(e)
672 }).catch(($, e, next) => next(e))
673
674 on('turn.complete', async ($, e, next) => {
675 const result = await next(e)
676 if (e.agentId === undefined) {
677 await update($, turnRunning, () => false)
678 await update($, isAwaitingPerson, () => false)
679 }
680 return result
681 }).catch(($, e, next) => next(e))
682
683 // Claude Code's notice that a permission prompt (or an MCP server's form) waits for the person, about six seconds
684 // after it appeared: Claude waits until the call has run (or was refused). A notification decides nothing; a hook on
685 // classic.PermissionRequest could, and the plugin directory cannot tell that it leaves the answer to the person.
686 on('classic.Notification', async ($, e, next) => {
687 const result = await next(e)
688 if (WAITING_NOTICES.has(e.notification_type)) await update($, isAwaitingPerson, () => true)
689 return result
690 }).catch(($, e, next) => next(e))
691
692 // A question to the person waits for the answer; the answer is input of the person's and starts the idle span over.
693 // Any call of the main conversation that has run ends a wait for a permission prompt. Observes only: the call runs
694 // as it would without the hook.
695 on('tool.call', async ($, e, next) => {
696 if (e.agentId !== undefined) return next(e)
697 const isQuestion = e.tool === 'AskUserQuestion'
698 if (isQuestion) await update($, isAwaitingPerson, () => true)
699 const result = await next(e)
700 try {
701 await update($, isAwaitingPerson, () => false)
702 if (isQuestion) await markActive($, await $.clock.now(), false)
703 } catch {
704 // The call has run; the bookkeeping waits for the next response.
705 }
706 return result
707 }).catch(($, e, next) => next(e))
708
709 // After /clear the conversation the pause belonged to is gone.
710 on('session.end', async ($, e, next) => {
711 const result = await next(e)
712 if (e.reason === 'clear') await enterRunning($)
713 return result
714 }).catch(($, e, next) => next(e))
715
716 // Typed „pausieren“ pauses, „weitermachen“ resumes; while pausing or paused every other prompt carries a note.
717 on('prompt.submit', async ($, e, next) => {
718 const current = await read($, pausePhase)
719 const isPerson = isPersonOrigin(e.origin)
720 const lang = await currentLang($)
721 const withNote = (note: string) => next({ ...e, context: [...(e.context ?? []), note] })
722
723 if (isPerson && current !== 'running' && isResume(e.text)) {
724 const since = current === 'paused' ? await read($, pausedAt) : await read($, pauseRequestedAt)
725 const minutes = minutesBetween(since, await $.clock.now())
726 await enterRunning($)
727 return withNote(resumeContext(minutes, lang))
728 }
729 if (isOwnOrigin(e.origin)) return next(e)
730 if (isPerson && current === 'running' && isPauseCommand(e.text)) {
731 await enterPausing($, true)
732 return withNote(pauseRequest(askAbove, lang))
733 }
734 if (current === 'paused') return withNote(isPerson ? pausedPersonNote(lang) : pausedMachineNote(lang))
735 if (current === 'pausing') {
736 // A turn interrupted before it read the appended request raises no Stop: the next prompt carries it whole.
737 if (await read($, pauseDelivered)) return withNote(pausingNote(lang))
738 await update($, pauseDelivered, () => true)
739 return withNote(pauseRequest(askAbove, lang))
740 }
741 return next(e)
742 }).catch(($, e, next) => next(e))
743
744 on('classic.PostModelSwitch', async ($, e, next) => {
745 const result = await next(e)
746 // Another model has no cache of this conversation: the next request writes it, a refresh would only pay for it.
747 if (e.from_model !== e.to_model) await update($, keepWarmNote, () => 'cold')
748 await settleTtl($, { minutes: e.cache_ttl === '5m' ? 5 : 60, source: 'model-switch' })
749 await refreshThreshold($)
750 return result
751 }).catch(($, e, next) => next(e))
752
753 // After /compact or the button: the summary Claude goes on with, as log lines the model does not read.
754 on('session.compact', async ($, e, next) => {
755 const before = (await read($, context))?.tokens ?? undefined
756 const result = await next(e)
757 if (result.skip !== undefined) return result
758 // The conversation is a new one now: the next request writes its cache, a refresh would read the old one. (A
759 // precompute installs nothing yet, and a subagent's compaction is its own.)
760 if (e.agentId === undefined && e.trigger !== 'precompute') await update($, keepWarmNote, () => 'cold')
761 if (e.trigger !== 'manual' && e.trigger !== 'plugin') return result
762
763 const lang = await currentLang($)
764 for (const line of compactionLines(result.messages, result.tokensBefore ?? before, result.tokensAfter, lang)) {
765 $.ui.log(line)
766 }
767
768 await refreshContext($)
769 return result
770 }).catch(($, e, next) => next(e))
771
772 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
773 if (e.props.hasSurvey) return next(e)
774 const elements = $.ui.resolve(e)
775 const { Box, Text, Button } = elements
776
777 const at = Math.max(await read($, now), 0)
778 const last = await read($, lastResponseAt)
779 const life = await read($, ttl)
780 const rate = await read($, lastHitRate)
781 const fill = await read($, context)
782 const limit = (await read($, threshold)) ?? { percent: FALLBACK_THRESHOLD_PERCENT, isFallback: true }
783 const compacting = await read($, isCompacting)
784 const lang = await currentLang($)
785 const t = strings(lang)
786
787 const share = remainingShare(last, life?.minutes ?? null, at)
788 const sky = weather(fill?.percent ?? null, limit.percent)
789 const isStorm = sky === '⛈️'
790 // A terminal gets narrow signs: an emoji's width differs between terminals and Claude Code (see terminalWeather).
791 const isTerminal = e.surface === 'terminal'
792 const signs = pauseSigns(isTerminal)
793 const terminalSky = terminalWeather(sky)
794 const skyText = isTerminal ? <Text color={terminalSky.tone}>{terminalSky.sign}</Text> : <Text>{sky}</Text>
795 const fillText = fill?.percent === null || fill?.percent === undefined ? '–' : `${fill.percent} %`
796
797 const cacheText =
798 share === null || last === null || life === null
799 ? life === null
800 ? t.cacheUnknown
801 : t.cacheNone
802 : share <= 0
803 ? t.cacheExpired
804 : t.cacheMinutes(remainingMinutes(last, life.minutes, at))
805 const tone = share === null ? 'subtle' : cacheTone(share)
806 // An expired cache leaves its readings stale: hits, context and threshold go until the next response.
807 const isExpired = share !== null && share <= 0
808
809 // Keep-warm's checkbox: the person's choice alone, the mod never switches it. Narrow signs in a terminal; in the
810 // desktop app ☐ / ☑ (emoji were bigger but looked out of place, 2026-10-15). Beside it why keep-warm waits for the
811 // next request, when it does.
812 const isKeptWarm = await read($, keepWarm)
813 const note = await read($, keepWarmNote)
814 const refreshes = await read($, keepWarmRefreshes)
815 const box = isTerminal ? (isKeptWarm ? '[✓]' : '[ ]') : isKeptWarm ? '☑' : '☐'
816 const noteText =
817 !isKeptWarm || note === null || life === null
818 ? null
819 : note === 'ended'
820 ? t.keepWarmEnded(keepWarmSpanText(life.minutes))
821 : note === 'cold'
822 ? t.keepWarmCold
823 : t.keepWarmMissed
824 const keepWarmControl = (
825 <Box flexDirection="row" gap={1}>
826 <Button
827 key="keep-warm"
828 label={`${box} ${t.keepWarm}${isKeptWarm && refreshes > 0 ? ` ↻${refreshes}` : ''}`}
829 plain
830 onPress={async () => setKeepWarm($, !(await read($, keepWarm)))}
831 />
832 {noteText === null ? null : (
833 <Text color="warning" wrap="truncate-end">
834 {noteText}
835 </Text>
836 )}
837 </Box>
838 )
839
840 // The pause button: „Pause“, then what Claude is finishing up, then „Weitermachen“.
841 const phase = await read($, pausePhase)
842 let pause
843 if (phase === 'running') {
844 pause = <Button key="pause" label={`${signs.pause} ${t.pause}`} onPress={() => requestPause($, askAbove)} />
845 } else if (phase === 'pausing') {
846 const left = await read($, pauseTasksLeft)
847 pause = (
848 <Text color="warning" wrap="truncate-end">
849 {`${signs.requested} ${t.pauseRequested}${t.tasksLeft(left)}`}
850 </Text>
851 )
852 } else {
853 const since = sinceText(await read($, pausedAt), at, lang)
854 pause = (
855 <Box flexDirection="row" gap={1}>
856 <Button key="resume" label={`${signs.resume} ${t.resume}`} variant="primary" onPress={() => requestResume($)} />
857 <Text color="success">{t.paused(since)}</Text>
858 </Box>
859 )
860 }
861
862 // The band is one site with a chain of hooks: what another mod beneath draws (savvy-progress' bar) goes under
863 // ours; returning ours alone would hide it. A row's gap sets the two apart and only exists between them.
864 const below = await next(e)
865 const withBelow = (own: typeof below) =>
866 below.type === 'engine' ? (
867 own
868 ) : (
869 <Box flexDirection="column" rowGap={1}>
870 {own}
871 {below}
872 </Box>
873 )
874
875 // The cache's time left as a line of its own over the band's width, coloured as the cache text: a terminal draws
876 // it in line signs, the other surfaces as a picture a few pixels high.
877 let bar = null
878 if (isTerminal) {
879 const cells = terminalBar(share, e.props.bodyColumns)
880 bar = (
881 <Box flexDirection="row">
882 {cells.lit === '' ? null : <Text color={tone}>{cells.lit}</Text>}
883 {cells.spent === '' ? null : <Text dimColor>{cells.spent}</Text>}
884 </Box>
885 )
886 } else if ('Svg' in elements) {
887 const { Svg } = elements
888 bar = <Svg source={svgBar(share)} alt={t.cacheBarAlt(cacheText)} height={SVG_BAR_HEIGHT} />
889 }
890
891 const compact = (
892 <Button
893 key="compact"
894 label={compacting ? t.compacting : t.compact}
895 variant={isStorm ? 'primary' : 'secondary'}
896 onPress={async () => {
897 if (await read($, isCompacting)) return
898 if (e.surface === 'desktop' || e.props.isWorking) {
899 await queueCompact($)
900 return
901 }
902 await update($, isCompacting, () => true)
903 try {
904 const result = await $.session.compact({})
905 if (result.skip !== undefined) $.ui.toast(t.compactSkipped(result.skip))
906 } catch {
907 // A turn started in between: queue it instead.
908 await queueCompact($)
909 } finally {
910 await update($, isCompacting, () => false)
911 }
912 }}
913 />
914 )
915
916 const ttlNote = life === null ? '' : life.source === 'remembered' ? ` (${life.minutes === 60 ? '1 h' : '5 m'}?)` : ''
917 return withBelow(
918 <Box flexDirection="column">
919 <Box flexDirection="row" gap={2}>
920 <Text color={tone} wrap="truncate-end">
921 {`${cacheText}${ttlNote}`}
922 </Text>
923 {keepWarmControl}
924 {isExpired ? null : (
925 <Text>
926 {t.hits} {rate === null ? '–' : `${Math.round(rate * 100)} %`}
927 </Text>
928 )}
929 {skyText}
930 {isExpired ? null : (
931 <Text>
932 {t.ctx} {fillText} / {t.max} {limit.percent} %{limit.isFallback ? '*' : ''}
933 </Text>
934 )}
935 {compact}
936 {pause}
937 </Box>
938 {bar}
939 </Box>,
940 )
941 })
942}
943hooks/i18n.ts 125 lines1// The band's and the toasts' words in German and English; no `$` here, so the tests read them directly.
2
3export type Lang = 'de' | 'en'
4
5/** A language from a setting or a locale (`de`, `de-DE`, `German`, `deutsch`, `en_US.UTF-8`); null when empty. */
6export function langOf(value: unknown): Lang | null {
7 const s = String(value ?? '').trim().toLowerCase()
8 if (!s || s === 'auto') return null
9 if (s === 'de' || /^de[-_.]/.test(s) || s.includes('german') || s.includes('deutsch')) return 'de'
10 return 'en'
11}
12
13/** The host's locale as the module's own runtime reports it (the system's language on Windows, where LANG is unset). */
14export function runtimeLocale(): string | null {
15 try {
16 return typeof Intl === 'undefined' ? null : Intl.DateTimeFormat().resolvedOptions().locale
17 } catch {
18 return null
19 }
20}
21
22export type Strings = {
23 cacheUnknown: string
24 cacheNone: string
25 cacheExpired: string
26 cacheMinutes: (minutes: number) => string
27 hits: string
28 ctx: string
29 max: string
30 compact: string
31 compacting: string
32 pause: string
33 resume: string
34 pauseRequested: string
35 tasksLeft: (n: number | null) => string
36 paused: (since: string) => string
37 compactQueued: string
38 compactQueueFailed: (error: string) => string
39 compactSkipped: (reason: string) => string
40 pauseRequestedToast: string
41 pausedToast: string
42 sendFailed: (error: string) => string
43 resumeSendFailed: (error: string) => string
44 keepWarm: string
45 keepWarmEnded: (span: string) => string
46 keepWarmMissed: string
47 keepWarmCold: string
48 keepWarmRefreshedLog: (read: string, out: string) => string
49 keepWarmEndedLog: (span: string, ttl: string) => string
50 keepWarmMissedLog: string
51 cacheBarAlt: (cache: string) => string
52}
53
54const DE: Strings = {
55 cacheUnknown: 'Cache ?',
56 cacheNone: 'Cache –',
57 cacheExpired: 'Cache abgelaufen',
58 cacheMinutes: m => `Cache ${m} m`,
59 hits: 'Hits',
60 ctx: 'Ctx',
61 max: 'Max',
62 compact: 'Compact',
63 compacting: 'Compact …',
64 pause: 'Pause',
65 resume: 'Weitermachen',
66 pauseRequested: 'Pause angefordert',
67 tasksLeft: n => (n === null || n <= 0 ? '' : n === 1 ? ' · 1 Task läuft noch' : ` · ${n} Tasks laufen noch`),
68 paused: since => `pausiert ${since}`,
69 compactQueued: '/compact eingereiht: startet, sobald Claude fertig ist',
70 compactQueueFailed: error => `/compact ließ sich nicht einreihen: ${error}`,
71 compactSkipped: reason => `Compact übersprungen: ${reason}`,
72 pauseRequestedToast: '⏸ Pause angefordert: Claude lässt Laufendes zu Ende laufen und sichert den Stand.',
73 pausedToast: '⏸ Sitzung pausiert. „Weitermachen“ setzt fort.',
74 sendFailed: error => `Pause: die Nachricht an Claude ließ sich nicht senden: ${error}`,
75 resumeSendFailed: error => `Pause: „weitermachen“ ließ sich nicht senden: ${error}`,
76 keepWarm: 'Keep-warm',
77 keepWarmEnded: span => `endete nach ${span} ohne Eingabe`,
78 keepWarmMissed: 'Cache war schon abgelaufen',
79 keepWarmCold: 'wartet auf die nächste Anfrage',
80 keepWarmRefreshedLog: (read, out) => `↻ Keep-warm: Cache aufgefrischt (${read} Tokens aus dem Cache gelesen, ${out} Tokens Antwort)`,
81 keepWarmEndedLog: (span, ttl) =>
82 `Keep-warm: nach ${span} ohne Eingabe nicht weiter aufgefrischt. Das ist die Grenze beim ${ttl}-Cache, damit eine vergessene Sitzung kein Kontingent mehr verbraucht. Die nächste Eingabe baut den Cache neu auf, dann hält Keep-warm ihn wieder warm.`,
83 keepWarmMissedLog:
84 'Keep-warm: Der Cache war schon abgelaufen, die Auffrischung hat ihn nicht mehr erreicht. Nach der nächsten Anfrage hält Keep-warm ihn wieder warm.',
85 cacheBarAlt: cache => `Restlaufzeit des Caches: ${cache}`,
86}
87
88const EN: Strings = {
89 cacheUnknown: 'Cache ?',
90 cacheNone: 'Cache –',
91 cacheExpired: 'Cache expired',
92 cacheMinutes: m => `Cache ${m} m`,
93 hits: 'Hits',
94 ctx: 'Ctx',
95 max: 'Max',
96 compact: 'Compact',
97 compacting: 'Compact …',
98 pause: 'Pause',
99 resume: 'Resume',
100 pauseRequested: 'Pause requested',
101 tasksLeft: n => (n === null || n <= 0 ? '' : n === 1 ? ' · 1 task still running' : ` · ${n} tasks still running`),
102 paused: since => `paused ${since}`,
103 compactQueued: '/compact queued: it starts once Claude is done',
104 compactQueueFailed: error => `/compact could not be queued: ${error}`,
105 compactSkipped: reason => `Compact skipped: ${reason}`,
106 pauseRequestedToast: '⏸ Pause requested: Claude lets running work finish and saves the state.',
107 pausedToast: '⏸ Session paused. “Resume” carries on.',
108 sendFailed: error => `Pause: the message to Claude could not be sent: ${error}`,
109 resumeSendFailed: error => `Pause: “continue” could not be sent: ${error}`,
110 keepWarm: 'Keep-warm',
111 keepWarmEnded: span => `stopped after ${span} without input`,
112 keepWarmMissed: 'the cache had already expired',
113 keepWarmCold: 'waiting for the next request',
114 keepWarmRefreshedLog: (read, out) => `↻ Keep-warm: cache refreshed (${read} tokens read from the cache, ${out} tokens of reply)`,
115 keepWarmEndedLog: (span, ttl) =>
116 `Keep-warm: no more refreshes after ${span} without input. That is the limit for the ${ttl} cache, so a forgotten session spends no more usage. The next input builds the cache afresh, and keep-warm keeps it warm again.`,
117 keepWarmMissedLog:
118 'Keep-warm: the cache had already expired, so the refresh no longer reached it. After the next request keep-warm keeps it warm again.',
119 cacheBarAlt: cache => `Cache time left: ${cache}`,
120}
121
122export function strings(lang: Lang): Strings {
123 return lang === 'de' ? DE : EN
124}
125hooks/logic.ts 298 lines1// Pure helpers of the cache-weather-and-pause-resume band: no `$`, so the tests can call them directly.
2
3import type { Lang } from './i18n'
4
5/** The auto-compact threshold when the session does not say: 83 % of the window. */
6export const FALLBACK_THRESHOLD_PERCENT = 83
7
8/** A log line the terminal draws whole is up to 2000 characters; stay below it. */
9export const LOG_CHUNK = 1800
10
11export type Weather = '☀️' | '🌧️' | '⛈️'
12
13export type CacheTone = 'success' | 'warning' | 'error'
14
15/** What is left of the cache, as a share of its lifetime (0..1), or null before the first response. */
16export function remainingShare(lastResponseAt: number | null, ttlMinutes: number | null, now: number): number | null {
17 if (lastResponseAt === null || ttlMinutes === null) return null
18 const ttlMs = ttlMinutes * 60_000
19 return Math.max(0, Math.min(1, (lastResponseAt + ttlMs - now) / ttlMs))
20}
21
22/** Whole minutes left, rounded up, so the label reads 0 only when the cache is gone. */
23export function remainingMinutes(lastResponseAt: number, ttlMinutes: number, now: number): number {
24 return Math.max(0, Math.ceil((lastResponseAt + ttlMinutes * 60_000 - now) / 60_000))
25}
26
27/** Green, yellow from 60 % of the lifetime left, red from 20 %. */
28export function cacheTone(share: number): CacheTone {
29 if (share <= 0.2) return 'error'
30 if (share <= 0.6) return 'warning'
31 return 'success'
32}
33
34/**
35 * The cache bar's line in a terminal, `columns` cells wide: line signs in the middle of their cells, a heavy line for
36 * what is left of the lifetime, a light one for what has run out. (Block signs at a cell's foot sat too far from the
37 * row above, 2026-10-15.) A cell stays lit while any of its slice is left.
38 */
39export function terminalBar(share: number | null, columns: number): { lit: string; spent: string } {
40 const width = Math.max(0, Math.floor(columns))
41 const lit = share === null || share <= 0 ? 0 : Math.min(width, Math.ceil(share * width))
42 return { lit: '━'.repeat(lit), spent: '─'.repeat(width - lit) }
43}
44
45// The picture's bar: a few pixels high, below a gap that sets it off from the row above.
46const BAR_GAP = 6
47const BAR_THICKNESS = 4
48
49/** How high the desktop app, VS Code and the phone draw the cache bar's picture, its gap above included (CSS px). */
50export const SVG_BAR_HEIGHT = BAR_GAP + BAR_THICKNESS
51
52// Fixed colours: an SVG drawn as an image reads no theme. Each reads on a light and a dark background alike.
53const BAR_COLOURS: Record<CacheTone, string> = { success: '#30a46c', warning: '#f5a524', error: '#e5484d' }
54
55/**
56 * The cache bar as an SVG, stretched over the band's width: wider than any band, so the slot it is drawn in caps it,
57 * and without a fixed aspect ratio, so it keeps its few pixels of height. A grey track, lit for what is left, below a
58 * transparent gap.
59 */
60export function svgBar(share: number | null): string {
61 const width = 4000
62 const lit = share === null ? 0 : Math.round(Math.max(0, Math.min(1, share)) * width)
63 const bar = `y="${BAR_GAP}" height="${BAR_THICKNESS}"`
64 const fill = share === null ? '' : `<rect ${bar} width="${lit}" fill="${BAR_COLOURS[cacheTone(share)]}"/>`
65 return (
66 `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${SVG_BAR_HEIGHT}" ` +
67 `viewBox="0 0 ${width} ${SVG_BAR_HEIGHT}" preserveAspectRatio="none">` +
68 `<rect ${bar} width="${width}" fill="#808080" fill-opacity="0.25"/>${lit > 0 ? fill : ''}</svg>`
69 )
70}
71
72/** How long before the cache lapses keep-warm refreshes it: a minute for the five-minute cache, five for the hour's. */
73export function keepWarmLeadMs(ttlMinutes: 5 | 60): number {
74 return ttlMinutes === 5 ? 60_000 : 5 * 60_000
75}
76
77/**
78 * How long keep-warm goes on in a session nobody works in (paused, or quiet after its last turn), from the last
79 * request or input: 8 hours for the hour's cache, 45 minutes for the five-minute one, whose refreshes come twelve times
80 * as often.
81 */
82export function keepWarmSpanMs(ttlMinutes: 5 | 60): number {
83 return ttlMinutes === 5 ? 45 * 60_000 : 8 * 60 * 60_000
84}
85
86/** The idle span as the band and the log name it: „8 h“, „45 m“. */
87export function keepWarmSpanText(ttlMinutes: 5 | 60): string {
88 const minutes = keepWarmSpanMs(ttlMinutes) / 60_000
89 return minutes >= 60 ? `${minutes / 60} h` : `${minutes} m`
90}
91
92/**
93 * What keep-warm does now: `wait` for the refresh's time, `refresh`, or neither because the cache has `lapsed` (a
94 * refresh would build it afresh) or the idle span has `ended`. While Claude works there is no span.
95 *
96 * `cacheUsedAt` is when a request last read or wrote the cache (the conversation's own, or a refresh); `activeAt` the
97 * last request or input that was no refresh, where the idle span starts.
98 */
99export function keepWarmStep(s: {
100 cacheUsedAt: number | null
101 activeAt: number | null
102 ttlMinutes: 5 | 60 | null
103 isWorking: boolean
104 now: number
105}): 'wait' | 'refresh' | 'lapsed' | 'ended' {
106 if (s.cacheUsedAt === null || s.ttlMinutes === null) return 'wait'
107 const lapsesAt = s.cacheUsedAt + s.ttlMinutes * 60_000
108 if (s.now >= lapsesAt) return 'lapsed'
109 if (s.now < lapsesAt - keepWarmLeadMs(s.ttlMinutes)) return 'wait'
110 if (!s.isWorking && s.now >= (s.activeAt ?? s.cacheUsedAt) + keepWarmSpanMs(s.ttlMinutes)) return 'ended'
111 return 'refresh'
112}
113
114/**
115 * Whether a refresh was served from the conversation's cache: nearly all of its input read from it. The system prompt
116 * alone may be served from a cache other sessions share, so some read tokens do not yet say the conversation's was warm.
117 */
118export function isWarmRead(usage: {
119 input_tokens: number
120 cache_read_input_tokens: number
121 cache_creation_input_tokens: number
122}): boolean {
123 const rate = hitRate(usage)
124 return rate !== null && rate >= 0.8
125}
126
127/** The share of the input tokens the cache served, or null when the response read no input. */
128export function hitRate(usage: {
129 input_tokens: number
130 cache_read_input_tokens: number
131 cache_creation_input_tokens: number
132}): number | null {
133 const total = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
134 return total > 0 ? usage.cache_read_input_tokens / total : null
135}
136
137/**
138 * The weather as a terminal draws it: narrow signs without emoji presentation, in the weather's colour. Terminals count
139 * an emoji's width their own way (the desktop app's terminal one column where Claude Code counts two), and the band then
140 * keeps the rest of an earlier frame; these signs take one column everywhere (2026-10-14).
141 */
142export function terminalWeather(sky: Weather): { sign: string; tone: 'warning' | 'suggestion' | 'error' } {
143 // Theme colours, so light and dark themes both read: a yellow sun, light-blue rain, a red storm.
144 if (sky === '☀️') return { sign: '☼', tone: 'warning' }
145 if (sky === '🌧️') return { sign: '≋', tone: 'suggestion' }
146 return { sign: '↯', tone: 'error' }
147}
148
149/** The signs of the pause button: emoji where the desktop app draws the band, narrow signs in a terminal. */
150export function pauseSigns(isTerminal: boolean): { pause: string; resume: string; requested: string } {
151 return isTerminal ? { pause: '❚❚', resume: '►', requested: '❚❚' } : { pause: '⏸', resume: '▶', requested: '⏳' }
152}
153
154/**
155 * The lifetime a resumed session's start tells, or null: Claude Code reports the cache as likely expired exactly when
156 * the last response is older than the lifetime, so a last response between 5 and 60 minutes old decides it.
157 */
158export function lifetimeFromResume(seconds: unknown, isLikelyExpired: unknown): 5 | 60 | null {
159 if (typeof seconds !== 'number' || typeof isLikelyExpired !== 'boolean') return null
160 if (seconds <= 5 * 60 || seconds >= 60 * 60) return null
161 return isLikelyExpired ? 5 : 60
162}
163
164/** Sunny up to half the threshold, rain up to 80 % of it, a storm above. */
165export function weather(contextPercent: number | null, thresholdPercent: number): Weather {
166 const ratio = (contextPercent ?? 0) / thresholdPercent
167 if (ratio <= 0.5) return '☀️'
168 if (ratio <= 0.8) return '🌧️'
169 return '⛈️'
170}
171
172/** The threshold in percent of the window, or null when the session gives none. */
173export function thresholdPercent(autoCompactThreshold: number | undefined, window: number): number | null {
174 if (!autoCompactThreshold || !window) return null
175 return Math.round((autoCompactThreshold / window) * 100)
176}
177
178/**
179 * The cache lifetime the session's last cache write used, read off transcript lines (JSONL): the API reports each
180 * write split by lifetime (`ephemeral_5m_input_tokens`, `ephemeral_1h_input_tokens`). The latest write that wrote
181 * anything decides; null when the text holds none.
182 */
183export function ttlFromTranscript(text: string): 5 | 60 | null {
184 const blocks = text.match(/"cache_creation":\{[^}]*\}/g) ?? []
185 for (let i = blocks.length - 1; i >= 0; i--) {
186 const block = blocks[i] ?? ''
187 const fiveMinutes = Number(/"ephemeral_5m_input_tokens":(\d+)/.exec(block)?.[1] ?? 0)
188 const oneHour = Number(/"ephemeral_1h_input_tokens":(\d+)/.exec(block)?.[1] ?? 0)
189 if (fiveMinutes > 0) return 5
190 if (oneHour > 0) return 60
191 }
192 return null
193}
194
195/** The time of the last assistant response in transcript lines (epoch ms), or null. */
196export function lastResponseFromTranscript(text: string): number | null {
197 const lines = text.split('\n')
198 for (let i = lines.length - 1; i >= 0; i--) {
199 const line = lines[i] ?? ''
200 if (!line.includes('"type":"assistant"') || !line.includes('"usage"')) continue
201 const stamp = /"timestamp":"([^"]+)"/.exec(line)?.[1]
202 const at = stamp ? Date.parse(stamp) : NaN
203 if (!Number.isNaN(at)) return at
204 }
205 return null
206}
207
208/** 412300 → "412.300" (German thousands) or "412,300" (English). */
209export function groupDigits(n: number | undefined | null, lang: Lang): string {
210 if (n === undefined || n === null) return '?'
211 return Math.round(n)
212 .toString()
213 .replace(/\B(?=(\d{3})+(?!\d))/g, lang === 'de' ? '.' : ',')
214}
215
216/**
217 * The log lines after a compaction: one line with the tokens before and after, then the summary Claude goes on with,
218 * cut at paragraphs and numbered when it takes several lines. The summary is the kept message that says the session
219 * continues, else the first one.
220 */
221export function compactionLines(
222 messages: readonly { role: string; text: string }[],
223 countBefore: number | undefined,
224 countAfter: number | undefined,
225 lang: Lang,
226): string[] {
227 const summary =
228 messages.find(m => m.role === 'user' && /continued from a previous conversation/i.test(m.text))?.text ??
229 messages[0]?.text ??
230 ''
231 const counts = `${groupDigits(countBefore, lang)} → ${groupDigits(countAfter, lang)}`
232 const head =
233 lang === 'de'
234 ? `🗜️ Compact: ${counts} Tokens · Zusammenfassung, mit der Claude weitermacht:`
235 : `🗜️ Compact: ${counts} tokens · the summary Claude carries on with:`
236 const chunks = chunkSummary(summary)
237 if (chunks.length === 0) return [head, lang === 'de' ? '(keine Zusammenfassung im Ergebnis)' : '(no summary in the result)']
238 return [head, ...chunks.map((chunk, i) => (chunks.length > 1 ? `(${i + 1}/${chunks.length}) ${chunk}` : chunk))]
239}
240
241/**
242 * Cuts a long summary into log lines of at most `max` characters, at paragraphs first, then at line breaks, then at
243 * sentence ends, and only as a last resort mid-text; nothing is dropped.
244 */
245export function chunkSummary(text: string, max = LOG_CHUNK): string[] {
246 const pieces = splitPieces(text.trim(), max)
247 const chunks: string[] = []
248 let current = ''
249 for (const piece of pieces) {
250 const joined = current ? `${current}\n\n${piece}` : piece
251 if (joined.length <= max) {
252 current = joined
253 continue
254 }
255 if (current) chunks.push(current)
256 current = piece
257 }
258 if (current) chunks.push(current)
259 return chunks
260}
261
262// Paragraphs, each cut further until it fits.
263function splitPieces(text: string, max: number): string[] {
264 const out: string[] = []
265 for (const paragraph of text.split(/\n\s*\n/)) {
266 const p = paragraph.trim()
267 if (!p) continue
268 if (p.length <= max) {
269 out.push(p)
270 continue
271 }
272 out.push(...cutToFit(p, max))
273 }
274 return out
275}
276
277// One overlong paragraph: at line breaks, then sentence ends, then hard.
278function cutToFit(paragraph: string, max: number): string[] {
279 const out: string[] = []
280 let rest = paragraph
281 while (rest.length > max) {
282 const window = rest.slice(0, max)
283 let cut = window.lastIndexOf('\n')
284 if (cut < max / 2) {
285 const sentence = Math.max(window.lastIndexOf('. '), window.lastIndexOf('! '), window.lastIndexOf('? '))
286 cut = sentence >= max / 2 ? sentence + 1 : cut
287 }
288 if (cut < max / 2) {
289 const space = window.lastIndexOf(' ')
290 cut = space >= max / 2 ? space : max
291 }
292 out.push(rest.slice(0, cut).trim())
293 rest = rest.slice(cut).trim()
294 }
295 if (rest) out.push(rest)
296 return out
297}
298hooks/pause.ts 145 lines1// The pause button's words to the model and the rules it reads the prompts by; no `$` here, so the tests read them
2// directly. The words follow the mod's language; reading what the person types understands both languages.
3
4import type { Lang } from './i18n'
5
6export const PLUGIN = 'cache-weather-and-pause-resume'
7export const DEFAULT_ASK_ABOVE_MINUTES = 5
8
9/** What the „Weitermachen“ / „Resume“ button submits, as the person would type it. */
10export function resumeText(lang: Lang): string {
11 return lang === 'de' ? 'weitermachen' : 'continue'
12}
13
14/** The request the „Pause“ button (or a typed „pausieren“ / „pause“) hands the model. */
15export function pauseRequest(askAboveMinutes: number, lang: Lang): string {
16 const minutes = formatMinutes(askAboveMinutes, lang)
17 if (lang === 'de') {
18 return [
19 '⏸ Pause-Knopf: Der Mensch bittet dich, die Sitzung zu pausieren.',
20 '',
21 '1. Starte ab jetzt nichts Neues mehr: keine neuen Arbeitsschritte, Läufe, Agenten oder Werkzeugaufrufe dafür. Was gerade läuft, läuft zu Ende.',
22 `2. Prüfe die laufenden Hintergrund-Tasks (Läufe, Agenten, Monitore, Workflows) und schätze je Task die ungefähre Restlaufzeit. Braucht einer voraussichtlich noch länger als ${minutes} (oder hat er kein absehbares Ende), frag den Menschen, als Dialog (AskUserQuestion), sofern verfügbar, und nenne je Task die Restlaufzeit: „jetzt beenden und nach ‚weitermachen‘ neu starten“ (nur anbieten, wenn der Task einen Neustart zulässt, sonst sag, warum nicht) oder „zu Ende laufen lassen“. Kürzere Tasks wartest du ohne Rückfrage ab: beende deinen Zug, du wirst bei ihrem Ende geweckt, und sichere dann auch ihr Ergebnis.`,
23 '3. Sichere den aktuellen Stand nach den Regeln dieses Projekts (CLAUDE.md, Memory), so dass du nach „weitermachen“ nahtlos fortsetzen kannst: was erledigt ist, was lief, was abgebrochen wurde und mit welchem Befehl es neu startet, was als Nächstes kommt. Gibt das Projekt keine Regel vor, verwirf nichts und committe nicht ungefragt; dann hält deine Pausen-Meldung den Stand fest.',
24 '4. Melde dann kurz „Pausiert“ mit diesem Stand (keinen Übergabe-Prompt) und beende deinen Zug. Danach tust du nichts mehr, bis der Mensch „weitermachen“ schreibt oder den Knopf „Weitermachen“ drückt. Geplante Weckungen (Cron, /loop, ScheduleWakeup) führst du in der Pause nicht aus.',
25 ].join('\n')
26 }
27 return [
28 '⏸ Pause button: the person asks you to pause the session.',
29 '',
30 '1. From now on, start nothing new: no new work steps, runs, agents or tool calls for them. Whatever is running finishes.',
31 `2. Check the running background tasks (runs, agents, monitors, workflows) and estimate each one's remaining time. If one will likely take longer than ${minutes} (or has no foreseeable end), ask the person, as a dialog (AskUserQuestion) where available, naming each task's remaining time: "stop it now and restart it after 'continue'" (offer this only if the task can be restarted, otherwise say why not) or "let it finish". Wait for shorter tasks without asking: end your turn, you will be woken when they finish, and then save their results too.`,
32 "3. Save the current state following this project's rules (CLAUDE.md, memory) so you can pick up seamlessly after \"continue\": what is done, what was running, what was stopped and the command that restarts it, what comes next. If the project sets no rule, discard nothing and do not commit unasked; your pause report then records the state.",
33 '4. Then report "Paused" briefly with this state (no handoff prompt) and end your turn. After that, do nothing until the person writes "continue" or presses the "Resume" button. Do not run scheduled wakeups (cron, /loop, ScheduleWakeup) during the pause.',
34 ].join('\n')
35}
36
37/** What the model reads beside „weitermachen“ / „continue“. */
38export function resumeContext(pausedMinutes: number | null, lang: Lang): string {
39 if (lang === 'de') {
40 const length = pausedMinutes === null ? '' : ` (sie dauerte ${formatMinutes(pausedMinutes, lang)})`
41 return `▶ Pause-Knopf: Die Pause ist beendet${length}. Setze die Arbeit am gesicherten Stand fort: starte zuerst die bei der Pause beendeten Tasks neu, wo das vereinbart war, dann weiter wie geplant.`
42 }
43 const length = pausedMinutes === null ? '' : ` (it lasted ${formatMinutes(pausedMinutes, lang)})`
44 return `▶ Pause button: the pause is over${length}. Carry on from the saved state: first restart the tasks stopped for the pause where that was agreed, then continue as planned.`
45}
46
47/** Beside a message of the person's while the session is paused. */
48export function pausedPersonNote(lang: Lang): string {
49 return lang === 'de'
50 ? '⏸ Pause-Knopf: Die Sitzung ist pausiert. Geh auf diese Nachricht ein; die pausierte Arbeit nimmst du erst nach „weitermachen“ wieder auf, und neue Läufe startest du nur, wenn diese Nachricht es ausdrücklich verlangt.'
51 : '⏸ Pause button: the session is paused. Respond to this message; resume the paused work only after "continue", and start new runs only if this message explicitly asks for them.'
52}
53
54/** Beside a task notification, a scheduled wakeup or another session's message while paused. */
55export function pausedMachineNote(lang: Lang): string {
56 return lang === 'de'
57 ? '⏸ Pause-Knopf: Die Sitzung ist pausiert. Starte nichts: sichere höchstens ein Ergebnis, das hier ankommt, vermerke es in einem Satz und beende deinen Zug.'
58 : '⏸ Pause button: the session is paused. Start nothing: at most save a result that arrives here, note it in one sentence and end your turn.'
59}
60
61/** Beside any message while the pause is asked for and Claude is still finishing up. */
62export function pausingNote(lang: Lang): string {
63 return lang === 'de'
64 ? '⏸ Pause-Knopf: Eine Pause ist angefordert. Starte nichts Neues; sobald nichts mehr läuft, sichere den Stand, melde „Pausiert“ und warte auf „weitermachen“.'
65 : '⏸ Pause button: a pause has been requested. Start nothing new; once nothing is running any more, save the state, report "Paused" and wait for "continue".'
66}
67
68// A whole message of „weitermachen“ / „continue“ and their kin, or one that starts with one of them and says more.
69const RESUME_WHOLE =
70 /^\s*(weiter|weitermachen|weiter machen|fortsetzen|fortfahren|mach weiter|resume|continue|go on|carry on)\s*[.!]*\s*$/i
71const RESUME_START = /^\s*(weitermachen|weiter machen|mach weiter|continue|resume)\b/i
72// Only a whole message pauses: „pausiere die Läufe nicht“ / „pause the music in the test“ must not.
73const PAUSE_WHOLE =
74 /^\s*((bitte|please)\s+)?(pause|pausieren|pausiere|pausier)(\s+(bitte|die sitzung|jetzt|please|the session|now))?\s*[.!]*\s*$/i
75
76export function isResume(text: string): boolean {
77 return RESUME_WHOLE.test(text) || RESUME_START.test(text)
78}
79
80export function isPauseCommand(text: string): boolean {
81 return PAUSE_WHOLE.test(text)
82}
83
84/** Prompt origins that are not the person: their text never pauses or resumes, and they read the machine note. */
85const MACHINE_ORIGINS = new Set([
86 'task-notification',
87 'scheduled-trigger',
88 'peer',
89 'peer-send-message',
90 'projects-relay',
91 'coordinator',
92 'observer',
93 'observer-activity',
94])
95
96export type OriginLike = { kind: string; name?: string } | undefined
97
98export function isOwnOrigin(origin: OriginLike): boolean {
99 return origin?.kind === 'plugin' && origin.name === PLUGIN
100}
101
102export function isPersonOrigin(origin: OriginLike): boolean {
103 if (origin === undefined) return true
104 if (origin.kind === 'plugin') return origin.name === PLUGIN
105 return !MACHINE_ORIGINS.has(origin.kind)
106}
107
108const DONE = new Set(['completed', 'complete', 'done', 'failed', 'killed', 'stopped', 'cancelled', 'canceled', 'error'])
109
110/** The background tasks still in flight: the Stop hook's list holds only those, the filter is a guard. */
111export function inFlight(tasks: readonly { status: string }[] | undefined): number {
112 return (tasks ?? []).filter(t => !DONE.has(t.status.toLowerCase())).length
113}
114
115export function minutesBetween(from: number | null, to: number): number | null {
116 if (from === null || to < from) return null
117 return Math.floor((to - from) / 60_000)
118}
119
120export function formatMinutes(minutes: number, lang: Lang): string {
121 if (minutes < 60) {
122 if (lang === 'de') return minutes === 1 ? '1 Minute' : `${minutes} Minuten`
123 return minutes === 1 ? '1 minute' : `${minutes} minutes`
124 }
125 const h = Math.floor(minutes / 60)
126 const m = minutes % 60
127 return m === 0 ? `${h} h` : `${h} h ${String(m).padStart(2, '0')} min`
128}
129
130/** For the band, kept short: „seit 12 m“ / „for 12 m“, „seit 1 h 05 m“, „gerade eben“ / „just now“. */
131export function sinceText(from: number | null, now: number, lang: Lang): string {
132 const minutes = minutesBetween(from, now)
133 const since = lang === 'de' ? 'seit' : 'for'
134 if (minutes === null || minutes < 1) return lang === 'de' ? 'gerade eben' : 'just now'
135 if (minutes < 60) return `${since} ${minutes} m`
136 const h = Math.floor(minutes / 60)
137 const m = minutes % 60
138 return `${since} ${h} h ${String(m).padStart(2, '0')} m`
139}
140
141export function askAboveMinutes(options: Readonly<Record<string, unknown>>): number {
142 const value = options.askAboveMinutes
143 return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.round(value) : DEFAULT_ASK_ABOVE_MINUTES
144}
145types/index.d.ts 66 lines1/**
2 * The cache lifetime and where it came from: the transcript's last cache write, a model switch, a resumed session's
3 * start, or an earlier session — `confirmed` when a session read it within the last day, `remembered` (shown with „?“)
4 * when not.
5 */
6export type Ttl = {
7 minutes: 5 | 60
8 source: 'transcript' | 'model-switch' | 'session-start' | 'confirmed' | 'remembered'
9}
10
11/** The live context window as the status line reads it. */
12export type ContextFill = { percent: number | null; tokens: number | null; window: number }
13
14/** The auto-compact threshold in percent of the window; `isFallback` when the session gave none. */
15export type Threshold = { percent: number; isFallback: boolean }
16
17/** Where the session stands: working, asked to pause and finishing up, or paused until „weitermachen“. */
18export type PausePhase = 'running' | 'pausing' | 'paused'
19
20/**
21 * Why keep-warm waits for the next request: the idle span ran out, a refresh found the cache gone, or the cache is
22 * `cold` after a compaction or a switch to another model (the next request writes the new one).
23 */
24export type KeepWarmNote = 'ended' | 'missed' | 'cold'
25
26declare module 'claude-code' {
27 interface PluginState {
28 'cache-weather-and-pause-resume': {
29 lastResponseAt: number | null
30 ttl: Ttl | null
31 hitRate: number | null
32 context: ContextFill | null
33 threshold: Threshold | null
34 now: number
35 transcriptPath: string | null
36 isCompacting: boolean
37 pausePhase: PausePhase
38 /** When the pause was asked for. */
39 pauseRequestedAt: number | null
40 /** When the session went into the pause. */
41 pausedAt: number | null
42 /** Whether the model has the pause request in front of it (false while an appended row waits for the next request). */
43 pauseDelivered: boolean
44 /** Background tasks still in flight when Claude last stopped while pausing. */
45 pauseTasksLeft: number | null
46 /** Whether a turn of the main conversation runs now. */
47 turnRunning: boolean
48 /** The session's id, the key its pause is kept under in the state file. */
49 sessionId: string | null
50 /** The language the band and the pause texts use, once session.start has read the settings. */
51 language: 'de' | 'en' | null
52 /** Whether keep-warm is on: the person's choice alone, kept on disk. */
53 keepWarm: boolean
54 /** When a request last read or wrote the cache: the conversation's own, or a keep-warm refresh. */
55 cacheUsedAt: number | null
56 /** The last request that was no refresh, or keep-warm switched on: the idle span counts from here. */
57 activeAt: number | null
58 keepWarmNote: KeepWarmNote | null
59 /** Refreshes since the last request that was none. */
60 keepWarmRefreshes: number
61 /** Whether Claude waits for the person: a permission prompt or a question is open. Keep-warm counts it as idle. */
62 isAwaitingPerson: boolean
63 }
64 }
65}
66