roger-roger 0.1.8 → 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 +1 -1
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +15 -2
- package/skills/roger-roger/herdr-plugin.toml +1 -1
- package/skills/roger-roger/scripts/herdr.mjs +37 -10
- package/skills/roger-roger/scripts/install.mjs +299 -124
- package/skills/roger-roger/scripts/names.mjs +14 -2
- package/skills/roger-roger/scripts/sessions.mjs +10 -4
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ There is no always-on service. `ask` starts a small watcher process, and one wat
|
|
|
93
93
|
- **One voice at a time**: several agents on one machine take turns at the speakers instead of talking over each other. A waiting announcement plays in full as soon as the one before it finishes.
|
|
94
94
|
- **Works with any agent**: Claude Code, Codex, OpenCode, or a plain shell script. A session is recognised by whatever id the agent publishes, or by the `--session` name it passes, or by the terminal — no per-product support needed.
|
|
95
95
|
- **Every agent has a name**: each session is given a colour nickname — 🌿 sage, 🌹 rose, 🍇 plum — shown on its Slack messages with a matching colour bar. `sessions` lists who is running; `sessions rename sage plum` changes one.
|
|
96
|
-
- **Named in Herdr too**: an agent running in a [Herdr](https://herdr.dev) pane gets its badge on the tab (`🍯 amber`) and its
|
|
96
|
+
- **Named in Herdr too**: an agent running in a [Herdr](https://herdr.dev) pane gets its badge and a word or two of its work on the tab (`🍯 amber | build process`) and its work on the agent (`build-process-improvements`), so the Herdr sidebar and the Slack messages name the same thing. Nothing to set up: the skill sees the pane it runs in and labels it on the first command, and again after a rename. As a Herdr plugin (`roger-roger install` registers it, or `herdr plugin install code-red-app/roger-roger/skills/roger-roger`), the daemon starts with Herdr, snooze is an action, and the setup opens as a popup pane.
|
|
97
97
|
- **Talk to an agent**: write "sage, rerun the tests" (or "hey sage rerun the tests", or "sage: ..."). With one agent running, a plain message goes to it. With several, but only one of them listening, it goes to that one and Slack says so, with a button to redirect it. With several, the bot asks which, with a button per agent — and remembers your answer for that thread.
|
|
98
98
|
- **You can hear who it is**: a spoken line starts with the agent's name — "Amber here.", "This is Rose.", "Jade speaking." — so three agents sharing one pair of speakers stay tellable apart (`setup --say-who off`).
|
|
99
99
|
- **Reachable while idle**: an agent that has finished can run `listen` in the background instead of going quiet. It costs nothing to wait, and when you write, it wakes up and answers. Slack shows 👀 when an agent is listening and 💤 when your message is saved for one that isn't, so you always know which.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "roger-roger",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Bring yourself back to the conversation: your coding agents reach you by Slack, a sound or a spoken line when they finish, get stuck, or need a decision, and you answer from your phone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -25,7 +25,7 @@ Every command prints a single JSON object.
|
|
|
25
25
|
|
|
26
26
|
**Your name.** The daemon gives every agent session a short colour nickname — 🌿 sage, 🌹 rose, 🍇 plum — shown on all your Slack messages with a matching colour bar down their edge. That is how the user tells three agents apart and how they address you: "sage, rerun the tests". `sessions` lists who is running; `status` tells you your own name.
|
|
27
27
|
|
|
28
|
-
**In Herdr, your pane is named after you — by the skill, not by you.** When the session runs inside a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`), the first command labels the pane's tab with your badge (`🍯 amber`) and its agent with your
|
|
28
|
+
**In Herdr, your pane is named after you — by the skill, not by you.** When the session runs inside a [Herdr](https://herdr.dev) pane (`HERDR_ENV=1`), the first command labels the pane's tab with your badge and the first two words of your work (`🍯 amber | build process`, so lead your `--session` with the words that say what it is) and its agent with your work (`build-process-improvements`: Herdr allows only lowercase letters, digits, `-` and `_` there, 32 characters at most), so the Herdr sidebar reads `code-red 🍯 amber build-process-improvements` and matches your Slack messages. The work part is your `--session` name, which is one more reason to pass it (without one, the skill falls back to your terminal title, which may be vague or out of date), and the labels follow a `sessions rename` or a new `--session` name by themselves. Don't run `herdr tab rename` or `herdr agent rename` yourself; `status` shows what was put on under `daemon.me.herdr`, and a label Herdr refused (a name another pane already has) is in `~/.roger-roger/daemon.log`.
|
|
29
29
|
|
|
30
30
|
**Always pass `--session`**, the same 3–6 word name on every call. It is not only a label: for agents that don't publish a session id of their own it is *how you are recognised* between commands, so leaving it off can make one piece of work look like two. Nothing here is specific to any agent — if your environment publishes a session id (anything like `*_SESSION_ID`) it is used automatically; otherwise `--session` plus the working directory identifies you, and the terminal is the last resort. If you know better than all of that, pass `--session-id ID` (and `--agent-pid PID`, so a crash is noticed straight away).
|
|
31
31
|
|
|
@@ -78,7 +78,7 @@ roger-roger notify --kind done --message "Refactor of the billing module is fini
|
|
|
78
78
|
|---|---|
|
|
79
79
|
| `--kind` | `done`, `blocked`, `input`, `milestone`, `error`, `info`. Sets the Slack emoji. |
|
|
80
80
|
| `--message` | The written message for Slack. Make it self-contained, because the user may read it on their phone without context. Say what happened and what, if anything, you need from them. Slack mrkdwn works. |
|
|
81
|
-
| `--say` | The spoken line.
|
|
81
|
+
| `--say` | The spoken line. Short, but it must **carry the point itself**: what happened and what, if anything, you need (see *What to say out loud* below). **Never put code in it**: no commands, flags, paths, URLs, hostnames or JSON. Say what the thing is and why, the way you would to someone across the room: not "ssh -o BatchMode=yes … lsblk …" but "Can I run lsblk on the server, to see which disks it has?". The command itself goes in `--message` (in a code block), where it can be read. Pass `--say` whenever the message has anything technical in it, because without it the message is what gets spoken. Ignored when the user's speech mode is `same`. With `brief`, always pass a one-sentence `--say`. |
|
|
82
82
|
| `--only` | Restrict this one call to some methods, e.g. `--only sound` when the user said "just ding me when done". |
|
|
83
83
|
| `--project` | Label shown in Slack. Defaults to the current directory name. |
|
|
84
84
|
| `--session` | **Always pass it.** A short name for this session, made up by you from what the session is about: 3–6 plain words, e.g. `"roger-roger skill slack integration"` or `"billing refactor tests"`. It's shown in the small `machine · agent · model · session` line under Slack messages, so the user can tell sessions apart. Decide it on your first call and reuse the exact same name for every later call in this session. Never use IDs or hashes. |
|
|
@@ -158,6 +158,19 @@ roger-roger listen --wait 25m
|
|
|
158
158
|
- Don't park after a quick exchange where the user is plainly at the keyboard, and don't park instead of doing work you could be doing.
|
|
159
159
|
- The user sees :eyes: in Slack when an agent is listening or actually running, and :zzz: when the message is saved for one that isn't — so they know whether to expect an answer now or at your next step.
|
|
160
160
|
|
|
161
|
+
### What to say out loud
|
|
162
|
+
|
|
163
|
+
The user hears the line from across the room and may never look at Slack. One or two sentences, and they must be enough on their own: the actual result, the actual question, the actual next step. A line that only points somewhere else ("here is what's next", "details in Slack", "I have an update", "done, see the message") is useless out loud: it makes the user walk over to find out what you could have said.
|
|
164
|
+
|
|
165
|
+
- Say the thing, not that there is a thing. Not "Here's what's next." but "Next I'd set up automatic releases, then the Herdr status pings."
|
|
166
|
+
- Lead with the outcome, then what you need: "The migration is done and all tests pass. Nothing needed from you."
|
|
167
|
+
- For a question, say the question and the choices in words: "Should I archive the twelve orphaned rows, or delete them?"
|
|
168
|
+
- For a failure, say what broke and whether you are stuck: "The build fails on a missing Slack token. I'm blocked until it's set."
|
|
169
|
+
- Numbers and names are fine ("318 tests", "the billing module"); code, paths and links are not.
|
|
170
|
+
- Terse is good, empty is not: "All green." is fine when that is the whole story; "It's done, here's what's next." never is.
|
|
171
|
+
|
|
172
|
+
When there is more than fits in two sentences, say the one thing that matters most, and let the written message carry the rest.
|
|
173
|
+
|
|
161
174
|
### Tone: Gemini audio tags
|
|
162
175
|
|
|
163
176
|
Gemini 3.1 Flash TTS reads inline tags in square brackets and changes its delivery. Put a tag where the change should happen. Choose tags that fit the moment, and use them sparingly (one or two per line):
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
id = "roger-roger"
|
|
8
8
|
name = "roger-roger"
|
|
9
|
-
version = "0.1.
|
|
9
|
+
version = "0.1.13"
|
|
10
10
|
min_herdr_version = "0.9.0"
|
|
11
11
|
description = "Slack, a sound or a spoken line from the agents in your panes, and questions you answer from your phone"
|
|
12
12
|
platforms = ["linux", "macos", "windows"]
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// `<directory> <tab label> <agent label>`. Out of the box that reads "code-red 2 claude" — the tab
|
|
3
3
|
// number and the agent's kind — which says nothing about *which* claude, while the user's Slack
|
|
4
4
|
// messages arrive signed "🍯 amber". This closes that gap: when a session runs inside a Herdr pane,
|
|
5
|
-
// its tab is labelled with the session's badge
|
|
6
|
-
//
|
|
5
|
+
// its tab is labelled with the session's badge and a word or two of the work (`🍯 amber | build
|
|
6
|
+
// process`) and its agent with the work
|
|
7
|
+
// (`build-process-improvements`), so the sidebar and Slack name the same thing.
|
|
7
8
|
//
|
|
8
9
|
// The pane's context comes from the environment Herdr injects into the pane (HERDR_ENV=1 and the
|
|
9
10
|
// ids). The CLI runs there and passes it along with its identity; the daemon, which runs somewhere
|
|
@@ -40,20 +41,46 @@ export function herdrContext(env = process.env) {
|
|
|
40
41
|
|
|
41
42
|
/** What the tab says: the session's badge, `🍯 amber`. */
|
|
42
43
|
export function tabLabel(session) {
|
|
43
|
-
|
|
44
|
+
if (!session?.nickname) return "";
|
|
45
|
+
const work = shortWork(session.name);
|
|
46
|
+
return work ? `${badge(session.nickname)} | ${work}` : badge(session.nickname);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Words that say nothing on their own, skipped when the work is cut down to two.
|
|
50
|
+
const FILLER = new Set(["a", "an", "the", "and", "or", "of", "for", "to", "in", "on", "with", "my", "our", "some", "into", "from", "about", "new"]);
|
|
51
|
+
const MAX_TAB_WORK = 24;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The work in a word or two, for the tab: "security audit of the billing service" → "security
|
|
55
|
+
* audit", "Home Assistant MCP setup" → "home assistant". Lowercase, punctuation gone, filler words
|
|
56
|
+
* skipped, and never longer than a tab can show.
|
|
57
|
+
*/
|
|
58
|
+
export function shortWork(text) {
|
|
59
|
+
const words = String(text ?? "")
|
|
60
|
+
.toLowerCase()
|
|
61
|
+
.replace(/[^\p{L}\p{N}\s-]+/gu, " ")
|
|
62
|
+
.split(/\s+/)
|
|
63
|
+
.map((w) => w.replace(/^-+|-+$/g, ""))
|
|
64
|
+
.filter((w) => w && !FILLER.has(w));
|
|
65
|
+
let out = "";
|
|
66
|
+
for (const word of words.slice(0, 2)) {
|
|
67
|
+
const next = out ? `${out} ${word}` : word;
|
|
68
|
+
if (next.length > MAX_TAB_WORK) break;
|
|
69
|
+
out = next;
|
|
70
|
+
}
|
|
71
|
+
return out || (words[0] ?? "").slice(0, MAX_TAB_WORK);
|
|
44
72
|
}
|
|
45
73
|
|
|
46
74
|
/**
|
|
47
|
-
* What the agent is called: its
|
|
48
|
-
* `
|
|
75
|
+
* What the agent is called: its work, in the only shape Herdr accepts —
|
|
76
|
+
* `build-process-improvements`. Without a `--session` name there is nothing to add to what
|
|
49
77
|
* Herdr already shows (the kind), so the agent is left alone.
|
|
50
78
|
*/
|
|
51
79
|
export function agentName(session) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
return AGENT_NAME.test(name) ? name : "";
|
|
80
|
+
// The work alone: which agent it is (claude, codex) was a guess from the environment, and Herdr
|
|
81
|
+
// already shows the kind beside it.
|
|
82
|
+
const name = slug(session?.name).slice(0, MAX_AGENT_NAME).replace(/[-_]+$/, "");
|
|
83
|
+
return name && AGENT_NAME.test(name) ? name : "";
|
|
57
84
|
}
|
|
58
85
|
|
|
59
86
|
function slug(text) {
|
|
@@ -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. 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
|
|
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
|
|
|
@@ -68,10 +68,22 @@ export function isNickname(value) {
|
|
|
68
68
|
* A free nickname from the pool, chosen at random so consecutive sessions don't look alike. Falls
|
|
69
69
|
* back to numbering (`sage2`) only once every name in the pool is in use.
|
|
70
70
|
*/
|
|
71
|
-
|
|
71
|
+
/**
|
|
72
|
+
* A name for a new session. `taken` are names in use right now and are never given out; `avoid`
|
|
73
|
+
* are names a quiet session was using lately, given out only once nothing else is left, so a
|
|
74
|
+
* session that goes quiet for an hour comes back to its own name rather than someone else's.
|
|
75
|
+
*/
|
|
76
|
+
export function pickName(taken = [], pool = NAMES, random = Math.random, { avoid = [] } = {}) {
|
|
72
77
|
const used = new Set(taken.map((n) => String(n).toLowerCase()));
|
|
73
78
|
const names = (pool.length ? pool : NAMES).map((n) => String(n).toLowerCase()).filter(isNickname);
|
|
74
|
-
|
|
79
|
+
// `avoid` runs from the most recently quiet to the longest quiet. Names are freed from the end
|
|
80
|
+
// of it one at a time, so the name lent out is the one whose owner has been gone the longest.
|
|
81
|
+
const held = avoid.map((n) => String(n).toLowerCase());
|
|
82
|
+
let free = [];
|
|
83
|
+
for (let keep = held.length; keep >= 0 && !free.length; keep--) {
|
|
84
|
+
const reserved = new Set(held.slice(0, keep));
|
|
85
|
+
free = names.filter((n) => !used.has(n) && !reserved.has(n));
|
|
86
|
+
}
|
|
75
87
|
// Two of a kind side by side defeat the point, so prefer an emoji nobody is wearing.
|
|
76
88
|
const swatches = new Set([...used].map((n) => colourOf(n).swatch));
|
|
77
89
|
const distinct = free.filter((n) => !swatches.has(colourOf(n).swatch));
|
|
@@ -16,7 +16,8 @@ import { NAMES, colourOf, pickName } from "./names.mjs";
|
|
|
16
16
|
const QUIET_MS = 30 * 60 * 1000;
|
|
17
17
|
// Even with a live pid, a session that hasn't said anything all day is no longer a sensible target.
|
|
18
18
|
const MAX_IDLE_MS = 12 * 3600 * 1000;
|
|
19
|
-
|
|
19
|
+
// A conversation can be picked up again weeks later, and should come back under the name it had.
|
|
20
|
+
const FORGET_MS = 30 * 24 * 3600 * 1000;
|
|
20
21
|
const MAX_QUEUE = 50;
|
|
21
22
|
|
|
22
23
|
export const STATES = ["working", "waiting", "paused", "done"];
|
|
@@ -87,15 +88,20 @@ export function register(identity, about = {}, pool = NAMES) {
|
|
|
87
88
|
: identity.id;
|
|
88
89
|
if (fingerprint) state.aliases[fingerprint] = id;
|
|
89
90
|
const existing = state.sessions[id];
|
|
90
|
-
const
|
|
91
|
-
|
|
91
|
+
const others = Object.values(state.sessions).filter((s) => s.id !== id && !s.endedAt);
|
|
92
|
+
const taken = others.filter((s) => isLive(s)).map((s) => s.nickname);
|
|
93
|
+
// Names quiet sessions are still remembered by, the most recently quiet first: a newcomer gets
|
|
94
|
+
// one only when nothing else is free, and then the one whose owner has been gone longest.
|
|
95
|
+
const avoid = others
|
|
96
|
+
.filter((s) => !isLive(s))
|
|
97
|
+
.sort((a, b) => Date.parse(b.lastSeen ?? 0) - Date.parse(a.lastSeen ?? 0))
|
|
92
98
|
.map((s) => s.nickname);
|
|
93
99
|
const session = {
|
|
94
100
|
...existing,
|
|
95
101
|
id,
|
|
96
102
|
// A session keeps its name, unless someone else was given it while this one was quiet — two
|
|
97
103
|
// live agents answering to "jade" is exactly what the names are there to prevent.
|
|
98
|
-
nickname: existing?.nickname && !taken.includes(existing.nickname) ? existing.nickname : pickName(taken, pool),
|
|
104
|
+
nickname: existing?.nickname && !taken.includes(existing.nickname) ? existing.nickname : pickName(taken, pool, Math.random, { avoid }),
|
|
99
105
|
pid: identity.pid || existing?.pid || 0,
|
|
100
106
|
cwd: identity.cwd ?? existing?.cwd ?? "",
|
|
101
107
|
project: identity.project || existing?.project || "",
|