roger-roger 0.1.3 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +7 -4
- 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 +3 -2
- package/skills/roger-roger/scripts/herdr.mjs +47 -2
- package/skills/roger-roger/scripts/inbox.mjs +16 -0
- package/skills/roger-roger/scripts/install.mjs +130 -2
- package/skills/roger-roger/scripts/lib.mjs +57 -2
- package/skills/roger-roger/scripts/roger-roger.mjs +1 -0
- 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
|
@@ -57,6 +57,7 @@ A missing key is not fatal. Speech falls back to the computer's built-in voice (
|
|
|
57
57
|
`roger-roger install` asks, in the terminal:
|
|
58
58
|
|
|
59
59
|
- which agents should have the skill (every one it finds is ticked)
|
|
60
|
+
- whether to tell your agents to always use it: a marked block in each agent's global instructions (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`, `~/.gemini/GEMINI.md`), which uninstall takes out again
|
|
60
61
|
- whether to register with Herdr, if it's installed
|
|
61
62
|
- how to reach you: Slack, sound, speech, in any combination
|
|
62
63
|
- Slack: it connects it for you (see above), and where messages go (your DMs, or a channel)
|
|
@@ -96,7 +97,7 @@ There is no always-on service. `ask` starts a small watcher process, and one wat
|
|
|
96
97
|
- **Talk to an agent**: write "sage, rerun the tests" (or "hey sage rerun the tests", or "sage: ..."). With one agent running, a plain message goes to it. With several, but only one of them listening, it goes to that one and Slack says so, with a button to redirect it. With several, the bot asks which, with a button per agent — and remembers your answer for that thread.
|
|
97
98
|
- **You can hear who it is**: a spoken line starts with the agent's name — "Amber here.", "This is Rose.", "Jade speaking." — so three agents sharing one pair of speakers stay tellable apart (`setup --say-who off`).
|
|
98
99
|
- **Reachable while idle**: an agent that has finished can run `listen` in the background instead of going quiet. It costs nothing to wait, and when you write, it wakes up and answers. Slack shows 👀 when an agent is listening and 💤 when your message is saved for one that isn't, so you always know which.
|
|
99
|
-
- **In the tray**: `tray install` puts an icon in the system tray, started with the daemon from then on (`tray start` starts it straight away; `setup --tray off` stops it starting by itself) — every agent at a glance, a translucent panel on click, snooze on right-click. It reads the same state the daemon already keeps and runs ordinary commands, so it can be closed, killed or never installed and nothing else changes.
|
|
100
|
+
- **In the tray**: `tray install` puts an icon in the system tray, started with the daemon from then on (`tray start` starts it straight away; `setup --tray off` stops it starting by itself) — every agent at a glance, a translucent panel on click, snooze on right-click, a Stop button while a voice is speaking (`roger-roger hush` from a terminal), and a log of who said what lately: every notification and question from the agents, and every message and answer from you, per agent or all together. It reads the same state the daemon already keeps and runs ordinary commands, so it can be closed, killed or never installed and nothing else changes.
|
|
100
101
|
- **Questions asked in the terminal**: when the agent asks you something with its own question tool (Claude Code's question dialog, OpenCode's question tool, Codex's `request_user_input`) or wants a plan approved, it's stuck until you answer and can't tell you itself. `hooks install` hooks into each of those agents it finds: once the question has gone unanswered for a little while, it arrives in Slack with its options, and when you answer in the terminal the message turns into your answer. Claude Code gets three hooks in `~/.claude/settings.json`; OpenCode a small plugin in `~/.config/opencode/plugins/`; Codex three hooks in `~/.codex/hooks.json`, which you trust once in Codex's `/hooks`. Claude Code knows when you've stopped typing; OpenCode and Codex don't, so they wait 20 seconds first (`setup --terminal-wait`). `setup --permission-pings on` does the same for ordinary permission prompts. `hooks uninstall` takes it all out again.
|
|
101
102
|
- **Quiet hours**: `setup --quiet-hours 22:00-08:00` mutes sound and speech overnight. It's off by default.
|
|
102
103
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roger-roger",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"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
|
|
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.
|
|
176
|
+
Tags must be separated by words or punctuation, never placed back to back. The script strips tags from Slack text and from the local-voice fallback, so they are safe to include.
|
|
177
|
+
|
|
178
|
+
Code is never read aloud. As a safety net, anything that reaches the speakers still looking like code is replaced with what it is ("an ssh command", "a file", "a link", "the details are in Slack"), but that is a fallback, not a way of writing: a line the safety net had to fix still sounds clumsy. Write the spoken line as plain speech in the first place, and name commands by what they do. Very short or odd lines (a single word) are sometimes refused by Gemini, so write a natural sentence.
|
|
177
179
|
|
|
178
180
|
## 3. Asking the user to choose
|
|
179
181
|
|
|
@@ -261,7 +263,7 @@ Judge it by what happens if you are wrong, not by whether it feels routine. A fo
|
|
|
261
263
|
## Other commands
|
|
262
264
|
|
|
263
265
|
- `install`: the interactive setup, for the user to run in a terminal (section 1). `install --yes` asks nothing: it links the skill for every agent on the machine and registers with Herdr, and prints what it did.
|
|
264
|
-
- `uninstall`: takes it out again, for the user to run in a terminal (`npx roger-roger@latest uninstall`): hooks, skill links, the Herdr plugin, the daemon and the tray, settings and tokens, the global command. `--yes` removes all of that without asking (`--slack-cli` adds the Slack CLI). Never run it for the user unasked.
|
|
266
|
+
- `uninstall`: takes it out again, for the user to run in a terminal (`npx roger-roger@latest uninstall`): the standing instruction `install` put in the agents' global files (a marked block in `CLAUDE.md`, `AGENTS.md`, `GEMINI.md`), hooks, skill links, the Herdr plugin, the daemon and the tray, settings and tokens, the global command. `--yes` removes all of that without asking (`--slack-cli` adds the Slack CLI). Never run it for the user unasked.
|
|
265
267
|
- `help [command]`: the commands, and one command's flags.
|
|
266
268
|
- `status`: current config, whether the tokens are present, the daemon, running sessions, and every valid option value.
|
|
267
269
|
- `slack-setup [status|install-cli|login|create]`: the Slack steps `install` runs, one at a time, for repairing a connection by hand. `create` again is safe: it updates the same app, which is what to do after the manifest changed or when a scope is missing.
|
|
@@ -269,6 +271,7 @@ Judge it by what happens if you are wrong, not by whether it feels routine. A fo
|
|
|
269
271
|
- `end [name]`: this session is finished. Do this when your work is done, so messages meant for someone else don't come to you.
|
|
270
272
|
- `tray status|start|stop|install`: an icon in the system tray showing every agent, with a translucent panel behind it and snooze on the right-click menu. The native part is a separate package installed on request into `~/.roger-roger` (about 4 MB, nothing compiled); everything here works without it. Once installed, the daemon starts it by itself whenever it starts (`setup --tray off` to stop that).
|
|
271
273
|
- `snooze [1h|off]`: mute sound and speech for a while; Slack keeps working. For "not now" moments that quiet hours don't cover.
|
|
274
|
+
- `hush`: stop the voice or sound playing right now (the user's Stop button in the tray does the same). Only that announcement; the next one plays, and the Slack message stays. If a notification comes back with `results.interrupted`, the user stopped it: don't send it again.
|
|
272
275
|
- `hooks status|install|uninstall [claude|opencode|codex|all]`: the terminal-question hooks. When you use your own question tool (Claude Code's question dialog, OpenCode's question tool, Codex's `request_user_input`) or present a plan to approve, you are frozen until it's answered and can't send anything yourself; with the hooks, the agent itself runs roger-roger, and once the question has gone unanswered for a little while the user gets it in Slack with its options. `roger-roger install` offers them; without a name, `hooks install` covers the agents on this machine. What each gets: Claude Code, three entries in `~/.claude/settings.json`; OpenCode, a small plugin at `~/.config/opencode/plugins/roger-roger.js` (restart OpenCode to load it); Codex, three entries in `~/.codex/hooks.json`, to trust in `/hooks`. They follow the user's `when`, quiet hours and snooze like any notification, and log each run to `~/.roger-roger/hooks.log`. Claude Code pings once nobody has typed for a few seconds; OpenCode and Codex can't tell, so they wait `setup --terminal-wait` (default 20s) and ping only if it's still unanswered. Ordinary permission prompts (leave to run a command) don't ping unless the user asks: `setup --permission-pings on` (Claude Code and OpenCode; Codex gives no signal when one is answered). Run `hooks install` again after the skill moves, so they point at the new copy; `hooks status` says if they don't.
|
|
273
276
|
- `daemon status|start|stop`: the always-on process behind everything. It starts on demand, so this is only for checking on it or restarting it after a change to the skill's code.
|
|
274
277
|
- `play [sound]`: preview a sound (defaults to the configured one).
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
id = "roger-roger"
|
|
8
8
|
name = "roger-roger"
|
|
9
|
-
version = "0.1.
|
|
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
|
-
|
|
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,
|
|
@@ -10,8 +10,8 @@ export const TAGLINE = "Bring yourself back to the conversation: your agents rea
|
|
|
10
10
|
/** Every command: where it belongs, how it is called, what it does, and (for the busy ones) its flags. */
|
|
11
11
|
export const COMMANDS = [
|
|
12
12
|
// ---- setting up
|
|
13
|
-
{ section: "Setting up", name: "install", usage: "[--yes]", summary: "Set everything up in the terminal: the command on your PATH, the skill for every agent, Herdr, Slack, the sound and the voice, hooks, tray. Run it again to change anything. --yes asks nothing and only links the skill.", flags: [["--yes", "no questions: link the skill for every agent on the machine and register with Herdr, then print what was done"]] },
|
|
14
|
-
{ section: "Setting up", name: "uninstall", usage: "[--yes]", summary: "Take it out again: hooks, links, Herdr, the daemon and the tray, settings and tokens, the global command.", flags: [["--yes", "no questions: remove everything except the Slack CLI, then print what was done"]] },
|
|
13
|
+
{ section: "Setting up", name: "install", usage: "[--yes]", summary: "Set everything up in the terminal: the command on your PATH, the skill for every agent and a standing instruction to use it, Herdr, Slack, the sound and the voice, hooks, tray. Run it again to change anything. --yes asks nothing and only links the skill.", flags: [["--yes", "no questions: link the skill for every agent on the machine and register with Herdr, then print what was done. Nothing is written to CLAUDE.md or AGENTS.md"]] },
|
|
14
|
+
{ section: "Setting up", name: "uninstall", usage: "[--yes]", summary: "Take it out again: the standing instruction, hooks, links, Herdr, the daemon and the tray, settings and tokens, the global command.", flags: [["--yes", "no questions: remove everything except the Slack CLI, then print what was done"], ["--slack-cli", "with --yes, remove the Slack CLI and its login as well"]] },
|
|
15
15
|
{ section: "Setting up", name: "status", usage: "", summary: "Settings, whether the tokens are there, the daemon, who is running, and every valid option value." },
|
|
16
16
|
{
|
|
17
17
|
section: "Setting up", name: "setup", usage: "[flags]", summary: "Change any setting; the rest are kept.",
|
|
@@ -66,6 +66,7 @@ export const COMMANDS = [
|
|
|
66
66
|
{ section: "Reaching the user", name: "control", usage: "[--key NAME] [--wait DURATION]", summary: "Read Pause, Stop and new messages without posting. --wait blocks while paused." },
|
|
67
67
|
{ section: "Reaching the user", name: "listen", usage: "[--wait 25m]", summary: "Park until the user says something. Run it in the background as the last thing you do." },
|
|
68
68
|
{ section: "Reaching the user", name: "snooze", usage: "[1h|off]", summary: "Sound and speech off for a while; Slack keeps working." },
|
|
69
|
+
{ section: "Reaching the user", name: "hush", usage: "", summary: "Stop the voice or sound playing right now. Only this announcement: the next plays as usual, and the Slack message stays. The tray's Stop button does the same." },
|
|
69
70
|
|
|
70
71
|
// ---- asking
|
|
71
72
|
{
|