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,560 @@
1
+ // `roger-roger install`: everything between "npm install" and the first notification, asked in the
2
+ // terminal rather than through an agent. It links the skill into every agent's skills folder, tells
3
+ // Herdr about it, connects Slack, lets the user hear the sounds and voices before choosing, installs
4
+ // the terminal-question hooks and the tray, and ends with a test notification.
5
+ //
6
+ // Each step is safe to repeat: run it again to change anything, and a step that is already done
7
+ // says so and moves on. Without a terminal (or with --yes) only the parts that need no answer run:
8
+ // the links, and the Herdr plugin.
9
+
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+ import { SKILL_DIR, VOICES, applySetup, configPath, listSounds, loadConfig, saveConfig, voiceSample } from "./lib.mjs";
15
+ import { PROVIDERS, SPEECH_SETUP, catalog, keyFor } from "./tts.mjs";
16
+ import { saveKey } from "./speechkey.mjs";
17
+ import { createApp, loginFinish, loginStart, setupStatus } from "./slackapp.mjs";
18
+ import { installCli } from "./slackcli.mjs";
19
+ import { call } from "./client.mjs";
20
+ import { identity } from "./agent.mjs";
21
+ import { Cancelled, createUI } from "./tui.mjs";
22
+
23
+ export const VERSION = (() => {
24
+ try {
25
+ return JSON.parse(fs.readFileSync(path.join(SKILL_DIR, "..", "..", "package.json"), "utf8")).version ?? "";
26
+ } catch {
27
+ return "";
28
+ }
29
+ })();
30
+
31
+ // ---------------------------------------------------------------- where skills live
32
+
33
+ /** The agents that read skills from a folder in the home directory, and where. */
34
+ export const SKILL_HOMES = [
35
+ { id: "claude", label: "Claude Code", dir: [".claude"] },
36
+ { id: "codex", label: "Codex", dir: [".codex"] },
37
+ { id: "opencode", label: "OpenCode", dir: [".config", "opencode"] },
38
+ { id: "cursor", label: "Cursor", dir: [".cursor"] },
39
+ { id: "gemini", label: "Gemini CLI", dir: [".gemini"] },
40
+ { id: "copilot", label: "GitHub Copilot CLI", dir: [".copilot"] },
41
+ { id: "agents", label: "Shared skills folder", dir: [".agents"], always: true, hint: "~/.agents/skills, where `npx skills add` puts skills" },
42
+ ];
43
+
44
+ /** Where each agent would find the skill, and which agents are on this machine. */
45
+ export function skillLinks(home = os.homedir(), fsx = fs) {
46
+ return SKILL_HOMES.map((a) => {
47
+ const root = path.join(home, ...a.dir);
48
+ return {
49
+ ...a,
50
+ root,
51
+ link: path.join(root, "skills", "roger-roger"),
52
+ present: a.always || fsx.existsSync(root),
53
+ };
54
+ });
55
+ }
56
+
57
+ /** One shape for a path, so two spellings of one place compare equal (macOS's /var is /private/var). */
58
+ const norm = (p, fsx = fs) => {
59
+ let r = path.resolve(p).replace(/[\\/]+$/, "");
60
+ try {
61
+ r = fsx.realpathSync(r);
62
+ } catch {}
63
+ return process.platform === "win32" ? r.toLowerCase() : r;
64
+ };
65
+
66
+ /**
67
+ * What to do so that `link` leads to `target`: nothing, create it, point an old link elsewhere at
68
+ * it, replace a real folder (a copy from `npx skills add`), or leave a file alone.
69
+ */
70
+ export function planLink(link, target, fsx = fs) {
71
+ let st;
72
+ try {
73
+ st = fsx.lstatSync(link);
74
+ } catch {
75
+ return { action: "create" };
76
+ }
77
+ if (st.isSymbolicLink()) {
78
+ let to = "";
79
+ try {
80
+ to = fsx.readlinkSync(link);
81
+ } catch {}
82
+ const resolved = path.resolve(path.dirname(link), to);
83
+ return norm(resolved, fsx) === norm(target, fsx) ? { action: "none", note: "already linked" } : { action: "relink", current: resolved };
84
+ }
85
+ if (st.isDirectory()) {
86
+ return norm(link, fsx) === norm(target, fsx) ? { action: "none", note: "this is the copy itself" } : { action: "replace", current: link };
87
+ }
88
+ return { action: "conflict", current: link };
89
+ }
90
+
91
+ /** Carry out a plan. A `replace` moves the folder aside rather than deleting it. */
92
+ export function applyLink(link, target, plan, fsx = fs, now = new Date()) {
93
+ if (plan.action === "none" || plan.action === "conflict") return plan;
94
+ if (plan.action === "relink") fsx.unlinkSync(link);
95
+ if (plan.action === "replace") {
96
+ const stamp = now.toISOString().slice(0, 16).replace(/[-:T]/g, "");
97
+ const aside = `${link}.replaced-${stamp}`;
98
+ fsx.renameSync(link, aside);
99
+ plan = { ...plan, movedTo: aside };
100
+ }
101
+ fsx.mkdirSync(path.dirname(link), { recursive: true });
102
+ fsx.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
103
+ return { ...plan, done: true };
104
+ }
105
+
106
+ // ---------------------------------------------------------------- herdr
107
+
108
+ /** The `herdr` binary, from the pane's environment or PATH: `{ command, version }`, or null. */
109
+ export function findHerdr(env = process.env) {
110
+ const candidates = [env.HERDR_BIN_PATH, "herdr"].filter(Boolean);
111
+ for (const command of candidates) {
112
+ try {
113
+ const r = spawnSync(command, ["--version"], { encoding: "utf8", windowsHide: true, timeout: 10_000, env });
114
+ const m = /herdr\s+v?(\d+\.\d+\.\d+)/.exec(`${r.stdout}${r.stderr}`);
115
+ if (r.status === 0) return { command, version: m?.[1] ?? "" };
116
+ } catch {}
117
+ }
118
+ return null;
119
+ }
120
+
121
+ /** Register the skill folder as a Herdr plugin (its manifest is `herdr-plugin.toml` in there). */
122
+ export function herdrLink(herdr, dir = SKILL_DIR) {
123
+ const r = spawnSync(herdr.command, ["plugin", "link", dir, "--enabled"], { encoding: "utf8", windowsHide: true, timeout: 20_000 });
124
+ if (r.status === 0) return { ok: true, output: r.stdout.trim() };
125
+ const text = `${r.stderr}${r.stdout}`.trim();
126
+ let message = text;
127
+ try {
128
+ message = JSON.parse(text)?.error?.message ?? text;
129
+ } catch {}
130
+ if (/already/i.test(message)) return { ok: true, note: message };
131
+ return { ok: false, error: message || `herdr plugin link exited with ${r.status ?? r.error?.message}` };
132
+ }
133
+
134
+ // ---------------------------------------------------------------- the quiet part
135
+
136
+ /**
137
+ * The steps that need no answers: link the skill for every agent present, and into Herdr if it is
138
+ * here. `replaceDirs` says whether a folder that is a copy of the skill may be moved aside.
139
+ */
140
+ export function quietInstall({ home = os.homedir(), env = process.env, target = SKILL_DIR, agents = null, replaceDirs = false } = {}) {
141
+ const links = skillLinks(home)
142
+ .filter((a) => a.present && (!agents || agents.includes(a.id)))
143
+ .map((a) => {
144
+ const plan = planLink(a.link, target);
145
+ if (plan.action === "replace" && !replaceDirs) return { agent: a.id, label: a.label, link: a.link, ...plan, skipped: "a folder is there already; run `roger-roger install` in a terminal to replace it" };
146
+ try {
147
+ return { agent: a.id, label: a.label, link: a.link, ...applyLink(a.link, target, plan) };
148
+ } catch (e) {
149
+ return { agent: a.id, label: a.label, link: a.link, ...plan, error: e.message };
150
+ }
151
+ });
152
+ const herdr = findHerdr(env);
153
+ return {
154
+ target,
155
+ links,
156
+ herdr: herdr ? { ...herdr, ...herdrLink(herdr, target) } : null,
157
+ };
158
+ }
159
+
160
+ // ---------------------------------------------------------------- the conversation
161
+
162
+ const SOUND_HINTS = {
163
+ chime: "two rising bell notes",
164
+ ding: "a single bell",
165
+ marimba: "soft and low",
166
+ bubble: "two quick pops",
167
+ alert: "three insistent beeps",
168
+ };
169
+
170
+ const PROVIDER_HINTS = {
171
+ gemini: "Google · 30 expressive voices, reads tone tags",
172
+ openai: "gpt-4o-mini-tts and friends",
173
+ elevenlabs: "your own voice library",
174
+ };
175
+
176
+ /** Ask the daemon for something, the way the CLI would. */
177
+ const daemon = (cmd, args = {}, session = null) => call(cmd, args, { session });
178
+
179
+ /** One retry loop for a step that talks to the network. */
180
+ async function attempt(ui, label, fn, { skipLabel = `Skip ${label} for now` } = {}) {
181
+ for (;;) {
182
+ try {
183
+ return await fn();
184
+ } catch (e) {
185
+ if (e instanceof Cancelled) throw e;
186
+ ui.failed(`${label} didn't work`, e.message);
187
+ const next = await ui.select({
188
+ message: "What now?",
189
+ choices: [
190
+ { value: "retry", label: "Try again" },
191
+ { value: "skip", label: skipLabel, hint: "run `roger-roger install` again later" },
192
+ ],
193
+ });
194
+ if (next === "skip") return null;
195
+ }
196
+ }
197
+ }
198
+
199
+ /** Slack: install the CLI, log in, create the app. Returns the user's member ID, or null if skipped. */
200
+ async function connectSlack(ui) {
201
+ for (;;) {
202
+ const status = await attempt(ui, "Checking Slack", () => ui.run("Checking the Slack connection", () => setupStatus(), { done: () => "Checked Slack" }));
203
+ if (!status) return null;
204
+ if (status.next === "done") {
205
+ const account = status.logins.find((l) => l.teamId === status.app?.teamId) ?? status.logins[0];
206
+ ui.done("Slack is connected", account ? `${account.team}, through the roger-roger app` : "");
207
+ return { userId: account?.userId ?? "", team: account?.team ?? "" };
208
+ }
209
+ if (status.next === "install-cli") {
210
+ const yes = await ui.confirm({ message: "Install the Slack CLI?", hint: "Slack's official tool, installed for your user only" });
211
+ if (!yes) return null;
212
+ const cli = await attempt(ui, "Installing the Slack CLI", () => ui.run("Downloading the Slack CLI", () => installCli(), { done: (c) => `Installed the Slack CLI ${c.version}` }));
213
+ if (!cli) return null;
214
+ continue;
215
+ }
216
+ if (status.next === "login") {
217
+ const ticket = await attempt(ui, "Logging in", () => ui.run("Asking Slack for a login ticket", () => loginStart(), { done: () => "Got a login ticket" }));
218
+ if (!ticket) return null;
219
+ ui.note("Log the Slack CLI in", [
220
+ "1. Paste this into any message box in Slack, in the workspace you want:",
221
+ "",
222
+ ` ${ticket.command}`,
223
+ "",
224
+ "2. Approve the dialog. Slack then shows you a challenge code.",
225
+ "3. Type or paste that code below.",
226
+ ]);
227
+ const code = await ui.text({ message: "Challenge code", placeholder: "the code Slack showed you", validate: (v) => (v ? "" : "paste the code, or press Esc to stop") });
228
+ const done = await attempt(ui, "Logging in", () => ui.run("Finishing the login", () => loginFinish({ challenge: code, ticket: ticket.ticket }), { done: (a) => `Logged in to ${a.map((x) => x.team).join(", ")}` }));
229
+ if (!done) return null;
230
+ continue;
231
+ }
232
+ if (status.next === "create") {
233
+ let teamId;
234
+ if (status.logins.length > 1) {
235
+ teamId = await ui.select({ message: "Which workspace?", choices: status.logins.map((l) => ({ value: l.teamId, label: l.team, hint: l.teamId })) });
236
+ }
237
+ const app = await attempt(ui, "Creating the Slack app", () => ui.run("Creating the roger-roger app in your workspace", () => createApp({ teamId }), { done: (a) => ({ text: `Created the roger-roger app in ${a.team}`, detail: "its tokens are in ~/.roger-roger/slack.json" }) }));
238
+ if (!app) return null;
239
+ return { userId: app.userId, team: app.team };
240
+ }
241
+ throw new Error(`unexpected Slack setup state "${status.next}"`);
242
+ }
243
+ }
244
+
245
+ /** Where Slack messages go: the user's DMs, the target they had, or a channel. */
246
+ async function slackTarget(ui, { config, userId }) {
247
+ const current = config?.slack?.target ?? "";
248
+ const choices = [];
249
+ if (userId) choices.push({ value: "dm", label: "Direct messages", hint: `from the roger-roger bot, to you (${userId})` });
250
+ if (current && current !== userId) choices.push({ value: "keep", label: `Keep ${current}`, hint: "what it is set to now" });
251
+ choices.push({ value: "channel", label: "A channel", hint: "invite the bot to it first" });
252
+ const pick = choices.length === 1 ? choices[0].value : await ui.select({ message: "Where should messages go?", choices, initial: current && current !== userId ? "keep" : "dm" });
253
+ if (pick === "dm") return { target: userId, mention: "" };
254
+ if (pick === "keep") return { target: current, mention: config?.slack?.mention ?? "" };
255
+ const target = await ui.text({ message: "Which channel?", placeholder: "C0123ABCD or #channel-name", validate: (v) => (/^(C[A-Z0-9]+|#[\w-]+)$/i.test(v) ? "" : "a channel ID (C…) or a #name") });
256
+ const mention = await ui.text({
257
+ message: "Mention you in channel posts?",
258
+ placeholder: "your member ID (U0123ABCD), or leave empty",
259
+ initial: userId,
260
+ validate: (v) => (!v || /^U[A-Z0-9]+$/i.test(v) ? "" : "a member ID starts with U"),
261
+ });
262
+ return { target, mention };
263
+ }
264
+
265
+ /** The voice: provider, key, voice, and how much to say. */
266
+ async function chooseSpeech(ui, { config, persist }) {
267
+ const provider = await ui.select({
268
+ message: "Who should make the voice?",
269
+ initial: config?.speechProvider ?? "gemini",
270
+ choices: Object.values(PROVIDERS).map((p) => ({ value: p.id, label: p.label, hint: PROVIDER_HINTS[p.id] ?? "" })),
271
+ });
272
+ const p = PROVIDERS[provider];
273
+ // Saved now so the previews below speak with this provider, not the one configured before.
274
+ await persist({ "speech-provider": provider });
275
+ let key = keyFor({ speechProvider: provider, speechKeyVar: config?.speechProvider === provider ? config?.speechKeyVar ?? "" : "" });
276
+ if (key.value) {
277
+ ui.done(`${p.label} key found`, `in ${key.name}`);
278
+ } else {
279
+ const pasted = await ui.text({
280
+ message: `${p.label} API key`,
281
+ mask: true,
282
+ placeholder: "paste it, or leave empty to use the computer's own voice for now",
283
+ });
284
+ if (pasted) {
285
+ const file = saveKey(p.keyVars[0], pasted);
286
+ key = { name: p.keyVars[0], value: pasted };
287
+ ui.done("Key saved", `${file}; setting ${p.keyVars[0]} in your environment works too`);
288
+ } else {
289
+ ui.done("No key", "the computer's own voice will speak until a key is set");
290
+ }
291
+ }
292
+ const hasKey = Boolean(key.value);
293
+
294
+ const all = catalog(provider).voices;
295
+ const choice = (v) => ({
296
+ value: v.id,
297
+ label: v.name ?? v.id,
298
+ hint: v.description ?? VOICES[v.id] ?? "",
299
+ preview: hasKey ? () => daemon("say", { _: [voiceSample(v.name ?? v.id)], voice: v.id, test: true }) : undefined,
300
+ previewing: `${v.name ?? v.id} is introducing itself…`,
301
+ });
302
+ const shortlist = (p.previewVoices ?? []).map((id) => all.find((v) => v.id === id)).filter(Boolean);
303
+ const current = config?.speechProvider === provider ? config?.voice : p.defaultVoice;
304
+ let voice = null;
305
+ if (shortlist.length && shortlist.length < all.length) {
306
+ voice = await ui.select({
307
+ message: "Which voice?",
308
+ initial: current,
309
+ choices: [...shortlist.map(choice), { value: null, label: "More voices…", hint: `all ${all.length}` }],
310
+ previewHint: hasKey ? "space to hear it" : "no key yet, so no previews",
311
+ });
312
+ }
313
+ if (voice === null) {
314
+ voice = await ui.select({ message: "Which voice?", initial: current, choices: all.map(choice), previewHint: hasKey ? "space to hear it" : "no key yet, so no previews" });
315
+ }
316
+ const speech = await ui.select({
317
+ message: "How much should be said out loud?",
318
+ initial: config?.speech ?? "auto",
319
+ choices: [
320
+ { value: "auto", label: "Let the agent decide", hint: "a short line out loud, the detail in Slack" },
321
+ { value: "brief", label: "One short sentence", hint: "always" },
322
+ { value: "same", label: "The whole message", hint: "exactly what Slack gets" },
323
+ ],
324
+ });
325
+ return { provider, voice, speech, hasKey };
326
+ }
327
+
328
+ /** The whole conversation. Returns the process exit code. */
329
+ export async function runInstall(args, { script, hooks, input = process.stdin, output = process.stdout, env = process.env, home = os.homedir() } = {}) {
330
+ const ui = createUI({ input, output, env });
331
+ const c = ui.style;
332
+ const major = Number(process.versions.node.split(".")[0]);
333
+
334
+ if (args.yes || !ui.interactive) {
335
+ const result = quietInstall({ home, env });
336
+ output.write(JSON.stringify({ ok: true, ...result, ...(ui.interactive ? {} : { note: "no terminal, so nothing was asked: only the links were made" }) }, null, 2) + "\n");
337
+ return 0;
338
+ }
339
+
340
+ const summary = [];
341
+ try {
342
+ ui.intro(`roger-roger ${VERSION ? c.dim(VERSION) : ""}`, "Brings you back to the conversation when you have walked away. A few questions, and your agents can reach you.");
343
+
344
+ if (major < 18) {
345
+ ui.failed(`Node ${process.versions.node} is too old`, "roger-roger needs Node 18 or newer (22 for Slack questions)");
346
+ return 1;
347
+ }
348
+
349
+ // ---- looking around
350
+ const config = loadConfig();
351
+ const agents = skillLinks(home).filter((a) => a.present);
352
+ const herdr = findHerdr(env);
353
+ ui.done("Looking around", [
354
+ `Node ${process.versions.node}${major < 22 ? " (22 or newer is needed for Slack questions)" : ""}`,
355
+ `Agents: ${agents.filter((a) => !a.always).map((a) => a.label).join(", ") || "no agent folders found yet"}`,
356
+ herdr ? `Herdr ${herdr.version}` : "",
357
+ config ? "Settings from an earlier setup are the defaults below" : "First setup",
358
+ ]);
359
+
360
+ // ---- the skill, where agents look for it
361
+ const wanted = await ui.multiselect({
362
+ message: "Which agents should have the skill?",
363
+ choices: agents.map((a) => ({ value: a.id, label: a.label, hint: a.hint ?? `~/${a.dir.join("/")}/skills`, checked: true })),
364
+ });
365
+ for (const a of agents.filter((x) => wanted.includes(x.id))) {
366
+ const plan = planLink(a.link, SKILL_DIR);
367
+ if (plan.action === "replace") {
368
+ const yes = await ui.confirm({ message: `${a.label} has its own copy of the skill. Replace it with a link?`, hint: "the copy is moved aside, not deleted" });
369
+ if (!yes) {
370
+ ui.done(`${a.label}: kept its copy`, a.link);
371
+ continue;
372
+ }
373
+ }
374
+ if (plan.action === "conflict") {
375
+ ui.failed(`${a.label}: something else is at ${a.link}`, "move it away and run install again");
376
+ continue;
377
+ }
378
+ try {
379
+ const done = applyLink(a.link, SKILL_DIR, plan);
380
+ ui.done(`${a.label}: ${plan.action === "none" ? plan.note : "linked"}`, `${a.link}${done.movedTo ? ` (the copy is at ${done.movedTo})` : ""}`);
381
+ summary.push(a.label);
382
+ } catch (e) {
383
+ ui.failed(`${a.label}: could not link`, e.message);
384
+ }
385
+ }
386
+
387
+ // ---- herdr
388
+ let herdrDone = false;
389
+ if (herdr) {
390
+ const yes = await ui.confirm({ message: "Register with Herdr as a plugin?", hint: "starts the daemon with Herdr, adds snooze to its actions" });
391
+ if (yes) {
392
+ const r = herdrLink(herdr, SKILL_DIR);
393
+ if (r.ok) {
394
+ ui.done("Registered with Herdr", r.note ?? "herdr plugin list shows it");
395
+ herdrDone = true;
396
+ } else ui.failed("Herdr would not link the plugin", r.error);
397
+ }
398
+ }
399
+
400
+ // ---- what and how
401
+ const setupArgs = {};
402
+ const persist = async (more = {}) => {
403
+ Object.assign(setupArgs, more);
404
+ const merged = applySetup(loadConfig(), setupArgs, listSounds(), SPEECH_SETUP);
405
+ saveConfig(merged);
406
+ return merged;
407
+ };
408
+
409
+ let methods = await ui.multiselect({
410
+ message: "How should agents reach you?",
411
+ min: 1,
412
+ choices: [
413
+ { value: "slack", label: "Slack", hint: "a DM from the roger-roger bot, with buttons to answer", checked: config ? config.methods.includes("slack") : true },
414
+ { value: "sound", label: "Sound", hint: "a short sound from the speakers", checked: config ? config.methods.includes("sound") : true },
415
+ { value: "speech", label: "Speech", hint: "a spoken line, in a voice you choose", checked: config ? config.methods.includes("speech") : true },
416
+ ],
417
+ });
418
+
419
+ let slack = null;
420
+ if (methods.includes("slack")) {
421
+ const connected = await connectSlack(ui);
422
+ if (connected) {
423
+ slack = await slackTarget(ui, { config, userId: connected.userId });
424
+ setupArgs["slack-target"] = slack.target;
425
+ setupArgs["slack-mention"] = slack.mention;
426
+ summary.push(`Slack → ${slack.target === connected.userId ? `your DMs (${connected.team})` : slack.target}`);
427
+ } else if (config?.slack?.target) {
428
+ slack = { target: config.slack.target, mention: config.slack.mention ?? "" };
429
+ ui.done("Slack stays as it was", `messages go to ${slack.target}`);
430
+ } else {
431
+ methods = methods.filter((m) => m !== "slack");
432
+ ui.done("Slack left out for now", "run `roger-roger install` again to connect it");
433
+ }
434
+ }
435
+ await persist({ methods: methods.join(",") });
436
+
437
+ if (methods.includes("sound")) {
438
+ const sounds = listSounds();
439
+ const sound = await ui.select({
440
+ message: "Which sound?",
441
+ initial: config?.sound ?? "chime",
442
+ choices: sounds.map((s) => ({ value: s, label: s, hint: SOUND_HINTS[s] ?? "", preview: () => daemon("play", { _: [s] }), previewing: `playing ${s}…` })),
443
+ });
444
+ await persist({ sound });
445
+ summary.push(`sound: ${sound}`);
446
+ }
447
+
448
+ if (methods.includes("speech")) {
449
+ const chosen = await chooseSpeech(ui, { config, persist });
450
+ await persist({ voice: chosen.voice, speech: chosen.speech });
451
+ summary.push(`voice: ${chosen.voice} (${PROVIDERS[chosen.provider].label}${chosen.hasKey ? "" : ", no key yet"})`);
452
+ }
453
+
454
+ const when = await ui.select({
455
+ message: "When should agents notify you?",
456
+ initial: config?.when ?? "auto",
457
+ choices: [
458
+ { value: "auto", label: "Let the agent judge", hint: "long tasks done, blocked, failures, big milestones" },
459
+ { value: "done-and-blocked", label: "Done and blocked", hint: "when finished, or when it needs you" },
460
+ { value: "blocked-only", label: "Blocked only", hint: "only when it cannot go on without you" },
461
+ { value: "on-request", label: "Only when asked", hint: "when you say \"ping me\" in a conversation" },
462
+ ],
463
+ });
464
+ await persist({ when });
465
+ summary.push(`notify: ${when}`);
466
+
467
+ // ---- terminal questions
468
+ const hookAgents = Object.entries(hooks?.agents ?? {}).filter(([, a]) => a.present());
469
+ if (hookAgents.length && methods.includes("slack")) {
470
+ const labels = { claude: "Claude Code", opencode: "OpenCode", codex: "Codex" };
471
+ const installed = hookAgents.every(([, a]) => {
472
+ const s = a.status({ script });
473
+ return s.installed && !s.note;
474
+ });
475
+ if (installed) {
476
+ ui.done("Terminal questions reach Slack", hookAgents.map(([n]) => labels[n] ?? n).join(", "));
477
+ } else {
478
+ const yes = await ui.confirm({
479
+ message: "Ping you in Slack when an agent asks a question in the terminal?",
480
+ hint: hookAgents.map(([n]) => labels[n] ?? n).join(", "),
481
+ });
482
+ if (yes) {
483
+ for (const [name, a] of hookAgents) {
484
+ try {
485
+ a.install({ script });
486
+ ui.done(`${labels[name] ?? name}: hooks installed`, name === "codex" ? "open /hooks in Codex once and trust the three roger-roger hooks" : name === "opencode" ? "restart OpenCode to load the plugin" : "");
487
+ summary.push(`hooks: ${labels[name] ?? name}`);
488
+ } catch (e) {
489
+ ui.failed(`${labels[name] ?? name}: could not install the hooks`, e.message);
490
+ }
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ // ---- tray
497
+ if (process.platform === "win32" || process.platform === "darwin") {
498
+ const status = await daemon("tray", { _: ["status"] }).catch(() => null);
499
+ if (status?.data?.installed) {
500
+ ui.done("Tray icon is installed", status.data.running ? "and running" : "it starts with the daemon");
501
+ } else {
502
+ const yes = await ui.confirm({ message: "Show every agent in the system tray?", hint: "downloads about 4 MB, nothing compiled", initial: true });
503
+ if (yes) {
504
+ const ok = await attempt(ui, "Installing the tray", () => ui.run("Installing the tray", () => daemon("tray", { _: ["install"] }), { done: () => "Tray installed" }), { skipLabel: "Skip the tray" });
505
+ if (ok) {
506
+ await daemon("tray", { _: ["start"] }).catch(() => {});
507
+ summary.push("tray");
508
+ }
509
+ }
510
+ }
511
+ }
512
+
513
+ // ---- try it
514
+ await ui.run("Starting the daemon", () => daemon("ping"), { done: (r) => ({ text: "Daemon running", detail: `pid ${r.data?.pid ?? "?"}, it stays up for every agent on this machine` }) }).catch(() => {});
515
+ const session = { identity: identity(env, process.cwd(), { session: "roger-roger install" }), about: { name: "roger-roger install", agent: "install" } };
516
+ const test = await ui.confirm({ message: "Send a test notification now?" });
517
+ if (test) {
518
+ await attempt(ui, "Sending the test", () =>
519
+ ui.run("Sending", () =>
520
+ daemon("notify", {
521
+ kind: "info",
522
+ message: "roger-roger is set up. This is what a notification looks like.",
523
+ say: "[positive] Roger roger. You are all set.",
524
+ project: "roger-roger",
525
+ session: "roger-roger install",
526
+ }, session),
527
+ { done: (r) => ({ text: "Test notification sent", detail: describeResults(r.data?.results) }) }),
528
+ { skipLabel: "Skip the test" });
529
+ }
530
+ await daemon("end", {}, session).catch(() => {});
531
+
532
+ ui.outro([
533
+ c.bold("Done."),
534
+ "",
535
+ ...summary.map((s) => `${c.green("•")} ${s}`),
536
+ ...(herdrDone ? [`${c.green("•")} Herdr plugin`] : []),
537
+ "",
538
+ `Settings: ${c.dim(configPath())}`,
539
+ `Change anything: ${c.cyan("roger-roger install")} again, or just tell your agent.`,
540
+ ]);
541
+ return 0;
542
+ } catch (e) {
543
+ if (e instanceof Cancelled) {
544
+ output.write(os.EOL + `${ui.rail.end} Stopped. Anything already done stays done; run ${c.cyan("roger-roger install")} to carry on.` + os.EOL + os.EOL);
545
+ return 130;
546
+ }
547
+ ui.failed("Setup stopped", e.message);
548
+ return 1;
549
+ }
550
+ }
551
+
552
+ /** One line on what a test notification managed. */
553
+ function describeResults(results = {}) {
554
+ const parts = [];
555
+ for (const [method, r] of Object.entries(results)) {
556
+ if (method === "queued") continue;
557
+ parts.push(r?.ok ? method : `${method} failed${r?.error ? ` (${r.error})` : ""}`);
558
+ }
559
+ return parts.join(" · ") || "nothing was sent";
560
+ }