roger-roger 0.1.15 → 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 +89 -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 +58 -5
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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);
|