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.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +147 -0
  3. package/package.json +45 -0
  4. package/skills/roger-roger/SKILL.md +289 -0
  5. package/skills/roger-roger/herdr-plugin.toml +38 -0
  6. package/skills/roger-roger/scripts/agent.mjs +132 -0
  7. package/skills/roger-roger/scripts/audio.mjs +392 -0
  8. package/skills/roger-roger/scripts/client.mjs +121 -0
  9. package/skills/roger-roger/scripts/daemon.mjs +604 -0
  10. package/skills/roger-roger/scripts/decisions.mjs +158 -0
  11. package/skills/roger-roger/scripts/handlers.mjs +1151 -0
  12. package/skills/roger-roger/scripts/herdr.mjs +140 -0
  13. package/skills/roger-roger/scripts/hooks-codex.mjs +154 -0
  14. package/skills/roger-roger/scripts/hooks-opencode.mjs +167 -0
  15. package/skills/roger-roger/scripts/hooks.mjs +420 -0
  16. package/skills/roger-roger/scripts/inbox.mjs +381 -0
  17. package/skills/roger-roger/scripts/install.mjs +560 -0
  18. package/skills/roger-roger/scripts/lib.mjs +1133 -0
  19. package/skills/roger-roger/scripts/names.mjs +84 -0
  20. package/skills/roger-roger/scripts/progress.mjs +91 -0
  21. package/skills/roger-roger/scripts/protocol.mjs +71 -0
  22. package/skills/roger-roger/scripts/roger-roger.mjs +536 -0
  23. package/skills/roger-roger/scripts/router.mjs +86 -0
  24. package/skills/roger-roger/scripts/sessions.mjs +218 -0
  25. package/skills/roger-roger/scripts/slack.mjs +240 -0
  26. package/skills/roger-roger/scripts/slackapp.mjs +205 -0
  27. package/skills/roger-roger/scripts/slackcli.mjs +144 -0
  28. package/skills/roger-roger/scripts/speaker.mjs +224 -0
  29. package/skills/roger-roger/scripts/speechkey.mjs +106 -0
  30. package/skills/roger-roger/scripts/tray.mjs +128 -0
  31. package/skills/roger-roger/scripts/tts.mjs +275 -0
  32. package/skills/roger-roger/scripts/tui.mjs +465 -0
  33. package/skills/roger-roger/slack/manifest.json +34 -0
  34. package/skills/roger-roger/sounds/alert.wav +0 -0
  35. package/skills/roger-roger/sounds/bubble.wav +0 -0
  36. package/skills/roger-roger/sounds/chime.wav +0 -0
  37. package/skills/roger-roger/sounds/ding.wav +0 -0
  38. package/skills/roger-roger/sounds/marimba.wav +0 -0
  39. package/skills/roger-roger/tray/main.mjs +749 -0
  40. package/skills/roger-roger/tray/panel.html +501 -0
@@ -0,0 +1,604 @@
1
+ #!/usr/bin/env node
2
+ // The one process that does the work. Agents come and go; this stays.
3
+ //
4
+ // It owns the Slack connection (always, not just while a question is open, so a message sent at any
5
+ // moment is taken in straight away), the speakers (one announcement at a time, with the Windows
6
+ // audio player kept warm), every state file, and the register of which agent sessions are running.
7
+ // Agents reach it over a local socket and are otherwise thin: parse a command, ask, print the answer.
8
+ //
9
+ // Only one can run: binding the socket is the mutual exclusion. A second daemon fails to bind and
10
+ // leaves. The lock file next to it is only for `status` to read.
11
+
12
+ import crypto from "node:crypto";
13
+ import fs from "node:fs";
14
+ import net from "node:net";
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { EventEmitter } from "node:events";
18
+ import { spawn } from "node:child_process";
19
+ import { fileURLToPath } from "node:url";
20
+ import {
21
+ CHECKS, EDIT, EDIT_CALLBACK, NOTE_CALLBACK, OTHER, OTHER_CALLBACK, SUBMIT, applyQuietHours,
22
+ rogerRogerHome, checkedIndices, editModal, legacyHome, loadConfigSafely, machineEnv, noteModal, otherModal, parseActionId,
23
+ parseProgressActionId, parseRouteActionId, pidAlive, progressBlocks, reminderText, slackAppToken,
24
+ slackToken, stripTags,
25
+ } from "./lib.mjs";
26
+ import { lineReader, send, socketPath, PROTOCOL_VERSION } from "./protocol.mjs";
27
+ import { connectSocket, openModal, permalink, postToChannel, updateMessage } from "./slack.mjs";
28
+ import { closeAudio, playAlert, warmUpAudio } from "./audio.mjs";
29
+ import { FROM_HOOKS, HANDLERS, owedTo, startTray } from "./handlers.mjs";
30
+ import * as progressStore from "./progress.mjs";
31
+ import * as tray from "./tray.mjs";
32
+ import * as sessions from "./sessions.mjs";
33
+ import * as inbox from "./inbox.mjs";
34
+ import { labelPane } from "./herdr.mjs";
35
+ import { expire, isExpired, list, patchPending, read, settle, undeliveredFor } from "./decisions.mjs";
36
+
37
+ const TICK_MS = 2_000;
38
+ const HEARTBEAT_STALE_MS = 30_000;
39
+ const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
40
+
41
+ const lockPath = () => path.join(rogerRogerHome(), "daemon.json");
42
+ const logPath = () => path.join(rogerRogerHome(), "daemon.log");
43
+
44
+ function log(line) {
45
+ try {
46
+ fs.appendFileSync(logPath(), `${new Date().toISOString()} [${process.pid}] ${line}\n`);
47
+ } catch {
48
+ // Logging must never take the daemon down.
49
+ }
50
+ }
51
+
52
+ // A broken config file is the user's to fix and the log's to mention, not a reason to stop serving.
53
+ const loadConfig = () => loadConfigSafely(undefined, log);
54
+
55
+ /**
56
+ * A fingerprint of the skill's scripts. A daemon started before the skill was updated would keep
57
+ * serving with old code, so it compares this each tick and hands over to a fresh one when it changes.
58
+ */
59
+ function codeStamp() {
60
+ try {
61
+ return fs.readdirSync(SCRIPTS_DIR)
62
+ .filter((f) => f.endsWith(".mjs"))
63
+ .map((f) => `${f}:${fs.statSync(path.join(SCRIPTS_DIR, f)).mtimeMs}`)
64
+ .sort()
65
+ .join("|");
66
+ } catch {
67
+ return "";
68
+ }
69
+ }
70
+
71
+ export function readLock() {
72
+ try {
73
+ return JSON.parse(fs.readFileSync(lockPath(), "utf8"));
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ /** For `status`: is a daemon up? The socket is the real answer, this is the cheap one. */
80
+ export function daemonRunning() {
81
+ const lock = readLock();
82
+ return Boolean(lock && pidAlive(lock.pid) && Date.now() - lock.heartbeat < HEARTBEAT_STALE_MS);
83
+ }
84
+
85
+ const bus = new EventEmitter();
86
+ bus.setMaxListeners(0);
87
+
88
+ // What a tray icon draws. Written whenever anything moves, at most once a second: a tray watches
89
+ // this one file rather than speaking the daemon's protocol itself.
90
+ let snapshotAt = 0;
91
+ let snapshotTimer = null;
92
+ function refreshTray({ ifStaleMs = 0 } = {}) {
93
+ // The tick calls this to keep "quiet for" honest while nothing is happening; everything else
94
+ // calls it because something just did.
95
+ if (ifStaleMs && Date.now() - snapshotAt < ifStaleMs) return;
96
+ const wait = Math.max(0, 1000 - (Date.now() - snapshotAt));
97
+ if (snapshotTimer) return;
98
+ snapshotTimer = setTimeout(() => {
99
+ snapshotTimer = null;
100
+ snapshotAt = Date.now();
101
+ try {
102
+ tray.write(tray.snapshot({ config: loadConfig(), daemon: { pid: process.pid, slackConnected: state.slackConnected } }));
103
+ } catch (e) {
104
+ log(`could not write the tray snapshot: ${e.message}`);
105
+ }
106
+ }, wait);
107
+ snapshotTimer.unref();
108
+ }
109
+
110
+ const changed = (why) => {
111
+ refreshTray();
112
+ bus.emit("change", why);
113
+ };
114
+
115
+ const state = {
116
+ startedAt: Date.now(),
117
+ socket: null, // Slack Socket Mode
118
+ socketRetryAt: 0,
119
+ socketFailures: 0,
120
+ slackConnected: false,
121
+ clients: 0,
122
+ waiters: new Map(), // decision id → how many agents are blocked on it right now
123
+ activity: new Map(), // session id → when it last ran something other than `listen`
124
+ inFlight: 0, // requests being served; a handover waits for them rather than cutting them off
125
+ };
126
+
127
+ /** Is an agent actually sitting on this question? Settling it decides what Slack says if not. */
128
+ const hasWaiter = (id) => (state.waiters.get(id) ?? 0) > 0;
129
+
130
+ // ---------------------------------------------------------------- serving agents
131
+
132
+ function ctxFor(connection, identity, about) {
133
+ const session = identity?.id ? sessions.decorate(sessions.register(identity, about ?? {})) : null;
134
+ // A session in a Herdr pane gets its name on the tab and the agent, once, in the background.
135
+ if (session) labelPane(session, { log, touch: sessions.touch }).catch((e) => log(`herdr: ${e.message}`));
136
+ let lastSync = Date.now(); // the Slack socket is live, so a catch-up is only for after downtime
137
+ return {
138
+ config: loadConfig(),
139
+ session,
140
+ identity,
141
+ log,
142
+ daemon: true,
143
+ emit: (event, data) => send(connection, { n: 1, event, data }),
144
+ /** When this agent last ran a real command. A parked `listen` gives up once it is awake again. */
145
+ lastActivity() {
146
+ return session ? state.activity.get(session.id) ?? 0 : 0;
147
+ },
148
+ /** Register this connection as waiting on a question; the returned function gives it back. */
149
+ waiting(id) {
150
+ state.waiters.set(id, (state.waiters.get(id) ?? 0) + 1);
151
+ return () => {
152
+ const left = (state.waiters.get(id) ?? 1) - 1;
153
+ if (left > 0) state.waiters.set(id, left);
154
+ else state.waiters.delete(id);
155
+ };
156
+ },
157
+ /** Wait, but wake early when anything changes — an answer, a button, a message. */
158
+ idle(ms) {
159
+ if (connection.destroyed) throw Object.assign(new Error("client went away"), { aborted: true });
160
+ return new Promise((resolve) => {
161
+ const done = () => {
162
+ clearTimeout(timer);
163
+ bus.off("change", done);
164
+ connection.off("close", done);
165
+ resolve();
166
+ };
167
+ const timer = setTimeout(done, ms);
168
+ bus.once("change", done);
169
+ connection.once("close", done);
170
+ });
171
+ },
172
+ /** Slack is connected live, so this is a no-op unless the connection has been down. */
173
+ async catchUp() {
174
+ if (state.slackConnected || Date.now() - lastSync < 15_000) return;
175
+ lastSync = Date.now();
176
+ await inbox.sync({ log }).catch((e) => log(`inbox sync failed: ${e.message}`));
177
+ },
178
+ };
179
+ }
180
+
181
+ const LOCAL = {
182
+ ping: () => ({ data: { ok: true, pid: process.pid, protocol: PROTOCOL_VERSION, startedAt: new Date(state.startedAt).toISOString() } }),
183
+ status: (ctx) => ({
184
+ data: {
185
+ ok: true,
186
+ pid: process.pid,
187
+ startedAt: new Date(state.startedAt).toISOString(),
188
+ slackConnected: state.slackConnected,
189
+ sessions: sessions.live().map((s) => ({ nickname: s.nickname, swatch: s.swatch, project: s.project, state: s.state, queued: s.queue?.length ?? 0, owes: undeliveredFor(s.id).length, me: s.id === ctx.session?.id })),
190
+ me: ctx.session
191
+ ? {
192
+ nickname: ctx.session.nickname,
193
+ swatch: ctx.session.swatch,
194
+ colour: ctx.session.colour,
195
+ ...(ctx.session.herdr ? { herdr: { tab: ctx.session.herdr.tab, pane: ctx.session.herdr.pane, labelled: ctx.session.herdrLabel ?? null } } : {}),
196
+ }
197
+ : null,
198
+ },
199
+ }),
200
+ stop: () => {
201
+ setTimeout(() => shutdown("asked to stop"), 50);
202
+ return { data: { ok: true, stopping: true } };
203
+ },
204
+ };
205
+
206
+ async function onRequest(connection, message) {
207
+ const { n, cmd, args = {}, session } = message;
208
+ const reply = (body) => send(connection, { n: n ?? 1, ...body });
209
+ const handler = LOCAL[cmd] ?? HANDLERS[cmd];
210
+ if (!handler) return reply({ ok: false, error: `unknown command "${cmd}"`, code: 2 });
211
+ let ctx;
212
+ try {
213
+ ctx = ctxFor(connection, session?.identity, session?.about);
214
+ } catch (e) {
215
+ return reply({ ok: false, error: `could not register the session: ${e.message}`, code: 1 });
216
+ }
217
+ // Running anything at all means the agent is back; that releases a `listen` it left parked.
218
+ if (cmd !== "listen" && !FROM_HOOKS.has(cmd) && ctx.session) state.activity.set(ctx.session.id, Date.now());
219
+ // From here the command is ours. If we die before answering, the caller must not run it again —
220
+ // a notification that was already posted would arrive twice.
221
+ reply({ event: "received" });
222
+ state.inFlight++;
223
+ try {
224
+ const { data, code = 0 } = (await handler(ctx, args)) ?? {};
225
+ // Whatever the user said while this agent wasn't listening rides back on whatever it ran.
226
+ const body = FROM_HOOKS.has(cmd) ? data : await owedTo(ctx, data).catch((e) => {
227
+ log(`handing over what was owed failed: ${e.message}`);
228
+ return data;
229
+ });
230
+ changed(cmd);
231
+ reply({ ok: true, data: body, code });
232
+ } catch (e) {
233
+ if (e.aborted) return; // the agent's command was interrupted; nobody is listening
234
+ log(`${cmd} failed: ${e.stack ?? e.message}`);
235
+ // Only a number is an exit code. Slack and Node errors carry text codes (`ratelimited`, `EPERM`),
236
+ // and the CLI reads anything that isn't a number as "the daemon wasn't there" and runs the
237
+ // command again itself — which is how one post became two.
238
+ reply({ ok: false, error: e.message, code: typeof e.code === "number" ? e.code : 1 });
239
+ } finally {
240
+ state.inFlight--;
241
+ }
242
+ }
243
+
244
+ function onConnection(connection) {
245
+ state.clients++;
246
+ connection.setEncoding("utf8");
247
+ connection.on("error", () => {});
248
+ connection.on("close", () => state.clients--);
249
+ connection.on("data", lineReader(
250
+ (message) => onRequest(connection, message).catch((e) => log(`request crashed: ${e.stack ?? e.message}`)),
251
+ (e) => log(`bad request line: ${e.message}`),
252
+ ));
253
+ }
254
+
255
+ // ---------------------------------------------------------------- Slack
256
+
257
+ async function onInteractive(payload) {
258
+ const callback = payload.view?.callback_id;
259
+ if (payload.type === "view_submission" && callback === NOTE_CALLBACK) {
260
+ const text = (payload.view.state?.values?.note?.text?.value ?? "").trim();
261
+ if (text) await onProgressNote(payload.view.private_metadata, text, payload.user?.id ?? null);
262
+ return changed("note");
263
+ }
264
+ if (payload.type === "view_submission" && (callback === OTHER_CALLBACK || callback === EDIT_CALLBACK)) {
265
+ const id = payload.view.private_metadata;
266
+ const edited = callback === EDIT_CALLBACK;
267
+ // A draft keeps its own line breaks and spacing; a typed answer is trimmed.
268
+ const raw = payload.view.state?.values?.answer?.text?.value ?? "";
269
+ const text = edited ? raw : raw.trim();
270
+ if (!text.trim() || read(id)?.status !== "pending") return;
271
+ const settled = await settle(id, {
272
+ status: "answered",
273
+ answer: { index: null, choice: null, text, by: payload.user?.id ?? null, ...(edited ? { edited: true } : {}) },
274
+ }, log, { waiting: hasWaiter(id) });
275
+ if (settled) log(`${id}: answered with ${edited ? "an edited draft" : "text"}`);
276
+ return changed("answer");
277
+ }
278
+ if (payload.type !== "block_actions") return;
279
+ for (const action of payload.actions ?? []) {
280
+ const routed = parseRouteActionId(action.action_id);
281
+ if (routed) {
282
+ await inbox.resolvePick(routed.ts, routed.sessionId, log);
283
+ changed("routed");
284
+ continue;
285
+ }
286
+ const progressAction = parseProgressActionId(action.action_id);
287
+ if (progressAction?.action === "note") {
288
+ const current = progressStore.current(progressAction.key);
289
+ if (current) await openModal(payload.trigger_id, noteModal({ key: progressAction.key, headline: current.headline }));
290
+ continue;
291
+ }
292
+ if (progressAction) {
293
+ await onProgressAction(progressAction, payload.user?.id ?? null);
294
+ changed("control");
295
+ continue;
296
+ }
297
+ const parsed = parseActionId(action.action_id);
298
+ if (!parsed) continue;
299
+ const decision = read(parsed.id);
300
+ if (!decision || decision.status !== "pending") continue;
301
+ if (parsed.index === OTHER) {
302
+ await openModal(payload.trigger_id, otherModal(decision));
303
+ continue;
304
+ }
305
+ if (parsed.index === EDIT && decision.draft) {
306
+ await openModal(payload.trigger_id, editModal(decision));
307
+ continue;
308
+ }
309
+ // Ticking a checkbox is just the user making up their mind; Submit is the answer.
310
+ if (parsed.index === CHECKS) continue;
311
+ if (parsed.index === SUBMIT) {
312
+ const indices = checkedIndices(payload.state, parsed.id).filter((i) => decision.choices[i] !== undefined);
313
+ const choices = indices.map((i) => decision.choices[i]);
314
+ const settled = await settle(parsed.id, { status: "answered", answer: { indices, choices, by: payload.user?.id ?? null } }, log, { waiting: hasWaiter(parsed.id) });
315
+ if (settled) log(`${parsed.id}: answered ${JSON.stringify(choices)}`);
316
+ changed("answer");
317
+ continue;
318
+ }
319
+ const choice = decision.choices[parsed.index];
320
+ if (choice === undefined) continue;
321
+ const settled = await settle(parsed.id, { status: "answered", answer: { index: parsed.index, choice, by: payload.user?.id ?? null } }, log, { waiting: hasWaiter(parsed.id) });
322
+ if (settled) log(`${parsed.id}: answered "${choice}"`);
323
+ changed("answer");
324
+ }
325
+ }
326
+
327
+ /** Pause / Resume / Stop on a progress message: record it for the agent, and show it in the message. */
328
+ async function onProgressAction({ key, action }, by) {
329
+ const current = progressStore.current(key);
330
+ if (!current) return;
331
+ const control = { state: action === "resume" ? "running" : action, by, at: new Date().toISOString() };
332
+ log(`progress ${key}: ${action}`);
333
+ await saveAndRender(key, { ...current, control });
334
+ }
335
+
336
+ async function onProgressNote(key, text, by) {
337
+ const current = progressStore.current(key);
338
+ if (!current) return;
339
+ log(`progress ${key}: note`);
340
+ await saveAndRender(key, { ...current, notes: [...(current.notes ?? []), { text, by, at: new Date().toISOString() }] });
341
+ }
342
+
343
+ async function saveAndRender(key, next) {
344
+ progressStore.save(key, next);
345
+ try {
346
+ await updateMessage(next.channel, next.ts, { text: stripTags(next.headline), blocks: progressBlocks({ ...next, key }), color: next.colour });
347
+ } catch (e) {
348
+ log(`progress ${key}: could not update message: ${e.message}`);
349
+ }
350
+ }
351
+
352
+ async function onEvent(event) {
353
+ if (event.type === "message" && event.channel_type === "im") {
354
+ await inbox.ingest(event, { log, live: true, waiting: hasWaiter });
355
+ changed("message");
356
+ }
357
+ }
358
+
359
+ /** Keep the Slack connection up whenever tokens exist, whether or not anything is pending. */
360
+ async function keepSlackConnected() {
361
+ if (state.socket || Date.now() < state.socketRetryAt) return;
362
+ if (!slackToken() || !slackAppToken() || typeof WebSocket !== "function") return;
363
+ try {
364
+ state.socket = await connectSocket({
365
+ onInteractive,
366
+ onEvent,
367
+ log,
368
+ onClose: () => {
369
+ state.socket = null;
370
+ state.slackConnected = false;
371
+ state.socketRetryAt = Date.now() + 2_000;
372
+ },
373
+ });
374
+ state.slackConnected = true;
375
+ state.socketFailures = 0;
376
+ log("slack connected");
377
+ // Anything sent while nothing was listening: take it in now.
378
+ await inbox.sync({ log }).catch((e) => log(`inbox sync failed: ${e.message}`));
379
+ changed("slack");
380
+ } catch (e) {
381
+ state.socketFailures++;
382
+ state.socketRetryAt = Date.now() + Math.min(60_000, 5_000 * state.socketFailures);
383
+ log(`socket connect failed: ${e.message}`);
384
+ }
385
+ }
386
+
387
+ /** Send the next reminder for a decision if its time has come. */
388
+ async function remindIfDue(decision) {
389
+ const due = decision.reminders ?? [];
390
+ const sent = decision.remindersSent ?? 0;
391
+ if (sent >= due.length || Date.now() < Date.parse(due[sent])) return;
392
+ // Record first, so a crash mid-reminder can't make it repeat forever.
393
+ if (!patchPending(decision.id, { remindersSent: sent + 1 })) return;
394
+
395
+ let config;
396
+ try {
397
+ config = loadConfig();
398
+ } catch {
399
+ config = null;
400
+ }
401
+ if (!config) return;
402
+ const { methods } = applyQuietHours(config.methods, config);
403
+ log(`${decision.id}: reminder ${sent + 1} of ${due.length}`);
404
+
405
+ const slackJob = methods.includes("slack")
406
+ ? permalink(decision.channel, decision.ts)
407
+ .then((link) => postToChannel(decision.channel, { text: reminderText({ question: decision.question, permalink: link, count: sent + 1 }), color: decision.colour }))
408
+ .catch((e) => log(`${decision.id}: reminder post failed: ${e.message}`))
409
+ : null;
410
+ try {
411
+ await playAlert({ methods, config, text: "[neutral] Quick reminder. I'm still waiting for your answer in Slack." });
412
+ } catch (e) {
413
+ log(`${decision.id}: reminder audio failed: ${e.message}`);
414
+ }
415
+ await slackJob;
416
+ }
417
+
418
+ // ---------------------------------------------------------------- the process
419
+
420
+ function writeLock() {
421
+ fs.mkdirSync(rogerRogerHome(), { recursive: true });
422
+ fs.writeFileSync(lockPath(), JSON.stringify({
423
+ pid: process.pid,
424
+ socket: socketPath(),
425
+ startedAt: state.startedAt,
426
+ heartbeat: Date.now(),
427
+ protocol: PROTOCOL_VERSION,
428
+ }, null, 2) + "\n");
429
+ }
430
+
431
+ function releaseLock() {
432
+ if (readLock()?.pid === process.pid) fs.rmSync(lockPath(), { force: true });
433
+ }
434
+
435
+ let stopping = false;
436
+ function shutdown(why, { restart = false } = {}) {
437
+ if (stopping) return;
438
+ stopping = true;
439
+ log(`daemon stopping: ${why}`);
440
+ try {
441
+ state.socket?.close();
442
+ server?.close();
443
+ fs.rmSync(socketPath(), { force: true }); // unix socket file; a no-op for a named pipe
444
+ } catch {
445
+ // Going down anyway.
446
+ }
447
+ releaseLock();
448
+ closeAudio();
449
+ // A tray left drawing the last snapshot would show agents and questions that nobody is keeping
450
+ // track of any more. Saying there is no daemon lets it say so too.
451
+ if (snapshotTimer) clearTimeout(snapshotTimer);
452
+ try {
453
+ tray.write(tray.snapshot({ config: loadConfig(), daemon: null }));
454
+ } catch {
455
+ // Going down anyway.
456
+ }
457
+ if (restart) {
458
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], { cwd: os.homedir(), detached: true, stdio: "ignore", windowsHide: true, env: machineEnv() });
459
+ child.unref();
460
+ }
461
+ // Let anything mid-flight finish answering first. A command cut off here used to be repeated by
462
+ // the CLI, which is how one notification became two. Parked long polls just get the full wait.
463
+ const deadline = Date.now() + 5_000;
464
+ const drain = () => {
465
+ if (state.inFlight <= 0 || Date.now() >= deadline) return process.exit(0);
466
+ setTimeout(drain, 50).unref();
467
+ };
468
+ setTimeout(drain, 100).unref();
469
+ }
470
+
471
+ let server = null;
472
+
473
+ /**
474
+ * The daemon from before the rename, if it is still running. It holds its own Socket Mode connection
475
+ * to the same Slack app, and Slack shares events out between connections — so with both up, about
476
+ * half of the user's clicks and messages would go to a process that no longer answers for anything.
477
+ * It speaks the same protocol, so it is asked to stop the same way `daemon stop` would.
478
+ */
479
+ async function stopLegacyDaemon() {
480
+ if (process.env.ROGER_ROGER_HOME) return; // a test or a second account; the old one isn't ours
481
+ const home = legacyHome();
482
+ const target = process.platform === "win32"
483
+ ? `\\\\.\\pipe\\attention-${crypto.createHash("sha1").update(home.toLowerCase()).digest("hex").slice(0, 12)}`
484
+ : path.join(home, "daemon.sock");
485
+ await new Promise((resolve) => {
486
+ const socket = net.createConnection(target);
487
+ const done = () => (socket.destroy(), resolve());
488
+ const timer = setTimeout(done, 3_000);
489
+ socket.once("error", () => (clearTimeout(timer), resolve()));
490
+ socket.once("connect", () => {
491
+ log("the daemon from before the rename is still running; asking it to stop");
492
+ send(socket, { n: 1, cmd: "stop", args: {} });
493
+ socket.setEncoding("utf8");
494
+ socket.on("data", lineReader((message) => {
495
+ if (message.n === 1 && !message.event) (clearTimeout(timer), done());
496
+ }));
497
+ });
498
+ });
499
+ }
500
+
501
+ function configMtime() {
502
+ try {
503
+ return fs.statSync(path.join(rogerRogerHome(), "config.json")).mtimeMs;
504
+ } catch {
505
+ return 0;
506
+ }
507
+ }
508
+
509
+ /** Bring the tray up with the daemon, unless the user said not to or it was never installed. */
510
+ async function autostartTray() {
511
+ if (process.env.ROGER_ROGER_NO_TRAY === "1" || loadConfig()?.tray === "off") return;
512
+ try {
513
+ const started = await startTray();
514
+ if (started.starting) log(`tray started (pid ${started.pid})`);
515
+ } catch (e) {
516
+ log(`could not start the tray: ${e.message}`);
517
+ }
518
+ }
519
+
520
+ /** Bind the socket, or discover that another daemon owns it and step aside. */
521
+ function listen() {
522
+ return new Promise((resolve, reject) => {
523
+ server = net.createServer(onConnection);
524
+ server.once("error", async (e) => {
525
+ if (e.code !== "EADDRINUSE") return reject(e);
526
+ // Either a daemon is already running, or a unix socket file was left behind by a dead one.
527
+ const alive = await new Promise((done) => {
528
+ const probe = net.createConnection(socketPath());
529
+ probe.once("connect", () => (probe.destroy(), done(true)));
530
+ probe.once("error", () => done(false));
531
+ });
532
+ if (alive) return reject(Object.assign(new Error("another daemon is already running"), { code: "TAKEN" }));
533
+ try {
534
+ fs.rmSync(socketPath(), { force: true });
535
+ } catch {
536
+ // Nothing else to try.
537
+ }
538
+ server = net.createServer(onConnection);
539
+ server.once("error", reject);
540
+ server.listen(socketPath(), resolve);
541
+ });
542
+ server.listen(socketPath(), resolve);
543
+ });
544
+ }
545
+
546
+ async function main() {
547
+ // The home may not exist yet: nothing has been set up, and this is the first command ever run.
548
+ // On macOS and Linux the socket lives in it, so binding would fail, and so would logging why.
549
+ try {
550
+ fs.mkdirSync(rogerRogerHome(), { recursive: true });
551
+ } catch {
552
+ // If it truly can't be made, listen() says so below.
553
+ }
554
+ try {
555
+ await listen();
556
+ } catch (e) {
557
+ if (e.code === "TAKEN") return; // someone beat us to it; that is a success, quietly
558
+ log(`could not listen on ${socketPath()}: ${e.message}`);
559
+ process.exitCode = 1;
560
+ return;
561
+ }
562
+ writeLock();
563
+ const stamp = codeStamp();
564
+ log(`daemon started on ${socketPath()}`);
565
+ warmUpAudio(); // so the first announcement of the day doesn't wait for a PowerShell to start
566
+ await stopLegacyDaemon().catch((e) => log(`could not stop the old daemon: ${e.message}`));
567
+ autostartTray();
568
+
569
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(signal, () => shutdown(signal));
570
+
571
+ let configStamp = configMtime();
572
+ while (!stopping) {
573
+ writeLock();
574
+ // `setup` runs in the caller's process, not here. Noticing its write is what keeps the tray's
575
+ // settings from sitting on the old values until the next slow refresh.
576
+ const stampNow = configMtime();
577
+ if (stampNow !== configStamp) {
578
+ configStamp = stampNow;
579
+ changed("config");
580
+ }
581
+ if (codeStamp() !== stamp) return shutdown("the skill's code changed; a fresh daemon takes over", { restart: true });
582
+
583
+ for (const d of list().filter((d) => isExpired(d))) {
584
+ const settled = await expire(d, log, { waiting: hasWaiter(d.id) });
585
+ if (settled) {
586
+ log(`${d.id}: ${settled.answer?.auto ? `no answer, defaulted to ${JSON.stringify(settled.answer.choice ?? settled.answer.choices)}` : "expired"}`);
587
+ changed("expired");
588
+ }
589
+ }
590
+ for (const d of list().filter((d) => d.status === "pending")) await remindIfDue(d);
591
+ refreshTray({ ifStaleMs: 15_000 }); // nothing may be happening, but time still passes
592
+ await keepSlackConnected();
593
+
594
+ await new Promise((r) => setTimeout(r, TICK_MS));
595
+ }
596
+ }
597
+
598
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
599
+ main().catch((e) => {
600
+ log(`daemon crashed: ${e.stack ?? e.message}`);
601
+ releaseLock();
602
+ process.exit(1);
603
+ });
604
+ }