@astrofoundry/pi-astro 0.24.0 → 0.25.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,65 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { QuotedMessage } from "./quote.ts";
6
+
7
+ /** An image on a Discord message, by its CDN url. */
8
+ export interface ImageAttachment {
9
+ filename: string;
10
+ url: string;
11
+ contentType: string;
12
+ size: number;
13
+ }
14
+
15
+ export const MAX_IMAGES = 4;
16
+ export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
17
+
18
+ /** Image attachments of the message and of the messages it quotes, oldest quote first, within the count and size limits. */
19
+ export function imageAttachments(messages: readonly QuotedMessage[]): ImageAttachment[] {
20
+ const images: ImageAttachment[] = [];
21
+ for (const message of messages) {
22
+ for (const a of message.attachments ?? []) {
23
+ if (!a.url || !a.content_type?.startsWith("image/") || (a.size ?? 0) > MAX_IMAGE_BYTES) continue;
24
+ images.push({ filename: a.filename, url: a.url, contentType: a.content_type, size: a.size ?? 0 });
25
+ }
26
+ }
27
+ return images.slice(0, MAX_IMAGES);
28
+ }
29
+
30
+ export interface DownloadedImages {
31
+ dir: string;
32
+ files: string[];
33
+ failed: string[];
34
+ }
35
+
36
+ /** Saves the images to a private temp directory, named by an index and a hash so filenames from Discord never reach the file system. */
37
+ export async function downloadImages(images: readonly ImageAttachment[], fetchImpl: typeof fetch = fetch): Promise<DownloadedImages> {
38
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "astro-discord-images-"));
39
+ const files: string[] = [];
40
+ const failed: string[] = [];
41
+ for (const [index, image] of images.entries()) {
42
+ try {
43
+ const response = await fetchImpl(image.url);
44
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
45
+ const bytes = Buffer.from(await response.arrayBuffer());
46
+ if (bytes.length > MAX_IMAGE_BYTES) throw new Error("larger than the limit");
47
+ const name = `${index + 1}-${createHash("sha256").update(image.url).digest("hex").slice(0, 12)}${path.extname(image.filename).toLowerCase().replace(/[^.a-z0-9]/g, "")}`;
48
+ const file = path.join(dir, name);
49
+ await fs.writeFile(file, bytes, { mode: 0o600 });
50
+ files.push(file);
51
+ } catch {
52
+ failed.push(image.filename);
53
+ }
54
+ }
55
+ return { dir, files, failed };
56
+ }
57
+
58
+ /** Task text telling the specialist which images travel with it and which could not be fetched. */
59
+ export function describeImages(task: string, images: readonly ImageAttachment[], failed: readonly string[]): string {
60
+ const lines: string[] = [];
61
+ const fetched = images.filter((i) => !failed.includes(i.filename));
62
+ if (fetched.length > 0) lines.push(`Images attached to the message, included with this task: ${fetched.map((i) => i.filename).join(", ")}.`);
63
+ if (failed.length > 0) lines.push(`Images that could not be fetched: ${failed.join(", ")}.`);
64
+ return lines.length === 0 ? task : `${task}\n\n${lines.join("\n")}`;
65
+ }
@@ -13,6 +13,7 @@ import { failureReply } from "./index.ts";
13
13
  import { DiscordRest } from "./rest.ts";
14
14
  import { CONVERSATION_IDLE_MS, CONVERSATION_MAX_AGE_MS, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, startConversation } from "./conversations.ts";
15
15
  import { quoteText, withQuotes } from "./quote.ts";
16
+ import { MAX_IMAGE_BYTES, describeImages, downloadImages, imageAttachments } from "./attachments.ts";
16
17
 
17
18
  const ID = "123456789012345678";
18
19
  const OTHER = "223456789012345678";
@@ -289,3 +290,34 @@ describe("quotes", () => {
289
290
  expect(withQuotes("check this", [{ label: "Forwarded message", message: alert }])).toMatch(/^check this\n\nForwarded message:\nWazuh alert/);
290
291
  });
291
292
  });
293
+
294
+ describe("image attachments", () => {
295
+ const png = { filename: "alert.png", url: "https://cdn.example/alert.png", content_type: "image/png", size: 1000 };
296
+ const image = { filename: png.filename, url: png.url, contentType: png.content_type, size: png.size };
297
+ it("keeps images within the limits, in message order", () => {
298
+ const big = { ...png, filename: "big.png", size: MAX_IMAGE_BYTES + 1 };
299
+ const text = { filename: "log.txt", url: "https://cdn.example/log.txt", content_type: "text/plain", size: 10 };
300
+ const many = Array.from({ length: 6 }, (_, i) => ({ ...png, filename: `${i}.png` }));
301
+ expect(imageAttachments([{ attachments: [text, big, png] }, { attachments: [{ ...png, filename: "quoted.jpg" }] }]).map((i) => i.filename)).toEqual(["alert.png", "quoted.jpg"]);
302
+ expect(imageAttachments([{ attachments: many }])).toHaveLength(4);
303
+ expect(imageAttachments([{}])).toEqual([]);
304
+ });
305
+
306
+ it("downloads to a private temp dir and reports failures", async () => {
307
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
308
+ const u = String(url);
309
+ if (u.endsWith("missing.png")) return new Response("nope", { status: 404 });
310
+ return new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 });
311
+ }) as unknown as typeof fetch;
312
+ const result = await downloadImages([image, { ...image, filename: "missing.png", url: "https://cdn.example/missing.png" }], fetchImpl);
313
+ try {
314
+ expect(result.files).toHaveLength(1);
315
+ expect(result.files[0]).toMatch(/\/1-[0-9a-f]{12}\.png$/);
316
+ expect(result.failed).toEqual(["missing.png"]);
317
+ expect(describeImages("check this", [image, { ...image, filename: "missing.png" }], result.failed)).toBe("check this\n\nImages attached to the message, included with this task: alert.png.\nImages that could not be fetched: missing.png.");
318
+ expect(describeImages("check this", [], [])).toBe("check this");
319
+ } finally {
320
+ rmSync(result.dir, { recursive: true, force: true });
321
+ }
322
+ });
323
+ });
@@ -1,3 +1,4 @@
1
+ import { promises as fs } from "node:fs";
1
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
3
4
  import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
@@ -10,6 +11,7 @@ import { applyAdminCommand } from "./admin.ts";
10
11
  import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
11
12
  import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
12
13
  import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
14
+ import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
13
15
  import { GatewayClient } from "./gateway.ts";
14
16
  import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
15
17
  import { DiscordRest, type MessageComponent } from "./rest.ts";
@@ -58,6 +60,7 @@ interface IncomingMessage {
58
60
  guild_id?: string;
59
61
  content?: string;
60
62
  author?: { id: string; bot?: boolean };
63
+ attachments?: QuotedMessage["attachments"];
61
64
  /** The message this one replies to; Discord resolves it on every reply event. */
62
65
  referenced_message?: QuotedMessage | null;
63
66
  /** Copies of forwarded messages, without their authors. */
@@ -82,6 +85,7 @@ interface ActiveTask {
82
85
  userId: string;
83
86
  startedAt: number;
84
87
  conversation: ResolvedConversation;
88
+ images: ImageAttachment[];
85
89
  }
86
90
 
87
91
  function log(line: string): void {
@@ -238,8 +242,10 @@ class Bridge {
238
242
  );
239
243
  if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
240
244
  saveState(this.conversations);
241
- const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation };
242
- const taskText = withQuotes(command.task, this.quotesFor(message, conversation.resumed));
245
+ const quotes = this.quotesFor(message, conversation.resumed);
246
+ const images = imageAttachments([message, ...quotes.map((q) => q.message)]);
247
+ const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation, images };
248
+ const taskText = withQuotes(command.task, quotes);
243
249
  const previous = this.queues.get(command.specialist) ?? Promise.resolve();
244
250
  const next = previous.then(() => this.runTask(agent, taskText, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
245
251
  this.queues.set(command.specialist, next);
@@ -273,6 +279,9 @@ class Bridge {
273
279
  log(`run astro.${task.specialist} for ${task.userId} (${task.conversation.resumed ? "continuing" : "new conversation"} ${task.conversation.id}): ${taskText.slice(0, 200)}`);
274
280
  const session: ChildSession = { dir: sessionDirFor(sessionsRoot(), task.specialist), id: task.conversation.id };
275
281
  pruneSessions(session.dir);
282
+ const images = task.images.length > 0 ? await downloadImages(task.images) : undefined;
283
+ if (images && images.failed.length > 0) log(`astro.${task.specialist}: could not fetch ${images.failed.join(", ")}`);
284
+ const prompt = images ? describeImages(taskText, task.images, images.failed) : taskText;
276
285
  const dirs = defaultDirs();
277
286
  const { skills, missing } = resolveSkills(agent.skills, dirs);
278
287
  if (missing.length > 0) log(`${agent.name}: skill(s) not found: ${missing.join(", ")}`);
@@ -289,7 +298,8 @@ class Bridge {
289
298
  const result = await runAgent({
290
299
  agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
291
300
  skills,
292
- task: taskText,
301
+ task: prompt,
302
+ files: images?.files,
293
303
  cwd: process.cwd(),
294
304
  depth,
295
305
  remaining: childRemaining(remaining, agent),
@@ -318,6 +328,7 @@ class Bridge {
318
328
  } finally {
319
329
  clearInterval(typing);
320
330
  this.active.delete(task.specialist);
331
+ if (images) await fs.rm(images.dir, { recursive: true, force: true }).catch(() => undefined);
321
332
  }
322
333
  await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
323
334
  await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
@@ -3,7 +3,7 @@ export interface QuotedMessage {
3
3
  id?: string;
4
4
  content?: string;
5
5
  embeds?: { title?: string; description?: string; fields?: { name: string; value: string }[]; footer?: { text: string }; author?: { name?: string } }[];
6
- attachments?: { filename: string }[];
6
+ attachments?: { filename: string; url?: string; content_type?: string; size?: number }[];
7
7
  author?: { id: string; username?: string; bot?: boolean };
8
8
  }
9
9
 
@@ -62,6 +62,8 @@ describe("child arguments", () => {
62
62
  const exact = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, session: { dir: "/s/x", id: "dns-1", continue: true } });
63
63
  expect(exact.slice(3, 7)).toEqual(["--session-dir", "/s/x", "--session-id", "dns-1"]);
64
64
  expect(exact).not.toContain("--continue");
65
+ const withFiles = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, files: ["/tmp/a.png", "/tmp/b.png"] });
66
+ expect(withFiles.slice(-4)).toEqual(["--", "@/tmp/a.png", "@/tmp/b.png", "Task: t"]);
65
67
  });
66
68
 
67
69
  it("inlines skills after the agent prompt", () => {
@@ -88,10 +88,12 @@ export interface ChildArgsInput {
88
88
  promptFile: string | null;
89
89
  defaults: DispatchDefaults;
90
90
  session?: ChildSession;
91
+ /** Local files passed as `@file` prompt inputs; Pi attaches images by content type. */
92
+ files?: string[];
91
93
  }
92
94
 
93
95
  /** Arguments for the child `pi` process: print mode, JSON events, a session file only when asked. */
94
- export function buildChildArgs({ agent, task, promptFile, defaults, session }: ChildArgsInput): string[] {
96
+ export function buildChildArgs({ agent, task, promptFile, defaults, session, files }: ChildArgsInput): string[] {
95
97
  const args = ["--mode", "json", "-p"];
96
98
  if (session) {
97
99
  args.push("--session-dir", session.dir);
@@ -106,7 +108,7 @@ export function buildChildArgs({ agent, task, promptFile, defaults, session }: C
106
108
  if (!agent.inheritProjectContext) args.push("--no-context-files");
107
109
  if (!agent.inheritSkills) args.push("--no-skills");
108
110
  if (promptFile) args.push(agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptFile);
109
- args.push("--", `Task: ${task}`);
111
+ args.push("--", ...(files ?? []).map((file) => `@${file}`), `Task: ${task}`);
110
112
  return args;
111
113
  }
112
114
 
@@ -201,6 +203,8 @@ export interface RunOptions {
201
203
  extraSystemPrompt?: string;
202
204
  /** Saved conversation to continue or start; omitted for a one-off run. */
203
205
  session?: ChildSession;
206
+ /** Local files (images) attached to the task. */
207
+ files?: string[];
204
208
  }
205
209
 
206
210
  export async function runAgent(options: RunOptions): Promise<RunResult> {
@@ -226,7 +230,7 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
226
230
  }
227
231
  try {
228
232
  if (options.session) await fs.promises.mkdir(options.session.dir, { recursive: true, mode: 0o700 });
229
- const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults, session: options.session });
233
+ const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults, session: options.session, files: options.files });
230
234
  const invocation = (options.invocation ?? piInvocation)(args);
231
235
  const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
232
236
  let aborted = false;
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { AgentDirs } from "./agents.ts";
6
6
  import { DEPTH_ENV, REMAINING_ENV } from "./child.ts";
7
- import astroSubagents, { parseRunCommand, pruneLegacyCopies } from "./index.ts";
7
+ import astroSubagents, { parseRunCommand, pruneLegacyCopies, runEntryText } from "./index.ts";
8
8
 
9
9
  type Handler = (event: Record<string, unknown>, ctx: unknown) => Promise<unknown>;
10
10
 
@@ -16,6 +16,8 @@ interface CapturedPi {
16
16
  registerCommand: (name: string, opts: { handler: (args: string, ctx: unknown) => Promise<void> | void }) => void;
17
17
  on: (event: string, handler: Handler) => void;
18
18
  sendMessage: ReturnType<typeof vi.fn>;
19
+ appendEntry: ReturnType<typeof vi.fn>;
20
+ registerEntryRenderer: ReturnType<typeof vi.fn>;
19
21
  }
20
22
 
21
23
  function makePi(): CapturedPi {
@@ -32,6 +34,8 @@ function makePi(): CapturedPi {
32
34
  handlers[event] = handler;
33
35
  },
34
36
  sendMessage: vi.fn(),
37
+ appendEntry: vi.fn(),
38
+ registerEntryRenderer: vi.fn(),
35
39
  };
36
40
  }
37
41
 
@@ -78,6 +82,9 @@ describe("astro-subagents extension", () => {
78
82
  const ctx = { ui: { notify: vi.fn(), setStatus }, hasUI: true, cwd: root, model: { provider: "openai-codex", id: "gpt-6-astra" } };
79
83
  await pi.commands.get("run")?.handler("astro.nobody -- x", ctx);
80
84
  expect(setStatus).toHaveBeenCalledWith("subagent", expect.stringContaining("astro.nobody on openai-codex/gpt-6-astra"));
85
+ expect(pi.appendEntry).toHaveBeenCalledWith("astro-subagents-run", { agent: "astro.nobody", task: "x", continue: false });
86
+ expect(pi.registerEntryRenderer).toHaveBeenCalledWith("astro-subagents-run", expect.any(Function));
87
+ expect(runEntryText({ agent: "astro.dns", task: "zones", continue: true })).toBe("/run astro.dns --continue -- zones");
81
88
  expect(pi.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "astro-subagents", content: expect.stringContaining("Unknown agent") }), { triggerTurn: false });
82
89
  });
83
90
 
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { StringEnum } from "@earendil-works/pi-ai";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { Box, Text } from "@earendil-works/pi-tui";
6
7
  import { type Static, Type } from "typebox";
7
8
  import { type AgentConfig, type AgentDirs, type AgentScope, BUNDLED_NAMESPACE, defaultDirs, discoverAgents, formatAgentList, resolveSkills } from "./agents.ts";
8
9
  import { type ChildSession, childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
@@ -88,6 +89,17 @@ export function parseRunCommand(input: string): { agent: string; task: string; c
88
89
  return { agent: match[1], task, continue: match[2] !== undefined };
89
90
  }
90
91
 
92
+ /** Transcript entry for a `/run` invocation; shown to the user, never sent to the model. */
93
+ export interface RunEntry {
94
+ agent: string;
95
+ task: string;
96
+ continue: boolean;
97
+ }
98
+
99
+ export function runEntryText(entry: RunEntry): string {
100
+ return `/run ${entry.agent}${entry.continue ? " --continue" : ""} -- ${entry.task}`;
101
+ }
102
+
91
103
  export interface GateOptions {
92
104
  dirs?: AgentDirs;
93
105
  env?: NodeJS.ProcessEnv;
@@ -265,6 +277,14 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
265
277
  return { content: [{ type: "text", text: finalOutput(result.messages) || "(no output)" }], details: details("single", [result]) };
266
278
  }
267
279
 
280
+ // Slash commands are not echoed in the transcript; show the invocation the way a user message looks.
281
+ pi.registerEntryRenderer<RunEntry>("astro-subagents-run", (entry, _options, theme) => {
282
+ if (!entry.data) return undefined;
283
+ const box = new Box(1, 0, (text) => theme.bg("userMessageBg", text));
284
+ box.addChild(new Text(theme.fg("userMessageText", runEntryText(entry.data)), 0, 0));
285
+ return box;
286
+ });
287
+
268
288
  pi.registerCommand("run", {
269
289
  description: "Run an agent: /run <agent> [--continue] -- <task>",
270
290
  handler: async (args, ctx) => {
@@ -278,6 +298,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
278
298
  return;
279
299
  }
280
300
  const agents = listAgents(ctx.cwd, "user").agents;
301
+ pi.appendEntry<RunEntry>("astro-subagents-run", { agent: parsed.agent, task: parsed.task, continue: parsed.continue });
281
302
  // Every /run saves its session so a later --continue can pick the conversation up.
282
303
  const session: ChildSession = { dir: sessionDirFor(dirs.sessions, parsed.agent), continue: parsed.continue };
283
304
  pruneSessions(session.dir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.24.0",
3
+ "version": "0.25.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,7 +105,7 @@ 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
- - 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. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment.
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. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment.
109
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`.