SLOPSHOPPER

cache-tax

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…

newcommandstatusprompttimer
v1.0.0no licenseupdated 2026-10-09Fraktalis/claude-mods/plugins/cache-tax
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-tax
› 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 › /keepwarm ⎿ cache-tax: Keep-warm on: up to 3 ping(s) while you are away, each ~2m before the 1h cache expires. Each costs a cache rea ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

My Claude Code mods, as a plugin marketplace (alex-mods). One folder per mod under plugins/.

ModWhat it does
savvy-progressEmber 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-treeFiles 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-skinsReskins 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-taxPrompt-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.

Install on this machine (reads straight from this folder)

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).

Install from GitHub (another machine)

/plugin install savvy-progress --marketplace Fraktalis/claude-mods

Then update later with claude plugin update savvy-progress@alex-mods after pushing a new version.

Working on a mod

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-progress

  • /savvy opens the Agents pane; /savvy-skin <type|*> <crab|developer|designer|researcher|architect|ghost> picks a mascot.
  • Registers a 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.
  • Cost is estimated from each request's token usage at the model's list price (table in hooks/register.tsx, PRICES); edit it when prices change.

file-tree

  • /tree opens the Files pane (it also opens by itself on the first file Claude edits or creates); /tree-clear forgets what was touched.
  • States: ◦ read, ● modified, + new, ✓ committed. Reads never downgrade a state; editing a committed file makes it modified again.
  • A successful 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.
  • Folders leading to touched files open by themselves; 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-tax

  • Status line: 🔥 cache 42m while warm, 🧊 cache cold · next msg ≈ $2.40 once the TTL has passed since the main conversation's last request.
  • On a cold cache, a message whose context is above the threshold (20k tokens by default) is stopped once with the cold and warm cost; the text is put back in the box, and Enter again within 2 min sends it.
  • Cost: cold re-writes the whole context to the cache (2x input price for a 1h TTL, 1.25x for 5m); warm reads it at the cache-read price. Prices in 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.
  • Options (plugin config): ttl (1h or 5m), minTokens, maxPings. The TTL is not detected: switch it to 5m in usage overage.

tool-skins

  • Redraws each tool row's header (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.
  • A new skin is one more entry in THEMES (hooks/register.tsx). The name claude-skins is reserved for Anthropic, hence tool-skins.
Source 2 files
hooks/register.ts 199 lines
1import { 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}
199
types/index.d.ts 18 lines
1/** 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