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

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.
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>.
/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.
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.
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).
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
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.
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)
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:
AIC-User-Agent header, as the Art Institute asks of API clients.seed-*.jpg paintings to fall back on when the museum is unreachable.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>
MIT
hooks/register.tsx 323 lines1import { 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}
323types/index.d.ts 17 lines1export 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