@astrofoundry/pi-astro 0.22.3 → 0.23.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.
@@ -0,0 +1,77 @@
1
+ import { type ChannelSettings, type DiscordConfig, SNOWFLAKE, SPECIALIST } from "./config.ts";
2
+
3
+ export interface AdminResult {
4
+ reply: string;
5
+ /** Present when the command changed the configuration. */
6
+ config?: DiscordConfig;
7
+ }
8
+
9
+ const USAGE = [
10
+ "config show",
11
+ "config channel <#channel> <always|mention> <*|name,name>",
12
+ "config channel <#channel> remove",
13
+ "config access <specialist> add|remove <@user>",
14
+ ].join("\n");
15
+
16
+ function idOf(token: string | undefined, kind: "channel" | "user"): string | null {
17
+ if (token === undefined) return null;
18
+ const match = kind === "channel" ? /^<#(\d{17,20})>$/.exec(token) : /^<@!?(\d{17,20})>$/.exec(token);
19
+ const id = match ? match[1] : token;
20
+ return SNOWFLAKE.test(id) ? id : null;
21
+ }
22
+
23
+ export function describeConfig(config: DiscordConfig): string {
24
+ const channels = Object.entries(config.channels).map(([id, c]) => `• <#${id}>: ${c.trigger}, ${c.specialists === "*" ? "every specialist" : c.specialists.join(", ")}${id === config.adminChannelId ? " (admin)" : ""}`);
25
+ const access = Object.entries(config.access)
26
+ .filter(([, ids]) => ids.length > 0)
27
+ .map(([name, ids]) => `• ${name}: ${ids.map((id) => `<@${id}>`).join(", ")}`);
28
+ return [
29
+ "Channels:",
30
+ ...channels,
31
+ `Owners: ${config.owners.map((id) => `<@${id}>`).join(", ")}`,
32
+ access.length > 0 ? "Extra access:" : "Extra access: none",
33
+ ...access,
34
+ `Approval timeout: ${config.approvalTimeoutMinutes} min`,
35
+ ].join("\n");
36
+ }
37
+
38
+ /** Applies one owner `config` command to a copy of the configuration. */
39
+ export function applyAdminCommand(config: DiscordConfig, args: readonly string[], configured: readonly string[]): AdminResult {
40
+ const [verb, ...rest] = args;
41
+ if (verb === undefined || verb === "show") return { reply: describeConfig(config) };
42
+ if (verb === "channel") {
43
+ const channelId = idOf(rest[0], "channel");
44
+ if (!channelId) return { reply: `channel id missing.\n${USAGE}` };
45
+ if (rest[1] === "remove") {
46
+ if (!(channelId in config.channels)) return { reply: `<#${channelId}> is not configured.` };
47
+ if (channelId === config.adminChannelId) return { reply: "The admin channel cannot be removed." };
48
+ const channels = { ...config.channels };
49
+ delete channels[channelId];
50
+ return { reply: `Removed <#${channelId}>.`, config: { ...config, channels } };
51
+ }
52
+ if (rest[1] !== "always" && rest[1] !== "mention") return { reply: `trigger must be always or mention.\n${USAGE}` };
53
+ const names = rest[2] === "*" ? "*" : (rest[2] ?? "").split(",").map((n) => n.trim()).filter(Boolean);
54
+ if (names !== "*") {
55
+ if (names.length === 0) return { reply: `specialists missing.\n${USAGE}` };
56
+ const unknown = names.filter((n) => !SPECIALIST.test(n) || !configured.includes(n));
57
+ if (unknown.length > 0) return { reply: `unknown specialist(s): ${unknown.join(", ")}. Configured: ${configured.join(", ")}.` };
58
+ }
59
+ const settings: ChannelSettings = { trigger: rest[1], specialists: names === "*" ? "*" : [...new Set(names)] };
60
+ const verbNow = channelId in config.channels ? "Updated" : "Added";
61
+ return {
62
+ reply: `${verbNow} <#${channelId}>: ${settings.trigger}, ${settings.specialists === "*" ? "every specialist" : settings.specialists.join(", ")}.`,
63
+ config: { ...config, channels: { ...config.channels, [channelId]: settings } },
64
+ };
65
+ }
66
+ if (verb === "access") {
67
+ const [name, action, who] = rest;
68
+ if (name === undefined || !configured.includes(name)) return { reply: `unknown specialist. Configured: ${configured.join(", ")}.` };
69
+ const userId = idOf(who, "user");
70
+ if ((action !== "add" && action !== "remove") || !userId) return { reply: USAGE };
71
+ const current = config.access[name] ?? [];
72
+ const next = action === "add" ? [...new Set([...current, userId])] : current.filter((id) => id !== userId);
73
+ if (next.length === current.length) return { reply: action === "add" ? `<@${userId}> already has ${name}.` : `<@${userId}> did not have ${name}.` };
74
+ return { reply: `${action === "add" ? "Granted" : "Revoked"} ${name} for <@${userId}>.`, config: { ...config, access: { ...config.access, [name]: next } } };
75
+ }
76
+ return { reply: USAGE };
77
+ }
@@ -1,22 +1,46 @@
1
- export type Command = { kind: "run"; specialist: string; task: string } | { kind: "help" } | { kind: "status" } | { kind: "none" };
1
+ import type { Trigger } from "./config.ts";
2
+
3
+ export type Command =
4
+ | { kind: "run"; specialist: string; task: string }
5
+ | { kind: "help" }
6
+ | { kind: "status" }
7
+ | { kind: "config"; args: string[] }
8
+ | { kind: "needs-prefix"; specialists: string[] }
9
+ | { kind: "none" };
10
+
11
+ export interface CommandContext {
12
+ botUserId: string;
13
+ /** Specialists present in this channel that the host has configured. */
14
+ specialists: readonly string[];
15
+ trigger: Trigger;
16
+ /** True when the message replies to one of the bot's messages. */
17
+ repliedToBot: boolean;
18
+ }
2
19
 
3
20
  /**
4
- * `<specialist> <task>` or `@bot <specialist> <task>` in a watched channel.
5
- * `help` lists what the sender may use; `status` shows running tasks.
21
+ * Interprets one channel message. `mention` channels react only when addressed
22
+ * (bot mention or a reply to the bot). A channel with one specialist needs no
23
+ * prefix; with several, an addressed message without a prefix asks for one.
6
24
  */
7
- export function parseCommand(content: string, botUserId: string, specialists: readonly string[]): Command {
8
- let text = content.trim().replace(new RegExp(`^<@!?${botUserId}>\\s*`), "");
9
- text = text.replace(/^[!/]/, "");
10
- if (text.length === 0) return { kind: "none" };
25
+ export function parseCommand(content: string, context: CommandContext): Command {
26
+ const mention = new RegExp(`^<@!?${context.botUserId}>\\s*`);
27
+ const addressed = mention.test(content.trim()) || context.repliedToBot;
28
+ const text = content.trim().replace(mention, "").replace(/^[!/]/, "").trim();
29
+ if (context.trigger === "mention" && !addressed) return { kind: "none" };
30
+ if (text.length === 0) return addressed ? { kind: "help" } : { kind: "none" };
11
31
  const [first, ...rest] = text.split(/\s+/);
12
32
  const word = first.toLowerCase();
13
33
  if (word === "help") return { kind: "help" };
14
34
  if (word === "status") return { kind: "status" };
15
- const specialist = word.replace(/^astro\./, "");
16
- if (!specialists.includes(specialist)) return { kind: "none" };
17
- const task = text.slice(first.length).trim();
18
- if (task.length === 0) return { kind: "none" };
19
- return { kind: "run", specialist, task: rest.length > 0 ? task : "" };
35
+ if (word === "config") return { kind: "config", args: rest };
36
+ const named = word.replace(/^astro\./, "");
37
+ if (context.specialists.includes(named)) {
38
+ const task = text.slice(first.length).trim();
39
+ return task.length > 0 ? { kind: "run", specialist: named, task } : { kind: "help" };
40
+ }
41
+ if (context.specialists.length === 1) return { kind: "run", specialist: context.specialists[0], task: text };
42
+ if (addressed) return { kind: "needs-prefix", specialists: [...context.specialists] };
43
+ return { kind: "none" };
20
44
  }
21
45
 
22
46
  /** Prompt paragraph appended for tasks that arrive from a chat channel. */
@@ -24,13 +48,16 @@ export const CHANNEL_RULES = `## Channel rules
24
48
 
25
49
  This task arrived from a Discord channel and your answer is posted there. Answer in plain text under 1500 characters when possible, facts first, no headings. When the task is ambiguous (which guest, which record, which host), do not act: ask one precise question and stop. Risky calls may wait for an operator's approval; if the tool reports a denial or a missing answer, report it and stop.`;
26
50
 
27
- export function helpText(allowed: readonly string[], isOwner: boolean): string {
28
- if (allowed.length === 0) return "You may not use any specialist here.";
29
- const lines = [
30
- "Send `<specialist> <task>` in this channel. Specialists you may use:",
31
- ...allowed.map((name) => `• \`${name}\``),
32
- "Reactmeans running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `status` lists running tasks.",
33
- ];
51
+ export function helpText(allowed: readonly string[], present: readonly string[], isOwner: boolean, trigger: Trigger): string {
52
+ if (allowed.length === 0) return present.length === 0 ? "No specialist is present in this channel." : "You may not use the specialists present in this channel.";
53
+ const how = present.length === 1 ? `Every message${trigger === "mention" ? " that addresses me" : ""} here goes to \`${present[0]}\`.` : `Send \`<specialist> <task>\`${trigger === "mention" ? " after mentioning me" : ""}. Specialists you may use here:`;
54
+ const lines = [how];
55
+ if (present.length > 1) lines.push(...allowed.map((name) => `• \`${name}\``));
56
+ lines.push("Reactions: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `status` lists running tasks.");
34
57
  if (isOwner) lines.push("Owners approve risky calls with the buttons the bot posts and may use every specialist.");
35
58
  return lines.join("\n");
36
59
  }
60
+
61
+ export function needsPrefixText(specialists: readonly string[]): string {
62
+ return `Several specialists live here; start your message with one of: ${specialists.map((s) => `\`${s}\``).join(", ")}.`;
63
+ }
@@ -1,15 +1,26 @@
1
- import { readFileSync, statSync } from "node:fs";
1
+ import { readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
 
5
5
  /** Set to "1" on the headless Pi host that should connect to Discord. */
6
6
  export const ACTIVATION_ENV = "ASTRO_DISCORD";
7
7
 
8
+ export type Trigger = "always" | "mention";
9
+
10
+ export interface ChannelSettings {
11
+ /** `always`: every message that names a specialist (or any message when one specialist is present); `mention`: only when addressed. */
12
+ trigger: Trigger;
13
+ /** Specialists available in this channel: every configured one, or a list of short names. */
14
+ specialists: "*" | string[];
15
+ }
16
+
8
17
  export interface DiscordConfig {
9
18
  applicationId: string;
10
19
  guildId: string;
11
- /** Channels the bot reads; messages elsewhere are ignored. */
12
- channelIds: string[];
20
+ /** Channels the bot reads, by id; messages elsewhere are ignored. Threads inherit their parent's entry. */
21
+ channels: Record<string, ChannelSettings>;
22
+ /** The one channel where owners may run `config` commands. */
23
+ adminChannelId: string;
13
24
  /** Users who may use every specialist and decide approvals. */
14
25
  owners: string[];
15
26
  /** Extra users per specialist name (without the astro. prefix). */
@@ -17,8 +28,8 @@ export interface DiscordConfig {
17
28
  approvalTimeoutMinutes: number;
18
29
  }
19
30
 
20
- const SNOWFLAKE = /^\d{17,20}$/;
21
- const SPECIALIST = /^[a-z][a-z0-9-]*$/;
31
+ export const SNOWFLAKE = /^\d{17,20}$/;
32
+ export const SPECIALIST = /^[a-z][a-z0-9-]*$/;
22
33
 
23
34
  export function configDir(): string {
24
35
  return process.env.ASTRO_DISCORD_DIR ?? join(homedir(), ".config", "astro-discord");
@@ -54,7 +65,18 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
54
65
  for (const key of ["applicationId", "guildId"]) {
55
66
  if (typeof r[key] !== "string" || !SNOWFLAKE.test(r[key] as string)) errors.push(`${key} must be a Discord id`);
56
67
  }
57
- const channelIds = snowflakes(r.channelIds, "channelIds", errors);
68
+ const channels: Record<string, ChannelSettings> = {};
69
+ if (typeof r.channels !== "object" || r.channels === null || Array.isArray(r.channels) || Object.keys(r.channels).length === 0) {
70
+ errors.push("channels must be a non-empty object of channel id to settings");
71
+ } else {
72
+ for (const [id, raw] of Object.entries(r.channels as Record<string, unknown>)) {
73
+ if (!SNOWFLAKE.test(id)) errors.push(`channels: ${id} is not a Discord id`);
74
+ const settings = validateChannel(raw, `channels.${id}`, errors);
75
+ if (settings) channels[id] = settings;
76
+ }
77
+ }
78
+ if (typeof r.adminChannelId !== "string" || !SNOWFLAKE.test(r.adminChannelId)) errors.push("adminChannelId must be a Discord id");
79
+ else if (!(r.adminChannelId in channels) && errors.length === 0) errors.push("adminChannelId must be one of the configured channels");
58
80
  const owners = snowflakes(r.owners, "owners", errors);
59
81
  const access: Record<string, string[]> = {};
60
82
  if (r.access !== undefined) {
@@ -69,7 +91,44 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
69
91
  const timeout = r.approvalTimeoutMinutes;
70
92
  if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > 240) errors.push("approvalTimeoutMinutes must be an integer from 1 to 240");
71
93
  if (errors.length > 0) return null;
72
- return { applicationId: r.applicationId as string, guildId: r.guildId as string, channelIds, owners, access, approvalTimeoutMinutes: timeout as number };
94
+ return {
95
+ applicationId: r.applicationId as string,
96
+ guildId: r.guildId as string,
97
+ channels,
98
+ adminChannelId: r.adminChannelId as string,
99
+ owners,
100
+ access,
101
+ approvalTimeoutMinutes: timeout as number,
102
+ };
103
+ }
104
+
105
+ export function validateChannel(raw: unknown, where: string, errors: string[]): ChannelSettings | null {
106
+ if (typeof raw !== "object" || raw === null) {
107
+ errors.push(`${where} must be an object`);
108
+ return null;
109
+ }
110
+ const r = raw as Record<string, unknown>;
111
+ if (r.trigger !== "always" && r.trigger !== "mention") errors.push(`${where}.trigger must be always or mention`);
112
+ let specialists: "*" | string[] = "*";
113
+ if (r.specialists !== "*") {
114
+ if (!Array.isArray(r.specialists) || r.specialists.length === 0 || !r.specialists.every((n) => typeof n === "string" && SPECIALIST.test(n))) {
115
+ errors.push(`${where}.specialists must be "*" or a non-empty list of specialist names`);
116
+ } else specialists = [...new Set(r.specialists as string[])];
117
+ }
118
+ return errors.length > 0 ? null : { trigger: r.trigger as Trigger, specialists };
119
+ }
120
+
121
+ /** Writes the configuration back atomically with owner-only permissions. */
122
+ export function saveConfig(config: DiscordConfig): void {
123
+ const path = configPath();
124
+ const tmp = `${path}.tmp`;
125
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
126
+ renameSync(tmp, path);
127
+ }
128
+
129
+ /** Specialists present in a channel, intersected with the ones configured on this host. */
130
+ export function channelSpecialists(settings: ChannelSettings, configured: readonly string[]): string[] {
131
+ return settings.specialists === "*" ? [...configured] : settings.specialists.filter((name) => configured.includes(name));
73
132
  }
74
133
 
75
134
  export function loadConfig(): { config: DiscordConfig | null; errors: string[] } {
@@ -102,7 +161,7 @@ export function canUse(config: DiscordConfig, userId: string, specialist: string
102
161
  return isOwner(config, userId) || (config.access[specialist] ?? []).includes(userId);
103
162
  }
104
163
 
105
- /** Specialists a user may address, from the runtime list of `astro.*` agents. */
164
+ /** Specialists a user may address among those present, from the runtime list of `astro.*` agents. */
106
165
  export function allowedSpecialists(config: DiscordConfig, userId: string, available: readonly string[]): string[] {
107
166
  return available.filter((name) => canUse(config, userId, name));
108
167
  }
@@ -4,15 +4,18 @@ import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { ApprovalServer, type Ticket } from "./approvals.ts";
6
6
  import { chunkMessage, fenceState } from "./chunk.ts";
7
- import { helpText, parseCommand } from "./commands.ts";
8
- import { allowedSpecialists, canUse, loadConfig, loadToken, validateConfig } from "./config.ts";
7
+ import { applyAdminCommand, describeConfig } from "./admin.ts";
8
+ import { helpText, needsPrefixText, parseCommand } from "./commands.ts";
9
+ import { allowedSpecialists, canUse, channelSpecialists, loadConfig, loadToken, validateConfig } from "./config.ts";
9
10
  import { INTENTS, initialState, reduce } from "./gateway.ts";
11
+ import type { RunResult } from "../astro-subagents/child.ts";
12
+ import { failureReply } from "./index.ts";
10
13
  import { DiscordRest } from "./rest.ts";
11
14
 
12
15
  const ID = "123456789012345678";
13
16
  const OTHER = "223456789012345678";
14
17
  const THIRD = "323456789012345678";
15
- const valid = { applicationId: ID, guildId: ID, channelIds: [ID], owners: [ID], access: { dns: [OTHER] }, approvalTimeoutMinutes: 30 };
18
+ const valid = { applicationId: ID, guildId: ID, channels: { [ID]: { trigger: "always", specialists: "*" }, [OTHER]: { trigger: "mention", specialists: ["arcane"] } }, adminChannelId: ID, owners: [ID], access: { dns: [OTHER] }, approvalTimeoutMinutes: 30 };
16
19
 
17
20
  describe("config", () => {
18
21
  it("validates ids and shapes", () => {
@@ -20,7 +23,11 @@ describe("config", () => {
20
23
  expect(validateConfig(valid, errors)).toEqual(valid);
21
24
  expect(errors).toEqual([]);
22
25
  expect(validateConfig({ ...valid, guildId: "abc" }, errors)).toBeNull();
23
- expect(validateConfig({ ...valid, channelIds: [] }, [])).toBeNull();
26
+ expect(validateConfig({ ...valid, channels: {} }, [])).toBeNull();
27
+ expect(validateConfig({ ...valid, channels: { [ID]: { trigger: "sometimes", specialists: "*" } } }, [])).toBeNull();
28
+ expect(validateConfig({ ...valid, adminChannelId: THIRD }, [])).toBeNull();
29
+ expect(channelSpecialists({ trigger: "always", specialists: "*" }, ["dns", "edge"])).toEqual(["dns", "edge"]);
30
+ expect(channelSpecialists({ trigger: "always", specialists: ["edge", "nope"] }, ["dns", "edge"])).toEqual(["edge"]);
24
31
  expect(validateConfig({ ...valid, access: { "Bad Name": [ID] } }, [])).toBeNull();
25
32
  expect(validateConfig({ ...valid, approvalTimeoutMinutes: 0 }, [])).toBeNull();
26
33
  expect(validateConfig({ ...valid, access: undefined }, [])).toMatchObject({ access: {} });
@@ -79,22 +86,67 @@ describe("chunk", () => {
79
86
  });
80
87
 
81
88
  describe("commands", () => {
82
- const specialists = ["dns", "edge"];
83
- it("parses specialist tasks, mentions, help, and status", () => {
84
- expect(parseCommand("dns list the zones", ID, specialists)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
85
- expect(parseCommand(`<@${ID}> edge probe the front door`, ID, specialists)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door" });
86
- expect(parseCommand("!astro.dns zones", ID, specialists)).toEqual({ kind: "run", specialist: "dns", task: "zones" });
87
- expect(parseCommand("help", ID, specialists)).toEqual({ kind: "help" });
88
- expect(parseCommand("Status", ID, specialists)).toEqual({ kind: "status" });
89
- expect(parseCommand("dns", ID, specialists)).toEqual({ kind: "none" });
90
- expect(parseCommand("hello everyone", ID, specialists)).toEqual({ kind: "none" });
91
- expect(parseCommand("", ID, specialists)).toEqual({ kind: "none" });
89
+ const many = { botUserId: ID, specialists: ["dns", "edge"], trigger: "always" as const, repliedToBot: false };
90
+ const one = { ...many, specialists: ["arcane"] };
91
+ it("parses prefixed tasks, mentions, help, status, and config", () => {
92
+ expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
93
+ expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door" });
94
+ expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones" });
95
+ expect(parseCommand("help", many)).toEqual({ kind: "help" });
96
+ expect(parseCommand("Status", many)).toEqual({ kind: "status" });
97
+ expect(parseCommand("config channel <#1> mention dns", many)).toEqual({ kind: "config", args: ["channel", "<#1>", "mention", "dns"] });
98
+ expect(parseCommand("dns", many)).toEqual({ kind: "help" });
99
+ expect(parseCommand("hello everyone", many)).toEqual({ kind: "none" });
100
+ expect(parseCommand("", many)).toEqual({ kind: "none" });
92
101
  });
93
102
 
94
- it("lists what a user may use", () => {
95
- expect(helpText([], false)).toMatch(/may not/);
96
- expect(helpText(["dns"], true)).toMatch(/`dns`/);
97
- expect(helpText(["dns"], true)).toMatch(/Owners approve/);
103
+ it("needs no prefix with one specialist and asks for one when addressed with several", () => {
104
+ expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
105
+ expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
106
+ expect(parseCommand(`<@${ID}> list the zones`, many)).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
107
+ expect(parseCommand("list the zones", { ...many, repliedToBot: true })).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
108
+ expect(parseCommand(`<@${ID}>`, many)).toEqual({ kind: "help" });
109
+ });
110
+
111
+ it("stays silent in mention channels unless addressed", () => {
112
+ const mention = { ...many, trigger: "mention" as const };
113
+ expect(parseCommand("dns list the zones", mention)).toEqual({ kind: "none" });
114
+ expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
115
+ expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
116
+ expect(parseCommand("anything", { ...one, trigger: "mention" })).toEqual({ kind: "none" });
117
+ expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything" });
118
+ });
119
+
120
+ it("writes help and prefix texts", () => {
121
+ expect(helpText([], [], false, "always")).toMatch(/No specialist/);
122
+ expect(helpText([], ["dns"], false, "always")).toMatch(/may not/);
123
+ expect(helpText(["arcane"], ["arcane"], false, "mention")).toMatch(/addresses me here goes to `arcane`/);
124
+ expect(helpText(["dns"], ["dns", "edge"], true, "always")).toMatch(/`dns`/);
125
+ expect(needsPrefixText(["dns", "edge"])).toMatch(/`dns`, `edge`/);
126
+ });
127
+ });
128
+
129
+ describe("admin commands", () => {
130
+ const config = validateConfig(valid, [])!;
131
+ const configured = ["dns", "edge", "arcane"];
132
+ it("shows, adds, updates, removes channels and grants access", () => {
133
+ expect(applyAdminCommand(config, ["show"], configured).reply).toContain(`<#${ID}>: always, every specialist (admin)`);
134
+ expect(describeConfig(config)).toContain(`dns: <@${OTHER}>`);
135
+ const added = applyAdminCommand(config, ["channel", `<#${THIRD}>`, "mention", "dns,edge"], configured);
136
+ expect(added.config?.channels[THIRD]).toEqual({ trigger: "mention", specialists: ["dns", "edge"] });
137
+ expect(added.reply).toMatch(/^Added/);
138
+ const updated = applyAdminCommand(added.config!, ["channel", THIRD, "always", "*"], configured);
139
+ expect(updated.config?.channels[THIRD]).toEqual({ trigger: "always", specialists: "*" });
140
+ expect(updated.reply).toMatch(/^Updated/);
141
+ expect(applyAdminCommand(config, ["channel", `<#${THIRD}>`, "mention", "nope"], configured).reply).toMatch(/unknown specialist/);
142
+ expect(applyAdminCommand(config, ["channel", `<#${ID}>`, "remove"], configured)).toEqual({ reply: "The admin channel cannot be removed." });
143
+ expect(applyAdminCommand(updated.config!, ["channel", `<#${THIRD}>`, "remove"], configured).config?.channels[THIRD]).toBeUndefined();
144
+ const granted = applyAdminCommand(config, ["access", "edge", "add", `<@${THIRD}>`], configured);
145
+ expect(granted.config?.access.edge).toEqual([THIRD]);
146
+ expect(applyAdminCommand(granted.config!, ["access", "edge", "add", THIRD], configured).config).toBeUndefined();
147
+ expect(applyAdminCommand(granted.config!, ["access", "edge", "remove", THIRD], configured).config?.access.edge).toEqual([]);
148
+ expect(applyAdminCommand(config, ["access", "nope", "add", THIRD], configured).reply).toMatch(/unknown specialist/);
149
+ expect(applyAdminCommand(config, ["bogus"], configured).reply).toMatch(/config show/);
98
150
  });
99
151
  });
100
152
 
@@ -147,6 +199,17 @@ describe("rest", () => {
147
199
  });
148
200
  });
149
201
 
202
+ describe("failure reply", () => {
203
+ it("names the reason and keeps stderr as an attachment", () => {
204
+ const base: RunResult = { agent: "astro.dns", agentSource: "bundled", task: "t", exitCode: 1, messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 } };
205
+ expect(failureReply({ ...base, errorMessage: "timed out after 30 min" })).toEqual({ text: "timed out after 30 min" });
206
+ expect(failureReply({ ...base, stderr: "noise\n" })).toEqual({ text: "the specialist process exited with code 1", attachment: "noise" });
207
+ expect(failureReply({ ...base, stopReason: "aborted" as RunResult["stopReason"] }).text).toBe("the run was stopped");
208
+ const withWords = failureReply({ ...base, messages: [{ role: "assistant", content: [{ type: "text", text: "I asked for approval." }] } as never] });
209
+ expect(withWords.text).toContain("Last words of the specialist:\nI asked for approval.");
210
+ });
211
+ });
212
+
150
213
  describe("approval server", () => {
151
214
  it("creates tickets, reports decisions, expires, and rejects bad tokens", async () => {
152
215
  const tickets: Ticket[] = [];
@@ -2,17 +2,52 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
3
3
  import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
4
4
  import { type AgentConfig, defaultDirs, discoverAgents, resolveSkills } from "../astro-subagents/agents.ts";
5
- import { type DispatchDefaults, childRemaining, currentDepth, isFailed, resultOutput, runAgent } from "../astro-subagents/child.ts";
5
+ import { AGENT_ENV, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
6
6
  import { ApprovalServer, type Ticket } from "./approvals.ts";
7
7
  import { chunkMessage } from "./chunk.ts";
8
- import { CHANNEL_RULES, helpText, parseCommand } from "./commands.ts";
9
- import { ACTIVATION_ENV, type DiscordConfig, allowedSpecialists, canUse, isOwner, loadConfig, loadToken } from "./config.ts";
8
+ import { applyAdminCommand } from "./admin.ts";
9
+ import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
10
+ import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
10
11
  import { GatewayClient } from "./gateway.ts";
11
12
  import { DiscordRest, type MessageComponent } from "./rest.ts";
12
13
 
13
14
  const REACTION = { running: "⏳", waiting: "🔒", done: "✅", failed: "❌" } as const;
14
15
  /** Answers longer than this go as a file attachment with a short summary. */
15
16
  const ATTACHMENT_THRESHOLD = 6000;
17
+ /** Upper bound for one specialist run started from chat; the wrappers' own timeouts are shorter. */
18
+ const TASK_TIMEOUT_MINUTES = 30;
19
+
20
+ interface ToolCallPart {
21
+ type?: string;
22
+ name?: string;
23
+ arguments?: unknown;
24
+ }
25
+
26
+ /** One log line per new message: tool calls with their arguments, tool results, and assistant text lengths. */
27
+ function describeProgress(result: RunResult, seen: number): { lines: string[]; seen: number } {
28
+ const lines: string[] = [];
29
+ for (const message of result.messages.slice(seen)) {
30
+ if (message.role === "assistant") {
31
+ for (const part of message.content as ToolCallPart[]) {
32
+ if (part.type === "toolCall") lines.push(`tool ${part.name ?? "?"} ${JSON.stringify(part.arguments ?? {}).slice(0, 300)}`);
33
+ else if (part.type === "text") lines.push(`assistant text ${((part as { text?: string }).text ?? "").length} chars`);
34
+ }
35
+ } else if (message.role === "toolResult") {
36
+ const { toolName, isError } = message as { toolName?: string; isError?: boolean };
37
+ lines.push(`result ${toolName ?? "?"}${isError ? " (error)" : ""}`);
38
+ }
39
+ }
40
+ return { lines, seen: result.messages.length };
41
+ }
42
+
43
+ /** Reply for a failed run: the reason in one line, the specialist's last words if any, raw stderr only as an attachment. */
44
+ export function failureReply(result: RunResult): { text: string; attachment?: string } {
45
+ const reason = result.errorMessage?.trim() || (result.stopReason === "aborted" ? "the run was stopped" : `the specialist process exited with code ${result.exitCode}`);
46
+ const lastWords = finalOutput(result.messages).trim();
47
+ const text = lastWords.length > 0 ? `${reason}\n\nLast words of the specialist:\n${lastWords}` : reason;
48
+ const stderr = result.stderr.trim();
49
+ return stderr.length > 0 ? { text, attachment: stderr } : { text };
50
+ }
16
51
 
17
52
  interface IncomingMessage {
18
53
  id: string;
@@ -20,6 +55,7 @@ interface IncomingMessage {
20
55
  guild_id?: string;
21
56
  content?: string;
22
57
  author?: { id: string; bot?: boolean };
58
+ referenced_message?: { author?: { id: string } } | null;
23
59
  }
24
60
 
25
61
  interface ComponentInteraction {
@@ -70,6 +106,8 @@ class Bridge {
70
106
  private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
71
107
  private config: DiscordConfig;
72
108
  private botUserId = "";
109
+ /** Thread id to parent channel id; null marks a plain channel. */
110
+ private readonly parents = new Map<string, string | null>();
73
111
  private readonly ctx: ExtensionContext;
74
112
  private readonly token: string;
75
113
 
@@ -108,7 +146,7 @@ class Bridge {
108
146
  },
109
147
  });
110
148
  gateway.start();
111
- log(`connected as ${me.username} (${me.id}); channels ${this.config.channelIds.join(", ")}; specialists ${this.specialists().names.join(", ")}`);
149
+ log(`connected as ${me.username} (${me.id}); channels ${Object.keys(this.config.channels).join(", ")}; specialists ${this.specialists().names.join(", ")}`);
112
150
  }
113
151
 
114
152
  private async dispatch(event: string, data: unknown): Promise<void> {
@@ -120,17 +158,54 @@ class Bridge {
120
158
  }
121
159
  }
122
160
 
161
+ /** Settings for a channel or, for a thread, of its parent channel; null when neither is configured. */
162
+ private async settingsFor(channelId: string): Promise<{ id: string; settings: ChannelSettings } | null> {
163
+ const direct = this.config.channels[channelId];
164
+ if (direct) return { id: channelId, settings: direct };
165
+ if (!this.parents.has(channelId)) {
166
+ try {
167
+ const channel = (await this.rest.channel(channelId)) as { parent_id?: string | null };
168
+ this.parents.set(channelId, channel.parent_id ?? null);
169
+ } catch {
170
+ this.parents.set(channelId, null);
171
+ }
172
+ }
173
+ const parent = this.parents.get(channelId);
174
+ const inherited = parent ? this.config.channels[parent] : undefined;
175
+ return parent && inherited ? { id: parent, settings: inherited } : null;
176
+ }
177
+
123
178
  private async onMessage(message: IncomingMessage): Promise<void> {
124
- if (!message.author || message.author.bot || !this.config.channelIds.includes(message.channel_id)) return;
179
+ if (!message.author || message.author.bot) return;
125
180
  this.refreshConfig();
181
+ const channel = await this.settingsFor(message.channel_id);
182
+ if (!channel) return;
126
183
  const { names, agents } = this.specialists();
127
- const command = parseCommand(message.content ?? "", this.botUserId, names);
128
- if (command.kind === "none") return;
184
+ const present = channelSpecialists(channel.settings, names);
129
185
  const userId = message.author.id;
130
- const allowed = allowedSpecialists(this.config, userId, names);
186
+ const command = parseCommand(message.content ?? "", {
187
+ botUserId: this.botUserId,
188
+ specialists: present,
189
+ trigger: channel.settings.trigger,
190
+ repliedToBot: message.referenced_message?.author?.id === this.botUserId,
191
+ });
192
+ if (command.kind === "none") return;
193
+ const allowed = allowedSpecialists(this.config, userId, present);
194
+ const owner = isOwner(this.config, userId);
195
+ if (command.kind === "config") {
196
+ if (!owner || channel.id !== this.config.adminChannelId) return;
197
+ const result = applyAdminCommand(this.config, command.args, names);
198
+ if (result.config) {
199
+ saveConfig(result.config);
200
+ this.config = result.config;
201
+ log(`config changed by ${userId}: ${command.args.join(" ")}`);
202
+ }
203
+ await this.rest.createMessage(message.channel_id, { content: result.reply, replyTo: message.id });
204
+ return;
205
+ }
131
206
  if (allowed.length === 0) return;
132
207
  if (command.kind === "help") {
133
- await this.rest.createMessage(message.channel_id, { content: helpText(allowed, isOwner(this.config, userId)), replyTo: message.id });
208
+ await this.rest.createMessage(message.channel_id, { content: helpText(allowed, present, owner, channel.settings.trigger), replyTo: message.id });
134
209
  return;
135
210
  }
136
211
  if (command.kind === "status") {
@@ -138,6 +213,10 @@ class Bridge {
138
213
  await this.rest.createMessage(message.channel_id, { content: lines.length > 0 ? lines.join("\n") : "Nothing is running.", replyTo: message.id });
139
214
  return;
140
215
  }
216
+ if (command.kind === "needs-prefix") {
217
+ await this.rest.createMessage(message.channel_id, { content: needsPrefixText(allowed), replyTo: message.id });
218
+ return;
219
+ }
141
220
  if (!canUse(this.config, userId, command.specialist)) return;
142
221
  const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
143
222
  if (!agent) return;
@@ -162,21 +241,35 @@ class Bridge {
162
241
  const { depth, remaining } = currentDepth();
163
242
  let failed = true;
164
243
  let output: string;
244
+ let attachment: string | undefined;
245
+ let seen = 0;
165
246
  try {
166
247
  const result = await runAgent({
167
- agent,
248
+ agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
168
249
  skills,
169
250
  task: taskText,
170
251
  cwd: process.cwd(),
171
252
  depth,
172
253
  remaining: childRemaining(remaining, agent),
173
254
  defaults,
174
- env: { [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
255
+ // The child must not become a second Discord bridge.
256
+ env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
175
257
  extraSystemPrompt: CHANNEL_RULES,
258
+ onUpdate: (partial) => {
259
+ const progress = describeProgress(partial, seen);
260
+ seen = progress.seen;
261
+ for (const line of progress.lines) log(`astro.${task.specialist}: ${line}`);
262
+ },
176
263
  });
177
264
  failed = isFailed(result);
178
- output = resultOutput(result);
179
- log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, cost ${result.usage.cost.toFixed(4)}`);
265
+ if (failed) {
266
+ const reply = failureReply(result);
267
+ output = reply.text;
268
+ attachment = reply.attachment;
269
+ } else {
270
+ output = finalOutput(result.messages) || "(no output)";
271
+ }
272
+ log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, ${result.usage.input + result.usage.output} tokens`);
180
273
  } catch (err) {
181
274
  output = err instanceof Error ? err.message : String(err);
182
275
  } finally {
@@ -184,10 +277,14 @@ class Bridge {
184
277
  }
185
278
  await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
186
279
  await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
187
- await this.post(task, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`);
280
+ await this.post(task, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
188
281
  }
189
282
 
190
- private async post(task: ActiveTask, text: string): Promise<void> {
283
+ private async post(task: ActiveTask, text: string, attachment?: string): Promise<void> {
284
+ if (attachment !== undefined) {
285
+ await this.rest.createMessage(task.channelId, { content: text.slice(0, 1900), replyTo: task.messageId, file: { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } });
286
+ return;
287
+ }
191
288
  if (text.length > ATTACHMENT_THRESHOLD) {
192
289
  const summary = `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
193
290
  await this.rest.createMessage(task.channelId, { content: summary, replyTo: task.messageId, file: { name: `astro-${task.specialist}-${Date.now()}.md`, content: text } });
@@ -252,7 +349,7 @@ class Bridge {
252
349
  * the headless LaunchAgent host sets; interactive sessions never connect.
253
350
  */
254
351
  export default function astroDiscord(pi: ExtensionAPI): void {
255
- if (process.env[ACTIVATION_ENV] !== "1") return;
352
+ if (process.env[ACTIVATION_ENV] !== "1" || process.env[AGENT_ENV]) return;
256
353
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
257
354
  const loaded = loadConfig();
258
355
  if (!loaded.config) {
@@ -13,7 +13,7 @@ import { homedir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import { Writable } from "node:stream";
16
- import { configDir, configPath, tokenPath, validateConfig } from "./config.ts";
16
+ import { type ChannelSettings, configDir, configPath, tokenPath, validateConfig } from "./config.ts";
17
17
  import { DiscordRest } from "./rest.ts";
18
18
 
19
19
  const LABEL = "com.astrofoundry.astro-discord";
@@ -78,26 +78,64 @@ async function main(): Promise<void> {
78
78
  }
79
79
  }
80
80
 
81
- let channelIds: string[];
81
+ const channels: Record<string, ChannelSettings> = {};
82
+ console.log("Channels. For each: its id, then how the bot listens (always, or only when mentioned), then which specialists live there.");
82
83
  for (;;) {
83
- channelIds = await askIds("Channel ID(s) the bot listens in, comma separated: ", 1);
84
- const names: string[] = [];
85
- let ok = true;
86
- for (const id of channelIds) {
87
- try {
88
- const channel = await rest.channel(id);
89
- if (channel.guild_id !== guildId) {
90
- console.log(` channel ${id} is not in that server`);
91
- ok = false;
92
- } else names.push(`#${channel.name ?? id}`);
93
- } catch (err) {
94
- console.log(` channel ${id}: ${err instanceof Error ? err.message : String(err)}`);
95
- ok = false;
84
+ const raw = await ask(Object.keys(channels).length === 0 ? "Channel ID: " : "Another channel ID (Enter to finish): ");
85
+ if (raw.length === 0) {
86
+ if (Object.keys(channels).length > 0) break;
87
+ console.log(" at least one channel is needed");
88
+ continue;
89
+ }
90
+ if (!SNOWFLAKE.test(raw)) {
91
+ console.log(" not a Discord id");
92
+ continue;
93
+ }
94
+ try {
95
+ const channel = await rest.channel(raw);
96
+ if (channel.guild_id !== guildId) {
97
+ console.log(` channel ${raw} is not in that server`);
98
+ continue;
96
99
  }
100
+ console.log(` channel ok: #${channel.name ?? raw}`);
101
+ } catch (err) {
102
+ console.log(` channel ${raw}: ${err instanceof Error ? err.message : String(err)}`);
103
+ continue;
97
104
  }
98
- if (ok) {
99
- console.log(` channels ok: ${names.join(", ")}`);
100
- break;
105
+ let trigger: "always" | "mention" = "always";
106
+ for (;;) {
107
+ const answer = (await ask(" trigger, always or mention [always]: ")).toLowerCase();
108
+ if (answer === "" || answer === "always") break;
109
+ if (answer === "mention") {
110
+ trigger = "mention";
111
+ break;
112
+ }
113
+ console.log(" always or mention");
114
+ }
115
+ let specialists: "*" | string[] = "*";
116
+ for (;;) {
117
+ const answer = await ask(" specialists, * for all or comma separated names (dns,edge,...) [*]: ");
118
+ if (answer === "" || answer === "*") break;
119
+ const names = answer.split(/[\s,]+/).filter(Boolean);
120
+ if (names.every((n) => /^[a-z][a-z0-9-]*$/.test(n))) {
121
+ specialists = [...new Set(names)];
122
+ break;
123
+ }
124
+ console.log(" lowercase names only");
125
+ }
126
+ channels[raw] = { trigger, specialists };
127
+ }
128
+ const channelIds = Object.keys(channels);
129
+ let adminChannelId = channelIds[0];
130
+ if (channelIds.length > 1) {
131
+ for (;;) {
132
+ const answer = await ask(`Admin channel for config commands [${channelIds[0]}]: `);
133
+ if (answer.length === 0) break;
134
+ if (channelIds.includes(answer)) {
135
+ adminChannelId = answer;
136
+ break;
137
+ }
138
+ console.log(" must be one of the channels above");
101
139
  }
102
140
  }
103
141
 
@@ -124,7 +162,7 @@ async function main(): Promise<void> {
124
162
  if (timeoutAnswer.length > 0) timeout = Number(timeoutAnswer);
125
163
 
126
164
  const errors: string[] = [];
127
- const config = validateConfig({ applicationId, guildId, channelIds, owners, access, approvalTimeoutMinutes: timeout }, errors);
165
+ const config = validateConfig({ applicationId, guildId, channels, adminChannelId, owners, access, approvalTimeoutMinutes: timeout }, errors);
128
166
  if (!config) {
129
167
  console.error(`invalid configuration: ${errors.join("; ")}`);
130
168
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.22.3",
3
+ "version": "0.23.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -105,8 +105,8 @@ Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller t
105
105
 
106
106
  The `astro-discord` extension gives Discord users access to the specialists from a channel. It runs in a headless Pi host on Cortex (LaunchAgent `com.astrofoundry.astro-discord`, user `cortex`), never in interactive sessions.
107
107
 
108
- - In a configured channel, `dns list the zones` or `@Cortex edge probe the front door` runs the specialist and posts the answer as a reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` lists what you may use; `status` lists running tasks. Long answers arrive as a `.md` attachment.
109
- - Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Edit `~/.config/astro-discord/config.json` on Cortex; changes apply to the next message.
108
+ - Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows what you may use in that channel; `status` lists running tasks. Long answers arrive as a `.md` attachment.
109
+ - Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
110
110
  - Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, Arcane redeploys, service restarts) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
111
111
  - Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.
112
112
  - Discord side: application `Cortex` with a bot, Message Content Intent enabled, invited with scopes `bot` and `applications.commands` and permissions View Channel, Send Messages, Attach Files, Read Message History, Add Reactions.