roger-roger 0.1.4 → 0.1.13
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 +2 -2
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +19 -3
- package/skills/roger-roger/herdr-plugin.toml +1 -1
- package/skills/roger-roger/scripts/activity.mjs +92 -0
- package/skills/roger-roger/scripts/audio.mjs +123 -19
- package/skills/roger-roger/scripts/daemon.mjs +5 -3
- package/skills/roger-roger/scripts/decisions.mjs +9 -0
- package/skills/roger-roger/scripts/handlers.mjs +29 -10
- package/skills/roger-roger/scripts/help.mjs +1 -0
- package/skills/roger-roger/scripts/herdr.mjs +84 -12
- package/skills/roger-roger/scripts/inbox.mjs +16 -0
- package/skills/roger-roger/scripts/install.mjs +299 -124
- package/skills/roger-roger/scripts/lib.mjs +57 -2
- package/skills/roger-roger/scripts/names.mjs +14 -2
- package/skills/roger-roger/scripts/roger-roger.mjs +1 -0
- package/skills/roger-roger/scripts/sessions.mjs +10 -4
- package/skills/roger-roger/scripts/tray.mjs +5 -0
- package/skills/roger-roger/tray/main.mjs +5 -1
- package/skills/roger-roger/tray/panel.html +167 -2
package/README.md
CHANGED
|
@@ -93,11 +93,11 @@ There is no always-on service. `ask` starts a small watcher process, and one wat
|
|
|
93
93
|
- **One voice at a time**: several agents on one machine take turns at the speakers instead of talking over each other. A waiting announcement plays in full as soon as the one before it finishes.
|
|
94
94
|
- **Works with any agent**: Claude Code, Codex, OpenCode, or a plain shell script. A session is recognised by whatever id the agent publishes, or by the `--session` name it passes, or by the terminal — no per-product support needed.
|
|
95
95
|
- **Every agent has a name**: each session is given a colour nickname — 🌿 sage, 🌹 rose, 🍇 plum — shown on its Slack messages with a matching colour bar. `sessions` lists who is running; `sessions rename sage plum` changes one.
|
|
96
|
-
- **Named in Herdr too**: an agent running in a [Herdr](https://herdr.dev) pane gets its badge on the tab (`🍯 amber`) and its
|
|
96
|
+
- **Named in Herdr too**: an agent running in a [Herdr](https://herdr.dev) pane gets its badge and a word or two of its work on the tab (`🍯 amber | build process`) and its work on the agent (`build-process-improvements`), so the Herdr sidebar and the Slack messages name the same thing. Nothing to set up: the skill sees the pane it runs in and labels it on the first command, and again after a rename. As a Herdr plugin (`roger-roger install` registers it, or `herdr plugin install code-red-app/roger-roger/skills/roger-roger`), the daemon starts with Herdr, snooze is an action, and the setup opens as a popup pane.
|
|
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.
|
|
3
|
+
"version": "0.1.13",
|
|
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
|
|
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 and the first two words of your work (`🍯 amber | build process`, so lead your `--session` with the words that say what it is) and its agent with your work (`build-process-improvements`: Herdr allows only lowercase letters, digits, `-` and `_` there, 32 characters at most), so the Herdr sidebar reads `code-red 🍯 amber 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 (
|
|
81
|
+
| `--say` | The spoken line. Short, but it must **carry the point itself**: what happened and what, if anything, you need (see *What to say out loud* below). **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. |
|
|
@@ -158,6 +158,19 @@ roger-roger listen --wait 25m
|
|
|
158
158
|
- Don't park after a quick exchange where the user is plainly at the keyboard, and don't park instead of doing work you could be doing.
|
|
159
159
|
- The user sees :eyes: in Slack when an agent is listening or actually running, and :zzz: when the message is saved for one that isn't — so they know whether to expect an answer now or at your next step.
|
|
160
160
|
|
|
161
|
+
### What to say out loud
|
|
162
|
+
|
|
163
|
+
The user hears the line from across the room and may never look at Slack. One or two sentences, and they must be enough on their own: the actual result, the actual question, the actual next step. A line that only points somewhere else ("here is what's next", "details in Slack", "I have an update", "done, see the message") is useless out loud: it makes the user walk over to find out what you could have said.
|
|
164
|
+
|
|
165
|
+
- Say the thing, not that there is a thing. Not "Here's what's next." but "Next I'd set up automatic releases, then the Herdr status pings."
|
|
166
|
+
- Lead with the outcome, then what you need: "The migration is done and all tests pass. Nothing needed from you."
|
|
167
|
+
- For a question, say the question and the choices in words: "Should I archive the twelve orphaned rows, or delete them?"
|
|
168
|
+
- For a failure, say what broke and whether you are stuck: "The build fails on a missing Slack token. I'm blocked until it's set."
|
|
169
|
+
- Numbers and names are fine ("318 tests", "the billing module"); code, paths and links are not.
|
|
170
|
+
- Terse is good, empty is not: "All green." is fine when that is the whole story; "It's done, here's what's next." never is.
|
|
171
|
+
|
|
172
|
+
When there is more than fits in two sentences, say the one thing that matters most, and let the written message carry the rest.
|
|
173
|
+
|
|
161
174
|
### Tone: Gemini audio tags
|
|
162
175
|
|
|
163
176
|
Gemini 3.1 Flash TTS reads inline tags in square brackets and changes its delivery. Put a tag where the change should happen. Choose tags that fit the moment, and use them sparingly (one or two per line):
|
|
@@ -173,7 +186,9 @@ Examples:
|
|
|
173
186
|
- `--say "[positive] Deployment finished. [short pause] Everything is green."`
|
|
174
187
|
- `--say "[serious] I'm blocked. I need the production database password to continue."`
|
|
175
188
|
|
|
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.
|
|
189
|
+
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.
|
|
190
|
+
|
|
191
|
+
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
192
|
|
|
178
193
|
## 3. Asking the user to choose
|
|
179
194
|
|
|
@@ -269,6 +284,7 @@ Judge it by what happens if you are wrong, not by whether it feels routine. A fo
|
|
|
269
284
|
- `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
285
|
- `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
286
|
- `snooze [1h|off]`: mute sound and speech for a while; Slack keeps working. For "not now" moments that quiet hours don't cover.
|
|
287
|
+
- `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
288
|
- `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
289
|
- `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
290
|
- `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.
|
|
9
|
+
version = "0.1.13"
|
|
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
|
-
|
|
22
|
-
child.on("
|
|
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
|
|
27
|
-
async function
|
|
28
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
{
|