Warns above the prompt and in a toast before a /model, /effort or /fast switch or an idle gap re-writes the prompt cache, with tokens and est. USD; stops the…

Shows when your prompt cache is about to expire or be re-written, and stops the two habits that re-write it most: a model switch on a warm cache and a subagent waiting on one long Bash call.

claude plugin marketplace add weskao/weskao-agent-plugins
claude plugin install cache-sentinel@weskao-agent-plugins
Needs Claude Code 2.1.287 or later (mods load by default).
Nothing to configure. After each of your turns a band above the prompt shows, when it matters (context ≥ 20k tokens):
⏳ prompt cache Nm 後過期 — the cache expires in N minutes (1h TTL on the main thread, 5m with CLAUDE_CODE_PROMPT_CACHE_TTL=5m); send now to keep it warm.❄ prompt cache 已過期 — the next prompt re-writes the whole context; small questions belong in a fresh session.⚠ /model 會重寫 prompt cache — typed /model, /effort or /fast would re-write it; /compact first, or hand the work to a subagent with its own model.Each line names the tokens and an estimated cost ([?] when the model's price is unknown). The ⏳ and ❄ lines also pop once as a toast.



Two guards:
/model on a warm cache above 100,000 context tokens is stopped once with the tokens and cost it would re-write; a second /model within a minute goes through (MODEL_SWITCH_GUARD_TOKENS=<n> in settings.json env changes the threshold). Every /model is logged to ~/.claude/cache/model-switch-<YYYY-MM>.jsonl. A switch made without /model is not guarded.timeout over 240,000 ms is refused with the fix: a shorter timeout, or run_in_background and polling./cache-sentinel-close turns the band and toasts off (the guards stay), /cache-sentinel-open turns them back on; the choice is kept across sessions. /cache-sentinel-update updates to the latest version (then restart Claude Code); at each session start a toast says when a newer version is published (the version list is fetched at most every 10 minutes).
Token counts come from the session's own usage; prices are read from ~/.claude/token-tools.json when present. The switch log stays on your disk. The only network request is the version check, a plain GET of the public marketplace.json on raw.githubusercontent.com.
MIT
hooks/register.tsx 224 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3import type { EngineInterface } from 'claude-code'
4import type { Cmd, Snap } from '../types'
5import { notifyUpdate, registerUpdate, runUpdate } from './shared/update'
6
7const OFF = { plugin: 'cache-sentinel', key: 'isOff' } as const
8
9async function isOff($: EngineInterface) {
10 return (await $.state.get(OFF)).value === true
11}
12
13const snap = atom({ plugin: 'cache-sentinel', key: 'snap' } as const, null)
14const cmd = atom({ plugin: 'cache-sentinel', key: 'cmd' } as const, null)
15const tick = atom({ plugin: 'cache-sentinel', key: 'tick' } as const, 0)
16
17const MIN = 60_000
18const MIN_TOKENS = 20_000 // below this a re-write costs cents: stay quiet
19const WARN_MS = 5 * MIN
20const SWITCH = /^\s*(\/(?:model|effort|fast))(?:\s|$)/
21const GUARD_TOKENS = 100_000 // MODEL_SWITCH_GUARD_TOKENS overrides: ask before a warm switch above it
22const WAIT_MS = 4 * MIN // a subagent's cache lives 5m: one foreground Bash wait past this re-writes its whole context
23const CONFIRM_MS = MIN // a second /model within this goes through the guard
24
25type Prices = { prices: Record<string, number>; fallback_price: number }
26let prices: Prices | null = null // read once per load; the file changes rarely and a reload re-reads it
27let lastKey = ''
28let isTicking = false
29let warnedAt: number | undefined // the last /model the guard stopped; module-local, so a reload re-arms it
30// hint kinds (⏳ ❄) already toasted this idle gap; turn.complete clears it, so each pops once per gap
31const toasted = new Set<string>()
32
33async function loadPrices($: EngineInterface) {
34 if (prices) return prices
35 try {
36 prices = JSON.parse(await $.fs.read(`${await $.env.get('HOME')}/.claude/token-tools.json`)) as Prices
37 } catch {}
38 return prices
39}
40
41// longest-prefix match, as cache-roi.py prices models; unknown → fallback, flagged [?]
42function priceOf(model: string, p: Prices | null) {
43 if (!p) return { usd: null, isPriced: false }
44 const key = Object.keys(p.prices).filter(k => model.startsWith(k)).sort((a, b) => b.length - a.length)[0]
45 const usd = key === undefined ? undefined : p.prices[key]
46 return usd === undefined ? { usd: p.fallback_price, isPriced: false } : { usd, isPriced: true }
47}
48
49function hint(s: Snap | null, c: Cmd | null, now: number): string[] | null {
50 if (!s || s.tokens < MIN_TOKENS) return null
51 const k = `${Math.round(s.tokens / 1000)}k tok`
52 const usd = s.usd === null ? '' : ` ≈ $${s.usd.toFixed(2)} est.${s.isPriced ? '' : ' [?]'}`
53 const left = s.endedAt + s.ttlMs - now
54 if (left <= 0) return [`❄ prompt cache 已過期:下次送出重寫 ${k}${usd} · 小問題開新 session`]
55 if (c) return [`⚠ ${c} 會重寫 prompt cache:${k}${usd}`, ' 先 /compact,或把工作交給指定 model 的 subagent']
56 if (left <= WARN_MS) return [`⏳ prompt cache ${Math.ceil(left / MIN)}m 後過期(${k})· 要接著做就現在送出`]
57 return null
58}
59
60// redraws the band only when its text would change, and toasts a hint kind the first time it appears
61async function refresh($: EngineInterface) {
62 const now = await $.clock.now()
63 const lines = hint(await read($, snap), await read($, cmd), now)
64 const key = (lines ?? []).join('\n')
65 if (key === lastKey) return
66 lastKey = key
67 await update($, tick, () => now)
68 const kind = lines?.[0]?.charAt(0)
69 // the ⚠ switch warning stays in the band only: the person just typed the command and is looking at the box
70 if (!lines || !kind || kind === '⚠' || toasted.has(kind) || (await isOff($))) return
71 toasted.add(kind)
72 $.ui.toast(lines.map(l => l.trim()).join(' · '), { timeoutMs: 8000 })
73}
74
75// one tick a minute drives the countdown and the expiry toast
76function startTicking($: EngineInterface) {
77 if (isTicking) return
78 isTicking = true
79 $.clock.every(MIN, () => {
80 void refresh($).catch(() => {})
81 })
82}
83
84async function setCmd($: EngineInterface, text: string) {
85 const c = (SWITCH.exec(text)?.[1] ?? null) as Cmd | null
86 if (c !== (await read($, cmd))) await update($, cmd, () => c)
87}
88
89type Switch = {
90 session_id: string; from_model: string; to_model: string | null; requested_model: string | null; source: 'command'
91 context_tokens: number; prompt_cache_warm: boolean; cache_ttl: '5m' | '1h' | null; estimated_cache_write_usd: number | null
92}
93
94// one JSON line per switch, the shape cache-roi.py's `guard` section reads (hooks/model-switch-guard.sh wrote it before)
95async function logSwitch($: EngineInterface, e: Switch, threshold: number, decision: 'ask' | 'allow') {
96 try {
97 const now = await $.clock.now()
98 const path = `${await $.env.get('HOME')}/.claude/cache/model-switch-${new Date(now).toISOString().slice(0, 7)}.jsonl`
99 const line = JSON.stringify({
100 ts: Math.floor(now / 1000), session_id: e.session_id, from: e.from_model, to: e.to_model, requested: e.requested_model,
101 source: e.source, context_tokens: e.context_tokens, warm: e.prompt_cache_warm, ttl: e.cache_ttl,
102 usd: e.estimated_cache_write_usd, threshold, decision,
103 })
104 // shortcut: read + write, no append in $.fs; one switch at a time per session, so no interleaving
105 const old = await $.fs.read(path).catch(() => '')
106 await $.fs.write(path, `${old}${line}\n`)
107 } catch {}
108}
109
110export const register: Register = on => {
111 // the model-switch guard: classic.PreModelSwitch never reaches a user-tier mod (the built-in cc-plugin-sec-default sends
112 // classic.* past it), so /model itself is the gate: a warm switch above the threshold is stopped once, and a second
113 // /model within CONFIRM_MS opens the picker. shortcut: a switch made outside /model (the picker's own shortcut) is not seen
114 on('command.run', { command: 'model' }, async ($, e, next) => {
115 const raw = (await $.env.get('MODEL_SWITCH_GUARD_TOKENS')) ?? ''
116 const min = /^\d+$/.test(raw) ? Number(raw) : GUARD_TOKENS
117 const [s, now] = await Promise.all([read($, snap), $.clock.now()])
118 if (warnedAt !== undefined && now - warnedAt <= CONFIRM_MS) {
119 warnedAt = undefined
120 return next(e)
121 }
122 const isWarm = s !== null && now < s.endedAt + s.ttlMs
123 const ask = isWarm && s.tokens > min
124 const target = e.args.trim() || null
125 await logSwitch($, {
126 session_id: await $.session.id(), from_model: await $.session.model(), to_model: target, requested_model: target, source: 'command',
127 context_tokens: s?.tokens ?? 0, prompt_cache_warm: isWarm, cache_ttl: s ? (s.ttlMs > 5 * MIN ? '1h' : '5m') : null,
128 estimated_cache_write_usd: s?.usd ?? null,
129 }, min, ask ? 'ask' : 'allow')
130 if (!ask) return next(e)
131 warnedAt = now
132 const usd = s.usd === null ? '' : ` 約 $${s.usd.toFixed(2)}`
133 return { text: `context ${s.tokens} tokens,換 model 重寫 cache${usd};建議先 /compact。${CONFIRM_MS / 1000} 秒內再下一次 /model 繼續。` }
134 }).catch(($, e, next) => next(e))
135
136 // a subagent (agentId set) blocking on one Bash call past WAIT_MS comes back to a cold cache: refuse, it backgrounds and polls instead
137 on('tool.call', { tool: 'Bash' }, ($, e, next) => {
138 const ms = typeof e.timeout === 'number' ? e.timeout : 0
139 if (!e.agentId || e.run_in_background === true || ms <= WAIT_MS) return next(e)
140 return {
141 deny: `cache-sentinel: timeout ${ms} ms exceeds ${WAIT_MS} ms for a subagent (its prompt cache lives 5 min; a longer wait re-writes the whole context). Use timeout ≤ ${WAIT_MS}, or run_in_background and poll with checks ≤ ${WAIT_MS} ms each.`,
142 }
143 }).catch(($, e, next) => next(e))
144
145 on('session.start', async ($, e, next) => {
146 await registerUpdate($)
147 // /cache-sentinel-open and /cache-sentinel-close; the choice is kept across sessions in $.store
148 try {
149 await $.command.register({ name: 'cache-sentinel-open', description: 'Turn cache-sentinel on' })
150 await $.command.register({ name: 'cache-sentinel-close', description: 'Turn cache-sentinel off' })
151 if ((await $.store.get('isOff')) === true) await $.state.set(OFF, true)
152 } catch {}
153 const started = await next(e)
154 notifyUpdate($)
155 return started
156 })
157 on('command.run', { command: 'cache-sentinel-update' }, async $ => ({ text: await runUpdate($) }))
158 on('command.run', { command: ['cache-sentinel-open', 'cache-sentinel-close'] }, async ($, e) => {
159 const off = e.command === 'cache-sentinel-close'
160 await $.state.set(OFF, off)
161 await $.store.set('isOff', off)
162 if (off) $.ui.status(undefined)
163 return { text: off ? 'cache-sentinel is off (/cache-sentinel-open to turn it on).' : 'cache-sentinel is on.' }
164 })
165
166 // only the main thread's turns keep its cache warm; a subagent runs on its own
167 on('turn.complete', async ($, e, next) => {
168 const result = await next(e)
169 if (!e.agentId) {
170 try {
171 const [usage, model, ttl, endedAt, p] = await Promise.all([
172 $.session.usage(), $.session.model(), $.env.get('CLAUDE_CODE_PROMPT_CACHE_TTL'), $.clock.now(), loadPrices($),
173 ])
174 const tokens = usage.context.tokens ?? 0
175 const ttlMs = ttl === '5m' ? 5 * MIN : 60 * MIN
176 const price = priceOf(model, p)
177 // a cache write costs 2x input on the 1h TTL, 1.25x on 5m
178 const usd = price.usd === null ? null : (tokens * price.usd * (ttlMs > 5 * MIN ? 2 : 1.25)) / 1e6
179 await update($, snap, () => ({ endedAt, tokens, ttlMs, usd, isPriced: price.isPriced }))
180 toasted.clear()
181 await refresh($)
182 startTicking($)
183 } catch {}
184 }
185 return result
186 })
187
188 on('prompt.edit', async ($, e, next) => {
189 const box = await next(e)
190 try {
191 await setCmd($, box.text)
192 } catch {}
193 return box
194 })
195 on('prompt.change', async ($, e, next) => {
196 const result = await next(e)
197 try {
198 await setCmd($, e.text)
199 } catch {}
200 return result
201 })
202
203 // AbovePrompt is shared: always keep what the plugins beneath drew
204 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
205 const below = await next(e)
206 if (await isOff($)) return below
207 await read($, tick) // the minute tick redraws the countdown
208 const lines = hint(await read($, snap), await read($, cmd), await $.clock.now())
209 if (!lines || e.props.hasSurvey) return below
210
211 const { Box, Text } = $.ui.resolve(e)
212 return (
213 <Box flexDirection="column">
214 {below}
215 {lines.map((line, i) => (
216 <Text key={String(i)} dimColor={i > 0}>
217 {line}
218 </Text>
219 ))}
220 </Box>
221 )
222 }).catch(($, e, next) => next(e))
223}
224hooks/shared/update.ts 82 lines1// generated from mods/_shared/update.ts by mods/_shared/sync.py: edit the source there, then run it
2import type { EngineInterface } from 'claude-code'
3
4// the public marketplace the mods are published to (skills/marketplace-publish). A copy loaded from anywhere else
5// (the local dev marketplace, --plugin-dir) has no upstream to compare with: it is never checked or updated
6export const MARKETPLACE = 'weskao-agent-plugins'
7const CATALOG = `https://raw.githubusercontent.com/weskao/${MARKETPLACE}/main/.claude-plugin/marketplace.json`
8// update-kit's default (ai-accounts); a raw.githubusercontent.com GET has no API rate limit and is CDN-cached ~5 min
9const TTL = 10 * 60 * 1000
10// { at, latest }: the last fetch's time and result, reused until TTL so every session start inside it still toasts
11const CHECK = 'updateCheck'
12
13// >0 when a is newer; dotted numbers only, a pre-release suffix (`-beta`) is ignored
14export const cmpVersion = (a: string, b: string): number => {
15 const pa = a.replace(/-.*/, '').split('.').map(Number)
16 const pb = b.replace(/-.*/, '').split('.').map(Number)
17 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
18 const d = (pa[i] || 0) - (pb[i] || 0)
19 if (d) return d
20 }
21 return 0
22}
23
24// an installed plugin lives under plugins/cache/<marketplace>/<name>/<version>
25export const isPublished = (root: string) => root.includes(`/${MARKETPLACE}/`)
26
27// the newer upstream version, at most one fetch per TTL; undefined when current, unpublished, offline or unparsable
28export async function checkUpdate($: EngineInterface, now = Date.now(), root = $.plugin.root): Promise<string | undefined> {
29 if (!isPublished(root)) return
30 try {
31 const local = JSON.parse((await $.fs.read(`${root}/.claude-plugin/plugin.json`)) as string).version as string
32 const last = (await $.store.get(CHECK)) as { at?: number; latest?: string } | undefined
33 let latest = last?.latest
34 // a clock that moved backwards refetches instead of trusting the entry
35 if (typeof last?.at !== 'number' || now - last.at < 0 || now - last.at >= TTL) {
36 // stamped before the fetch: a failing network is not retried on every launch
37 await $.store.set(CHECK, { at: now })
38 const res = await $.http.fetch(CATALOG)
39 if (!res.ok) return
40 const plugins = JSON.parse(res.text).plugins as { name: string; version?: string }[]
41 latest = plugins.find(p => p.name === $.plugin.name)?.version
42 await $.store.set(CHECK, { at: now, latest })
43 }
44 return latest && local && cmpVersion(latest, local) > 0 ? latest : undefined
45 } catch {
46 return
47 }
48}
49
50// /<name>-update: refresh the marketplace, then update this plugin; the new code loads after a restart
51export async function runUpdate($: EngineInterface, root = $.plugin.root): Promise<string> {
52 const id = `${$.plugin.name}@${MARKETPLACE}`
53 if (!isPublished(root)) return `${$.plugin.name} was not installed from ${MARKETPLACE}, so it has nothing to update from. To switch: claude plugin install ${id}`
54 const out: string[] = []
55 for (const argv of [['claude', 'plugin', 'marketplace', 'update', MARKETPLACE], ['claude', 'plugin', 'update', id]]) {
56 try {
57 const r = await $.process.run(argv, { timeoutMs: 120_000 })
58 if (r.exitCode !== 0) return `\`${argv.join(' ')}\` failed:\n${(r.stderr || r.stdout).trim()}`
59 out.push(r.stdout.trim())
60 } catch (err) {
61 return `\`${argv.join(' ')}\` could not run (${String(err)}). Run it in a terminal instead.`
62 }
63 }
64 await $.store.delete(CHECK)
65 return `${out.filter(Boolean).join('\n')}\nRestart Claude Code to load the new ${$.plugin.name}.`
66}
67
68// a module may hook session.start once, so each mod calls these from its own hook and answers
69// `command.run` for `<name>-update` with runUpdate
70export async function registerUpdate($: EngineInterface) {
71 try {
72 await $.command.register({ name: `${$.plugin.name}-update`, description: `Update ${$.plugin.name} to the latest version` })
73 } catch {}
74}
75
76// call unawaited after next(e): the network never holds the first prompt
77export function notifyUpdate($: EngineInterface) {
78 void checkUpdate($)
79 .then(v => v && $.ui.toast(`${$.plugin.name} ${v} is available: run /${$.plugin.name}-update`))
80 .catch(() => {})
81}
82types/index.d.ts 11 lines1// The main thread's last turn: when it ended, the context it left, and what re-writing it costs
2export type Snap = { endedAt: number; tokens: number; ttlMs: number; usd: number | null; isPriced: boolean }
3// A draft that would re-write the cache when sent
4export type Cmd = '/model' | '/effort' | '/fast'
5
6declare module 'claude-code' {
7 interface PluginState {
8 'cache-sentinel': { isOff: boolean; snap: Snap | null; cmd: Cmd | null; tick: number }
9 }
10}
11