SLOPSHOPPER

now-playing

What Spotify is playing, one line above the prompt: track, progress and the lyric being sung, with ⏮ ⏸ ⏭ buttons. /music controls it too. macOS.

newbandcommandprocessnetworktimer
★ 167v0.1.0MITupdated 2026-10-05hamzafer/claude-code-mods/mods/now-playing
A shopper browsing a rack in a slop shop
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 294 lines
1// Now Playing: what is Spotify playing?
2//   One line above the prompt: the track and artist, a progress bar, the time, and the
3//   lyric line being sung (synced lyrics from LRCLIB, fetched once per track). Paused, the
4//   line dims; Spotify closed, it goes away. The mod never opens Spotify itself.
5//   ⏮ ⏸ ⏭ at the end of the line are buttons: click them, or ctrl+x tab to the band and
6//   press b, p or n. /music, /music next and /music prev do the same from the prompt.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register, Timer } from 'claude-code'
9
10import type { Lyrics, Track } from '../types'
11
12const PLAYING_POLL_MS = 3_000 // between polls the position moves on with the clock
13const IDLE_POLL_MS = 5_000 // paused or closed
14const LYRICS_TIMEOUT_MS = 5_000
15const BAR = 12
16const LYRIC_LEAD_S = 0.3 // a line shows a moment early, as karaoke apps do
17const LRCLIB = 'https://lrclib.net/api'
18const AGENT = 'now-playing (https://github.com/hamzafer/claude-code-mods)'
19
20// One osascript run: closed, stopped, or the player's state and track, a field per line.
21// The `is running` check comes first, so asking never launches Spotify.
22const READ_SCRIPT = `if application "Spotify" is running then
23  tell application "Spotify"
24    set s to player state as string
25    if s is "stopped" then return "stopped"
26    set t to current track
27    return s & linefeed & (id of t) & linefeed & (name of t) & linefeed & (artist of t) & linefeed & (album of t) & linefeed & (duration of t) & linefeed & (player position as string)
28  end tell
29end if
30return "closed"`
31
32export const ACTIONS = { toggle: 'playpause', next: 'next track', prev: 'previous track' } as const
33export type Action = keyof typeof ACTIONS
34
35// Held by the host, so the line survives a hot reload of this file.
36const track = atom({ plugin: 'now-playing', key: 'track' } as const, null as Track | null)
37const lyrics = atom({ plugin: 'now-playing', key: 'lyrics' } as const, null as Lyrics | null)
38const now = atom({ plugin: 'now-playing', key: 'now' } as const, 0)
39
40let polling: Promise<void> | null = null // a slow osascript must not let two polls overlap
41// Module-level, so a hot reload (which runs register again) can cancel the old loop.
42let timers: Timer[] = []
43
44export const register: Register = (on, options) => {
45  const wantsLyrics = options.lyrics !== false
46  let lastPoll = 0
47  let isMac = false
48
49  on('session.start', async ($, e, next) => {
50    const r = await next(e)
51    for (const t of timers) t.cancel()
52    timers = []
53    // Off macOS there is no Spotify to ask: no command, no polling, nothing runs again.
54    const uname = await $.process.run(['uname', '-s'], { timeoutMs: 3_000 }).catch(() => null)
55    isMac = uname?.stdout.trim() === 'Darwin'
56    if (!isMac) return r
57    await $.command.register({ name: 'music', description: 'Spotify: play or pause. /music next, /music prev skip' }).catch(() => {}) // a name Claude Code already has is refused: start anyway
58    lastPoll = await $.clock.now()
59    timers = [
60      $.clock.every(250, () => {
61        void (async () => {
62          const t = await $.clock.now()
63          const cur = await read($, track)
64          // Playing, the line moves on without running anything: 4 times a second while a lyric shows, else each second.
65          const l = cur?.state === 'playing' ? await read($, lyrics) : null
66          const hasLyrics = !!l && l.id === cur?.id && l.lines.length > 0
67          if (cur?.state === 'playing' && (hasLyrics || t - (await read($, now)) >= 1000)) await update($, now, () => t)
68          if (t - lastPoll >= (cur?.state === 'playing' ? PLAYING_POLL_MS : IDLE_POLL_MS)) {
69            lastPoll = t
70            await poll($, wantsLyrics)
71          }
72        })().catch(() => {})
73      }),
74    ]
75    void poll($, wantsLyrics).catch(() => {})
76    return r
77  })
78
79  on('session.end', async ($, e, next) => {
80    for (const t of timers) t.cancel()
81    timers = []
82    return next(e)
83  })
84
85  on('command.run', { command: 'music' }, async ($, e) => {
86    const action = actionOf(e.args)
87    if (!action) return { text: 'Usage: /music (play or pause), /music next, /music prev' }
88    const ok = await control($, action, wantsLyrics).catch(() => false)
89    if (!ok) return { text: 'Spotify is not running. Open it first; /music never starts it' }
90    const t = await read($, track)
91    return { text: t ? `${t.state === 'playing' ? '🎵' : '⏸'} ${t.name} · ${t.artist}` : 'Done' }
92  })
93
94  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
95    const rest = await next(e) // what other mods and Claude Code draw here stays
96    const t = await read($, track)
97    const tick = await read($, now) // subscribes the line to the clock
98    if (e.props.hasSurvey || !t) return rest
99    const l = await read($, lyrics)
100    const { Box, Button, Text } = $.ui.resolve(e)
101    const press = (action: Action) => () => void control($, action, wantsLyrics).catch(() => false)
102    const pos = positionAt(t, Math.max(tick, t.at))
103    const line = l && l.id === t.id ? lyricAt(l, pos + LYRIC_LEAD_S) : ''
104    const sung = isRtl(line) ? '' : line // a terminal draws right-to-left scripts garbled, cached lyrics included
105    const isPaused = t.state !== 'playing'
106    const b = bar(pos, t.duration, BAR)
107    return (
108      <Box flexDirection="column">
109        <Box paddingX={1} flexDirection="row">
110          {/* Narrow, the end of this Text goes first (the time, then the bar); the buttons stay. */}
111          <Box flexShrink={1}>
112            <Text wrap="truncate-end">
113              {/* A leading emoji is drawn plain: some terminals shift or clip a bold one. */}
114              <Text dimColor={isPaused}>{isPaused ? '⏸' : '🎵'}</Text>
115              <Text bold={!isPaused} dimColor={isPaused}>{` ${t.name}`}</Text>
116              <Text dimColor>{` · ${t.artist}`}</Text>
117              {!isPaused && <Text color="#1db954">{`  ${b.done}`}</Text>}
118              {!isPaused && <Text dimColor>{b.left}</Text>}
119              {!isPaused && <Text dimColor>{` ${clock(pos)}/${clock(t.duration)}`}</Text>}
120            </Text>
121          </Box>
122          <Box flexShrink={0} flexDirection="row" gap={0} marginLeft={2}>
123            <Button key="prev" hotkey="b" label="⏮" onPress={press('prev')} />
124            <Button key="toggle" hotkey="p" label={isPaused ? '▶' : '⏸'} onPress={press('toggle')} />
125            <Button key="next" hotkey="n" label="⏭" onPress={press('next')} />
126          </Box>
127        </Box>
128        {/* The lyric gets a line of its own, so the buttons never cut it. */}
129        {!isPaused && sung !== '' && (
130          <Box paddingX={1}>
131            <Text dimColor italic wrap="truncate-end">{`   ♪ ${sung}`}</Text>
132          </Box>
133        )}
134        {rest}
135      </Box>
136    )
137  })
138}
139
140// Reads Spotify's state; on a new track, fetches its lyrics once. `fresh` waits out a poll
141// already running and reads again, so a control action sees its own result.
142async function poll($: EngineInterface, wantsLyrics: boolean, fresh = false) {
143  if (polling && !fresh) return
144  while (polling) await polling.catch(() => {})
145  polling = pollOnce($, wantsLyrics)
146  try {
147    await polling
148  } finally {
149    polling = null
150  }
151}
152
153async function pollOnce($: EngineInterface, wantsLyrics: boolean) {
154  const before = await read($, track)
155  // Closed last time: a cheap pgrep before any osascript, so an idle session runs almost nothing.
156  if (!before) {
157    const pg = await $.process.run(['pgrep', '-x', 'Spotify'], { timeoutMs: 3_000 }).catch(() => null)
158    if (!pg || pg.exitCode !== 0) return
159  }
160  const asked = await $.clock.now()
161  const out = await $.process.run(['osascript', '-e', READ_SCRIPT], { timeoutMs: 5_000 }).catch(() => null)
162  if (!out || out.exitCode !== 0) return
163  // Spotify read its position partway through the call, not when the answer arrived.
164  const t = parseTrack(out.stdout, asked + ((await $.clock.now()) - asked) / 2)
165  await update($, track, () => t)
166  if (t) await update($, now, () => t.at)
167  if (t && wantsLyrics && t.id !== before?.id) void loadLyrics($, t).catch(() => {})
168}
169
170// Sends one command to Spotify, then reads it back. False when Spotify is not running.
171async function control($: EngineInterface, action: Action, wantsLyrics: boolean) {
172  const script = `if application "Spotify" is running then
173  tell application "Spotify" to ${ACTIONS[action]}
174  return "ok"
175end if
176return "closed"`
177  const r = await $.process.run(['osascript', '-e', script], { timeoutMs: 5_000 })
178  if (r.exitCode === 0 && r.stdout.trim() === 'closed') await update($, track, () => null) // it quit since the last poll
179  if (r.exitCode !== 0 || r.stdout.trim() !== 'ok') return false
180  await poll($, wantsLyrics, true)
181  return true
182}
183
184// Fetches synced lyrics from LRCLIB: the exact match first, then a search. Silent on any error.
185async function loadLyrics($: EngineInterface, t: Track) {
186  const q = (p: Record<string, string>) => new URLSearchParams(p).toString()
187  const seconds = String(Math.round(t.duration))
188  let synced = await getJson($, `${LRCLIB}/get?${q({ track_name: t.name, artist_name: t.artist, album_name: t.album, duration: seconds })}`).then(
189    (j: any) => (typeof j?.syncedLyrics === 'string' ? j.syncedLyrics : ''),
190  )
191  // Terminals can't draw right-to-left scripts (Urdu, Arabic, Persian, Hebrew) joined and in order,
192  // so look for a romanized version; with none, show no lyric rather than garbled text.
193  if (!synced || isRtl(synced)) {
194    const list = await getJson($, `${LRCLIB}/search?${q({ track_name: t.name, artist_name: t.artist })}`)
195    synced = pickSynced(Array.isArray(list) ? list : [], t.duration)
196  }
197  if ((await read($, track))?.id !== t.id) return // the track changed while we waited
198  await update($, lyrics, () => ({ id: t.id, lines: parseLrc(synced) }))
199}
200
201async function getJson($: EngineInterface, url: string): Promise<unknown> {
202  const timeout = $.clock.sleep(LYRICS_TIMEOUT_MS).then(() => null)
203  const fetched = $.http
204    .fetch(url, { headers: { 'User-Agent': AGENT, 'Lrclib-Client': AGENT } })
205    .then(r => (r.ok ? JSON.parse(r.text) : null))
206    .catch(() => null)
207  return Promise.race([fetched, timeout])
208}
209
210export function actionOf(args: string): Action | null {
211  const a = args.trim().toLowerCase()
212  if (a === '' || a === 'play' || a === 'pause' || a === 'toggle') return 'toggle'
213  if (a === 'next' || a === 'skip') return 'next'
214  if (a === 'prev' || a === 'previous' || a === 'back') return 'prev'
215  return null
216}
217
218// osascript's answer as a track; null when Spotify is closed or stopped.
219export function parseTrack(stdout: string, at: number): Track | null {
220  const f = stdout.replace(/\n$/, '').split('\n')
221  if (f.length < 7) return null
222  const [state, id, name, artist, album, ms, position] = f as [string, string, string, string, string, string, string]
223  if (state !== 'playing' && state !== 'paused') return null
224  const duration = Number(ms) / 1000
225  const pos = Number(position.replace(',', '.')) // some locales print 38,7
226  return {
227    state,
228    id,
229    name: name || 'Unknown track',
230    artist: artist || 'Unknown artist',
231    album,
232    duration: Number.isFinite(duration) ? duration : 0,
233    position: Number.isFinite(pos) ? pos : 0,
234    at,
235  }
236}
237
238// Where playback is at `at`, moved on from the last poll while playing.
239export function positionAt(t: Track, at: number) {
240  const p = t.state === 'playing' ? t.position + Math.max(0, at - t.at) / 1000 : t.position
241  return t.duration > 0 ? Math.min(p, t.duration) : p
242}
243
244// `[mm:ss.xx] words` lines, sorted, blank lines kept as pauses.
245export function parseLrc(text: string): [number, string][] {
246  const lines: [number, string][] = []
247  for (const raw of text.split('\n')) {
248    const stamps = [...raw.matchAll(/\[(\d+):(\d+(?:[.:]\d+)?)\]/g)]
249    if (stamps.length === 0) continue
250    const words = raw.replace(/\[[^\]]*\]/g, '').trim()
251    for (const s of stamps) lines.push([Number(s[1]) * 60 + Number(s[2]!.replace(':', '.')), words])
252  }
253  return lines.sort((a, b) => a[0] - b[0])
254}
255
256export function lyricAt(l: Lyrics, seconds: number) {
257  let line = ''
258  for (const [at, words] of l.lines) {
259    if (at > seconds) break
260    line = words
261  }
262  return line
263}
264
265// The search result with synced lyrics whose length is closest to the track's (within 5 s),
266// skipping right-to-left scripts the terminal would draw garbled.
267export function pickSynced(list: any[], duration: number) {
268  const ok = list
269    .filter(x => typeof x?.syncedLyrics === 'string' && x.syncedLyrics && !isRtl(x.syncedLyrics) && Math.abs(Number(x.duration) - duration) <= 5)
270    .sort((a, b) => Math.abs(a.duration - duration) - Math.abs(b.duration - duration))
271  return ok[0]?.syncedLyrics ?? ''
272}
273
274// True when most letters are in a right-to-left script (Hebrew, Arabic, Urdu, Persian).
275export function isRtl(text: string) {
276  const letters = text.replace(/\[[^\]]*\]/g, '').match(/\p{L}/gu) ?? []
277  if (letters.length === 0) return false
278  const rtl = letters.filter(c => /[\u0590-\u08FF\uFB1D-\uFDFF\uFE70-\uFEFF]/.test(c)).length
279  return rtl / letters.length > 0.3
280}
281
282export function bar(pos: number, duration: number, width: number) {
283  const n = duration > 0 ? Math.round(Math.min(1, Math.max(0, pos / duration)) * width) : 0
284  return { done: '━'.repeat(n), left: '─'.repeat(width - n) }
285}
286
287export function clock(seconds: number) {
288  const s = Math.max(0, Math.floor(seconds))
289  const h = Math.floor(s / 3600)
290  const m = Math.floor((s % 3600) / 60)
291  const ss = String(s % 60).padStart(2, '0')
292  return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${m}:${ss}`
293}
294
types/index.d.ts 22 lines
1export type Track = {
2  state: 'playing' | 'paused'
3  id: string // Spotify's track URI
4  name: string
5  artist: string
6  album: string
7  duration: number // seconds
8  position: number // seconds, at `at`
9  at: number // when it was read, ms since the epoch
10}
11
12export type Lyrics = {
13  id: string // the track they belong to
14  lines: [number, string][] // seconds into the track, the line sung from then
15}
16
17declare module 'claude-code' {
18  interface PluginState {
19    'now-playing': { track: Track | null; lyrics: Lyrics | null; now: number }
20  }
21}
22