SLOPSHOPPER

radio

Internet radio in Claude Code: /tune streams SomaFM, lo-fi and classical through one player shared by every session, with controls above the prompt

newbandcommandtoaststatusprocess
v0.2.2MITupdated 2026-10-04sivori/claude-mods/plugins/radio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · radio
› 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 › /tune ⎿ radio: ♪ Lofi Girl ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mods

Small mods for Claude Code: status lines, a band above the prompt, a docked pane, and a radio. Each one is a plugin of function hooks.

radio, backlog-band, spend-meter and commit-drift in a Claude Code terminal (illustration)

Install

claude plugin marketplace add sivori/claude-mods
claude plugin install radio@claude-mods        # or any mod below

Update later with claude plugin marketplace update claude-mods.

To try one without installing, clone the repo and run claude --plugin-dir ./plugins/<mod>.

The mods

radio

/tune streams internet radio inside Claude Code: SomaFM, lo-fi, classical and choral. (The command is /tune because Claude Code has a built-in /radio.) All your sessions share one player, so music started in two sessions never plays twice. Switching stations in any session replaces the current stream, and the status line shows what's playing everywhere. While something plays, a row of buttons sits above the prompt: ◀ ■ ▶ to change or stop, − + for volume. Stopping keeps the row, with a play button that resumes the last station.

/tune              toggle (resumes the last station); /tune listen just plays
/tune groove       pick by name, genre ("jazz", "choral") or number
/tune next | prev | stop | list
/tune vol 30       also vol +10 / vol -10
/tune band         hide or show the buttons

Plays through mpv (brew install mpv), which changes volume without a gap; otherwise ffplay from ffmpeg, which restarts the stream on each volume change.

By default the music keeps playing after you close Claude Code, until /tune stop. Turn on stopWithLastSession in /config to have it stop once no session is open; a watchdog catches a crashed session too, within about a minute.

spend-meter

Session cost, context fill and 5-hour rate-limit use in the status line ($1.23 · ctx 42% · 5h 24%). It toasts once as spend passes $5, $10, $25, $50 and $100.

commit-drift

Uncommitted file count and time since the last commit in the status line, for whichever repo you're editing. After an editing turn on a tree that hasn't been committed for 30+ minutes, it nudges you (at most every 10 minutes).

backlog-band

Shows the open ## Now items of the active repo's BACKLOG.md above the prompt. /later <item> appends to ## Next, and /backlog-band hides or shows the band. It expects this format:

## Now
- [ ] Blocking or imminent work

## Next
- [ ] The normal queue

## Someday
- [ ] Ideas

## Done
- [x] 2026-08-01 Completed

day-recap

So you never start cold. While you work it records your prompts, the files edited and the commits made. Once the session has been quiet for 10 minutes, it asks the model for a two-line recap (what got done, the next step) and saves it per repo. When you next open Claude Code in that repo, the recap sits above the prompt until your first message:

↩ Last time in dash · yesterday 5:12pm
  Done: Added the spend chart and matched its colors.
  Next: Write a test for the empty-data case.

/recap writes it now, and /recap last brings back the one from last time. A session that did nothing leaves the earlier recap in place. If you quit before the recap is written, the next session writes it from the recorded activity with Haiku.

quest-board

Three daily quests in a pane, drawn from the active repo: a TODO/FIXME comment to resolve, an open GitHub issue to close (needs gh), and a test command that failed to get passing. It picks one of each kind when it can, keeps the same board all day, and draws a new one tomorrow. A quest clears by itself when the TODO line is gone, the issue is closed or the test command passes. Each cleared quest gets a toast, and clearing all three gets a bigger one. The status line shows ⚔ quests 1/3.

/quests             open the board
/quests reroll      swap the unfinished quests for new ones
/quests check       re-check the issues now (otherwise every 10 minutes)

commonplace-pane

A docked pane of public-domain paintings from the Art Institute of Chicago. Its "weather" follows your repo: calm landscapes while the tree is clean, overcast as changes pile up or commands fail, shipwrecks in a storm. The painting rotates every 20 minutes, and /commonplace next hangs a new one.

The images need a terminal with the kitty graphics protocol (kitty, Ghostty); elsewhere the pane shows the title and artist. It uses macOS sips to convert images. Paintings are cached in ~/.cache/commonplace-pane/, which keeps the newest 30 and deletes the rest after each new painting. Two optional settings live in /config:

  • contact: an email or URL sent in the museum's AIC-User-Agent header, as the Art Institute asks of API clients.
  • seedDir: a folder of seed-*.jpg paintings to fall back on when the museum is unreachable.

Developing

Each mod is plugins/<name>/ with .claude-plugin/plugin.json, hooks/hooks.json and a hooks/register.ts(x) module. To check one:

claude plugin validate plugins/<name>
claude plugin test plugins/<name>

License

MIT

Source 2 files
hooks/register.tsx 323 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { LastStation, NowPlaying } from '../types'
5
6export type Station = { name: string; url: string; genre: string }
7
8// Lo-fi, SomaFM, and a few public-radio streams.
9export const STATIONS: Station[] = [
10  { name: 'Lofi Girl', url: 'https://play.streamafrica.net/lofiradio', genre: 'lo-fi' },
11  { name: 'Chillhop', url: 'https://streams.fluxfm.de/Chillhop/mp3-320/audio/', genre: 'lo-fi' },
12  { name: 'SomaFM Secret Agent', url: 'https://ice1.somafm.com/secretagent-128-mp3', genre: 'chill jazz' },
13  { name: 'SomaFM Sonic Universe', url: 'https://ice1.somafm.com/sonicuniverse-128-mp3', genre: 'jazz' },
14  { name: 'SomaFM Groove Salad', url: 'https://ice1.somafm.com/groovesalad-256-mp3', genre: 'ambient' },
15  { name: 'SomaFM DEF CON', url: 'https://ice1.somafm.com/defcon-256-mp3', genre: 'synth' },
16  { name: 'SomaFM Drone Zone', url: 'https://ice1.somafm.com/dronezone-256-mp3', genre: 'drone' },
17  { name: 'SomaFM Space Station', url: 'https://ice1.somafm.com/spacestation-128-mp3', genre: 'ambient' },
18  { name: 'SomaFM Vaporwaves', url: 'https://ice1.somafm.com/vaporwaves-128-mp3', genre: 'vapor' },
19  { name: 'SomaFM cliqhop idm', url: 'https://ice1.somafm.com/cliqhop-256-mp3', genre: 'idm' },
20  { name: 'SomaFM Deep Space One', url: 'https://ice1.somafm.com/deepspaceone-128-mp3', genre: 'deep' },
21  { name: 'SomaFM Beat Blender', url: 'https://ice1.somafm.com/beatblender-128-mp3', genre: 'beats' },
22  { name: 'WWFM Classical', url: 'https://wwfm.streamguys1.com/live', genre: 'classical' },
23  { name: 'Classical Choral', url: 'https://choral.stream.publicradio.org/choral.aac', genre: 'choral' },
24]
25
26export type Action =
27  | { kind: 'toggle' | 'resume' | 'stop' | 'next' | 'prev' | 'list' | 'band' }
28  | { kind: 'volume'; value: number; isRelative: boolean }
29  | { kind: 'play'; index: number }
30  | { kind: 'unknown'; query: string }
31
32/** `/tune` arguments → what to do. Names match on any substring of name or genre. */
33export function parse(args: string, stations: readonly Station[] = STATIONS): Action {
34  const text = args.trim().toLowerCase()
35  if (text === '' || text === 'toggle') return { kind: 'toggle' }
36  if (['play', 'listen', 'on', 'start', 'resume'].includes(text)) return { kind: 'resume' }
37  if (['stop', 'off', 'pause', 'quiet'].includes(text)) return { kind: 'stop' }
38  if (text === 'next' || text === 'n') return { kind: 'next' }
39  if (text === 'prev' || text === 'p') return { kind: 'prev' }
40  if (text === 'list' || text === 'ls') return { kind: 'list' }
41  if (text === 'band') return { kind: 'band' }
42
43  const vol = /^vol(?:ume)?\s+([+-]?)(\d+)$/.exec(text)
44  if (vol) return { kind: 'volume', value: (vol[1] === '-' ? -1 : 1) * Number(vol[2]), isRelative: vol[1] !== '' }
45
46  if (/^\d+$/.test(text)) {
47    const index = Number(text) - 1
48    return index >= 0 && index < stations.length ? { kind: 'play', index } : { kind: 'unknown', query: text }
49  }
50
51  const query = text.replace(/^play\s+/, '')
52  const index = stations.findIndex(s => s.name.toLowerCase().includes(query))
53  const byGenre = stations.findIndex(s => s.genre.toLowerCase().includes(query))
54  const found = index >= 0 ? index : byGenre
55  return found >= 0 ? { kind: 'play', index: found } : { kind: 'unknown', query }
56}
57
58export function listing(current: number, isPlaying: boolean, volume: number): string {
59  const rows = STATIONS.map((s, i) => `${i === current ? (isPlaying ? '▶' : '·') : ' '} ${String(i + 1).padStart(2)}. ${s.name}  (${s.genre})`)
60  return [...rows, '', `volume ${volume} · /tune <n|name|genre> · next · prev · stop · vol [+-]N · band`].join('\n')
61}
62
63/** `i` is always reduced modulo the list before it gets here. */
64const stationAt = (i: number): Station => STATIONS[i] as Station
65
66const clamp = (v: number) => Math.max(0, Math.min(100, Math.round(v)))
67
68// One player per machine, shared by every Claude session: a detached mpv (or
69// ffplay) whose pid, station and backend live in ~/.cache/claude-radio/now.
70// Any session's /tune replaces it rather than starting a second stream, and
71// any session's stop ends it. A pid is only killed while `ps` still names it
72// a player, so a recycled pid is never touched.
73//
74// The player's own log, overwritten per stream, is player.log: when a
75// station stops, its last lines say why.
76//
77// Both players reconnect when a live stream's connection drops; left to
78// their defaults they exit, and the station silently stops.
79//
80// mpv takes volume changes live over its IPC socket; ffplay has no runtime
81// control, so `vol` exits 3 and the caller restarts the stream instead.
82//
83// Each session touches sessions/<id> on every sync. With stopWithLastSession
84// on, a watchdog beside the player ends it once no lease is under a minute
85// old, so even a crashed last session doesn't leave music playing.
86export const CTL = `
87dir="$HOME/.cache/claude-radio"; f="$dir/now"; leases="$dir/sessions"; sock="$dir/mpv.sock"
88PATH="$PATH:/opt/homebrew/bin:/usr/local/bin"
89# A file written by radio 0.1 has no backend field: read it off the process.
90alive() { [ -f "$f" ] && read -r opid oidx obk < "$f" && c=$(ps -p "$opid" -o comm= 2>/dev/null) && echo "$c" | grep -qE 'ffplay|mpv' && { [ -n "$obk" ] || obk=$(basename "$c"); }; }
91fresh() { [ -n "$(find "$leases" -type f -mmin -1 2>/dev/null | head -1)" ]; }
92case "$1" in
93  status)
94    [ -n "$2" ] && mkdir -p "$leases" && touch "$leases/$2"
95    find "$leases" -type f -mmin +1440 -delete 2>/dev/null
96    if alive; then echo "$oidx $obk"; else rm -f "$f"; fi ;;
97  leave)
98    rm -f "$leases/$2"
99    if [ "$3" = 1 ] && ! fresh && alive; then kill -9 "$opid"; rm -f "$f"; fi ;;
100  stop) if alive; then kill -9 "$opid"; fi; rm -f "$f" ;;
101  vol)
102    alive && [ "$obk" = mpv ] && printf '{"command":["set_property","volume",%s]}\\n' "$2" | nc -U -w 1 "$sock" >/dev/null 2>&1 && exit 0
103    exit 3 ;;
104  play)
105    idx="$2"; url="$3"; vol="$4"; watch="$5"
106    if alive; then kill -9 "$opid"; fi
107    mkdir -p "$dir" "$leases"
108    if command -v mpv >/dev/null; then
109      rm -f "$sock"; bk=mpv
110      nohup mpv --no-video --no-terminal --volume="$vol" --input-ipc-server="$sock" --log-file="$dir/player.log" --msg-level=all=info --stream-lavf-o=reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1,reconnect_delay_max=10 "$url" </dev/null >/dev/null 2>&1 &
111    elif command -v ffplay >/dev/null; then
112      bk=ffplay
113      nohup ffplay -nodisp -vn -nostats -loglevel error -volume "$vol" -infbuf -reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 10 "$url" </dev/null >/dev/null 2>"$dir/player.log" &
114    else
115      echo "no player" >&2; exit 127
116    fi
117    pid=$!; echo "$pid $idx $bk" > "$f"
118    if [ "$watch" = 1 ]; then
119      nohup sh -c 'while kill -0 "$1" 2>/dev/null; do sleep 15; [ -n "$(find "$2" -type f -mmin -1 2>/dev/null | head -1)" ] || { kill -9 "$1"; rm -f "$3"; break; }; done' watchdog "$pid" "$leases" "$f" </dev/null >/dev/null 2>&1 &
120    fi ;;
121esac
122`
123
124/** What `status` printed: the station and backend, or null when silent. */
125export function parseStatus(stdout: string): NowPlaying | null {
126  const match = /^(\d+)\s+(mpv|ffplay)/.exec(stdout.trim())
127  if (!match) return null
128  const index = Number(match[1]) % STATIONS.length
129  return { index, name: stationAt(index).name, backend: match[2] as NowPlaying['backend'] }
130}
131
132// What the band draws; the status line and /tune read the same value.
133const nowPlaying = atom({ plugin: 'radio', key: 'now' } as const, null)
134const isBandHidden = atom({ plugin: 'radio', key: 'isBandHidden' } as const, false)
135// Set whenever something plays and kept through a stop, so the band can offer
136// to resume; null until the first stream of the session.
137const lastStation = atom({ plugin: 'radio', key: 'lastStation' } as const, null)
138
139// Module-scope on purpose: a reload re-runs this file and re-reads the
140// shared player, so starting over loses nothing.
141let index = 0
142let volume = 50
143let sessionId = ''
144let stopWithLastSession = false
145/** Whether this session started the current stream (only it toasts a drop). */
146let isOwner = false
147
148async function ctl($: EngineInterface, args: readonly string[]) {
149  return $.process.run(['/bin/sh', '-c', CTL, 'radio', ...args])
150}
151
152async function publish($: EngineInterface, now: NowPlaying | null) {
153  await update($, nowPlaying, () => now)
154  $.ui.status(now === null ? undefined : `♪ ${now.name}`)
155  if (now !== null) {
156    const last: LastStation = { index: now.index, name: now.name }
157    await update($, lastStation, () => last)
158  }
159}
160
161/** Re-read the shared player; another session may have switched or stopped it. */
162async function sync($: EngineInterface) {
163  const { stdout } = await ctl($, ['status', sessionId])
164  const now = parseStatus(stdout)
165  const was = await read($, nowPlaying)
166  // Another session may have changed the volume; the band shows it.
167  const stored = clamp(Number((await $.store.get('volume')) ?? volume))
168  const isVolumeNew = stored !== volume
169  volume = stored
170  if (now?.index === was?.index && now?.backend === was?.backend) {
171    if (isVolumeNew) await redraw($)
172    return
173  }
174  if (now === null && isOwner && was !== null) $.ui.toast(`Radio dropped: ${was.name}`)
175  if (now !== null) index = now.index
176  isOwner = false
177  await publish($, now)
178}
179
180async function stop($: EngineInterface) {
181  await ctl($, ['stop'])
182  isOwner = false
183  await publish($, null)
184}
185
186/** Starts `next` on the shared player; resolves to an error line, or undefined. */
187async function play($: EngineInterface, next: number): Promise<string | undefined> {
188  index = (next + STATIONS.length) % STATIONS.length
189  await $.store.set('index', index)
190  const result = await ctl($, ['play', String(index), stationAt(index).url, String(volume), stopWithLastSession ? '1' : '0'])
191  if (result.exitCode !== 0) {
192    await publish($, null)
193    return result.exitCode === 127 ? 'No player found — brew install mpv (or ffmpeg)' : result.stderr.trim() || `exit ${result.exitCode}`
194  }
195  isOwner = true
196  await sync($)
197  return undefined
198}
199
200async function playReply($: EngineInterface, next: number) {
201  const error = await play($, next)
202  return { text: error ?? `♪ ${stationAt(index).name}` }
203}
204
205/** Applies `volume` to the running player: live on mpv, a restart on ffplay. */
206async function applyVolume($: EngineInterface) {
207  await $.store.set('volume', volume)
208  if ((await read($, nowPlaying)) === null) return undefined
209  const live = await ctl($, ['vol', String(volume)])
210  return live.exitCode === 0 ? undefined : play($, index)
211}
212
213export const register: Register = (on, options) => {
214  stopWithLastSession = options.stopWithLastSession === true
215
216  on('session.start', async ($, e, next) => {
217    const started = await next(e)
218    index = Number((await $.store.get('index')) ?? 0) % STATIONS.length
219    volume = clamp(Number((await $.store.get('volume')) ?? 50))
220    sessionId = await $.session.id()
221    // Sync and the timer first: they drive the band and the status line, and
222    // must run even when registering the command below is refused.
223    await sync($)
224    $.clock.every(10_000, () => sync($))
225    // A reload can find /tune still held from the last load (or by a second
226    // copy of this mod), and the engine refuses the name; the command.run
227    // hook still answers it, so carry on.
228    await $.command
229      .register({
230        name: 'tune',
231        description: 'Stream internet radio (SomaFM, lo-fi, classical…), shared across sessions',
232        argumentHint: '[n|name|genre|next|prev|stop|list|vol N|band|listen]',
233        immediate: true,
234      })
235      .catch(() => undefined)
236    return started
237  })
238
239  on('session.end', async ($, e, next) => {
240    await ctl($, ['leave', sessionId, stopWithLastSession ? '1' : '0'])
241    return next(e)
242  })
243
244  on('command.run', { command: 'tune' }, async ($, e) => {
245    await sync($)
246    const playing = (await read($, nowPlaying)) !== null
247    const action = parse(e.args)
248    switch (action.kind) {
249      case 'toggle':
250        if (playing) {
251          await stop($)
252          return { text: 'Radio off.' }
253        }
254        return playReply($, index)
255      case 'resume':
256        return playing ? { text: `♪ ${stationAt(index).name} (already playing)` } : playReply($, index)
257      case 'stop':
258        await stop($)
259        return { text: 'Radio off.' }
260      case 'next':
261      case 'prev':
262      case 'play':
263        return playReply($, action.kind === 'play' ? action.index : index + (action.kind === 'next' ? 1 : -1))
264      case 'list':
265        return { text: listing(index, playing, volume) }
266      case 'band': {
267        const hidden = await update($, isBandHidden, was => !was)
268        return { text: hidden ? 'Radio band hidden.' : 'Radio band shown while playing.' }
269      }
270      case 'volume': {
271        volume = clamp(action.isRelative ? volume + action.value : action.value)
272        const error = await applyVolume($)
273        await redraw($)
274        return { text: error ?? `Volume ${volume}` }
275      }
276      case 'unknown':
277        return { text: `No station matches "${action.query}".\n\n${listing(index, playing, volume)}` }
278    }
279  })
280
281  // A one-line player above the prompt while something plays.
282  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
283    const now = await read($, nowPlaying)
284    const last = await read($, lastStation)
285    // Stopped, the band stays with a play button while the session has heard something.
286    const station = now ?? last
287    if (e.props.hasSurvey || station === null || (await read($, isBandHidden))) return next(e)
288    const { Box, Button, Text } = $.ui.resolve(e)
289    const isPlaying = now !== null
290    return (
291      <Box>
292        <Button key="prev" label="◀" onPress={() => play($, station.index - 1)} />
293        <Text> </Text>
294        {isPlaying ? (
295          <Button key="stop" label="■" onPress={() => stop($)} />
296        ) : (
297          <Button key="play" label="play" variant="primary" onPress={() => play($, station.index)} />
298        )}
299        <Text> </Text>
300        <Button key="next" label="▶" onPress={() => play($, station.index + 1)} />
301        <Text> </Text>
302        <Button key="down" label="−" onPress={() => adjust($, -10)} />
303        <Button key="up" label="+" onPress={() => adjust($, 10)} />
304        <Text dimColor wrap="truncate-end">
305          {isPlaying ? '  ♪ ' : '  paused · '}
306          {station.name} · vol {volume}
307        </Text>
308      </Box>
309    )
310  })
311}
312
313async function adjust($: EngineInterface, step: number) {
314  volume = clamp(volume + step)
315  await applyVolume($)
316  await redraw($)
317}
318
319/** The band shows the volume; nudge a redraw even when the station is unchanged. */
320async function redraw($: EngineInterface) {
321  await update($, nowPlaying, now => (now === null ? null : { ...now }))
322}
323
types/index.d.ts 17 lines
1export type NowPlaying = {
2  /** Index into STATIONS. */
3  index: number
4  name: string
5  /** mpv changes volume live; ffplay restarts the stream. */
6  backend: 'mpv' | 'ffplay'
7}
8
9/** The station last heard this session: what a stopped band offers to resume. */
10export type LastStation = { index: number; name: string }
11
12declare module 'claude-code' {
13  interface PluginState {
14    radio: { now: NowPlaying | null; isBandHidden: boolean; lastStation: LastStation | null }
15  }
16}
17