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.
- package/README.md +7 -2
- package/package.json +1 -1
- package/skills/roger-roger/SKILL.md +62 -243
- package/skills/roger-roger/herdr-plugin.toml +1 -1
- package/skills/roger-roger/reference.md +150 -0
- package/skills/roger-roger/scripts/client.mjs +5 -2
- package/skills/roger-roger/scripts/daemon.mjs +94 -7
- package/skills/roger-roger/scripts/desktop.mjs +171 -0
- package/skills/roger-roger/scripts/doctor.mjs +241 -0
- package/skills/roger-roger/scripts/handlers.mjs +73 -7
- package/skills/roger-roger/scripts/help.mjs +8 -2
- package/skills/roger-roger/scripts/herdr.mjs +12 -0
- package/skills/roger-roger/scripts/home.mjs +105 -0
- package/skills/roger-roger/scripts/install.mjs +29 -12
- package/skills/roger-roger/scripts/lib.mjs +63 -3
- package/skills/roger-roger/scripts/open-url.js +11 -0
- package/skills/roger-roger/scripts/presence.mjs +157 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +75 -1
- package/skills/roger-roger/scripts/sessions.mjs +13 -2
- package/skills/roger-roger/scripts/slackapp.mjs +42 -2
- package/skills/roger-roger/scripts/tray.mjs +2 -0
- package/skills/roger-roger/slack/manifest.json +2 -2
- package/skills/roger-roger/tray/main.mjs +4 -0
- package/skills/roger-roger/tray/panel.html +11 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// `roger-roger doctor`: everything that can quietly stop a notification from arriving, checked in
|
|
2
|
+
// one go, each with the one thing to do about it. `status` says what is set; this says what is wrong.
|
|
3
|
+
//
|
|
4
|
+
// It reads, and never changes anything. The checks that need the network (are the Slack tokens still
|
|
5
|
+
// good, does the bot have every scope) are skipped with --offline.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
10
|
+
import { SKILL_DIR, VERSION, configPath, loadConfig, olderThan, pidAlive, rogerRogerHome, readSlackCredentials, slackAppToken, slackToken } from "./lib.mjs";
|
|
11
|
+
import { keyFor, providerOf } from "./tts.mjs";
|
|
12
|
+
import { apiBase } from "./slack.mjs";
|
|
13
|
+
import { MANIFEST_PATH, appUrl, currentApp } from "./slackapp.mjs";
|
|
14
|
+
import { trayInstalled, trayProcesses } from "./handlers.mjs";
|
|
15
|
+
import { presence } from "./presence.mjs";
|
|
16
|
+
import { handlerCommand } from "./desktop.mjs";
|
|
17
|
+
import { skillFootprint } from "./install.mjs";
|
|
18
|
+
|
|
19
|
+
const real = (p) => {
|
|
20
|
+
try {
|
|
21
|
+
return fs.realpathSync(p);
|
|
22
|
+
} catch {
|
|
23
|
+
return path.resolve(p);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
const same = (a, b) => {
|
|
27
|
+
const [x, y] = [real(a), real(b)];
|
|
28
|
+
return process.platform === "win32" ? x.toLowerCase() === y.toLowerCase() : x === y;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** `{ name, level: "ok"|"warn"|"fail"|"info", detail, fix? }` */
|
|
32
|
+
const check = (name, level, detail, fix) => ({ name, level, detail, ...(fix ? { fix } : {}) });
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every copy of the package that a `roger-roger` on the PATH leads to, by real path, so one copy
|
|
36
|
+
* reached through two links (nvm's `Program Files\nodejs`, say) counts once.
|
|
37
|
+
*/
|
|
38
|
+
export function copiesOnPath(env = process.env) {
|
|
39
|
+
const seen = new Map();
|
|
40
|
+
for (const dir of String(env.PATH ?? env.Path ?? "").split(path.delimiter).filter(Boolean)) {
|
|
41
|
+
for (const pkg of [path.join(dir, "node_modules", "roger-roger"), path.join(dir, "..", "lib", "node_modules", "roger-roger")]) {
|
|
42
|
+
try {
|
|
43
|
+
const version = JSON.parse(fs.readFileSync(path.join(pkg, "package.json"), "utf8")).version ?? "";
|
|
44
|
+
const key = real(pkg).toLowerCase();
|
|
45
|
+
if (!seen.has(key)) seen.set(key, { dir: real(pkg), version });
|
|
46
|
+
} catch {}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return [...seen.values()];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The bot's scopes, from the header Slack puts on every Web API answer. */
|
|
53
|
+
async function botScopes(token) {
|
|
54
|
+
const res = await fetch(`${apiBase()}/auth.test`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
|
|
55
|
+
const body = await res.json();
|
|
56
|
+
return { ok: Boolean(body.ok), error: body.error, team: body.team, scopes: (res.headers.get("x-oauth-scopes") ?? "").split(",").map((s) => s.trim()).filter(Boolean) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function appTokenWorks(token) {
|
|
60
|
+
const res = await fetch(`${apiBase()}/apps.connections.open`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) });
|
|
61
|
+
const body = await res.json();
|
|
62
|
+
return { ok: Boolean(body.ok), error: body.error };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function hasCommand(name) {
|
|
66
|
+
try {
|
|
67
|
+
execFileSync(process.platform === "win32" ? "where" : "which", [name], { stdio: "ignore", windowsHide: true, timeout: 5_000 });
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run every check. `daemon` is what the running daemon said about itself (or null), `agents` the
|
|
76
|
+
* hook installers keyed by agent, `script` this copy's CLI.
|
|
77
|
+
*/
|
|
78
|
+
export async function runDoctor({ daemon = null, agents = {}, script = "", offline = false, env = process.env } = {}) {
|
|
79
|
+
const checks = [];
|
|
80
|
+
|
|
81
|
+
// ---- settings
|
|
82
|
+
let config = null;
|
|
83
|
+
try {
|
|
84
|
+
config = loadConfig();
|
|
85
|
+
checks.push(config
|
|
86
|
+
? check("settings", "ok", configPath())
|
|
87
|
+
: check("settings", "fail", "not set up yet", "run `npx roger-roger@latest install` in a terminal"));
|
|
88
|
+
} catch (e) {
|
|
89
|
+
checks.push(check("settings", "fail", `${configPath()} can't be read: ${e.message}`, "fix the file by hand, or run `roger-roger install` to write it again"));
|
|
90
|
+
}
|
|
91
|
+
const methods = config?.methods ?? [];
|
|
92
|
+
|
|
93
|
+
// ---- this copy, the others, and the daemon
|
|
94
|
+
const copies = copiesOnPath(env);
|
|
95
|
+
const here = path.join(SKILL_DIR, "..", "..");
|
|
96
|
+
checks.push(check("this copy", "info", `${VERSION || "unversioned"} at ${real(SKILL_DIR)}`));
|
|
97
|
+
if (copies.length > 1) {
|
|
98
|
+
checks.push(check("copies on the PATH", "warn", copies.map((c) => `${c.version} at ${c.dir}`).join("; "), "keep one: `npm uninstall -g roger-roger` with the Node that installed each extra"));
|
|
99
|
+
}
|
|
100
|
+
const newest = copies.reduce((best, c) => (!best || olderThan(best.version, c.version) ? c : best), null);
|
|
101
|
+
if (newest && VERSION && olderThan(VERSION, newest.version) && !same(here, newest.dir)) {
|
|
102
|
+
checks.push(check("copy in use", "warn", `this is ${VERSION}, but ${newest.version} is installed at ${newest.dir}`, "run the `roger-roger` command rather than this copy, or update this one"));
|
|
103
|
+
}
|
|
104
|
+
if (!daemon) {
|
|
105
|
+
checks.push(check("daemon", "info", "not running; the next command starts it"));
|
|
106
|
+
} else {
|
|
107
|
+
const where = daemon.script ? path.dirname(daemon.script) : null;
|
|
108
|
+
const detail = `pid ${daemon.pid}${daemon.version ? `, ${daemon.version}` : ""}${where ? ` from ${real(where)}` : ""}`;
|
|
109
|
+
if (!daemon.version) checks.push(check("daemon", "warn", `${detail}, from a copy too old to say its version`, "`roger-roger daemon stop`; the next command starts a current one"));
|
|
110
|
+
else if (VERSION && olderThan(daemon.version, VERSION)) checks.push(check("daemon", "warn", `${detail}, older than this copy (${VERSION})`, "it hands over by itself at the next command; `roger-roger daemon stop` makes it now"));
|
|
111
|
+
else checks.push(check("daemon", "ok", detail));
|
|
112
|
+
if (methods.includes("slack") && daemon.slackConnected === false && slackAppToken()) {
|
|
113
|
+
checks.push(check("slack connection", "warn", "the daemon isn't connected to Slack, so buttons and messages won't arrive", "see ~/.roger-roger/daemon.log for why"));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---- Slack
|
|
118
|
+
if (methods.includes("slack")) {
|
|
119
|
+
if (!config.slack?.target) checks.push(check("slack target", "fail", "Slack is on, but nowhere to post", "`roger-roger setup --slack-target U…` (your member ID) or a channel"));
|
|
120
|
+
const token = slackToken();
|
|
121
|
+
if (!token) {
|
|
122
|
+
checks.push(check("slack bot token", "fail", "missing", "run `roger-roger install` to connect Slack"));
|
|
123
|
+
} else if (!offline) {
|
|
124
|
+
try {
|
|
125
|
+
const bot = await botScopes(token);
|
|
126
|
+
if (!bot.ok) {
|
|
127
|
+
checks.push(check("slack bot token", "fail", `Slack refused it (${bot.error})`, "`roger-roger slack-setup create` installs the app again and saves fresh tokens"));
|
|
128
|
+
} else {
|
|
129
|
+
checks.push(check("slack bot token", "ok", `works, in ${bot.team}`));
|
|
130
|
+
const wanted = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")).oauth_config?.scopes?.bot ?? [];
|
|
131
|
+
const missing = bot.scopes.length ? wanted.filter((s) => !bot.scopes.includes(s)) : [];
|
|
132
|
+
if (missing.length) checks.push(check("slack scopes", "warn", `the app lacks ${missing.join(", ")}`, "`roger-roger slack-setup create` updates the same app with the current manifest"));
|
|
133
|
+
}
|
|
134
|
+
} catch (e) {
|
|
135
|
+
checks.push(check("slack bot token", "warn", `could not reach Slack: ${e.message}`));
|
|
136
|
+
}
|
|
137
|
+
const appToken = slackAppToken();
|
|
138
|
+
if (!appToken) {
|
|
139
|
+
checks.push(check("slack app token", "warn", "missing, so questions with buttons won't work", "`roger-roger slack-setup create`"));
|
|
140
|
+
} else {
|
|
141
|
+
const r = await appTokenWorks(appToken).catch((e) => ({ ok: false, error: e.message }));
|
|
142
|
+
checks.push(r.ok ? check("slack app token", "ok", "works") : check("slack app token", "fail", `Slack refused it (${r.error})`, "`roger-roger slack-setup create`"));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const app = currentApp();
|
|
146
|
+
if (app?.appId) {
|
|
147
|
+
checks.push(check("slack app", "info", `this machine uses ${app.appId} (${appUrl(app.appId)}). Any other roger-roger app on https://api.slack.com/apps is left over from an earlier install and can be deleted there`));
|
|
148
|
+
} else if (readSlackCredentials()?.source === "slack-cli") {
|
|
149
|
+
checks.push(check("slack app", "warn", "the tokens are here, but not which app they belong to, so `slack-setup create` would make a second app", "find the app whose tokens these are on https://api.slack.com/apps; delete any others"));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- speech and sound
|
|
154
|
+
if (methods.includes("speech")) {
|
|
155
|
+
const key = keyFor(config);
|
|
156
|
+
checks.push(key.value
|
|
157
|
+
? check("speech key", "ok", `${providerOf(config).label} key in ${key.name}`)
|
|
158
|
+
: check("speech key", "warn", `no ${providerOf(config).label} key (${key.name}); the computer's own voice speaks instead`, "paste one into `roger-roger install`, or set the variable"));
|
|
159
|
+
}
|
|
160
|
+
if (methods.includes("sound") || methods.includes("speech")) {
|
|
161
|
+
if (process.platform === "darwin") {
|
|
162
|
+
if (!hasCommand("afplay")) checks.push(check("audio player", "fail", "afplay is missing"));
|
|
163
|
+
} else if (process.platform !== "win32") {
|
|
164
|
+
const players = ["paplay", "pw-play", "aplay", "ffplay"].filter(hasCommand);
|
|
165
|
+
checks.push(players.length ? check("audio player", "ok", players[0]) : check("audio player", "fail", "none of paplay, pw-play, aplay, ffplay", "install pulseaudio-utils or alsa-utils"));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- desktop notifications
|
|
170
|
+
if (methods.includes("desktop")) {
|
|
171
|
+
if (process.platform === "win32") {
|
|
172
|
+
const r = spawnSync("reg.exe", ["query", "HKCU\\Software\\Classes\\roger-roger\\shell\\open\\command", "/ve"], { encoding: "utf8", windowsHide: true, timeout: 5_000 });
|
|
173
|
+
const handler = r.status === 0 ? r.stdout : "";
|
|
174
|
+
if (!handler) checks.push(check("desktop", "info", "not registered with Windows yet; the first notification does it"));
|
|
175
|
+
else if (!handler.includes(handlerCommand())) checks.push(check("desktop", "info", "the Go to pane button runs another copy of the skill; the next notification from this one points it here"));
|
|
176
|
+
else checks.push(check("desktop", "ok", "Windows notifications, with Go to pane"));
|
|
177
|
+
} else if (process.platform === "darwin") {
|
|
178
|
+
checks.push(hasCommand("terminal-notifier")
|
|
179
|
+
? check("desktop", "ok", "macOS notifications, with Go to pane (terminal-notifier)")
|
|
180
|
+
: check("desktop", "warn", "notifications show, but clicking one can't go to the agent's pane", "`brew install terminal-notifier`"));
|
|
181
|
+
} else {
|
|
182
|
+
checks.push(check("desktop", "warn", "desktop notifications work on Windows and macOS only", "`roger-roger setup --methods` without desktop"));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- where the user is
|
|
187
|
+
if (config && config.presence !== "off") {
|
|
188
|
+
const where = await presence({ awayAfterMs: (config.awayAfter ?? 180) * 1000 });
|
|
189
|
+
checks.push(where.state === "unknown"
|
|
190
|
+
? check("presence", "info", "this machine doesn't say whether you are at it, so every configured method is used")
|
|
191
|
+
: check("presence", "ok", `${where.state}${where.idleSeconds !== null ? `, idle ${where.idleSeconds}s` : ""}; at the desk you get ${(config.presentMethods ?? []).join(", ") || "nothing"}`));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- hooks and skill links pointing elsewhere
|
|
195
|
+
for (const [name, agent] of Object.entries(agents)) {
|
|
196
|
+
let s;
|
|
197
|
+
try {
|
|
198
|
+
s = agent.status({ script });
|
|
199
|
+
} catch {
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!s.installed && !s.partial) continue;
|
|
203
|
+
if (s.scriptExists === false) checks.push(check(`${name} hooks`, "fail", `they run ${s.script}, which is gone`, `\`roger-roger hooks install ${name}\``));
|
|
204
|
+
else if (s.partial) checks.push(check(`${name} hooks`, "warn", "only some are installed", `\`roger-roger hooks install ${name}\``));
|
|
205
|
+
else if (s.note) checks.push(check(`${name} hooks`, "warn", s.note, `\`roger-roger hooks install ${name}\``));
|
|
206
|
+
else checks.push(check(`${name} hooks`, "ok", "installed"));
|
|
207
|
+
}
|
|
208
|
+
for (const link of skillFootprint()) {
|
|
209
|
+
try {
|
|
210
|
+
if (!fs.existsSync(path.join(link.link, "SKILL.md"))) checks.push(check(`${link.label} skill`, "fail", `${link.link} leads nowhere`, "`roger-roger install` links it again"));
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---- the tray
|
|
215
|
+
if (trayInstalled() && config?.tray !== "off") {
|
|
216
|
+
// Any copy's tray counts. The process list only finds this copy's, so its pid file is asked too.
|
|
217
|
+
const running = await trayProcesses().catch(() => []);
|
|
218
|
+
try {
|
|
219
|
+
const raw = fs.readFileSync(path.join(rogerRogerHome(), "tray.pid"), "utf8").trim();
|
|
220
|
+
const pid = Number(raw.startsWith("{") ? JSON.parse(raw).pid : raw);
|
|
221
|
+
if (pidAlive(pid) && !running.includes(pid)) running.push(pid);
|
|
222
|
+
} catch {}
|
|
223
|
+
checks.push(running.length
|
|
224
|
+
? check("tray", "ok", `running (pid ${running.join(", ")})`)
|
|
225
|
+
: check("tray", "warn", "installed but not running (quit from its menu, it stays away until the daemon next starts)", "`roger-roger tray start`"));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const worst = checks.some((c) => c.level === "fail") ? "fail" : checks.some((c) => c.level === "warn") ? "warn" : "ok";
|
|
229
|
+
return { ok: worst !== "fail", worst, checks };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The checks as lines for a person, with the fix under each one that needs it. */
|
|
233
|
+
export function formatDoctor({ checks }, style = { green: (s) => s, yellow: (s) => s, red: (s) => s, dim: (s) => s, bold: (s) => s }) {
|
|
234
|
+
const mark = { ok: style.green("✔"), warn: style.yellow("!"), fail: style.red("✖"), info: style.dim("·") };
|
|
235
|
+
const lines = [];
|
|
236
|
+
for (const c of checks) {
|
|
237
|
+
lines.push(`${mark[c.level]} ${style.bold(c.name)} ${c.detail}`);
|
|
238
|
+
if (c.fix) lines.push(` ${style.dim("→")} ${c.fix}`);
|
|
239
|
+
}
|
|
240
|
+
return lines.join("\n") + "\n";
|
|
241
|
+
}
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
announceAs, applySetup, applyQuietHours, rogerRogerHome, machineEnv, pidAlive, decisionBlocks, formatElapsed, listArg, listSounds, saveConfig,
|
|
17
17
|
messageBlocks, parseChoices, parseDuration, parseFields, parseRecommend, parseRecommendMany,
|
|
18
18
|
parseRemind, previewList, progressBlocks, sessionLabel, slackAppToken, slackText, slackToken,
|
|
19
|
-
soundAnnouncement, speechText, splitList, stripTags, summaryBlocks, voiceSample,
|
|
19
|
+
soundAnnouncement, speechText, splitList, stripTags, summaryBlocks, voiceSample, withGotoButton,
|
|
20
20
|
} from "./lib.mjs";
|
|
21
21
|
import {
|
|
22
22
|
deliverSpeech, mapPool, playAlert, playWav, playWavBuffer, prepareSpeech, soundFile,
|
|
@@ -30,7 +30,9 @@ import * as sessions from "./sessions.mjs";
|
|
|
30
30
|
import * as inbox from "./inbox.mjs";
|
|
31
31
|
import * as decisions from "./decisions.mjs";
|
|
32
32
|
import * as activity from "./activity.mjs";
|
|
33
|
-
import { labelPane } from "./herdr.mjs";
|
|
33
|
+
import { focusPane, labelPane } from "./herdr.mjs";
|
|
34
|
+
import { methodsForPresence, presence } from "./presence.mjs";
|
|
35
|
+
import { notificationText, showDesktop } from "./desktop.mjs";
|
|
34
36
|
import { answeredNotice, isQuestion, matchRequest, permissionNotice, rememberRequest } from "./hooks.mjs";
|
|
35
37
|
|
|
36
38
|
export const EXIT_NOT_CONFIGURED = 3;
|
|
@@ -96,6 +98,21 @@ function methodsFor(args, config) {
|
|
|
96
98
|
return applyQuietHours(wanted, config);
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Where the user is, and what that leaves of `methods`. `--only` is the agent saying exactly how,
|
|
103
|
+
* so it is left alone; so is everything when presence is off. `keep` are methods this call can't do
|
|
104
|
+
* without, such as Slack for a reply in a Slack thread.
|
|
105
|
+
*/
|
|
106
|
+
export async function withPresence(args, config, methods, keep = []) {
|
|
107
|
+
if (config.presence === "off" || splitList(args.only).length) return { methods, presence: null };
|
|
108
|
+
const where = await presence({ awayAfterMs: (config.awayAfter ?? DEFAULTS.awayAfter) * 1000 });
|
|
109
|
+
const { methods: left, skipped } = methodsForPresence(methods, where.state, {
|
|
110
|
+
presentMethods: config.presentMethods ?? DEFAULTS.presentMethods,
|
|
111
|
+
keep,
|
|
112
|
+
});
|
|
113
|
+
return { methods: left, presence: { ...where, ...(skipped.length ? { skipped } : {}) } };
|
|
114
|
+
}
|
|
115
|
+
|
|
99
116
|
function attachments(args) {
|
|
100
117
|
const files = listArg(args.attach);
|
|
101
118
|
for (const f of files) {
|
|
@@ -112,6 +129,31 @@ function draftOf(args) {
|
|
|
112
129
|
return draft.trim() ? draft : "";
|
|
113
130
|
}
|
|
114
131
|
|
|
132
|
+
/**
|
|
133
|
+
* The sound and the voice. The daemon plays them after it has answered, so the agent isn't held for
|
|
134
|
+
* the synthesis and the whole announcement; it gets `{ playing: true }` per method instead. Without
|
|
135
|
+
* a daemon the command has to stay for them, because ending the process would cut them off.
|
|
136
|
+
*/
|
|
137
|
+
async function announce(ctx, opts) {
|
|
138
|
+
const audible = opts.methods.filter((m) => m === "sound" || m === "speech");
|
|
139
|
+
if (!audible.length) return {};
|
|
140
|
+
if (!ctx.later) return playAlert(opts);
|
|
141
|
+
ctx.later("announcement", playAlert(opts));
|
|
142
|
+
return Object.fromEntries(audible.map((m) => [m, { ok: true, playing: true }]));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The native notification, when `desktop` is among the methods. It carries a Go to pane button when
|
|
147
|
+
* the agent sits in a Herdr pane. A failure is reported, never thrown: the other methods still count.
|
|
148
|
+
*/
|
|
149
|
+
function desktopJob(ctx, methods, { kind, message, project }) {
|
|
150
|
+
if (!methods.includes("desktop")) return null;
|
|
151
|
+
const session = ctx.session;
|
|
152
|
+
const text = notificationText({ kind, message, nickname: session?.nickname ?? null, project });
|
|
153
|
+
return showDesktop({ ...text, goto: session?.herdr?.tab ? session.nickname : null, tag: session?.nickname ?? null })
|
|
154
|
+
.catch((e) => ({ ok: false, error: e.message }));
|
|
155
|
+
}
|
|
156
|
+
|
|
115
157
|
/** Note what the agent is doing, so the picker can say "waiting on you" next to its name. */
|
|
116
158
|
function setState(ctx, state) {
|
|
117
159
|
if (ctx.session) sessions.touch(ctx.session.id, { state });
|
|
@@ -128,7 +170,11 @@ export async function notify(ctx, args) {
|
|
|
128
170
|
const fields = parseFields(listArg(args.field));
|
|
129
171
|
const replyTo = typeof args["reply-to"] === "string" ? args["reply-to"].trim() : "";
|
|
130
172
|
const files = attachments(args);
|
|
131
|
-
const
|
|
173
|
+
const quiet = methodsFor(args, config);
|
|
174
|
+
const muted = quiet.muted;
|
|
175
|
+
// A reply to something said in Slack, buttons and files only make sense in Slack, wherever the user is.
|
|
176
|
+
const needsSlack = Boolean(replyTo || args.actions !== undefined || files.length);
|
|
177
|
+
const { methods, presence: where } = await withPresence(args, config, quiet.methods, needsSlack ? ["slack"] : []);
|
|
132
178
|
const followUp = followUpActions(args, config, methods);
|
|
133
179
|
const text = spoken(ctx, config, args, message);
|
|
134
180
|
const results = {};
|
|
@@ -147,6 +193,7 @@ export async function notify(ctx, args) {
|
|
|
147
193
|
const elapsed = args.summary && ctx.session ? formatElapsed(Date.now() - Date.parse(ctx.session.firstSeen)) : "";
|
|
148
194
|
blocks = summaryBlocks({ headline, fields, elapsed, decisions: answered, label });
|
|
149
195
|
}
|
|
196
|
+
if (ctx.session?.herdr?.tab) blocks = withGotoButton(blocks, ctx.session.id);
|
|
150
197
|
|
|
151
198
|
// Slack posts while the speech is synthesized; the sound waits for the speech.
|
|
152
199
|
if (methods.includes("sound") || methods.includes("speech")) warmUpAudio();
|
|
@@ -180,9 +227,12 @@ export async function notify(ctx, args) {
|
|
|
180
227
|
})().catch((e) => (results.slack = { ok: Boolean(results.slack?.ok), error: e.message }))
|
|
181
228
|
: null;
|
|
182
229
|
|
|
183
|
-
|
|
230
|
+
const desktop = desktopJob(ctx, methods, { kind, message, project: args.project });
|
|
231
|
+
Object.assign(results, await announce(ctx, { methods, config, text, prepared, who: ctx.session?.nickname }));
|
|
184
232
|
await slackJob;
|
|
185
|
-
|
|
233
|
+
if (desktop) results.desktop = await desktop;
|
|
234
|
+
// Nothing left to do because the user is at the desk and wants nothing there is a success too.
|
|
235
|
+
const succeeded = Object.values(results).some((r) => r.ok) || (!methods.length && Boolean(where?.skipped?.length));
|
|
186
236
|
if (succeeded) activity.record({ from: "agent", kind, agent: activity.agentOf(ctx.session), text: message });
|
|
187
237
|
const body = {
|
|
188
238
|
ok: succeeded,
|
|
@@ -190,6 +240,7 @@ export async function notify(ctx, args) {
|
|
|
190
240
|
results,
|
|
191
241
|
...(followUpId ? { id: followUpId, status: "pending", next: `wait ${followUpId}` } : {}),
|
|
192
242
|
...(muted.length ? { mutedByQuietHours: muted } : {}),
|
|
243
|
+
...(where ? { presence: where } : {}),
|
|
193
244
|
...(files.length && !methods.includes("slack") ? { note: "--attach needs slack" } : {}),
|
|
194
245
|
};
|
|
195
246
|
// Park in the same breath as replying. Doing it as a second command leaves a few seconds where
|
|
@@ -741,6 +792,18 @@ export function renameSession(ctx, args) {
|
|
|
741
792
|
return ok({ ok: true, session: sessions.decorate(renamed) });
|
|
742
793
|
}
|
|
743
794
|
|
|
795
|
+
/** `goto sage`: bring that agent's Herdr tab to the front. The tray's and Slack's "Go to pane" run this. */
|
|
796
|
+
export async function gotoSession(ctx, args) {
|
|
797
|
+
const who = (args._ ?? [])[0];
|
|
798
|
+
const target = who ? sessions.byNickname(who) ?? sessions.get(who) : ctx.session;
|
|
799
|
+
if (!target) fail(who ? `no session called "${who}"` : "say which agent, e.g. `goto sage`");
|
|
800
|
+
try {
|
|
801
|
+
return ok({ ok: true, nickname: target.nickname, ...(await focusPane(target)) });
|
|
802
|
+
} catch (e) {
|
|
803
|
+
fail(e.message);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
744
807
|
export function endSession(ctx, args) {
|
|
745
808
|
const who = (args._ ?? [])[0];
|
|
746
809
|
const target = who ? sessions.byNickname(who) ?? sessions.get(who) : ctx.session;
|
|
@@ -779,7 +842,7 @@ export async function ask(ctx, args) {
|
|
|
779
842
|
if (!slackToken()) fail("asking needs Slack: SLACK_API_BOT_TOKEN is not set");
|
|
780
843
|
if (!config.slack.target) fail("asking needs Slack: run setup with --slack-target");
|
|
781
844
|
if (!slackAppToken()) {
|
|
782
|
-
fail("asking needs Slack buttons: set ROGER_ROGER_SLACK_APP_TOKEN (or SLACK_APP_TOKEN) to an app-level xapp- token with Socket Mode and Interactivity on (see
|
|
845
|
+
fail("asking needs Slack buttons: set ROGER_ROGER_SLACK_APP_TOKEN (or SLACK_APP_TOKEN) to an app-level xapp- token with Socket Mode and Interactivity on (see reference.md, Requirements)");
|
|
783
846
|
}
|
|
784
847
|
if (typeof WebSocket !== "function") fail("asking needs Node 22 or newer (built-in WebSocket)");
|
|
785
848
|
|
|
@@ -852,7 +915,9 @@ export async function ask(ctx, args) {
|
|
|
852
915
|
setState(ctx, "waiting");
|
|
853
916
|
activity.record({ from: "agent", kind: "question", agent: activity.agentOf(ctx.session), text: question, detail: choices.join(" / ") });
|
|
854
917
|
|
|
855
|
-
const
|
|
918
|
+
const desktop = desktopJob(ctx, methods, { kind: "question", message: `${stripTags(question)} (${choices.join(" / ")})`, project: args.project });
|
|
919
|
+
const audio = await announce(ctx, { methods: audible, config, text, prepared, who: ctx.session?.nickname });
|
|
920
|
+
if (desktop) audio.desktop = await desktop;
|
|
856
921
|
const extra = { audio, ...(attached ? { attached } : {}), ...(muted.length ? { mutedByQuietHours: muted } : {}) };
|
|
857
922
|
if (args.wait !== undefined) return waitFor(ctx, id, parseDuration(args.wait, Infinity), extra);
|
|
858
923
|
// Waiting costs the agent nothing: run it in the background and carry on. It always comes back,
|
|
@@ -1162,6 +1227,7 @@ export const HANDLERS = {
|
|
|
1162
1227
|
sessions: listSessions,
|
|
1163
1228
|
rename: renameSession,
|
|
1164
1229
|
end: endSession,
|
|
1230
|
+
goto: gotoSession,
|
|
1165
1231
|
play,
|
|
1166
1232
|
say,
|
|
1167
1233
|
"preview-sounds": previewSounds,
|
|
@@ -11,12 +11,13 @@ export const TAGLINE = "Bring yourself back to the conversation: your agents rea
|
|
|
11
11
|
export const COMMANDS = [
|
|
12
12
|
// ---- setting up
|
|
13
13
|
{ section: "Setting up", name: "install", usage: "[--yes]", summary: "Set everything up in the terminal: the command on your PATH, the skill for every agent and a standing instruction to use it, Herdr, Slack, the sound and the voice, hooks, tray. Run it again to change anything. --yes asks nothing and only links the skill.", flags: [["--yes", "no questions: link the skill for every agent on the machine and register with Herdr, then print what was done. Nothing is written to CLAUDE.md or AGENTS.md"]] },
|
|
14
|
-
{ section: "Setting up", name: "uninstall", usage: "[--yes]", summary: "Take it out again: the standing instruction, hooks, links, Herdr, the daemon and the tray, settings and tokens, the global command.", flags: [["--yes", "no questions: remove everything except the Slack CLI, then print what was done"], ["--slack-cli", "with --yes, remove the Slack CLI and its login as well"]] },
|
|
14
|
+
{ section: "Setting up", name: "uninstall", usage: "[--yes]", summary: "Take it out again: the standing instruction, hooks, links, Herdr, the daemon and the tray, settings and tokens, the global command.", flags: [["--yes", "no questions: remove everything except the Slack CLI, then print what was done"], ["--slack-cli", "with --yes, remove the Slack CLI and its login as well"], ["--slack-app", "with --yes, delete the roger-roger app from the Slack workspace as well"]] },
|
|
15
15
|
{ section: "Setting up", name: "status", usage: "", summary: "Settings, whether the tokens are there, the daemon, who is running, and every valid option value." },
|
|
16
|
+
{ section: "Setting up", name: "doctor", usage: "[--offline] [--json]", summary: "Everything that could stop a notification arriving, each with what to do about it: tokens and scopes, the daemon and which copy it runs, hooks, the tray, the speech key.", flags: [["--offline", "skip the checks that ask Slack"], ["--json", "the report as JSON even in a terminal"]] },
|
|
16
17
|
{
|
|
17
18
|
section: "Setting up", name: "setup", usage: "[flags]", summary: "Change any setting; the rest are kept.",
|
|
18
19
|
flags: [
|
|
19
|
-
["--methods slack,sound,speech", "how to be reached, any combination"],
|
|
20
|
+
["--methods slack,sound,speech,desktop", "how to be reached, any combination; desktop is a native notification (Windows, macOS)"],
|
|
20
21
|
["--slack-target U0123|C0123|#name", "your member ID for DMs, or a channel"],
|
|
21
22
|
["--slack-mention U0123", "who to mention in channel posts"],
|
|
22
23
|
["--sound NAME", "chime, ding, marimba, bubble, alert"],
|
|
@@ -34,6 +35,9 @@ export const COMMANDS = [
|
|
|
34
35
|
["--remind 15m|off", "remind about an unanswered question after this long"],
|
|
35
36
|
["--on-expire recommended|nothing", "what an unanswered question settles as"],
|
|
36
37
|
["--quiet-hours 22:00-08:00|off, --quiet-mute speech,sound", "when, and what, to keep quiet"],
|
|
38
|
+
["--presence on|off", "at the desk, use only --present-methods; away or locked, everything"],
|
|
39
|
+
["--present-methods sound,speech,desktop|none", "what reaches you while you are at the computer"],
|
|
40
|
+
["--away-after 3m", "no keyboard or mouse for this long counts as away"],
|
|
37
41
|
["--permission-pings on|off", "also ping for ordinary permission prompts in the terminal"],
|
|
38
42
|
["--terminal-wait 20s", "how long a terminal question waits before the ping (OpenCode, Codex)"],
|
|
39
43
|
["--tray auto|off", "start the tray with the daemon once it is installed"],
|
|
@@ -65,6 +69,7 @@ export const COMMANDS = [
|
|
|
65
69
|
{ section: "Reaching the user", name: "progress", usage: "--message TEXT [--key NAME] [--done]", summary: "One Slack message, edited in place as the work moves on, with Pause, Stop and Note buttons." },
|
|
66
70
|
{ section: "Reaching the user", name: "control", usage: "[--key NAME] [--wait DURATION]", summary: "Read Pause, Stop and new messages without posting. --wait blocks while paused." },
|
|
67
71
|
{ section: "Reaching the user", name: "listen", usage: "[--wait 25m]", summary: "Park until the user says something. Run it in the background as the last thing you do." },
|
|
72
|
+
{ section: "Reaching the user", name: "desktop", usage: "test [--goto NAME]", summary: "Show one native notification now, to see that this machine shows them. --goto adds the Go to pane button for that agent." },
|
|
68
73
|
{ section: "Reaching the user", name: "snooze", usage: "[1h|off]", summary: "Sound and speech off for a while; Slack keeps working." },
|
|
69
74
|
{ section: "Reaching the user", name: "hush", usage: "", summary: "Stop the voice or sound playing right now. Only this announcement: the next plays as usual, and the Slack message stays. The tray's Stop button does the same." },
|
|
70
75
|
|
|
@@ -95,6 +100,7 @@ export const COMMANDS = [
|
|
|
95
100
|
|
|
96
101
|
// ---- sessions
|
|
97
102
|
{ section: "Sessions", name: "sessions", usage: "[--all] | rename NAME NEWNAME", summary: "Who is running and what each is called. Rename gives one a different colour name." },
|
|
103
|
+
{ section: "Sessions", name: "goto", usage: "NAME", summary: "Bring that agent's Herdr tab to the front. The tray and Slack messages from a Herdr pane offer the same as a button." },
|
|
98
104
|
{ section: "Sessions", name: "end", usage: "[NAME]", summary: "This session is finished; stop routing messages to it." },
|
|
99
105
|
{ section: "Sessions", name: "inbox", usage: "", summary: "Messages the user sent this session, and anything left for the next agent." },
|
|
100
106
|
|
|
@@ -216,3 +216,15 @@ export async function labelPane(session, { log = () => {}, touch = () => {}, run
|
|
|
216
216
|
inFlight.delete(session.id);
|
|
217
217
|
}
|
|
218
218
|
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Bring a session's pane to the front in Herdr: its workspace, then its tab. Herdr focuses panes
|
|
222
|
+
* only by direction, so the tab is as close as it goes; an agent normally has a tab to itself.
|
|
223
|
+
*/
|
|
224
|
+
export async function focusPane(session, { run = runHerdr } = {}) {
|
|
225
|
+
if (!session?.herdr?.tab) throw new Error(`${session?.nickname ?? "that agent"} isn't running in a Herdr pane`);
|
|
226
|
+
const opts = { bin: session.herdr.bin, socket: session.herdr.socket };
|
|
227
|
+
if (session.herdr.workspace) await run(["workspace", "focus", session.herdr.workspace], opts);
|
|
228
|
+
await run(["tab", "focus", session.herdr.tab], opts);
|
|
229
|
+
return { focused: true, tab: session.herdr.tab, ...(session.herdr.workspace ? { workspace: session.herdr.workspace } : {}) };
|
|
230
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The roger-roger app's Home tab in Slack: who is running, what is waiting on you, what is in
|
|
2
|
+
// progress, and the few buttons worth having from a phone (snooze, stop the voice, go to an agent's
|
|
3
|
+
// pane). The tray shows the same at the desk; this is the one that travels.
|
|
4
|
+
//
|
|
5
|
+
// Slack keeps a published Home view until the next publish, so it is redrawn whenever something
|
|
6
|
+
// moves (at most every few seconds) for anyone who has opened it, and for the configured DM user.
|
|
7
|
+
|
|
8
|
+
import { GOTO_PREFIX, stripTags } from "./lib.mjs";
|
|
9
|
+
import { permalink, slackCall } from "./slack.mjs";
|
|
10
|
+
import { slackToken } from "./lib.mjs";
|
|
11
|
+
|
|
12
|
+
export const HOME_PREFIX = "roger-roger_home";
|
|
13
|
+
|
|
14
|
+
/** `roger-roger_home:snooze:1h` → { action: "snooze", value: "1h" }, or null. */
|
|
15
|
+
export function parseHomeActionId(value) {
|
|
16
|
+
const m = new RegExp(`^${HOME_PREFIX}:(snooze|hush)(?::(.+))?$`).exec(String(value));
|
|
17
|
+
return m ? { action: m[1], value: m[2] ?? null } : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const button = (text, actionId, style) => ({ type: "button", action_id: actionId, text: { type: "plain_text", text, emoji: true }, ...(style ? { style } : {}) });
|
|
21
|
+
const section = (text) => ({ type: "section", text: { type: "mrkdwn", text } });
|
|
22
|
+
const context = (text) => ({ type: "context", elements: [{ type: "mrkdwn", text }] });
|
|
23
|
+
|
|
24
|
+
function ago(ms) {
|
|
25
|
+
const m = Math.round(ms / 60_000);
|
|
26
|
+
if (m < 1) return "just now";
|
|
27
|
+
if (m < 60) return `${m}m ago`;
|
|
28
|
+
const h = Math.floor(m / 60);
|
|
29
|
+
return `${h}h ${m % 60}m ago`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The Home view for a tray snapshot (tray.mjs). `links` maps a question's id to its message's
|
|
34
|
+
* permalink, when there is one.
|
|
35
|
+
*/
|
|
36
|
+
export function homeView(snap, { links = {}, now = Date.now() } = {}) {
|
|
37
|
+
const blocks = [{ type: "header", text: { type: "plain_text", text: "roger-roger", emoji: true } }];
|
|
38
|
+
|
|
39
|
+
// What is muted, and the buttons that change it.
|
|
40
|
+
const muted = snap.muted ?? {};
|
|
41
|
+
const status = [];
|
|
42
|
+
if (!snap.daemon) status.push(":warning: The daemon on the computer isn't running, so this may be out of date.");
|
|
43
|
+
if (muted.snoozeUntil) status.push(`:zzz: Snoozed until ${new Date(muted.snoozeUntil).toTimeString().slice(0, 5)}: no sound or voice, Slack still on.`);
|
|
44
|
+
else if (muted.quietNow) status.push(":crescent_moon: Quiet hours.");
|
|
45
|
+
if (snap.speaking) status.push(`:loud_sound: ${snap.speaking.who ? `*${snap.speaking.who}* is speaking.` : "Something is playing."}`);
|
|
46
|
+
if (status.length) blocks.push(section(status.join("\n")));
|
|
47
|
+
const controls = muted.snoozeUntil
|
|
48
|
+
? [button("Stop snoozing", `${HOME_PREFIX}:snooze:off`)]
|
|
49
|
+
: [button("Snooze 15m", `${HOME_PREFIX}:snooze:15m`), button("Snooze 1h", `${HOME_PREFIX}:snooze:1h`)];
|
|
50
|
+
if (snap.speaking) controls.push(button(":octagonal_sign: Stop the voice", `${HOME_PREFIX}:hush`, "danger"));
|
|
51
|
+
blocks.push({ type: "actions", block_id: `${HOME_PREFIX}_controls`, elements: controls });
|
|
52
|
+
|
|
53
|
+
// Waiting on you comes first: it is the reason to open this.
|
|
54
|
+
const questions = snap.questions ?? [];
|
|
55
|
+
if (questions.length) {
|
|
56
|
+
blocks.push({ type: "divider" }, section("*Waiting on you*"));
|
|
57
|
+
for (const q of questions) {
|
|
58
|
+
const who = q.nickname ? `${q.swatch ? `${q.swatch} ` : ""}*${q.nickname}*` : "An agent";
|
|
59
|
+
const link = links[q.id] ? ` · <${links[q.id]}|answer it>` : "";
|
|
60
|
+
const left = q.expiresAt ? ` · settles in ${Math.max(0, Math.round((Date.parse(q.expiresAt) - now) / 60_000))}m` : "";
|
|
61
|
+
blocks.push(section(`${who}: ${stripTags(q.question)}${link}`), context(`${(q.choices ?? []).join(" / ")}${left}`));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const agents = snap.agents ?? [];
|
|
66
|
+
blocks.push({ type: "divider" }, section(agents.length ? "*Running*" : "_Nothing is running._"));
|
|
67
|
+
for (const a of agents) {
|
|
68
|
+
const what = [a.project, a.name].filter(Boolean).join(" · ");
|
|
69
|
+
const row = section(`${a.swatch ? `${a.swatch} ` : ""}*${a.nickname}* ${a.state ?? ""}${what ? `\n${what}` : ""}`);
|
|
70
|
+
if (a.inHerdr) row.accessory = button(":desktop_computer: Go to pane", `${GOTO_PREFIX}:${a.id}`);
|
|
71
|
+
blocks.push(row, context(`quiet ${ago(a.quietForMs ?? 0)}${a.queued ? ` · ${a.queued} unread` : ""}${a.owes ? ` · ${a.owes} answer(s) to collect` : ""}`));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const working = snap.working ?? [];
|
|
75
|
+
if (working.length) {
|
|
76
|
+
blocks.push({ type: "divider" }, section("*In progress*"));
|
|
77
|
+
for (const w of working) blocks.push(section(`${w.nickname ? `*${w.nickname}*: ` : ""}${stripTags(w.headline)}`));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
blocks.push(context(`Updated ${new Date(snap.updatedAt ?? now).toTimeString().slice(0, 5)}`));
|
|
81
|
+
// Slack's limit for a Home view.
|
|
82
|
+
return { type: "home", blocks: blocks.slice(0, 100) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const linkCache = new Map();
|
|
86
|
+
|
|
87
|
+
/** Permalinks for the open questions, asked once per question. */
|
|
88
|
+
async function linksFor(decisions) {
|
|
89
|
+
const links = {};
|
|
90
|
+
for (const d of decisions) {
|
|
91
|
+
if (!d.channel || !d.ts) continue;
|
|
92
|
+
if (!linkCache.has(d.id)) linkCache.set(d.id, await permalink(d.channel, d.ts).catch(() => null));
|
|
93
|
+
if (linkCache.get(d.id)) links[d.id] = linkCache.get(d.id);
|
|
94
|
+
}
|
|
95
|
+
return links;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Draw the Home tab for one user. `pending` are the open decisions, for their links. */
|
|
99
|
+
export async function publishHome(userId, snap, pending = []) {
|
|
100
|
+
const token = slackToken();
|
|
101
|
+
if (!token || !userId) return { ok: false, error: "no token or user" };
|
|
102
|
+
const view = homeView(snap, { links: await linksFor(pending) });
|
|
103
|
+
const r = await slackCall("views.publish", token, { user_id: userId, view });
|
|
104
|
+
return r.ok ? { ok: true } : { ok: false, error: r.error };
|
|
105
|
+
}
|