SLOPSHOPPER

cache-clock

A prompt-cache line under your status line: minutes left while warm, and how many tokens the next message re-caches once cold. /cache-clock setup adds it.

newcommandprocess
★ 167v0.1.0MITupdated 2026-10-06hamzafer/claude-code-mods/mods/cache-clock
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cache-clock
› 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-clock ⎿ cache-clock: cache-clock is off. /cache-clock setup adds the cache line under your status line. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

<h1 align="center">Claude Code mods</h1>

<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT"></a> <a href="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml"><img src="https://github.com/hamzafer/claude-code-mods/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI"></a>

<a href="#-install">Install</a> · <a href="#-the-mods">All mods</a> · <a href="docs/mods.md">Docs</a> · <a href="https://claude.dev/blog/getting-started-with-claude-code-mods/">What are mods?</a>

<table> <tr> <td align="center" width="33%"><a href="docs/mods.md#-context-bar"><img src="images/context-bar.png" alt="context-bar: Claude Code context window usage as a stacked bar, a color per category" width="260"></a><br>📊 <b>context-bar</b><br>what fills your context</td> <td align="center" width="33%"><a href="docs/mods.md#-review-watch"><img src="images/review-watch.png" alt="review-watch: live lines for running Codex and subagent code reviews in Claude Code" width="260"></a><br>🔍 <b>review-watch</b><br>running code reviews, live</td> <td align="center" width="33%"><a href="docs/mods.md#-md-preview"><img src="images/md-preview.png" alt="md-preview: Markdown that Claude Code edits, rendered like GitHub next to the diff" width="260"></a><br>📝 <b>md-preview</b><br>Markdown rendered like GitHub</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-blast-radius"><img src="images/gallery/blast-radius.png" alt="blast-radius: a Claude Code hook holds rm -rf and lists the files it would delete" width="260"></a><br>💥 <b>blast-radius</b><br>see what <code>rm -rf</code> would delete</td> <td align="center" width="33%"><a href="docs/mods.md#-now-playing"><img src="images/now-playing.png" alt="now-playing: Spotify track, progress bar and synced lyrics inside Claude Code" width="260"></a><br>🎵 <b>now-playing</b><br>Spotify and its lyrics, live</td> <td align="center" width="33%"><a href="docs/mods.md#-reels-and-snake"><img src="images/reels-demo.gif" alt="reels: YouTube Shorts in a Claude Code pane while it works" width="260"></a><br>📱 <b>reels</b><br>Shorts while Claude works</td> </tr> <tr> <td align="center" width="33%"><a href="docs/mods.md#-where-am-i"><img src="images/gallery/where-am-i.png" alt="where-am-i: the session goal, current step and what waits on you, above the Claude Code prompt" width="260"></a><br>📍 <b>where-am-i</b><br>goal, now, waiting on you</td> <td align="center" width="33%"><a href="docs/mods.md#-lines-above-the-prompt"><img src="images/gallery/lines.png" alt="token-weather, usage-meter and other Claude Code status lines stacked above the prompt" width="260"></a><br>🌦️ <b>token-weather and friends</b><br>lines above the prompt</td> <td align="center" width="33%"><a href="#mission-control"><img src="images/gallery/mission-control.png" alt="mission-control: Claude Code subagents, tool calls and the files they touch, live" width="260"></a><br>🛰️ <b>mission-control</b><br>agents and the code they touch</td> </tr> </table>

🚀 Install

Add the marketplace once, then install any mod by name:

claude plugin marketplace add hamzafer/claude-code-mods
claude plugin install context-bar@claude-code-mods

Or install the general-purpose set in one go:

for m in context-bar token-weather usage-meter where-am-i next-steps agent-radar review-watch replay-theater md-preview blast-radius mission-control; do
  claude plugin install "$m@claude-code-mods"
done

Restart Claude Code after installing. To try one without installing:

git clone https://github.com/hamzafer/claude-code-mods && cd claude-code-mods
claude --plugin-dir mods/context-bar

Needs Claude Code 2.1.287+. A few mods need more (Chrome, gh, a connector); the tables say which.

🧩 The mods

👀 See what's happening

ModWhat it doesCommand
🛰️mission-controlLive map of agents, tool calls and the code they touch/mission
📊context-barYour context window as one stacked bar, a color per category, with token counts and where it compacts/context-bar
🌦️token-weatherContext fill from Clear to Compact soon, plus a prompt-cache countdown
⏱️cache-clockA prompt-cache line under your status line, from Claude Code's own figures: time left, hit rate and misses, and the tokens your next message re-caches once it goes cold. Needs Node and Claude Code 2.1.251+/cache-clock setup
📍where-am-iGoal, doing now, waiting on you, next step/where
➡️next-steps2 or 3 likely next prompts after each turn, one key to draft one1 2 3, 0 hides
💰usage-meter5-hour and 7-day plan usage, the reset countdown and the session's cost
💳openai-balanceYour OpenAI API credit: an estimated balance with a gauge, today's spend, where the money mostly went, and the last call. Needs an OpenAI organization Admin key/openai-balance
📡agent-radarOne live line per running subagent/radar
🔍review-watchOne live line per running code review (Codex or a review subagent) with the model, target, elapsed time and Codex's latest output. A toast lists the findings when it ends
🌐browser-lanesWhether this session has a browser, and who holds it/browser
🕌prayer-timesThe current prayer and how long is left, the next one, and zawal. Computed on your computer, Hanafi or standard Asr/prayers
🎬replay-theaterSteps through the last turn's edits, one diff at a time/replay
📝md-previewRenders the Markdown files Claude edits like GitHub does, with before and after side by side. Needs Chrome and a terminal that shows images/md

🛡️ Guard your repo

ModWhat it doesCommand
💥blast-radiusHolds rm -r, force pushes and migrations, shows what they'd delete, cancels after 60 s with no answer

💸 Spend less

ModWhat it doesCommand
🔀switchboardPicks the model for each subagent that doesn't name one, with OpenAI's Decisions API or Jev, from its short label only. Shows what every subagent cost/route

🔧 My setup (fork and adapt)

These are built around my own tools and rules. Fork them and change the rules to yours.

ModWhat it doesCommand
👀glanceOne line with what needs you: next meeting, PRs, Linear issues, Slack DMs. Needs gh and the Google Calendar, Linear and Slack connectors/glance
🚦merge-gateHolds gh pr merge until CI is green and Codex reviewed once. Needs gh and the Codex CLI. Reviews run on one fixed model; change it to yours/gate
📏rulebook-guardEnforces my writing and git rules: rewrites em dashes, asks before --amend, unformatted pushes, emails and phone numbers in notes
💾session-saverSaves where you left off, shows it on resume. Needs unpause/park [note]

🎮 For fun (opt-in)

ModWhat it doesCommand
📱reelsYouTube Shorts while Claude works, pauses when it's done/reels
🐍snakeSnake while Claude works/snake
🎵now-playingWhat Spotify is playing, with a progress bar, the lyric being sung, and ⏮ ⏸ ⏭ buttons. Needs macOS and the Spotify app/music

<a id="mission-control"></a>

🛰️ Flagship: mission-control

Every subagent, every tool call and every file they touch, in a pane next to the chat. Shown at 4x: two subagents building a logout feature across four files.

mission-control at 4x: two subagents and a logout feature landing across four files

Install it like any mod, restart, and type /mission (or /mission code to open the code map). q closes it.

  • 🤖 w shows the agents and every tool call, live
  • 🗺️ c shows the code map, with import arrows
  • 🔵 Blue while the agent reads a file
  • 🟠 Orange while it writes
  • 🟢 Green when done, with one line on what changed

The Code view also needs macOS, Google Chrome and a terminal that shows images (Ghostty, kitty, iTerm2). The Who view works everywhere.

📚 More

Source 1 files
hooks/register.ts 231 lines
1// Cache Clock: a prompt-cache line under the status line, drawn from the
2// status line input's `prompt_cache`. Plugins cannot register a status line,
3// so /cache-clock setup points statusLine at a script that runs the person's
4// own status line first and adds the cache line under it.
5import type { EngineInterface, Register } from 'claude-code'
6
7// The scripts setup copies out of the plugin. They live in the config dir, not
8// in the plugin's folder, which changes with every version of the mod.
9const SCRIPTS = ['cache-clock.mjs', 'render.mjs']
10// What marks a statusLine command as this mod's.
11const MARK = 'cache-clock/cache-clock.mjs'
12const REFRESH_SECONDS = 30
13// The first Claude Code that sends `prompt_cache` to the status line.
14const MIN_VERSION = [2, 1, 251]
15
16type StatusLine = { type?: string; command?: string; refreshInterval?: number; [key: string]: unknown }
17// What setup leaves for remove and for the script: the settings file it wrote,
18// the statusLine that file had before, and the command the script runs first.
19// One per settings file (setup-<key>.json), so setups in several projects and
20// in the user settings each keep their own original.
21type Saved = { file: string; previous: StatusLine | null; wrapped: string | null }
22
23export const register: Register = on => {
24  on('session.start', async ($, e, next) => {
25    const r = await next(e)
26    await $.command.register({
27      name: 'cache-clock',
28      description: 'Add a prompt-cache line under the status line; /cache-clock remove takes it out',
29      argumentHint: '[setup|remove]',
30    }).catch(() => {}) // a name Claude Code already has is refused: start anyway
31    void refreshScripts($).catch(() => {})
32    return r
33  })
34
35  on('command.run', { command: 'cache-clock' }, async ($, e) => {
36    const word = e.args.trim().toLowerCase()
37    try {
38      if (word === 'setup') return { text: await setup($) }
39      if (word === 'remove') return { text: await remove($) }
40      if (word === '') return { text: await status($) }
41      return { text: 'Usage: /cache-clock [setup|remove]' }
42    } catch (err) {
43      return { text: `cache-clock: ${err instanceof Error ? err.message : String(err)}` }
44    }
45  })
46}
47
48async function setup($: EngineInterface) {
49  const dir = await clockDir($)
50  const found = await inForce($)
51  if (found.source === 'policy' || found.source === 'flag') {
52    const where = found.source === 'policy' ? 'managed settings' : '--settings'
53    return `Your status line comes from ${where}, which cache-clock can't change. Nothing changed.`
54  }
55  if (isOurs(found.statusLine)) {
56    await copyScripts($, dir)
57    return 'cache-clock is already set up. Its scripts are up to date.'
58  }
59
60  const file = await targetFile($, found.source)
61  const settings = await readJson($, file)
62  const current = found.statusLine
63  const wrapped = typeof current?.command === 'string' && current.command.trim() ? current.command : null
64  const saved: Saved = { file, previous: asStatusLine(settings.statusLine), wrapped }
65  const key = keyFor(file)
66
67  await copyScripts($, dir)
68  await $.fs.write(`${dir}/setup-${key}.json`, JSON.stringify(saved, null, 2) + '\n')
69
70  const { command: _command, refreshInterval, ...kept } = current ?? {}
71  settings.statusLine = {
72    ...kept,
73    type: 'command',
74    command: `node "${shellQuoted(`${dir}/cache-clock.mjs`)}" ${key}`,
75    refreshInterval: typeof refreshInterval === 'number' && refreshInterval < REFRESH_SECONDS ? refreshInterval : REFRESH_SECONDS,
76  }
77  await $.fs.write(file, JSON.stringify(settings, null, 2) + '\n')
78
79  const lines = [
80    wrapped
81      ? `cache-clock set up in ${file}. Your status line stays as it is, with the cache line under it.`
82      : `cache-clock set up in ${file}. The cache line is your status line.`,
83    'It shows up after Claude\'s next reply. /cache-clock remove puts things back.',
84    ...(await warnings($)),
85  ]
86  return lines.join('\n')
87}
88
89// Takes out the setup in force here: the user settings', or this project's local one.
90async function remove($: EngineInterface) {
91  const dir = await clockDir($)
92  const found = await inForce($)
93  const file = await targetFile($, isOurs(found.statusLine) ? found.source : 'user')
94  const key = keyFor(file)
95  const saved = await readSaved($, `${dir}/setup-${key}.json`)
96  if (!saved) return 'cache-clock is not set up here. Nothing changed.'
97
98  const settings = await readJson($, saved.file)
99  if (!isOurs(asStatusLine(settings.statusLine))) {
100    await $.fs.write(`${dir}/setup-${key}.json`, '{}\n')
101    return `The status line in ${saved.file} was changed since setup, so it was left as it is.`
102  }
103  if (saved.previous) settings.statusLine = saved.previous
104  else delete settings.statusLine
105  await $.fs.write(saved.file, JSON.stringify(settings, null, 2) + '\n')
106  await $.fs.write(`${dir}/setup-${key}.json`, '{}\n')
107  return saved.previous
108    ? `cache-clock removed. Your status line in ${saved.file} is back as it was.`
109    : `cache-clock removed from ${saved.file}.`
110}
111
112async function status($: EngineInterface) {
113  const found = await inForce($)
114  if (isOurs(found.statusLine)) {
115    return ['cache-clock is on. /cache-clock remove takes it out.', ...(await warnings($))].join('\n')
116  }
117  return 'cache-clock is off. /cache-clock setup adds the cache line under your status line.'
118}
119
120// After an update of the mod, the copies setup made catch up with the new scripts.
121async function refreshScripts($: EngineInterface) {
122  if (!isOurs((await inForce($)).statusLine)) return
123  const dir = await clockDir($)
124  for (const name of SCRIPTS) {
125    const fresh = await $.fs.read(`${$.plugin.root}/statusline/${name}`)
126    const copy = await $.fs.read(`${dir}/${name}`).catch(() => null)
127    if (copy !== fresh) await $.fs.write(`${dir}/${name}`, fresh)
128  }
129}
130
131async function copyScripts($: EngineInterface, dir: string) {
132  for (const name of SCRIPTS) {
133    await $.fs.write(`${dir}/${name}`, await $.fs.read(`${$.plugin.root}/statusline/${name}`))
134  }
135}
136
137async function warnings($: EngineInterface) {
138  const out: string[] = []
139  const node = await $.process.run(['node', '--version'], { timeoutMs: 5000 }).catch(() => null)
140  if (!node || node.exitCode !== 0) out.push('Warning: node is not on your PATH. The cache line needs Node to run.')
141  const { base, version } = await $.session.version()
142  if (isOlder(base ?? version, MIN_VERSION)) {
143    out.push(`Warning: Claude Code ${version} is older than 2.1.251, so it sends no cache data. The line stays empty until you update.`)
144  }
145  return out
146}
147
148// The statusLine in force and the settings source it comes from, highest precedence first.
149async function inForce($: EngineInterface) {
150  for (const source of ['policy', 'flag', 'local', 'project', 'user'] as const) {
151    const settings = await $.settings.read({ source }).catch(() => ({}))
152    const statusLine = asStatusLine((settings as Record<string, unknown>).statusLine)
153    if (statusLine) return { source, statusLine }
154  }
155  return { source: null, statusLine: null }
156}
157
158// The file setup writes: where the status line lives, except that a project's
159// shared settings get a local override instead of a path from this machine.
160async function targetFile($: EngineInterface, source: string | null) {
161  const root = await $.session.root() // where Claude Code reads a project's settings from
162  if (source === 'local' || source === 'project') return `${root}/.claude/settings.local.json`
163  return `${await configDir($)}/settings.json`
164}
165
166async function configDir($: EngineInterface) {
167  const custom = await $.env.get('CLAUDE_CONFIG_DIR')
168  if (custom) return custom.replace(/\/+$/, '')
169  const home = await $.env.get('HOME')
170  if (!home) throw new Error('HOME is not set, so the Claude config folder is unknown.')
171  return `${home}/.claude`
172}
173
174async function clockDir($: EngineInterface) {
175  return `${await configDir($)}/cache-clock`
176}
177
178async function readJson($: EngineInterface, file: string): Promise<Record<string, unknown>> {
179  if (!(await $.fs.exists(file))) return {}
180  const text = await $.fs.read(file)
181  if (!text.trim()) return {}
182  let parsed: unknown
183  try {
184    parsed = JSON.parse(text)
185  } catch {
186    throw new Error(`${file} is not valid JSON, so it was left as it is.`)
187  }
188  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error(`${file} does not hold a settings object.`)
189  return parsed as Record<string, unknown>
190}
191
192async function readSaved($: EngineInterface, path: string): Promise<Saved | null> {
193  const text = await $.fs.read(path).catch(() => null)
194  if (!text) return null
195  try {
196    const saved = JSON.parse(text)
197    return typeof saved?.file === 'string' ? (saved as Saved) : null
198  } catch {
199    return null
200  }
201}
202
203// A short stable name for a settings file's saved state: FNV-1a of its path, in hex.
204function keyFor(file: string) {
205  let hash = 0x811c9dc5
206  for (let i = 0; i < file.length; i++) hash = Math.imul(hash ^ file.charCodeAt(i), 0x01000193) >>> 0
207  return hash.toString(16).padStart(8, '0')
208}
209
210// Safe inside double quotes for sh.
211function shellQuoted(path: string) {
212  return path.replace(/["\\$`]/g, c => `\\${c}`)
213}
214
215function asStatusLine(value: unknown): StatusLine | null {
216  return value && typeof value === 'object' && !Array.isArray(value) ? (value as StatusLine) : null
217}
218
219function isOurs(statusLine: StatusLine | null) {
220  return typeof statusLine?.command === 'string' && statusLine.command.includes(MARK)
221}
222
223function isOlder(version: string, min: number[]) {
224  const parts = version.split(/[.-]/).slice(0, 3).map(Number)
225  if (parts.some(Number.isNaN)) return false
226  for (let i = 0; i < min.length; i++) {
227    if (parts[i] !== min[i]) return parts[i] < min[i]
228  }
229  return false
230}
231