SLOPSHOPPER

cc-radio

Music that plays while Claude Code is working, so you know by ear when it's done.

newbandguardcommandprocesstimer
v2.2.0MITupdated 2026-10-05surendranb/cc-radio
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ccradio
› fix the failing auth test and add an audit log call ● ccradio: begin: session preview-, surfaces=["terminal"], headless=false, auto=false ● ccradio: ccradio report preview-session turn=0 waiting=0 agents=0 -> exit 0 ⏺ 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 › /ccradio ⎿ ccradio: (no output) ● ccradio: ccradio report preview-session turn=1 waiting=0 agents=0 -> exit 0 ● ccradio: ccradio report preview-session turn=0 waiting=0 agents=0 -> exit 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

cc-radio

Music that plays while Claude Code is working, so you know by ear when it's done.

The music isn't background ambiance you switch on. It's the progress indicator.

you send a deep problem      ──►  ...6 seconds of real work...  ──►  ♪ music starts
Claude asks you something    ──►  silence
you answer                   ──►  ♪ music is back at once
Claude finishes              ──►  silence
Claude starts again          ──►  ♪ a different station

A quick two-second answer stays silent. Only real work gets a soundtrack.

Before you begin

cc-radio needs the following:

  • Claude Code 2.1.287 or later. cc-radio is a mod, and mods need this version. Run claude --version to check, and update if yours is older.
  • mpv, which plays the audio.
  • Python 3. On macOS it comes with the Xcode Command Line Tools (xcode-select --install) or Homebrew; most Linux distributions include it.
  • macOS or Linux. cc-radio doesn't run on Windows, and mods don't load in a WSL session of the Desktop app.

To install mpv:

brew install mpv          # macOS
sudo apt install mpv      # Debian, Ubuntu

cc-radio was last tested on Claude Code 2.1.288. If mpv isn't on the PATH that Claude Code sees, which can happen when you start it from a GUI, set CCRADIO_MPV to mpv's full path in the env block of ~/.claude/settings.json.

Install cc-radio

  1. Add the marketplace and install the plugin:
   claude plugin marketplace add surendranb/cc-radio
   claude plugin install ccradio@cc-radio

Or, inside a Claude Code session, run both in one command:

   /plugin install ccradio --marketplace surendranb/cc-radio
  1. Restart Claude Code.

There's nothing to turn on. Music starts the next time Claude works for more than six seconds.

What the mod can do

A mod runs inside Claude Code with your own permissions. This one hooks nine events and runs one process, scripts/ccradio in this repository, which drives mpv. mpv fetches the streams. The script calls one web service, Radio Browser, and only when you run search, genre, or language.

To check this yourself before you install, clone the repository and run the following command:

claude plugin validate --strict .

The hooks: and calls: lines in the output list every event the mod handles and every call it makes.

The one rule

Music plays while Claude Code is working anywhere, and only then. A tab counts as working or not depending on its state:

Tab stateMusic
A turn is in flightplays
A subagent is still running, even after the turn that started it endedplays
Claude is waiting on you: a permission prompt or a questionsilence
Another app is using your mic: a call, a huddle, dictation (macOS)silence
You pressed Escape, or the turn ended on an API errorsilence
The tab is idlesilence

You take priority over the machine. If any tab is waiting on you, the music pauses, even while another tab is working. You're about to think and type, and that's when you want quiet. When you answer, the music comes back at once, on the same station.

Calls and dictation

When another app starts using your mic (a Zoom call, a Slack huddle, dictation), the music pauses. It comes back on the same station once the mic has been free for 6 seconds. A muted second in a call doesn't bring it back.

cc-radio reads CoreAudio and never opens the mic itself. It needs no permission and no extra software. It works on macOS only.

DetailBehavior
Granola and other all-day recordersIgnored. Granola is ignored by default. Set CCRADIO_MIC_IGNORE=granola,otherapp to change the list. Each entry matches part of the process path.
Turn it offSet CCRADIO_MIC=off.
Music you started by handLeft alone. Only the automatic music steps aside.
See who holds the micRun ccradio mic.
macOS before 14macOS can't say which app holds the mic, so the ignore list can't apply.

How it works

The plugin has two parts: a sensor and a player.

The sensor

The sensor is a mod, hooks/register.js. It runs inside each Claude Code tab and watches three things: whether a turn is in flight, whether Claude is waiting on you, and how many subagents are running. Every time one of them changes, and every five seconds as a heartbeat, it reports the tab's record to the player.

The sensor listens to the engine's own events only: turn.start and turn.complete for the turn, agent.spawn and a subagent's turn.complete for the agents, and tool.check and tool.call for anything that waits on you. On a machine with Claude Code's built-in guard, the guard bypasses the classic.* mirrors of the settings hooks for a user-installed mod, so nothing here depends on them. This leaves one gap: an MCP elicitation dialog doesn't pause the music.

A permission prompt gets two seconds of grace before it counts as waiting, so a prompt that you answer at once doesn't interrupt the music. In auto mode, the classifier settles most prompts without you, and the engine gives a mod no event for the rare one it hands back to you. So in auto mode a permission prompt doesn't pause the music. A question that Claude asks you with AskUserQuestion pauses the music in every mode.

The player

The player is scripts/ccradio. It keeps every tab's record, decides from all of them whether music should play, and makes mpv match. The decision is one pure function of the records, and every player command is safe to repeat, so it doesn't matter which tab reports first, twice, or late. They all reach the same answer from the same facts.

The heartbeat and a watchdog keep the signal honest. A busy tab refreshes its record every five seconds. A tab that crashes, or closes without reporting, stops refreshing it, and 20 seconds later the record no longer counts. The player also runs a small watchdog process for as long as mpv is up. Every ten seconds it reconciles the player with the records, so even when the only tab dies without a word, the music stops within half a minute.

The six-second delay handles short turns. Without it, every "yes" and "thanks" triggers a burst of noise. With it, silence means done and music means working. A pause for a question keeps its place in the delay, so the music comes straight back when you answer.

Pause is a real silence, not mpv's pause. A live stream can't pause: the player would keep buffering and then play stale audio on resume. cc-radio unloads the stream and reloads it, which is always live and takes about a second.

Multiple tabs

One machine, one pair of speakers, one radio.

WhenWhat happens
The first tab starts workingMusic starts
A second tab joinsMusic continues on the same station
The first tab finishes, and the second is still workingMusic continues
The last tab finishesSilence
Any tab asks you somethingSilence, until you answer
The last tab closesThe player process exits

To see what each tab reports and what the radio decided, run ccradio working.

Subagents

Subagents run inside the turn that spawned them, so they don't change the answer while that turn runs. A background subagent that outlives its turn keeps the tab counted as working until it stops. A question or permission prompt from a subagent counts the same as one from the main thread, because you're the one who has to answer it.

Control the radio

You never need these commands. The mod does everything. They're here for when you want them. /ccradio <verb> runs at once, even while Claude is working, and costs no tokens.

/ccradio                    what's playing
/ccradio play [station]     play now, or switch station
/ccradio next / prev        move one station along
/ccradio shuffle            reshuffle the station order
/ccradio pause              silence, and the tabs stay quiet
/ccradio auto               hand the radio back to the tabs
/ccradio stop               silence, and the player process down
/ccradio vol up|down|<n>    volume 0-130, mpv only, never your system volume
/ccradio now                current track
/ccradio stations           list the stations in play
/ccradio genre lofi         pick a genre, and keep the automatic music in it
/ccradio language tamil     the same, by language
/ccradio genre off          go back to the built-ins
/ccradio search <term>      find more through Radio Browser
/ccradio working            which tabs are reporting, and what the radio decided
/ccradio debug [n|clear]    trace log: why the radio did or didn't act

The same verbs work from any shell as ccradio <verb> after you add the plugin's scripts directory to your PATH.

Who owns the music

The radio is in one of three modes, and /ccradio tells you which.

  • auto is the default. The tabs drive: music while they work, silence otherwise. Changing the station with next, prev, shuffle, or genre keeps the radio in auto. Changing the station never takes the radio away from the tabs.
  • manual means the music is yours. Running /ccradio play while nothing is working marks the music as yours, and the tabs leave it alone until you run /ccradio auto. Running play while a tab is already working only switches the station.
  • off is silence. /ccradio pause or /ccradio stop keeps the tabs from starting anything until you run /ccradio auto. Use this for a call or a meeting.

To use plain words instead of a verb, run /ccradio:radio put on something ambient. Claude maps the words to the right call. This costs a turn.

Diagnose a silent radio

To see every decision cc-radio makes, turn on tracing:

  1. Add the following to ~/.claude/settings.json:
   "env": { "CCRADIO_DEBUG": "1" }
  1. Restart Claude Code.
  1. Read the trace:
   $ ccradio debug
   11:55:18  report aaaaaaaa {"turn": true}
   11:55:18  reconcile: wanted (1 tab(s) working), 6.0s of delay left
   11:55:24  daemon up, pid 42425, volume 70
   11:55:24  reconcile: play soma-thistle (1 tab(s) working)
   11:55:40  report aaaaaaaa {"waiting": true}
   11:55:40  reconcile: pause (a tab is waiting on you)
   11:55:52  report aaaaaaaa {"waiting": false}
   11:55:52  reconcile: play soma-thistle (1 tab(s) working)
   11:57:03  report aaaaaaaa {"turn": false}
   11:57:03  reconcile: pause (every tab is idle)

When CCRADIO_DEBUG is unset, tracing writes nothing and costs nothing. The log caps at 512 KB and rotates once.

To see every event the sensor saw and what the player answered, start a session with claude --debug-file ./radio.log and run grep ccradio ./radio.log. The mod writes one line per report to that log.

A claude -p run gets no soundtrack: the mod sees that nothing is drawn and stays quiet. A session that you drive from another device through Remote Control also draws nothing on this machine, but it does get music, because that's you at work. To give a scripted run music too, set CCRADIO_HEADLESS=1.

Stations

cc-radio ships 17 stations, all commercial-free and listener-supported:

  • SomaFM (12): Groove Salad, Drone Zone, DEF CON Radio, Lush, Space Station, Beat Blender, Fluid, Deep Space One, Secret Agent, Boot Liquor, Sonic Universe, and ThistleRadio
  • Radio Paradise (4): Main, Mellow, Rock, and Global
  • Nightwave Plaza (1): vaporwave

Pick a genre or a language

The built-in stations are all ambient and electronic. That suits focus, but it's one mood, and not everyone writes code to Drone Zone.

ccradio genre lofi          # or jazz, classical, metal, carnatic, ghazal
ccradio language tamil      # or hindi, malayalam, japanese, french
ccradio genre               # what's in play, and tags worth trying
ccradio genre off           # back to the 17 built-ins

A pick is a preference, not a play button. It becomes the pool that the automatic music draws from, so every station the tabs put on stays inside it until you clear it. If music is already playing, it switches over. Each pick fetches about 30 stations, ordered by how often the directory's listeners play them.

Any tag that Radio Browser knows works, not only the suggested ones. There's no API key. cc-radio hands only plain http and https streams to mpv, starts mpv with --ytdl=no so a directory entry can never run yt-dlp, and strips terminal control characters from every station name and track title before it shows them.

A note on "free and open source"

These stations are free to listen to and ad-free, but the music on them is commercially licensed. It isn't open source or Creative Commons. For strictly Creative Commons or public-domain audio, see Free Music Archive, ccMixter, and Musopen.

Why cc-radio runs a daemon

A mod can't stream audio itself, and Claude Code keeps no process alive between turns, so the player has to live outside it. mpv runs detached with a JSON IPC socket. It's the only common player that supports live load, unload, volume, and now-playing metadata without dying.

Volume is mpv's own software volume. It never touches your system volume, so turning the radio down doesn't quiet your calls or notifications.

Where the station shows

The mod draws the station and track in the band above the prompt while music plays, and nothing when it's silent.

If you'd rather have it in the status line, point statusLine.command in ~/.claude/settings.json at scripts/ccradio-statusline. To keep a status line you already have, put its command in ~/.config/ccradio/base-statusline.

Tune cc-radio

To changeWhere
When the music startsIn Claude Code, run /plugin configure ccradio@cc-radio and set Start delay. The default is 6 seconds.
The built-in stationsstations/curated.json

Develop

Run the checks from the repository root:

python3 tests/test_ccradio.py    # the player: 90 tests, no mpv, network, or speakers
node --test tests/               # the mod's pure part
claude plugin validate .         # the manifest, and what the mod hooks and calls
claude plugin test               # the mod against a fake host, 2.1.287 or later

To try a change without installing it, start a session with claude --plugin-dir /path/to/cc-radio. Claude Code reloads the mod when a file in it changes.

Contributors

License

MIT. Support the stations you listen to. SomaFM and Radio Paradise both run on listener donations.

Source 2 files
hooks/register.js 321 lines
1// cc-radio: music that plays while Claude Code works, so you know by ear
2// when it's done.
3//
4// This mod is the sensor. It watches one tab: is a turn in flight, is Claude
5// waiting on you, how many subagents are running. Every change, and every
6// few seconds as a heartbeat while the tab is busy, it reports that record to
7// scripts/ccradio, which keeps every tab's record, decides from all of them
8// whether music should play, and drives mpv. The decision lives there, in one
9// place, as a pure function of the records; this file only has to be an
10// honest witness.
11//
12// Only the engine's own events are used. The `classic.*` mirrors of the
13// settings hooks are bypassed for a user-installed mod on a machine with the
14// built-in guard, so a sensor built on them would see nothing there.
15//
16// The host reads on(...) and $.noun.method(...) from source, so they are
17// spelled literally, and helpers that take $ are top-level functions.
18
19import { TabTracker, reportArgs } from "./radio-tab.mjs";
20
21const HEARTBEAT_MS = 5000;
22// A permission "ask" is often settled within a second, without you. Only an
23// ask that outlives this grace is a real wait.
24const ASK_GRACE_MS = 2000;
25// Permission modes whose own decider settles an "ask" without you. There a
26// tool.check "ask" says nothing about you being needed, so it is not a wait.
27const AUTO_MODES = ["auto", "bypassPermissions", "dontAsk"];
28
29let tab = new TabTracker();
30let sessionId = null;
31let headless = false;
32let startDelay = 6;
33let heartbeat = null;
34let askTimer = null;
35let starting = false;
36let inFlight = false;
37let asksLogged = 0;
38let line = ""; // the band line, as the last report printed it
39
40export function register(on, options) {
41  if (options && typeof options.start_delay === "number") {
42    startDelay = options.start_delay;
43  }
44
45  on("session.start", async ($, e, next) => {
46    await ready($);
47    return next(e);
48  });
49
50  // The session ends, or /clear, /resume, or /branch moves this process to a
51  // new session id. Report the old tab gone and start over: the next event
52  // of any kind begins again under the new id.
53  on("session.end", async ($, e, next) => {
54    if (heartbeat && typeof heartbeat.cancel === "function") heartbeat.cancel();
55    heartbeat = null;
56    if (askTimer && typeof askTimer.cancel === "function") askTimer.cancel();
57    askTimer = null;
58    await gone($);
59    sessionId = null;
60    tab = new TabTracker();
61    return next(e);
62  });
63
64  // --- a turn in flight -------------------------------------------------
65
66  // Fires for the main loop only; a subagent's run raises no turn.start.
67  on("turn.start", async ($, e, next) => {
68    await report($, tab.turnStart());
69    return next(e);
70  });
71
72  on("turn.complete", async ($, e, next) => {
73    // A main turn ending for any reason: answered, interrupted with Escape,
74    // refused, or an API error. A subagent's turn ending is that agent done.
75    if (e.agentId) await report($, tab.agentEnd(e.agentId));
76    else await report($, tab.turnEnd());
77    return next(e);
78  });
79
80  // --- subagents ----------------------------------------------------------
81
82  on("agent.spawn", async ($, e, next) => {
83    const started = await next(e);
84    if (started && started.agentId) await report($, tab.agentStart(started.agentId));
85    return started;
86  });
87
88  // --- waiting on you -----------------------------------------------------
89
90  // The engine's verdict on a tool call. `ask` means a decider has to settle
91  // it: you, or in auto mode a classifier. Start the clock; the tool call
92  // resolving stops it.
93  on("tool.check", async ($, e, next) => {
94    const verdict = await next(e);
95    if (verdict && verdict.decision === "ask") {
96      const auto = await autoDecider($);
97      if (asksLogged < 5) {
98        asksLogged += 1;
99        $.ui.log(`tool.check ask for ${e.tool} (auto=${auto}): ${verdict.reason || ""}`, { to: "debug" });
100      }
101      if (!auto) await askStarted($, e.tool_use_id, ASK_GRACE_MS);
102    }
103    return verdict;
104  });
105
106  // Every tool call passes through here. A question to you is waiting from
107  // before the dialog opens until it closes, no grace needed; any call
108  // resolving, allowed or refused, means whatever it waited on was answered.
109  on("tool.call", async ($, e, next) => {
110    if (e.tool === "AskUserQuestion") await askStarted($, e.tool_use_id, 0);
111    try {
112      return await next(e);
113    } finally {
114      await askEnded($, e.tool_use_id);
115    }
116  });
117
118  // --- the user ------------------------------------------------------------
119
120  on("command.run", { command: "ccradio" }, async ($, e) => {
121    await ready($);
122    const r = await run($, String(e.args || "status").trim().split(/\s+/));
123    await refresh($);
124    return { text: r.out || r.err || "(no output)" };
125  });
126
127  on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
128    if (e.hasSurvey || !line) return next(e);
129    const { Box, Text } = $.ui.resolve(e);
130    // Keep what the mods after this one draw in the band, and add our line.
131    const theirs = await next(e);
132    const children = [Text({ dimColor: true, children: line })];
133    if (theirs) children.unshift(theirs);
134    return Box({ flexDirection: "column", paddingX: 1, children });
135  });
136}
137
138// --- helpers that take $ (top level, so the host can read what they call) ---
139
140// A resumed session may fire no session.start, so the first event of any
141// kind has to be able to start us up.
142async function ready($) {
143  if (sessionId || starting) return;
144  starting = true;
145  try {
146    await begin($);
147  } finally {
148    starting = false;
149  }
150}
151
152async function begin($) {
153  try {
154    sessionId = String(await $.session.id());
155  } catch {
156    // A host with no session id still gets a key of its own to report under.
157    sessionId = "tab-" + Math.random().toString(36).slice(2, 10);
158  }
159  let surfaces = null;
160  try {
161    surfaces = await $.session.surfaces();
162  } catch {
163    surfaces = null; // a host that cannot say is not a scripted run
164  }
165  // An empty list means a scripted `claude -p` run, which earns no soundtrack.
166  // A session driven from another device draws nothing here either, and that
167  // one is you at work, so a bridge counts as a surface.
168  headless = Array.isArray(surfaces) && surfaces.length === 0;
169  try {
170    if (headless && (await $.env.get("CLAUDE_CODE_BRIDGE_SESSION_ID"))) headless = false;
171    if (headless && (await $.env.get("CCRADIO_HEADLESS")) === "1") headless = false;
172  } catch {
173    // leave it
174  }
175  $.ui.log(
176    `begin: session ${sessionId.slice(0, 8)}, surfaces=${JSON.stringify(surfaces)}, headless=${headless}, auto=${await autoDecider($)}`,
177    { to: "debug" },
178  );
179  try {
180    await $.command.register({
181      name: "ccradio",
182      description: "Radio: status, play, next, pause, auto, vol up, genre <tag>",
183      argumentHint: "[status|play|next|pause|auto|vol up|down|genre <tag>]",
184      immediate: true,
185    });
186  } catch {
187    // Already registered, or the name is taken: the shell command still works.
188  }
189  if (headless) return;
190  await report($, true);
191  if (!heartbeat) {
192    try {
193      heartbeat = $.clock.every(HEARTBEAT_MS, () => tick($));
194    } catch {
195      heartbeat = null; // no timer on this host: events alone keep the record fresh
196    }
197  }
198}
199
200// The heartbeat. An idle tab has nothing to keep fresh: the player reads a
201// missing record as idle, and its watchdog retires the music on its own.
202async function tick($) {
203  if (inFlight) return; // a slow report is still out; do not pile up
204  await pruneAgents($);
205  const r = tab.record();
206  if (!r.turn && !r.waiting && r.agents === 0) return;
207  await report($, true);
208}
209
210// Drop any subagent the engine no longer lists as running, so one whose end
211// we never saw cannot keep this tab working forever.
212async function pruneAgents($) {
213  if (tab.agents.size === 0) return;
214  let listed;
215  try {
216    listed = await $.agent.list();
217  } catch {
218    return;
219  }
220  if (!Array.isArray(listed)) return;
221  const running = new Set(listed.filter((a) => a && a.status === "running").map((a) => a.id));
222  for (const id of [...tab.agents]) {
223    if (!running.has(id)) tab.agentEnd(id);
224  }
225}
226
227// The permission mode that settles an "ask", read each time it matters, so a
228// change in settings is seen. A mode switched inside the session is not in
229// the settings, and the engine gives a mod no other way to read it.
230async function autoDecider($) {
231  try {
232    const settings = await $.settings.read({});
233    const mode = settings && settings.permissions && settings.permissions.defaultMode;
234    return AUTO_MODES.includes(String(mode || ""));
235  } catch {
236    return false;
237  }
238}
239
240async function askStarted($, id, graceMs) {
241  const changed = tab.askStart(id);
242  if (!changed) return;
243  if (askTimer && typeof askTimer.cancel === "function") askTimer.cancel();
244  askTimer = null;
245  if (graceMs <= 0) {
246    await report($, true);
247    return;
248  }
249  try {
250    askTimer = $.clock.after(graceMs, () => {
251      askTimer = null;
252      if (tab.waiting) report($, true);
253    });
254  } catch {
255    askTimer = null;
256    await report($, true); // no timer on this host: report at once
257  }
258}
259
260async function askEnded($, id) {
261  const changed = tab.askEnd(id);
262  if (!changed) return;
263  if (askTimer && !tab.waiting) {
264    // The ask settled inside the grace: nothing was ever reported, nothing to undo.
265    if (typeof askTimer.cancel === "function") askTimer.cancel();
266    askTimer = null;
267    return;
268  }
269  await report($, true);
270}
271
272async function report($, changed) {
273  await ready($);
274  if (!changed || headless || !sessionId) return;
275  inFlight = true;
276  try {
277    const r = await run($, reportArgs(sessionId, tab.record()));
278    setLine($, r.exitCode === 0 ? r.out : `cc-radio: ${r.err || "report failed"}`);
279  } finally {
280    inFlight = false;
281  }
282}
283
284async function gone($) {
285  if (headless || !sessionId) return;
286  await run($, ["report", sessionId, "gone"]);
287  setLine($, "");
288}
289
290async function refresh($) {
291  if (headless || !sessionId) return;
292  const r = await run($, ["statusline"]);
293  setLine($, r.out);
294}
295
296function setLine($, text) {
297  const next = (text || "").trim().split("\n")[0].slice(0, 120);
298  if (next === line) return;
299  line = next;
300  $.ui.invalidate("ui.render");
301}
302
303// Run the player's command line. `out` is its stdout, `err` the first line of
304// its stderr; the whole stderr goes to the debug log, never to the band.
305async function run($, args) {
306  try {
307    const r = await $.process.run([`${$.plugin.root}/scripts/ccradio`, ...args], {
308      env: { CCRADIO_START_DELAY: String(startDelay) },
309      timeoutMs: 20000,
310    });
311    const out = (r.stdout || "").trim();
312    const errAll = (r.stderr || "").trim();
313    $.ui.log(`ccradio ${args.join(" ")} -> exit ${r.exitCode}${out ? ": " + out : ""}${errAll ? " | " + errAll : ""}`, { to: "debug" });
314    return { exitCode: r.exitCode, out, err: errAll.split("\n")[0] };
315  } catch (err) {
316    const msg = err && err.message ? err.message : String(err);
317    $.ui.log(`ccradio ${args.join(" ")} failed: ${msg}`, { to: "debug" });
318    return { exitCode: -1, out: "", err: msg };
319  }
320}
321
hooks/radio-tab.mjs 101 lines
1// One tab's view of itself, kept by the mod and reported to scripts/ccradio.
2//
3// Pure: no mods API in here, so it runs under plain node for tests. Every
4// method returns true when the record changed, so the caller reports only
5// on a change and not on every tool call.
6//
7// The record answers three questions about this tab:
8//   turn     is a main-thread turn in flight?
9//   waiting  is Claude waiting on you (permission, question, elicitation)?
10//   agents   how many subagents are running, including background ones that
11//            outlive the turn that started them?
12
13export class TabTracker {
14  constructor() {
15    this.turn = false;
16    this.agents = new Set();
17    // Open asks, by tool_use_id when the event carries one. An ask with no
18    // id is kept under a shared key, so it clears when any tool resolves.
19    this.asks = new Set();
20  }
21
22  get waiting() {
23    return this.asks.size > 0;
24  }
25
26  record() {
27    return { turn: this.turn, waiting: this.waiting, agents: this.agents.size };
28  }
29
30  // A main-thread turn began. Any ask still open belongs to a turn that is gone.
31  turnStart() {
32    const before = this.key();
33    this.turn = true;
34    this.asks.clear();
35    return before !== this.key();
36  }
37
38  // A main-thread turn ended: answered, interrupted, refused, or errored.
39  turnEnd() {
40    const before = this.key();
41    this.turn = false;
42    this.asks.clear();
43    return before !== this.key();
44  }
45
46  agentStart(id) {
47    const before = this.key();
48    this.agents.add(id || "agent");
49    return before !== this.key();
50  }
51
52  agentEnd(id) {
53    const before = this.key();
54    if (id && this.agents.has(id)) {
55      this.agents.delete(id);
56    } else if (!id) {
57      this.agents.clear();
58    }
59    return before !== this.key();
60  }
61
62  // Claude put something in front of you and is waiting for the answer.
63  askStart(id) {
64    const before = this.key();
65    this.asks.add(id || "*");
66    return before !== this.key();
67  }
68
69  // The thing you were asked about resolved: the tool ran, was refused, or the
70  // dialog closed. With no id, every open ask is taken as answered.
71  askEnd(id) {
72    const before = this.key();
73    if (id) {
74      // This call resolved: its own dialog, if any, is closed, and so is any
75      // permission prompt, which arrives with no id. A dialog another call
76      // opened is still up.
77      this.asks.delete(id);
78      this.asks.delete("*");
79    } else {
80      this.asks.clear();
81    }
82    return before !== this.key();
83  }
84
85  key() {
86    const r = this.record();
87    return `${r.turn ? 1 : 0}:${r.waiting ? 1 : 0}:${r.agents}`;
88  }
89}
90
91// What `ccradio report` wants on its command line for a record.
92export function reportArgs(sessionId, rec) {
93  return [
94    "report",
95    sessionId,
96    `turn=${rec.turn ? 1 : 0}`,
97    `waiting=${rec.waiting ? 1 : 0}`,
98    `agents=${rec.agents}`,
99  ];
100}
101