@astrofoundry/pi-astro 0.32.0 → 0.33.1

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.
@@ -1,5 +1,6 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, spawn } from "node:child_process";
2
2
  import { promises as fs } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
3
4
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
5
  import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
5
6
  import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
@@ -10,13 +11,46 @@ import { ApprovalServer, type Ticket } from "./approvals.ts";
10
11
  import { chunkMessage } from "./chunk.ts";
11
12
  import { applyAdminCommand } from "./admin.ts";
12
13
  import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
13
- import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
14
+ import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, type ReportName, type ReportSchedule, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, reportSchedule, saveConfig } from "./config.ts";
14
15
  import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
15
16
  import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
16
- import { failureMessage, formatDailyReport, isDue, readReportState, writeReportState } from "./daily-report.ts";
17
+ import { failureMessage, formatDailyReport } from "./daily-report.ts";
17
18
  import { GatewayClient } from "./gateway.ts";
18
19
  import { type CommandOption, IS_COMPONENTS_V2, KeyedQueue, MEDIA, MEDIA_COMMANDS, type MediaKind, type Outcome, addOutcome, applyOutcome, commandArgs, hasActiveButton, listMessage, mediaAccess, mediaPointer, parseAddButton, parseAddReport, parseMediaList, parseQueueState, queueLine } from "./media.ts";
19
20
  import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
21
+ import { isDue, readReportState, writeReportState } from "./reports.ts";
22
+ import { ACTIVITY_COMMAND, type Activity, addRestarts, astrogateActivity, herdrActivity, needsPrompt, type PendingRestart, readRestarts, restartCommand, SERVICE_LABEL, SERVICE_ORDER, writeRestarts } from "./restarts.ts";
23
+ import {
24
+ arcaneServerVersion,
25
+ BREW_ALL,
26
+ brewHasOutdated,
27
+ buildLines,
28
+ collectTools,
29
+ type Exec,
30
+ failureText,
31
+ layoutMessages,
32
+ type LineState,
33
+ messageComponents,
34
+ notesFor,
35
+ parseToolsButton,
36
+ parseVerdict,
37
+ readPackageVersion,
38
+ readPostedReport,
39
+ REVIEW_SOURCES,
40
+ type Review,
41
+ reviewPrompt,
42
+ rollbackCommand,
43
+ type Service,
44
+ servicesFor,
45
+ TOOL_PATH,
46
+ type ToolLine,
47
+ type ToolsButton,
48
+ type ToolsReport,
49
+ updateCommand,
50
+ verifiedVersion,
51
+ verifyCommand,
52
+ writePostedReport,
53
+ } from "./tools-report.ts";
20
54
  import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
21
55
  import { DiscordRest, type MessageComponent } from "./rest.ts";
22
56
 
@@ -95,6 +129,11 @@ const MEDIA_MESSAGE_CACHE = 100;
95
129
  /** Upper bound for one media wrapper call from Discord; a single add or list takes seconds. */
96
130
  const MEDIA_TIMEOUT_MS = 3 * 60_000;
97
131
  const EPHEMERAL = 64;
132
+ /** The release Pi has installed: the package this bridge runs from, read from disk so an update shows at once. */
133
+ const PACKAGE_JSON = fileURLToPath(new URL("../../package.json", import.meta.url));
134
+ /** Upper bound for one tool update from an Update button; `brew upgrade` of everything can take a while. */
135
+ const UPDATE_TIMEOUT_MS = 30 * 60_000;
136
+ const REVIEWER = "astro.release-reviewer";
98
137
 
99
138
  interface ActiveTask {
100
139
  specialist: string;
@@ -141,9 +180,13 @@ class Bridge {
141
180
  private readonly mediaRuns = new Map<string, MediaRun>();
142
181
  private readonly mediaClicks = new KeyedQueue();
143
182
  private readonly mediaMessages = new Map<string, MessageComponent[]>();
183
+ /** Tool updates started from the Cortex Tools Report, by line key; they count as open work. */
184
+ private readonly toolRuns = new Map<string, { channelId: string; label: string }>();
185
+ private readonly toolClicks = new KeyedQueue();
144
186
  private gateway: GatewayClient | undefined;
145
187
  private stopping = false;
146
- private reportRunning = false;
188
+ private readonly reportsRunning = new Set<ReportName>();
189
+ private restartsRunning = false;
147
190
  private reportTimer: NodeJS.Timeout | undefined;
148
191
  private readonly since = new Date().toISOString();
149
192
  private config: DiscordConfig;
@@ -183,6 +226,7 @@ class Bridge {
183
226
  tasks: [
184
227
  ...[...this.active.values()].map((t) => ({ specialist: t.specialist, channelId: t.channelId, messageId: t.messageId, ...(t.placeholder ? { placeholderId: t.placeholder } : {}) })),
185
228
  ...[...this.mediaRuns.values()].map((r) => ({ specialist: MEDIA, channelId: r.channelId })),
229
+ ...[...this.toolRuns.values()].map((r) => ({ specialist: "tools", channelId: r.channelId })),
186
230
  ],
187
231
  approvals: [...this.approvalMessages.entries()].map(([ticketId, a]) => ({ ticketId, channelId: a.channelId, messageId: a.messageId, userChannelId: a.userMessage.channelId, userMessageId: a.userMessage.messageId })),
188
232
  };
@@ -229,7 +273,10 @@ class Bridge {
229
273
  for (const task of this.active.values()) task.abort?.abort();
230
274
  if (work.tasks.length > 0 || work.approvals.length > 0) log(`shutdown: cancelling ${work.approvals.length} approval(s), interrupting ${work.tasks.length} task(s)`);
231
275
  // Media runs are answered through their own interaction instead of a new channel message.
232
- const answers = [...this.mediaRuns.values()].map((r) => this.answerInterruptedMedia(r).catch(() => undefined));
276
+ const answers = [
277
+ ...[...this.mediaRuns.values()].map((r) => this.answerInterruptedMedia(r).catch(() => undefined)),
278
+ ...[...this.toolRuns.values()].map((r) => this.rest.createMessage(r.channelId, { content: `❌ The update of ${r.label} was interrupted: the bridge restarted. Its line in the report says what to do next.` }).catch(() => undefined)),
279
+ ];
233
280
  await Promise.all([this.closeOpen({ ...work, tasks: work.tasks.filter((t) => t.messageId !== undefined) }, "restart"), ...answers]);
234
281
  this.approvals.close();
235
282
  writeOpenWork({ since: this.since, tasks: [], approvals: [] });
@@ -256,10 +303,14 @@ class Bridge {
256
303
  gateway.start();
257
304
  this.gateway = gateway;
258
305
  void this.registerMediaCommands();
259
- // Checked every minute; the report itself decides whether today's is due.
260
- this.reportTimer = setInterval(() => void this.maybeDailyReport(), 60_000);
306
+ await this.closeInterruptedUpdates();
307
+ // Checked every minute: each report decides whether today's is due, and waiting restarts check whether their service is idle.
308
+ this.reportTimer = setInterval(() => {
309
+ void this.maybeReports();
310
+ void this.processRestarts();
311
+ }, 60_000);
261
312
  this.reportTimer.unref();
262
- void this.maybeDailyReport();
313
+ void this.maybeReports();
263
314
  log(`connected as ${me.username} (${me.id}); channels ${Object.keys(this.config.channels).join(", ")}; specialists ${this.specialists().names.join(", ")}`);
264
315
  }
265
316
 
@@ -353,49 +404,346 @@ class Bridge {
353
404
  this.queues.set(command.specialist, next);
354
405
  }
355
406
 
407
+ /** Every configured report that is due today and not already running; each records its date before the post, so a crash during delivery never repeats it. */
408
+ private async maybeReports(): Promise<void> {
409
+ if (this.stopping) return;
410
+ this.refreshConfig();
411
+ for (const schedule of this.config.reports) {
412
+ if (this.reportsRunning.has(schedule.report)) continue;
413
+ const { due, date } = isDue(schedule, readReportState(schedule.report), new Date());
414
+ if (!due) continue;
415
+ this.reportsRunning.add(schedule.report);
416
+ const run = schedule.report === "arcane" ? this.arcaneReport(schedule, date) : this.toolsReport(schedule, date);
417
+ void run
418
+ .catch((err) => log(`${schedule.report} report ${date}: ${err instanceof Error ? err.message : String(err)}`))
419
+ .finally(() => this.reportsRunning.delete(schedule.report));
420
+ }
421
+ }
422
+
423
+ /** The Arcane version report through the specialist wrapper, without a model, posted with its prefix. */
424
+ private async arcaneReport(schedule: ReportSchedule, date: string): Promise<void> {
425
+ if (!loadSpecialists().config) {
426
+ log("arcane report: specialists.json missing; no report");
427
+ return;
428
+ }
429
+ log(`arcane report ${date}: running arcane update-report`);
430
+ let message: { content: string; attachment?: string };
431
+ try {
432
+ const stdout = await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000);
433
+ message = formatDailyReport(JSON.parse(stdout), schedule.timeZone);
434
+ } catch (err) {
435
+ log(`arcane report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
436
+ message = { content: failureMessage() };
437
+ }
438
+ if (this.stopping) return;
439
+ writeReportState("arcane", { date, status: "sending" });
440
+ const posted = await this.rest.createMessage(schedule.channelId, {
441
+ content: message.content,
442
+ ...(message.attachment ? { file: { name: `arcane-daily-report-${date}.txt`, content: message.attachment } } : {}),
443
+ });
444
+ writeReportState("arcane", { date, status: message.content === failureMessage() ? "failed" : "sent", messageId: posted.id });
445
+ log(`arcane report ${date}: posted ${posted.id}`);
446
+ }
447
+
448
+ /** Runs a program with an argument list as this user, never through a shell; resolves even on failure. */
449
+ private readonly execTool: Exec = (file, args, options) =>
450
+ new Promise((resolve) => {
451
+ execFile(file, args, { timeout: options.timeoutMs, maxBuffer: 50 * 1024 * 1024, env: { ...process.env, ...options.env } }, (err, stdout, stderr) => {
452
+ const code = err ? (typeof (err as { code?: unknown }).code === "number" ? (err as { code: number }).code : 1) : 0;
453
+ resolve({ code, stdout: String(stdout), stderr: String(stderr) || (err ? err.message : "") });
454
+ });
455
+ });
456
+
356
457
  /**
357
- * Runs the Arcane version report through the specialist wrapper, without a
358
- * model, and posts it with the prefix. One message per local date: the date
359
- * is recorded before the post, so a crash during delivery never repeats it.
458
+ * The Cortex Tools Report: every tool with a newer version, a release-notes
459
+ * review for the tools we build on, and an Update button per line. Nothing
460
+ * is installed until an owner presses one.
360
461
  */
361
- private async maybeDailyReport(): Promise<void> {
362
- if (this.reportRunning || this.stopping) return;
462
+ private async toolsReport(schedule: ReportSchedule, date: string): Promise<void> {
463
+ log(`tools report ${date}: collecting`);
464
+ let report: ToolsReport;
465
+ try {
466
+ const collected = await collectTools({
467
+ exec: this.execTool,
468
+ fetchImpl: fetch,
469
+ piAstroPackage: PACKAGE_JSON,
470
+ arcaneServer: async () => arcaneServerVersion(JSON.parse(await this.runEntry("arcane", "tools-report", ["version"], 2 * 60_000))),
471
+ });
472
+ const lines = buildLines(collected);
473
+ for (const line of lines) if (REVIEW_SOURCES[line.key] && !this.stopping) line.review = await this.reviewLine(line);
474
+ report = { date, checkedAt: new Date().toISOString(), channelId: schedule.channelId, timeZone: schedule.timeZone, lines, warnings: collected.warnings, brewOutdated: brewHasOutdated(collected.brewOutdated), brewAll: "offered", messages: [] };
475
+ } catch (err) {
476
+ log(`tools report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
477
+ if (this.stopping) return;
478
+ writeReportState("tools", { date, status: "sending" });
479
+ const posted = await this.rest.createMessage(schedule.channelId, { content: failureText() });
480
+ writeReportState("tools", { date, status: "failed", messageId: posted.id });
481
+ return;
482
+ }
483
+ if (this.stopping) return;
484
+ writeReportState("tools", { date, status: "sending" });
485
+ for (const page of layoutMessages(report)) {
486
+ const posted = await this.rest.createComponentsMessage(schedule.channelId, messageComponents(report, page));
487
+ report.messages.push({ id: posted.id, ...page });
488
+ }
489
+ writePostedReport(report);
490
+ writeReportState("tools", { date, status: "sent", messageId: report.messages[0]?.id });
491
+ log(`tools report ${date}: ${report.lines.length} update(s) in ${report.messages.length} message(s)`);
492
+ }
493
+
494
+ /** The release-notes verdict of one line from the tool-less reviewer agent; the notes reach it only as task text. */
495
+ private async reviewLine(line: ToolLine): Promise<Review> {
496
+ const notes = await notesFor(line, fetch);
497
+ if (notes.notes === null) return { verdict: null, reason: notes.reason };
498
+ const agent = this.specialists().agents.find((a) => a.name === REVIEWER);
499
+ if (!agent) return { verdict: null, reason: `${REVIEWER} is not installed` };
500
+ const { depth } = currentDepth();
501
+ try {
502
+ const result = await runAgent({
503
+ agent,
504
+ skills: [],
505
+ task: reviewPrompt(line, notes.notes, notes.omitted),
506
+ cwd: process.cwd(),
507
+ depth,
508
+ remaining: 0,
509
+ defaults: { model: this.ctx.model ? `${this.ctx.model.provider}/${this.ctx.model.id}` : undefined, thinkingLevel: this.ctx.thinkingLevel },
510
+ env: { [ACTIVATION_ENV]: "0" },
511
+ });
512
+ const verdict = isFailed(result) ? null : parseVerdict(finalOutput(result.messages));
513
+ log(`tools report: reviewed ${line.label} ${line.installed} -> ${line.latest}: ${verdict?.verdict ?? "no answer"}`);
514
+ return verdict ?? { verdict: null, reason: "the review gave no usable answer; read the notes before updating" };
515
+ } catch (err) {
516
+ return { verdict: null, reason: `the review failed: ${err instanceof Error ? err.message.slice(0, 120) : String(err)}` };
517
+ }
518
+ }
519
+
520
+ /** Lines a crash or restart left "updating" can be pressed again; their message says so. */
521
+ private async closeInterruptedUpdates(): Promise<void> {
522
+ const report = readPostedReport();
523
+ if (!report) return;
524
+ const interrupted = report.lines.filter((l) => l.state === "running");
525
+ if (interrupted.length === 0 && report.brewAll !== "running") return;
526
+ for (const l of interrupted) {
527
+ l.state = "failed";
528
+ l.detail = "interrupted by a bridge restart; check the version before pressing Retry";
529
+ }
530
+ if (report.brewAll === "running") {
531
+ report.brewAll = "failed";
532
+ report.brewAllDetail = "interrupted by a bridge restart";
533
+ }
534
+ writePostedReport(report);
535
+ for (const m of report.messages) await this.rest.editComponents(report.channelId, m.id, messageComponents(report, m)).catch(() => undefined);
536
+ }
537
+
538
+ private async redrawLine(report: ToolsReport, key: string | null): Promise<void> {
539
+ const m = report.messages.find((x) => (key === null ? x.all : x.keys.includes(key)));
540
+ if (m) await this.rest.editComponents(report.channelId, m.id, messageComponents(report, m)).catch((err) => log(`tools report: redraw failed: ${err instanceof Error ? err.message : String(err)}`));
541
+ }
542
+
543
+ /** Update, Upgrade everything, and Restart now buttons: owners only, checked against the report the bridge posted. */
544
+ private async onToolsButton(interaction: Interaction, customId: string): Promise<void> {
545
+ const button = parseToolsButton(customId);
546
+ const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
363
547
  this.refreshConfig();
364
- const settings = this.config.dailyReport;
365
- if (!settings) return;
366
- const { due, date } = isDue(settings, readReportState(), new Date());
367
- if (!due) return;
368
- const gate = loadSpecialists().config;
369
- if (!gate) {
370
- log("daily report: specialists.json missing; no report");
548
+ if (!button) {
549
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "This button is no longer active.");
371
550
  return;
372
551
  }
373
- this.reportRunning = true;
552
+ if (!isOwner(this.config, userId)) {
553
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "Only owners update tools or restart services.");
554
+ return;
555
+ }
556
+ if (this.stopping) {
557
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "The bridge is restarting; try again in a minute.");
558
+ return;
559
+ }
560
+ if (button.kind === "restart") {
561
+ await this.onRestartNow(interaction, button.service, userId);
562
+ return;
563
+ }
564
+ const report = readPostedReport();
565
+ const posted = report?.messages.find((m) => m.id === interaction.message?.id);
566
+ if (!report || !posted) {
567
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "This report is outdated; use the newest Cortex Tools Report.");
568
+ return;
569
+ }
570
+ const state = button.kind === "all" ? (button.date === report.date && posted.all ? report.brewAll : null) : (report.lines.find((l) => l.key === button.key && l.latest === button.version && posted.keys.includes(l.key))?.state ?? null);
571
+ if (state === null) {
572
+ await this.rest.ephemeralReply(interaction.id, interaction.token, "This update is no longer offered.");
573
+ return;
574
+ }
575
+ if (state === "running" || state === "updated") {
576
+ await this.rest.ephemeralReply(interaction.id, interaction.token, state === "running" ? "This update is already running." : "This update is already done.");
577
+ return;
578
+ }
579
+ await this.rest.deferUpdate(interaction.id, interaction.token);
580
+ await this.toolClicks.run("tools", () => this.runToolUpdate(button, userId)).catch((err) => log(`tools update failed: ${err instanceof Error ? err.message : String(err)}`));
581
+ }
582
+
583
+ /** One update, one at a time: run the fixed command, read the version back, roll back where possible, then mark the line. */
584
+ private async runToolUpdate(button: Exclude<ToolsButton, { kind: "restart" }>, userId: string): Promise<void> {
585
+ const report = readPostedReport();
586
+ if (!report) return;
587
+ const target = button.kind === "update" ? report.lines.find((l) => l.key === button.key && l.latest === button.version) : undefined;
588
+ if (button.kind === "update" && (!target || target.state === "updated" || target.state === "running")) return;
589
+ if (button.kind === "all" && (report.brewAll === "updated" || report.brewAll === "running")) return;
590
+ const key = target?.key ?? null;
591
+ const label = target ? `${target.label} ${target.latest}` : "every outdated Homebrew package";
592
+ const mark = (r: ToolsReport, state: LineState, detail?: string) => {
593
+ const l = key === null ? undefined : r.lines.find((x) => x.key === key);
594
+ if (l) {
595
+ l.state = state;
596
+ l.detail = detail;
597
+ } else if (key === null) {
598
+ r.brewAll = state;
599
+ r.brewAllDetail = detail;
600
+ }
601
+ };
602
+ mark(report, "running");
603
+ writePostedReport(report);
604
+ await this.redrawLine(report, key);
605
+ this.toolRuns.set(key ?? "brew-all", { channelId: report.channelId, label });
606
+ this.saveOpenWork();
607
+ log(`tools: updating ${label} for ${userId}`);
608
+ let ok = false;
609
+ let detail = "";
610
+ // Homebrew upgrades dependencies with the package, so Node can change under the bridge and Astrogate.
611
+ const viaBrew = !target || target.action.kind === "brew" || target.action.kind === "cask";
612
+ const nodeBefore = viaBrew ? await this.brewNodeVersion() : "";
374
613
  try {
375
- log(`daily report ${date}: running arcane update-report`);
376
- let message: { content: string; attachment?: string };
377
- try {
378
- const stdout = await this.runEntry("arcane", "daily-report", ["update-report"], 15 * 60_000);
379
- message = formatDailyReport(JSON.parse(stdout), settings.timeZone);
380
- } catch (err) {
381
- log(`daily report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
382
- message = { content: failureMessage() };
614
+ const command = target ? updateCommand(target.action, target.latest) : BREW_ALL;
615
+ const r = await this.execTool(command.file, command.args, { timeoutMs: UPDATE_TIMEOUT_MS, env: { PATH: TOOL_PATH } });
616
+ if (!target) {
617
+ ok = r.code === 0;
618
+ if (!ok) detail = `brew upgrade failed: ${r.stderr.trim().split("\n").pop()?.slice(0, 200) || `exit ${r.code}`}`;
619
+ } else {
620
+ const verify = verifyCommand(target.action);
621
+ if (r.code !== 0) detail = r.stderr.trim().split("\n").pop()?.slice(0, 200) || `exit ${r.code}`;
622
+ else if (!verify) ok = readPackageVersion(PACKAGE_JSON) === target.latest;
623
+ else {
624
+ const v = await this.execTool(verify.file, verify.args, { timeoutMs: 2 * 60_000, env: { PATH: TOOL_PATH, HOMEBREW_NO_AUTO_UPDATE: "1" } });
625
+ ok = v.code === 0 && verifiedVersion(target.action, v.stdout, target.latest);
626
+ }
627
+ if (!ok && r.code === 0) detail = `the command finished, but ${target.latest} is not what is installed now`;
628
+ if (!ok) {
629
+ const rollback = rollbackCommand(target.action, target.installed);
630
+ if (rollback) {
631
+ const rb = await this.execTool(rollback.file, rollback.args, { timeoutMs: UPDATE_TIMEOUT_MS, env: { PATH: TOOL_PATH } });
632
+ detail += rb.code === 0 ? `; rolled back to ${target.installed}` : `; rollback to ${target.installed} failed`;
633
+ } else if (target.action.kind === "brew" || target.action.kind === "cask") {
634
+ detail += `; Homebrew cannot downgrade, \`brew pin ${target.action.kind === "brew" ? target.action.name : target.action.token}\` holds a version`;
635
+ }
636
+ }
383
637
  }
384
- if (this.stopping) return;
385
- writeReportState({ date, status: "sending" });
386
- const posted = await this.rest.createMessage(settings.channelId, {
387
- content: message.content,
388
- ...(message.attachment ? { file: { name: `arcane-daily-report-${date}.txt`, content: message.attachment } } : {}),
389
- });
390
- writeReportState({ date, status: message.content === failureMessage() ? "failed" : "sent", messageId: posted.id });
391
- log(`daily report ${date}: posted ${posted.id}`);
392
638
  } catch (err) {
393
- log(`daily report ${date}: delivery failed: ${err instanceof Error ? err.message : String(err)}`);
639
+ detail = err instanceof Error ? err.message : String(err);
640
+ } finally {
641
+ this.toolRuns.delete(key ?? "brew-all");
642
+ this.saveOpenWork();
643
+ }
644
+ const current = readPostedReport();
645
+ const fresh = current && current.date === report.date ? current : report;
646
+ mark(fresh, ok ? "updated" : "failed", ok ? undefined : detail.slice(0, 300));
647
+ if (fresh === current) writePostedReport(fresh);
648
+ await this.redrawLine(fresh, key);
649
+ log(`tools: ${label} ${ok ? "updated" : `failed: ${detail}`}`);
650
+ if (!ok) return;
651
+ const services = servicesFor(target ? target.action : "brew-all");
652
+ if (viaBrew && (await this.brewNodeVersion()) !== nodeBefore) services.push("astrogate", "bridge");
653
+ if (services.length > 0) writeRestarts(addRestarts(readRestarts(), [...new Set(services)], label, new Date()));
654
+ }
655
+
656
+ private async brewNodeVersion(): Promise<string> {
657
+ return (await this.execTool("/opt/homebrew/bin/node", ["--version"], { timeoutMs: 30_000 })).stdout.trim();
658
+ }
659
+
660
+ private bridgeActivity(): Activity {
661
+ const running = [
662
+ ...[...this.active.values()].map((t) => `${t.specialist} task for <@${t.userId}>`),
663
+ ...(this.approvalMessages.size > 0 ? [`${this.approvalMessages.size} approval request(s)`] : []),
664
+ ...(this.mediaRuns.size > 0 ? [`${this.mediaRuns.size} media command(s)`] : []),
665
+ ...[...this.toolRuns.values()].map((r) => `update of ${r.label}`),
666
+ ...[...this.reportsRunning].map((name) => `the ${name} report`),
667
+ ];
668
+ return { idle: running.length === 0, running };
669
+ }
670
+
671
+ private async serviceActivity(service: Service): Promise<Activity> {
672
+ if (service === "bridge") return this.bridgeActivity();
673
+ const command = ACTIVITY_COMMAND[service];
674
+ const r = await this.execTool(command.file, command.args, { timeoutMs: 60_000, env: { PATH: TOOL_PATH } });
675
+ return service === "herdr" ? herdrActivity(r.code, r.stdout, r.stderr) : astrogateActivity(r.code, r.stdout, r.stderr);
676
+ }
677
+
678
+ private restartChannel(): string {
679
+ return reportSchedule(this.config, "tools")?.channelId ?? this.config.adminChannelId;
680
+ }
681
+
682
+ /** Restarts each waiting service once nothing runs on it; after a day of waiting, offers a Restart now button instead. */
683
+ private async processRestarts(): Promise<void> {
684
+ if (this.restartsRunning || this.stopping || this.toolRuns.size > 0) return;
685
+ let pending = readRestarts();
686
+ if (pending.length === 0) return;
687
+ this.restartsRunning = true;
688
+ try {
689
+ for (const service of SERVICE_ORDER) {
690
+ const p = pending.find((x) => x.service === service);
691
+ if (!p) continue;
692
+ const activity = await this.serviceActivity(service);
693
+ if (activity.idle) {
694
+ pending = pending.filter((x) => x !== p);
695
+ writeRestarts(pending);
696
+ await this.restartService(p, null);
697
+ continue;
698
+ }
699
+ if (needsPrompt(p, new Date())) {
700
+ const content = `⏳ ${SERVICE_LABEL[service]} has waited a day to restart for ${p.reasons.join(", ")}. Still running: ${activity.running.join("; ")}. Restart now stops them.`;
701
+ const posted = await this.rest.createMessage(this.restartChannel(), {
702
+ content: content.slice(0, 1900),
703
+ components: [{ type: 1, components: [{ type: 2, style: 4, label: "Restart now", custom_id: `tools:restart:${service}` }] }],
704
+ });
705
+ p.prompt = { channelId: posted.channel_id, messageId: posted.id };
706
+ writeRestarts(pending);
707
+ }
708
+ }
394
709
  } finally {
395
- this.reportRunning = false;
710
+ this.restartsRunning = false;
396
711
  }
397
712
  }
398
713
 
714
+ /** Restarts one service; the bridge's own restart runs detached, because launchd stops this process with it. */
715
+ private async restartService(p: PendingRestart, byUser: string | null): Promise<void> {
716
+ const who = byUser ? ` by <@${byUser}>` : "";
717
+ const text = `♻️ Restarting ${SERVICE_LABEL[p.service]}${who} for ${p.reasons.join(", ")}.`;
718
+ if (p.prompt) await this.rest.editMessage(p.prompt.channelId, p.prompt.messageId, text, []).catch(() => undefined);
719
+ else await this.rest.createMessage(this.restartChannel(), { content: text.slice(0, 1900) }).catch(() => undefined);
720
+ const uid = process.getuid?.();
721
+ if (uid === undefined) {
722
+ log(`restart ${p.service}: this platform has no user id for launchctl; restart it by hand`);
723
+ return;
724
+ }
725
+ const command = restartCommand(p.service, uid);
726
+ log(`restart ${p.service}${byUser ? ` by ${byUser}` : ""}: ${command.args.join(" ")}`);
727
+ if (p.service === "bridge") {
728
+ spawn(command.file, command.args, { detached: true, stdio: "ignore" }).unref();
729
+ return;
730
+ }
731
+ const r = await this.execTool(command.file, command.args, { timeoutMs: 2 * 60_000, env: { PATH: TOOL_PATH } });
732
+ if (r.code !== 0) await this.rest.createMessage(this.restartChannel(), { content: `❌ Restarting ${SERVICE_LABEL[p.service]} failed: ${(r.stderr || r.stdout).trim().split("\n")[0]?.slice(0, 300)}. Restart it by hand.` }).catch(() => undefined);
733
+ }
734
+
735
+ private async onRestartNow(interaction: Interaction, service: Service, userId: string): Promise<void> {
736
+ const pending = readRestarts();
737
+ const p = pending.find((x) => x.service === service);
738
+ if (!p) {
739
+ await this.rest.ephemeralReply(interaction.id, interaction.token, `No restart of ${SERVICE_LABEL[service]} is waiting.`);
740
+ return;
741
+ }
742
+ await this.rest.deferUpdate(interaction.id, interaction.token);
743
+ writeRestarts(pending.filter((x) => x !== p));
744
+ await this.restartService(p, userId);
745
+ }
746
+
399
747
  /** Runs one specialist wrapper through the sudo entry point, without a model; rejects with the wrapper's stderr. */
400
748
  private runEntry(service: string, caller: string, args: string[], timeoutMs: number): Promise<string> {
401
749
  const gate = loadSpecialists().config;
@@ -688,6 +1036,10 @@ class Bridge {
688
1036
  await this.onMediaButton(interaction, customId);
689
1037
  return;
690
1038
  }
1039
+ if (customId.startsWith("tools:")) {
1040
+ await this.onToolsButton(interaction, customId);
1041
+ return;
1042
+ }
691
1043
  const match = /^apr:([0-9a-f-]{36}):(approve|deny)$/.exec(customId);
692
1044
  if (!match) return;
693
1045
  const userId = interaction.member?.user?.id ?? interaction.user?.id ?? "";
@@ -164,7 +164,7 @@ export function parseQueueState(value: unknown): boolean | null {
164
164
 
165
165
  const IMDB_ID = /^tt\d{1,12}$/;
166
166
 
167
- function escapeDiscord(text: string): string {
167
+ export function escapeDiscord(text: string): string {
168
168
  return text.replace(/([\\*_~`|[\]])/g, "\\$1");
169
169
  }
170
170
 
@@ -0,0 +1,49 @@
1
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir, type ReportName } from "./config.ts";
4
+
5
+ export interface ReportState {
6
+ /** Local date in the report's time zone, `YYYY-MM-DD`. */
7
+ date: string;
8
+ status: "sending" | "sent" | "failed";
9
+ messageId?: string;
10
+ }
11
+
12
+ /** Local date and minutes past midnight in a time zone. */
13
+ export function zonedNow(timeZone: string, now: Date): { date: string; minutes: number } {
14
+ const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now);
15
+ const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
16
+ return { date: `${get("year")}-${get("month")}-${get("day")}`, minutes: Number(get("hour")) * 60 + Number(get("minute")) };
17
+ }
18
+
19
+ /**
20
+ * Due once per local date, from the configured time on: a bridge that was down
21
+ * at the report's time posts when it comes back that day. Any record for today,
22
+ * even a failed or interrupted send, means no second message.
23
+ */
24
+ export function isDue(schedule: { time: string; timeZone: string }, state: ReportState | null, now: Date): { due: boolean; date: string } {
25
+ const { date, minutes } = zonedNow(schedule.timeZone, now);
26
+ const [h, m] = schedule.time.split(":").map(Number);
27
+ return { due: minutes >= h * 60 + m && state?.date !== date, date };
28
+ }
29
+
30
+ /** The Arcane report keeps the file it had before reports became a list, so the switch never posts twice on one day. */
31
+ function statePath(name: ReportName): string {
32
+ return join(configDir(), name === "arcane" ? "daily-report.json" : `${name}-report.json`);
33
+ }
34
+
35
+ export function readReportState(name: ReportName): ReportState | null {
36
+ try {
37
+ const raw = JSON.parse(readFileSync(statePath(name), "utf-8")) as Partial<ReportState>;
38
+ return typeof raw.date === "string" && typeof raw.status === "string" ? (raw as ReportState) : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export function writeReportState(name: ReportName, state: ReportState): void {
45
+ const path = statePath(name);
46
+ const tmp = `${path}.tmp`;
47
+ writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
48
+ renameSync(tmp, path);
49
+ }
@@ -81,6 +81,17 @@ export class DiscordRest {
81
81
  return (await this.call("POST", `/channels/${channelId}/messages`, JSON.stringify(payload), "application/json")).json() as Promise<DiscordMessage>;
82
82
  }
83
83
 
84
+ /** A Components V2 message (flag `1 << 15`): text lives in Text Display components, there is no `content`. */
85
+ async createComponentsMessage(channelId: string, components: MessageComponent[]): Promise<DiscordMessage> {
86
+ const payload = { flags: 1 << 15, components, allowed_mentions: { parse: [] } };
87
+ return (await this.call("POST", `/channels/${channelId}/messages`, JSON.stringify(payload), "application/json")).json() as Promise<DiscordMessage>;
88
+ }
89
+
90
+ /** Replaces the components of a Components V2 message; its flag stays set and needs no repeat. */
91
+ async editComponents(channelId: string, messageId: string, components: MessageComponent[]): Promise<void> {
92
+ await this.call("PATCH", `/channels/${channelId}/messages/${messageId}`, JSON.stringify({ components, allowed_mentions: { parse: [] } }), "application/json");
93
+ }
94
+
84
95
  async editMessage(channelId: string, messageId: string, content: string, components: MessageComponent[] = []): Promise<void> {
85
96
  await this.call("PATCH", `/channels/${channelId}/messages/${messageId}`, JSON.stringify({ content, components, allowed_mentions: { parse: [] } }), "application/json");
86
97
  }