roger-roger 0.1.4 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +19 -3
- package/skills/roger-roger/herdr-plugin.toml +1 -1
- package/skills/roger-roger/scripts/activity.mjs +92 -0
- package/skills/roger-roger/scripts/audio.mjs +123 -19
- package/skills/roger-roger/scripts/daemon.mjs +5 -3
- package/skills/roger-roger/scripts/decisions.mjs +9 -0
- package/skills/roger-roger/scripts/handlers.mjs +29 -10
- package/skills/roger-roger/scripts/help.mjs +1 -0
- package/skills/roger-roger/scripts/herdr.mjs +84 -12
- package/skills/roger-roger/scripts/inbox.mjs +16 -0
- package/skills/roger-roger/scripts/install.mjs +299 -124
- package/skills/roger-roger/scripts/lib.mjs +57 -2
- package/skills/roger-roger/scripts/names.mjs +14 -2
- package/skills/roger-roger/scripts/roger-roger.mjs +1 -0
- package/skills/roger-roger/scripts/sessions.mjs +10 -4
- package/skills/roger-roger/scripts/tray.mjs +5 -0
- package/skills/roger-roger/tray/main.mjs +5 -1
- package/skills/roger-roger/tray/panel.html +167 -2
|
@@ -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.
|
|
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
|
-
// ----
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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
|
|
531
|
-
|
|
532
|
-
|
|
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 === "
|
|
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 =
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
}
|
|
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 =
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
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:
|
|
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
|
|
651
|
-
await persist({ voice:
|
|
652
|
-
summary.push(`voice: ${
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
if (
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
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?.
|
|
701
|
-
ui.done("Tray icon is installed", status.
|
|
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
|
-
// ----
|
|
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
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
await
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
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 ?? []).
|
|
952
|
+
return (JSON.parse(r.stdout)?.result?.plugins ?? []).find((p) => p.plugin_id === "roger-roger") ?? null;
|
|
778
953
|
} catch {
|
|
779
|
-
return
|
|
954
|
+
return null;
|
|
780
955
|
}
|
|
781
956
|
}
|
|
782
957
|
|