roger-roger 0.1.0

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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/package.json +45 -0
  4. package/skills/roger-roger/SKILL.md +289 -0
  5. package/skills/roger-roger/herdr-plugin.toml +38 -0
  6. package/skills/roger-roger/scripts/agent.mjs +132 -0
  7. package/skills/roger-roger/scripts/audio.mjs +392 -0
  8. package/skills/roger-roger/scripts/client.mjs +121 -0
  9. package/skills/roger-roger/scripts/daemon.mjs +604 -0
  10. package/skills/roger-roger/scripts/decisions.mjs +158 -0
  11. package/skills/roger-roger/scripts/handlers.mjs +1151 -0
  12. package/skills/roger-roger/scripts/herdr.mjs +140 -0
  13. package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
  14. package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
  15. package/skills/roger-roger/scripts/hooks.mjs +420 -0
  16. package/skills/roger-roger/scripts/inbox.mjs +381 -0
  17. package/skills/roger-roger/scripts/install.mjs +560 -0
  18. package/skills/roger-roger/scripts/lib.mjs +1133 -0
  19. package/skills/roger-roger/scripts/names.mjs +84 -0
  20. package/skills/roger-roger/scripts/progress.mjs +91 -0
  21. package/skills/roger-roger/scripts/protocol.mjs +71 -0
  22. package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
  23. package/skills/roger-roger/scripts/router.mjs +86 -0
  24. package/skills/roger-roger/scripts/sessions.mjs +218 -0
  25. package/skills/roger-roger/scripts/slack.mjs +240 -0
  26. package/skills/roger-roger/scripts/slackapp.mjs +205 -0
  27. package/skills/roger-roger/scripts/slackcli.mjs +144 -0
  28. package/skills/roger-roger/scripts/speaker.mjs +224 -0
  29. package/skills/roger-roger/scripts/speechkey.mjs +106 -0
  30. package/skills/roger-roger/scripts/tray.mjs +128 -0
  31. package/skills/roger-roger/scripts/tts.mjs +275 -0
  32. package/skills/roger-roger/scripts/tui.mjs +465 -0
  33. package/skills/roger-roger/slack/manifest.json +34 -0
  34. package/skills/roger-roger/sounds/alert.wav +0 -0
  35. package/skills/roger-roger/sounds/bubble.wav +0 -0
  36. package/skills/roger-roger/sounds/chime.wav +0 -0
  37. package/skills/roger-roger/sounds/ding.wav +0 -0
  38. package/skills/roger-roger/sounds/marimba.wav +0 -0
  39. package/skills/roger-roger/tray/main.mjs +749 -0
  40. package/skills/roger-roger/tray/panel.html +501 -0
@@ -0,0 +1,132 @@
1
+ // Working out who is calling, without knowing what they are.
2
+ //
3
+ // The skill is used by whatever agent the user runs, so nothing here may depend on one product.
4
+ // Identity is resolved in layers, best first:
5
+ //
6
+ // 1. explicit — `--session-id`, or ROGER_ROGER_SESSION_ID
7
+ // 2. published — a session id the agent puts in the environment. Some are named here, but
8
+ // anything shaped like `*_SESSION_ID` counts, so an agent nobody has heard of
9
+ // still gets a stable id.
10
+ // 3. named — the `--session` name the skill asks every agent to pass and keep for the
11
+ // whole session, hashed with the directory. Works anywhere, for anything.
12
+ // 4. terminal — the terminal and directory, for a bare command with none of the above.
13
+ //
14
+ // Only the first two know anything about a particular agent; the rest work for anything that can run
15
+ // a command. The daemon also remembers which session a terminal belonged to, so a later call that
16
+ // leaves `--session` off still lands in the same place (see sessions.mjs).
17
+
18
+ import crypto from "node:crypto";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import { herdrContext } from "./herdr.mjs";
22
+
23
+ const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : "");
24
+ const hash = (value) => crypto.createHash("sha1").update(value).digest("hex").slice(0, 16);
25
+
26
+ /** Session-id variables we know by name, tried in this order. */
27
+ const SESSION_VARS = [
28
+ "CLAUDE_CODE_SESSION_ID",
29
+ "CODEX_SESSION_ID", "CODEX_THREAD_ID", "CODEX_CONVERSATION_ID",
30
+ "OPENCODE_SESSION_ID", "OPENCODE_SESSION",
31
+ "CURSOR_SESSION_ID", "AIDER_SESSION_ID", "GEMINI_CLI_SESSION_ID",
32
+ "GOOSE_SESSION_ID", "AMP_SESSION_ID", "COPILOT_SESSION_ID", "AGENT_SESSION_ID",
33
+ ];
34
+
35
+ /** …and the shape of one we don't, so a new agent needs no change here. */
36
+ const SESSION_VAR = /^[A-Z][A-Z0-9_]*_(?:SESSION|THREAD|CONVERSATION)_ID$/;
37
+
38
+ /** Things that own a "session" but are not the agent running this command. */
39
+ const NOT_AN_AGENT = /^(?:AWS|AZURE|GCP|GOOGLE|GITHUB|GITLAB|DOCKER|K8S|KUBE|DB|DATABASE|BROWSER|SSH|XDG|DBUS|SYSTEMD|CI|BUILD|TEST|SENTRY|DATADOG|NEW_RELIC|OTEL)_/;
40
+
41
+ /** Agent process ids, which tell a dead session from a quiet one. Only trusted when named. */
42
+ const PID_VARS = ["ROGER_ROGER_AGENT_PID", "CLAUDE_PID", "CODEX_PID", "OPENCODE_PID", "AIDER_PID", "GOOSE_PID"];
43
+
44
+ /** Env prefixes that give the agent's name, for the label on Slack messages. */
45
+ const AGENT_PREFIXES = {
46
+ CLAUDECODE: "claude", CLAUDE_CODE: "claude", CLAUDE: "claude",
47
+ CODEX: "codex", OPENCODE: "opencode", CURSOR: "cursor", AIDER: "aider",
48
+ GEMINI_CLI: "gemini", GOOSE: "goose", AMP: "amp", COPILOT: "copilot",
49
+ CLINE: "cline", CONTINUE: "continue", WINDSURF: "windsurf", CRUSH: "crush", DROID: "droid",
50
+ };
51
+
52
+ const AGENT_NAMES = { "claude-code": "claude" };
53
+
54
+ /**
55
+ * The agent's name from the environment: AI_AGENT if it is set (`claude-code_2-1-273_agent` →
56
+ * `claude`), otherwise whichever product's variables are present. Empty when nothing says.
57
+ */
58
+ export function detectAgent(env = process.env) {
59
+ const declared = str(env.AI_AGENT).split(/[_\s/@]/)[0].toLowerCase();
60
+ if (declared) return AGENT_NAMES[declared] ?? declared;
61
+ const keys = Object.keys(env);
62
+ for (const [prefix, name] of Object.entries(AGENT_PREFIXES)) {
63
+ if (keys.some((k) => k === prefix || k.startsWith(`${prefix}_`))) return name;
64
+ }
65
+ return "";
66
+ }
67
+
68
+ /** A session id the agent published, from a name we know or from the shape of the variable. */
69
+ export function publishedSessionId(env = process.env) {
70
+ for (const name of SESSION_VARS) if (str(env[name])) return str(env[name]);
71
+ for (const key of Object.keys(env).sort()) {
72
+ if (SESSION_VAR.test(key) && !NOT_AN_AGENT.test(key) && str(env[key])) return str(env[key]);
73
+ }
74
+ return "";
75
+ }
76
+
77
+ /** The agent's own process, when it says which. 0 when we can't know, which is not an error. */
78
+ export function agentPid(env = process.env, about = {}) {
79
+ const candidates = [about.agentPid, ...PID_VARS.map((name) => env[name])];
80
+ for (const value of candidates) {
81
+ const pid = Number(value);
82
+ if (Number.isInteger(pid) && pid > 0 && pid !== process.pid) return pid;
83
+ }
84
+ return 0;
85
+ }
86
+
87
+ /**
88
+ * A stand-in id for agents that publish nothing: the terminal and directory. Deliberately free of
89
+ * anything per-command — an agent that runs each tool call in a fresh shell must still look like one
90
+ * session, so the parent process is no use here.
91
+ */
92
+ export function terminalFingerprint(env = process.env, cwd = process.cwd()) {
93
+ const parts = [
94
+ cwd,
95
+ env.WT_SESSION, env.TERM_SESSION_ID, env.ITERM_SESSION_ID, env.TMUX_PANE, env.STY,
96
+ env.SSH_TTY, env.WINDOWID, env.KONSOLE_DBUS_SESSION, env.VSCODE_PID, env.HERDR_PANE_ID,
97
+ env.SESSIONNAME, env.TERM_PROGRAM, env.TERM,
98
+ os.userInfo?.().username,
99
+ ];
100
+ return `term-${hash(parts.filter(Boolean).join("|"))}`;
101
+ }
102
+
103
+ /** True when the agent says the user is watching; unknown counts as not watching. */
104
+ function isAttended(env) {
105
+ const key = Object.keys(env).find((k) => k.endsWith("_SESSION_ATTENDED"));
106
+ return key ? ["1", "true", "yes"].includes(String(env[key]).toLowerCase()) : false;
107
+ }
108
+
109
+ /**
110
+ * Who is calling. `about` is what this command was told: `sessionId` (--session-id), `session` (the
111
+ * --session name) and `agentPid` (--agent-pid). `source` records which layer answered, which
112
+ * `status` shows so an unfamiliar agent can be diagnosed.
113
+ */
114
+ export function identity(env = process.env, cwd = process.cwd(), about = {}) {
115
+ const explicit = str(about.sessionId) || str(env.ROGER_ROGER_SESSION_ID);
116
+ const published = explicit || publishedSessionId(env);
117
+ const named = str(about.session) ? `named-${hash(`${cwd}|${str(about.session).toLowerCase()}`)}` : "";
118
+ const fingerprint = terminalFingerprint(env, cwd);
119
+ return {
120
+ id: published || named || fingerprint,
121
+ source: published ? (explicit ? "explicit" : "agent") : named ? "name" : "terminal",
122
+ fingerprint,
123
+ pid: agentPid(env, about),
124
+ cwd,
125
+ project: path.basename(cwd) || "",
126
+ host: os.hostname(),
127
+ agent: detectAgent(env),
128
+ attended: isAttended(env),
129
+ // The Herdr pane this runs in, if any, so the daemon can label it (herdr.mjs).
130
+ herdr: herdrContext(env),
131
+ };
132
+ }
@@ -0,0 +1,392 @@
1
+ // Sound playback, local speech, and synthesized speech. Speech is split into prepare (network)
2
+ // and deliver (playback) so the audio can be ready before the notification sound plays.
3
+
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { spawn } from "node:child_process";
8
+ import { SOUNDS_DIR, duckFactor, rogerRogerHome, stripTags } from "./lib.mjs";
9
+ import { providerOf, synthesize } from "./tts.mjs";
10
+ import { withSpeaker } from "./speaker.mjs";
11
+
12
+ /** Run a command to completion; resolves true only on a clean exit. Never rejects. */
13
+ function run(cmd, args, opts = {}) {
14
+ return new Promise((resolve) => {
15
+ let child;
16
+ try {
17
+ child = spawn(cmd, args, { stdio: "ignore", windowsHide: true, timeout: 120_000, ...opts });
18
+ } catch {
19
+ return resolve(false);
20
+ }
21
+ child.on("error", () => resolve(false));
22
+ child.on("exit", (code) => resolve(code === 0));
23
+ });
24
+ }
25
+
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;
29
+ return false;
30
+ }
31
+
32
+ export function soundFile(name) {
33
+ return path.join(SOUNDS_DIR, `${name}.wav`);
34
+ }
35
+
36
+ // Other apps' volume, turned down while the voice speaks and put back after. Windows keeps a volume
37
+ // per app ("audio session") on the default output device; each is scaled from where the user had it
38
+ // and restored to exactly that. The player's own session — it is the one speaking — is left alone.
39
+ // Compiled only the first time it is needed, since it adds a moment to the player's start.
40
+ const DUCKER_CS = `
41
+ using System; using System.Collections.Generic; using System.Runtime.InteropServices; using System.Threading;
42
+ [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] class RrEnumerator {}
43
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")]
44
+ interface IRrEnumerator { int NotImpl(); [PreserveSig] int GetDefaultAudioEndpoint(int flow, int role, out IRrDevice device); }
45
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("D666063F-1587-4E43-81F1-B948E807363F")]
46
+ interface IRrDevice { [PreserveSig] int Activate(ref Guid iid, int ctx, IntPtr p, [MarshalAs(UnmanagedType.IUnknown)] out object o); }
47
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("77AA99A0-1BD6-484F-8BC7-2C654C9A9B6F")]
48
+ interface IRrSessionManager2 { int NotImpl1(); int NotImpl2(); [PreserveSig] int GetSessionEnumerator(out IRrSessionEnum e); }
49
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("E2F5BB11-0570-40CA-ACDD-3AA01277DEE8")]
50
+ interface IRrSessionEnum { [PreserveSig] int GetCount(out int n); [PreserveSig] int GetSession(int i, out IRrSession s); }
51
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("bfb7ff88-7239-4fc9-8fa2-07c950be9c6d")]
52
+ interface IRrSession {
53
+ [PreserveSig] int GetState(out int s); [PreserveSig] int GetDisplayName(out IntPtr p); [PreserveSig] int SetDisplayName(IntPtr v, IntPtr c);
54
+ [PreserveSig] int GetIconPath(out IntPtr p); [PreserveSig] int SetIconPath(IntPtr v, IntPtr c); [PreserveSig] int GetGroupingParam(out Guid g);
55
+ [PreserveSig] int SetGroupingParam(IntPtr g, IntPtr c); [PreserveSig] int RegisterAudioSessionNotification(IntPtr n);
56
+ [PreserveSig] int UnregisterAudioSessionNotification(IntPtr n); [PreserveSig] int GetSessionIdentifier(out IntPtr p);
57
+ [PreserveSig] int GetSessionInstanceIdentifier([MarshalAs(UnmanagedType.LPWStr)] out string p); [PreserveSig] int GetProcessId(out uint pid);
58
+ }
59
+ [InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("87CE5498-68D6-44E5-9215-6DA47EF883D8")]
60
+ interface IRrVolume { [PreserveSig] int SetMasterVolume(float v, ref Guid ctx); [PreserveSig] int GetMasterVolume(out float v); }
61
+ public static class RrDucker {
62
+ static Dictionary<string, float> saved = new Dictionary<string, float>();
63
+ static List<KeyValuePair<string, IRrVolume>> Sessions(uint self) {
64
+ var list = new List<KeyValuePair<string, IRrVolume>>();
65
+ var en = (IRrEnumerator)(new RrEnumerator()); IRrDevice dev; en.GetDefaultAudioEndpoint(0, 1, out dev);
66
+ Guid iid = typeof(IRrSessionManager2).GUID; object o; dev.Activate(ref iid, 23, IntPtr.Zero, out o);
67
+ IRrSessionEnum e; ((IRrSessionManager2)o).GetSessionEnumerator(out e); int n; e.GetCount(out n);
68
+ for (int i = 0; i < n; i++) { IRrSession s; e.GetSession(i, out s); uint pid; s.GetProcessId(out pid);
69
+ if (pid == self) continue; string id; s.GetSessionInstanceIdentifier(out id); list.Add(new KeyValuePair<string, IRrVolume>(id, (IRrVolume)s)); }
70
+ return list;
71
+ }
72
+ // Down to level x their own volume in a few steps, so it sounds like a fade rather than a cut.
73
+ public static string Duck(float level, uint self) {
74
+ var all = Sessions(self); Guid g = Guid.Empty;
75
+ foreach (var kv in all) { float v; kv.Value.GetMasterVolume(out v); if (!saved.ContainsKey(kv.Key)) saved[kv.Key] = v; }
76
+ for (int step = 1; step <= 4; step++) {
77
+ foreach (var kv in all) { float from = saved[kv.Key]; kv.Value.SetMasterVolume(from * (1 - (1 - level) * step / 4f), ref g); }
78
+ Thread.Sleep(40);
79
+ }
80
+ var sb = new System.Text.StringBuilder(); foreach (var kv in saved) sb.Append(kv.Key).Append('\\u0001').Append(kv.Value.ToString(System.Globalization.CultureInfo.InvariantCulture)).Append('\\u0002');
81
+ return sb.ToString();
82
+ }
83
+ public static void Restore(uint self) {
84
+ var all = Sessions(self); Guid g = Guid.Empty;
85
+ for (int step = 1; step <= 4; step++) {
86
+ foreach (var kv in all) { float to; if (!saved.TryGetValue(kv.Key, out to)) continue; float now; kv.Value.GetMasterVolume(out now); kv.Value.SetMasterVolume(now + (to - now) * step / 4f, ref g); }
87
+ Thread.Sleep(40);
88
+ }
89
+ foreach (var kv in all) { float to; if (saved.TryGetValue(kv.Key, out to)) kv.Value.SetMasterVolume(to, ref g); }
90
+ saved.Clear();
91
+ }
92
+ // What a previous player saved before it died, so volumes it turned down don't stay down.
93
+ public static void Remember(string data) {
94
+ foreach (var pair in data.Split('\\u0002')) { var p = pair.Split('\\u0001'); if (p.Length == 2) saved[p[0]] = float.Parse(p[1], System.Globalization.CultureInfo.InvariantCulture); }
95
+ }
96
+ }`;
97
+
98
+ // On Windows every playback goes through one long-lived PowerShell. Starting PowerShell
99
+ // takes 1.5-8 seconds, which is the whole gap between a ding and the speech after it if
100
+ // each gets its own process. Commands are read one per line from stdin. Every argument is
101
+ // base64 of UTF-8: Windows PowerShell reads stdin in the OEM code page, so a raw path or text
102
+ // with anything beyond ASCII in it (a user name with an accent, say) arrived mangled.
103
+ const PS_PLAYER = [
104
+ "Add-Type -AssemblyName System.Speech; $s = $null;",
105
+ "function Arg($b) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b)) }",
106
+ "while ($null -ne ($l = [Console]::In.ReadLine())) {",
107
+ " try {",
108
+ " $p = $l.Split([char]9, 2);",
109
+ " if ($p[0] -eq 'PLAY') { (New-Object System.Media.SoundPlayer (Arg $p[1])).PlaySync() }",
110
+ " elseif ($p[0] -eq 'DUCKINIT') { if (-not ('RrDucker' -as [type])) { Add-Type -TypeDefinition (Arg $p[1]) } }",
111
+ " elseif ($p[0] -eq 'DUCK') { $d = [RrDucker]::Duck([float](Arg $p[1]), [uint32]$PID); [Console]::Out.WriteLine('DATA ' + [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($d))) }",
112
+ " elseif ($p[0] -eq 'UNDUCK') { [RrDucker]::Restore([uint32]$PID) }",
113
+ " elseif ($p[0] -eq 'REMEMBER') { [RrDucker]::Remember((Arg $p[1])) }",
114
+ " elseif ($p[0] -eq 'SAY') {",
115
+ " if (-not $s) { $s = New-Object System.Speech.Synthesis.SpeechSynthesizer }",
116
+ " $s.Speak((Arg $p[1]))",
117
+ " }",
118
+ " [Console]::Out.WriteLine('OK')",
119
+ " } catch { [Console]::Out.WriteLine('ERR ' + $_.Exception.Message) }",
120
+ " [Console]::Out.Flush()",
121
+ "}",
122
+ ].join(" ");
123
+
124
+ let session = null;
125
+
126
+ // Longer than any sound or spoken line, so only a player that has wedged ever reaches it.
127
+ const REQUEST_TIMEOUT_MS = 120_000;
128
+
129
+ const b64 = (text) => Buffer.from(text, "utf8").toString("base64");
130
+
131
+ function windowsSession() {
132
+ if (session) return session;
133
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_PLAYER], {
134
+ stdio: ["pipe", "pipe", "ignore"],
135
+ windowsHide: true,
136
+ });
137
+ const waiters = [];
138
+ let buffer = "";
139
+ let pendingData = null;
140
+ let dead = null;
141
+ // A dead player is forgotten, so the next request starts a new one; otherwise every sound after
142
+ // the first failure failed too, until the daemon was restarted.
143
+ const fail = (why) => {
144
+ dead ??= why;
145
+ if (session === self) session = null;
146
+ while (waiters.length) waiters.shift()(`ERR ${dead}`);
147
+ };
148
+ child.stdout.setEncoding("utf8");
149
+ child.stdout.on("data", (chunk) => {
150
+ buffer += chunk;
151
+ let nl;
152
+ while ((nl = buffer.indexOf("\n")) !== -1) {
153
+ const line = buffer.slice(0, nl).trim();
154
+ buffer = buffer.slice(nl + 1);
155
+ if (!line) continue;
156
+ // DUCK hands back what it saved on a line of its own, before its OK. The two are one answer.
157
+ if (line.startsWith("DATA ")) {
158
+ pendingData = line;
159
+ continue;
160
+ }
161
+ const answer = line === "OK" && pendingData ? pendingData : line;
162
+ pendingData = null;
163
+ waiters.shift()?.(answer);
164
+ }
165
+ });
166
+ child.stdin.on("error", () => fail("audio player is not running"));
167
+ child.on("error", (e) => fail(`powershell unavailable: ${e.message}`));
168
+ child.on("exit", () => fail("audio player exited"));
169
+
170
+ let queue = Promise.resolve();
171
+ const self = {
172
+ request(command) {
173
+ const reply = queue.then(() => new Promise((resolve) => {
174
+ if (dead) return resolve(`ERR ${dead}`);
175
+ // A player that stops answering would hold up every request queued behind it for good.
176
+ // It is killed and forgotten instead; the queue moves on, and the next request starts afresh.
177
+ const timer = setTimeout(() => {
178
+ fail("audio player stopped responding");
179
+ child.kill();
180
+ }, REQUEST_TIMEOUT_MS);
181
+ waiters.push((line) => {
182
+ clearTimeout(timer);
183
+ resolve(line);
184
+ });
185
+ child.stdin.write(`${command}\n`);
186
+ }));
187
+ queue = reply;
188
+ return reply.then((line) => {
189
+ if (line.startsWith("DATA ")) return Buffer.from(line.slice(5), "base64").toString("utf8");
190
+ if (line !== "OK") throw new Error(line.replace(/^ERR\s*/, "") || "playback failed");
191
+ return "";
192
+ });
193
+ },
194
+ close() {
195
+ if (session === self) session = null;
196
+ child.stdin.end();
197
+ },
198
+ };
199
+ session = self;
200
+ return self;
201
+ }
202
+
203
+ /** Start the audio backend ahead of time (Windows only; elsewhere players start fast). */
204
+ export function warmUpAudio() {
205
+ if (process.platform === "win32") windowsSession();
206
+ }
207
+
208
+ /** Let the process exit: ends the Windows player once queued playback has finished. */
209
+ export function closeAudio() {
210
+ session?.close();
211
+ }
212
+
213
+ export async function playWav(file) {
214
+ if (!fs.existsSync(file)) throw new Error(`no such sound file: ${file}`);
215
+ let ok;
216
+ if (process.platform === "win32") {
217
+ return windowsSession().request(`PLAY\t${b64(file)}`);
218
+ } else if (process.platform === "darwin") {
219
+ ok = await run("afplay", [file]);
220
+ } else {
221
+ ok = await firstThatWorks([
222
+ ["paplay", [file]],
223
+ ["pw-play", [file]],
224
+ ["aplay", ["-q", file]],
225
+ ["ffplay", ["-nodisp", "-autoexit", "-loglevel", "quiet", file]],
226
+ ]);
227
+ }
228
+ if (!ok) throw new Error("no working audio player found (tried the platform defaults)");
229
+ }
230
+
231
+ let tempCounter = 0;
232
+ export async function playWavBuffer(wav) {
233
+ const file = path.join(os.tmpdir(), `roger-roger-${process.pid}-${Date.now()}-${tempCounter++}.wav`);
234
+ fs.writeFileSync(file, wav);
235
+ try {
236
+ await playWav(file);
237
+ } finally {
238
+ fs.rmSync(file, { force: true });
239
+ }
240
+ }
241
+
242
+ export async function speakLocally(text) {
243
+ const plain = stripTags(text);
244
+ let ok;
245
+ if (process.platform === "win32") {
246
+ return windowsSession().request(`SAY\t${b64(plain)}`);
247
+ } else if (process.platform === "darwin") {
248
+ ok = await run("say", [plain]);
249
+ } else {
250
+ ok = await firstThatWorks([
251
+ ["spd-say", ["-w", plain]],
252
+ ["espeak-ng", [plain]],
253
+ ["espeak", [plain]],
254
+ ]);
255
+ }
256
+ if (!ok) throw new Error("no local speech synthesiser found");
257
+ }
258
+
259
+ // ---------------------------------------------------------------- other sounds, turned down
260
+
261
+ const duckFile = () => path.join(rogerRogerHome(), "ducked.txt");
262
+
263
+ /** The player's first duck: compile the helper, and put back anything a crashed player left down. */
264
+ async function duckPlayer() {
265
+ const player = windowsSession();
266
+ if (player.ducker) return player;
267
+ await player.request(`DUCKINIT\t${b64(DUCKER_CS)}`);
268
+ try {
269
+ const left = fs.readFileSync(duckFile(), "utf8");
270
+ if (left) {
271
+ await player.request(`REMEMBER\t${b64(left)}`);
272
+ await player.request("UNDUCK");
273
+ }
274
+ fs.rmSync(duckFile(), { force: true });
275
+ } catch {
276
+ // Nothing was left down.
277
+ }
278
+ player.ducker = true;
279
+ return player;
280
+ }
281
+
282
+ /**
283
+ * Turn everything else down for the length of `fn`, then back up. Windows only for now; elsewhere
284
+ * it just runs `fn`. What was turned down is written to a file first, so a player that dies in the
285
+ * middle doesn't leave the user's music quiet for good — the next one puts it back.
286
+ */
287
+ export async function withOthersQuieter(config, fn) {
288
+ const level = config ? duckFactor(config) : 1;
289
+ if (level >= 1 || process.platform !== "win32") return fn();
290
+ let player;
291
+ try {
292
+ player = await duckPlayer();
293
+ const saved = await player.request(`DUCK\t${b64(String(level))}`);
294
+ if (saved) fs.writeFileSync(duckFile(), saved, "utf8");
295
+ } catch {
296
+ // Not being able to turn other things down is no reason not to speak.
297
+ return fn();
298
+ }
299
+ try {
300
+ return await fn();
301
+ } finally {
302
+ await player.request("UNDUCK").catch(() => {});
303
+ fs.rmSync(duckFile(), { force: true });
304
+ }
305
+ }
306
+
307
+ /** Synthesize `text` with the configured provider and return it as a WAV buffer (nothing is played). */
308
+ export async function speechAudio(text, config) {
309
+ return synthesize(text, config);
310
+ }
311
+
312
+ /** Map with at most `limit` jobs in flight; results keep input order. */
313
+ export async function mapPool(items, limit, fn) {
314
+ const results = new Array(items.length);
315
+ let next = 0;
316
+ const worker = async () => {
317
+ while (next < items.length) {
318
+ const i = next++;
319
+ results[i] = await fn(items[i], i);
320
+ }
321
+ };
322
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
323
+ return results;
324
+ }
325
+
326
+ /** Fetch the audio for a line up front. Never rejects: failures come back as `{ error }`. */
327
+ export function prepareSpeech(text, config, { local = false } = {}) {
328
+ if (local) return Promise.resolve({ skipped: true });
329
+ return synthesize(text, config).then((wav) => ({ wav }), (e) => ({ error: e.message }));
330
+ }
331
+
332
+ /** 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 } = {}) {
334
+ const engine = providerOf(config).id;
335
+ const errors = prepared.error ? [`${engine}: ${prepared.error}`] : [];
336
+ if (prepared.wav) {
337
+ try {
338
+ await playWavBuffer(prepared.wav);
339
+ return { ok: true, engine, voice: config.voice };
340
+ } catch (e) {
341
+ errors.push(`playback: ${e.message}`);
342
+ }
343
+ }
344
+ if (config.localFallback || prepared.skipped) {
345
+ try {
346
+ await speakLocally(text);
347
+ return { ok: true, engine: "local", errors };
348
+ } catch (e) {
349
+ errors.push(`local: ${e.message}`);
350
+ }
351
+ }
352
+ if (dingIfSilent) {
353
+ try {
354
+ await playWav(soundFile(config.sound));
355
+ return { ok: true, engine: "sound", errors };
356
+ } catch (e) {
357
+ errors.push(`sound: ${e.message}`);
358
+ }
359
+ }
360
+ return { ok: false, errors };
361
+ }
362
+
363
+ /**
364
+ * The audible part of an alert. Speech is synthesized before the sound starts, so the
365
+ * voice follows the ding immediately instead of after a network round trip — and before
366
+ * the speaker is claimed, so a queued agent waits for playback, not for the network.
367
+ * The ding and the voice after it are one turn: another agent cannot cut between them.
368
+ */
369
+ export async function playAlert({ methods, config, text, prepared }) {
370
+ const results = {};
371
+ const wantsSpeech = methods.includes("speech");
372
+ if (wantsSpeech || methods.includes("sound")) warmUpAudio();
373
+ const speech = wantsSpeech ? await (prepared ?? prepareSpeech(text, config)) : null;
374
+ if (!wantsSpeech && !methods.includes("sound")) return results;
375
+
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 () => {
378
+ if (methods.includes("sound")) {
379
+ try {
380
+ await playWav(soundFile(config.sound));
381
+ results.sound = { ok: true, sound: config.sound };
382
+ } catch (e) {
383
+ results.sound = { ok: false, error: e.message };
384
+ }
385
+ }
386
+ if (wantsSpeech) {
387
+ results.speech = await deliverSpeech(speech, text, config, { dingIfSilent: !methods.includes("sound") });
388
+ }
389
+ if (turn.waitedMs > 0) results.queued = { waitedMs: turn.waitedMs, ...(turn.tookOver ? { tookOver: true } : {}) };
390
+ return results;
391
+ }));
392
+ }
@@ -0,0 +1,121 @@
1
+ // The CLI's side of the socket. One command, one connection: connect, ask, print, exit.
2
+ //
3
+ // If nothing is listening, the daemon is started and we try again. Several agents racing to start it
4
+ // is fine — only one can bind the socket, and the losers connect to the winner.
5
+
6
+ import net from "node:net";
7
+ import path from "node:path";
8
+ import { spawn } from "node:child_process";
9
+ import { fileURLToPath } from "node:url";
10
+ import os from "node:os";
11
+ import { machineEnv } from "./lib.mjs";
12
+ import { lineReader, send, socketPath } from "./protocol.mjs";
13
+
14
+ const DAEMON = path.join(path.dirname(fileURLToPath(import.meta.url)), "daemon.mjs");
15
+ const START_TIMEOUT_MS = 15_000;
16
+ const RETRY_MS = 120;
17
+
18
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
+
20
+ function tryConnect(target) {
21
+ return new Promise((resolve, reject) => {
22
+ const socket = net.createConnection(target);
23
+ const fail = (e) => {
24
+ socket.destroy();
25
+ reject(e);
26
+ };
27
+ socket.once("error", fail);
28
+ socket.once("connect", () => {
29
+ socket.removeListener("error", fail);
30
+ resolve(socket);
31
+ });
32
+ });
33
+ }
34
+
35
+ /**
36
+ * Start the daemon in the background. It detaches, so it outlives this command — which is why it
37
+ * starts in the user's home directory rather than the caller's project (a process sitting in a
38
+ * folder stops Windows deleting or renaming it, and the home directory is the one folder nobody
39
+ * does that to), and without the caller's session variables (it serves every agent, so it must not
40
+ * look like this one; the tray it starts inherits whatever it has).
41
+ */
42
+ export function startDaemon(env = process.env) {
43
+ const child = spawn(process.execPath, [DAEMON], {
44
+ cwd: os.homedir(),
45
+ detached: true,
46
+ stdio: "ignore",
47
+ windowsHide: true,
48
+ env: machineEnv(env),
49
+ });
50
+ child.unref();
51
+ }
52
+
53
+ /**
54
+ * A connection to the daemon, starting it if it isn't running yet. Throws only when it can't be
55
+ * reached at all, which is the CLI's signal to fall back to doing the work in-process.
56
+ */
57
+ export async function connect({ start = true, timeoutMs = START_TIMEOUT_MS, env = process.env } = {}) {
58
+ const target = socketPath(env);
59
+ try {
60
+ return await tryConnect(target);
61
+ } catch (e) {
62
+ if (!start || env.ROGER_ROGER_NO_DAEMON === "1") throw e;
63
+ }
64
+ startDaemon(env);
65
+ const deadline = Date.now() + timeoutMs;
66
+ let last;
67
+ while (Date.now() < deadline) {
68
+ await sleep(RETRY_MS);
69
+ try {
70
+ return await tryConnect(target);
71
+ } catch (e) {
72
+ last = e;
73
+ }
74
+ }
75
+ throw new Error(`the roger-roger daemon did not start within ${Math.round(timeoutMs / 1000)}s (${last?.code ?? last?.message ?? "no reason given"})`);
76
+ }
77
+
78
+ /** Send one request on an open socket. Events arrive first; the reply resolves the promise. */
79
+ export function request(socket, { cmd, args = {}, session = null, onEvent }) {
80
+ return new Promise((resolve, reject) => {
81
+ const n = 1;
82
+ let settled = false;
83
+ // Set once the daemon says it has the command. After that a lost connection is an unknown
84
+ // outcome, not a failure to deliver, so the caller must not quietly do it a second time.
85
+ let acknowledged = false;
86
+ // Set once the command is only waiting, with the time it would have waited until.
87
+ let parkedUntil = null;
88
+ const done = (fn, value) => {
89
+ if (settled) return;
90
+ settled = true;
91
+ fn(value);
92
+ };
93
+ socket.setEncoding("utf8");
94
+ socket.on("data", lineReader((message) => {
95
+ if (message.n !== n) return;
96
+ if (message.event) {
97
+ if (message.event === "received") acknowledged = true;
98
+ if (message.event === "parked") parkedUntil = message.data?.until ?? null;
99
+ return onEvent?.(message.event, message.data);
100
+ }
101
+ if (message.ok) return done(resolve, { data: message.data, code: message.code ?? 0 });
102
+ const error = new Error(message.error ?? "the daemon refused the request");
103
+ error.code = message.code ?? 1;
104
+ error.data = message.data;
105
+ done(reject, error);
106
+ }));
107
+ socket.on("error", (e) => done(reject, Object.assign(e, { acknowledged, parkedUntil })));
108
+ socket.on("close", () => done(reject, Object.assign(new Error("the daemon closed the connection"), { acknowledged, parkedUntil })));
109
+ if (!send(socket, { n, cmd, args, session })) done(reject, new Error("could not write to the daemon"));
110
+ });
111
+ }
112
+
113
+ /** Connect, ask one thing, disconnect. */
114
+ export async function call(cmd, args = {}, { session = null, onEvent, env = process.env } = {}) {
115
+ const socket = await connect({ env });
116
+ try {
117
+ return await request(socket, { cmd, args, session, onEvent });
118
+ } finally {
119
+ socket.end();
120
+ }
121
+ }