roger-roger 0.1.14 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,10 +11,11 @@ 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, readSlackCredentials, rogerRogerHome, saveConfig, voiceSample } from "./lib.mjs";
14
+ import { SKILL_DIR, VERSION, VOICES, applySetup, olderThan, configPath, listSounds, loadConfig, readSlackCredentials, rogerRogerHome, saveConfig, voiceSample } from "./lib.mjs";
15
15
  import { PROVIDERS, SPEECH_SETUP, catalog, keyFor } from "./tts.mjs";
16
16
  import { envValue, hasSavedKey, saveKey } from "./speechkey.mjs";
17
- import { createApp, loginFinish, loginStart, setupStatus } from "./slackapp.mjs";
17
+ import { unregisterWindows } from "./desktop.mjs";
18
+ import { createApp, loginFinish, loginStart, setupStatus, currentApp, deleteApp } from "./slackapp.mjs";
18
19
  import { cliInstallDir, findCli, installCli } from "./slackcli.mjs";
19
20
  import { call, connect, request } from "./client.mjs";
20
21
  import { identity } from "./agent.mjs";
@@ -200,13 +201,7 @@ export function globalCopy(env = process.env) {
200
201
  }
201
202
  }
202
203
 
203
- /** a < b for dotted versions; anything unparseable counts as older. */
204
- export function olderThan(a, b) {
205
- const parse = (v) => String(v ?? "").split(".").map((n) => parseInt(n, 10) || 0);
206
- const [x, y] = [parse(a), parse(b)];
207
- for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
208
- return false;
209
- }
204
+ export { olderThan };
210
205
 
211
206
  /**
212
207
  * Which copy of the skill everything should point at, and whether to offer a global install:
@@ -763,6 +758,9 @@ export async function runInstall(args, { script, hooks, input = process.stdin, o
763
758
  { value: "slack", label: "Slack", hint: "a DM from the roger-roger bot, with buttons to answer", checked: config ? config.methods.includes("slack") : true },
764
759
  { value: "sound", label: "Sound", hint: "a short sound from the speakers", checked: config ? config.methods.includes("sound") : true },
765
760
  { value: "speech", label: "Speech", hint: "a spoken line, in a voice you choose", checked: config ? config.methods.includes("speech") : true },
761
+ ...(["win32", "darwin"].includes(process.platform)
762
+ ? [{ value: "desktop", label: "Desktop notification", hint: process.platform === "win32" ? "a Windows notification, with Go to pane for agents in Herdr" : "a macOS notification; brew install terminal-notifier for Go to pane", checked: config ? config.methods.includes("desktop") : true }]
763
+ : []),
766
764
  ],
767
765
  });
768
766
  if (methods.includes("slack")) {
@@ -1036,13 +1034,17 @@ export async function uninstallFindings({ home = os.homedir(), env = process.env
1036
1034
  home: [rogerRogerHome(env), path.join(home, ".attention")].filter((d) => fs.existsSync(d)),
1037
1035
  global: global?.present ? global : null,
1038
1036
  slackCli: await slackCliFootprint(home),
1037
+ // Only while its record is here: once the home is gone there is nothing left to find it by.
1038
+ slackApp: fs.existsSync(rogerRogerHome(env)) ? currentApp() : null,
1039
1039
  };
1040
1040
  }
1041
1041
 
1042
1042
  /** Everything but the Slack CLI, with no questions. Returns what was done. */
1043
- export async function quietUninstall({ home = os.homedir(), env = process.env, hooks = null, script = "", slackCli = false } = {}) {
1043
+ export async function quietUninstall({ home = os.homedir(), env = process.env, hooks = null, script = "", slackCli = false, slackApp = false } = {}) {
1044
1044
  const found = await uninstallFindings({ home, env, hooks, script });
1045
- const done = { daemonStopped: await stopDaemon(env), hooks: [], links: [], instructions: [], herdr: null, home: [], slackCli: false, global: null };
1045
+ const done = { daemonStopped: await stopDaemon(env), hooks: [], links: [], instructions: [], herdr: null, home: [], slackApp: null, slackCli: false, global: null };
1046
+ // Before the home goes, because the app's ID goes with it.
1047
+ if (slackApp && found.slackApp) done.slackApp = await deleteApp().catch((e) => ({ ok: false, error: e.message }));
1046
1048
  for (const f of found.instructions) {
1047
1049
  try {
1048
1050
  removeInstructions(f.path);
@@ -1072,6 +1074,8 @@ export async function quietUninstall({ home = os.homedir(), env = process.env, h
1072
1074
  fs.rmSync(dir, { recursive: true, force: true });
1073
1075
  done.home.push(dir);
1074
1076
  }
1077
+ // The app id and link handler desktop notifications registered with Windows go with the settings.
1078
+ if (found.home.length) done.desktop = unregisterWindows();
1075
1079
  if (slackCli && found.slackCli.present) {
1076
1080
  removeSlackCli({ home });
1077
1081
  done.slackCli = true;
@@ -1093,7 +1097,7 @@ export async function runUninstall(args, { script, hooks, input = process.stdin,
1093
1097
  const c = ui.style;
1094
1098
 
1095
1099
  if (args.yes || !ui.interactive) {
1096
- const done = await quietUninstall({ home, env, hooks, script, slackCli: Boolean(args["slack-cli"]) });
1100
+ const done = await quietUninstall({ home, env, hooks, script, slackCli: Boolean(args["slack-cli"]), slackApp: Boolean(args["slack-app"]) });
1097
1101
  output.write(JSON.stringify({ ok: true, ...done }, null, 2) + "\n");
1098
1102
  return 0;
1099
1103
  }
@@ -1108,6 +1112,9 @@ export async function runUninstall(args, { script, hooks, input = process.stdin,
1108
1112
  if (found.hookAgents.length) choices.push({ value: "hooks", label: "Terminal-question hooks", hint: found.hookAgents.map((n) => labels[n] ?? n).join(", "), checked: true });
1109
1113
  if (found.herdr) choices.push({ value: "herdr", label: "The Herdr plugin", hint: "herdr plugin unlink", checked: true });
1110
1114
  if (found.home.length) choices.push({ value: "home", label: "Settings, Slack tokens, state", hint: found.home.join(", "), checked: true });
1115
+ // Left in the workspace without its settings, the app can't be found again, and the next install
1116
+ // makes a second one beside it. So it goes with the settings unless the user says otherwise.
1117
+ if (found.slackApp) choices.push({ value: "slack-app", label: "The roger-roger Slack app", hint: `${found.slackApp.teamDomain ?? found.slackApp.teamId} (${found.slackApp.appId}), deleted from the workspace; kept, it becomes a duplicate when you install again`, checked: true });
1111
1118
  if (found.global) choices.push({ value: "global", label: "The roger-roger command", hint: `npm uninstall -g (${found.global.dir})`, checked: true });
1112
1119
  if (found.slackCli.present) choices.push({ value: "slack-cli", label: "The Slack CLI", hint: `${found.slackCli.dir} and its login in ~/.slack; Slack's tool, so off by default`, checked: false });
1113
1120
  if (!choices.length) {
@@ -1127,6 +1134,15 @@ export async function runUninstall(args, { script, hooks, input = process.stdin,
1127
1134
 
1128
1135
  const removed = [];
1129
1136
  if (await stopDaemon(env)) ui.done("Stopped the daemon and the tray");
1137
+ if (what.includes("slack-app")) {
1138
+ // A failure is shown on the task's own line, with where to delete it by hand.
1139
+ const r = await ui.run("Deleting the Slack app", async () => {
1140
+ const d = await deleteApp();
1141
+ if (!d.ok) throw new Error(d.error);
1142
+ return d;
1143
+ }, { done: (d) => (d.deleted ? `Deleted the Slack app ${d.appId}` : d.note) }).catch(() => null);
1144
+ if (r?.deleted) removed.push("Slack app");
1145
+ }
1130
1146
  if (what.includes("hooks")) {
1131
1147
  for (const name of found.hookAgents) {
1132
1148
  try {
@@ -1173,6 +1189,7 @@ export async function runUninstall(args, { script, hooks, input = process.stdin,
1173
1189
  ui.done("Removed", dir);
1174
1190
  removed.push(dir);
1175
1191
  }
1192
+ if (unregisterWindows().removed) ui.done("Removed the desktop notification registration", "HKCU\\Software\\Classes\\roger-roger");
1176
1193
  }
1177
1194
  if (what.includes("slack-cli")) {
1178
1195
  removeSlackCli({ home });
@@ -13,19 +13,35 @@ import { detectAgent } from "./agent.mjs";
13
13
  export const SKILL_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
14
14
  export const SOUNDS_DIR = path.join(SKILL_DIR, "sounds");
15
15
 
16
- /** The package's version, when the skill sits inside the package; empty for a bare skills.sh copy. */
16
+ /**
17
+ * The package's version. A bare skills.sh copy has no package.json around it, but it does carry the
18
+ * Herdr manifest, which is bumped with every release; only a copy with neither is unversioned.
19
+ */
17
20
  export const VERSION = (() => {
18
21
  try {
19
22
  return JSON.parse(fs.readFileSync(path.join(SKILL_DIR, "..", "..", "package.json"), "utf8")).version ?? "";
20
23
  } catch {
21
- return "";
24
+ try {
25
+ return /^version = "([^"]*)"/m.exec(fs.readFileSync(path.join(SKILL_DIR, "herdr-plugin.toml"), "utf8"))?.[1] ?? "";
26
+ } catch {
27
+ return "";
28
+ }
22
29
  }
23
30
  })();
24
31
 
25
- export const METHODS = ["slack", "sound", "speech"];
32
+ /** a < b for dotted versions; anything unparseable counts as older. */
33
+ export function olderThan(a, b) {
34
+ const parse = (v) => String(v ?? "").split(".").map((n) => parseInt(n, 10) || 0);
35
+ const [x, y] = [parse(a), parse(b)];
36
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0);
37
+ return false;
38
+ }
39
+
40
+ export const METHODS = ["slack", "sound", "speech", "desktop"];
26
41
  export const KINDS = ["done", "blocked", "input", "milestone", "error", "info"];
27
42
  export const SPEECH_MODES = ["auto", "same", "brief"];
28
43
  export const WHEN_MODES = ["auto", "done-and-blocked", "blocked-only", "on-request"];
44
+ export const PRESENCE_MODES = ["on", "off"];
29
45
 
30
46
  export const DEFAULT_MODEL = "gemini-3.1-flash-tts-preview";
31
47
 
@@ -163,6 +179,12 @@ export const DEFAULTS = {
163
179
  speechVolume: 100,
164
180
  // The tray icon: "auto" starts it with the daemon whenever it is installed, "off" leaves it to `tray start`.
165
181
  tray: "auto",
182
+ // Whether the user is at the computer decides how a notification reaches them. "on": at the desk
183
+ // (touched the keyboard or mouse in the last `awayAfter` seconds, screen unlocked) only the
184
+ // `presentMethods` of the configured ones are used; away or locked, all of them. "off": always all.
185
+ presence: "on",
186
+ presentMethods: ["sound", "speech", "desktop"],
187
+ awayAfter: 180,
166
188
  notes: "",
167
189
  };
168
190
 
@@ -485,6 +507,21 @@ export function applySetup(base, args, sounds = listSounds(), speech = GEMINI_ON
485
507
  const v = String(args.tray).toLowerCase();
486
508
  c.tray = v === "on" || v === "true" || v === "auto" ? "auto" : v === "off" || v === "false" ? "off" : oneOf(v, ["auto", "off"], "tray");
487
509
  }
510
+ if (args.presence !== undefined) {
511
+ const v = String(args.presence).toLowerCase();
512
+ c.presence = v === "true" || v === "on" ? "on" : v === "false" || v === "off" ? "off" : oneOf(v, PRESENCE_MODES, "presence");
513
+ }
514
+ if (args["present-methods"] !== undefined) {
515
+ const value = String(args["present-methods"]).trim().toLowerCase();
516
+ const methods = value === "none" ? [] : splitList(value);
517
+ for (const m of methods) oneOf(m, METHODS, "present-methods");
518
+ c.presentMethods = [...new Set(methods)];
519
+ }
520
+ if (args["away-after"] !== undefined) {
521
+ const seconds = parseDuration(String(args["away-after"]));
522
+ if (!Number.isFinite(seconds) || seconds < 10 || seconds > 4 * 3600) throw new Error("--away-after must be a duration from 10s to 4h, e.g. 3m");
523
+ c.awayAfter = seconds;
524
+ }
488
525
  if (args.remind !== undefined) c.remind = parseRemind(args.remind).map((s) => formatDuration(s));
489
526
  if (args["on-expire"] !== undefined) c.onExpire = oneOf(args["on-expire"], ON_EXPIRE, "on-expire");
490
527
  if (args["quiet-hours"] !== undefined) {
@@ -1067,6 +1104,29 @@ export function parseProgressActionId(value) {
1067
1104
  return m ? { key: m[1], action: m[2] } : null;
1068
1105
  }
1069
1106
 
1107
+ export const GOTO_PREFIX = "roger-roger_goto";
1108
+
1109
+ /**
1110
+ * A "Go to pane" button for a message from an agent in a Herdr pane: pressed on the desktop Slack,
1111
+ * it brings that agent's tab to the front. Put before the session label, which stays last.
1112
+ */
1113
+ export function withGotoButton(blocks, sessionId) {
1114
+ if (!sessionId) return blocks;
1115
+ const button = {
1116
+ type: "actions",
1117
+ block_id: `${GOTO_PREFIX}_block`,
1118
+ elements: [{ type: "button", action_id: `${GOTO_PREFIX}:${sessionId}`, text: { type: "plain_text", text: ":desktop_computer: Go to pane", emoji: true } }],
1119
+ };
1120
+ const last = blocks.length - 1;
1121
+ return last >= 0 && blocks[last].type === "context" ? [...blocks.slice(0, last), button, blocks[last]] : [...blocks, button];
1122
+ }
1123
+
1124
+ /** `roger-roger_goto:<session id>` → the session id, or null. */
1125
+ export function parseGotoActionId(value) {
1126
+ const m = new RegExp(`^${GOTO_PREFIX}:(.+)$`).exec(String(value));
1127
+ return m ? m[1] : null;
1128
+ }
1129
+
1070
1130
  /** 45s, 12m, 1h 12m, 2d 3h. */
1071
1131
  export function formatElapsed(ms) {
1072
1132
  const s = Math.max(0, Math.round(ms / 1000));
@@ -0,0 +1,11 @@
1
+ // Windows only: what a roger-roger:// link runs, by way of wscript.exe. A notification's buttons are
2
+ // links, and Windows starts a link's handler as a fresh process; node.exe started that way opens a
3
+ // console window for a moment. wscript has no console, so this hands the link to node hidden.
4
+ //
5
+ // Arguments: node.exe, roger-roger.mjs, the link.
6
+ var shell = new ActiveXObject("WScript.Shell");
7
+ var args = WScript.Arguments;
8
+ if (args.length >= 3) {
9
+ var quote = function (s) { return '"' + String(s).replace(/"/g, "") + '"'; };
10
+ shell.Run(quote(args(0)) + " " + quote(args(1)) + " open-url " + quote(args(2)), 0, false);
11
+ }
@@ -0,0 +1,157 @@
1
+ // Is the user at the computer? How long since they last touched the keyboard or mouse, and whether
2
+ // the screen is locked. A notification while they are sitting in front of the terminal doesn't need
3
+ // to go to their phone as well; one while they are away does.
4
+ //
5
+ // Every platform answers differently, and some not at all. Anything that can't be read is "unknown",
6
+ // and unknown changes nothing: the configured methods are used as they always were.
7
+ //
8
+ // - Windows: GetLastInputInfo, and LogonUI running for a locked screen. PowerShell takes a second or
9
+ // two to start, so one helper is kept, like the audio player, and asked a line at a time.
10
+ // - macOS: HIDIdleTime from `ioreg`. The lock isn't readable without a helper binary; idle covers it.
11
+ // - Linux: `loginctl` for the lock (and idle hint), `xprintidle` for idle time under X when installed.
12
+
13
+ import { execFile, spawn } from "node:child_process";
14
+
15
+ const PS_PRESENCE = `
16
+ $ErrorActionPreference = 'Stop'
17
+ Add-Type @'
18
+ using System;
19
+ using System.Runtime.InteropServices;
20
+ public static class RogerRogerIdle {
21
+ [StructLayout(LayoutKind.Sequential)] struct LastInput { public uint cbSize; public uint dwTime; }
22
+ [DllImport("user32.dll")] static extern bool GetLastInputInfo(ref LastInput info);
23
+ public static long Ms() {
24
+ var info = new LastInput(); info.cbSize = (uint)Marshal.SizeOf(info);
25
+ if (!GetLastInputInfo(ref info)) return -1;
26
+ return (long)(uint)(Environment.TickCount - (int)info.dwTime);
27
+ }
28
+ }
29
+ '@
30
+ [Console]::Out.WriteLine('READY')
31
+ while ($null -ne ($line = [Console]::In.ReadLine())) {
32
+ try {
33
+ $locked = [bool](Get-Process -Name LogonUI -ErrorAction SilentlyContinue)
34
+ [Console]::Out.WriteLine("$([RogerRogerIdle]::Ms()) $([int]$locked)")
35
+ } catch {
36
+ [Console]::Out.WriteLine('-1 0')
37
+ }
38
+ }
39
+ `;
40
+
41
+ const ANSWER_TIMEOUT_MS = 5_000;
42
+ let helper = null;
43
+
44
+ function windowsHelper() {
45
+ if (helper) return helper;
46
+ const child = spawn("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", PS_PRESENCE], {
47
+ stdio: ["pipe", "pipe", "ignore"],
48
+ windowsHide: true,
49
+ });
50
+ const waiters = [];
51
+ let buffer = "";
52
+ const self = {
53
+ ask() {
54
+ return new Promise((resolve) => {
55
+ const timer = setTimeout(() => resolve(null), ANSWER_TIMEOUT_MS);
56
+ waiters.push((line) => (clearTimeout(timer), resolve(line)));
57
+ try {
58
+ child.stdin.write("?\n");
59
+ } catch {
60
+ resolve(null);
61
+ }
62
+ });
63
+ },
64
+ close() {
65
+ if (helper === self) helper = null;
66
+ try {
67
+ child.stdin.end();
68
+ } catch {}
69
+ },
70
+ };
71
+ const fail = () => {
72
+ if (helper === self) helper = null;
73
+ while (waiters.length) waiters.shift()(null);
74
+ };
75
+ child.stdout.setEncoding("utf8");
76
+ child.stdout.on("data", (chunk) => {
77
+ buffer += chunk;
78
+ let nl;
79
+ while ((nl = buffer.indexOf("\n")) !== -1) {
80
+ const line = buffer.slice(0, nl).trim();
81
+ buffer = buffer.slice(nl + 1);
82
+ if (line && line !== "READY") waiters.shift()?.(line);
83
+ }
84
+ });
85
+ child.stdin.on("error", fail);
86
+ child.on("error", fail);
87
+ child.on("exit", fail);
88
+ // It must not keep a finished CLI process alive; the daemon has other reasons to stay up.
89
+ child.unref();
90
+ child.stdout.unref?.();
91
+ child.stdin.unref?.();
92
+ helper = self;
93
+ return self;
94
+ }
95
+
96
+ const run = (cmd, args) => new Promise((resolve) => {
97
+ execFile(cmd, args, { encoding: "utf8", timeout: 3_000, windowsHide: true }, (error, stdout) => resolve(error ? null : stdout));
98
+ });
99
+
100
+ /** `{ idleMs, locked }`, either of them null when this machine won't say. */
101
+ export async function readInput(platform = process.platform) {
102
+ if (platform === "win32") {
103
+ const line = await windowsHelper().ask();
104
+ const [ms, locked] = String(line ?? "").split(" ").map(Number);
105
+ return { idleMs: Number.isFinite(ms) && ms >= 0 ? ms : null, locked: line ? locked === 1 : null };
106
+ }
107
+ if (platform === "darwin") {
108
+ const out = await run("ioreg", ["-c", "IOHIDSystem", "-d", "4"]);
109
+ const ns = /"HIDIdleTime"\s*=\s*(\d+)/.exec(out ?? "")?.[1];
110
+ return { idleMs: ns ? Number(BigInt(ns) / 1_000_000n) : null, locked: null };
111
+ }
112
+ const session = process.env.XDG_SESSION_ID;
113
+ const hints = session ? await run("loginctl", ["show-session", session, "-p", "LockedHint", "-p", "IdleSinceHintMonotonic", "-p", "IdleHint"]) : null;
114
+ const locked = hints ? /LockedHint=yes/.test(hints) : null;
115
+ const x = process.env.DISPLAY ? await run("xprintidle", []) : null;
116
+ const idleMs = x && /^\d+/.test(x.trim()) ? Number(x.trim()) : null;
117
+ return { idleMs, locked };
118
+ }
119
+
120
+ /** Lets a finished process exit. */
121
+ export function closePresence() {
122
+ helper?.close();
123
+ }
124
+
125
+ export const STATES = ["present", "away", "locked", "unknown"];
126
+
127
+ /**
128
+ * `present`, `away` (idle for at least `awayAfterMs`), `locked`, or `unknown`, with the idle time
129
+ * that decided it. `read` is for tests.
130
+ */
131
+ export async function presence({ awayAfterMs, read = readInput, env = process.env } = {}) {
132
+ // Forced, for tests and for trying the settings out without walking away from the desk.
133
+ const forced = String(env.ROGER_ROGER_PRESENCE ?? "").toLowerCase();
134
+ if (STATES.includes(forced)) return { state: forced, idleSeconds: null, forced: true };
135
+ let input;
136
+ try {
137
+ input = await read();
138
+ } catch {
139
+ input = { idleMs: null, locked: null };
140
+ }
141
+ const idleSeconds = input.idleMs === null ? null : Math.round(input.idleMs / 1000);
142
+ if (input.locked) return { state: "locked", idleSeconds };
143
+ if (input.idleMs === null) return { state: "unknown", idleSeconds };
144
+ return { state: input.idleMs >= awayAfterMs ? "away" : "present", idleSeconds };
145
+ }
146
+
147
+ /**
148
+ * The methods to use, given where the user is. At the desk they get `presentMethods` of what was
149
+ * configured: the terminal is in front of them, so a phone that buzzes too is one ping too many.
150
+ * Anything else — away, locked, or a machine that won't say — gets everything configured.
151
+ * `keep` are methods this call needs whatever happens (a reply in a Slack thread belongs in Slack).
152
+ */
153
+ export function methodsForPresence(methods, where, { presentMethods, keep = [] }) {
154
+ if (where !== "present") return { methods, skipped: [] };
155
+ const allowed = new Set([...presentMethods, ...keep]);
156
+ return { methods: methods.filter((m) => allowed.has(m)), skipped: methods.filter((m) => !allowed.has(m)) };
157
+ }
@@ -20,6 +20,7 @@ import { call, connect, request } from "./client.mjs";
20
20
  import { identity } from "./protocol.mjs";
21
21
  import { FROM_HOOKS, HANDLERS, owedTo } from "./handlers.mjs";
22
22
  import { closeAudio } from "./audio.mjs";
23
+ import { closePresence } from "./presence.mjs";
23
24
  import { createApp, hooksConfig, loginFinish, loginStart, runHook, setupStatus } from "./slackapp.mjs";
24
25
  import { findCli, installCli } from "./slackcli.mjs";
25
26
  import { daemonRunning } from "./daemon.mjs";
@@ -32,6 +33,9 @@ import { codexHome, codexHookRequest, codexStatus, installCodex, uninstallCodex
32
33
  import { installOpencode, opencodeConfigDir, opencodeHookRequest, opencodeStatus, uninstallOpencode } from "./hooks-opencode.mjs";
33
34
  import { runInstall, runUninstall } from "./install.mjs";
34
35
  import { helpText } from "./help.mjs";
36
+ import { formatDoctor, runDoctor } from "./doctor.mjs";
37
+ import { parseOpenUrl, showDesktop, notificationText } from "./desktop.mjs";
38
+ import { coloursOn, makeStyle } from "./tui.mjs";
35
39
 
36
40
  function out(obj, code = 0) {
37
41
  process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
@@ -193,7 +197,7 @@ async function status() {
193
197
  slackTokenSource: readSlackCredentials() ? "slack-setup" : slackToken() ? "environment" : null,
194
198
  slackAppToken: Boolean(slackAppToken()),
195
199
  ...(rawAppToken && !slackAppToken()
196
- ? { slackAppTokenWarning: "the Slack app token is not an app-level xapp- token, so `ask` is unavailable (see SKILL.md, Requirements)" }
200
+ ? { slackAppTokenWarning: "the Slack app token is not an app-level xapp- token, so `ask` is unavailable (see reference.md, Requirements)" }
197
201
  : {}),
198
202
  // Who speaks, where its key is read from, and whether anything is there. Never the key itself.
199
203
  speechProvider: providerOf(config).id,
@@ -277,6 +281,71 @@ async function daemonCommand(args) {
277
281
  throw new Error(`unknown daemon command "${step}" (status, start, stop)`);
278
282
  }
279
283
 
284
+ /**
285
+ * `doctor`: what could stop a notification arriving, and what to do about each. A person at a
286
+ * terminal gets lines; anything else (an agent) the JSON, like every other command.
287
+ */
288
+ async function doctor(args) {
289
+ let daemon = null;
290
+ try {
291
+ daemon = (await askDaemon("status")).data;
292
+ } catch {}
293
+ const report = await runDoctor({ daemon, agents: AGENTS, script: SCRIPT, offline: Boolean(args.offline) });
294
+ if (process.stdout.isTTY && !args.json) {
295
+ process.stdout.write(formatDoctor(report, makeStyle(coloursOn())));
296
+ process.exitCode = report.ok ? 0 : 1;
297
+ return;
298
+ }
299
+ return out(report, report.ok ? 0 : 1);
300
+ }
301
+
302
+ /**
303
+ * `open-url roger-roger://goto/sage`: what a notification's button runs. It comes from a click, not
304
+ * from an agent, so it asks the daemon as nobody: running it must not register a session of its own.
305
+ * Any web page can open a roger-roger:// link too, which is why only "go to that pane" is accepted.
306
+ */
307
+ async function openUrl(args) {
308
+ // Started by a click, with nowhere to print: the log is the only way to see what happened.
309
+ const note = (line) => {
310
+ try {
311
+ fs.mkdirSync(rogerRogerHome(), { recursive: true });
312
+ fs.appendFileSync(path.join(rogerRogerHome(), "open-url.log"), `${new Date().toISOString()} ${args._[0] ?? "(none)"}: ${line}\n`);
313
+ } catch {}
314
+ };
315
+ const link = parseOpenUrl(args._[0]);
316
+ if (!link) {
317
+ note("refused");
318
+ return out({ ok: false, error: `not a link roger-roger opens: ${args._[0] ?? "(none)"}` }, 2);
319
+ }
320
+ const nobody = { identity: null, about: {} };
321
+ try {
322
+ let result;
323
+ try {
324
+ result = await call("goto", { _: [link.who] }, { session: nobody });
325
+ } catch (e) {
326
+ // A daemon from an older copy doesn't know `goto` (exit 2). Focusing a tab needs nothing it
327
+ // holds, so it is done here instead.
328
+ if (e.code !== 2) throw e;
329
+ result = await runLocally("goto", { _: [link.who] }, nobody);
330
+ }
331
+ const { data, code } = result;
332
+ note(data?.ok === false ? `failed: ${data.error}` : `focused ${link.who}`);
333
+ return out(data, code);
334
+ } catch (e) {
335
+ note(`failed: ${e.message}`);
336
+ return out({ ok: false, error: e.message }, typeof e.code === "number" ? e.code : 1);
337
+ }
338
+ }
339
+
340
+ /** `desktop test`: one notification now, to see that this machine shows them. */
341
+ async function desktopCommand(args) {
342
+ const step = args._[0] ?? "test";
343
+ if (step !== "test") throw new Error(`unknown desktop command "${step}" (test)`);
344
+ const text = notificationText({ kind: "info", message: "Desktop notifications work. Agents' messages will look like this.", nickname: null, project: "roger-roger" });
345
+ const r = await showDesktop({ ...text, goto: typeof args.goto === "string" ? args.goto : null });
346
+ return out(r, r.ok ? 0 : 1);
347
+ }
348
+
280
349
  // ---------------------------------------------------------------- agent hooks
281
350
 
282
351
  const SCRIPT = fileURLToPath(import.meta.url);
@@ -445,6 +514,9 @@ async function main() {
445
514
  process.exitCode = await runUninstall(args, { script: SCRIPT, hooks: { agents: AGENTS } });
446
515
  return;
447
516
  case "status": return status();
517
+ case "doctor": return doctor(args);
518
+ case "open-url": return openUrl(args);
519
+ case "desktop": return desktopCommand(args);
448
520
  case "setup": return setup(args);
449
521
  case "slack-setup": return slackSetup(args);
450
522
  case "slack-hook":
@@ -457,6 +529,7 @@ async function main() {
457
529
  // `sessions rename sage plum` is the only sub-command; anything else lists them.
458
530
  case "sessions": return args._[0] === "rename" ? run("rename", { ...args, _: args._.slice(1) }) : run("sessions", args);
459
531
  case "end": return run("end", args);
532
+ case "goto": return run("goto", args);
460
533
  case "inbox":
461
534
  case "messages": return run("messages", args);
462
535
 
@@ -488,6 +561,7 @@ main()
488
561
  .catch((e) => out({ ok: false, error: e.message }, typeof e.code === "number" ? e.code : 1))
489
562
  .finally(() => {
490
563
  closeAudio();
564
+ closePresence();
491
565
  // Normally the process exits on its own. If idle sockets hold it open, exit anyway after a
492
566
  // moment (an immediate process.exit can trip a libuv assertion on Windows).
493
567
  setTimeout(() => process.exit(), 3000).unref();
@@ -129,9 +129,20 @@ export function get(id) {
129
129
  return load().sessions[id] ?? null;
130
130
  }
131
131
 
132
- export function byNickname(nickname) {
132
+ /**
133
+ * The session going by that name now. Names are lent out again once their owner has gone, so old
134
+ * records can share it: the running one wins, then whichever was seen last. Taking the first one
135
+ * found sent "go to flint" to a pane closed a week before.
136
+ */
137
+ export function byNickname(nickname, now = Date.now()) {
133
138
  const want = String(nickname ?? "").toLowerCase();
134
- return Object.values(load().sessions).find((s) => s.nickname === want) ?? null;
139
+ const named = Object.values(load().sessions).filter((s) => s.nickname === want);
140
+ const rank = (s) => [isLive(s, now) ? 1 : 0, Date.parse(s.lastSeen ?? 0) || 0];
141
+ named.sort((a, b) => {
142
+ const [x, y] = [rank(a), rank(b)];
143
+ return y[0] - x[0] || y[1] - x[1];
144
+ });
145
+ return named[0] ?? null;
135
146
  }
136
147
 
137
148
  /** Every live session, oldest first, with its colour resolved. */
@@ -43,10 +43,23 @@ export function hooksConfig() {
43
43
  return { hooks: { "get-hooks": hookCommand("get-hooks"), deploy: hookCommand("deploy") } };
44
44
  }
45
45
 
46
- /** Write (or refresh) the project. The app IDs the CLI keeps in .slack/apps.json are left alone. */
46
+ const appsFile = () => path.join(projectDir(), ".slack", "apps.json");
47
+
48
+ /**
49
+ * Write (or refresh) the project. The app IDs the CLI keeps in .slack/apps.json are left alone, and
50
+ * put back from slack.json when they went missing: without them `slack deploy` doesn't update the
51
+ * app the user already has, it makes another one, and the workspace fills up with roger-roger apps.
52
+ */
47
53
  function writeProject() {
48
54
  const dir = projectDir();
49
55
  fs.mkdirSync(path.join(dir, ".slack"), { recursive: true });
56
+ const known = readSlackCredentials()?.app;
57
+ if (!fs.existsSync(appsFile()) && known?.appId && known?.teamId) {
58
+ fs.writeFileSync(appsFile(), JSON.stringify({
59
+ apps: { [known.teamId]: { app_id: known.appId, team_id: known.teamId, ...(known.teamDomain ? { team_domain: known.teamDomain } : {}) } },
60
+ ...(known.teamDomain ? { default: known.teamDomain } : {}),
61
+ }, null, 2) + "\n");
62
+ }
50
63
  fs.writeFileSync(path.join(dir, ".slack", "hooks.json"), JSON.stringify(hooksConfig(), null, 2) + "\n");
51
64
  // The CLI reads manifest.json from the project root; the skill ships the source of truth.
52
65
  fs.copyFileSync(MANIFEST_PATH, path.join(dir, "manifest.json"));
@@ -60,7 +73,7 @@ export function linkedApp() {
60
73
  try {
61
74
  const apps = JSON.parse(fs.readFileSync(path.join(projectDir(), ".slack", "apps.json"), "utf8"));
62
75
  const app = Object.values(apps.apps ?? {})[0];
63
- return app ? { appId: app.app_id, teamId: app.team_id } : null;
76
+ return app ? { appId: app.app_id, teamId: app.team_id, ...(app.team_domain ? { teamDomain: app.team_domain } : {}) } : null;
64
77
  } catch {
65
78
  return null;
66
79
  }
@@ -82,10 +95,12 @@ export function runHook(name, env = process.env) {
82
95
  process.stderr.write("roger-roger: the Slack CLI did not provide a bot token\n");
83
96
  return 1;
84
97
  }
98
+ const app = linkedApp() ?? readSlackCredentials()?.app;
85
99
  saveCredentials({
86
100
  source: "slack-cli",
87
101
  botToken,
88
102
  appToken: appToken.startsWith("xapp-") ? appToken : "",
103
+ ...(app ? { app } : {}),
89
104
  savedAt: new Date().toISOString(),
90
105
  });
91
106
  process.stdout.write(`roger-roger: saved Slack credentials to ${slackCredentialsPath()}\n`);
@@ -195,7 +210,10 @@ export async function createApp({ teamId } = {}) {
195
210
  const r = await slack(cli, ["deploy", "--team", account.teamId], { cwd });
196
211
  if (r.code !== 0) throw new Error(`slack deploy failed: ${r.out}`);
197
212
 
213
+ // The CLI writes the app's ID once the app exists, which is after the hook saved the tokens.
214
+ const created = linkedApp();
198
215
  const stored = readSlackCredentials();
216
+ if (stored && created && stored.app?.appId !== created.appId) saveCredentials({ ...stored, app: created });
199
217
  const botOk = await tokenWorks("auth.test", stored?.botToken);
200
218
  const appOk = await tokenWorks("apps.connections.open", stored?.appToken);
201
219
  if (!botOk || !appOk) {
@@ -203,3 +221,25 @@ export async function createApp({ teamId } = {}) {
203
221
  }
204
222
  return { ...linkedApp(), team: account.team, userId: account.userId };
205
223
  }
224
+
225
+ /** The app this machine uses: from the CLI's project, or from slack.json when that went missing. */
226
+ export function currentApp() {
227
+ return linkedApp() ?? readSlackCredentials()?.app ?? null;
228
+ }
229
+
230
+ export const appUrl = (appId) => `https://api.slack.com/apps/${appId}`;
231
+
232
+ /**
233
+ * Delete the roger-roger app from its workspace, for uninstall. Without its settings an app left in
234
+ * the workspace can't be found again, and the next install makes a second one beside it.
235
+ */
236
+ export async function deleteApp() {
237
+ const app = currentApp();
238
+ if (!app?.appId) return { ok: true, deleted: false, note: "no app is recorded on this machine" };
239
+ const cli = await findCli();
240
+ if (!cli) return { ok: false, deleted: false, appId: app.appId, error: `the Slack CLI isn't installed; delete it at ${appUrl(app.appId)}` };
241
+ writeProject();
242
+ const r = await slack(cli, ["app", "delete", "--app", app.appId, "--team", app.teamId, "--force"], { cwd: projectDir(), input: "y\n", timeout: 60_000 });
243
+ if (r.code !== 0) return { ok: false, deleted: false, appId: app.appId, error: `${r.out || "slack app delete failed"} (or delete it at ${appUrl(app.appId)})` };
244
+ return { ok: true, deleted: true, appId: app.appId, teamId: app.teamId };
245
+ }
@@ -36,6 +36,8 @@ function agentLine(session, pending, now) {
36
36
  quietForMs: Math.max(0, now - Date.parse(session.lastSeen ?? 0)),
37
37
  queued: session.queue?.length ?? 0,
38
38
  owes: decisions.undeliveredFor(session.id).length,
39
+ // In a Herdr pane, so the panel can offer to bring its tab to the front.
40
+ inHerdr: Boolean(session.herdr?.tab),
39
41
  ...(waiting ? { waitingOn: { id: waiting.id, question: waiting.question, expiresAt: waiting.expiresAt } } : {}),
40
42
  };
41
43
  }
@@ -6,7 +6,7 @@
6
6
  },
7
7
  "features": {
8
8
  "app_home": {
9
- "home_tab_enabled": false,
9
+ "home_tab_enabled": true,
10
10
  "messages_tab_enabled": true,
11
11
  "messages_tab_read_only_enabled": false
12
12
  },
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "settings": {
24
24
  "event_subscriptions": {
25
- "bot_events": ["message.im"]
25
+ "bot_events": ["message.im", "app_home_opened"]
26
26
  },
27
27
  "interactivity": {
28
28
  "is_enabled": true
@@ -467,6 +467,10 @@ async function main() {
467
467
  }
468
468
  if (ask.do === "snooze") return run("snooze", ask.value || "1h");
469
469
  if (ask.do === "hush") return run("hush");
470
+ if (ask.do === "goto" && ask.value) {
471
+ hidePanel();
472
+ return run("goto", String(ask.value));
473
+ }
470
474
  if (ask.do === "hide") return hidePanel();
471
475
  if (ask.do === "keep-open") {
472
476
  keepOpen = Boolean(ask.value);