SLOPSHOPPER

Cache Watch

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

newcommandprocesstimer
v0.3.0MITupdated 2026-10-08Ev3nt1ne/cache-watch
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-watch
› 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-watch ⎿ cache-watch: Warns 10 min before a 60-min cache goes cold, via desktop popup. ⎿ cache-watch: No request sent yet in this session: nothing to keep warm. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Cache Watch

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.

Status: what has been tested

Being open about this, since it's a young project and some of it can only be checked by living with it.

PartStatus
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 extensionWork
/cache-watch and /cache-watch test in a terminal sessionWork. 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 onceWorks. 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 pushCannot 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 sessionWorks. 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 sessionWorks, 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
macOSNot 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.

How the time is computed, and why it is a guess

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:

  • The cache is taken as warm for ttlMinutes (60) after the last model request of the main conversation. Each new request restarts it.
  • Requests from subagents are ignored (e.agentId): they use other caches. So do other mods' calls to other models.
  • The clock is set twice per request — when the request goes out, and again when its response is in. The second one is the anchor that matters, because the API writes the cache entry when the response completes.

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.

The phone leg

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:

MachineHow
Windows (incl. WSL)GetLastInputInfo, system-wide: counts you typing in any window
Linuxxprintidle, else GNOME's org.gnome.Mutter.IdleMonitor.GetIdletime over D-Bus
Neither answersidle 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.

What is not verified

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:

  • A firing across a machine sleep. The re-arm guard is written for a timer that returns early, but a suspended host has not been tried.

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:

  • The notify-send popup, and whether --urgency=critical really stays until dismissed outside GNOME.
  • The Linux sound path (canberra-gtk-play, then paplay).
  • Linux idle detection (xprintidle, else GNOME's Mutter idle monitor over D-Bus).
  • macOS is not supported at all — there is no popup path, and /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.

Not possible yet

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 timing

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:

  • Every firing appends a line to ~/.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.
  • A timer that comes back more than 30 seconds early is re-armed for the rest of its wait, up to three times, and the early return is logged. On a host 8.33% fast — the skew measured while building this — the first 50-minute wait returns 231 s early, the re-arm covers the remainder, and the warning lands within about 20 s of its time. That is measured, not predicted: on 2026-10-07 the log recorded early … drift=-231s re-armed followed by fire … rearms=1 drift=-17s, four times over.
  • The warning names the minutes that are really left at the moment it fires, not the configured 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.

Known issue: WSL2 clock skew makes warnings early

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.

Notifications

Which desktop popup is used depends on the machine. The mod checks each time it notifies:

MachinePopupSound (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 outcanberra-gtk-play, else paplay with the freedesktop sound
Neither (macOS, a server)none: /cache-watch test reports desktop: no notifier foundnone

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.

Scope

  • Built for the long cache. 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.
  • Loaded everywhere if you list the folder in env.CLAUDE_CODE_PLUGIN_DIRS: every VS Code window, every project, the terminal.
  • Survives restarts: the mod and its settings are plain files. Sessions started after a reboot load it as before.
  • Settings are global: one set of values for all sessions, in pluginConfigs.
  • Only the countdown is per session: each session has its own cache, so each keeps its own clock and warns on its own. A session that is not running (window closed, machine off) has no clock and warns nobody.
  • Parallel sessions warn in parallel. Seven open sessions are seven clocks. The folder in the title is how you tell which one is asking.

Install

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.

Settings

/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
    }
  }
}
SettingDefaultWhat it does
warnMinutes10How long before the assumed end of the cache to warn
ttlMinutes60The cache's assumed lifetime. Built for the 60-minute cache and anything longer — see Scope
desktoptrueThe desktop popup
phonefalseAsk for the phone push. Off by default because on 2.1.293 nothing delivers it — read The phone leg before turning it on
awayMinutes2Count you away from the machine after this long. Under it, the phone is not tried
soundfalsePlay the reminder sound with the popup
logtrueAppend one line per firing to ~/.cache/cache-watch/fire.log

Commands

  • /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 drift

Limits

  • The cache's real state is invisible to mods, so everything here is a clock, not a reading.
  • A skewed host clock moves the warning. Host timers use CLOCK_MONOTONIC, and under WSL2 that clock commonly runs at the wrong rate; see Known issue: WSL2 clock skew above.
  • **The phone leg cannot deliver on 2.1.
Source 2 files
hooks/register.ts 421 lines
1import { 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}
421
types/index.d.ts 18 lines
1/**
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