@astrofoundry/pi-astro 0.21.2 → 0.22.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,175 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Interactive setup for the Discord bridge. Run on the host that will hold
4
+ * the bot, as the user that runs Pi:
5
+ * node --disable-warning=ExperimentalWarning <package>/extensions/astro-discord/setup.ts
6
+ * Prompts for the bot token and Discord ids, checks each against the API,
7
+ * writes ~/.config/astro-discord/{token,config.json} (0600), the LaunchAgent,
8
+ * and prints the commands to start it. Imports nothing from Pi on purpose.
9
+ */
10
+ import { execFileSync } from "node:child_process";
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { createInterface } from "node:readline/promises";
15
+ import { Writable } from "node:stream";
16
+ import { configDir, configPath, tokenPath, validateConfig } from "./config.ts";
17
+ import { DiscordRest } from "./rest.ts";
18
+
19
+ const LABEL = "com.astrofoundry.astro-discord";
20
+ const SNOWFLAKE = /^\d{17,20}$/;
21
+
22
+ class MutableOutput extends Writable {
23
+ muted = false;
24
+ _write(chunk: Buffer, _encoding: BufferEncoding, callback: () => void): void {
25
+ if (!this.muted) process.stdout.write(chunk);
26
+ callback();
27
+ }
28
+ }
29
+
30
+ async function main(): Promise<void> {
31
+ const output = new MutableOutput();
32
+ const rl = createInterface({ input: process.stdin, output, terminal: true });
33
+ const ask = async (question: string, hidden = false): Promise<string> => {
34
+ output.muted = false;
35
+ process.stdout.write(question);
36
+ output.muted = hidden;
37
+ const answer = await rl.question("");
38
+ output.muted = false;
39
+ if (hidden) process.stdout.write("\n");
40
+ return answer.trim();
41
+ };
42
+ const askIds = async (question: string, min: number): Promise<string[]> => {
43
+ for (;;) {
44
+ const ids = (await ask(question)).split(/[\s,]+/).filter(Boolean);
45
+ if (ids.length >= min && ids.every((id) => SNOWFLAKE.test(id))) return ids;
46
+ console.log(` need ${min === 0 ? "" : "at least one "}Discord id${min > 1 ? "s" : ""} (17 to 20 digits, comma separated)`);
47
+ }
48
+ };
49
+
50
+ console.log("astro-discord setup. Nothing is written before every value has been checked.\n");
51
+ const existing = existsSync(tokenPath()) ? readFileSync(tokenPath(), "utf-8").trim() : "";
52
+ let token = await ask(existing ? "Bot token (Enter keeps the stored one): " : "Bot token (Developer Portal, Bot, Reset Token): ", true);
53
+ if (token.length === 0 && existing) token = existing;
54
+ const rest = new DiscordRest(token);
55
+ const me = await rest.currentUser();
56
+ console.log(` token ok: bot ${me.username} (${me.id})`);
57
+
58
+ let applicationId: string;
59
+ for (;;) {
60
+ applicationId = await ask("Application ID (General Information): ");
61
+ if (SNOWFLAKE.test(applicationId)) break;
62
+ console.log(" not a Discord id");
63
+ }
64
+
65
+ let guildId: string;
66
+ for (;;) {
67
+ guildId = (await askIds("Server ID (right-click the server, Copy Server ID): ", 1))[0];
68
+ try {
69
+ const guild = (await (await fetch(`https://discord.com/api/v10/guilds/${guildId}`, { headers: { Authorization: `Bot ${token}` } })).json()) as { name?: string; message?: string };
70
+ if (guild.name) {
71
+ console.log(` server ok: ${guild.name}`);
72
+ break;
73
+ }
74
+ console.log(` Discord says: ${guild.message ?? "unknown server"}; is the bot invited there?`);
75
+ } catch (err) {
76
+ console.log(` check failed: ${err instanceof Error ? err.message : String(err)}`);
77
+ }
78
+ }
79
+
80
+ let channelIds: string[];
81
+ for (;;) {
82
+ channelIds = await askIds("Channel ID(s) the bot listens in, comma separated: ", 1);
83
+ const names: string[] = [];
84
+ let ok = true;
85
+ for (const id of channelIds) {
86
+ try {
87
+ const channel = await rest.channel(id);
88
+ if (channel.guild_id !== guildId) {
89
+ console.log(` channel ${id} is not in that server`);
90
+ ok = false;
91
+ } else names.push(`#${channel.name ?? id}`);
92
+ } catch (err) {
93
+ console.log(` channel ${id}: ${err instanceof Error ? err.message : String(err)}`);
94
+ ok = false;
95
+ }
96
+ }
97
+ if (ok) {
98
+ console.log(` channels ok: ${names.join(", ")}`);
99
+ break;
100
+ }
101
+ }
102
+
103
+ const owners = await askIds("Owner user ID(s), may use every specialist and approve risky calls: ", 1);
104
+ for (const id of owners) {
105
+ const user = (await (await fetch(`https://discord.com/api/v10/users/${id}`, { headers: { Authorization: `Bot ${token}` } })).json()) as { username?: string };
106
+ console.log(` owner ${id}: ${user.username ?? "unknown user"}`);
107
+ }
108
+
109
+ const access: Record<string, string[]> = {};
110
+ console.log("Extra users per specialist (Enter on an empty specialist name to finish):");
111
+ for (;;) {
112
+ const name = await ask(" specialist name (dns, edge, ...): ");
113
+ if (name.length === 0) break;
114
+ if (!/^[a-z][a-z0-9-]*$/.test(name)) {
115
+ console.log(" lowercase name only");
116
+ continue;
117
+ }
118
+ access[name] = await askIds(` user id(s) allowed to use ${name}: `, 1);
119
+ }
120
+
121
+ let timeout = 30;
122
+ const timeoutAnswer = await ask("Approval timeout in minutes [30]: ");
123
+ if (timeoutAnswer.length > 0) timeout = Number(timeoutAnswer);
124
+
125
+ const errors: string[] = [];
126
+ const config = validateConfig({ applicationId, guildId, channelIds, owners, access, approvalTimeoutMinutes: timeout }, errors);
127
+ if (!config) {
128
+ console.error(`invalid configuration: ${errors.join("; ")}`);
129
+ process.exit(2);
130
+ }
131
+
132
+ mkdirSync(configDir(), { recursive: true, mode: 0o700 });
133
+ chmodSync(configDir(), 0o700);
134
+ writeFileSync(tokenPath(), `${token}\n`, { mode: 0o600 });
135
+ chmodSync(tokenPath(), 0o600);
136
+ writeFileSync(configPath(), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
137
+ chmodSync(configPath(), 0o600);
138
+
139
+ const pi = execFileSync("/bin/sh", ["-c", "command -v pi"], { encoding: "utf-8", env: { ...process.env, PATH: `${process.env.PATH ?? ""}:/opt/homebrew/bin:${homedir()}/.local/bin` } }).trim();
140
+ const runScript = join(configDir(), "run.sh");
141
+ const logFile = join(homedir(), "Library", "Logs", "astro-discord.log");
142
+ writeFileSync(
143
+ runScript,
144
+ `#!/bin/sh\n# Headless Pi host for the Discord bridge. Pi's RPC mode exits when stdin closes, so a pipe keeps it open.\nexport PATH="/opt/homebrew/bin:$HOME/.local/bin:/usr/bin:/bin"\nexport ASTRO_DISCORD=1\ncd "$HOME"\ntail -f /dev/null | exec ${pi} --mode rpc --no-session\n`,
145
+ { mode: 0o700 },
146
+ );
147
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
148
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
149
+ <plist version="1.0">
150
+ <dict>
151
+ <key>Label</key><string>${LABEL}</string>
152
+ <key>ProgramArguments</key><array><string>/bin/sh</string><string>${runScript}</string></array>
153
+ <key>RunAtLoad</key><true/>
154
+ <key>KeepAlive</key><true/>
155
+ <key>StandardOutPath</key><string>${logFile}</string>
156
+ <key>StandardErrorPath</key><string>${logFile}</string>
157
+ </dict>
158
+ </plist>
159
+ `;
160
+ const plistPath = join(homedir(), "Library", "LaunchAgents", `${LABEL}.plist`);
161
+ mkdirSync(join(homedir(), "Library", "LaunchAgents"), { recursive: true });
162
+ writeFileSync(plistPath, plist, { mode: 0o644 });
163
+ rl.close();
164
+
165
+ console.log(`\nwrote ${tokenPath()}, ${configPath()}, ${runScript}, ${plistPath}`);
166
+ console.log("Start or restart the bridge:");
167
+ console.log(` launchctl bootout gui/$(id -u)/${LABEL} 2>/dev/null; launchctl bootstrap gui/$(id -u) ${plistPath}`);
168
+ console.log(`Log: tail -f ${logFile}`);
169
+ console.log(`In Discord, in one of the channels: help`);
170
+ }
171
+
172
+ main().catch((err) => {
173
+ console.error(err instanceof Error ? err.message : String(err));
174
+ process.exit(1);
175
+ });
@@ -64,9 +64,10 @@ export function childRemaining(parentRemaining: number, agent: AgentConfig): num
64
64
  return agent.maxSubagentDepth === undefined ? inherited : Math.min(inherited, agent.maxSubagentDepth);
65
65
  }
66
66
 
67
- export function composeSystemPrompt(agent: AgentConfig, skills: ResolvedSkill[]): string {
67
+ export function composeSystemPrompt(agent: AgentConfig, skills: ResolvedSkill[], extra?: string): string {
68
68
  const parts = [agent.systemPrompt];
69
69
  for (const skill of skills) parts.push(`## Skill: ${skill.name}\n\n${skill.body}`);
70
+ if (extra) parts.push(extra);
70
71
  return parts.filter((p) => p.length > 0).join("\n\n");
71
72
  }
72
73
 
@@ -177,6 +178,10 @@ export interface RunOptions {
177
178
  signal?: AbortSignal;
178
179
  onUpdate?: (result: RunResult) => void;
179
180
  invocation?: (args: string[]) => Invocation;
181
+ /** Extra variables for the child process, for example an approval endpoint. */
182
+ env?: Record<string, string>;
183
+ /** Text appended to the system prompt, for example channel rules. */
184
+ extraSystemPrompt?: string;
180
185
  }
181
186
 
182
187
  export async function runAgent(options: RunOptions): Promise<RunResult> {
@@ -192,7 +197,7 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
192
197
  model: agent.model ?? options.defaults.model,
193
198
  step: options.step,
194
199
  };
195
- const prompt = composeSystemPrompt(agent, options.skills);
200
+ const prompt = composeSystemPrompt(agent, options.skills, options.extraSystemPrompt);
196
201
  let tmpDir: string | null = null;
197
202
  let promptFile: string | null = null;
198
203
  if (prompt.length > 0) {
@@ -203,7 +208,7 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
203
208
  try {
204
209
  const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults });
205
210
  const invocation = (options.invocation ?? piInvocation)(args);
206
- const env = buildChildEnv(agent, options.depth, options.remaining);
211
+ const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
207
212
  let aborted = false;
208
213
  const exitCode = await new Promise<number>((resolve) => {
209
214
  // Own process group so a kill reaches the child's own tool processes, not only pi itself.
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { isRisky, requestApproval } from "./index.ts";
3
+
4
+ describe("approvals", () => {
5
+ it("marks interrupting and destructive calls as risky", () => {
6
+ expect(isRisky("edge", ["vps", "reboot", "--confirm"])).toBe(true);
7
+ expect(isRisky("edge", ["vps", "status"])).toBe(false);
8
+ expect(isRisky("edge", ["vps", "deploy-nginx"])).toBe(true);
9
+ expect(isRisky("dns", ["technitium", "primary", "zones"])).toBe(false);
10
+ expect(isRisky("dns", ["technitium", "primary", "record-delete", "z", "d", "A", "ipAddress=1.1.1.1"])).toBe(true);
11
+ expect(isRisky("dns", ["cloudflare", "record-add", "37pla.net", "{}"])).toBe(true);
12
+ expect(isRisky("identity", ["zitadel", "GET", "/v2/users"])).toBe(false);
13
+ expect(isRisky("identity", ["zitadel", "DELETE", "/v2/users/1"])).toBe(true);
14
+ expect(isRisky("identity", ["dmz", "deploy-config"])).toBe(true);
15
+ expect(isRisky("arcane", ["projects", "list"])).toBe(false);
16
+ expect(isRisky("arcane", ["projects", "redeploy", "mealie"])).toBe(true);
17
+ expect(isRisky("arcane", ["gitops", "sync", "arcane", "--yes"])).toBe(true);
18
+ expect(isRisky("proxmox", ["guests"])).toBe(false);
19
+ expect(isRisky("proxmox", ["set", "107", "memory=768"])).toBe(true);
20
+ expect(isRisky("proxmox", ["shutdown", "107", "--confirm"])).toBe(true);
21
+ expect(isRisky("security", ["decisions"])).toBe(false);
22
+ expect(isRisky("inference", ["hermes-restart", "api"])).toBe(true);
23
+ expect(isRisky("backup", ["restic-snapshots"])).toBe(false);
24
+ expect(isRisky("backup", ["vzdump-run", "107"])).toBe(true);
25
+ });
26
+
27
+ it("creates a ticket and polls until decided", async () => {
28
+ const seen: string[] = [];
29
+ let polls = 0;
30
+ const fetchImpl = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
31
+ seen.push(`${init?.method ?? "GET"} ${String(url)}`);
32
+ if (init?.method === "POST") {
33
+ expect(JSON.parse(init.body as string)).toEqual({ agent: "astro.edge", service: "edge", args: ["vps", "reboot", "--confirm"] });
34
+ return new Response(JSON.stringify({ id: "t1" }), { status: 201 });
35
+ }
36
+ polls++;
37
+ return new Response(JSON.stringify({ id: "t1", decision: polls < 3 ? null : "deny" }), { status: 200 });
38
+ }) as unknown as typeof fetch;
39
+ const decision = await requestApproval("http://127.0.0.1:1", "tok", { agent: "astro.edge", service: "edge", args: ["vps", "reboot", "--confirm"] }, undefined, 5, fetchImpl);
40
+ expect(decision).toBe("deny");
41
+ expect(seen[0]).toBe("POST http://127.0.0.1:1/approvals");
42
+ expect(seen[1]).toBe("GET http://127.0.0.1:1/approvals/t1");
43
+ expect(polls).toBe(3);
44
+ const failing = vi.fn(async () => new Response("no", { status: 500 })) as unknown as typeof fetch;
45
+ await expect(requestApproval("http://127.0.0.1:1", "tok", { agent: "a", service: "s", args: [] }, undefined, 5, failing)).rejects.toThrow(/500/);
46
+ });
47
+ });
@@ -10,6 +10,83 @@ export const CHILD_AGENT_ENV = AGENT_ENV;
10
10
  const DEFAULT_USER = "specialist";
11
11
  const FIXED_COMMAND_MARKERS = ["specialist-cli", "arcane-cli"];
12
12
 
13
+ /** Set by a chat bridge (astro-discord) on the specialist child: where to ask an operator before a risky call. */
14
+ export const APPROVAL_URL_ENV = "ASTRO_APPROVAL_URL";
15
+ export const APPROVAL_TOKEN_ENV = "ASTRO_APPROVAL_TOKEN";
16
+
17
+ /**
18
+ * Calls that interrupt a service, delete data, or change live configuration.
19
+ * Anything carrying `--confirm` is risky by construction; these patterns add
20
+ * the writes that do not need `--confirm` in a terminal session.
21
+ */
22
+ export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
23
+ arcane: [
24
+ /^(projects|stacks)\s+(up|down|redeploy|restart|stop|delete|remove|rm|destroy|upgrade)\b/,
25
+ /^containers\s+(stop|restart|kill|remove|rm|delete)\b/,
26
+ /^(images|volumes|networks)\s+(prune|remove|rm|delete)\b/,
27
+ /^system\s+(prune|upgrade)\b/,
28
+ /^gitops\s+(sync|delete|update|import)\b/,
29
+ ],
30
+ identity: [/^zitadel\s+(DELETE|POST|PUT|PATCH)\b/, /^dmz\s+(deploy-config|pomerium-restart|render|lego-renew)\b/],
31
+ network: [/^unifi\s+policy-(create|update|delete)\b/],
32
+ dns: [/^technitium\s+primary\s+(zone-create|zone-delete|record-add|record-update|record-delete)\b/, /^cloudflare\s+record-(add|update|delete)\b/],
33
+ edge: [/^vps\s+deploy-nginx\b/],
34
+ backup: [/^(vzdump-run|host-backup-run|arcane-backup-run|gcs-run|restic-check-run|restic-restore|restore-clean)\b/],
35
+ proxmox: [/^(set|snapshot-create|start)\b/],
36
+ inference: [/^(nexus-restart|nexus-start|hermes-restart)\b/],
37
+ };
38
+
39
+ export function isRisky(service: string, args: readonly string[]): boolean {
40
+ if (args.includes("--confirm")) return true;
41
+ const joined = args.join(" ");
42
+ return (RISKY[service] ?? []).some((pattern) => pattern.test(joined));
43
+ }
44
+
45
+ export type Decision = "approve" | "deny" | "timeout";
46
+
47
+ export interface ApprovalRequest {
48
+ agent: string;
49
+ service: string;
50
+ args: readonly string[];
51
+ }
52
+
53
+ interface Ticket {
54
+ id?: unknown;
55
+ }
56
+
57
+ interface TicketState {
58
+ decision?: unknown;
59
+ }
60
+
61
+ /**
62
+ * Asks the bridge for an operator decision: POST creates a ticket, then the
63
+ * ticket is polled until it is decided. Polling keeps every HTTP call short.
64
+ */
65
+ export async function requestApproval(url: string, token: string, request: ApprovalRequest, signal?: AbortSignal, pollMs = 5000, fetchImpl: typeof fetch = fetch): Promise<Decision> {
66
+ const headers = { Authorization: `Bearer ${token}`, "Content-Type": "application/json" };
67
+ const created = await fetchImpl(`${url}/approvals`, { method: "POST", headers, body: JSON.stringify(request), signal });
68
+ if (!created.ok) throw new Error(`approval endpoint answered ${created.status}`);
69
+ const ticket = (await created.json()) as Ticket;
70
+ if (typeof ticket.id !== "string") throw new Error("approval endpoint returned no ticket id");
71
+ for (;;) {
72
+ await new Promise<void>((resolve, reject) => {
73
+ const timer = setTimeout(resolve, pollMs);
74
+ signal?.addEventListener(
75
+ "abort",
76
+ () => {
77
+ clearTimeout(timer);
78
+ reject(new Error("aborted while waiting for approval"));
79
+ },
80
+ { once: true },
81
+ );
82
+ });
83
+ const response = await fetchImpl(`${url}/approvals/${ticket.id}`, { headers, signal });
84
+ if (!response.ok) throw new Error(`approval endpoint answered ${response.status}`);
85
+ const state = (await response.json()) as TicketState;
86
+ if (state.decision === "approve" || state.decision === "deny" || state.decision === "timeout") return state.decision;
87
+ }
88
+ }
89
+
13
90
  export interface Guard {
14
91
  user: string;
15
92
  commandMarkers: string[];
@@ -103,6 +180,13 @@ function registerSpecialistTool(pi: ExtensionAPI, config: SpecialistsConfig, ent
103
180
  ],
104
181
  parameters: params,
105
182
  async execute(_toolCallId, input, signal) {
183
+ const approvalUrl = process.env[APPROVAL_URL_ENV];
184
+ if (approvalUrl && isRisky(entry.service, input.args)) {
185
+ const decision = await requestApproval(approvalUrl, process.env[APPROVAL_TOKEN_ENV] ?? "", { agent: entry.agent, service: entry.service, args: input.args }, signal);
186
+ if (decision !== "approve") {
187
+ throw new Error(`${entry.service}: the operator ${decision === "deny" ? "denied" : "did not answer the approval request for"} \`${input.args.join(" ")}\`. Report this and stop.`);
188
+ }
189
+ }
106
190
  const result = await pi.exec("sudo", entryArgs(config, entry, input.args), {
107
191
  signal,
108
192
  timeout: entry.timeoutMs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -67,6 +67,10 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
67
67
  6. Host: note fields, `provision`, config file, `specialists.json` entry, target entry script. Document the fields in the homelab `03-cortex-macmini/ROTATION.md` and host facts in `03-cortex-macmini/README.md`.
68
68
  7. README in this folder: table row and setup step; root `README.md`: the agent and skill lists.
69
69
 
70
+ ## Chat bridges
71
+
72
+ `extensions/astro-discord/` runs specialists for Discord messages through `runAgent` and passes `ASTRO_APPROVAL_URL`/`ASTRO_APPROVAL_TOKEN` to the child. The gate consults `RISKY` in `extensions/specialist-gate/index.ts` and blocks on a ticket until an owner decides; keep `RISKY` in step with the wrappers when a new interrupting or destructive command appears (every `--confirm` command is covered by construction).
73
+
70
74
  ## Next
71
75
 
72
- All nine specialists exist; `network` writes firewall policies and `identity` deploys Pomerium routes. Open: Europa scheduled-task control for `inference` (needs Europa on to test Windows OpenSSH forced commands), then Discord access to the specialists, then the Astrogate route.
76
+ All nine specialists exist; `network` writes firewall policies, `identity` deploys Pomerium routes, and Discord access is live. Open: Europa scheduled-task control for `inference` (needs Europa on to test Windows OpenSSH forced commands), then the Astrogate route.
@@ -101,6 +101,16 @@ Host: Cortex (macOS, Homebrew `node`, `bw`, `jq`, `gcloud-cli` with `python@3.14
101
101
 
102
102
  Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller test -- projects list` prints JSON; the same for `identity -- dmz status`, `network -- unifi sites`, `dns -- technitium primary zones`, `edge -- vps status`, `security -- attention`, `backup -- vzdump-tasks`, `proxmox -- guests`, `inference -- nexus-status`; `/run astro.arcane -- list projects` works in Pi; `bash` with `arcane-cli --help` is blocked in a normal session.
103
103
 
104
+ ## Discord
105
+
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
+
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.
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
+ - Setup, as `cortex`: `node --disable-warning=ExperimentalWarning ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/setup.ts` 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
+ - 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.
113
+
104
114
  ## Rotation and operations
105
115
 
106
116
  Rotation steps for every field live in the homelab repository, `03-cortex-macmini/ROTATION.md`. After any change in Vaultwarden run `specialist-cli provision`. Host state and the sudo rules are documented in `03-cortex-macmini/README.md`; the forced-command scripts are tracked under `02-pulsar-proxmox/dmz/system/`, `02-pulsar-proxmox/pulsar/system/`, `02-pulsar-proxmox/observability/system/`, `02-pulsar-proxmox/hermes/system/`, `host/` of the `astronaute77/arcane` repository, `04-nexus-macstudio/system/`, and `00-frontdoor-vps/system/`.