roger-roger 0.1.4 → 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
@@ -97,7 +97,7 @@ There is no always-on service. `ask` starts a small watcher process, and one wat
97
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.
98
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`).
99
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.
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. 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.
101
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.
102
102
  - **Quiet hours**: `setup --quiet-hours 22:00-08:00` mutes sound and speech overnight. It's off by default.
103
103
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "roger-roger",
3
- "version": "0.1.4",
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
 
@@ -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.4"
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,
@@ -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
  {
@@ -110,17 +110,62 @@ function errorText(text) {
110
110
  }
111
111
  }
112
112
 
113
+ /**
114
+ * What the pane's agent is working on, from its terminal title: Claude Code, Codex and others set it
115
+ * to the conversation's topic ("Home Assistant MCP setup"). Empty when the title is only the shell
116
+ * or the program's own name, which says nothing about the work.
117
+ */
118
+ export function workFromTitle(title, agent = "") {
119
+ const t = str(title).replace(/^[^\p{L}\p{N}]+/u, "").trim();
120
+ if (!t) return "";
121
+ if (/[\\/]|\.exe$|^(powershell|pwsh|cmd|bash|zsh|fish|sh|node)$/i.test(t)) return "";
122
+ if (agent && t.toLowerCase() === String(agent).toLowerCase()) return "";
123
+ if (/^(claude( code)?|codex|opencode|gemini|cursor|copilot)$/i.test(t)) return "";
124
+ return t;
125
+ }
126
+
127
+ // Herdr is asked for the title at most this often per session; a label follows the topic, not keystrokes.
128
+ const TITLE_EVERY_MS = 60_000;
129
+ const titleAsked = new Map();
130
+
131
+ /** The work to label with: the `--session` name if the agent gave one, else the pane's title. */
132
+ async function workFor(session, run, now = Date.now()) {
133
+ if (str(session.name)) return str(session.name);
134
+ const last = titleAsked.get(session.id);
135
+ if (last && now - last.at < TITLE_EVERY_MS) return last.work;
136
+ let work = "";
137
+ try {
138
+ const out = await run(["agent", "get", session.herdr.pane], { bin: session.herdr.bin, socket: session.herdr.socket });
139
+ const agent = JSON.parse(out)?.result?.agent ?? {};
140
+ work = workFromTitle(agent.terminal_title_stripped ?? agent.terminal_title, agent.agent ?? session.agent);
141
+ } catch {
142
+ // No title is no label, not an error: the tab still gets the badge.
143
+ }
144
+ titleAsked.set(session.id, { at: now, work });
145
+ return work;
146
+ }
147
+
113
148
  const inFlight = new Set();
114
149
 
115
150
  /**
116
151
  * Put the session's labels on its Herdr pane, if it has one and they are not there yet. Never
117
152
  * throws: a pane that can't be labelled is a line in the log, not a failed command. `touch` records
118
153
  * what was put on, so it isn't done again; `run` is the herdr invocation (replaced in tests).
154
+ * Without a `--session` name, the work is taken from the pane's terminal title.
119
155
  */
120
156
  export async function labelPane(session, { log = () => {}, touch = () => {}, run = runHerdr } = {}) {
121
- const commands = labelCommands(session);
122
- if (!commands.length || inFlight.has(session.id)) return { labelled: false };
157
+ if (!session?.herdr?.tab || !session?.herdr?.pane || inFlight.has(session.id)) return { labelled: false };
123
158
  inFlight.add(session.id);
159
+ let commands;
160
+ try {
161
+ commands = labelCommands({ ...session, name: await workFor(session, run) });
162
+ } catch {
163
+ commands = [];
164
+ }
165
+ if (!commands.length) {
166
+ inFlight.delete(session.id);
167
+ return { labelled: false };
168
+ }
124
169
  const done = { ...(session.herdrLabel ?? {}) };
125
170
  try {
126
171
  for (const args of commands) {
@@ -18,6 +18,7 @@ import { routeMessage } from "./router.mjs";
18
18
  import * as progressStore from "./progress.mjs";
19
19
  import * as sessions from "./sessions.mjs";
20
20
  import * as decisions from "./decisions.mjs";
21
+ import * as activity from "./activity.mjs";
21
22
 
22
23
  const KEEP = 200;
23
24
 
@@ -166,6 +167,20 @@ export async function ingest(message, { log = () => {}, live = false, waiting =
166
167
  const route = entry.route;
167
168
  const files = entry.files ?? [];
168
169
  log(`message ${message.ts} → ${route.kind}${route.key ? ` ${route.key}` : route.id ? ` ${route.id}` : ""}`);
170
+ // An answer in a question's thread is logged when the question settles, as the answer it is.
171
+ if (route.kind !== "decision") {
172
+ const to = route.kind === "session" ? sessions.get(route.id) : route.kind === "progress" && route.session ? sessions.get(route.session) : null;
173
+ const where = { progress: "a note on the progress message", ask: "asked which agent", inbox: "saved for the next agent" }[route.kind] ?? "";
174
+ activity.record({
175
+ from: "user",
176
+ kind: "message",
177
+ agent: to ? activity.agentOf(sessions.decorate(to)) : null,
178
+ text: text || (files.length ? files.map((f) => f.name).join(", ") : ""),
179
+ detail: where,
180
+ at: tsIso(message.ts),
181
+ ref: message.ts,
182
+ });
183
+ }
169
184
 
170
185
  await addReaction(channel, message.ts, reactionFor(route)).catch((e) => log(`reaction failed: ${e.message}`));
171
186
 
@@ -268,6 +283,7 @@ export async function resolvePick(messageTs, sessionId, log = () => {}) {
268
283
  const chosen = sessionId === NOBODY ? null : sessions.get(sessionId);
269
284
  const route = chosen ? { kind: "session", id: chosen.id, via: "picked" } : { kind: "inbox" };
270
285
  writeState({ ...state, messages: state.messages.map((m) => (m.ts === messageTs ? { ...m, route } : m)) });
286
+ activity.retarget(messageTs, chosen ? activity.agentOf(sessions.decorate(chosen)) : null, chosen ? "" : "saved for the next agent");
271
287
 
272
288
  if (chosen) {
273
289
  deliver(chosen.id, { text: entry.text, by: entry.user, at: tsIso(entry.ts), ts: entry.ts, channel: entry.channel, via: "picked" });
@@ -111,7 +111,7 @@ const MARK_END = "<!-- roger-roger:end -->";
111
111
  /** The standing instruction, between marks so it can be replaced or removed without touching anything else. */
112
112
  export const INSTRUCTIONS = [
113
113
  MARK_START,
114
- "- Always use the `roger-roger` skill (the `roger-roger` command, or `node <skill-dir>/scripts/roger-roger.mjs`): at the end of every turn, whatever its size, send a notification as the last action before the final reply, and use `ask` when blocked on a decision. Don't skip it because the task seemed quick or the user seems to be at the keyboard. Pass `--session` (three to six words about the work) and `--agent-model` on every call. If the skill isn't available, say so once.",
114
+ "- Always use the `roger-roger` skill (the `roger-roger` command, or `node <skill-dir>/scripts/roger-roger.mjs`): at the end of every turn, whatever its size, send a notification as the last action before the final reply, and use `ask` when blocked on a decision. Don't skip it because the task seemed quick or the user seems to be at the keyboard. On every call pass `--session` with what the work is, in three to six words (`--session \"billing refactor tests\"`), and `--agent-model`: the work names your Slack messages and your Herdr pane. Never put code, commands, paths or URLs in the spoken line (`--say`): say what the thing is and why, and leave the command itself to the written message. If the skill isn't available, say so once.",
115
115
  MARK_END,
116
116
  ].join("\n");
117
117
 
@@ -535,8 +535,63 @@ export function slackText({ kind = "info", message, project, mention }) {
535
535
 
536
536
  /** What gets spoken: the agent's `--say` line, unless the user wants the message read verbatim. */
537
537
  export function speechText({ mode = "auto", message, say }) {
538
- if (mode === "same") return message;
539
- return say || message;
538
+ return speakable(mode === "same" ? message : say || message);
539
+ }
540
+
541
+ // A word that only makes sense on a screen: shell punctuation, a flag, a path, a quote, a template.
542
+ const CODEISH = /[{}$|;<>=\\`"']|^--?[a-z]|[\w.~-]\/[\w.-]|^https?:|^[\w-]+\.[a-z]{2,4}[,.]?$|^\w+\(|\)$/i;
543
+
544
+ /**
545
+ * Does this stretch read as code rather than as a sentence? `quoted` is for text already marked as
546
+ * code (in backticks): there one flag or path is enough. Loose text needs two, and a fair share.
547
+ */
548
+ export function looksLikeCode(text, { quoted = false } = {}) {
549
+ const words = String(text).trim().split(/\s+/).filter(Boolean);
550
+ if (!words.length) return false;
551
+ const codeish = words.filter((w) => CODEISH.test(w)).length;
552
+ if (words.length === 1) return words[0].length > 24 || /[{}$|;<>=\\/]/.test(words[0]);
553
+ if (quoted) return codeish >= 1;
554
+ return codeish >= 2 && codeish / words.length >= 0.3;
555
+ }
556
+
557
+ /** What a piece of code is, in words: "an ssh command", "a file", or just "a command". */
558
+ function nameCode(code) {
559
+ const s = String(code).trim();
560
+ if (!/\s/.test(s) && /[\/\\]/.test(s) && !/^https?:/i.test(s)) return "a file";
561
+ const first = s.split(/\s+/)[0].replace(/^[^\w]+/, "");
562
+ // "an ssh", "an npm", "a git": a word with no vowel is spelt out, and f, h, l, m, n, r, s, x start with a vowel sound.
563
+ const an = /^[aeiou]/i.test(first) || (!/[aeiouy]/i.test(first) && /^[fhlmnrsx]/i.test(first));
564
+ if (/^[a-z][\w-]{1,15}$/i.test(first) && !/^(the|a|an|and|or|to|then)$/i.test(first)) return `${an ? "an" : "a"} ${first} command`;
565
+ return "a command";
566
+ }
567
+
568
+ /**
569
+ * The line as it should be heard. Nobody wants a voice reading out `ssh -o BatchMode=yes …` or a
570
+ * URL, so code is said as what it is: a fenced block becomes "the details are in Slack", inline
571
+ * code that is really code becomes "an lsblk command" or "a file", a link becomes "a link", and a
572
+ * stretch of bare command line becomes what it is too. Markdown emphasis goes; tone tags stay.
573
+ */
574
+ export function speakable(text) {
575
+ let s = String(text ?? "");
576
+ s = s.replace(/```[\s\S]*?(```|$)/g, " (the details are in Slack) ");
577
+ s = s.replace(/`([^`\n]+)`/g, (_, code) => (looksLikeCode(code, { quoted: true }) ? nameCode(code) : code));
578
+ s = s.replace(/<?https?:\/\/[^\s>|]+(\|[^>]*)?>?/gi, "a link");
579
+ s = s
580
+ .split(/\n+|(?<=[.!?])\s+(?=[A-Z[])/)
581
+ .map((part) => {
582
+ // A sentence that ends in a command ("Can I run ssh -o … 'df -h'") keeps its words and names the command.
583
+ const words = part.trim().split(/\s+/);
584
+ const start = words.findIndex((w, i) => looksLikeCode(words.slice(i).join(" ")) && CODEISH.test(words[Math.min(i + 1, words.length - 1)]));
585
+ if (start < 0) return part;
586
+ return [...words.slice(0, start), nameCode(words.slice(start).join(" "))].join(" ");
587
+ })
588
+ .join(" ");
589
+ return s
590
+ .replace(/(^|\s)[*_~]+(?=\S)|(?<=\S)[*_~]+(?=\s|[,.!?;:]|$)/g, "$1")
591
+ .replace(/^\s*[>•-]\s+/gm, "")
592
+ .replace(/\s+([,.!?;:])/g, "$1")
593
+ .replace(/\s{2,}/g, " ")
594
+ .trim();
540
595
  }
541
596
 
542
597
  // Several agents share one pair of speakers, and a voice from the next room with no name on it is
@@ -465,6 +465,7 @@ async function main() {
465
465
  case "control":
466
466
  case "listen":
467
467
  case "snooze":
468
+ case "hush":
468
469
  case "tray":
469
470
  case "ask":
470
471
  case "wait":
@@ -11,6 +11,7 @@ import {
11
11
  rogerRogerHome, inQuietHours, listSounds, snoozeUntil,
12
12
  } from "./lib.mjs";
13
13
  import * as decisions from "./decisions.mjs";
14
+ import * as activity from "./activity.mjs";
14
15
  import * as progressStore from "./progress.mjs";
15
16
  import * as sessions from "./sessions.mjs";
16
17
  import { PROVIDERS, catalog, keyFor, providerOf } from "./tts.mjs";
@@ -46,6 +47,8 @@ export function snapshot({ config = null, daemon = null, now = Date.now() } = {}
46
47
  return {
47
48
  updatedAt: new Date(now).toISOString(),
48
49
  daemon: daemon ? { pid: daemon.pid, slackConnected: Boolean(daemon.slackConnected) } : null,
50
+ // Who is speaking right now ({ who, since }), so the panel can offer to stop it.
51
+ speaking: daemon?.speaking ?? null,
49
52
  muted: {
50
53
  snoozeUntil: snoozeUntil(config, new Date(now)),
51
54
  quietHours: config?.quietHours ?? null,
@@ -61,6 +64,8 @@ export function snapshot({ config = null, daemon = null, now = Date.now() } = {}
61
64
  expiresAt: d.expiresAt,
62
65
  })),
63
66
  working: progressStore.active(now).map((p) => ({ key: p.key, headline: p.headline, nickname: p.nickname ?? null })),
67
+ // Who said what lately, newest first: the Log view.
68
+ log: activity.recent(60),
64
69
  // Everything `setup` can change, so the panel can offer it without asking anyone. Tokens live
65
70
  // in slack.json and the environment and are deliberately not here.
66
71
  settings: config
@@ -191,6 +191,8 @@ function tooltip(state, stale) {
191
191
  */
192
192
  function menu(state, spot) {
193
193
  const items = [];
194
+ // While a voice plays, the first thing on the menu stops it.
195
+ if (state.speaking) items.push({ id: "hush", label: state.speaking.who ? `Stop ${state.speaking.who} speaking` : "Stop the voice" }, { id: "sep0", label: "-" });
194
196
  if (state.muted?.snoozeUntil) {
195
197
  items.push({ id: "snooze:off", label: "Stop snoozing" });
196
198
  } else {
@@ -203,9 +205,10 @@ function menu(state, spot) {
203
205
  return { items };
204
206
  }
205
207
 
206
- const menuKey = (state, spot) => `${state.muted?.snoozeUntil ? "snoozed" : "awake"}:${spot ? "spot" : "free"}`;
208
+ const menuKey = (state, spot) => `${state.muted?.snoozeUntil ? "snoozed" : "awake"}:${spot ? "spot" : "free"}:${state.speaking ? `speaking-${state.speaking.who ?? ""}` : "silent"}`;
207
209
 
208
210
  function onMenu(id) {
211
+ if (id === "hush") return run("hush");
209
212
  if (id === "snooze:off") return run("snooze", "off");
210
213
  if (id?.startsWith("snooze:")) return run("snooze", id.slice("snooze:".length));
211
214
  return null;
@@ -463,6 +466,7 @@ async function main() {
463
466
  return log(`the panel said something I could not read: ${message?.body ?? message}`);
464
467
  }
465
468
  if (ask.do === "snooze") return run("snooze", ask.value || "1h");
469
+ if (ask.do === "hush") return run("hush");
466
470
  if (ask.do === "hide") return hidePanel();
467
471
  if (ask.do === "keep-open") {
468
472
  keepOpen = Boolean(ask.value);
@@ -141,6 +141,40 @@
141
141
  .slider input[type="range"] { flex: 1; accent-color: var(--accent); padding: 0; border: 0; background: transparent; }
142
142
  .slider output { width: 40px; text-align: right; font-size: 11px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
143
143
  .flash.is-ok { border-color: #7fd1a0; }
144
+
145
+ /* The log: who said what, newest first. The agent's colour runs down the left edge. */
146
+ .row.is-agent { cursor: pointer; }
147
+ .filters { display: flex; gap: 4px; flex-wrap: wrap; padding: 6px 4px 8px; }
148
+ .entry { position: relative; padding: 8px 8px 8px 12px; border-bottom: 1px solid var(--border-subtle); cursor: pointer; }
149
+ .entry::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 2px; background: var(--edge, var(--text-muted)); }
150
+ .entry.from-user::before { background: var(--accent); }
151
+ .entry:hover { background: color-mix(in srgb, var(--bg-section) 60%, transparent); }
152
+ .entry .top { gap: 6px; font-size: 11px; color: var(--text-dim); }
153
+ .entry .name { font-weight: 600; color: var(--text); }
154
+ .entry .arrow { color: var(--text-muted); }
155
+ .entry .kind { padding: 0 5px; border: 1px solid var(--border-subtle); font-size: 10px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--text-muted); }
156
+ .entry .kind.k-blocked, .entry .kind.k-error, .entry .kind.k-question { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 50%, transparent); }
157
+ .entry .kind.k-done, .entry .kind.k-answer { color: #7fd1a0; border-color: #7fd1a055; }
158
+ .entry .text { margin-top: 4px; line-height: 1.4; color: var(--text); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; }
159
+ .entry.is-open .text { display: block; }
160
+ .entry .detail { margin-top: 3px; font-size: 11px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
161
+ .entry.is-open .detail { white-space: normal; }
162
+ .day { padding: 12px 8px 4px; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); }
163
+
164
+ /* On air: shown only while a voice or sound plays, with the one thing you might want — stop it. */
165
+ .onair { display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-bottom: 1px solid var(--border-subtle); background: color-mix(in srgb, var(--accent) 12%, transparent); }
166
+ .onair[hidden] { display: none; }
167
+ .onair .bars { display: flex; align-items: flex-end; gap: 2px; height: 14px; }
168
+ .onair .bars i { width: 3px; background: var(--accent); animation: level 900ms ease-in-out infinite; }
169
+ .onair .bars i:nth-child(2) { animation-delay: -300ms; }
170
+ .onair .bars i:nth-child(3) { animation-delay: -600ms; }
171
+ @keyframes level { 0%, 100% { height: 4px; } 50% { height: 14px; } }
172
+ @media (prefers-reduced-motion: reduce) { .onair .bars i { animation: none; height: 10px; } }
173
+ .onair .label { flex: 1; min-width: 0; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
174
+ .onair .label strong { font-weight: 600; }
175
+ .onair button { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; font-size: 11px; font-weight: 600; color: #101317; background: var(--accent); border-color: var(--accent); }
176
+ .onair button:hover { filter: brightness(1.08); }
177
+ .onair button svg { width: 11px; height: 11px; fill: currentColor; stroke: none; }
144
178
  svg { width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
145
179
  </style>
146
180
  </head>
@@ -151,6 +185,9 @@
151
185
  <span class="count" id="count"></span>
152
186
  <span class="spacer"></span>
153
187
  <span class="badge" id="badge" hidden></span>
188
+ <button class="icon-btn icon-btn-sm" id="log" title="Log: who said what">
189
+ <svg viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path><path d="M8 9h8"></path><path d="M8 13h5"></path></svg>
190
+ </button>
154
191
  <button class="icon-btn icon-btn-sm" id="pin" title="Keep open when I click elsewhere">
155
192
  <svg viewBox="0 0 24 24"><path d="M12 17v5"></path><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"></path></svg>
156
193
  </button>
@@ -158,6 +195,13 @@
158
195
  <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6h.09A1.65 1.65 0 0 0 10 3.09V3a2 2 0 1 1 4 0v.09A1.65 1.65 0 0 0 15 4.6a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9v.09a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>
159
196
  </button>
160
197
  </header>
198
+ <div class="onair" id="onair" hidden>
199
+ <span class="bars" aria-hidden="true"><i></i><i></i><i></i></span>
200
+ <span class="label" id="onair-label"></span>
201
+ <button data-do="hush" title="Stop this announcement (the Slack message stays)">
202
+ <svg viewBox="0 0 24 24"><rect x="5" y="5" width="14" height="14"></rect></svg>Stop
203
+ </button>
204
+ </div>
161
205
  <main id="body"></main>
162
206
  <div class="flash" id="flash" hidden></div>
163
207
  <footer id="actions"></footer>
@@ -168,9 +212,15 @@
168
212
  const plural = (n, one, many) => n + " " + (n === 1 ? one : many);
169
213
  const send = (body) => window.ipc.postMessage(JSON.stringify(body));
170
214
 
171
- let latest = { agents: [], questions: [], working: [] };
215
+ let latest = { agents: [], questions: [], working: [], log: [] };
172
216
  let settingsOpen = false;
173
217
  let advancedOpen = false;
218
+ // The log view, which agent it is narrowed to (null: everyone), and which entries are opened out.
219
+ let logOpen = false;
220
+ let logFilter = null;
221
+ // Stop was pressed: hide the strip at once rather than waiting for the daemon to say it stopped.
222
+ let hushedAt = 0;
223
+ const openEntries = new Set();
174
224
 
175
225
  function ago(ms) {
176
226
  const s = Math.round((ms ?? 0) / 1000);
@@ -197,7 +247,7 @@
197
247
  if (a.queued) bits.push(plural(a.queued, "message", "messages") + " unread");
198
248
  if (a.owes) bits.push(plural(a.owes, "answer", "answers") + " to collect");
199
249
  return (
200
- '<div class="row"><div class="top">' +
250
+ '<div class="row is-agent" data-agent="' + esc(a.nickname) + '" title="Show what ' + esc(a.nickname) + ' said"><div class="top">' +
201
251
  '<span class="status-dot"' + colour + "></span>" +
202
252
  '<span class="who">' + esc(a.nickname) + "</span>" +
203
253
  '<span class="state">' + esc(a.state) + "</span>" +
@@ -217,6 +267,70 @@
217
267
  );
218
268
  }
219
269
 
270
+ // ---------------------------------------------------------------- log
271
+
272
+ const KIND_LABEL = { done: "done", blocked: "blocked", input: "needs you", milestone: "milestone", error: "error", info: "info", question: "question", answer: "answer", message: "message", cancelled: "cancelled", expired: "expired" };
273
+
274
+ function clock(iso) {
275
+ const d = new Date(iso);
276
+ return isFinite(d) ? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
277
+ }
278
+
279
+ function dayOf(iso) {
280
+ const d = new Date(iso);
281
+ if (!isFinite(d)) return "";
282
+ const today = new Date();
283
+ const yesterday = new Date(today.getTime() - 864e5);
284
+ if (d.toDateString() === today.toDateString()) return "Today";
285
+ if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
286
+ return d.toLocaleDateString([], { weekday: "long", day: "numeric", month: "short" });
287
+ }
288
+
289
+ function entryRow(e, i) {
290
+ const name = (a) => '<span class="name">' + esc(a ? (a.swatch ? a.swatch + " " : "") + a.nickname : "an agent") + "</span>";
291
+ const who = e.from === "user"
292
+ ? '<span class="name">you</span>' + (e.agent ? '<span class="arrow">→</span>' + name(e.agent) : "")
293
+ : name(e.agent) + '<span class="arrow">→</span><span class="name">you</span>';
294
+ const key = e.at + "|" + i;
295
+ const edge = e.agent && e.agent.colour ? ' style="--edge:' + esc(e.agent.colour) + '"' : "";
296
+ const detail = e.kind === "answer" && e.detail ? "to: " + e.detail : e.detail;
297
+ return (
298
+ '<div class="entry' + (e.from === "user" ? " from-user" : "") + (openEntries.has(key) ? " is-open" : "") + '" data-entry="' + esc(key) + '"' + edge + ">" +
299
+ '<div class="top">' + who +
300
+ '<span class="kind k-' + esc(e.kind) + '">' + esc(KIND_LABEL[e.kind] || e.kind) + "</span>" +
301
+ '<span class="age" title="' + esc(new Date(e.at).toLocaleString()) + '">' + clock(e.at) + "</span></div>" +
302
+ (e.text ? '<div class="text">' + esc(e.text) + "</div>" : "") +
303
+ (detail ? '<div class="detail">' + esc(detail) + "</div>" : "") +
304
+ "</div>"
305
+ );
306
+ }
307
+
308
+ function logView(log) {
309
+ const names = [...new Set(log.map((e) => e.agent && e.agent.nickname).filter(Boolean))];
310
+ const shown = logFilter ? log.filter((e) => e.agent && e.agent.nickname === logFilter) : log;
311
+ const parts = [];
312
+ if (names.length > 1 || logFilter) {
313
+ parts.push('<div class="filters">' +
314
+ '<button class="chip' + (logFilter ? "" : " is-active") + '" data-filter="">Everyone</button>' +
315
+ names.map((n) => '<button class="chip' + (logFilter === n ? " is-active" : "") + '" data-filter="' + esc(n) + '">' + esc(n) + "</button>").join("") +
316
+ "</div>");
317
+ }
318
+ if (!shown.length) {
319
+ parts.push('<p class="empty">' + (logFilter ? "Nothing from " + esc(logFilter) + " yet." : "Nothing said yet. Notifications, questions, your answers and your messages show up here.") + "</p>");
320
+ return parts.join("");
321
+ }
322
+ let day = "";
323
+ shown.forEach((e, i) => {
324
+ const d = dayOf(e.at);
325
+ if (d !== day) {
326
+ day = d;
327
+ parts.push('<p class="day">' + esc(d) + "</p>");
328
+ }
329
+ parts.push(entryRow(e, i));
330
+ });
331
+ return parts.join("");
332
+ }
333
+
220
334
  // ---------------------------------------------------------------- settings
221
335
 
222
336
  const setting = (label, control, hint) =>
@@ -363,6 +477,8 @@
363
477
 
364
478
  document.getElementById("count").textContent = settingsOpen
365
479
  ? "settings"
480
+ : logOpen
481
+ ? "log" + (logFilter ? " · " + logFilter : "")
366
482
  : agents.length
367
483
  ? plural(agents.length, "agent", "agents") + (questions.length ? " · " + plural(questions.length, "question", "questions") : "")
368
484
  : "nothing running";
@@ -371,12 +487,27 @@
371
487
  badge.hidden = !snoozed && !quiet;
372
488
  badge.textContent = snoozed ? "snoozed" : "quiet hours";
373
489
  document.getElementById("gear").classList.toggle("is-active", settingsOpen);
490
+ document.getElementById("log").classList.toggle("is-active", logOpen && !settingsOpen);
491
+
492
+ // Who is speaking, and the Stop button, only while something is.
493
+ const speaking = state.speaking;
494
+ const onair = document.getElementById("onair");
495
+ onair.hidden = !speaking || hushedAt > Date.now() - 2500;
496
+ if (speaking) {
497
+ const who = speaking.who;
498
+ const agent = agents.find((a) => a.nickname === who);
499
+ document.getElementById("onair-label").innerHTML = who && agent
500
+ ? "<strong>" + esc((agent.swatch ? agent.swatch + " " : "") + who) + "</strong> is speaking"
501
+ : who ? "Playing a " + esc(who) : "Speaking";
502
+ }
374
503
 
375
504
  const parts = [];
376
505
  // The tray says so when the file it reads has stopped being kept up to date.
377
506
  if (state.stale) parts.push('<p class="stale">Daemon not running — state may be old.</p>');
378
507
  if (settingsOpen) {
379
508
  parts.push(settingsView(state.settings, state.options || {}));
509
+ } else if (logOpen) {
510
+ parts.push(logView(state.log ?? []));
380
511
  } else if (!agents.length && !questions.length && !working.length) {
381
512
  parts.push('<p class="empty">Nothing is running.</p>');
382
513
  } else {
@@ -455,10 +586,40 @@
455
586
 
456
587
  document.getElementById("gear").addEventListener("click", () => {
457
588
  settingsOpen = !settingsOpen;
589
+ if (settingsOpen) logOpen = false;
590
+ draw();
591
+ });
592
+
593
+ document.getElementById("log").addEventListener("click", () => {
594
+ logOpen = settingsOpen ? true : !logOpen;
595
+ settingsOpen = false;
596
+ logFilter = null;
597
+ document.getElementById("body").scrollTop = 0;
458
598
  draw();
459
599
  });
460
600
 
461
601
  document.addEventListener("click", (e) => {
602
+ // An agent's row opens its log; a filter narrows the log; an entry opens out to its full text.
603
+ const agent = e.target.closest("[data-agent]");
604
+ if (agent) {
605
+ logOpen = true;
606
+ logFilter = agent.dataset.agent;
607
+ document.getElementById("body").scrollTop = 0;
608
+ return draw();
609
+ }
610
+ const filter = e.target.closest("[data-filter]");
611
+ if (filter) {
612
+ logFilter = filter.dataset.filter || null;
613
+ return draw();
614
+ }
615
+ const entry = e.target.closest("[data-entry]");
616
+ if (entry) {
617
+ const key = entry.dataset.entry;
618
+ if (openEntries.has(key)) openEntries.delete(key);
619
+ else openEntries.add(key);
620
+ entry.classList.toggle("is-open");
621
+ return;
622
+ }
462
623
  const action = e.target.closest("[data-do]");
463
624
  if (action) {
464
625
  e.preventDefault();
@@ -466,6 +627,10 @@
466
627
  action.disabled = true;
467
628
  action.textContent = "Speaking…";
468
629
  }
630
+ if (action.dataset.do === "hush") {
631
+ hushedAt = Date.now();
632
+ document.getElementById("onair").hidden = true;
633
+ }
469
634
  return send({ do: action.dataset.do, value: action.dataset.value });
470
635
  }
471
636
  const chip = e.target.closest("[data-chip]");