roger-roger 0.1.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/LICENSE +21 -0
- package/README.md +147 -0
- package/package.json +45 -0
- package/skills/roger-roger/SKILL.md +289 -0
- package/skills/roger-roger/herdr-plugin.toml +38 -0
- package/skills/roger-roger/scripts/agent.mjs +132 -0
- package/skills/roger-roger/scripts/audio.mjs +392 -0
- package/skills/roger-roger/scripts/client.mjs +121 -0
- package/skills/roger-roger/scripts/daemon.mjs +604 -0
- package/skills/roger-roger/scripts/decisions.mjs +158 -0
- package/skills/roger-roger/scripts/handlers.mjs +1151 -0
- package/skills/roger-roger/scripts/herdr.mjs +140 -0
- package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
- package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
- package/skills/roger-roger/scripts/hooks.mjs +420 -0
- package/skills/roger-roger/scripts/inbox.mjs +381 -0
- package/skills/roger-roger/scripts/install.mjs +560 -0
- package/skills/roger-roger/scripts/lib.mjs +1133 -0
- package/skills/roger-roger/scripts/names.mjs +84 -0
- package/skills/roger-roger/scripts/progress.mjs +91 -0
- package/skills/roger-roger/scripts/protocol.mjs +71 -0
- package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
- package/skills/roger-roger/scripts/router.mjs +86 -0
- package/skills/roger-roger/scripts/sessions.mjs +218 -0
- package/skills/roger-roger/scripts/slack.mjs +240 -0
- package/skills/roger-roger/scripts/slackapp.mjs +205 -0
- package/skills/roger-roger/scripts/slackcli.mjs +144 -0
- package/skills/roger-roger/scripts/speaker.mjs +224 -0
- package/skills/roger-roger/scripts/speechkey.mjs +106 -0
- package/skills/roger-roger/scripts/tray.mjs +128 -0
- package/skills/roger-roger/scripts/tts.mjs +275 -0
- package/skills/roger-roger/scripts/tui.mjs +465 -0
- package/skills/roger-roger/slack/manifest.json +34 -0
- package/skills/roger-roger/sounds/alert.wav +0 -0
- package/skills/roger-roger/sounds/bubble.wav +0 -0
- package/skills/roger-roger/sounds/chime.wav +0 -0
- package/skills/roger-roger/sounds/ding.wav +0 -0
- package/skills/roger-roger/sounds/marimba.wav +0 -0
- package/skills/roger-roger/tray/main.mjs +749 -0
- package/skills/roger-roger/tray/panel.html +501 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// roger-roger: notify the user via Slack, a sound, and/or speech, and ask them to choose
|
|
3
|
+
// between options with Slack buttons. Zero dependencies; Node 18+ (22+ for `ask`).
|
|
4
|
+
//
|
|
5
|
+
// This is the thin end of the skill. The work happens in one long-lived daemon that owns Slack, the
|
|
6
|
+
// speakers and the state; this parses the command, hands it over, and prints the one JSON object it
|
|
7
|
+
// gets back. If the daemon can't be reached the same handlers run here instead, which still works
|
|
8
|
+
// for everything except Slack buttons, since those need a live connection to arrive.
|
|
9
|
+
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULTS, DUCK_MODES, KINDS, METHODS, ON_EXPIRE, SPEECH_MODES, WHEN_MODES,
|
|
14
|
+
applySetup, rogerRogerHome, configPath, listArg, listSounds, loadConfig, migrateLegacyHome, parseArgs,
|
|
15
|
+
readSlackCredentials, saveConfig, slackAppToken, slackToken,
|
|
16
|
+
} from "./lib.mjs";
|
|
17
|
+
import { NAMES } from "./names.mjs";
|
|
18
|
+
import { PROVIDERS, SPEECH_SETUP, catalog, keyFor, providerOf } from "./tts.mjs";
|
|
19
|
+
import { call, connect, request } from "./client.mjs";
|
|
20
|
+
import { identity } from "./protocol.mjs";
|
|
21
|
+
import { FROM_HOOKS, HANDLERS, owedTo } from "./handlers.mjs";
|
|
22
|
+
import { closeAudio } from "./audio.mjs";
|
|
23
|
+
import { createApp, hooksConfig, loginFinish, loginStart, runHook, setupStatus } from "./slackapp.mjs";
|
|
24
|
+
import { findCli, installCli } from "./slackcli.mjs";
|
|
25
|
+
import { daemonRunning } from "./daemon.mjs";
|
|
26
|
+
import * as sessions from "./sessions.mjs";
|
|
27
|
+
import * as inbox from "./inbox.mjs";
|
|
28
|
+
import { labelPane } from "./herdr.mjs";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { claudeHookRequest, claudeSettingsPath, claudeStatus, installClaude, readStdin, uninstallClaude } from "./hooks.mjs";
|
|
31
|
+
import { codexHome, codexHookRequest, codexStatus, installCodex, uninstallCodex } from "./hooks-codex.mjs";
|
|
32
|
+
import { installOpencode, opencodeConfigDir, opencodeHookRequest, opencodeStatus, uninstallOpencode } from "./hooks-opencode.mjs";
|
|
33
|
+
import { runInstall } from "./install.mjs";
|
|
34
|
+
|
|
35
|
+
function out(obj, code = 0) {
|
|
36
|
+
process.stdout.write(JSON.stringify(obj, null, 2) + "\n");
|
|
37
|
+
process.exitCode = code;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------- talking to the daemon
|
|
43
|
+
|
|
44
|
+
/** Who is asking: the agent session, and what it has told us about itself. */
|
|
45
|
+
function who(args = {}) {
|
|
46
|
+
// The tray runs commands for the user, not for any agent: nothing it does should land on, or
|
|
47
|
+
// release, some agent's session.
|
|
48
|
+
if (process.env.ROGER_ROGER_CALLER === "tray") return { identity: null, about: {} };
|
|
49
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v.trim() : undefined);
|
|
50
|
+
// `--session` doubles as identity for agents that publish none of their own, so it is passed to
|
|
51
|
+
// both: what to call this work, and how to recognise the same session next time.
|
|
52
|
+
const about = { session: str(args.session), sessionId: str(args["session-id"]), agentPid: args["agent-pid"] };
|
|
53
|
+
return {
|
|
54
|
+
identity: identity(process.env, process.cwd(), about),
|
|
55
|
+
about: { name: about.session, model: str(args["agent-model"]), agent: str(args.agent) },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Everything the daemon can't work out for itself, because it runs in another directory. */
|
|
60
|
+
function normalize(args) {
|
|
61
|
+
const normalized = { ...args };
|
|
62
|
+
normalized.project = args.project === undefined ? path.basename(process.cwd()) : String(args.project);
|
|
63
|
+
const files = listArg(args.attach);
|
|
64
|
+
if (files.length) {
|
|
65
|
+
normalized.attach = files.map((f) => {
|
|
66
|
+
const full = path.resolve(process.cwd(), f);
|
|
67
|
+
if (!fs.existsSync(full) || !fs.statSync(full).isFile()) throw new Error(`--attach: no such file: ${f}`);
|
|
68
|
+
return full;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (typeof args["draft-file"] === "string") {
|
|
72
|
+
normalized.draft = fs.readFileSync(path.resolve(process.cwd(), args["draft-file"]), "utf8");
|
|
73
|
+
}
|
|
74
|
+
return normalized;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Run a command here, when no daemon can be reached. Slack buttons won't arrive; nothing else changes. */
|
|
78
|
+
async function runLocally(cmd, args, session) {
|
|
79
|
+
const handler = HANDLERS[cmd];
|
|
80
|
+
if (!handler) throw new Error(`unknown command "${cmd}"`);
|
|
81
|
+
const config = loadConfig();
|
|
82
|
+
const record = session.identity ? sessions.register(session.identity, session.about) : null;
|
|
83
|
+
if (record) labelPane(sessions.decorate(record), { touch: sessions.touch }).catch(() => {});
|
|
84
|
+
let lastSync = 0;
|
|
85
|
+
const ctx = {
|
|
86
|
+
config,
|
|
87
|
+
session: record ? sessions.decorate(record) : null,
|
|
88
|
+
identity: session.identity,
|
|
89
|
+
log: () => {},
|
|
90
|
+
daemon: false,
|
|
91
|
+
emit: () => {},
|
|
92
|
+
idle: sleep,
|
|
93
|
+
waiting: () => () => {},
|
|
94
|
+
async catchUp() {
|
|
95
|
+
if (Date.now() - lastSync < 15_000) return;
|
|
96
|
+
lastSync = Date.now();
|
|
97
|
+
await inbox.sync().catch(() => {});
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
const result = (await handler(ctx, args)) ?? {};
|
|
101
|
+
if (FROM_HOOKS.has(cmd)) return result;
|
|
102
|
+
return { ...result, data: await owedTo(ctx, result.data).catch(() => result.data) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Commands with nothing to undo. If the daemon goes away mid-flight these can just be asked
|
|
106
|
+
// again; anything that posts, plays or hands something over must not be.
|
|
107
|
+
const REPEATABLE = new Set(["listen", "wait", "check", "status", "sessions", "decisions", "ping"]);
|
|
108
|
+
|
|
109
|
+
/** Hand a command to the daemon, or do it here if there isn't one. */
|
|
110
|
+
async function run(cmd, rawArgs) {
|
|
111
|
+
const args = normalize(rawArgs);
|
|
112
|
+
const session = who(args);
|
|
113
|
+
try {
|
|
114
|
+
const { data, code } = await call(cmd, args, { session });
|
|
115
|
+
return out(data, code);
|
|
116
|
+
} catch (e) {
|
|
117
|
+
// A refusal from the daemon is the answer; only a transport failure means we do it ourselves.
|
|
118
|
+
if (typeof e.code === "number") return out({ ok: false, error: e.message }, e.code);
|
|
119
|
+
// It had finished the doing and was only waiting, so the waiting can be picked up again — this
|
|
120
|
+
// is what keeps a parked `listen` alive when the daemon hands over to a newer one.
|
|
121
|
+
if (e.parkedUntil) {
|
|
122
|
+
const left = Math.round((Date.parse(e.parkedUntil) - Date.now()) / 1000);
|
|
123
|
+
if (left <= 1) return out({ ok: false, woke: "timeout" }, 4);
|
|
124
|
+
try {
|
|
125
|
+
const { data, code } = await call("listen", { wait: `${left}s` }, { session });
|
|
126
|
+
return out(data, code);
|
|
127
|
+
} catch (retry) {
|
|
128
|
+
if (typeof retry.code === "number") return out({ ok: false, error: retry.message }, retry.code);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// The daemon had it and went away mid-command. It may well have done the thing, so doing it
|
|
132
|
+
// here too would double it — say so instead. Unless there was nothing to do twice: a command
|
|
133
|
+
// that only waits or reads can simply ask again, which is what a parked `listen` needs when the
|
|
134
|
+
// daemon hands over to a newer one.
|
|
135
|
+
if (e.acknowledged && REPEATABLE.has(cmd)) {
|
|
136
|
+
try {
|
|
137
|
+
const { data, code } = await call(cmd, args, { session });
|
|
138
|
+
return out(data, code);
|
|
139
|
+
} catch (retry) {
|
|
140
|
+
if (typeof retry.code === "number") return out({ ok: false, error: retry.message }, retry.code);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (e.acknowledged) {
|
|
144
|
+
return out({ ok: false, error: `the daemon took the command and then went away (${e.message}); it was not run again here, in case it already happened` }, 1);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const { data, code } = await runLocally(cmd, args, session);
|
|
148
|
+
return out({ ...data, daemon: `unreachable (${e.message}) - ran in this process, so Slack buttons will not be received` }, code);
|
|
149
|
+
} catch (local) {
|
|
150
|
+
return out({ ok: false, error: local.message }, typeof local.code === "number" ? local.code : 1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Ask the daemon something without starting one. */
|
|
156
|
+
async function askDaemon(cmd) {
|
|
157
|
+
const socket = await connect({ start: false });
|
|
158
|
+
try {
|
|
159
|
+
return await request(socket, { cmd, session: who() });
|
|
160
|
+
} finally {
|
|
161
|
+
socket.end();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------- local commands
|
|
166
|
+
|
|
167
|
+
async function status() {
|
|
168
|
+
const file = configPath();
|
|
169
|
+
let config = null;
|
|
170
|
+
let error;
|
|
171
|
+
try {
|
|
172
|
+
config = loadConfig(file);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
error = `config file is unreadable: ${e.message}`;
|
|
175
|
+
}
|
|
176
|
+
const rawAppToken = (process.env.ROGER_ROGER_SLACK_APP_TOKEN || process.env.SLACK_APP_TOKEN || "").trim();
|
|
177
|
+
|
|
178
|
+
let daemon;
|
|
179
|
+
try {
|
|
180
|
+
daemon = { running: true, ...(await askDaemon("status")).data };
|
|
181
|
+
} catch (e) {
|
|
182
|
+
daemon = { running: daemonRunning(), reachable: false, reason: e.message };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
out({
|
|
186
|
+
configured: Boolean(config),
|
|
187
|
+
configPath: file,
|
|
188
|
+
...(error ? { error } : {}),
|
|
189
|
+
config: config ?? undefined,
|
|
190
|
+
env: {
|
|
191
|
+
slackToken: Boolean(slackToken()),
|
|
192
|
+
slackTokenSource: readSlackCredentials() ? "slack-setup" : slackToken() ? "environment" : null,
|
|
193
|
+
slackAppToken: Boolean(slackAppToken()),
|
|
194
|
+
...(rawAppToken && !slackAppToken()
|
|
195
|
+
? { slackAppTokenWarning: "the Slack app token is not an app-level xapp- token, so `ask` is unavailable (see SKILL.md, Requirements)" }
|
|
196
|
+
: {}),
|
|
197
|
+
// Who speaks, where its key is read from, and whether anything is there. Never the key itself.
|
|
198
|
+
speechProvider: providerOf(config).id,
|
|
199
|
+
speechKey: Boolean(keyFor(config).value),
|
|
200
|
+
speechKeyVar: keyFor(config).name,
|
|
201
|
+
},
|
|
202
|
+
daemon,
|
|
203
|
+
sessions: sessions.live().map((s) => ({ nickname: s.nickname, swatch: s.swatch, project: s.project, state: s.state, queued: s.queue?.length ?? 0 })),
|
|
204
|
+
platform: process.platform,
|
|
205
|
+
options: {
|
|
206
|
+
methods: METHODS,
|
|
207
|
+
sounds: listSounds(),
|
|
208
|
+
speechProviders: Object.values(PROVIDERS).map((p) => ({ id: p.id, label: p.label, keyVars: p.keyVars })),
|
|
209
|
+
// The configured provider's; `voices --refresh` fetches the real list.
|
|
210
|
+
voices: catalog(providerOf(config).id).voices,
|
|
211
|
+
models: catalog(providerOf(config).id).models,
|
|
212
|
+
speech: SPEECH_MODES,
|
|
213
|
+
duck: DUCK_MODES,
|
|
214
|
+
when: WHEN_MODES,
|
|
215
|
+
kinds: KINDS,
|
|
216
|
+
onExpire: ON_EXPIRE,
|
|
217
|
+
names: NAMES,
|
|
218
|
+
},
|
|
219
|
+
defaults: DEFAULTS,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function setup(args) {
|
|
224
|
+
const file = configPath();
|
|
225
|
+
let base = null;
|
|
226
|
+
try {
|
|
227
|
+
base = loadConfig(file);
|
|
228
|
+
} catch {
|
|
229
|
+
// An unreadable file is replaced rather than blocking setup.
|
|
230
|
+
}
|
|
231
|
+
const config = applySetup(base, args, undefined, SPEECH_SETUP);
|
|
232
|
+
saveConfig(config, file);
|
|
233
|
+
out({ ok: true, configPath: file, config });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function slackSetup(args) {
|
|
237
|
+
const step = args._[0] ?? "status";
|
|
238
|
+
if (step === "status") return out(await setupStatus());
|
|
239
|
+
if (step === "install-cli") return out(await installCli());
|
|
240
|
+
// slackapp.mjs finds the CLI itself; looking here too only gives the friendlier message first.
|
|
241
|
+
// findCli runs the binary to read its version, so it has to be awaited: the bare promise is
|
|
242
|
+
// always truthy and the check never fired.
|
|
243
|
+
if (step === "login") {
|
|
244
|
+
if (!(await findCli())) throw new Error("the Slack CLI isn't installed: run `slack-setup install-cli` first");
|
|
245
|
+
if (args.challenge === undefined) return out(await loginStart());
|
|
246
|
+
if (!args.ticket) throw new Error("pass the ticket from `slack-setup login` with --ticket, alongside --challenge");
|
|
247
|
+
return out(await loginFinish({ challenge: String(args.challenge), ticket: String(args.ticket) }));
|
|
248
|
+
}
|
|
249
|
+
if (step === "create") {
|
|
250
|
+
if (!(await findCli())) throw new Error("the Slack CLI isn't installed: run `slack-setup install-cli` first");
|
|
251
|
+
return out(await createApp({ teamId: args.team ? String(args.team) : undefined }));
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`unknown slack-setup step "${step}" (status, install-cli, login, create)`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function daemonCommand(args) {
|
|
257
|
+
const step = args._[0] ?? "status";
|
|
258
|
+
if (step === "status") {
|
|
259
|
+
try {
|
|
260
|
+
return out({ ok: true, running: true, ...(await askDaemon("status")).data });
|
|
261
|
+
} catch (e) {
|
|
262
|
+
return out({ ok: false, running: false, reason: e.message, home: rogerRogerHome() });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (step === "stop") {
|
|
266
|
+
try {
|
|
267
|
+
return out((await askDaemon("stop")).data);
|
|
268
|
+
} catch {
|
|
269
|
+
return out({ ok: true, running: false, note: "no daemon was running" });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (step === "start") {
|
|
273
|
+
const { data } = await call("ping", {}, { session: who() });
|
|
274
|
+
return out({ ok: true, ...data });
|
|
275
|
+
}
|
|
276
|
+
throw new Error(`unknown daemon command "${step}" (status, start, stop)`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ---------------------------------------------------------------- agent hooks
|
|
280
|
+
|
|
281
|
+
const SCRIPT = fileURLToPath(import.meta.url);
|
|
282
|
+
|
|
283
|
+
/** One line per hook run, so "why didn't it ping me?" has an answer. Kept small. */
|
|
284
|
+
function hookLog(line) {
|
|
285
|
+
try {
|
|
286
|
+
const file = path.join(rogerRogerHome(), "hooks.log");
|
|
287
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
288
|
+
if (fs.existsSync(file) && fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`);
|
|
289
|
+
fs.appendFileSync(file, `${new Date().toISOString()} ${line}\n`);
|
|
290
|
+
} catch {
|
|
291
|
+
// A hook that can't log still must not fail.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Each agent's hooks: how to read what it sends, and how to install them. Claude Code and Codex run a
|
|
296
|
+
// command per hook; OpenCode loads a plugin that forwards its events to the same command.
|
|
297
|
+
const AGENTS = {
|
|
298
|
+
claude: {
|
|
299
|
+
parse: claudeHookRequest,
|
|
300
|
+
status: claudeStatus,
|
|
301
|
+
install: installClaude,
|
|
302
|
+
uninstall: uninstallClaude,
|
|
303
|
+
present: () => fs.existsSync(path.dirname(claudeSettingsPath())),
|
|
304
|
+
describe: (p) => `${p?.hook_event_name ?? "no payload"}${p?.notification_type ? ` (${p.notification_type})` : ""}`,
|
|
305
|
+
},
|
|
306
|
+
opencode: {
|
|
307
|
+
parse: opencodeHookRequest,
|
|
308
|
+
status: opencodeStatus,
|
|
309
|
+
install: installOpencode,
|
|
310
|
+
uninstall: uninstallOpencode,
|
|
311
|
+
present: () => fs.existsSync(opencodeConfigDir()),
|
|
312
|
+
describe: (p) => p?.type ?? "no payload",
|
|
313
|
+
},
|
|
314
|
+
codex: {
|
|
315
|
+
parse: codexHookRequest,
|
|
316
|
+
status: codexStatus,
|
|
317
|
+
install: installCodex,
|
|
318
|
+
uninstall: uninstallCodex,
|
|
319
|
+
present: () => fs.existsSync(codexHome()),
|
|
320
|
+
describe: (p) => `${p?.hook_event_name ?? "no payload"}${p?.tool_name ? ` (${p.tool_name})` : ""}`,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
/** Ask the daemon, or do it here when the daemon is from a copy of the skill that predates hooks. */
|
|
325
|
+
async function sendPermission(args, session) {
|
|
326
|
+
try {
|
|
327
|
+
return await call("permission", args, { session });
|
|
328
|
+
} catch (e) {
|
|
329
|
+
// A daemon from an older copy of the skill doesn't know the command yet (exit 2): do it here.
|
|
330
|
+
// Anything else numeric is a real answer; a lost connection after "received" may have done it.
|
|
331
|
+
if (e.acknowledged || (typeof e.code === "number" && e.code !== 2)) throw e;
|
|
332
|
+
return runLocally("permission", args, session);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** How long a question waits in the terminal before the ping, for agents that can't tell if anyone is typing. */
|
|
337
|
+
function terminalWait() {
|
|
338
|
+
try {
|
|
339
|
+
const seconds = loadConfig()?.terminalWait;
|
|
340
|
+
return Number.isFinite(seconds) && seconds >= 0 ? seconds : DEFAULTS.terminalWait;
|
|
341
|
+
} catch {
|
|
342
|
+
return DEFAULTS.terminalWait;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* `hook <agent>`: run by the agent itself (or its plugin), with the event as JSON on stdin. It prints
|
|
348
|
+
* nothing and always exits 0 — Claude Code reads a hook's output as instructions, and a failing hook
|
|
349
|
+
* is something the user sees in the middle of their session.
|
|
350
|
+
*/
|
|
351
|
+
async function hook(agentName) {
|
|
352
|
+
const agent = AGENTS[agentName];
|
|
353
|
+
if (!agent) return hookLog(`unknown agent "${agentName}"`);
|
|
354
|
+
const payload = await readStdin();
|
|
355
|
+
const request = agent.parse(payload);
|
|
356
|
+
if (!request) return hookLog(`${agentName}: ignored ${agent.describe(payload)}`);
|
|
357
|
+
const cwd = request.cwd && fs.existsSync(request.cwd) ? request.cwd : process.cwd();
|
|
358
|
+
const session = {
|
|
359
|
+
identity: identity(process.env, cwd, { sessionId: request.sessionId }),
|
|
360
|
+
about: { agent: agentName },
|
|
361
|
+
};
|
|
362
|
+
const tag = `${agentName}: ${request.args.event} ${request.sessionId.slice(0, 8)}`;
|
|
363
|
+
try {
|
|
364
|
+
const noted = await sendPermission(request.args, session);
|
|
365
|
+
hookLog(`${tag}: ${describeHookResult(noted.data ?? {})}`);
|
|
366
|
+
// Nothing noted (say, answered already): nothing to wait for.
|
|
367
|
+
if (!request.waitFirst || noted.data?.noted === undefined) return;
|
|
368
|
+
// Give the user the chance to answer at the keyboard first. Answering in the meantime marks the
|
|
369
|
+
// request, and the ping below then finds nothing to do.
|
|
370
|
+
const seconds = terminalWait();
|
|
371
|
+
await sleep(seconds * 1000);
|
|
372
|
+
const pinged = await sendPermission({ event: "prompt", id: request.args.id, cwd: request.args.cwd }, session);
|
|
373
|
+
hookLog(`${agentName}: prompt ${request.sessionId.slice(0, 8)} after ${seconds}s: ${describeHookResult(pinged.data ?? {})}`);
|
|
374
|
+
} catch (e) {
|
|
375
|
+
hookLog(`${tag}: failed: ${e.message}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** One line for the log: what the hook did, or why it did nothing. */
|
|
380
|
+
function describeHookResult(d) {
|
|
381
|
+
if (d.skipped) return `skipped (${d.skipped})`;
|
|
382
|
+
if (d.noted !== undefined) return `noted ${d.noted}`;
|
|
383
|
+
if (d.closed !== undefined) return d.closed ? `closed ${d.about}` : `answered ${d.about}${d.error ? ` (message not updated: ${d.error})` : ""}`;
|
|
384
|
+
return `pinged about ${d.about ?? "an unknown tool"} ${JSON.stringify(d.results ?? {})}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* `hooks status|install|uninstall [claude|opencode|codex|all]`. Without a name: every agent that is on this
|
|
389
|
+
* machine for install, and all of them for status and uninstall.
|
|
390
|
+
*/
|
|
391
|
+
function hooksCommand(args) {
|
|
392
|
+
const step = args._[0] ?? "status";
|
|
393
|
+
if (!["status", "install", "uninstall"].includes(step)) throw new Error(`unknown hooks command "${step}" (status, install, uninstall)`);
|
|
394
|
+
const wanted = args._[1] ?? (step === "install" ? "present" : "all");
|
|
395
|
+
const names = wanted === "all" ? Object.keys(AGENTS)
|
|
396
|
+
: wanted === "present" ? Object.keys(AGENTS).filter((n) => AGENTS[n].present())
|
|
397
|
+
: [wanted];
|
|
398
|
+
for (const n of names) if (!AGENTS[n]) throw new Error(`hooks: unknown agent "${n}" (${Object.keys(AGENTS).join(", ")}, all)`);
|
|
399
|
+
const results = names.map((n) => {
|
|
400
|
+
try {
|
|
401
|
+
if (step === "status") return AGENTS[n].status({ script: SCRIPT });
|
|
402
|
+
if (step === "install") return { ok: true, ...AGENTS[n].install({ script: SCRIPT }) };
|
|
403
|
+
return { ok: true, ...AGENTS[n].uninstall() };
|
|
404
|
+
} catch (e) {
|
|
405
|
+
return { agent: n, ok: false, error: e.message };
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
const failed = results.some((r) => r.ok === false);
|
|
409
|
+
return out({ ok: !failed, agents: results }, failed ? 1 : 0);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const HELP = `roger-roger - bring the user back to the conversation
|
|
413
|
+
|
|
414
|
+
install [--yes] set everything up in the terminal: link the skill for every agent,
|
|
415
|
+
connect Slack, choose the sound and the voice, hooks, tray.
|
|
416
|
+
--yes (or no terminal) only links the skill and registers with Herdr
|
|
417
|
+
status config, daemon, running sessions, every valid option (JSON)
|
|
418
|
+
setup [--methods slack,sound,speech] [--sound NAME] [--voice NAME]
|
|
419
|
+
[--speech auto|same|brief] [--when auto|done-and-blocked|blocked-only|on-request]
|
|
420
|
+
[--slack-target U0123|C0123|#channel] [--slack-mention U0123]
|
|
421
|
+
[--local-fallback true|false] [--model ID] [--notes TEXT]
|
|
422
|
+
[--remind 15m|off] [--on-expire recommended|nothing] [--say-who on|off]
|
|
423
|
+
[--quiet-hours 22:00-08:00|off] [--quiet-mute speech,sound,slack] [--session-label on|off]
|
|
424
|
+
[--speech-provider gemini|openai|elevenlabs] [--speech-key-var NAME|auto]
|
|
425
|
+
[--speech-volume 0-150] [--duck on|off] [--duck-level 0-100] [--tray auto|off]
|
|
426
|
+
[--permission-pings on|off] [--terminal-wait 20s]
|
|
427
|
+
slack-setup [status|install-cli|login|create]
|
|
428
|
+
create the roger-roger Slack app with the Slack CLI
|
|
429
|
+
|
|
430
|
+
notify --kind done|blocked|input|milestone|error|info --message TEXT
|
|
431
|
+
[--say TEXT] [--only sound,speech] [--project NAME] [--attach FILE]
|
|
432
|
+
[--summary] [--field "Label=value"] [--actions "A|B"] [--recommend A]
|
|
433
|
+
[--reply-to TS] [--then-listen 25m]
|
|
434
|
+
[--session NAME] [--agent-model MODEL]
|
|
435
|
+
progress --message TEXT [--key NAME] [--done]
|
|
436
|
+
one Slack message, edited in place as the work moves on
|
|
437
|
+
control [--key NAME] [--wait DURATION]
|
|
438
|
+
read Pause / Stop and new messages without posting
|
|
439
|
+
inbox messages the user sent this session, and anything unclaimed
|
|
440
|
+
snooze [1h|off] mute the sound and the speech for a while; Slack keeps working
|
|
441
|
+
tray status|start|stop|install an icon showing every agent, and a panel behind it
|
|
442
|
+
listen [--wait 25m] park until the user says something; run it in the background as
|
|
443
|
+
the last thing you do, so a message reaches you while you're idle
|
|
444
|
+
|
|
445
|
+
ask --question TEXT --choices "A|B" [--recommend A] [--details TEXT] [--multi]
|
|
446
|
+
[--draft TEXT|--draft-file PATH] [--expires 45m] [--remind 15m]
|
|
447
|
+
[--on-expire recommended|nothing] [--other true|false] [--wait [DURATION]] [--reply-to TS]
|
|
448
|
+
wait ID [--timeout DURATION] block until answered/expired/cancelled (exit 0 / 5, 4 = timed out,
|
|
449
|
+
6 = the user sent a message instead)
|
|
450
|
+
check ID the current state, without waiting
|
|
451
|
+
cancel ID [--reason TEXT] withdraw a question
|
|
452
|
+
decisions [--all] pending questions (and recent closed ones with --all)
|
|
453
|
+
|
|
454
|
+
sessions [--all] which agents are running, and what each is called
|
|
455
|
+
sessions rename NAME NEWNAME give a session a different colour name
|
|
456
|
+
end [NAME] this session (or NAME) is finished; stop routing messages to it
|
|
457
|
+
|
|
458
|
+
play [SOUND] preview a sound
|
|
459
|
+
say TEXT [--voice NAME] [--local | --test]
|
|
460
|
+
--test checks the provider and key: its voice or an error, never the local one
|
|
461
|
+
voices [--refresh] the speech provider's voices and models (--refresh asks the provider)
|
|
462
|
+
preview-sounds [SOUND...] [--voice NAME] [--local]
|
|
463
|
+
announce each sound by name, then play it
|
|
464
|
+
preview-voices [VOICE...] [--all] [--text TEXT]
|
|
465
|
+
|
|
466
|
+
daemon status|start|stop the always-on process behind all of the above
|
|
467
|
+
hooks status|install|uninstall [claude|opencode|all]
|
|
468
|
+
ping you when an agent asks a question in the terminal
|
|
469
|
+
|
|
470
|
+
Any command takes --session NAME (what this work is called, and how the session is recognised
|
|
471
|
+
between commands), --agent-model MODEL, --agent NAME, and, if the agent knows better than the
|
|
472
|
+
environment does, --session-id ID and --agent-pid PID.
|
|
473
|
+
`;
|
|
474
|
+
|
|
475
|
+
async function main() {
|
|
476
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
477
|
+
const args = parseArgs(rest);
|
|
478
|
+
// Before anything reads the home: the first run after the rename brings ~/.attention across.
|
|
479
|
+
try {
|
|
480
|
+
migrateLegacyHome({ hooks: hooksConfig() });
|
|
481
|
+
} catch (e) {
|
|
482
|
+
process.stderr.write(`roger-roger: could not copy ~/.attention to the new home: ${e.message}\n`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
switch (command) {
|
|
486
|
+
case "install":
|
|
487
|
+
process.exitCode = await runInstall(args, { script: SCRIPT, hooks: { agents: AGENTS } });
|
|
488
|
+
return;
|
|
489
|
+
case "status": return status();
|
|
490
|
+
case "setup": return setup(args);
|
|
491
|
+
case "slack-setup": return slackSetup(args);
|
|
492
|
+
case "slack-hook":
|
|
493
|
+
process.exitCode = runHook(args._[0]);
|
|
494
|
+
return;
|
|
495
|
+
case "daemon": return daemonCommand(args);
|
|
496
|
+
case "hooks": return hooksCommand(args);
|
|
497
|
+
case "hook": return hook(args._[0]);
|
|
498
|
+
|
|
499
|
+
// `sessions rename sage plum` is the only sub-command; anything else lists them.
|
|
500
|
+
case "sessions": return args._[0] === "rename" ? run("rename", { ...args, _: args._.slice(1) }) : run("sessions", args);
|
|
501
|
+
case "end": return run("end", args);
|
|
502
|
+
case "inbox":
|
|
503
|
+
case "messages": return run("messages", args);
|
|
504
|
+
|
|
505
|
+
case "notify":
|
|
506
|
+
case "progress":
|
|
507
|
+
case "control":
|
|
508
|
+
case "listen":
|
|
509
|
+
case "snooze":
|
|
510
|
+
case "tray":
|
|
511
|
+
case "ask":
|
|
512
|
+
case "wait":
|
|
513
|
+
case "check":
|
|
514
|
+
case "cancel":
|
|
515
|
+
case "decisions":
|
|
516
|
+
case "play":
|
|
517
|
+
case "say":
|
|
518
|
+
case "voices":
|
|
519
|
+
case "preview-sounds":
|
|
520
|
+
case "preview-voices":
|
|
521
|
+
return run(command, args);
|
|
522
|
+
|
|
523
|
+
default:
|
|
524
|
+
process.stdout.write(HELP);
|
|
525
|
+
process.exitCode = command && command !== "help" ? 2 : 0;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
main()
|
|
530
|
+
.catch((e) => out({ ok: false, error: e.message }, typeof e.code === "number" ? e.code : 1))
|
|
531
|
+
.finally(() => {
|
|
532
|
+
closeAudio();
|
|
533
|
+
// Normally the process exits on its own. If idle sockets hold it open, exit anyway after a
|
|
534
|
+
// moment (an immediate process.exit can trip a libuv assertion on Windows).
|
|
535
|
+
setTimeout(() => process.exit(), 3000).unref();
|
|
536
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Where an incoming Slack message goes. Pure functions over a snapshot of the world, so the rules
|
|
2
|
+
// can be read and tested without Slack, sockets or processes.
|
|
3
|
+
//
|
|
4
|
+
// In order:
|
|
5
|
+
// 1. a reply in a thread → whoever owns that thread (a progress message, a question, or a message
|
|
6
|
+
// that was routed to a session earlier: threads remember)
|
|
7
|
+
// 2. addressed by name → "sage, rerun it", "hey sage rerun it", "sage: rerun it"
|
|
8
|
+
// 3. one live session → that one; there is nothing to be ambiguous about
|
|
9
|
+
// 4. one is listening → that one; the others are only technically running
|
|
10
|
+
// 5. several live → ask the user which, with a button per session
|
|
11
|
+
// 6. none live → the inbox, for the next agent that checks
|
|
12
|
+
|
|
13
|
+
/** Openers people put in front of a name. Matched only when a real nickname follows. */
|
|
14
|
+
const OPENERS = new Set(["hey", "hi", "hello", "ok", "okay", "yo", "oi", "psst", "to", "for", "re", "attn"]);
|
|
15
|
+
|
|
16
|
+
const ADDRESS = /^[@!]?([a-z][a-z0-9-]{1,15})(\s*[,:;>–—-]+\s*|\s+|$)([\s\S]*)$/i;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pull a nickname off the front of a message: `sage, do X` / `sage: do X` / `hey sage do X` /
|
|
20
|
+
* `@sage do X` / `sage` on its own. Returns `{ nickname, text }`, or null when the message doesn't
|
|
21
|
+
* start with a name we know. Only known nicknames match, so "note: remember to..." is just a message.
|
|
22
|
+
*/
|
|
23
|
+
export function parseAddress(text, nicknames = []) {
|
|
24
|
+
const known = new Set(nicknames.map((n) => String(n).toLowerCase()));
|
|
25
|
+
let rest = String(text ?? "").trim();
|
|
26
|
+
for (let step = 0; step < 2 && rest; step++) {
|
|
27
|
+
const m = ADDRESS.exec(rest);
|
|
28
|
+
if (!m) return null;
|
|
29
|
+
const word = m[1].toLowerCase();
|
|
30
|
+
if (known.has(word)) {
|
|
31
|
+
const body = m[3].trim();
|
|
32
|
+
// "sage" on its own is still for sage: keep the original text so the agent sees what was said.
|
|
33
|
+
return { nickname: word, text: body || String(text).trim() };
|
|
34
|
+
}
|
|
35
|
+
if (step === 0 && OPENERS.has(word)) {
|
|
36
|
+
rest = m[3];
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decide where a message belongs.
|
|
46
|
+
*
|
|
47
|
+
* `progress` are the active progress messages, `pending` the open questions, `sessions` the live
|
|
48
|
+
* agent sessions, and `threads` maps a Slack thread_ts to the session that owns it.
|
|
49
|
+
*
|
|
50
|
+
* Returns one of:
|
|
51
|
+
* { kind: "progress", key, session } a note for that work
|
|
52
|
+
* { kind: "decision", id, session } the answer to that question
|
|
53
|
+
* { kind: "session", id, text, via } a message for one agent (via "listening" was a guess)
|
|
54
|
+
* { kind: "ask", candidates } several agents are live: let the user pick
|
|
55
|
+
* { kind: "inbox" } nobody is running; keep it for the next agent
|
|
56
|
+
*/
|
|
57
|
+
export function routeMessage(message, { progress = [], pending = [], sessions = [], threads = {} } = {}) {
|
|
58
|
+
const text = String(message?.text ?? "");
|
|
59
|
+
const thread = message.thread_ts && message.thread_ts !== message.ts ? message.thread_ts : null;
|
|
60
|
+
|
|
61
|
+
if (thread) {
|
|
62
|
+
const p = progress.find((s) => s.ts === thread);
|
|
63
|
+
if (p) return { kind: "progress", key: p.key, session: p.session ?? null };
|
|
64
|
+
const d = pending.find((x) => x.ts === thread);
|
|
65
|
+
if (d) return { kind: "decision", id: d.id, session: d.session ?? null };
|
|
66
|
+
// A thread that was routed to a session before keeps going there, as long as it is still live.
|
|
67
|
+
const owner = threads[thread];
|
|
68
|
+
if (owner && sessions.some((s) => s.id === owner)) return { kind: "session", id: owner, text, via: "thread" };
|
|
69
|
+
return { kind: "inbox" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const addressed = parseAddress(text, sessions.map((s) => s.nickname));
|
|
73
|
+
if (addressed) {
|
|
74
|
+
const target = sessions.find((s) => s.nickname === addressed.nickname);
|
|
75
|
+
if (target) return { kind: "session", id: target.id, text: addressed.text, via: "name" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (sessions.length === 1) return { kind: "session", id: sessions[0].id, text, via: "only" };
|
|
79
|
+
// "Live" only means the agent's process is up; most of those are sitting idle. If exactly one is
|
|
80
|
+
// parked and waiting to be spoken to, it is the obvious recipient — asking is busywork. The guess
|
|
81
|
+
// is said out loud in Slack, with the others a tap away, so getting it wrong costs one tap.
|
|
82
|
+
const listening = sessions.filter((s) => s.state === "listening");
|
|
83
|
+
if (listening.length === 1) return { kind: "session", id: listening[0].id, text, via: "listening" };
|
|
84
|
+
if (sessions.length > 1) return { kind: "ask", candidates: sessions.map((s) => s.id), text };
|
|
85
|
+
return { kind: "inbox" };
|
|
86
|
+
}
|