SLOPSHOPPER

token-weather

A live forecast of the context window, with a prompt-cache countdown, drawn above the prompt.

newbandprocesstimer
★ 167v0.2.1MITupdated 2026-10-06hamzafer/claude-code-mods/mods/token-weather
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · token-weather
› 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 ☁ Cloudy 49% of context 97.4k / 200k last turns ██ steady ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
☁ Cloudy 49% of context 97.4k / 200k last turns ██ steady ⟨Claude Code's own drawing⟩
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 2 files
hooks/register.tsx 268 lines
1// Token Weather: a live forecast of the context window, above the prompt,
2// and how long the prompt cache stays warm after the last request.
3import { atom, read, update } from 'claude-code'
4import type { EngineInterface, Register, Timer } from 'claude-code'
5
6import type { CacheTtl, TokenWeatherReading } from '../types'
7
8const HISTORY = 12
9const BARS = '▁▂▃▄▅▆▇█'
10const FORECAST = [
11  { upTo: 25, icon: '☀', word: 'Clear', color: 'yellow' },
12  { upTo: 50, icon: '☁', word: 'Cloudy', color: 'cyan' },
13  { upTo: 75, icon: '☂', word: 'Showers', color: 'blue' },
14  { upTo: 90, icon: '☇', word: 'Storm', color: 'magenta' },
15  { upTo: Infinity, icon: '↯', word: 'Compact soon', color: 'red' },
16] as const
17
18// The cache countdown ticks every second only in its last two minutes;
19// before that it moves in 15 s steps (30 s with over ten minutes left),
20// so the band is not redrawn every second.
21const TTL_MS = { '5m': 5 * 60_000, '1h': 60 * 60_000 } as const
22const FAST_BELOW_MS = 2 * 60_000
23const SLOW_STEP_MS = 15_000
24const SLOWER_ABOVE_MS = 10 * 60_000
25const SLOWER_STEP_MS = 30_000
26const WARN_BELOW_MS = 60_000
27// How much of the transcript's end to read for the last response's usage.
28const TAIL_BYTES = 1024 * 1024
29
30// Held by the host, so the history survives a hot reload of this file.
31const readings = atom({ plugin: 'token-weather', key: 'readings' } as const, [] as TokenWeatherReading[])
32const lastRequestAt = atom({ plugin: 'token-weather', key: 'lastRequestAt' } as const, null as number | null)
33// The cache lifetime Claude's responses last showed; null until one wrote to the cache.
34const detectedTtl = atom({ plugin: 'token-weather', key: 'detectedTtl' } as const, null as CacheTtl | null)
35// Whether the cache-clock mod draws the cache on the status line, from Claude Code's own figures.
36const cacheClockOn = atom({ plugin: 'token-weather', key: 'cacheClockOn' } as const, false)
37
38export const register: Register = (on, options) => {
39  // `5m` or `1h` set by hand wins; `auto` (the default) goes by what was detected.
40  const override: CacheTtl | null = options.cacheTtl === '5m' || options.cacheTtl === '1h' ? options.cacheTtl : null
41
42  on('session.start', async ($, e, next) => {
43    const result = await next(e)
44    await takeReading($)
45    await checkCacheClock($)
46    if ((await read($, detectedTtl)) === null) {
47      const known = await $.store.get('detectedTtl').catch(() => undefined) // the last session's, until this one's first response says
48      if (known === '5m' || known === '1h') await update($, detectedTtl, () => known)
49    }
50    const since = await read($, lastRequestAt) // kept across a hot reload: pick the countdown up again
51    if (since !== null) restart($, since, await $.clock.now(), await ttlMsFor($, override))
52    return result
53  })
54
55
56  // A /clear or a resume leaves the old conversation, and its cache, behind.
57  on('session.end', async ($, e, next) => {
58    if (e.reason === 'clear' || e.reason === 'resume') {
59      stop()
60      await update($, lastRequestAt, () => null)
61    }
62    return next(e)
63  })
64
65  // One model request of a turn. The cache's clock restarts when each one on the
66  // main thread finishes, tool-loop requests inside a turn included.
67  on('turn.step', async function* ($, e, next) {
68    const result = yield* next(e)
69    if (!e.agentId && result.stopReason !== null) {
70      const now = await $.clock.now()
71      await update($, lastRequestAt, () => now)
72      restart($, now, now, await ttlMsFor($, override))
73    }
74    return result
75  })
76
77  on('turn.complete', async ($, e, next) => {
78    const result = await next(e)
79    if (!e.agentId) {
80      await takeReading($) // main-loop turns only, not subagents
81      await checkCacheClock($) // set up or removed mid-session
82      if (override === null) {
83        // The last response's usage in the transcript says which lifetime it wrote to the cache with.
84        const found = ttlFromTranscript(await readTail($))
85        if (found !== null) await detect($, found)
86      }
87    }
88    return result
89  })
90
91  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
92    const rest = await next(e) // what other mods and Claude Code draw here stays
93    const history = await read($, readings)
94    const now = history.at(-1)
95    if (e.props.hasSurvey || !now) {
96      return rest
97    }
98
99    const { Box, Text } = $.ui.resolve(e)
100    const f = FORECAST.find(b => now.percent < b.upTo) ?? FORECAST[4]
101    const isWide = e.props.bodyColumns >= 60
102
103    // The cache part goes first when the line runs out of room.
104    const since = await read($, lastRequestAt)
105    const ttlMs = await ttlMsFor($, override)
106    let cache: ReturnType<typeof cachePart> | null = null
107    if (since !== null && !(await read($, cacheClockOn))) {
108      const clockNow = await $.clock.now()
109      if (armedFor !== since) restart($, since, clockNow, ttlMs) // its timer was lost (a reload): pick it up
110      cache = cachePart(since + ttlMs - clockNow)
111    }
112    const lineWidth =
113      2 + // paddingX
114      `${f.icon} ${f.word} ${now.percent}% of context ${short(now.tokens)} / ${short(now.window)}`.length +
115      (isWide ? `  last turns ${sparkline(history)}${history.length > 1 ? trend(history) : ''}`.length : 0)
116    const showCache = isWide && cache !== null && lineWidth + cache.text.length <= e.props.bodyColumns
117
118    return (
119      <Box flexDirection="column">
120        <Box flexDirection="row" paddingX={1}>
121          <Text color={f.color} bold>{`${f.icon} ${f.word}`}</Text>
122          <Text>{` ${now.percent}% of context`}</Text>
123          <Text dimColor>{` ${short(now.tokens)} / ${short(now.window)}`}</Text>
124          {isWide && <Text dimColor>{'  last turns '}</Text>}
125          {isWide && <Text color={f.color}>{sparkline(history)}</Text>}
126          {isWide && history.length > 1 && <Text dimColor>{trend(history)}</Text>}
127          {showCache && cache && <Text color={cache.color} dimColor={cache.isCold}>{cache.text}</Text>}
128        </Box>
129        {rest}
130      </Box>
131    )
132  })
133}
134
135// cache-clock's status line shows the exact countdown, so this one steps aside.
136async function checkCacheClock($: EngineInterface) {
137  const settings = await $.settings.read().catch(() => ({}) as Record<string, unknown>)
138  const line = settings.statusLine as { command?: unknown } | undefined
139  const isOn = typeof line?.command === 'string' && line.command.includes('cache-clock/cache-clock.mjs')
140  if ((await read($, cacheClockOn)) !== isOn) await update($, cacheClockOn, () => isOn)
141}
142
143async function ttlMsFor($: EngineInterface, override: CacheTtl | null) {
144  return TTL_MS[override ?? (await read($, detectedTtl)) ?? '5m']
145}
146
147// Keeps a newly detected lifetime, here and for the next session, and moves the countdown
148// onto it. Only called with no override set.
149async function detect($: EngineInterface, ttl: CacheTtl) {
150  if ((await read($, detectedTtl)) === ttl) return
151  await update($, detectedTtl, () => ttl)
152  await $.store.set('detectedTtl', ttl).catch(() => {}) // remembered for the next session when the store allows
153  const now = await $.clock.now()
154  const since = await read($, lastRequestAt) // read last, so a request that just finished is the one re-armed
155  if (since !== null) restart($, since, now, TTL_MS[ttl])
156}
157
158// The end of this session's transcript, where the last response is: whole if small,
159// else its last bytes. '' when it cannot be found or read.
160async function readTail($: EngineInterface) {
161  try {
162    const path = await transcriptPath($)
163    if (path === null) return ''
164    const { size } = await $.fs.stat(path)
165    if (size <= TAIL_BYTES) return await $.fs.read(path)
166    const { exitCode, stdout } = await $.process.run(['tail', '-c', String(TAIL_BYTES), path])
167    return exitCode === 0 ? stdout : ''
168  } catch {
169    return '' // unreadable: keep the last detected value
170  }
171}
172
173// <config dir>/projects/<project root, each other character a dash>/<session id>.jsonl
174// A very long project path, or a root moved during the session, gives a path that does
175// not exist: then nothing is detected and the last value stays.
176async function transcriptPath($: EngineInterface) {
177  const home = await $.env.get('HOME')
178  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) ?? (home ? `${home}/.claude` : undefined)
179  if (!configDir) return null
180  const project = (await $.session.root()).replace(/[^a-zA-Z0-9]/g, '-')
181  return `${configDir}/projects/${project}/${await $.session.id()}.jsonl`
182}
183
184// The lifetime the latest main-thread response wrote to the cache with; null when it
185// wrote nothing (a pure cache hit) or no response was found.
186function ttlFromTranscript(text: string): CacheTtl | null {
187  const lines = text.split('\n')
188  for (let i = lines.length - 1; i >= 0; i--) {
189    let entry: any
190    try {
191      entry = JSON.parse(lines[i] ?? '')
192    } catch {
193      continue // blank, or cut by the tail
194    }
195    if (entry?.type !== 'assistant' || entry.isSidechain || !entry.message?.usage) continue
196    const written = entry.message.usage.cache_creation
197    if (Number(written?.ephemeral_1h_input_tokens) > 0) return '1h'
198    if (Number(written?.ephemeral_5m_input_tokens) > 0) return '5m'
199    return null
200  }
201  return null
202}
203
204// The countdown's redraw timer, one at a time; a reload of this file starts it over.
205let tick: Timer | undefined
206let armedFor: number | null = null
207
208function stop() {
209  tick?.cancel()
210  tick = undefined
211  armedFor = null
212}
213
214function restart($: EngineInterface, since: number, now: number, ttlMs: number) {
215  armedFor = since
216  arm($, since, now, ttlMs)
217}
218
219// Redraws the band when the countdown's text next changes, until it reads cold.
220function arm($: EngineInterface, since: number, now: number, ttlMs: number) {
221  if (armedFor !== since) return // a newer request took over
222  tick?.cancel()
223  const left = since + ttlMs - now
224  if (left <= 0) return
225  const isFast = left <= FAST_BELOW_MS
226  const step = isFast ? 1000 : left > SLOWER_ABOVE_MS ? SLOWER_STEP_MS : SLOW_STEP_MS
227  const wait = Math.min(left % step || step, isFast ? step : left - (left > SLOWER_ABOVE_MS ? SLOWER_ABOVE_MS : FAST_BELOW_MS))
228  tick = $.clock.after(wait, () => {
229    $.ui.invalidate('ui.render')
230    $.clock.now().then(t => arm($, since, t, ttlMs)).catch(() => {})
231  })
232}
233
234async function takeReading($: EngineInterface) {
235  const { context } = await $.session.usage()
236  if (!context?.window) return
237  const tokens = context.tokens ?? 0
238  const percent = context.percent ?? Math.round((tokens / context.window) * 100)
239  await update($, readings, history => [...history, { tokens, window: context.window, percent }].slice(-HISTORY))
240}
241
242// What the cache part says with `leftMs` until the cache expires.
243function cachePart(leftMs: number) {
244  if (leftMs <= 0) {
245    return { text: '  ❄ cache cold', color: 'red', isCold: true }
246  }
247  const seconds = Math.ceil(leftMs / 1000)
248  const clock = `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
249  return { text: `  ❄ cache ${clock}`, color: seconds * 1000 < WARN_BELOW_MS ? 'yellow' : undefined, isCold: false }
250}
251
252function sparkline(history: TokenWeatherReading[]) {
253  const top = Math.max(...history.map(r => r.tokens), 1)
254  return history.map(r => BARS[Math.floor((r.tokens / top) * (BARS.length - 1))]).join('')
255}
256
257function trend(history: TokenWeatherReading[]) {
258  const delta = (history.at(-1)?.tokens ?? 0) - (history.at(-2)?.tokens ?? 0)
259  if (delta === 0) return ' steady'
260  return delta > 0 ? ` ▲ +${short(delta)} last turn` : ` ▼ ${short(-delta)} last turn`
261}
262
263function short(n: number) {
264  if (n >= 1_000_000) return `${+(n / 1_000_000).toFixed(1)}M`
265  if (n >= 1_000) return `${+(n / 1_000).toFixed(1)}k`
266  return String(n)
267}
268
types/index.d.ts 17 lines
1export type CacheTtl = '5m' | '1h'
2export type TokenWeatherReading = { tokens: number; window: number; percent: number }
3
4declare module 'claude-code' {
5  interface PluginState {
6    'token-weather': {
7      readings: TokenWeatherReading[]
8      // When the last main-thread model request finished, in ms since the epoch; null before the first.
9      lastRequestAt: number | null
10      // The cache lifetime Claude's responses last showed; null until one wrote to the cache.
11      detectedTtl: CacheTtl | null
12      // Whether the cache-clock mod's status line is set up; its countdown replaces this one.
13      cacheClockOn: boolean
14    }
15  }
16}
17