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 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 kind and work on the agent (`claude-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.
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.8",
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 kind and your work (`claude-build-process-improvements`: Herdr allows only lowercase letters, digits, `-` and `_` there, 32 characters at most), so the Herdr sidebar reads `code-red 🍯 amber claude-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`.
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. Keep it short, one or two sentences, and **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`. |
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.8"
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 (`🍯 amber`) and its agent with the kind and the work
6
- // (`claude-build-process-improvements`), so the sidebar and Slack name the same thing.
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
- return session?.nickname ? badge(session.nickname) : "";
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 kind and its work, in the only shape Herdr accepts —
48
- * `claude-build-process-improvements`. Without a `--session` name there is nothing to add to what
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
- const work = slug(session?.name);
53
- if (!work) return "";
54
- const kind = slug(session?.agent) || "agent";
55
- const name = `${kind}-${work}`.slice(0, MAX_AGENT_NAME).replace(/[-_]+$/, "");
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. Never put code, commands, paths or URLs in the spoken line (`--say`): say what the thing is and why, and leave the command itself to the written message. 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
 
@@ -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
- export function pickName(taken = [], pool = NAMES, random = Math.random) {
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
- const free = names.filter((n) => !used.has(n));
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
- const FORGET_MS = 7 * 24 * 3600 * 1000;
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 taken = Object.values(state.sessions)
91
- .filter((s) => s.id !== id && isLive(s))
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 || "",