SLOPSHOPPER

cache-weather-and-pause-resume

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…

newbandguardtoastpromptmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-weather-and-pause-resume
› 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 Cache ? [ ] Keep-warm Hits – ≋ Ctx 49 % / Max 83 %* [ Compact ] [ ❚❚ Pause ] ──────────────────────────────────────────────────────────────────────────────────────────────────── ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Cache ? [ ] Keep-warm Hits – ≋ Ctx 49 % / Max 83 %* [ Compact ] [ ❚❚ Pause ] ────────────────────────────────────────────────────────────────────────────────────────────────────
README

cache-weather-and-pause-resume

CI License: MIT

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.

The band in a terminal: cache 58 minutes left, Keep-warm checked, hits 98 %, a sun at 5 % of a 97 % threshold, Compact and Pause; below it the green cache bar over the band's width

The band in the Claude desktop app: cache 20 minutes left, Keep-warm checked, hits 99 %, a sun at 8 % of a 97 % threshold, Compact and Pause; below it the cache bar, yellow with a third of the hour left

Examples

  1. Step away while a long run is going. Claude has a test suite running in the background and you need to leave. Press ⏸ Pause: Claude starts nothing new, lets the run finish (or asks you first, with its remaining time, when it will take longer than 5 minutes), notes where it stands and reports “Paused”. Back at your desk, press ▶ Resume and Claude carries on from there, even after the app was restarted.
  2. Come back before the cache runs out. The band counts down the prompt cache since the last response, say 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.
  3. Compact when it suits you. The weather turns ⛈️ as the context nears the auto-compact threshold (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.
  4. Keep the cache warm through a long run or a break. Claude waits for a build that takes 70 minutes, or you are in a meeting. Check Keep-warm: shortly before the cache runs out the mod refreshes it, so the next request reads the conversation from the cache instead of writing all of it again.

You can also type: “pause” pauses, “continue” resumes (in German “pausieren” and „weitermachen“).

What the band shows

PartWhat it shows
CacheMinutes 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-warmA 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.
HitsThe 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 / MaxContext fill in percent and the auto-compact threshold (*: the fallback of 83 %, when the session reports none).
CompactSame as /compact; highlighted during ⛈️. In the desktop app, or while Claude is working, /compact is queued.
⏸ Pause / ▶ ResumeAsks Claude to pause the session, and carries on (below). In a terminal ❚❚ Pause and ► Resume.
Cache barA 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.

The pause button

StateBandWhat happens
working⏸ PauseA press hands Claude the pause request.
pause requested⏳ Pause requested · 2 tasks still runningClaude lets running work finish, asks about long tasks and saves the state. Typing “continue” takes the request back.
paused▶ Resume · paused for 12 mClaude 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):

  1. Start nothing new; whatever is running finishes.
  2. Estimate the remaining time of each background task (runs, agents, monitors, workflows). For one that will take longer than the threshold (5 minutes by default, option 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.
  3. Save the state following the project's rules (CLAUDE.md, memory): what is done, what ran, what was stopped and how to restart it, what comes next. Without a project rule: discard nothing, commit nothing unasked.
  4. Report “Paused” briefly and wait. Scheduled wakeups (cron, /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:

  • Pressed while Claude works: the request joins the running turn as a row Claude reads at its next request (after the tool call in progress). Should the turn end before that, the Stop hook keeps Claude from stopping and hands the request on; after an interrupted turn the next message carries it.
  • Pressed while Claude is idle: the request goes in as a prompt in your words.
  • Paused is reached once Claude stops and the Stop hook reports no background work in flight. While tasks still run the band counts them.
  • A pause is kept per session in ~/.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.

Keep-warm

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:

  • When: a minute before the end of a 5-minute cache, five minutes before the end of a 1-hour cache.
  • How: one question over the conversation as its last request sent it ($.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.
  • While Claude works (a long run, a subagent, a pause being requested): for as long as it takes.
  • While the session is idle or paused, or Claude waits for you (a permission prompt, a question): for 8 hours with the 1-hour cache, for 45 minutes with the 5-minute one, counted from the last request or input. Then the band says “stopped after 8 h without input”, and a line explains why: a forgotten session should not keep spending. Your next message builds the cache again and keep-warm takes over again.
  • When a refresh finds the cache already gone (the machine slept, say), keep-warm waits for the next request rather than build it afresh, and says so.
  • After /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”).
  • Only answered requests count: one that failed read nothing from the cache and starts nothing over.
  • Your switch alone: the mod never checks or unchecks it. It holds for this session: a restart or a resume keeps it, a new session and /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.

Install

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.

Settings

OptionDefaultWhat it does
languageautode 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).
askAboveMinutes5From 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 } }
}

Sharing the band with other mods

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.

Where the values come from

  • Cache lifetime (5 min / 1 h): from the session's transcript, which records every cache write split by lifetime (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 ?.
  • Hit rate and time of the last response: from every model request of the main conversation; in a resumed session, until its first request, from the session start's report of how long ago the last response was.
  • Context and threshold: $.session.usage(); the threshold from the local estimate (no API request).

What the mod does on your machine

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:

WhatWhenExactly
a prompt in your nameyou press Pause while Claude is idlethe pause request: fixed text from hooks/pause.ts, with your askAboveMinutes
a prompt in your nameyou press Resume“continue” (in German „weitermachen“)
a row in the conversationyou press Pause while Claude works, or Resumethe pause request; a fixed note that the pause is over, with its length in minutes
a note added to your prompta 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 /compactyou press Compact while Claude works, or in the desktop appqueued like a typed /compact; otherwise the button compacts through the session's own compaction call
the Stop hook's answerClaude stops before it read the pause requestClaude goes on once, with the pause request as the reason
a question beside the conversationKeep-warm is checked and the cache is about to run outa 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.

State file

~/.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.

Requirements

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.

Development

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.

Support

Questions, bugs and ideas: GitHub issues. The mod needs no account and no sample data: install it and open a session.

Credits

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.

License

MIT © 2026 Sven Moderow

Source 5 files
hooks/register.tsx 943 lines
1import { 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}
943
hooks/i18n.ts 125 lines
1// 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}
125
hooks/logic.ts 298 lines
1// 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}
298
hooks/pause.ts 145 lines
1// 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}
145
types/index.d.ts 66 lines
1/**
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