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

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.
cc-radio needs the following:
claude --version to check, and update if yours is older.xcode-select --install) or Homebrew; most Linux distributions include it.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.
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
There's nothing to turn on. Music starts the next time Claude works for more than six seconds.
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.
Music plays while Claude Code is working anywhere, and only then. A tab counts as working or not depending on its state:
| Tab state | Music |
|---|---|
| A turn is in flight | plays |
| A subagent is still running, even after the turn that started it ended | plays |
| Claude is waiting on you: a permission prompt or a question | silence |
| Another app is using your mic: a call, a huddle, dictation (macOS) | silence |
| You pressed Escape, or the turn ended on an API error | silence |
| The tab is idle | silence |
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.
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.
| Detail | Behavior |
|---|---|
| Granola and other all-day recorders | Ignored. 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 off | Set CCRADIO_MIC=off. |
| Music you started by hand | Left alone. Only the automatic music steps aside. |
| See who holds the mic | Run ccradio mic. |
| macOS before 14 | macOS can't say which app holds the mic, so the ignore list can't apply. |
The plugin has two parts: a sensor and a player.
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 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.
One machine, one pair of speakers, one radio.
| When | What happens |
|---|---|
| The first tab starts working | Music starts |
| A second tab joins | Music continues on the same station |
| The first tab finishes, and the second is still working | Music continues |
| The last tab finishes | Silence |
| Any tab asks you something | Silence, until you answer |
| The last tab closes | The player process exits |
To see what each tab reports and what the radio decided, run ccradio working.
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.
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.
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.
To see every decision cc-radio makes, turn on tracing:
~/.claude/settings.json: "env": { "CCRADIO_DEBUG": "1" }
$ 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.
cc-radio ships 17 stations, all commercial-free and listener-supported:
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.
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.
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.
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.
| To change | Where |
|---|---|
| When the music starts | In Claude Code, run /plugin configure ccradio@cc-radio and set Start delay. The default is 6 seconds. |
| The built-in stations | stations/curated.json |
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.
MIT. Support the stations you listen to. SomaFM and Radio Paradise both run on listener donations.
hooks/register.js 321 lines1// 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}
321hooks/radio-tab.mjs 101 lines1// 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