A Herdr plugin that puts a prompt-cache countdown on every agent pane, and marks the turns that missed the cache.
┌ w2Y:p9 ────────────────────────────────────────────────── ⚡ 47m left ─┐
│ > implement the retry logic │
A cold turn re-reads your whole conversation at full input price and makes you wait for it. The badge tells you, before you step away, how long you have — and tells you afterwards when you paid for it anyway.
Two surfaces, one number. The tab bar (top right) covers the pane you are in — including a pane
alone in its tab, which has no border to draw on. The agent list carries the whole herd at once:
maple has 59 minutes left, while biscuit-opencode and comet are both cold and have to rebuild
their prefix on the next turn. The selected row is drawn on a lighter background, so it gets its own
colour — one green cannot read on both.
- Install
- What the badge means
- Where the numbers come from
- Supported harnesses
- Adding a harness
- The watcher
- CLI
- Config
- How it works
- Development
- License
Needs Node 22.6+ or Bun — whichever you already have. There is no build step and no binary to download; the plugin runs its TypeScript directly.
git clone git@github.com:AltanS/herdr-cache-alert.git
cd herdr-cache-alert && ./bin/herdr-cache-alert setup✓ plugin linked into 2 session(s) from ~/src/herdr-cache-alert
✓ cli installed ~/.local/bin/herdr-cache-alert
✓ keybinding prefix+alt+c toggles the agent-list badge
✓ sidebar cache tokens styled in the agent sidebar
✓ tab bar countdown added to the tab bar
✓ agent list coloured badge ON — prefix+alt+c hides it
✓ watcher started (pid 41233) — ticks every 30s
✓ badges painted 6 of 6 agent panes
✓ did it · · nothing needed doing · ! needs you, with the fix in the same line. A re-run is
mostly dots. setup is safe to run again and it is how you repair a half-configured install:
- links the plugin into Herdr (
herdr plugin link), - puts
herdr-cache-alertin~/.local/bin, - binds
prefix+alt+cto the agent-list toggle, - adds the coloured
$cache_*tokens to your agent sidebar, - adds the countdown to your tab bar, which is the surface that works everywhere,
- starts the watcher, which repaints every 30 seconds.
It does not touch your Claude Code config.
Step 3 is the only one that touches a file you own, so it is careful about it: it refuses a chord
already in use, keeps a backup at config.toml.cache-alert-backup, validates with
herdr config check before leaving the new config in place, and restores the backup if that
check fails. Pass --no-keys to skip it and bind herdr.cache-alert.toggle yourself.
Check it worked:
herdr-cache-alert statusBadges appear on agent panes within one tick. Already inside Herdr? The same thing runs from the action palette as Cache: finish setup.
herdr plugin action invoke update --plugin herdr.cache-alertor herdr-cache-alert update from a shell. One step: it advances the checkout and re-registers the
plugin with Herdr. The re-link matters — Herdr caches the action set at link time, so a release that
adds an action answers plugin_action_not_found until the plugin is linked again.
A routine update stays inside the major it is on. Crossing one needs the separate consent:
herdr plugin action invoke update-major --plugin herdr.cache-alertherdr-cache-alert uninstall # or --dry-run first✓ plugin unlinked from 2 session(s)
✓ watcher stopped 2
✓ badges cleared 20 pane(s)
✓ config removed keybinding, sidebar, tab-bar
✓ cli removed ~/.local/bin/herdr-cache-alert
✓ state removed ~/.local/state/herdr/plugins/herdr.cache-alert
kept:
the checkout at ~/src/herdr-cache-alert — delete it yourself if you want it gone
~/.config/herdr/config.toml.cache-alert-backup — your config as it was before we edited it
The order is not the obvious one, and that is the point. This plugin is self-healing: every
Herdr event hook runs ensure, which starts a watcher if the session has none. Clearing the badges
first fires a hook, which spawns a fresh watcher, which repaints everything you just cleared. So it
unlinks first, then stops, then clears.
Everything it writes to your config.toml sits between markers:
# cache-alert:begin sidebar — remove with `herdr-cache-alert uninstall`
[ui.sidebar.agents]
rows = [...]
# cache-alert:end sidebarThat is what lets removal be exact rather than a regex guessing at your file. Anything outside those
markers is yours: if you already set tab_bar_right, or already have a [ui.sidebar.agents], or
already bound prefix+alt+c, setup reports it and changes nothing. --keep-config skips the
config step entirely; --dry-run prints what would go and touches nothing.
| badge | meaning |
|---|---|
⚡ 47m left |
warm — the cache survives another 47 minutes of you not sending anything |
⚠ 4m left |
expiring — under a quarter of the TTL to go; finish the thought |
❄ COLD |
no cache: either the last turn read nothing from it, or the clock ran out |
| (nothing) | unknown — no adapter, no session, or no evidence yet |
The number is idle time remaining, not elapsed time. It counts down toward the moment the cache dies, and every message you send resets it to the full TTL — the window is sliding on every harness here. Walking away is what spends it.
Unknown paints nothing. A ? on the border would be clutter that teaches you nothing, so
silence is the answer whenever the plugin cannot honestly say.
❄ COLD from an observed miss is sticky: it stays until a warm turn clears it, because the miss
you need to see is usually one you were looking away for.
Minutes, never seconds. The watcher ticks every 30 seconds, so a second-by-second countdown would be wrong half the time it was on screen.
Colour comes from config, not from the badge string. Herdr's client does not render ANSI escapes
in pane metadata, but ui.sidebar.agents.rows styles each token — so the sidebar is coloured by
state. The pane border and the tab bar stay monochrome and lean on the glyph.
Three surfaces, because Herdr's chrome is conditional and the pane's own content is not:
| surface | shows | scope | works when |
|---|---|---|---|
| tab bar | ⚡ 44m left |
focused pane | always — the tab bar is there over a solo pane |
| agent sidebar | claude ⚡ 44m left, coloured |
every pane | the sidebar is open |
| pane top border | ⚡ 44m left |
that pane | the pane shares a tab, and ui.show_agent_labels_on_pane_borders = true |
A pane alone in its tab has no border, and nothing can give it one. Tested
three ways: the agent label is documented as "split pane borders" only,
ui.pane_outer_borders = true changes nothing there, and a manual pane name does
not render either. The tab bar is the answer.
setup adds a command entry to ui.tab_bar_right. Herdr re-runs it every five
seconds by itself, which is the whole trick: nothing has to redraw and nobody
has to push, so the number keeps falling while you are away from the machine.
[ui]
tab_bar_right = [{ type = "command", command = "…/herdr-cache-alert tabbar", interval_seconds = 5, timeout_seconds = 3 }]It reads the badge the watcher already published rather than probing again, so
every surface agrees, and it costs one socket round trip. Herdr uses only the
last line of output and clears the entry on empty output — so "nothing known"
empties the slot for free, and a dead watcher clears it within one --ttl-ms
window. Blank rather than wrong, as everywhere else here.
Already set tab_bar_right? Setup will not rewrite your list — that list is
ordered, capped, and yours. herdr-cache-alert tabbar-snippet prints the entry
to paste.
Eleven agents, one glance. Green is warm, red is cold, and the number is how long you have left. Note the selected row at the bottom — same warm state as the rows above it, different green.
setup adds this to [ui.sidebar.agents], and it is where the colour comes from:
rows = [["state_icon", "workspace", "tab"], ["agent",
{ token = "$cache_warm", fg = "#a6e3a1", bold = true },
{ token = "$cache_expiring", fg = "#f9e2af", bold = true },
{ token = "$cache_cold", fg = "#f38ba8", bold = true },
{ token = "$cache_warm_focus", fg = "#166534", bold = true },
{ token = "$cache_expiring_focus", fg = "#92400e", bold = true },
{ token = "$cache_cold_focus", fg = "#991b1b", bold = true }]]Herdr styles a row token statically — one colour per token NAME — so one token could only ever be one colour. The plugin reports the badge under a per-state name and clears the rest, which is what buys green/amber/red.
Six tokens, not three, and the screenshot is why. Herdr draws the selected
row on active_row_bg, which on a dark theme is a light grey — measured
#d2d3da here against #23273a for its neighbours. A pale green tuned for the
dark rows scores 1.00:1 on that one and vanishes on exactly the row you are
looking at. No single colour fixes it: the best possible compromise is about
3.1:1 on each. So the focused pane reports a different NAME, carrying a dark
colour for the light row — 4.8:1 or better everywhere.
Exactly one of the six is ever set. Two parser rules go with them: fg must be
#RGB or #RRGGBB (named theme colours are rejected), and an unknown key is
rejected too, so the line cannot drift silently.
If you already customise [ui.sidebar.agents], setup will not touch it. It
prints the tokens instead: herdr-cache-alert sidebar-snippet. Until you add
them, the agent list shows no badge — the tab bar and the pane border still do.
prefix+alt+c shows or hides the agent-list badge. It governs these tokens,
which is the thing the agent list actually renders.
herdr-cache-alert doctor— is a watcher running?- Is
ui.hide_tab_bar_when_single_tab = true? That removes the whole tab row. - Is the sidebar open?
prefix+btoggles it. herdr-cache-alert status— if that prints badges, the plugin is fine and only the surface is missing.
Herdr does not hot-reload config.toml. setup runs herdr server reload-config
for you; if you edit the config by hand, run it yourself or the entry sits inert.
There is deliberately no tab-label fallback: writing a countdown there means renaming a tab every minute, which churns Herdr's event bus and makes the tab bar twitch.
Cache lifetimes are vendor behaviour, not a standard. They change without warning, they differ between a subscription and an API key, and half of what is written about them online is a guess.
So no bare constant is allowed in this codebase. Every cache number ships as a Sourced<T>: a
value, a confidence, a documentation link, the date it was last checked, and the sentence it came
from.
export interface Sourced<T> {
value: T;
confidence: "documented" | "reported" | "inferred" | "observed";
source: {
url: string; // the exact page, never a search result
title: string;
publisher: string;
retrievedAt: string; // ISO date the claim was last checked
quote?: string; // verbatim from that page
kind: "vendor-doc" | "vendor-blog" | "vendor-changelog" | "community" | "observed";
};
note?: string;
}Read any of it back:
herdr-cache-alert rules # every rule, with its source and quote
herdr-cache-alert explain # why THIS pane says what it says
herdr-cache-alert claims --stale 180 # claims nobody has re-verified latelyclaims exits non-zero when something is stale, so it can gate a release. A cache rule nobody has
re-read in six months is a number this plugin should stop asserting with a straight face.
confidence: "observed" outranks everything else, and it is not a figure of speech. Claude Code's
transcript records which TTL each turn actually wrote to:
usage.cache_creation.ephemeral_1h_input_tokens: 2421
usage.cache_creation.ephemeral_5m_input_tokens: 0
That turn proves the session is on the one-hour TTL. No documented rule, tier guess or environment sniff can outvote it.
Tier detection can lie. ANTHROPIC_API_KEY being set does not mean it is used — an OAuth login
wins over it, and an apiKeyHelper supplies one without the variable existing at all. Worse, Herdr
exposes no per-pane environment, so what the plugin reads is its own environment, which is usually
the pane's but never provably so.
Every detection therefore carries its evidence and its confidence, and an unknown tier takes the harness's shortest rule. A wrong early warning costs you a glance. A wrong "still warm" sends you back to a cache that expired ten minutes ago.
| harness | tier detection | countdown | cold detection |
|---|---|---|---|
| Claude Code | ~/.claude/.credentials.json, ANTHROPIC_* |
✅ from the transcript | ✅ real token counts |
| Codex CLI | ~/.codex/auth.json (auth_mode) |
✅ from the rollout log | ✅ cached_input_tokens |
| opencode | auth.json entry types |
✅ from the SQLite store | ✅ tokens.cache.read |
| OpenRouter | n/a — a gateway is always per-token | ❌ forced-only, no probe | ❌ |
Cold detection needs the harness to write cache token counts somewhere readable. Three of the four
do. An adapter that cannot read them counts down and stays quiet about warm-versus-cold rather than
guess, because a wrong ❄ COLD is worse than no ❄ COLD.
The TTL is not always a property of the tier. OpenAI runs two cache regimes at once, split by
model generation — 30 minutes on GPT-5.6 and later, 5-10 minutes idle before it. opencode is a
different upstream provider per session. Both are handled by ttlForProbe, which picks the rule from
what the probe actually found; the tier rule is the fallback for when that is not yet known, and it
always carries the shorter number.
opencode needs Herdr's integration. herdr integration install opencode is what reports the
session id this adapter reads. Without it there is no session to look up and the badge stays silent.
OpenRouter is never auto-selected: nothing calls itself "openrouter" in a pane, because an agent reaches it through some other CLI. Force it when you know that is what you are on:
CACHE_ALERT_HARNESS=openrouter herdr-cache-alert explainOne file, one registry line. Export a HarnessAdapter:
export const myAdapter: HarnessAdapter = {
id: "myagent", // MUST equal the `agent` label Herdr reports for the pane
label: "My Agent",
rules: MY_RULES, // sourced, or it does not ship
detectTier, // → { tier, confidence, evidence[] }
ttlOverride, // optional: a TTL the harness's own config forces
ttlForProbe, // optional: a TTL implied by the model/upstream the probe found
probe, // → the last turn's timing and cache counts, or null
};then add it to ADAPTERS in src/harness/index.ts.
Four things worth knowing before you write one:
- There is no
matches(pane)hook. Herdr already labels the pane's agent, and the registry is a map from that label onto an adapter. Self-matching would eventually let two adapters claim one pane, and the loser would be whichever was registered second. probereturningnullis a normal answer — a fresh session, a harness with nothing to read, a pane adopted mid-flight. Never guess there. The engine paints nothing rather than paint a fiction.- Persist through the store you are handed, not your own dotfile. It is keyed by
<adapter>:<session-id>and ages entries out after a day. - Report no cache counts rather than zeros when your harness logs none. A
0reads downstream as "cold", which is a lie the badge would then paint.
src/harness/openrouter.ts is the minimal worked example: rules and a
tier detector, no telemetry at all. That combination is legal, and it still earns a countdown.
herdr-cache-alert status [--pane ID] what every agent pane's cache is doing
herdr-cache-alert explain [--pane ID] where a pane's number comes from, claim by claim
herdr-cache-alert rules [--json] every cache rule, with sources
herdr-cache-alert claims [--stale DAYS] claims nobody has re-verified lately
herdr-cache-alert tabbar one line for the tab bar (what Herdr calls on its interval)
herdr-cache-alert tabbar-snippet the ui.tab_bar_right entry that drives the countdown
herdr-cache-alert sidebar-snippet the [ui.sidebar.agents] rows that colour the badge
herdr-cache-alert toggle [on|off] the display-name fallback badge (prefix+alt+c)
herdr-cache-alert sync paint every pane once
herdr-cache-alert watch [--force|stop|status]
herdr-cache-alert clear remove every badge and tab mark
herdr-cache-alert setup [--no-keys]
herdr-cache-alert update [--major]
herdr-cache-alert doctor resolved paths and detection state
~/.config/herdr/plugins/config/herdr.cache-alert/config.json, all keys optional:
| key | default | effect |
|---|---|---|
quietWhileWarm |
false |
show nothing until the cache is in trouble |
notifyOnCold |
false |
raise a Herdr notification on an observed miss |
claimStaleDays |
180 |
how old a claim may get before claims complains |
forceHarness |
"" |
pin an adapter instead of detecting one |
forceTier |
"" |
pin subscription or api instead of detecting one |
pollMs |
5000 |
reserved for adapters that poll |
warnSeconds |
300 |
reserved; the badge uses 25% of the rule's TTL, min 60s |
CACHE_ALERT_HARNESS, CACHE_ALERT_TIER and CACHE_ALERT_QUIET=1 override the file for one
invocation.
notifyOnCold is off by default on purpose: ten panes going cold while you were at lunch is a
notification storm, not a warning.
setup starts one background process per machine. It is what makes the countdown a countdown.
Every 30 seconds it lists the agent panes, probes each one's harness log, recomputes the state,
and repaints. A full tick over 11 panes measured 0.08s — one seek of 128 KB per pane and one
socket round trip. It re-reads config.json each tick, so a config edit takes effect without a
restart.
herdr-cache-alert watch status # running? which pid?
herdr-cache-alert watch stop # stop it
herdr-cache-alert watch # start it in the foreground
herdr-cache-alert doctor # says loudly when no watcher is running| trigger | when it fires | what it covers |
|---|---|---|
| the watcher | every 30s, unconditionally | the clock ticking down while nothing happens |
| Herdr events | agent detected, agent status changed, pane created/closed/moved | the moment a turn ends and the warm/cold verdict changes |
This means a dead watcher is invisible on a busy pane. Events keep repainting it, so the badge
looks healthy while every idle pane quietly goes blank. doctor is how you catch that — it reports
NO WATCHER RUNNING in a notification rather than burying it in the log.
Nothing stops. The watcher keeps ticking, so the countdown keeps descending — ⚡ 12m left, then
⚠ 4m left, then ❄ COLD the moment the TTL elapses. What you see when you come back is
accurate, and the number is never more than 30 seconds stale. Since the badge is denominated in
minutes, that is invisible.
Come back after an hour on a subscription session and it will read ❄ COLD, because it is. The
first message you send then rebuilds the whole prefix at full input price — and the turn after that
confirms it, because the Claude adapter reads the real token counts and reports the miss it
measured rather than the one it predicted.
Badges do not freeze at a stale number. Every paint carries --ttl-ms of two ticks, so a badge that
stops being re-reported is dropped by Herdr about 60 seconds later. Verified: a metadata token
written with --ttl-ms 5000 was gone 20 seconds later.
That is the intended failure mode — blank rather than wrong. On a suspended laptop the whole plugin freezes with everything else, the badges clear, and the first tick after resume recomputes from real timestamps and paints the truth. There is no window in which an old number is presented as current.
herdr pane list ──> adapter registry ──> detectTier ──> rule
│ │
└── probe (harness's own log) ──┤
▼
ttl = observed ?? remembered ?? override ?? rule
▼
expiresAt = lastRequestAt + ttl ──> badge
The countdown cannot be event-driven. Herdr's events fire when something happens, and the
entire point of this plugin is the stretch when nothing is happening — an idle pane sliding toward
expiry emits no events at all. An event-only badge would freeze at ⚡ 40m and still say it an hour
later. Hence the watcher.
The watcher is bounded on both sides: every paint carries --ttl-ms of about two ticks, so if the
watcher dies its badges clear themselves within a tick instead of lying indefinitely. Every paint
also carries a monotonic --seq, so a slow tick cannot overwrite a newer one.
Every paint writes both surfaces, even when the string is unchanged. That is not wasted work:
--ttl-ms means a badge that stops being re-reported expires, and ❄ COLD never changes by
definition — so skipping it made the one state worth showing the one that reliably vanished.
Probes seek. A ~/.claude/projects directory on the development machine measured 3.7 GB, so each
probe reads the last 128 KB of one file and walks backwards to the newest turn.
bun install
bun run test # 75 tests on node:test, then the CLI under both runtimes
bun run lint # oxlint + the vendored anti-slop rules, --max-warnings 0
bun x tsc --noEmit # strict, noUncheckedIndexedAccess, erasableSyntaxOnly
./scripts/check-version.sh # SemVer consistency across manifest, package.json, CHANGELOG
herdr plugin link "$PWD" # re-run after ANY manifest changeThe suite runs on Node's built-in runner, so it adds no dependency and there is still no build
step. scripts/test.sh points the state and config directories at a throwaway sandbox — without
that a test writes your real state.json and lands on a live pane's countdown.
It covers the seams that have already shipped a bug: the precedence ladder, the cold mark's
stickiness, the badge vocabulary, the six sidebar token names, the event payload's spelling, and
the update planner. It also walks every cache rule the plugin ships and fails if a documented
claim carries no verbatim quote, or a retrievedAt will not parse. None of it can prove a badge
appeared on screen — for that, still watch a live session.
Both runtimes are supported and both must keep working:
CACHE_ALERT_RUNTIME=node ./bin/herdr-cache-alert status
CACHE_ALERT_RUNTIME=bun ./bin/herdr-cache-alert statusSee CLAUDE.md for the working agreement, including the versioning rules and the Herdr API gotchas worth not relearning.
MIT © Altan Sarisin

