Warns you (desktop popup, optional sound) a few minutes before this session's prompt cache goes cold

A Claude Code mod that warns you a few minutes before a session's prompt cache goes cold, so you can send something and keep it warm.
Claude Code caches your conversation on the API side with a one-hour lifetime. While that entry is alive, your next message re-reads it cheaply; once it lapses, the whole conversation is sent and written again at the cache-write price. If you step away from a long session for an hour, you come back to a bill you could have avoided with one keystroke.
Cache Watch sends one notification per idle stretch, warnMinutes (10 by default) before the cache's assumed end: a desktop popup that stays until you dismiss it, optionally a sound, and — if you switch it on — a phone push, which on Claude Code 2.1.293 has nowhere to go. That leg ships off for exactly that reason; The phone leg is the whole story. It says nothing when the cache actually goes cold — by then there is nothing to save.
Why I built it. I keep several long Claude Code sessions open and I walk away from them. This is a personal tool I'm sharing as it is. Issues and ideas are welcome.
Being open about this, since it's a young project and some of it can only be checked by living with it.
| Part | Status |
|---|---|
Windows popup under WSL2 (bin/toast.ps1) | Works. Seen live, and again on 2026-10-07 |
Windows popup with sound (sound: true) | Works. Toast shown with the reminder audio attached, and the chime confirmed heard on 2026-10-07 |
/cache-watch and /cache-watch test in the VS Code extension | Work |
/cache-watch and /cache-watch test in a terminal session | Work. Checked on 2026-10-07 in print mode, both with claude --plugin-dir and loaded from settings.json |
Windows idle detection (bin/idle.ps1, GetLastInputInfo) | Works. Checked live on 2026-10-07: reported 4 s and 40 s correctly, and gated the phone |
Linux idle detection (xprintidle, Mutter) | Untested. Covered by the test suite against a fake command; no X11 or GNOME session on this WSL box to try it against |
Firing log (~/.cache/cache-watch/fire.log, /cache-watch log) | Works. Written live on 2026-10-07, directory created on first use |
| Several sessions warning at once | Works. Watched live on 2026-10-07: parallel warnings landed and the folder in each title told them apart. It also turned up the quoting bug below |
| Phone push | Cannot arrive. Read out of the 2.1.293 binary on 2026-10-07: the tool has no mobile channel at all. Three live attempts with Remote Control connected in the phone app delivered nothing. See The phone leg and docs/01-phone-push.md |
Warning timing (fires once, at ttlMinutes - warnMinutes, subagent requests ignored, settings honoured, logged) | Covered by the test suite (15 tests, mocked clock, claude plugin test .) |
| A scheduled warning firing in a live session | Works. Seen on 2026-10-07 in an interactive terminal session and in a VS Code window — not /cache-watch test, but the timer's own firing, logged as fire |
| Warning timing in a real idle session | Works, and the re-arm guard with it. On 2026-10-07 the log paired early … drift=-231s re-armed with fire … rearms=1 drift=-17s: the host's clock returned the timer nearly four minutes early, the re-arm covered the rest, and the warning landed within 17 s of its time. See The timing |
Linux desktop popup and sound (notify-send) | Untested on a real desktop. The automated test drives a fake notify-send; it has never run against a notification daemon |
| macOS | Not supported. No popup path; /cache-watch test reports desktop: no notifier found |
Built and run on Ubuntu 24.04 under WSL2, with Claude Code 2.1.291, and the phone leg re-read against 2.1.293. It needs a version that loads mods (function hooks). Everything below that is read out of the binary carries the version it was read from, because these internals move.
A mod cannot read the cache's real state. On 2.1.291 nothing on $ carries an expiry or a warm/cold flag: $.session.usage() has the context window, the rate-limit windows and the cost, and the only prompt_cache_warm in the whole API lives inside model-switch events. So this mod keeps its own clock:
ttlMinutes (60) after the last model request of the main conversation. Each new request restarts it.e.agentId): they use other caches. So do other mods' calls to other models.That model is not guesswork about the hour itself. On about 110 idle stretches of more than 30 minutes across my own transcripts, every gap of 59.9 minutes or less between consecutive assistant messages came back with a large cache_read_input_tokens, and every gap of 60.7 minutes or more came back with cache_read near zero and the whole prefix written again. The boundary is sharp and it sits at 60 minutes from the previous response.
If Claude Code ever exposes the real cache state to mods, switch to it. The places to change are schedule() and status() in hooks/register.ts.
This part does not work, and on 2.1.293 it cannot. The phone leg has no delivery path at all: PushNotification reports a push that it never had a way to send. Read this before you turn phone on and trust what the log tells you. The full reading, with the extracted code, is in docs/01-phone-push.md.
The mod asks for the push through Claude Code's own PushNotification tool. Read out of the 2.1.293 binary, that tool decides like this:
isRemote = env.CLAUDE_CODE_REMOTE || isRemoteSession()
transport = isRemote || surfaceCapabilities.replBridgeActive() || <a third source, unresolved>
if (transport && !isRemote && !agentPushNotifEnabled) -> config_off
if (!isRemote && !env.CLAUDE_CODE_DISABLE_NOTIFICATION_PRESENCE_CHECK
&& userPresent()) -> user_present
emit({ type: "os_notification", notificationType: "push_notification" }) // the only send
if (!transport) -> no_transport (local popup only)
else -> pushSent
Two gates — and then a send that goes nowhere near a phone.
The delivery, which is the whole problem. That emit is the tool's entire output. There is no device token, no APNs or FCM call, no request to any server. pushSent is assigned from the transport flag and localSent from whether the session is interactive: both are labels computed from local state, not receipts. The tool's own wording for that branch is "Mobile push requested." And the event they label is documented for local surfaces only:
The surface dispatches to its platform notification channel (iTerm2/Kitty/Ghostty/bell in the terminal; native IPC for desktop/IDE).
No mobile channel is named, the event is dropped from the serialized event stream, and the only consumer found anywhere is the local terminal UI. Measured against that: three live attempts on 2026-10-07 — one scheduled warning, two from ordinary turns — with Remote Control connected in the Claude phone app and its notification permission known good. All three reported success. None arrived, and none showed up in the Remote Control session view either.
The transport flag. replBridgeActive() is a plain in-memory boolean recording that Remote Control (/remote-control, "Control this session from your phone or claude.ai/code") attached to this session. It does not mean the bridge is still alive, and, as above, it does not mean anything on the far end will render a notification. It is also per-session, so with several sessions open it is one bridge each: a bridge on the session you are sitting in does nothing for a warning fired by another one.
The presence check. Claude Code's own idea of whether you are there:
userPresent() = terminalFocus() ?? (Date.now() - lastInteractionTime() < 60_000)
If the surface reports focus, focus alone decides. So a window you left focused and walked away from reads as present for ever — exactly when a push would be the only thing that reaches you. CLAUDE_CODE_DISABLE_NOTIFICATION_PRESENCE_CHECK=1 in your settings env switches that check off; it also stops Claude's own pushes being suppressed, so it is left to you. The firing log records user_present when it happens, which is the evidence to decide on rather than a guess.
This check also behaves differently per surface, which matters if you try to test the leg by hand. On 2026-10-07, with a message typed seconds earlier: a terminal session refused the send with user_present, while a VS Code session under the same conditions answered no_transport instead — the check never fired there. So a hand test in VS Code tells you nothing about the gate, and a hand test in a terminal is swallowed by it unless you set that variable first.
What the mod does instead. It asks the machine, not Claude Code:
| Machine | How |
|---|---|
| Windows (incl. WSL) | GetLastInputInfo, system-wide: counts you typing in any window |
| Linux | xprintidle, else GNOME's org.gnome.Mutter.IdleMonitor.GetIdletime over D-Bus |
| Neither answers | idle unknown, and the mod assumes you are away |
The phone is tried only when the machine says nobody has touched it for awayMinutes (2 by default). Under that, the desktop popup has already reached you and a push would be noise.
Since 2.1.293 gates a leg that cannot deliver, phone now ships off, and the probe only runs when you switch the phone on — it exists to gate the phone and nothing else, so a default install spends no PowerShell call on it and logs idle=unknown. The code is kept as it is: it is correct, and if a mobile channel ever appears the leg starts working by flipping one setting. With the phone on, the measured idle time goes into every log line, and /cache-watch test prints it:
Test sent: Windows: shown, phone: not sent (Claude Code took you to be at the keyboard).
(you last touched this machine 4s ago; a real warning skips the phone under 2 min.)
/cache-watch test always attempts the push, because a test fired by hand is fired by someone present, so it can never show you the real thing.
Until version 0.2.0 the mod reported phone: sent for any result that was not an error — so a skipped push looked like a delivered one. That was the first half of "it says sent and my phone is silent". The second half was that phone: sent then repeated the tool's own pushSent as though it were a delivery; it now reads phone: requested, which is all the tool ever knew.
Push also needs "agentPushNotifEnabled": true in your settings; with it off the tool answers config_off and the mod says so. One trap: setting CLAUDE_CODE_REMOTE makes transport true unconditionally, so the tool reports pushSent: true with nothing on the other end. Don't.
The status table above says what has been checked. This is the other half of it, in one place, because a young tool that is vague about its gaps is worse than one that names them.
One item that used to sit in this list has been settled, and not in the mod's favour: a phone push arriving cannot happen on 2.1.293, because no mobile channel exists. See The phone leg and docs/01-phone-push.md.
Never seen happen, on any machine. These are not believed to be broken; nobody has watched them:
Untested because this machine cannot test it. Built and run on Ubuntu 24.04 under WSL2, so every Linux-desktop path is exercised against a fake command in the test suite and has never met a real notification daemon:
notify-send popup, and whether --urgency=critical really stays until dismissed outside GNOME.canberra-gtk-play, then paplay).xprintidle, else GNOME's Mutter idle monitor over D-Bus)./cache-watch test says desktop: no notifier found.Code with no coverage. One path in the mod is untested and cannot be tested here: the re-arm branch that catches a timer returning more than 30 seconds early. A mocked clock fires exactly on time, so the test kit cannot produce an early timer; it is written to be plainly correct rather than proven, and a skewed host (see Known issue: WSL2 clock skew) is the only thing that exercises it. That host has now exercised it repeatedly — every early … re-armed line in the firing log of 2026-10-07 is this branch running — so it is observed, and still untested.
Known to be incomplete, by choice. When the warning fires and the machine says you are still at the keyboard, the phone leg is dropped rather than deferred. Re-probing the machine a few minutes later, and pushing only if you have gone away by then, would be better and is not built.
The two things that cannot be built at all are below.
Two things this mod should do and cannot, both waiting on Claude Code rather than on the mod. Checked against 2.1.291; if a later build adds either, the mod is a few lines from using it.
phone: all — a push that does not need a live bridge. What you want is the notification Claude's web chat sends to your phone: one that arrives whichever session it came from, with no per-session Remote Control. The CLI has no way to ask for it. The phone is a surface, and a push is an event rendered by attached surfaces; the one notification endpoint in the binary, /api/claude_code/notification/preferences, reads and writes two booleans (agentPushNotifEnabled, inputNeededNotifEnabled) and sends nothing. Server-side pushes exist, but they belong to sessions Anthropic's backend owns — cloud agents, scheduled routines, teleported sessions — not to a local one. And the per-session path is no fallback: on 2.1.293 it does not deliver either, because the event a local session emits is dispatched only to terminal and desktop/IDE channels (The phone leg). So there is no push from a local Claude Code session to a phone at all, by either route. The mod keeps asking and keeps reporting what came back, so the day a channel appears the firing log will show it.
The cache's real state. There is no warm/cold flag or expiry on $: $.session.usage() carries the context window, the rate-limit windows and the cost, and the only prompt_cache_warm in the API lives inside model-switch events, where a mod cannot read it for the session it is in. So the countdown here is a clock of our own, and the one number it needs — when the entry really lapses — is the one number nobody will tell it. If that ever appears, schedule() and status() in hooks/register.ts are the two places to change.
The clock is anchored on the last model request of the main conversation, and the arithmetic from there is exact — the test suite schedules and fires it on a mocked clock. What is not exact is the host timer the mod waits on: on a machine whose monotonic clock runs at the wrong rate the warning lands early or late in real time, by a margin that grows with the wait. That is a host problem, described in Known issue: WSL2 clock skew below, and the mod does not try to correct for it. What it does instead is generic, and absorbs the common cases:
~/.cache/cache-watch/fire.log with the anchor, the time the warning was meant for, the drift between them, which session and folder it came from, and what each channel did. /cache-watch log prints the last ten.early … drift=-231s re-armed followed by fire … rearms=1 drift=-17s, four times over.warnMinutes, so a timer that drifts either way still tells the truth.If you simply prefer a wider margin, that is a setting rather than a bug to live with: warnMinutes: 15 costs you one message.
Not a bug in this mod, but it changes when your warnings land, so it is worth knowing before you file one against the mod.
Under WSL2, a systemd-timesyncd left running alongside Hyper-V's own time synchronisation leaves the kernel's tick wrong, and then CLOCK_MONOTONIC — the clock host timers run on — advances at the wrong rate while CLOCK_REALTIME stays correct. Ubuntu 24.04 and earlier enable timesyncd by default, against Microsoft's own recommendation for WSL, so the default install is affected. It is open upstream and unpatched.
The error is a rate, so it scales with the wait: at the 8.33% measured on the machine this was built on, a 50-minute timer ends after 46.2 real minutes and the warning arrives with about 14 minutes of cache left instead of 10. The sign is not the same everywhere — the upstream report measures 6.9% the other way, firing late — which is why the mod does not try to correct it. Nor is the rate steady on one machine: this one read +8.33% in the afternoon of 2026-10-07 and +6.99% the same evening — positive both times, and it has never been measured negative here. A correction factor would have to be re-measured to stay right, which is the argument for re-arming instead. Check your own machine:
python3 -c "import time; m=time.clock_gettime(time.CLOCK_MONOTONIC); r=time.clock_gettime(time.CLOCK_MONOTONIC_RAW); print(f'monotonic is {(m/r-1)*100:+.2f}% off')"
Roughly +0.00% is healthy. Anything else is the host, and the fix is on the host: disable systemd-timesyncd and restore the tick with sudo adjtimex --tick 10000. Either way the mod's re-arm guard absorbs most of it — The timing above says what it does, and /cache-watch log shows what it did.
Which desktop popup is used depends on the machine. The mod checks each time it notifies:
| Machine | Popup | Sound (if on) |
|---|---|---|
WSL (Windows' powershell.exe reachable) | Windows "reminder" notification, stays until dismissed (bin/toast.ps1) | Windows reminder sound |
Linux desktop (notify-send on PATH) | notify-send --urgency=critical; GNOME keeps it until dismissed, other desktops may time it out | canberra-gtk-play, else paplay with the freedesktop sound |
| Neither (macOS, a server) | none: /cache-watch test reports desktop: no notifier found | none |
Defaults: popup on, phone off, sound off, log on. The phone ships off because on 2.1.293 it cannot arrive — see The phone leg. Every notification names the project folder and the start of your last message, so warnings from parallel sessions can be told apart:
Claude cache cold in 10 min · api-server
“rename the config loader”. Send a message to keep it warm.
**The quoted message is the last one you typed.** Not every user-role message is: the harness posts its own as user messages — background-task notifications, slash-command wrappers, IDE selections, reminders — and they carry no flag that tells them apart. Quoting one put a raw <task-notification><task-id>… in a live popup on 2026-10-07, in a terminal session that had run a background command; the same warning read cleanly from a VS Code session that had not. They are now recognised by their opening tag and skipped, and a message that opens with a tag this mod has not met is dropped rather than quoted.
Linux packages, if missing (Ubuntu names): sudo apt install libnotify-bin for notify-send, and for sound sudo apt install pulseaudio-utils for paplay. canberra-gtk-play is used first if the desktop already has it.
ttlMinutes defaults to 60 because that is the lifetime Claude Code uses, and this mod is for that one and for any longer period a later build might use. Below roughly ten minutes left there is too little time to act on for a warning to be worth sending, so short lifetimes are out of scope today. A fast option — off by default, switched on deliberately, firing one minute before the cache goes cold whatever ttlMinutes says — would cover every lifetime. It is not built.env.CLAUDE_CODE_PLUGIN_DIRS: every VS Code window, every project, the terminal.pluginConfigs.From a Claude Code session:
/plugin marketplace add Ev3nt1ne/cache-watch
/plugin install cache-watch@cache-watch
Or clone it and load it in every session through ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "/path/to/cache-watch" } }
To try it once without changing settings: claude --plugin-dir /path/to/cache-watch.
/config lists them under cache-watch, or put them in ~/.claude/settings.json:
"pluginConfigs": {
"cache-watch": {
"options": {
"warnMinutes": 10,
"ttlMinutes": 60,
"desktop": true,
"phone": false,
"awayMinutes": 2,
"sound": false,
"log": true
}
}
}
| Setting | Default | What it does |
|---|---|---|
warnMinutes | 10 | How long before the assumed end of the cache to warn |
ttlMinutes | 60 | The cache's assumed lifetime. Built for the 60-minute cache and anything longer — see Scope |
desktop | true | The desktop popup |
phone | false | Ask for the phone push. Off by default because on 2.1.293 nothing delivers it — read The phone leg before turning it on |
awayMinutes | 2 | Count you away from the machine after this long. Under it, the phone is not tried |
sound | false | Play the reminder sound with the popup |
log | true | Append one line per firing to ~/.cache/cache-watch/fire.log |
/cache-watch — the settings and the time left in this session/cache-watch test — notify now, on every channel that is on, and report what each one did/cache-watch log — the last ten firings, with their driftCLOCK_MONOTONIC, and under WSL2 that clock commonly runs at the wrong rate; see Known issue: WSL2 clock skew above.hooks/register.ts 421 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4// The prompt cache's own status is not exposed to mods (checked on 2.1.291:
5// neither `$.session.usage()` nor any other noun carries an expiry; only
6// model-switch events carry a `prompt_cache_warm` flag), so this mod keeps its
7// own clock: the cache is assumed warm for `ttlMinutes` after the last request
8// the main conversation sent (subagents and other models use other caches).
9
10type $ = EngineInterface
11
12// WSL reaches Windows' own PowerShell; on it the popup is a Windows notification.
13const POWERSHELL = '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'
14// A Linux desktop's sound, tried in order: libcanberra's event sound, then the file through PulseAudio/PipeWire.
15const LINUX_SOUND =
16 'canberra-gtk-play -i message-new-instant 2>/dev/null || paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga'
17// How long the person has been away from the machine itself, in milliseconds: X11's idle time,
18// else GNOME's (Mutter's idle monitor over D-Bus). Prints nothing where neither answers.
19const LINUX_IDLE =
20 'if command -v xprintidle >/dev/null 2>&1; then xprintidle; exit; fi; ' +
21 'if command -v dbus-send >/dev/null 2>&1; then dbus-send --session --print-reply ' +
22 '--dest=org.gnome.Mutter.IdleMonitor /org/gnome/Mutter/IdleMonitor/Core ' +
23 'org.gnome.Mutter.IdleMonitor.GetIdletime 2>/dev/null | grep -oE "[0-9]+$"; fi'
24const MINUTE = 60_000
25// A timer that fires this much before its time is the host's doing, not ours: re-arm instead of warning early.
26const EARLY_MS = 30_000
27const MAX_REARMS = 3
28// The firing log keeps its last lines and nothing else.
29const LOG_LINES = 200
30
31const lastRequestAt = atom({ plugin: 'cache-watch', key: 'lastRequestAt' } as const, null)
32
33type Settings = {
34 warnMinutes: number
35 ttlMinutes: number
36 desktop: boolean
37 phone: boolean
38 awayMinutes: number
39 sound: boolean
40 log: boolean
41}
42
43function settingsFrom(options: PluginOptions): Settings {
44 const num = (v: unknown, d: number) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : d)
45 const bool = (v: unknown, d: boolean) => (typeof v === 'boolean' ? v : d)
46 return {
47 warnMinutes: num(options.warnMinutes, 10),
48 ttlMinutes: num(options.ttlMinutes, 60),
49 desktop: bool(options.desktop, true),
50 phone: bool(options.phone, false),
51 awayMinutes: num(options.awayMinutes, 2),
52 sound: bool(options.sound, false),
53 log: bool(options.log, true),
54 }
55}
56
57/** The pending warning and what it was scheduled for; a module variable, so a reload drops it and session.start sets it again. */
58type Plan = { anchor: number; firesAt: number; rearms: number }
59let timer: { cancel: () => void } | null = null
60
61/** One line appended to `~/.cache/cache-watch/fire.log`; never allowed to break a warning. */
62async function appendLog($: $, s: Settings, line: string) {
63 if (!s.log) return
64 try {
65 const home = await $.env.get('HOME')
66 if (!home) return
67 const dir = `${home}/.cache/cache-watch`
68 const path = `${dir}/fire.log`
69 let previous = ''
70 try {
71 previous = await $.fs.read(path)
72 } catch {
73 // No log yet.
74 }
75 const kept = [...previous.split('\n').filter(Boolean), line].slice(-LOG_LINES).join('\n')
76 try {
77 await $.fs.write(path, `${kept}\n`)
78 } catch {
79 await $.process.run(['mkdir', '-p', dir], { timeoutMs: 10_000 })
80 await $.fs.write(path, `${kept}\n`)
81 }
82 } catch {
83 // The log is a convenience. A machine that will not take it still gets its warning.
84 }
85}
86
87function iso(ms: number) {
88 return new Date(ms).toISOString().replace('T', ' ').slice(0, 19)
89}
90
91async function readLog($: $): Promise<string[]> {
92 const home = await $.env.get('HOME')
93 if (!home) return []
94 try {
95 return (await $.fs.read(`${home}/.cache/cache-watch/fire.log`)).split('\n').filter(Boolean)
96 } catch {
97 return []
98 }
99}
100
101async function folderOf($: $): Promise<string> {
102 const cwd = await $.session.cwd()
103 return cwd.split('/').filter(Boolean).at(-1) ?? cwd
104}
105
106/**
107 * Not every user-role message is one you typed. The harness posts its own as user messages —
108 * background-task notifications, slash-command wrappers, IDE selections, reminders — and they
109 * carry no flag that tells them apart, so they are recognised by the tag they open with.
110 * Quoting one put `<task-notification><task-id>…` in a live popup, which is how this was found.
111 */
112const ENVELOPE =
113 /^\s*<\/?(?:task-notification|command-name|command-message|command-args|local-command-std(?:out|err)|system-reminder|ide_selection|user-prompt-submit-hook)\b/
114
115/** The last thing you actually asked here, so a warning from one of several open sessions is recognisable. */
116async function gistOf($: $): Promise<string> {
117 const lastAsk = (await $.session.messages())
118 .filter(m => m.role === 'user' && m.text.trim() && !ENVELOPE.test(m.text))
119 .at(-1)
120 if (!lastAsk) return ''
121 const text = lastAsk.text.trim().replace(/\s+/g, ' ')
122 // An envelope the list above does not know yet is still better dropped than quoted.
123 if (text.startsWith('<')) return ''
124 return text.length > 60 ? `${text.slice(0, 60)}…` : text
125}
126
127type Desktop = 'windows' | 'linux' | 'none'
128
129/** Which desktop popup this machine can raise: Windows through WSL, else a Linux notification daemon. */
130async function desktopKind($: $): Promise<Desktop> {
131 try {
132 await $.fs.stat(POWERSHELL)
133 return 'windows'
134 } catch {
135 // Not WSL, or no Windows drive mounted.
136 }
137 try {
138 const r = await $.process.run(['sh', '-c', 'command -v notify-send'], { timeoutMs: 5_000 })
139 if (r.exitCode === 0) return 'linux'
140 } catch {
141 // No shell to ask.
142 }
143 return 'none'
144}
145
146/**
147 * How long since the person last touched this machine — any window, not just this
148 * session — in milliseconds, or null where nothing on the machine will say.
149 *
150 * This is the machine's own idea of the person, and it is a better one than Claude
151 * Code's: Claude Code counts you present while its terminal has focus, or if you typed
152 * into this session in the last minute, so a window left focused and walked away from
153 * reads as present for ever — exactly when a push to the phone would be the only thing
154 * that reaches you.
155 */
156async function idleMs($: $, machine: Desktop): Promise<number | null> {
157 try {
158 if (machine === 'windows') {
159 const script = await $.fs.read(`${$.plugin.root}/bin/idle.ps1`)
160 const r = await $.process.run([POWERSHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', '-'], {
161 stdin: script,
162 timeoutMs: 20_000,
163 })
164 const ms = /idle=(\d+)/.exec(r.stdout)?.[1]
165 return ms === undefined ? null : Number(ms)
166 }
167 if (machine === 'linux') {
168 const r = await $.process.run(['sh', '-c', LINUX_IDLE], { timeoutMs: 10_000 })
169 const ms = /(\d+)/.exec(r.stdout)?.[1]
170 return ms === undefined ? null : Number(ms)
171 }
172 } catch {
173 // Nothing on this machine to ask.
174 }
175 return null
176}
177
178function seconds(ms: number) {
179 return ms < 90_000 ? `${Math.round(ms / 1000)}s` : `${Math.round(ms / MINUTE)}min`
180}
181
182async function linuxPopup($: $, s: Settings, title: string, body: string): Promise<string> {
183 // Critical urgency: GNOME keeps it on screen until dismissed; other desktops may time it out.
184 const r = await $.process.run(['notify-send', '--app-name=Claude Code', '--urgency=critical', title, body], {
185 timeoutMs: 10_000,
186 })
187 if (r.exitCode !== 0) return `Linux: failed (${(r.stderr || r.stdout || `exit ${r.exitCode}`).trim().slice(0, 120)})`
188 if (!s.sound) return 'Linux: shown'
189 const snd = await $.process.run(['sh', '-c', LINUX_SOUND], { timeoutMs: 10_000 })
190 return snd.exitCode === 0 ? 'Linux: shown, sound played' : 'Linux: shown, sound failed (needs canberra-gtk-play or paplay)'
191}
192
193/**
194 * What `PushNotification` answers. On 2.1.293 it does not reach a phone at all: the whole
195 * tool is one local `os_notification` event, and `pushSent` is a label computed from a
196 * transport flag rather than anything the other end confirmed. See docs/01-phone-push.md.
197 */
198type CallResult = { deny?: string; isError?: boolean; text?: string; result?: unknown }
199type PushResult = {
200 pushSent?: boolean
201 localSent?: boolean
202 disabledReason?: 'config_off' | 'user_present' | 'no_transport'
203}
204
205/** The phone leg, reported as the tool actually answered it: `pushSent` is a request, not a delivery. */
206function phoneReport(r: CallResult): string {
207 if (r.deny) return `phone: refused (${r.deny})`
208 if (r.isError) return `phone: failed (${String(r.text ?? '').slice(0, 120)})`
209 const push = (r.result ?? null) as PushResult | null
210 if (push?.pushSent) return 'phone: requested (no phone delivery on 2.1.293)'
211 switch (push?.disabledReason) {
212 case 'no_transport':
213 return 'phone: not sent (no phone reachable: Remote Control is not connected on this machine)'
214 case 'user_present':
215 return 'phone: not sent (Claude Code took you to be at the keyboard)'
216 case 'config_off':
217 return 'phone: not sent (push is off: agentPushNotifEnabled in settings)'
218 }
219 if (push?.localSent) return 'phone: not sent (the terminal popup went out instead)'
220 return `phone: not sent (${String(r.text ?? JSON.stringify(push)).slice(0, 100)})`
221}
222
223/**
224 * Sends the warning on every channel switched on; returns what each one did and the
225 * machine's idle time, where it could be read.
226 *
227 * `force` sends the push even when you are plainly at the keyboard: what `test` wants,
228 * since a test fired by hand is always fired by someone present.
229 */
230async function notify(
231 $: $,
232 s: Settings,
233 title: string,
234 body: string,
235 force = false,
236): Promise<{ done: string[]; idle: number | null }> {
237 const done: string[] = []
238 // Probed whatever the popup setting says, because the phone leg reads the machine's idle time.
239 const machine = await desktopKind($)
240 const kind: Desktop = s.desktop ? machine : 'none'
241 let idle: number | null = null
242 if (s.desktop && kind === 'none') done.push('desktop: no notifier found (not WSL, and no notify-send)')
243 if (kind === 'linux') {
244 try {
245 done.push(await linuxPopup($, s, title, body))
246 } catch (err) {
247 done.push(`Linux: failed (${String(err instanceof Error ? err.message : err)})`)
248 }
249 }
250 if (kind === 'windows') {
251 try {
252 const script = await $.fs.read(`${$.plugin.root}/bin/toast.ps1`)
253 const r = await $.process.run([POWERSHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', '-'], {
254 stdin: script,
255 env: {
256 CW_TITLE: title,
257 CW_BODY: body,
258 CW_SOUND: s.sound ? '1' : '0',
259 // Hands these variables to the Windows process.
260 WSLENV: 'CW_TITLE:CW_BODY:CW_SOUND',
261 },
262 timeoutMs: 20_000,
263 })
264 done.push(r.stdout.includes('shown') ? 'Windows: shown' : `Windows: failed (${(r.stderr || r.stdout).trim().slice(0, 120)})`)
265 } catch (err) {
266 done.push(`Windows: failed (${String(err instanceof Error ? err.message : err)})`)
267 }
268 }
269 if (s.phone) {
270 idle = await idleMs($, machine)
271 // At the machine: the popup has already reached you, and a push would only be noise.
272 if (!force && idle !== null && idle < s.awayMinutes * MINUTE) {
273 done.push(`phone: not tried (you touched this machine ${seconds(idle)} ago)`)
274 } else {
275 try {
276 const r = (await $.tool.call({
277 tool: 'PushNotification',
278 message: `${title}: ${body}`.slice(0, 190),
279 status: 'proactive',
280 })) as CallResult
281 done.push(phoneReport(r))
282 } catch (err) {
283 done.push(`phone: failed (${String(err instanceof Error ? err.message : err)})`)
284 }
285 }
286 }
287 if (!done.length) done.push('no channel is switched on')
288 return { done, idle }
289}
290
291/**
292 * The warning itself, told how long it had meant to wait, so a timer the host
293 * fires early is re-armed and one it fires late says the time that is really left.
294 */
295async function warn($: $, s: Settings, plan: Plan) {
296 const now = await $.clock.now()
297 const drift = now - plan.firesAt
298 if (drift < -EARLY_MS && plan.rearms < MAX_REARMS) {
299 // The host's timer came back before its time; wait out the rest.
300 await appendLog(
301 $,
302 s,
303 `${iso(now)} early anchor=${iso(plan.anchor)} planned=${iso(plan.firesAt)} drift=${Math.round(drift / 1000)}s re-armed`,
304 )
305 timer = $.clock.after(plan.firesAt - now, () => void warn($, s, { ...plan, rearms: plan.rearms + 1 }))
306 return
307 }
308 const left = Math.max(0, Math.round((plan.anchor + s.ttlMinutes * MINUTE - now) / MINUTE))
309 const folder = await folderOf($)
310 const gist = await gistOf($)
311 const { done, idle } = await notify(
312 $,
313 s,
314 `Claude cache cold in ${left} min · ${folder}`,
315 `${gist ? `“${gist}”. ` : ''}Send a message to keep it warm.`,
316 )
317 $.ui.log(`cache-watch: warned (${done.join(', ')})`)
318 await appendLog(
319 $,
320 s,
321 `${iso(now)} fire anchor=${iso(plan.anchor)} planned=${iso(plan.firesAt)} drift=${Math.round(drift / 1000)}s ` +
322 `left=${left}min warn=${s.warnMinutes} ttl=${s.ttlMinutes} rearms=${plan.rearms} ` +
323 `idle=${idle === null ? 'unknown' : seconds(idle)} ` +
324 `session=${(await $.session.id()).slice(0, 8)} cwd=${folder} | ${done.join(', ')}`,
325 )
326}
327
328/** Sets the one pending warning from the last request's time. */
329async function schedule($: $, s: Settings) {
330 timer?.cancel()
331 timer = null
332 const anchor = await read($, lastRequestAt)
333 if (anchor === null || s.warnMinutes >= s.ttlMinutes) return
334 const firesAt = anchor + (s.ttlMinutes - s.warnMinutes) * MINUTE
335 const delay = firesAt - (await $.clock.now())
336 // Already inside the warning window (a reload, or settings changed): too late to warn usefully.
337 if (delay <= 0) return
338 timer = $.clock.after(delay, () => void warn($, s, { anchor, firesAt, rearms: 0 }))
339}
340
341/** Marks the cache as refreshed now and moves the warning with it. */
342async function touch($: $, s: Settings) {
343 const now = await $.clock.now()
344 await update($, lastRequestAt, () => now)
345 await schedule($, s)
346}
347
348function minutes(ms: number) {
349 return `${Math.max(0, Math.round(ms / MINUTE))} min`
350}
351
352async function status($: $, s: Settings): Promise<string> {
353 const anchor = await read($, lastRequestAt)
354 const channels = [s.desktop && 'desktop popup', s.phone && 'phone', s.sound && 'sound'].filter(Boolean).join(' + ') || 'none'
355 const gate = s.phone
356 ? ` The phone is only tried when you have been away from this machine for ${s.awayMinutes} min — and on 2.1.293 nothing delivers it (see README).`
357 : ''
358 const conf = `Warns ${s.warnMinutes} min before a ${s.ttlMinutes}-min cache goes cold, via ${channels}.${gate}`
359 if (anchor === null) return `${conf}\nNo request sent yet in this session: nothing to keep warm.`
360 const left = anchor + s.ttlMinutes * MINUTE - (await $.clock.now())
361 if (left <= 0) return `${conf}\nThe cache is probably cold (last request ${minutes(-left + s.ttlMinutes * MINUTE)} ago).`
362 return `${conf}\nAbout ${minutes(left)} left; the warning ${left > s.warnMinutes * MINUTE ? `comes in ${minutes(left - s.warnMinutes * MINUTE)}` : 'window has started'}.`
363}
364
365export const register: Register = (on, options) => {
366 const s = settingsFrom(options)
367
368 on('session.start', async ($, e, next) => {
369 await $.command.register({
370 name: 'cache-watch',
371 description: 'Cache warning: time left in this session, `test` to notify now, `log` for the last firings',
372 argumentHint: '[test|log]',
373 })
374 // After a reload, the clock picks up from the last request this session recorded.
375 await schedule($, s)
376 return next(e)
377 })
378
379 // Each request of the main conversation refreshes its cache, so the clock restarts.
380 // Twice per request: before it goes out, and again when the response is in, which is
381 // when the API writes the cache entry the next request will read.
382 on('turn.step', async function* ($, e, next) {
383 const main = !e.agentId
384 if (main) await touch($, s)
385 const result = yield* next(e)
386 if (main) await touch($, s)
387 return result
388 })
389
390 on('command.run', { command: 'cache-watch' }, async ($, e) => {
391 const arg = e.args.trim()
392 if (arg === 'test') {
393 const folder = await folderOf($)
394 const gist = await gistOf($)
395 // `force`: a test is always fired by someone present, so the gate would always skip it.
396 const { done, idle } = await notify(
397 $,
398 s,
399 `Claude cache: test notification · ${folder}`,
400 `${gist ? `“${gist}”. ` : ''}This is how the warning will look.`,
401 true,
402 )
403 // Logged like a firing, so the log's own plumbing is checked by the same command.
404 await appendLog(
405 $,
406 s,
407 `${iso(await $.clock.now())} test idle=${idle === null ? 'unknown' : seconds(idle)} ` +
408 `session=${(await $.session.id()).slice(0, 8)} cwd=${folder} | ${done.join(', ')}`,
409 )
410 const seen = idle === null ? 'this machine reports no idle time' : `you last touched this machine ${seconds(idle)} ago`
411 return { text: `Test sent: ${done.join(', ')}. (${seen}; a real warning skips the phone under ${s.awayMinutes} min.)` }
412 }
413 if (arg === 'log') {
414 const lines = await readLog($)
415 if (!lines.length) return { text: s.log ? 'No warning has fired yet on this machine.' : 'The firing log is switched off (`log: false`).' }
416 return { text: `~/.cache/cache-watch/fire.log, last ${Math.min(10, lines.length)} of ${lines.length}:\n${lines.slice(-10).join('\n')}` }
417 }
418 return { text: await status($, s) }
419 })
420}
421types/index.d.ts 18 lines1/**
2 * When the main conversation's last model request of this session was recorded
3 * (ms since the epoch); null before the first one.
4 *
5 * Written twice per request: once as it goes out, once when its response is in.
6 * The second write is the one the clock is meant to run from, because the API
7 * writes the cache entry when the response completes.
8 */
9export type CacheWatchLastRequest = number | null
10
11declare module 'claude-code' {
12 interface PluginState {
13 'cache-watch': {
14 lastRequestAt: CacheWatchLastRequest
15 }
16 }
17}
18