Warns before a message that would re-read the whole conversation on a cold prompt cache, shows the cache state in the status line, and can keep it warm with…

My Claude Code mods, as a plugin marketplace (alex-mods). One folder per mod under plugins/.
| Mod | What it does |
|---|---|
| savvy-progress | Ember progress bar above the prompt for a batch of subagents, and an Agents pane: crab mascot per agent, live status, per-agent progress bar, model, tokens, estimated cost, elapsed time. |
| file-tree | Files pane with the project tree: files Claude reads (blue), modifies (orange) or creates (purple) light up with a shimmer, and turn green once committed, in every repo they live in. |
| tool-skins | Reskins the transcript's tool rows: an icon and a palette colour per tool (dracula skin from kevinvn1709/vscode-dracula-color-theme), the row's result still drawn by Claude Code. |
| cache-tax | Prompt-cache state in the status line; stops a message that would re-read the whole conversation on a cold cache and shows what it costs; /keepwarm keeps the cache warm while you are away. |
claude plugin marketplace add E:/WS/claude-mods
claude plugin install savvy-progress@alex-mods --scope user
claude plugin install file-tree@alex-mods --scope user
claude plugin install cache-tax@alex-mods --scope user
claude plugin install tool-skins@alex-mods --scope user
Installed this way, Claude Code reads the plugin from this folder, so an edit here reaches a session with /reload-plugins (no reinstall, no version bump).
/plugin install savvy-progress --marketplace Fraktalis/claude-mods
Then update later with claude plugin update savvy-progress@alex-mods after pushing a new version.
claude plugin validate plugins/savvy-progress
claude plugin test plugins/savvy-progress
Bump version in the mod's .claude-plugin/plugin.json for each release that GitHub installs should pick up.
/savvy opens the Agents pane; /savvy-skin <type|*> <crab|developer|designer|researcher|architect|ghost> picks a mascot.progress tool (mcp__savvy-progress__progress); each subagent is asked to report done/total/step with it. The main loop can also call it, with title to name the batch.hooks/register.tsx, PRICES); edit it when prices change./tree opens the Files pane (it also opens by itself on the first file Claude edits or creates); /tree-clear forgets what was touched.git commit run by Claude re-checks git status in every repository holding a modified or new file; commits made in a terminal are picked up on the next prompt.node_modules, .git and the like are listed dimmed and stay closed. Touched only hides the rest. Files outside the session's folder are listed under Elsewhere.🔥 cache 42m while warm, 🧊 cache cold · next msg ≈ $2.40 once the TTL has passed since the main conversation's last request.hooks/register.ts (PRICES), same table as savvy-progress./cache shows the details; /keepwarm on|off sends a one-line ping ~1m30 before expiry, at most maxPings times in a row (never while Claude works or while you type). Each ping costs a cache read and a one-word reply.ttl (1h or 5m), minTokens, maxPings. The TTL is not detected: switch it to 5m in usage overage.ToolUse): icon + tool name in its palette colour, the call's argument (last 3 path segments, or the one-line command), … running, ✗ error, Interrupted. The result under the row (ToolResult: diffs, Bash output) is still Claude Code's own./skin lists skins, /skin <name> switches, /skin off hands the rows back; the choice is kept across sessions. dracula is on by default.dracula comes from themes/dracula-color-theme.json of github.com/kevinvn1709/vscode-dracula-color-theme, a Darcula-style palette despite its name: keyword #CC7832 → Bash, function #FFC66D → Edit, string #6A8759 → Write, number #6897BB → Read, type #4EC9B0 → Grep/Glob, property #9876AA → Agent, info #6796e6 → web tools, debug #b267e6 → MCP tools, on #212122.THEMES (hooks/register.tsx). The name claude-skins is reserved for Anthropic, hence tool-skins.hooks/register.ts 199 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LastRequest } from '../types'
5
6const last = atom({ plugin: 'cache-tax', key: 'last' } as const, null)
7const confirm = atom({ plugin: 'cache-tax', key: 'confirm' } as const, null)
8const keepwarm = atom({ plugin: 'cache-tax', key: 'keepwarm' } as const, false)
9const pings = atom({ plugin: 'cache-tax', key: 'pings' } as const, 0)
10const isBusy = atom({ plugin: 'cache-tax', key: 'isBusy' } as const, false)
11
12// ---------- pure helpers (tested in cache.test.ts) ----------
13
14export type Ttl = '1h' | '5m'
15export const ttlMs = (ttl: Ttl) => (ttl === '5m' ? 5 * 60_000 : 60 * 60_000)
16
17/** $ per million tokens: input, cache read. */
18type Price = { input: number; cacheRead: number }
19
20// ponytail: first-party list prices (claude-api skill table, 2026-10-06), same table as savvy-progress; edit both when prices change
21const PRICES: [RegExp, Price][] = [
22 [/fable|mythos/, { input: 10, cacheRead: 0.25 }],
23 [/opus-5-5/, { input: 4, cacheRead: 0.2 }],
24 [/opus/, { input: 5, cacheRead: 0.5 }],
25 [/sonnet-5/, { input: 2, cacheRead: 0.2 }],
26 [/sonnet/, { input: 3, cacheRead: 0.3 }],
27 [/haiku-5/, { input: 0.1, cacheRead: 0.01 }],
28 [/haiku/, { input: 1, cacheRead: 0.1 }],
29]
30const priceOf = (model: string): Price => PRICES.find(([re]) => re.test(model))?.[1] ?? PRICES[1]![1]
31
32/** What re-sending `ctx` tokens costs: cold re-writes the cache (1.25x input for 5m, 2x for 1h), warm reads it. */
33export const estimate = (ctx: number, model: string, ttl: Ttl) => {
34 const p = priceOf(model)
35 return {
36 cold: (ctx * p.input * (ttl === '1h' ? 2 : 1.25)) / 1_000_000,
37 warm: (ctx * p.cacheRead) / 1_000_000,
38 }
39}
40
41export const money = (usd: number) => (usd < 0.01 ? '<$0.01' : `$${usd.toFixed(2)}`)
42export const kTokens = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
43export const span = (ms: number) => {
44 const m = Math.max(0, Math.round(ms / 60_000))
45 return m >= 60 ? `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}` : `${m}m`
46}
47
48/** Tokens the next request re-sends: everything the last one carried, plus its answer. */
49export const nextContext = (u: { input_tokens: number; output_tokens: number; cache_read_input_tokens: number; cache_creation_input_tokens: number }) =>
50 u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens + u.output_tokens
51
52/** The status line text for a moment `now`. */
53export const statusText = (l: LastRequest | null, now: number, ttl: Ttl, minTokens: number, isKeepwarm: boolean) => {
54 if (!l || l.ctx === 0) return undefined
55 const left = l.at + ttlMs(ttl) - now
56 const tail = isKeepwarm ? ' · keepwarm' : ''
57 if (left > 0) return `🔥 cache ${span(left)}${tail}`
58 if (l.ctx < minTokens) return `🧊 cache cold${tail}`
59 return `🧊 cache cold · next msg ≈ ${money(estimate(l.ctx, l.model, ttl).cold)}${tail}`
60}
61
62const CONFIRM_MS = 120_000
63const PING_LEAD_MS = 90_000
64const PING_TEXT = 'Keep-warm ping from the cache-tax plugin, to keep the prompt cache warm while the user is away. Reply with just "ok".'
65
66// ---------- options ----------
67
68type Options = { ttl: Ttl; minTokens: number; maxPings: number }
69const readOptions = (raw: unknown): Options => {
70 const o = (raw ?? {}) as Partial<Record<keyof Options, unknown>>
71 return {
72 ttl: o.ttl === '5m' ? '5m' : '1h',
73 minTokens: typeof o.minTokens === 'number' ? o.minTokens : 20_000,
74 maxPings: typeof o.maxPings === 'number' ? o.maxPings : 3,
75 }
76}
77
78// ---------- shared steps ----------
79
80async function refreshStatus($: EngineInterface, opts: Options) {
81 const l = await read($, last)
82 const isOn = await read($, keepwarm)
83 $.ui.status(statusText(l, await $.clock.now(), opts.ttl, opts.minTokens, isOn))
84}
85
86// ---------- hooks ----------
87
88export const register: Register = (on, options) => {
89 const opts = readOptions(options)
90 let pingTimer: { cancel: () => void } | null = null
91
92 on('session.start', async ($, e, next) => {
93 await $.command.register({ name: 'keepwarm', description: 'Keep the prompt cache warm while you are away: /keepwarm on|off', argumentHint: 'on|off' })
94 await $.command.register({ name: 'cache', description: 'Show the prompt cache state and what the next message would cost' })
95 $.clock.every(60_000, () => void refreshStatus($, opts))
96 await refreshStatus($, opts)
97 return next(e)
98 })
99
100 // the main loop's requests: when the cache was last refreshed, and what the next one re-sends
101 on('turn.step', async function* ($, e, next) {
102 if (!e.agentId) await update($, isBusy, () => true)
103 const r = yield* next(e)
104 if (!e.agentId && r.usage) {
105 const model = r.usage.model || e.model
106 const l: LastRequest = { at: await $.clock.now(), ctx: nextContext(r.usage), model }
107 await update($, last, () => l)
108 await refreshStatus($, opts)
109 }
110 return r
111 })
112
113 on('turn.complete', async ($, e, next) => {
114 if (e.agentId) return next(e)
115 await update($, isBusy, () => false)
116 pingTimer?.cancel()
117 pingTimer = null
118 if (await read($, keepwarm)) {
119 const done = await read($, pings)
120 const l = await read($, last)
121 if (l && done < opts.maxPings) {
122 const scheduledFor = l.at
123 pingTimer = $.clock.after(Math.max(10_000, ttlMs(opts.ttl) - PING_LEAD_MS), () => {
124 void (async () => {
125 // only when nothing happened since, Claude is idle, and the person is not typing
126 const cur = await read($, last)
127 const box = await $.prompt.read().catch(() => null)
128 if (!cur || cur.at !== scheduledFor || (await read($, isBusy)) || (box && box.text.trim() !== '')) return
129 await update($, pings, n => n + 1)
130 await $.prompt.submit({ text: PING_TEXT, cursor: PING_TEXT.length } as never)
131 })()
132 })
133 }
134 }
135 return next(e)
136 })
137
138 on('prompt.submit', async ($, e, next) => {
139 const kind = e.origin?.kind
140 // plugin and SDK prompts (the keep-warm ping included) are never stopped
141 if (kind !== undefined && kind !== 'composer' && kind !== 'bridge') return next(e)
142 await update($, pings, () => 0)
143
144 const l = await read($, last)
145 const now = await $.clock.now()
146 const isCold = !!l && now - l.at > ttlMs(opts.ttl)
147 if (!l || !isCold || l.ctx < opts.minTokens) return next(e)
148
149 const c = await read($, confirm)
150 if (c && c.until > now) {
151 await update($, confirm, () => null)
152 return next(e)
153 }
154
155 await update($, confirm, () => ({ until: now + CONFIRM_MS }))
156 const cost = estimate(l.ctx, l.model, opts.ttl)
157 // put the text back in the box once the drop has cleared it, so Enter sends it as is
158 const text = e.text
159 $.clock.after(150, () => void $.prompt.fill({ text, mode: 'replace' } as never))
160 return {
161 drop:
162 `🧊 Prompt cache is cold (idle ${span(now - l.at)}). This message re-reads ~${kTokens(l.ctx)} tokens ` +
163 `≈ ${money(cost.cold)} (warm it would be ≈ ${money(cost.warm)}). Press Enter again within 2 min to send it, ` +
164 `or /compact first to shrink the context.`,
165 }
166 })
167
168 on('command.run', { command: 'keepwarm' }, async ($, e) => {
169 const arg = e.args.trim().toLowerCase()
170 const isOn = arg === 'on' ? true : arg === 'off' ? false : !(await read($, keepwarm))
171 await update($, keepwarm, () => isOn)
172 await update($, pings, () => 0)
173 if (!isOn) {
174 pingTimer?.cancel()
175 pingTimer = null
176 }
177 await refreshStatus($, opts)
178 return {
179 text: isOn
180 ? `Keep-warm on: up to ${opts.maxPings} ping(s) while you are away, each ~${span(PING_LEAD_MS)} before the ${opts.ttl} cache expires. Each costs a cache read plus a one-word reply. Starts after Claude's next answer.`
181 : 'Keep-warm off.',
182 }
183 })
184
185 on('command.run', { command: 'cache' }, async $ => {
186 const l = await read($, last)
187 if (!l) return { text: 'No request yet in this session: nothing cached.' }
188 const now = await $.clock.now()
189 const left = l.at + ttlMs(opts.ttl) - now
190 const cost = estimate(l.ctx, l.model, opts.ttl)
191 return {
192 text:
193 `${left > 0 ? `🔥 warm, ${span(left)} left` : `🧊 cold for ${span(-left)}`} (TTL ${opts.ttl}). ` +
194 `Next message re-sends ~${kTokens(l.ctx)} tokens on ${l.model}: ≈ ${money(cost.warm)} warm, ≈ ${money(cost.cold)} cold. ` +
195 `Keep-warm: ${(await read($, keepwarm)) ? 'on' : 'off'}.`,
196 }
197 })
198}
199types/index.d.ts 18 lines1/** The main loop's last request: what the next one re-sends, and when the cache was last refreshed. */
2export type LastRequest = { at: number; ctx: number; model: string }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'cache-tax': {
7 last: LastRequest | null
8 /** A cold-cache warning shown for this prompt: Enter again before `until` sends it. */
9 confirm: { until: number } | null
10 keepwarm: boolean
11 /** Pings sent since the person last typed. */
12 pings: number
13 /** The main loop is mid-turn. */
14 isBusy: boolean
15 }
16 }
17}
18