roger-roger 0.1.4 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,8 +11,8 @@ import fs from "node:fs";
11
11
  import os from "node:os";
12
12
  import path from "node:path";
13
13
  import { spawnSync } from "node:child_process";
14
- import { SKILL_DIR, VERSION, VOICES, applySetup, configPath, listSounds, loadConfig, rogerRogerHome, saveConfig, voiceSample } from "./lib.mjs";
15
- import { PROVIDERS, SPEECH_SETUP, catalog } from "./tts.mjs";
14
+ import { SKILL_DIR, VERSION, VOICES, applySetup, configPath, listSounds, loadConfig, readSlackCredentials, rogerRogerHome, saveConfig, voiceSample } from "./lib.mjs";
15
+ import { PROVIDERS, SPEECH_SETUP, catalog, keyFor } from "./tts.mjs";
16
16
  import { envValue, hasSavedKey, saveKey } from "./speechkey.mjs";
17
17
  import { createApp, loginFinish, loginStart, setupStatus } from "./slackapp.mjs";
18
18
  import { cliInstallDir, findCli, installCli } from "./slackcli.mjs";
@@ -111,7 +111,7 @@ const MARK_END = "<!-- roger-roger:end -->";
111
111
  /** The standing instruction, between marks so it can be replaced or removed without touching anything else. */
112
112
  export const INSTRUCTIONS = [
113
113
  MARK_START,
114
- "- Always use the `roger-roger` skill (the `roger-roger` command, or `node <skill-dir>/scripts/roger-roger.mjs`): at the end of every turn, whatever its size, send a notification as the last action before the final reply, and use `ask` when blocked on a decision. Don't skip it because the task seemed quick or the user seems to be at the keyboard. Pass `--session` (three to six words about the work) and `--agent-model` on every call. If the skill isn't available, say so once.",
114
+ "- Always use the `roger-roger` skill (the `roger-roger` command, or `node <skill-dir>/scripts/roger-roger.mjs`): at the end of every turn, whatever its size, send a notification as the last action before the final reply, and use `ask` when blocked on a decision. Don't skip it because the task seemed quick or the user seems to be at the keyboard. On every call pass `--session` with what the work is, in three to six words (`--session \"billing refactor tests\"`), and `--agent-model`: the work names your Slack messages and your Herdr pane, and its first two words go on the Herdr tab, so lead with them (`security audit`, not `doing a security audit`). The spoken line (`--say`) is one or two sentences that carry the point itself, the result, the question or the next step in words, never just \"here is what's next\" or \"details in Slack\". No code, commands, paths or URLs in it: say what the thing is and why, and leave the command to the written message. If the skill isn't available, say so once.",
115
115
  MARK_END,
116
116
  ].join("\n");
117
117
 
@@ -471,6 +471,77 @@ async function chooseSpeech(ui, { config, persist }) {
471
471
  return { provider, voice, speech, hasKey };
472
472
  }
473
473
 
474
+ // ---------------------------------------------------------------- what is set up already
475
+
476
+ const HOOK_LABELS = { claude: "Claude Code", opencode: "OpenCode", codex: "Codex" };
477
+
478
+ /**
479
+ * Everything an earlier setup left, read from where it lives rather than from a record of it: the
480
+ * settings, which agents have the skill, the standing instruction, the hooks, Herdr, the tray.
481
+ * `tray` is only known with a daemon to ask, so it is passed in.
482
+ */
483
+ export function currentSetup({ home = os.homedir(), env = process.env, hooks = null, script = "", herdr = null, global = null, config = loadConfig(), tray = null } = {}) {
484
+ const hookAgents = Object.entries(hooks?.agents ?? {}).filter(([, a]) => {
485
+ try {
486
+ return a.present() && a.status({ script }).installed;
487
+ } catch {
488
+ return false;
489
+ }
490
+ }).map(([name]) => name);
491
+ const plugin = herdr ? herdrPlugin(herdr) : null;
492
+ return {
493
+ config,
494
+ // The version that set this up. Older setups didn't write it down; the global copy is the next best guess.
495
+ version: config?.installedVersion ?? (global?.present ? global.version : "") ?? "",
496
+ links: skillFootprint(home),
497
+ instructions: instructionFiles(home).filter((f) => f.has),
498
+ hooks: hookAgents,
499
+ herdr: plugin ? { root: plugin.plugin_root ?? "" } : null,
500
+ slack: Boolean(readSlackCredentials()),
501
+ tray,
502
+ };
503
+ }
504
+
505
+ const WHEN_LABELS = { auto: "when the agent judges it worth it", "done-and-blocked": "when done or blocked", "blocked-only": "only when blocked", "on-request": "only when asked" };
506
+ const SPEECH_LABELS = { auto: "the agent decides how much", brief: "one short sentence", same: "the whole message" };
507
+
508
+ /** The current setup as lines for a note: what reaches you, when, and where it is installed. */
509
+ export function describeSetup(now, env = process.env) {
510
+ const c = now.config;
511
+ if (!c) return [];
512
+ const how = [];
513
+ if (c.methods.includes("slack")) how.push(`Slack (${c.slack?.target || "no target"})`);
514
+ if (c.methods.includes("sound")) how.push(`sound (${c.sound})`);
515
+ if (c.methods.includes("speech")) {
516
+ const provider = PROVIDERS[c.speechProvider] ?? PROVIDERS.gemini;
517
+ const key = keyFor(c);
518
+ how.push(`speech (${c.voice}, ${provider.label}, ${key.value ? `key in ${key.name}` : "no key"})`);
519
+ }
520
+ const list = (items) => (items.length ? items.join(", ") : "none");
521
+ return [
522
+ `Reaches you by ${list(how)}`,
523
+ `Notifies ${WHEN_LABELS[c.when] ?? c.when}${c.methods.includes("speech") ? `; ${SPEECH_LABELS[c.speech] ?? c.speech} out loud` : ""}`,
524
+ `Skill for ${list(now.links.map((a) => a.label))}`,
525
+ `Always use it ${now.instructions.length ? `yes, in ${now.instructions.map((f) => `${f.label}'s ${f.file}`).join(", ")}` : "not told"}`,
526
+ `Terminal pings ${list(now.hooks.map((n) => HOOK_LABELS[n] ?? n))}`,
527
+ `Herdr plugin ${now.herdr ? "registered" : "no"}`,
528
+ ...(now.tray ? [`Tray ${now.tray.installed ? (now.tray.running ? "running" : "installed") : "no"}`] : []),
529
+ ];
530
+ }
531
+
532
+ /** The parts of the setup that can be gone through again, in the order they are asked. */
533
+ export const SECTIONS = [
534
+ { id: "agents", label: "Which agents have the skill" },
535
+ { id: "instructions", label: "Telling agents to always use it" },
536
+ { id: "herdr", label: "The Herdr plugin" },
537
+ { id: "reach", label: "How you're reached", hint: "Slack, sound, speech" },
538
+ { id: "sound", label: "The sound" },
539
+ { id: "voice", label: "The voice", hint: "provider, key, voice, how much is said" },
540
+ { id: "when", label: "When agents notify you" },
541
+ { id: "hooks", label: "Pings for terminal questions" },
542
+ { id: "tray", label: "The tray icon" },
543
+ ];
544
+
474
545
  /** The whole conversation. Returns the process exit code. */
475
546
  export async function runInstall(args, { script, hooks, input = process.stdin, output = process.stdout, env = process.env, home = os.homedir() } = {}) {
476
547
  const ui = createUI({ input, output, env });
@@ -485,40 +556,74 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
485
556
 
486
557
  const summary = [];
487
558
  try {
488
- 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.");
489
-
490
559
  if (major < 18) {
491
560
  ui.failed(`Node ${process.versions.node} is too old`, "roger-roger needs Node 18 or newer (22 for Slack questions)");
492
561
  return 1;
493
562
  }
494
563
 
495
- // ---- looking around
564
+ // ---- what is here, and what an earlier setup chose
496
565
  const config = loadConfig();
497
- const agents = skillLinks(home).filter((a) => a.present);
498
566
  const herdr = findHerdr(env);
499
- ui.done("Looking around", [
500
- `Node ${process.versions.node}${major < 22 ? " (22 or newer is needed for Slack questions)" : ""}`,
501
- `Agents: ${agents.filter((a) => !a.always).map((a) => a.label).join(", ") || "no agent folders found yet"}`,
502
- herdr ? `Herdr ${herdr.version}` : "",
503
- config ? "Settings from an earlier setup are the defaults below" : "First setup",
504
- ]);
567
+ const global = globalCopy(env);
568
+ const trayStatus = config && (process.platform === "win32" || process.platform === "darwin")
569
+ ? (await daemon("tray", { _: ["status"] }).catch(() => null))?.data ?? null
570
+ : null;
571
+ const now = currentSetup({ home, env, hooks, script, herdr, global, config, tray: trayStatus });
572
+ const first = !config;
573
+ const from = now.version;
574
+ const upgrading = Boolean(from && VERSION && olderThan(from, VERSION));
575
+
576
+ ui.intro(
577
+ `roger-roger ${first ? (VERSION ? c.dim(VERSION) : "") : upgrading ? `${c.dim(from)} → ${c.bold(VERSION)}` : c.dim(VERSION)}`,
578
+ first
579
+ ? "Brings you back to the conversation when you have walked away. A few questions, and your agents can reach you."
580
+ : upgrading
581
+ ? `An update. Everything you chose before is kept unless you say otherwise.`
582
+ : "Already set up. Change what you like; everything else stays as it is.",
583
+ );
584
+
585
+ // ---- how much to go through: all of it the first time; on an update, what the user picks
586
+ let mode = "all";
587
+ let chosen = new Set(SECTIONS.map((s) => s.id));
588
+ if (!first) {
589
+ ui.note("Your setup now", describeSetup(now, env));
590
+ mode = await ui.select({
591
+ message: upgrading ? "How should the update go?" : "What would you like to do?",
592
+ initial: upgrading ? "keep" : "change",
593
+ choices: [
594
+ { value: "keep", label: upgrading ? "Keep everything, just update (recommended)" : "Keep everything, refresh what's installed", hint: "no questions: links, hooks, Herdr and the instructions are brought up to date" },
595
+ { value: "change", label: "Change some things", hint: "pick which parts, the rest stays as it is" },
596
+ { value: "all", label: "Go through everything", hint: "every question, with your current answers ready" },
597
+ ],
598
+ });
599
+ if (mode === "keep") chosen = new Set();
600
+ if (mode === "change") {
601
+ chosen = new Set(await ui.multiselect({
602
+ message: "What would you like to change?",
603
+ choices: SECTIONS.map((s) => ({ value: s.id, label: s.label, hint: s.hint ?? "" })),
604
+ }));
605
+ }
606
+ }
607
+ const asking = (id) => chosen.has(id);
608
+ if (!first && mode !== "all") ui.done(chosen.size ? `Changing: ${SECTIONS.filter((s) => chosen.has(s.id)).map((s) => s.label.toLowerCase()).join(", ")}` : "Keeping everything as it is");
505
609
 
506
610
  // ---- the copy everything points at: the global one, if there is or is about to be one
507
611
  let target = SKILL_DIR;
508
612
  let hookScript = script;
509
- const global = globalCopy(env);
510
613
  const pick = pickTarget(SKILL_DIR, global);
511
614
  if (pick.reason === "global") {
512
- ui.done("The roger-roger command is on your PATH", global.dir);
615
+ if (first || mode === "all") ui.done("The roger-roger command is on your PATH", global.dir);
513
616
  } else if (pick.offer) {
514
- const yes = await ui.confirm(pick.reason === "update"
617
+ // An update of the global copy is what "keep everything, just update" means, so it isn't asked then.
618
+ const yes = pick.reason === "update" && mode === "keep" ? true : await ui.confirm(pick.reason === "update"
515
619
  ? { message: `Update the roger-roger command from ${pick.from} to ${pick.to}?`, hint: "npm install -g roger-roger@latest; the old one would keep answering" }
516
- : { message: "Put the roger-roger command on your PATH?", hint: "npm install -g roger-roger, so you and your agents can run it from anywhere" });
620
+ : { message: "Put the roger-roger command on your PATH?", yes: "Yes (recommended)", hint: "npm install -g roger-roger, so you and your agents can run it from anywhere" });
517
621
  if (yes) {
518
622
  const done = await attempt(ui, pick.reason === "update" ? "Updating" : "Installing globally", () => ui.run(`${pick.reason === "update" ? "Updating" : "Installing"} roger-roger with npm`, () => installGlobally(env), { done: () => ({ text: `The roger-roger command is on your PATH${pick.to ? `, at ${pick.to}` : ""}`, detail: global.dir }) }), { skipLabel: "Carry on with this copy" });
519
623
  if (done !== null && fs.existsSync(path.join(global.dir, "scripts", "roger-roger.mjs"))) {
520
624
  target = global.dir;
521
625
  hookScript = path.join(global.dir, "scripts", "roger-roger.mjs");
626
+ if (pick.reason === "update") summary.push(`updated to ${pick.to}`);
522
627
  // The daemon should come from the copy that stays, not from the one npx will throw away.
523
628
  await daemon("stop").catch(() => {});
524
629
  spawnSync(process.execPath, [hookScript, "daemon", "start"], { encoding: "utf8", windowsHide: true, timeout: 30_000, env });
@@ -527,12 +632,22 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
527
632
  }
528
633
 
529
634
  // ---- the skill, where agents look for it
530
- const wanted = await ui.multiselect({
531
- message: "Which agents should have the skill?",
532
- choices: agents.map((a) => ({ value: a.id, label: a.label, hint: a.hint ?? `~/${a.dir.join("/")}/skills`, checked: true })),
533
- });
635
+ const agents = skillLinks(home).filter((a) => a.present);
636
+ const linkedBefore = new Set(now.links.map((a) => a.id));
637
+ const wanted = asking("agents")
638
+ ? await ui.multiselect({
639
+ message: "Which agents should have the skill?",
640
+ choices: agents.map((a) => ({ value: a.id, label: a.label, hint: a.hint ?? `~/${a.dir.join("/")}/skills`, checked: first || linkedBefore.has(a.id) })),
641
+ })
642
+ : agents.filter((a) => linkedBefore.has(a.id)).map((a) => a.id);
643
+ let relinked = 0;
534
644
  for (const a of agents.filter((x) => wanted.includes(x.id))) {
535
645
  const plan = planLink(a.link, target);
646
+ if (plan.action === "none") {
647
+ if (asking("agents")) ui.done(`${a.label}: ${plan.note}`, a.link);
648
+ summary.push(a.label);
649
+ continue;
650
+ }
536
651
  if (plan.action === "replace") {
537
652
  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" });
538
653
  if (!yes) {
@@ -546,33 +661,53 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
546
661
  }
547
662
  try {
548
663
  const done = applyLink(a.link, target, plan);
549
- ui.done(`${a.label}: ${plan.action === "none" ? plan.note : "linked"}`, `${a.link}${done.movedTo ? ` (the copy is at ${done.movedTo})` : ""}`);
664
+ ui.done(`${a.label}: ${plan.action === "relink" ? "now points at this copy" : "linked"}`, `${a.link}${done.movedTo ? ` (the copy is at ${done.movedTo})` : ""}`);
550
665
  summary.push(a.label);
666
+ relinked++;
551
667
  } catch (e) {
552
668
  ui.failed(`${a.label}: could not link`, e.message);
553
669
  }
554
670
  }
671
+ // Taken off an agent that had it.
672
+ for (const a of now.links.filter((x) => asking("agents") && !wanted.includes(x.id))) {
673
+ try {
674
+ removeSkillLink(a.link);
675
+ ui.done(`${a.label}: skill removed`, a.link);
676
+ } catch (e) {
677
+ ui.failed(`${a.label}: could not remove`, e.message);
678
+ }
679
+ }
680
+ if (!asking("agents") && linkedBefore.size && !relinked) ui.done("The skill is linked as before", now.links.map((a) => a.label).join(", "));
555
681
 
556
682
  // ---- the standing instruction, so agents use it without being told each time
557
683
  const instructionTargets = instructionFiles(home).filter((f) => f.present);
558
684
  if (instructionTargets.length) {
559
685
  const already = instructionTargets.filter((f) => f.has);
560
- const yes = await ui.confirm({
561
- message: already.length === instructionTargets.length ? "Keep telling your agents to always use roger-roger?" : "Tell your agents to always use roger-roger?",
562
- yes: "Yes (recommended)",
563
- hint: `a marked block in ${instructionTargets.map((f) => `~/${[...f.dir, f.file].join("/")}`).join(", ")}`,
564
- initial: true,
565
- });
686
+ const yes = asking("instructions")
687
+ ? await ui.confirm({
688
+ message: already.length ? "Keep telling your agents to always use roger-roger?" : "Tell your agents to always use roger-roger?",
689
+ yes: "Yes (recommended)",
690
+ hint: `a marked block in ${instructionTargets.map((f) => `~/${[...f.dir, f.file].join("/")}`).join(", ")}`,
691
+ initial: first || already.length > 0,
692
+ })
693
+ : already.length > 0;
566
694
  if (yes) {
567
- for (const f of instructionTargets) {
695
+ // Kept, it is still refreshed, so a changed wording reaches every agent.
696
+ const files = asking("instructions") ? instructionTargets : already;
697
+ let refreshed = 0;
698
+ for (const f of files) {
568
699
  try {
700
+ const before = fs.readFileSync(f.path, "utf8").toString();
569
701
  const r = addInstructions(f.path);
570
- ui.done(`${f.label}: ${r.added ? "instructions added" : "instructions refreshed"}`, f.path);
702
+ const changed = fs.readFileSync(f.path, "utf8") !== before;
703
+ if (r.added || changed || asking("instructions")) ui.done(`${f.label}: ${r.added ? "instructions added" : changed ? "instructions brought up to date" : "instructions already up to date"}`, f.path);
704
+ if (changed) refreshed++;
571
705
  summary.push(`instructions: ${f.label}`);
572
706
  } catch (e) {
573
707
  ui.failed(`${f.label}: could not write ${f.path}`, e.message);
574
708
  }
575
709
  }
710
+ if (!asking("instructions") && !refreshed) ui.done("Instructions unchanged", already.map((f) => f.path).join(", "));
576
711
  } else if (already.length) {
577
712
  for (const f of already) {
578
713
  try {
@@ -588,13 +723,25 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
588
723
  // ---- herdr
589
724
  let herdrDone = false;
590
725
  if (herdr) {
591
- const yes = await ui.confirm({ message: "Register with Herdr as a plugin?", hint: "starts the daemon with Herdr, adds snooze to its actions" });
726
+ const yes = asking("herdr")
727
+ ? await ui.confirm({ message: "Register with Herdr as a plugin?", hint: "starts the daemon with Herdr, adds snooze to its actions", initial: first || Boolean(now.herdr) })
728
+ : Boolean(now.herdr);
592
729
  if (yes) {
593
- const r = herdrLink(herdr, target);
594
- if (r.ok) {
595
- ui.done("Registered with Herdr", r.note ?? "herdr plugin list shows it");
730
+ // Registered elsewhere (an older copy): unlinked and linked again, so Herdr runs this one.
731
+ const stale = now.herdr && norm(now.herdr.root.replace(/^\\\\\?\\/, "")) !== norm(target);
732
+ if (stale) herdrUnlink(herdr);
733
+ if (!now.herdr || stale || asking("herdr")) {
734
+ const r = herdrLink(herdr, target);
735
+ if (r.ok) {
736
+ ui.done(stale ? "Herdr now runs this copy" : "Registered with Herdr", r.note ?? target);
737
+ herdrDone = true;
738
+ } else ui.failed("Herdr would not link the plugin", r.error);
739
+ } else {
596
740
  herdrDone = true;
597
- } else ui.failed("Herdr would not link the plugin", r.error);
741
+ }
742
+ } else if (now.herdr) {
743
+ const r = herdrUnlink(herdr);
744
+ if (r.ok) ui.done("Herdr plugin unlinked");
598
745
  }
599
746
  }
600
747
 
@@ -607,98 +754,112 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
607
754
  return merged;
608
755
  };
609
756
 
610
- let methods = await ui.multiselect({
611
- message: "How should agents reach you?",
612
- min: 1,
613
- choices: [
614
- { value: "slack", label: "Slack", hint: "a DM from the roger-roger bot, with buttons to answer", checked: config ? config.methods.includes("slack") : true },
615
- { value: "sound", label: "Sound", hint: "a short sound from the speakers", checked: config ? config.methods.includes("sound") : true },
616
- { value: "speech", label: "Speech", hint: "a spoken line, in a voice you choose", checked: config ? config.methods.includes("speech") : true },
617
- ],
618
- });
619
-
620
- let slack = null;
621
- if (methods.includes("slack")) {
622
- const connected = await connectSlack(ui);
623
- if (connected) {
624
- slack = await slackTarget(ui, { config, userId: connected.userId });
625
- setupArgs["slack-target"] = slack.target;
626
- setupArgs["slack-mention"] = slack.mention;
627
- summary.push(`Slack → ${slack.target === connected.userId ? `your DMs (${connected.team})` : slack.target}`);
628
- } else if (config?.slack?.target) {
629
- slack = { target: config.slack.target, mention: config.slack.mention ?? "" };
630
- ui.done("Slack stays as it was", `messages go to ${slack.target}`);
631
- } else {
632
- methods = methods.filter((m) => m !== "slack");
633
- ui.done("Slack left out for now", "run `roger-roger install` again to connect it");
757
+ let methods = config?.methods ?? [];
758
+ if (asking("reach")) {
759
+ methods = await ui.multiselect({
760
+ message: "How should agents reach you?",
761
+ min: 1,
762
+ choices: [
763
+ { value: "slack", label: "Slack", hint: "a DM from the roger-roger bot, with buttons to answer", checked: config ? config.methods.includes("slack") : true },
764
+ { value: "sound", label: "Sound", hint: "a short sound from the speakers", checked: config ? config.methods.includes("sound") : true },
765
+ { value: "speech", label: "Speech", hint: "a spoken line, in a voice you choose", checked: config ? config.methods.includes("speech") : true },
766
+ ],
767
+ });
768
+ if (methods.includes("slack")) {
769
+ const connected = await connectSlack(ui);
770
+ if (connected) {
771
+ const slack = await slackTarget(ui, { config, userId: connected.userId });
772
+ setupArgs["slack-target"] = slack.target;
773
+ setupArgs["slack-mention"] = slack.mention;
774
+ summary.push(`Slack → ${slack.target === connected.userId ? `your DMs (${connected.team})` : slack.target}`);
775
+ } else if (config?.slack?.target) {
776
+ ui.done("Slack stays as it was", `messages go to ${config.slack.target}`);
777
+ } else {
778
+ methods = methods.filter((m) => m !== "slack");
779
+ ui.done("Slack left out for now", "run `roger-roger install` again to connect it");
780
+ }
634
781
  }
782
+ await persist({ methods: methods.join(",") });
783
+ // Newly turned on, the parts that go with it are asked even if they weren't picked.
784
+ if (methods.includes("sound") && !config?.methods?.includes("sound")) chosen.add("sound");
785
+ if (methods.includes("speech") && !config?.methods?.includes("speech")) chosen.add("voice");
786
+ if (methods.includes("slack") && !config?.methods?.includes("slack")) chosen.add("hooks");
635
787
  }
636
- await persist({ methods: methods.join(",") });
637
788
 
638
- if (methods.includes("sound")) {
639
- const sounds = listSounds();
789
+ if (methods.includes("sound") && asking("sound")) {
640
790
  const sound = await ui.select({
641
791
  message: "Which sound?",
642
792
  initial: config?.sound ?? "chime",
643
- choices: sounds.map((s) => ({ value: s, label: s, hint: SOUND_HINTS[s] ?? "", preview: () => daemon("play", { _: [s] }), previewing: `playing ${s}…` })),
793
+ choices: listSounds().map((s) => ({ value: s, label: s, hint: `${SOUND_HINTS[s] ?? ""}${config?.sound === s ? " · current" : ""}`, preview: () => daemon("play", { _: [s] }), previewing: `playing ${s}…` })),
644
794
  });
645
795
  await persist({ sound });
646
796
  summary.push(`sound: ${sound}`);
647
797
  }
648
798
 
649
- if (methods.includes("speech")) {
650
- const chosen = await chooseSpeech(ui, { config, persist });
651
- await persist({ voice: chosen.voice, speech: chosen.speech });
652
- summary.push(`voice: ${chosen.voice} (${PROVIDERS[chosen.provider].label}${chosen.hasKey ? "" : ", no key yet"})`);
799
+ if (methods.includes("speech") && asking("voice")) {
800
+ const voice = await chooseSpeech(ui, { config, persist });
801
+ await persist({ voice: voice.voice, speech: voice.speech });
802
+ summary.push(`voice: ${voice.voice} (${PROVIDERS[voice.provider].label}${voice.hasKey ? "" : ", no key yet"})`);
653
803
  }
654
804
 
655
- const when = await ui.select({
656
- message: "When should agents notify you?",
657
- initial: config?.when ?? "auto",
658
- choices: [
659
- { value: "auto", label: "Let the agent judge", hint: "long tasks done, blocked, failures, big milestones" },
660
- { value: "done-and-blocked", label: "Done and blocked", hint: "when finished, or when it needs you" },
661
- { value: "blocked-only", label: "Blocked only", hint: "only when it cannot go on without you" },
662
- { value: "on-request", label: "Only when asked", hint: "when you say \"ping me\" in a conversation" },
663
- ],
664
- });
665
- await persist({ when });
666
- summary.push(`notify: ${when}`);
805
+ if (asking("when")) {
806
+ const when = await ui.select({
807
+ message: "When should agents notify you?",
808
+ initial: config?.when ?? "auto",
809
+ choices: [
810
+ { value: "auto", label: "Let the agent judge", hint: "long tasks done, blocked, failures, big milestones" },
811
+ { value: "done-and-blocked", label: "Done and blocked", hint: "when finished, or when it needs you" },
812
+ { value: "blocked-only", label: "Blocked only", hint: "only when it cannot go on without you" },
813
+ { value: "on-request", label: "Only when asked", hint: "when you say \"ping me\" in a conversation" },
814
+ ],
815
+ });
816
+ await persist({ when });
817
+ summary.push(`notify: ${WHEN_LABELS[when] ?? when}`);
818
+ }
667
819
 
668
820
  // ---- terminal questions
669
821
  const hookAgents = Object.entries(hooks?.agents ?? {}).filter(([, a]) => a.present());
670
822
  if (hookAgents.length && methods.includes("slack")) {
671
- const labels = { claude: "Claude Code", opencode: "OpenCode", codex: "Codex" };
672
- const installed = hookAgents.every(([, a]) => {
673
- const s = a.status({ script: hookScript });
674
- return s.installed && !s.note;
675
- });
676
- if (installed) {
677
- ui.done("Terminal questions reach Slack", hookAgents.map(([n]) => labels[n] ?? n).join(", "));
678
- } else {
679
- const yes = await ui.confirm({
680
- message: "Ping you in Slack when an agent asks a question in the terminal?",
681
- hint: hookAgents.map(([n]) => labels[n] ?? n).join(", "),
682
- });
683
- if (yes) {
684
- for (const [name, a] of hookAgents) {
685
- try {
686
- a.install({ script: hookScript });
687
- 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" : "");
688
- summary.push(`hooks: ${labels[name] ?? name}`);
689
- } catch (e) {
690
- ui.failed(`${labels[name] ?? name}: could not install the hooks`, e.message);
691
- }
823
+ const had = new Set(now.hooks);
824
+ const names = hookAgents.map(([n]) => HOOK_LABELS[n] ?? n).join(", ");
825
+ const yes = asking("hooks")
826
+ ? await ui.confirm({ message: had.size ? "Keep pinging you in Slack for questions asked in the terminal?" : "Ping you in Slack when an agent asks a question in the terminal?", hint: names, initial: first || had.size > 0 })
827
+ : had.size > 0;
828
+ if (yes) {
829
+ for (const [name, a] of hookAgents) {
830
+ const status = a.status({ script: hookScript });
831
+ // Installed and pointing here: nothing to do. Anything else is (re)installed.
832
+ if (status.installed && !status.note) {
833
+ if (asking("hooks")) ui.done(`${HOOK_LABELS[name] ?? name}: hooks in place`);
834
+ summary.push(`hooks: ${HOOK_LABELS[name] ?? name}`);
835
+ continue;
836
+ }
837
+ if (!asking("hooks") && !had.has(name)) continue; // kept means kept: no new agents unasked
838
+ try {
839
+ a.install({ script: hookScript });
840
+ ui.done(`${HOOK_LABELS[name] ?? name}: hooks ${status.installed ? "now point at this copy" : "installed"}`, name === "codex" && !status.installed ? "open /hooks in Codex once and trust the three roger-roger hooks" : name === "opencode" ? "restart OpenCode to load the plugin" : "");
841
+ summary.push(`hooks: ${HOOK_LABELS[name] ?? name}`);
842
+ } catch (e) {
843
+ ui.failed(`${HOOK_LABELS[name] ?? name}: could not install the hooks`, e.message);
844
+ }
845
+ }
846
+ } else if (had.size) {
847
+ for (const name of had) {
848
+ try {
849
+ hooks.agents[name].uninstall();
850
+ ui.done(`${HOOK_LABELS[name] ?? name}: hooks removed`);
851
+ } catch (e) {
852
+ ui.failed(`${HOOK_LABELS[name] ?? name}: could not remove the hooks`, e.message);
692
853
  }
693
854
  }
694
855
  }
695
856
  }
696
857
 
697
858
  // ---- tray
698
- if (process.platform === "win32" || process.platform === "darwin") {
699
- const status = await daemon("tray", { _: ["status"] }).catch(() => null);
700
- if (status?.data?.installed) {
701
- ui.done("Tray icon is installed", status.data.running ? "and running" : "it starts with the daemon");
859
+ if ((process.platform === "win32" || process.platform === "darwin") && (first || asking("tray"))) {
860
+ const status = trayStatus ?? (await daemon("tray", { _: ["status"] }).catch(() => null))?.data;
861
+ if (status?.installed) {
862
+ ui.done("Tray icon is installed", status.running ? "and running" : "it starts with the daemon");
702
863
  } else {
703
864
  const yes = await ui.confirm({ message: "Show every agent in the system tray?", hint: "downloads about 4 MB, nothing compiled", initial: true });
704
865
  if (yes) {
@@ -711,29 +872,38 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
711
872
  }
712
873
  }
713
874
 
714
- // ---- try it
875
+ // ---- remember which version did this, so the next run can say what it is updating from
876
+ if (VERSION) {
877
+ const saved = loadConfig();
878
+ if (saved) saveConfig({ ...saved, installedVersion: VERSION });
879
+ }
880
+
881
+ // ---- try it: offered the first time, and when how you're reached has changed
715
882
  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(() => {});
716
- const session = { identity: identity(env, process.cwd(), { session: "roger-roger install" }), about: { name: "roger-roger install", agent: "install" } };
717
- const test = await ui.confirm({ message: "Send a test notification now?" });
718
- if (test) {
719
- await attempt(ui, "Sending the test", () =>
720
- ui.run("Sending", () =>
721
- daemon("notify", {
722
- kind: "info",
723
- message: "roger-roger is set up. This is what a notification looks like.",
724
- say: "[positive] Roger roger. You are all set.",
725
- project: "roger-roger",
726
- session: "roger-roger install",
727
- }, session),
728
- { done: (r) => ({ text: "Test notification sent", detail: describeResults(r.data?.results) }) }),
729
- { skipLabel: "Skip the test" });
883
+ const touchedDelivery = ["reach", "sound", "voice"].some(asking);
884
+ if (first || touchedDelivery) {
885
+ const session = { identity: identity(env, process.cwd(), { session: "roger-roger install" }), about: { name: "roger-roger install", agent: "install" } };
886
+ const test = await ui.confirm({ message: "Send a test notification now?", initial: first });
887
+ if (test) {
888
+ await attempt(ui, "Sending the test", () =>
889
+ ui.run("Sending", () =>
890
+ daemon("notify", {
891
+ kind: "info",
892
+ message: "roger-roger is set up. This is what a notification looks like.",
893
+ say: "[positive] Roger roger. You are all set.",
894
+ project: "roger-roger",
895
+ session: "roger-roger install",
896
+ }, session),
897
+ { done: (r) => ({ text: "Test notification sent", detail: describeResults(r.data?.results) }) }),
898
+ { skipLabel: "Skip the test" });
899
+ }
900
+ await daemon("end", {}, session).catch(() => {});
730
901
  }
731
- await daemon("end", {}, session).catch(() => {});
732
902
 
733
903
  ui.outro([
734
- c.bold("Done."),
904
+ c.bold(first ? "Done." : upgrading ? `Updated to ${VERSION}.` : "Done."),
735
905
  "",
736
- ...summary.map((s) => `${c.green("•")} ${s}`),
906
+ ...[...new Set(summary)].map((s) => `${c.green("•")} ${s}`),
737
907
  ...(herdrDone ? [`${c.green("•")} Herdr plugin`] : []),
738
908
  "",
739
909
  `Settings: ${c.dim(configPath())}`,
@@ -772,11 +942,16 @@ export function removeSkillLink(link, fsx = fs) {
772
942
 
773
943
  /** Whether Herdr has the plugin, so uninstall can say so rather than guess. */
774
944
  export function herdrHasPlugin(herdr) {
945
+ return Boolean(herdrPlugin(herdr));
946
+ }
947
+
948
+ /** The registered plugin as Herdr lists it (with its `plugin_root`), or null. */
949
+ export function herdrPlugin(herdr) {
775
950
  try {
776
951
  const r = spawnSync(herdr.command, ["plugin", "list", "--json"], { encoding: "utf8", windowsHide: true, timeout: 15_000 });
777
- return (JSON.parse(r.stdout)?.result?.plugins ?? []).some((p) => p.plugin_id === "roger-roger");
952
+ return (JSON.parse(r.stdout)?.result?.plugins ?? []).find((p) => p.plugin_id === "roger-roger") ?? null;
778
953
  } catch {
779
- return false;
954
+ return null;
780
955
  }
781
956
  }
782
957