roger-roger 0.1.3 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,6 +57,7 @@ A missing key is not fatal. Speech falls back to the computer's built-in voice (
57
57
  `roger-roger install` asks, in the terminal:
58
58
 
59
59
  - which agents should have the skill (every one it finds is ticked)
60
+ - whether to tell your agents to always use it: a marked block in each agent's global instructions (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`, `~/.gemini/GEMINI.md`), which uninstall takes out again
60
61
  - whether to register with Herdr, if it's installed
61
62
  - how to reach you: Slack, sound, speech, in any combination
62
63
  - Slack: it connects it for you (see above), and where messages go (your DMs, or a channel)
@@ -96,7 +97,7 @@ There is no always-on service. `ask` starts a small watcher process, and one wat
96
97
  - **Talk to an agent**: write "sage, rerun the tests" (or "hey sage rerun the tests", or "sage: ..."). With one agent running, a plain message goes to it. With several, but only one of them listening, it goes to that one and Slack says so, with a button to redirect it. With several, the bot asks which, with a button per agent — and remembers your answer for that thread.
97
98
  - **You can hear who it is**: a spoken line starts with the agent's name — "Amber here.", "This is Rose.", "Jade speaking." — so three agents sharing one pair of speakers stay tellable apart (`setup --say-who off`).
98
99
  - **Reachable while idle**: an agent that has finished can run `listen` in the background instead of going quiet. It costs nothing to wait, and when you write, it wakes up and answers. Slack shows 👀 when an agent is listening and 💤 when your message is saved for one that isn't, so you always know which.
99
- - **In the tray**: `tray install` puts an icon in the system tray, started with the daemon from then on (`tray start` starts it straight away; `setup --tray off` stops it starting by itself) — every agent at a glance, a translucent panel on click, snooze on right-click. It reads the same state the daemon already keeps and runs ordinary commands, so it can be closed, killed or never installed and nothing else changes.
100
+ - **In the tray**: `tray install` puts an icon in the system tray, started with the daemon from then on (`tray start` starts it straight away; `setup --tray off` stops it starting by itself) — every agent at a glance, a translucent panel on click, snooze on right-click, a Stop button while a voice is speaking (`roger-roger hush` from a terminal), and a log of who said what lately: every notification and question from the agents, and every message and answer from you, per agent or all together. It reads the same state the daemon already keeps and runs ordinary commands, so it can be closed, killed or never installed and nothing else changes.
100
101
  - **Questions asked in the terminal**: when the agent asks you something with its own question tool (Claude Code's question dialog, OpenCode's question tool, Codex's `request_user_input`) or wants a plan approved, it's stuck until you answer and can't tell you itself. `hooks install` hooks into each of those agents it finds: once the question has gone unanswered for a little while, it arrives in Slack with its options, and when you answer in the terminal the message turns into your answer. Claude Code gets three hooks in `~/.claude/settings.json`; OpenCode a small plugin in `~/.config/opencode/plugins/`; Codex three hooks in `~/.codex/hooks.json`, which you trust once in Codex's `/hooks`. Claude Code knows when you've stopped typing; OpenCode and Codex don't, so they wait 20 seconds first (`setup --terminal-wait`). `setup --permission-pings on` does the same for ordinary permission prompts. `hooks uninstall` takes it all out again.
101
102
  - **Quiet hours**: `setup --quiet-hours 22:00-08:00` mutes sound and speech overnight. It's off by default.
102
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roger-roger",
3
- "version": "0.1.3",
3
+ "version": "0.1.8",
4
4
  "description": "Bring yourself back to the conversation: your coding agents reach you by Slack, a sound or a spoken line when they finish, get stuck, or need a decision, and you answer from your phone.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,7 +25,7 @@ Every command prints a single JSON object.
25
25
 
26
26
  **Your name.** The daemon gives every agent session a short colour nickname — 🌿 sage, 🌹 rose, 🍇 plum — shown on all your Slack messages with a matching colour bar down their edge. That is how the user tells three agents apart and how they address you: "sage, rerun the tests". `sessions` lists who is running; `status` tells you your own name.
27
27
 
28
- **In Herdr, your pane is named after you — by the skill, not by you.** When the session runs inside a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`), the first command labels the pane's tab with your badge (`🍯 amber`) and its agent with your kind and your work (`claude-build-process-improvements`: Herdr allows only lowercase letters, digits, `-` and `_` there, 32 characters at most), so the Herdr sidebar reads `code-red 🍯 amber claude-build-process-improvements` and matches your Slack messages. The work part is your `--session` name, which is one more reason to pass it, and the labels follow a `sessions rename` or a new `--session` name by themselves. Don't run `herdr tab rename` or `herdr agent rename` yourself; `status` shows what was put on under `daemon.me.herdr`, and a label Herdr refused (a name another pane already has) is in `~/.roger-roger/daemon.log`.
28
+ **In Herdr, your pane is named after you — by the skill, not by you.** When the session runs inside a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`), the first command labels the pane's tab with your badge (`🍯 amber`) and its agent with your kind and your work (`claude-build-process-improvements`: Herdr allows only lowercase letters, digits, `-` and `_` there, 32 characters at most), so the Herdr sidebar reads `code-red 🍯 amber claude-build-process-improvements` and matches your Slack messages. The work part is your `--session` name, which is one more reason to pass it (without one, the skill falls back to your terminal title, which may be vague or out of date), and the labels follow a `sessions rename` or a new `--session` name by themselves. Don't run `herdr tab rename` or `herdr agent rename` yourself; `status` shows what was put on under `daemon.me.herdr`, and a label Herdr refused (a name another pane already has) is in `~/.roger-roger/daemon.log`.
29
29
 
30
30
  **Always pass `--session`**, the same 3–6 word name on every call. It is not only a label: for agents that don't publish a session id of their own it is *how you are recognised* between commands, so leaving it off can make one piece of work look like two. Nothing here is specific to any agent — if your environment publishes a session id (anything like `*_SESSION_ID`) it is used automatically; otherwise `--session` plus the working directory identifies you, and the terminal is the last resort. If you know better than all of that, pass `--session-id ID` (and `--agent-pid PID`, so a crash is noticed straight away).
31
31
 
@@ -78,7 +78,7 @@ roger-roger notify --kind done --message "Refactor of the billing module is fini
78
78
  |---|---|
79
79
  | `--kind` | `done`, `blocked`, `input`, `milestone`, `error`, `info`. Sets the Slack emoji. |
80
80
  | `--message` | The written message for Slack. Make it self-contained, because the user may read it on their phone without context. Say what happened and what, if anything, you need from them. Slack mrkdwn works. |
81
- | `--say` | The spoken line (optional). Keep it short, one or two sentences, with no paths, code, or URLs. If you leave it out, the message is spoken. Ignored when the user's speech mode is `same`. With `brief`, always pass a one-sentence `--say`. |
81
+ | `--say` | The spoken line. Keep it short, one or two sentences, and **never put code in it**: no commands, flags, paths, URLs, hostnames or JSON. Say what the thing is and why, the way you would to someone across the room: not "ssh -o BatchMode=yes … lsblk …" but "Can I run lsblk on the server, to see which disks it has?". The command itself goes in `--message` (in a code block), where it can be read. Pass `--say` whenever the message has anything technical in it, because without it the message is what gets spoken. Ignored when the user's speech mode is `same`. With `brief`, always pass a one-sentence `--say`. |
82
82
  | `--only` | Restrict this one call to some methods, e.g. `--only sound` when the user said "just ding me when done". |
83
83
  | `--project` | Label shown in Slack. Defaults to the current directory name. |
84
84
  | `--session` | **Always pass it.** A short name for this session, made up by you from what the session is about: 3–6 plain words, e.g. `"roger-roger skill slack integration"` or `"billing refactor tests"`. It's shown in the small `machine · agent · model · session` line under Slack messages, so the user can tell sessions apart. Decide it on your first call and reuse the exact same name for every later call in this session. Never use IDs or hashes. |
@@ -173,7 +173,9 @@ Examples:
173
173
  - `--say "[positive] Deployment finished. [short pause] Everything is green."`
174
174
  - `--say "[serious] I'm blocked. I need the production database password to continue."`
175
175
 
176
- Tags must be separated by words or punctuation, never placed back to back. The script strips tags from Slack text and from the local-voice fallback, so they are safe to include. Very short or odd lines (a single word) are sometimes refused by Gemini, so write a natural sentence.
176
+ Tags must be separated by words or punctuation, never placed back to back. The script strips tags from Slack text and from the local-voice fallback, so they are safe to include.
177
+
178
+ Code is never read aloud. As a safety net, anything that reaches the speakers still looking like code is replaced with what it is ("an ssh command", "a file", "a link", "the details are in Slack"), but that is a fallback, not a way of writing: a line the safety net had to fix still sounds clumsy. Write the spoken line as plain speech in the first place, and name commands by what they do. Very short or odd lines (a single word) are sometimes refused by Gemini, so write a natural sentence.
177
179
 
178
180
  ## 3. Asking the user to choose
179
181
 
@@ -261,7 +263,7 @@ Judge it by what happens if you are wrong, not by whether it feels routine. A fo
261
263
  ## Other commands
262
264
 
263
265
  - `install`: the interactive setup, for the user to run in a terminal (section 1). `install --yes` asks nothing: it links the skill for every agent on the machine and registers with Herdr, and prints what it did.
264
- - `uninstall`: takes it out again, for the user to run in a terminal (`npx roger-roger@latest uninstall`): hooks, skill links, the Herdr plugin, the daemon and the tray, settings and tokens, the global command. `--yes` removes all of that without asking (`--slack-cli` adds the Slack CLI). Never run it for the user unasked.
266
+ - `uninstall`: takes it out again, for the user to run in a terminal (`npx roger-roger@latest uninstall`): the standing instruction `install` put in the agents' global files (a marked block in `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`), hooks, skill links, the Herdr plugin, the daemon and the tray, settings and tokens, the global command. `--yes` removes all of that without asking (`--slack-cli` adds the Slack CLI). Never run it for the user unasked.
265
267
  - `help [command]`: the commands, and one command's flags.
266
268
  - `status`: current config, whether the tokens are present, the daemon, running sessions, and every valid option value.
267
269
  - `slack-setup [status|install-cli|login|create]`: the Slack steps `install` runs, one at a time, for repairing a connection by hand. `create` again is safe: it updates the same app, which is what to do after the manifest changed or when a scope is missing.
@@ -269,6 +271,7 @@ Judge it by what happens if you are wrong, not by whether it feels routine. A fo
269
271
  - `end [name]`: this session is finished. Do this when your work is done, so messages meant for someone else don't come to you.
270
272
  - `tray status|start|stop|install`: an icon in the system tray showing every agent, with a translucent panel behind it and snooze on the right-click menu. The native part is a separate package installed on request into `~/.roger-roger` (about 4 MB, nothing compiled); everything here works without it. Once installed, the daemon starts it by itself whenever it starts (`setup --tray off` to stop that).
271
273
  - `snooze [1h|off]`: mute sound and speech for a while; Slack keeps working. For "not now" moments that quiet hours don't cover.
274
+ - `hush`: stop the voice or sound playing right now (the user's Stop button in the tray does the same). Only that announcement; the next one plays, and the Slack message stays. If a notification comes back with `results.interrupted`, the user stopped it: don't send it again.
272
275
  - `hooks status|install|uninstall [claude|opencode|codex|all]`: the terminal-question hooks. When you use your own question tool (Claude Code's question dialog, OpenCode's question tool, Codex's `request_user_input`) or present a plan to approve, you are frozen until it's answered and can't send anything yourself; with the hooks, the agent itself runs roger-roger, and once the question has gone unanswered for a little while the user gets it in Slack with its options. `roger-roger install` offers them; without a name, `hooks install` covers the agents on this machine. What each gets: Claude Code, three entries in `~/.claude/settings.json`; OpenCode, a small plugin at `~/.config/opencode/plugins/roger-roger.js` (restart OpenCode to load it); Codex, three entries in `~/.codex/hooks.json`, to trust in `/hooks`. They follow the user's `when`, quiet hours and snooze like any notification, and log each run to `~/.roger-roger/hooks.log`. Claude Code pings once nobody has typed for a few seconds; OpenCode and Codex can't tell, so they wait `setup --terminal-wait` (default 20s) and ping only if it's still unanswered. Ordinary permission prompts (leave to run a command) don't ping unless the user asks: `setup --permission-pings on` (Claude Code and OpenCode; Codex gives no signal when one is answered). Run `hooks install` again after the skill moves, so they point at the new copy; `hooks status` says if they don't.
273
276
  - `daemon status|start|stop`: the always-on process behind everything. It starts on demand, so this is only for checking on it or restarting it after a change to the skill's code.
274
277
  - `play [sound]`: preview a sound (defaults to the configured one).
@@ -6,7 +6,7 @@
6
6
 
7
7
  id = "roger-roger"
8
8
  name = "roger-roger"
9
- version = "0.1.3"
9
+ version = "0.1.8"
10
10
  min_herdr_version = "0.9.0"
11
11
  description = "Slack, a sound or a spoken line from the agents in your panes, and questions you answer from your phone"
12
12
  platforms = ["linux", "macos", "windows"]
@@ -0,0 +1,92 @@
1
+ // Who said what, lately: every notification and question an agent sent, and every message and
2
+ // answer the user sent back, newest last. The tray shows it as a log, so "what did amber say an
3
+ // hour ago, and did I answer?" has an answer without scrolling Slack.
4
+ //
5
+ // Kept in ~/.roger-roger/activity.json, capped, and written through a temp file so the tray never
6
+ // reads half of it. Recording never throws: a log that can't be written is not a reason for a
7
+ // notification to fail.
8
+
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import { rogerRogerHome, stripTags } from "./lib.mjs";
12
+ import { colourOf } from "./names.mjs";
13
+
14
+ export const MAX_ENTRIES = 200;
15
+ const MAX_TEXT = 600;
16
+
17
+ export const activityPath = (env = process.env) => path.join(rogerRogerHome(env), "activity.json");
18
+
19
+ export function read(env = process.env) {
20
+ try {
21
+ const entries = JSON.parse(fs.readFileSync(activityPath(env), "utf8"));
22
+ return Array.isArray(entries) ? entries : [];
23
+ } catch {
24
+ return [];
25
+ }
26
+ }
27
+
28
+ /** Who an entry is about: the agent's name and colour, or nobody yet. */
29
+ export function agentOf(session) {
30
+ if (!session?.nickname) return null;
31
+ const colour = colourOf(session.nickname);
32
+ return { nickname: session.nickname, swatch: session.swatch ?? colour.swatch, colour: session.colour ?? colour.hex, project: session.project ?? "" };
33
+ }
34
+
35
+ const clip = (text) => {
36
+ const s = stripTags(String(text ?? "")).replace(/\s+/g, " ").trim();
37
+ return s.length > MAX_TEXT ? `${s.slice(0, MAX_TEXT - 1)}…` : s;
38
+ };
39
+
40
+ /**
41
+ * Add one entry. `from` is "agent" or "user"; `agent` is who the agent was, or who the user wrote
42
+ * to (null when that isn't known yet); `kind` is what it was: notify's kind, "question", "answer",
43
+ * or "message".
44
+ */
45
+ export function record({ from, agent = null, kind, text, detail = "", at = new Date().toISOString(), ref = "" }, env = process.env) {
46
+ try {
47
+ const entry = { at, from, kind, text: clip(text), ...(detail ? { detail: clip(detail) } : {}), ...(agent ? { agent } : {}), ...(ref ? { ref } : {}) };
48
+ write([...read(env), entry].slice(-MAX_ENTRIES), env);
49
+ return entry;
50
+ } catch {
51
+ return null;
52
+ }
53
+ }
54
+
55
+ /** The user picked who a message was for after it was logged: say so on its entry. */
56
+ export function retarget(ref, agent, detail = "", env = process.env) {
57
+ try {
58
+ const entries = read(env);
59
+ const i = entries.findLastIndex((e) => e.ref === ref);
60
+ if (i < 0) return false;
61
+ const { detail: _old, agent: _was, ...rest } = entries[i];
62
+ entries[i] = { ...rest, ...(agent ? { agent } : {}), ...(detail ? { detail } : {}) };
63
+ write(entries, env);
64
+ return true;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ function write(entries, env) {
71
+ const file = activityPath(env);
72
+ fs.mkdirSync(path.dirname(file), { recursive: true });
73
+ const tmp = `${file}.${process.pid}.tmp`;
74
+ fs.writeFileSync(tmp, JSON.stringify(entries, null, 1) + "\n", "utf8");
75
+ fs.renameSync(tmp, file);
76
+ }
77
+
78
+ /** The newest `n`, newest first, for the tray. */
79
+ export function recent(n = 60, env = process.env) {
80
+ return read(env).slice(-n).reverse();
81
+ }
82
+
83
+ /** How an answer reads in one line: the button, the options ticked, the typed words, or what happened. */
84
+ export function answerText(decision) {
85
+ const a = decision.answer ?? {};
86
+ if (decision.status === "cancelled") return "cancelled";
87
+ if (decision.status === "expired") return "expired, unanswered";
88
+ if (a.edited) return `edited the draft: ${a.text ?? ""}`;
89
+ if (Array.isArray(a.choices)) return a.choices.length ? a.choices.join(", ") : "none of them";
90
+ if (a.choice) return a.auto ? `${a.choice} (no answer, so the recommended one)` : a.choice;
91
+ return a.text ?? "";
92
+ }
@@ -9,6 +9,71 @@ import { SOUNDS_DIR, duckFactor, rogerRogerHome, stripTags } from "./lib.mjs";
9
9
  import { providerOf, synthesize } from "./tts.mjs";
10
10
  import { withSpeaker } from "./speaker.mjs";
11
11
 
12
+ // ---------------------------------------------------------------- what is playing, and stopping it
13
+ //
14
+ // Everything audible goes through onAir(), so the daemon knows who is speaking (the tray shows a
15
+ // Stop button while someone is) and interruptPlayback() can cut it off: the players are killed,
16
+ // the generation moves on, and anything that notices the generation changed under it stops
17
+ // instead of carrying on to the next sound or falling back to the computer's voice.
18
+
19
+ let generation = 0;
20
+ const players = new Set();
21
+ let onAirCount = 0;
22
+ let playing = null;
23
+ const listeners = new Set();
24
+
25
+ /** Called with what is playing (or null) whenever that changes. Returns a function to stop listening. */
26
+ export function onPlaybackChange(fn) {
27
+ listeners.add(fn);
28
+ return () => listeners.delete(fn);
29
+ }
30
+ const emit = () => {
31
+ for (const fn of listeners) {
32
+ try {
33
+ fn(playing);
34
+ } catch {}
35
+ }
36
+ };
37
+
38
+ /** Who is speaking right now, `{ who, since }`, or null. */
39
+ export const nowPlaying = () => playing;
40
+
41
+ /** True once playback started at `gen` has been interrupted. */
42
+ export const stoppedSince = (gen) => generation !== gen;
43
+
44
+ /** Mark `fn` as audible for its duration, on behalf of `who`. `fn` gets the generation to check. */
45
+ export async function onAir(who, fn) {
46
+ const gen = generation;
47
+ onAirCount++;
48
+ if (!playing) {
49
+ playing = { who: who || null, since: new Date().toISOString() };
50
+ emit();
51
+ }
52
+ try {
53
+ return await fn(gen);
54
+ } finally {
55
+ onAirCount--;
56
+ if (!onAirCount) {
57
+ playing = null;
58
+ emit();
59
+ }
60
+ }
61
+ }
62
+
63
+ /** Stop whatever is playing, now. Returns whether anything was. */
64
+ export function interruptPlayback() {
65
+ const was = playing;
66
+ if (!was) return { stopped: false };
67
+ generation++;
68
+ for (const child of players) {
69
+ try {
70
+ child.kill();
71
+ } catch {}
72
+ }
73
+ session?.kill();
74
+ return { stopped: true, who: was.who };
75
+ }
76
+
12
77
  /** Run a command to completion; resolves true only on a clean exit. Never rejects. */
13
78
  function run(cmd, args, opts = {}) {
14
79
  return new Promise((resolve) => {
@@ -18,14 +83,19 @@ function run(cmd, args, opts = {}) {
18
83
  } catch {
19
84
  return resolve(false);
20
85
  }
21
- child.on("error", () => resolve(false));
22
- child.on("exit", (code) => resolve(code === 0));
86
+ players.add(child);
87
+ child.on("error", () => (players.delete(child), resolve(false)));
88
+ child.on("exit", (code) => (players.delete(child), resolve(code === 0)));
23
89
  });
24
90
  }
25
91
 
26
- /** Try each [cmd, args] in order; true on the first that runs cleanly. */
27
- async function firstThatWorks(candidates, opts) {
28
- for (const [cmd, args] of candidates) if (await run(cmd, args, opts)) return true;
92
+ /** Try each in turn, but stop trying once playback was interrupted: that exit was ours, not a failure. */
93
+ async function firstThatWorksUnlessStopped(candidates, opts) {
94
+ const gen = generation;
95
+ for (const [cmd, args] of candidates) {
96
+ if (await run(cmd, args, opts)) return true;
97
+ if (stoppedSince(gen)) return true;
98
+ }
29
99
  return false;
30
100
  }
31
101
 
@@ -195,6 +265,14 @@ function windowsSession() {
195
265
  if (session === self) session = null;
196
266
  child.stdin.end();
197
267
  },
268
+ // PlaySync and Speak block the player, so a stop can't be asked for: the player goes, and the
269
+ // next request starts a new one.
270
+ kill() {
271
+ fail("interrupted");
272
+ try {
273
+ child.kill();
274
+ } catch {}
275
+ },
198
276
  };
199
277
  session = self;
200
278
  return self;
@@ -210,15 +288,26 @@ export function closeAudio() {
210
288
  session?.close();
211
289
  }
212
290
 
291
+ /** A Windows player request that, if the player was killed to stop it, just ends: stopping isn't failing. */
292
+ async function windowsRequest(command, gen) {
293
+ try {
294
+ await windowsSession().request(command);
295
+ } catch (e) {
296
+ if (!stoppedSince(gen)) throw e;
297
+ }
298
+ }
299
+
300
+ // Interrupted playback returns as if it had finished: the caller checks stoppedSince() to know.
213
301
  export async function playWav(file) {
214
302
  if (!fs.existsSync(file)) throw new Error(`no such sound file: ${file}`);
303
+ const gen = generation;
215
304
  let ok;
216
305
  if (process.platform === "win32") {
217
- return windowsSession().request(`PLAY\t${b64(file)}`);
306
+ return windowsRequest(`PLAY\t${b64(file)}`, gen);
218
307
  } else if (process.platform === "darwin") {
219
- ok = await run("afplay", [file]);
308
+ ok = (await run("afplay", [file])) || stoppedSince(gen);
220
309
  } else {
221
- ok = await firstThatWorks([
310
+ ok = await firstThatWorksUnlessStopped([
222
311
  ["paplay", [file]],
223
312
  ["pw-play", [file]],
224
313
  ["aplay", ["-q", file]],
@@ -241,13 +330,14 @@ export async function playWavBuffer(wav) {
241
330
 
242
331
  export async function speakLocally(text) {
243
332
  const plain = stripTags(text);
333
+ const gen = generation;
244
334
  let ok;
245
335
  if (process.platform === "win32") {
246
- return windowsSession().request(`SAY\t${b64(plain)}`);
336
+ return windowsRequest(`SAY\t${b64(plain)}`, gen);
247
337
  } else if (process.platform === "darwin") {
248
- ok = await run("say", [plain]);
338
+ ok = (await run("say", [plain])) || stoppedSince(gen);
249
339
  } else {
250
- ok = await firstThatWorks([
340
+ ok = await firstThatWorksUnlessStopped([
251
341
  ["spd-say", ["-w", plain]],
252
342
  ["espeak-ng", [plain]],
253
343
  ["espeak", [plain]],
@@ -299,7 +389,13 @@ export async function withOthersQuieter(config, fn) {
299
389
  try {
300
390
  return await fn();
301
391
  } finally {
302
- await player.request("UNDUCK").catch(() => {});
392
+ // Stopping the voice kills this player, and with it what it saved. A fresh player reads the
393
+ // saved levels back from the file and puts them back, so the music doesn't stay down.
394
+ try {
395
+ await player.request("UNDUCK");
396
+ } catch {
397
+ await duckPlayer().catch(() => {});
398
+ }
303
399
  fs.rmSync(duckFile(), { force: true });
304
400
  }
305
401
  }
@@ -330,14 +426,18 @@ export function prepareSpeech(text, config, { local = false } = {}) {
330
426
  }
331
427
 
332
428
  /** Play prepared audio; else the local synthesiser; else (if nothing else made a noise) the ding. */
333
- export async function deliverSpeech(prepared, text, config, { dingIfSilent = false } = {}) {
429
+ export async function deliverSpeech(prepared, text, config, { dingIfSilent = false, gen = generation } = {}) {
334
430
  const engine = providerOf(config).id;
335
431
  const errors = prepared.error ? [`${engine}: ${prepared.error}`] : [];
432
+ // Stopped by the user: that is the end of it, not a reason to try the computer's voice instead.
433
+ const interrupted = () => ({ ok: true, engine, interrupted: true });
434
+ if (stoppedSince(gen)) return interrupted();
336
435
  if (prepared.wav) {
337
436
  try {
338
437
  await playWavBuffer(prepared.wav);
339
- return { ok: true, engine, voice: config.voice };
438
+ return stoppedSince(gen) ? interrupted() : { ok: true, engine, voice: config.voice };
340
439
  } catch (e) {
440
+ if (stoppedSince(gen)) return interrupted();
341
441
  errors.push(`playback: ${e.message}`);
342
442
  }
343
443
  }
@@ -366,15 +466,16 @@ export async function deliverSpeech(prepared, text, config, { dingIfSilent = fal
366
466
  * the speaker is claimed, so a queued agent waits for playback, not for the network.
367
467
  * The ding and the voice after it are one turn: another agent cannot cut between them.
368
468
  */
369
- export async function playAlert({ methods, config, text, prepared }) {
469
+ export async function playAlert({ methods, config, text, prepared, who = null }) {
370
470
  const results = {};
371
471
  const wantsSpeech = methods.includes("speech");
372
472
  if (wantsSpeech || methods.includes("sound")) warmUpAudio();
373
473
  const speech = wantsSpeech ? await (prepared ?? prepareSpeech(text, config)) : null;
374
474
  if (!wantsSpeech && !methods.includes("sound")) return results;
375
475
 
376
- // The ding and the voice are one announcement, so everything else goes quiet for both of them.
377
- return withSpeaker((turn) => withOthersQuieter(wantsSpeech ? config : null, async () => {
476
+ // The ding and the voice are one announcement, so everything else goes quiet for both of them,
477
+ // and stopping it stops both: a stop during the ding means no voice after it.
478
+ return withSpeaker((turn) => onAir(who, (gen) => withOthersQuieter(wantsSpeech ? config : null, async () => {
378
479
  if (methods.includes("sound")) {
379
480
  try {
380
481
  await playWav(soundFile(config.sound));
@@ -384,9 +485,12 @@ export async function playAlert({ methods, config, text, prepared }) {
384
485
  }
385
486
  }
386
487
  if (wantsSpeech) {
387
- results.speech = await deliverSpeech(speech, text, config, { dingIfSilent: !methods.includes("sound") });
488
+ results.speech = stoppedSince(gen)
489
+ ? { ok: true, interrupted: true }
490
+ : await deliverSpeech(speech, text, config, { dingIfSilent: !methods.includes("sound"), gen });
388
491
  }
492
+ if (stoppedSince(gen)) results.interrupted = true;
389
493
  if (turn.waitedMs > 0) results.queued = { waitedMs: turn.waitedMs, ...(turn.tookOver ? { tookOver: true } : {}) };
390
494
  return results;
391
- }));
495
+ })));
392
496
  }
@@ -25,7 +25,7 @@ import {
25
25
  } from "./lib.mjs";
26
26
  import { lineReader, send, socketPath, PROTOCOL_VERSION } from "./protocol.mjs";
27
27
  import { connectSocket, openModal, permalink, postToChannel, updateMessage } from "./slack.mjs";
28
- import { closeAudio, playAlert, warmUpAudio } from "./audio.mjs";
28
+ import { closeAudio, nowPlaying, onPlaybackChange, playAlert, warmUpAudio } from "./audio.mjs";
29
29
  import { FROM_HOOKS, HANDLERS, owedTo, startTray } from "./handlers.mjs";
30
30
  import * as progressStore from "./progress.mjs";
31
31
  import * as tray from "./tray.mjs";
@@ -99,7 +99,7 @@ function refreshTray({ ifStaleMs = 0 } = {}) {
99
99
  snapshotTimer = null;
100
100
  snapshotAt = Date.now();
101
101
  try {
102
- tray.write(tray.snapshot({ config: loadConfig(), daemon: { pid: process.pid, slackConnected: state.slackConnected } }));
102
+ tray.write(tray.snapshot({ config: loadConfig(), daemon: { pid: process.pid, slackConnected: state.slackConnected, speaking: nowPlaying() } }));
103
103
  } catch (e) {
104
104
  log(`could not write the tray snapshot: ${e.message}`);
105
105
  }
@@ -408,7 +408,7 @@ async function remindIfDue(decision) {
408
408
  .catch((e) => log(`${decision.id}: reminder post failed: ${e.message}`))
409
409
  : null;
410
410
  try {
411
- await playAlert({ methods, config, text: "[neutral] Quick reminder. I'm still waiting for your answer in Slack." });
411
+ await playAlert({ methods, config, text: "[neutral] Quick reminder. I'm still waiting for your answer in Slack.", who: decision.nickname ?? null });
412
412
  } catch (e) {
413
413
  log(`${decision.id}: reminder audio failed: ${e.message}`);
414
414
  }
@@ -563,6 +563,8 @@ async function main() {
563
563
  const stamp = codeStamp();
564
564
  log(`daemon started on ${socketPath()}`);
565
565
  warmUpAudio(); // so the first announcement of the day doesn't wait for a PowerShell to start
566
+ // The tray shows a Stop button while something plays, so it has to hear when that starts and ends.
567
+ onPlaybackChange(() => changed("speaking"));
566
568
  await stopLegacyDaemon().catch((e) => log(`could not stop the old daemon: ${e.message}`));
567
569
  autostartTray();
568
570
 
@@ -6,6 +6,7 @@ import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { rogerRogerHome, outcomeText, resolvedBlocks, stripTags } from "./lib.mjs";
8
8
  import { updateMessage } from "./slack.mjs";
9
+ import * as activity from "./activity.mjs";
9
10
 
10
11
  const KEEP_RESOLVED_MS = 7 * 24 * 3600 * 1000;
11
12
 
@@ -114,6 +115,14 @@ export async function settle(id, patch, log = () => {}, { waiting = false } = {}
114
115
  const owed = Boolean(current.sessionId) && patch.status === "answered";
115
116
  const settled = { ...current, ...patch, resolvedAt: now, ...(owed ? { deliveredAt: waiting ? now : null } : {}) };
116
117
  write(settled);
118
+ const byUser = settled.status === "answered" && !settled.answer?.auto;
119
+ activity.record({
120
+ from: byUser ? "user" : "agent",
121
+ kind: settled.status === "answered" ? "answer" : settled.status,
122
+ agent: activity.agentOf({ nickname: settled.nickname, swatch: settled.swatch, colour: settled.colour }),
123
+ text: activity.answerText(settled),
124
+ detail: settled.question,
125
+ });
117
126
 
118
127
  try {
119
128
  const outcome = outcomeText(settled);
@@ -20,7 +20,7 @@ import {
20
20
  } from "./lib.mjs";
21
21
  import {
22
22
  deliverSpeech, mapPool, playAlert, playWav, playWavBuffer, prepareSpeech, soundFile,
23
- speakLocally, speechAudio, warmUpAudio, withOthersQuieter,
23
+ interruptPlayback, nowPlaying, onAir, speakLocally, speechAudio, stoppedSince, warmUpAudio, withOthersQuieter,
24
24
  } from "./audio.mjs";
25
25
  import { withSpeaker } from "./speaker.mjs";
26
26
  import { catalog, findVoice, keyFor, missingKey, providerOf, refreshCatalog, testSample } from "./tts.mjs";
@@ -29,6 +29,7 @@ import * as progressStore from "./progress.mjs";
29
29
  import * as sessions from "./sessions.mjs";
30
30
  import * as inbox from "./inbox.mjs";
31
31
  import * as decisions from "./decisions.mjs";
32
+ import * as activity from "./activity.mjs";
32
33
  import { labelPane } from "./herdr.mjs";
33
34
  import { answeredNotice, isQuestion, matchRequest, permissionNotice, rememberRequest } from "./hooks.mjs";
34
35
 
@@ -179,9 +180,10 @@ export async function notify(ctx, args) {
179
180
  })().catch((e) => (results.slack = { ok: Boolean(results.slack?.ok), error: e.message }))
180
181
  : null;
181
182
 
182
- Object.assign(results, await playAlert({ methods, config, text, prepared }));
183
+ Object.assign(results, await playAlert({ methods, config, text, prepared, who: ctx.session?.nickname }));
183
184
  await slackJob;
184
185
  const succeeded = Object.values(results).some((r) => r.ok);
186
+ if (succeeded) activity.record({ from: "agent", kind, agent: activity.agentOf(ctx.session), text: message });
185
187
  const body = {
186
188
  ok: succeeded,
187
189
  kind,
@@ -679,6 +681,19 @@ export async function tray(ctx, args) {
679
681
  * Quiet for a while, on purpose. Quiet hours are a schedule and a meeting is not one, so this is
680
682
  * the ad-hoc version: sound and speech off until it runs out, Slack untouched.
681
683
  */
684
+ /**
685
+ * Stop the voice (or sound) playing right now: the tray's Stop button, or `roger-roger hush`. Only
686
+ * this announcement; the next one plays as usual, and the Slack message was posted either way.
687
+ * It only means anything in the daemon, which is where playback happens.
688
+ */
689
+ export function hush(ctx) {
690
+ if (!ctx.daemon) return ok({ ok: true, stopped: false, note: "no daemon, so nothing of roger-roger's is playing" });
691
+ const playing = nowPlaying();
692
+ const result = interruptPlayback();
693
+ if (result.stopped) ctx.log(`hush: stopped ${playing?.who ?? "playback"}`);
694
+ return ok({ ok: true, stopped: result.stopped, ...(result.stopped && playing?.who ? { who: playing.who } : {}) });
695
+ }
696
+
682
697
  export function snooze(ctx, args) {
683
698
  const value = (args._ ?? [])[0] ?? args.for ?? "1h";
684
699
  // Snoozing is not setting up: saving here would write the defaults and call that the user's choice.
@@ -835,8 +850,9 @@ export async function ask(ctx, args) {
835
850
  });
836
851
  sessions.bindThread(ts, ctx.session?.id);
837
852
  setState(ctx, "waiting");
853
+ activity.record({ from: "agent", kind: "question", agent: activity.agentOf(ctx.session), text: question, detail: choices.join(" / ") });
838
854
 
839
- const audio = await playAlert({ methods: audible, config, text, prepared });
855
+ const audio = await playAlert({ methods: audible, config, text, prepared, who: ctx.session?.nickname });
840
856
  const extra = { audio, ...(attached ? { attached } : {}), ...(muted.length ? { mutedByQuietHours: muted } : {}) };
841
857
  if (args.wait !== undefined) return waitFor(ctx, id, parseDuration(args.wait, Infinity), extra);
842
858
  // Waiting costs the agent nothing: run it in the background and carry on. It always comes back,
@@ -956,7 +972,7 @@ export async function play(ctx, args) {
956
972
  const name = (args._ ?? [])[0] ?? ctx.config?.sound ?? DEFAULTS.sound;
957
973
  const sounds = listSounds();
958
974
  if (!sounds.includes(name)) fail(`unknown sound "${name}"; available: ${sounds.join(", ")}`);
959
- await withSpeaker(() => playWav(soundFile(name)));
975
+ await withSpeaker(() => onAir("preview", () => playWav(soundFile(name))));
960
976
  return ok({ ok: true, played: name });
961
977
  }
962
978
 
@@ -989,11 +1005,11 @@ export async function say(ctx, args) {
989
1005
  } catch (e) {
990
1006
  fail(`${provider.label} failed with the key from ${key.name}: ${e.message}`);
991
1007
  }
992
- await withSpeaker(() => withOthersQuieter(config, () => playWavBuffer(sample.wav)));
1008
+ await withSpeaker(() => onAir("voice test", () => withOthersQuieter(config, () => playWavBuffer(sample.wav))));
993
1009
  return ok({ ok: true, engine: provider.id, voice: config.voice, model: config.model, keyVar: key.name, cached: sample.cached });
994
1010
  }
995
1011
  const prepared = await prepareSpeech(text, config, { local: Boolean(args.local) });
996
- const result = await withSpeaker(() => deliverSpeech(prepared, text, config));
1012
+ const result = await withSpeaker(() => onAir("preview", (gen) => deliverSpeech(prepared, text, config, { gen })));
997
1013
  return ok(result, result.ok ? 0 : 1);
998
1014
  }
999
1015
 
@@ -1059,9 +1075,10 @@ export async function previewSounds(ctx, args) {
1059
1075
  const audio = args.local ? sounds.map(() => ({ error: "--local" })) : await prefetch(sounds.map(soundAnnouncement), config);
1060
1076
 
1061
1077
  // The whole run-through is one turn at the speakers, so another agent can't cut in mid-list.
1062
- const played = await withSpeaker(async () => {
1078
+ const played = await withSpeaker(() => onAir("preview", async (gen) => {
1063
1079
  const done = [];
1064
1080
  for (const [i, name] of sounds.entries()) {
1081
+ if (stoppedSince(gen)) break;
1065
1082
  const entry = { sound: name };
1066
1083
  try {
1067
1084
  if (audio[i].wav) await playWavBuffer(audio[i].wav);
@@ -1078,7 +1095,7 @@ export async function previewSounds(ctx, args) {
1078
1095
  done.push(entry);
1079
1096
  }
1080
1097
  return done;
1081
- });
1098
+ }));
1082
1099
  const succeeded = played.some((p) => p.ok);
1083
1100
  return ok({ ok: succeeded, order: sounds, played }, succeeded ? 0 : 1);
1084
1101
  }
@@ -1097,9 +1114,10 @@ export async function previewVoices(ctx, args) {
1097
1114
  const audio = await mapPool(voices, 4, (voice) =>
1098
1115
  speechAudio(text(voice), { ...config, voice }).then((wav) => ({ wav }), (e) => ({ error: e.message })));
1099
1116
 
1100
- const played = await withSpeaker(async () => {
1117
+ const played = await withSpeaker(() => onAir("preview", async (gen) => {
1101
1118
  const done = [];
1102
1119
  for (const [i, voice] of voices.entries()) {
1120
+ if (stoppedSince(gen)) break;
1103
1121
  const entry = { voice, name: byId[voice]?.name ?? voice, ...(byId[voice]?.description ? { character: byId[voice].description } : {}) };
1104
1122
  if (audio[i].error) {
1105
1123
  Object.assign(entry, { ok: false, error: audio[i].error });
@@ -1114,7 +1132,7 @@ export async function previewVoices(ctx, args) {
1114
1132
  done.push(entry);
1115
1133
  }
1116
1134
  return done;
1117
- });
1135
+ }));
1118
1136
  const succeeded = played.some((p) => p.ok);
1119
1137
  return ok({ ok: succeeded, order: voices, played }, succeeded ? 0 : 1);
1120
1138
  }
@@ -1134,6 +1152,7 @@ export const HANDLERS = {
1134
1152
  messages,
1135
1153
  listen,
1136
1154
  snooze,
1155
+ hush,
1137
1156
  tray,
1138
1157
  ask,
1139
1158
  wait,
@@ -10,8 +10,8 @@ export const TAGLINE = "Bring yourself back to the conversation: your agents rea
10
10
  /** Every command: where it belongs, how it is called, what it does, and (for the busy ones) its flags. */
11
11
  export const COMMANDS = [
12
12
  // ---- setting up
13
- { section: "Setting up", name: "install", usage: "[--yes]", summary: "Set everything up in the terminal: the command on your PATH, the skill for every agent, Herdr, Slack, the sound and the voice, hooks, tray. Run it again to change anything. --yes asks nothing and only links the skill.", flags: [["--yes", "no questions: link the skill for every agent on the machine and register with Herdr, then print what was done"]] },
14
- { section: "Setting up", name: "uninstall", usage: "[--yes]", summary: "Take it out again: hooks, links, Herdr, the daemon and the tray, settings and tokens, the global command.", flags: [["--yes", "no questions: remove everything except the Slack CLI, then print what was done"]] },
13
+ { section: "Setting up", name: "install", usage: "[--yes]", summary: "Set everything up in the terminal: the command on your PATH, the skill for every agent and a standing instruction to use it, Herdr, Slack, the sound and the voice, hooks, tray. Run it again to change anything. --yes asks nothing and only links the skill.", flags: [["--yes", "no questions: link the skill for every agent on the machine and register with Herdr, then print what was done. Nothing is written to CLAUDE.md or AGENTS.md"]] },
14
+ { section: "Setting up", name: "uninstall", usage: "[--yes]", summary: "Take it out again: the standing instruction, hooks, links, Herdr, the daemon and the tray, settings and tokens, the global command.", flags: [["--yes", "no questions: remove everything except the Slack CLI, then print what was done"], ["--slack-cli", "with --yes, remove the Slack CLI and its login as well"]] },
15
15
  { section: "Setting up", name: "status", usage: "", summary: "Settings, whether the tokens are there, the daemon, who is running, and every valid option value." },
16
16
  {
17
17
  section: "Setting up", name: "setup", usage: "[flags]", summary: "Change any setting; the rest are kept.",
@@ -66,6 +66,7 @@ export const COMMANDS = [
66
66
  { section: "Reaching the user", name: "control", usage: "[--key NAME] [--wait DURATION]", summary: "Read Pause, Stop and new messages without posting. --wait blocks while paused." },
67
67
  { section: "Reaching the user", name: "listen", usage: "[--wait 25m]", summary: "Park until the user says something. Run it in the background as the last thing you do." },
68
68
  { section: "Reaching the user", name: "snooze", usage: "[1h|off]", summary: "Sound and speech off for a while; Slack keeps working." },
69
+ { section: "Reaching the user", name: "hush", usage: "", summary: "Stop the voice or sound playing right now. Only this announcement: the next plays as usual, and the Slack message stays. The tray's Stop button does the same." },
69
70
 
70
71
  // ---- asking
71
72
  {