@astrofoundry/pi-astro 0.28.1 β†’ 0.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecia
13
13
  import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
14
14
  import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
15
15
  import { GatewayClient } from "./gateway.ts";
16
+ import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
16
17
  import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
17
18
  import { DiscordRest, type MessageComponent } from "./rest.ts";
18
19
 
@@ -86,8 +87,13 @@ interface ActiveTask {
86
87
  startedAt: number;
87
88
  conversation: ResolvedConversation;
88
89
  images: ImageAttachment[];
90
+ abort?: AbortController;
91
+ placeholder?: string;
89
92
  }
90
93
 
94
+ /** How long a shutdown may spend editing Discord messages; launchd kills the job 20 s after the stop signal. */
95
+ const SHUTDOWN_BUDGET_MS = 8000;
96
+
91
97
  function log(line: string): void {
92
98
  process.stderr.write(`[astro-discord ${new Date().toISOString()}] ${line}\n`);
93
99
  }
@@ -115,6 +121,9 @@ class Bridge {
115
121
  private readonly queues = new Map<string, Promise<void>>();
116
122
  private readonly active = new Map<string, ActiveTask>();
117
123
  private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
124
+ private gateway: GatewayClient | undefined;
125
+ private stopping = false;
126
+ private readonly since = new Date().toISOString();
118
127
  private config: DiscordConfig;
119
128
  private conversations: ConversationState;
120
129
  private botUserId = "";
@@ -146,9 +155,65 @@ class Bridge {
146
155
  else log(`config not reloaded: ${loaded.errors.join("; ")}`);
147
156
  }
148
157
 
158
+ private openWork(): OpenWork {
159
+ return {
160
+ since: this.since,
161
+ tasks: [...this.active.values()].map((t) => ({ specialist: t.specialist, channelId: t.channelId, messageId: t.messageId, ...(t.placeholder ? { placeholderId: t.placeholder } : {}) })),
162
+ approvals: [...this.approvalMessages.entries()].map(([ticketId, a]) => ({ ticketId, channelId: a.channelId, messageId: a.messageId, userChannelId: a.userMessage.channelId, userMessageId: a.userMessage.messageId })),
163
+ };
164
+ }
165
+
166
+ /** Records what is open in Discord, so a restart can wait for it and the next process can close it after a crash. */
167
+ private saveOpenWork(): void {
168
+ try {
169
+ writeOpenWork(this.openWork());
170
+ } catch (err) {
171
+ log(`open work not saved: ${err instanceof Error ? err.message : String(err)}`);
172
+ }
173
+ }
174
+
175
+ /** Tells Discord that open approvals were cancelled and running tasks interrupted, so no button is left dead without a word. */
176
+ private async closeOpen(work: OpenWork, reason: StopReason): Promise<void> {
177
+ const edits: Promise<unknown>[] = [];
178
+ for (const a of work.approvals) {
179
+ edits.push(this.rest.editMessage(a.channelId, a.messageId, cancelledApprovalText(reason), approvalButtons(a.ticketId, true)).catch(() => undefined));
180
+ edits.push(this.rest.removeOwnReaction(a.userChannelId, a.userMessageId, REACTION.waiting).catch(() => undefined));
181
+ }
182
+ for (const t of work.tasks) {
183
+ const text = interruptedTaskText(t.specialist, reason);
184
+ edits.push((t.placeholderId ? this.rest.editMessage(t.channelId, t.placeholderId, text) : this.rest.createMessage(t.channelId, { content: text, replyTo: t.messageId })).catch(() => undefined));
185
+ edits.push(
186
+ this.rest
187
+ .removeOwnReaction(t.channelId, t.messageId, REACTION.running)
188
+ .then(() => this.rest.addReaction(t.channelId, t.messageId, REACTION.failed))
189
+ .catch(() => undefined),
190
+ );
191
+ }
192
+ await Promise.race([Promise.all(edits), new Promise((resolve) => setTimeout(resolve, SHUTDOWN_BUDGET_MS).unref())]);
193
+ }
194
+
195
+ /** Called from Pi's session_shutdown, which Pi awaits on SIGTERM before it exits. */
196
+ async shutdown(): Promise<void> {
197
+ if (this.stopping) return;
198
+ this.stopping = true;
199
+ this.gateway?.stop();
200
+ const work = this.openWork();
201
+ for (const task of this.active.values()) task.abort?.abort();
202
+ if (work.tasks.length > 0 || work.approvals.length > 0) log(`shutdown: cancelling ${work.approvals.length} approval(s), interrupting ${work.tasks.length} task(s)`);
203
+ await this.closeOpen(work, "restart");
204
+ this.approvals.close();
205
+ writeOpenWork({ since: this.since, tasks: [], approvals: [] });
206
+ }
207
+
149
208
  async start(): Promise<void> {
150
209
  const me = await this.rest.currentUser();
151
210
  this.botUserId = me.id;
211
+ const leftover = readOpenWork();
212
+ if (leftover) {
213
+ log(`closing what the previous process left open: ${leftover.approvals.length} approval(s), ${leftover.tasks.length} task(s)`);
214
+ await this.closeOpen(leftover, "crash");
215
+ writeOpenWork({ since: this.since, tasks: [], approvals: [] });
216
+ }
152
217
  await this.approvals.listen();
153
218
  const gateway = new GatewayClient({
154
219
  token: this.token,
@@ -159,6 +224,7 @@ class Bridge {
159
224
  },
160
225
  });
161
226
  gateway.start();
227
+ this.gateway = gateway;
162
228
  log(`connected as ${me.username} (${me.id}); channels ${Object.keys(this.config.channels).join(", ")}; specialists ${this.specialists().names.join(", ")}`);
163
229
  }
164
230
 
@@ -266,12 +332,16 @@ class Bridge {
266
332
 
267
333
  private async runTask(agent: AgentConfig, taskText: string, task: ActiveTask): Promise<void> {
268
334
  task.startedAt = Date.now();
335
+ task.abort = new AbortController();
269
336
  this.active.set(task.specialist, task);
337
+ this.saveOpenWork();
270
338
  await this.rest.addReaction(task.channelId, task.messageId, REACTION.running);
271
339
  const placeholder = await this.rest
272
340
  .createMessage(task.channelId, { content: `${REACTION.running} **${task.specialist}** is working on your task, please wait…`, replyTo: task.messageId })
273
341
  .then((m) => m.id)
274
342
  .catch(() => undefined);
343
+ task.placeholder = placeholder;
344
+ this.saveOpenWork();
275
345
  // Discord clears the typing indicator after about ten seconds; refresh it while the task runs.
276
346
  await this.rest.triggerTyping(task.channelId).catch(() => undefined);
277
347
  const typing = setInterval(() => void this.rest.triggerTyping(task.channelId).catch(() => undefined), 8000);
@@ -308,6 +378,7 @@ class Bridge {
308
378
  env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
309
379
  extraSystemPrompt: CHANNEL_RULES,
310
380
  session,
381
+ signal: task.abort.signal,
311
382
  onUpdate: (partial) => {
312
383
  const progress = describeProgress(partial, seen);
313
384
  seen = progress.seen;
@@ -327,9 +398,14 @@ class Bridge {
327
398
  output = err instanceof Error ? err.message : String(err);
328
399
  } finally {
329
400
  clearInterval(typing);
330
- this.active.delete(task.specialist);
401
+ if (!this.stopping) {
402
+ this.active.delete(task.specialist);
403
+ this.saveOpenWork();
404
+ }
331
405
  if (images) await fs.rm(images.dir, { recursive: true, force: true }).catch(() => undefined);
332
406
  }
407
+ // During shutdown the interrupted message was already written; an aborted run has nothing else to say.
408
+ if (this.stopping) return;
333
409
  await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
334
410
  await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
335
411
  const answers = await this.deliver(task, placeholder, `${failed ? "❌" : "βœ…"} **${task.specialist}**\n${output}`, attachment);
@@ -373,12 +449,14 @@ class Bridge {
373
449
  const content = `πŸ”’ **${short}** wants to run ${code(ticket.args.join(" "))} for <@${task.userId}>. ${owners}: approve or deny within ${this.config.approvalTimeoutMinutes} min.`;
374
450
  const posted = await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, components: approvalButtons(ticket.id) });
375
451
  this.approvalMessages.set(ticket.id, { channelId: posted.channel_id, messageId: posted.id, userMessage: task });
452
+ this.saveOpenWork();
376
453
  }
377
454
 
378
455
  private async expireApproval(ticket: Ticket): Promise<void> {
379
456
  const posted = this.approvalMessages.get(ticket.id);
380
457
  if (!posted) return;
381
458
  this.approvalMessages.delete(ticket.id);
459
+ this.saveOpenWork();
382
460
  await this.rest.editMessage(posted.channelId, posted.messageId, `βŒ› No decision within ${this.config.approvalTimeoutMinutes} min; ${code(ticket.args.join(" "))} was not run.`).catch(() => undefined);
383
461
  await this.rest.removeOwnReaction(posted.userMessage.channelId, posted.userMessage.messageId, REACTION.waiting).catch(() => undefined);
384
462
  }
@@ -401,6 +479,7 @@ class Bridge {
401
479
  }
402
480
  const posted = this.approvalMessages.get(ticket.id);
403
481
  this.approvalMessages.delete(ticket.id);
482
+ this.saveOpenWork();
404
483
  const verdict = decision === "approve" ? `βœ… Approved by <@${userId}>` : `β›” Denied by <@${userId}>`;
405
484
  await this.rest.updateInteractionMessage(interaction.id, interaction.token, `${verdict}: ${code(ticket.args.join(" "))}`);
406
485
  if (posted) await this.rest.removeOwnReaction(posted.userMessage.channelId, posted.userMessage.messageId, REACTION.waiting).catch(() => undefined);
@@ -414,6 +493,10 @@ class Bridge {
414
493
  */
415
494
  export default function astroDiscord(pi: ExtensionAPI): void {
416
495
  if (process.env[ACTIVATION_ENV] !== "1" || process.env[AGENT_ENV]) return;
496
+ let bridge: Bridge | undefined;
497
+ pi.on("session_shutdown", async () => {
498
+ await bridge?.shutdown();
499
+ });
417
500
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
418
501
  const loaded = loadConfig();
419
502
  if (!loaded.config) {
@@ -428,7 +511,8 @@ export default function astroDiscord(pi: ExtensionAPI): void {
428
511
  return;
429
512
  }
430
513
  try {
431
- await new Bridge(ctx, loaded.config, token).start();
514
+ bridge = new Bridge(ctx, loaded.config, token);
515
+ await bridge.start();
432
516
  } catch (err) {
433
517
  log(`start failed: ${err instanceof Error ? err.message : String(err)}`);
434
518
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The LaunchAgent's run.sh. Pi's RPC mode exits when stdin closes, so stdin is
3
+ * a FIFO opened read-write: it never reaches end-of-file. `exec` makes Pi the
4
+ * job's own process, so launchd's stop signal reaches Pi, whose shutdown lets
5
+ * the bridge cancel open approvals in Discord before it exits.
6
+ */
7
+ export function runScriptText(pi: string): string {
8
+ return [
9
+ "#!/bin/sh",
10
+ "# Headless Pi host for the Discord bridge. Pi's RPC mode exits when stdin closes; a FIFO opened read-write never closes.",
11
+ "# exec makes Pi the job's process, so launchd's stop signal reaches it and the bridge can close open approvals first.",
12
+ 'export PATH="/opt/homebrew/bin:$HOME/.local/bin:/usr/bin:/bin"',
13
+ "export ASTRO_DISCORD=1",
14
+ 'cd "$HOME"',
15
+ 'fifo="$HOME/.config/astro-discord/stdin.fifo"',
16
+ '[ -p "$fifo" ] || mkfifo -m 600 "$fifo"',
17
+ `exec ${pi} --mode rpc --no-session <> "$fifo"`,
18
+ "",
19
+ ].join("\n");
20
+ }
@@ -0,0 +1,71 @@
1
+ import { spawn } from "node:child_process";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+ import { runScriptText } from "./launcher.ts";
7
+ import { busyPath, cancelledApprovalText, interruptedTaskText, readOpenWork, writeOpenWork } from "./open-work.ts";
8
+
9
+ describe("open work record", () => {
10
+ let dir: string;
11
+ beforeEach(() => {
12
+ dir = mkdtempSync(join(tmpdir(), "astro-open-"));
13
+ process.env.ASTRO_DISCORD_DIR = dir;
14
+ });
15
+ afterEach(() => {
16
+ delete process.env.ASTRO_DISCORD_DIR;
17
+ rmSync(dir, { recursive: true, force: true });
18
+ });
19
+
20
+ it("exists only while something is open", () => {
21
+ const work = { since: "t", tasks: [{ specialist: "arcane", channelId: "1", messageId: "2", placeholderId: "3" }], approvals: [] };
22
+ writeOpenWork(work);
23
+ expect(readOpenWork()).toEqual(work);
24
+ writeOpenWork({ since: "t", tasks: [], approvals: [] });
25
+ expect(existsSync(busyPath())).toBe(false);
26
+ expect(readOpenWork()).toBeNull();
27
+ writeFileSync(busyPath(), "not json");
28
+ expect(readOpenWork()).toBeNull();
29
+ });
30
+
31
+ it("says why an approval was cancelled and a task interrupted", () => {
32
+ expect(cancelledApprovalText("restart")).toMatch(/bridge restarted before a decision, so nothing was run/);
33
+ expect(cancelledApprovalText("crash")).toMatch(/stopped unexpectedly/);
34
+ expect(interruptedTaskText("arcane", "restart")).toMatch(/^❌ \*\*arcane\*\* was interrupted: the bridge restarted\./);
35
+ });
36
+ });
37
+
38
+ describe("launcher", () => {
39
+ let home: string;
40
+ beforeEach(() => {
41
+ home = mkdtempSync(join(tmpdir(), "astro-launch-"));
42
+ mkdirSync(join(home, ".config", "astro-discord"), { recursive: true });
43
+ });
44
+ afterEach(() => rmSync(home, { recursive: true, force: true }));
45
+
46
+ it("keeps stdin open and hands the stop signal to Pi", async () => {
47
+ const out = join(home, "events");
48
+ const fakePi = join(home, "fake-pi.mjs");
49
+ writeFileSync(
50
+ fakePi,
51
+ [
52
+ "#!/usr/bin/env node",
53
+ `import { appendFileSync } from "node:fs";`,
54
+ `const log = (e) => appendFileSync(${JSON.stringify(out)}, e + "\\n");`,
55
+ `log("args " + process.argv.slice(2).join(" "));`,
56
+ `process.stdin.on("end", () => log("eof"));`,
57
+ `process.stdin.resume();`,
58
+ `process.on("SIGTERM", () => { log("term"); process.exit(0); });`,
59
+ ].join("\n"),
60
+ );
61
+ chmodSync(fakePi, 0o755);
62
+ const script = join(home, "run.sh");
63
+ writeFileSync(script, runScriptText(`${process.execPath} ${fakePi}`), { mode: 0o700 });
64
+ const child = spawn("/bin/sh", [script], { env: { HOME: home, PATH: process.env.PATH ?? "" }, stdio: "ignore" });
65
+ await new Promise((resolve) => setTimeout(resolve, 1500));
66
+ child.kill("SIGTERM");
67
+ const code = await new Promise<number | null>((resolve) => child.once("exit", resolve));
68
+ expect(code).toBe(0);
69
+ expect(readFileSync(out, "utf-8").trim().split("\n")).toEqual(["args --mode rpc --no-session", "term"]);
70
+ }, 15_000);
71
+ });
@@ -0,0 +1,73 @@
1
+ import { readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { configDir } from "./config.ts";
4
+
5
+ /** A running task as far as Discord shows it: the user's message and the bot's "working" reply. */
6
+ export interface OpenTask {
7
+ specialist: string;
8
+ channelId: string;
9
+ messageId: string;
10
+ placeholderId?: string;
11
+ }
12
+
13
+ /** An approval request still waiting for a button. */
14
+ export interface OpenApproval {
15
+ ticketId: string;
16
+ channelId: string;
17
+ messageId: string;
18
+ /** The user's message that carries the πŸ”’ reaction. */
19
+ userChannelId: string;
20
+ userMessageId: string;
21
+ }
22
+
23
+ /**
24
+ * What the bridge has open in Discord. Written to `busy.json` whenever it
25
+ * changes and removed when nothing is open, so a restart can wait for it and a
26
+ * new process can close what a crashed one left behind.
27
+ */
28
+ export interface OpenWork {
29
+ since: string;
30
+ tasks: OpenTask[];
31
+ approvals: OpenApproval[];
32
+ }
33
+
34
+ export function busyPath(): string {
35
+ return join(configDir(), "busy.json");
36
+ }
37
+
38
+ export function writeOpenWork(work: OpenWork): void {
39
+ const path = busyPath();
40
+ if (work.tasks.length === 0 && work.approvals.length === 0) {
41
+ rmSync(path, { force: true });
42
+ return;
43
+ }
44
+ const tmp = `${path}.tmp`;
45
+ writeFileSync(tmp, `${JSON.stringify(work, null, 2)}\n`, { mode: 0o600 });
46
+ renameSync(tmp, path);
47
+ }
48
+
49
+ /** The record a previous process left, or null when it closed everything. */
50
+ export function readOpenWork(): OpenWork | null {
51
+ let raw: unknown;
52
+ try {
53
+ raw = JSON.parse(readFileSync(busyPath(), "utf-8"));
54
+ } catch {
55
+ return null;
56
+ }
57
+ const r = raw as Partial<OpenWork> | null;
58
+ if (!r || !Array.isArray(r.tasks) || !Array.isArray(r.approvals)) return null;
59
+ return { since: String(r.since ?? ""), tasks: r.tasks, approvals: r.approvals };
60
+ }
61
+
62
+ export type StopReason = "restart" | "crash";
63
+
64
+ export function cancelledApprovalText(reason: StopReason): string {
65
+ return reason === "restart"
66
+ ? "πŸ”Œ Cancelled: the bridge restarted before a decision, so nothing was run. Ask again."
67
+ : "πŸ”Œ Cancelled: the bridge stopped unexpectedly before a decision, so nothing was run. Ask again.";
68
+ }
69
+
70
+ export function interruptedTaskText(specialist: string, reason: StopReason): string {
71
+ const why = reason === "restart" ? "the bridge restarted" : "the bridge stopped unexpectedly";
72
+ return `❌ **${specialist}** was interrupted: ${why}. Work it had already started may have finished; ask it to check the current state.`;
73
+ }
@@ -14,6 +14,7 @@ import { join } from "node:path";
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import { Writable } from "node:stream";
16
16
  import { type ChannelSettings, configDir, configPath, tokenPath, validateConfig } from "./config.ts";
17
+ import { runScriptText } from "./launcher.ts";
17
18
  import { DiscordRest } from "./rest.ts";
18
19
 
19
20
  const LABEL = "com.astrofoundry.astro-discord";
@@ -180,7 +181,7 @@ async function main(): Promise<void> {
180
181
  const logFile = join(homedir(), "Library", "Logs", "astro-discord.log");
181
182
  writeFileSync(
182
183
  runScript,
183
- `#!/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`,
184
+ runScriptText(pi),
184
185
  { mode: 0o700 },
185
186
  );
186
187
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
@@ -13,8 +13,15 @@ describe("approvals", () => {
13
13
  expect(isRisky("identity", ["zitadel", "DELETE", "/v2/users/1"])).toBe(true);
14
14
  expect(isRisky("identity", ["dmz", "deploy-config"])).toBe(true);
15
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);
16
+ expect(isRisky("arcane", ["projects", "redeploy", "mealie"])).toBe(false);
17
+ expect(isRisky("arcane", ["projects", "upgrade", "media", "sonarr", "--yes"])).toBe(false);
18
+ expect(isRisky("arcane", ["gitops", "sync", "arcane", "--yes"])).toBe(false);
19
+ expect(isRisky("arcane", ["projects", "upgrade", "--help"])).toBe(false);
20
+ expect(isRisky("arcane", ["projects", "down", "media"])).toBe(true);
21
+ expect(isRisky("arcane", ["--json", "projects", "delete", "media"])).toBe(true);
22
+ expect(isRisky("arcane", ["volumes", "remove", "mealie_mealie-pgdata"])).toBe(true);
23
+ expect(isRisky("arcane", ["system", "upgrade"])).toBe(true);
24
+ expect(isRisky("edge", ["vps", "reboot", "--confirm", "--help"])).toBe(false);
18
25
  expect(isRisky("proxmox", ["guests"])).toBe(false);
19
26
  expect(isRisky("proxmox", ["set", "107", "memory=768"])).toBe(true);
20
27
  expect(isRisky("proxmox", ["shutdown", "107", "--confirm"])).toBe(true);
@@ -20,12 +20,13 @@ export const APPROVAL_TOKEN_ENV = "ASTRO_APPROVAL_TOKEN";
20
20
  * the writes that do not need `--confirm` in a terminal session.
21
21
  */
22
22
  export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
23
+ // Destructive or hard to undo only: upgrades, redeploys, restarts, and syncs are routine and run on the caller's request.
23
24
  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/,
25
+ /^(projects|stacks)\s+(down|delete|remove|rm|destroy)\b/,
26
+ /^containers\s+(remove|rm|delete)\b/,
26
27
  /^(images|volumes|networks)\s+(prune|remove|rm|delete)\b/,
27
28
  /^system\s+(prune|upgrade)\b/,
28
- /^gitops\s+(sync|delete|update|import)\b/,
29
+ /^gitops\s+delete\b/,
29
30
  ],
30
31
  identity: [/^zitadel\s+(DELETE|POST|PUT|PATCH)\b/, /^dmz\s+(deploy-config|pomerium-restart|render|lego-renew)\b/],
31
32
  network: [/^unifi\s+policy-(create|update|delete)\b/],
@@ -37,8 +38,11 @@ export const RISKY: Readonly<Record<string, readonly RegExp[]>> = {
37
38
  };
38
39
 
39
40
  export function isRisky(service: string, args: readonly string[]): boolean {
41
+ // A help lookup prints usage and changes nothing, whatever command it names.
42
+ if (args.includes("--help") || args.includes("-h")) return false;
40
43
  if (args.includes("--confirm")) return true;
41
- const joined = args.join(" ");
44
+ // Flags placed before the command (`--json projects delete x`) must not hide it from the patterns.
45
+ const joined = args.filter((a) => !a.startsWith("-")).join(" ");
42
46
  return (RISKY[service] ?? []).some((pattern) => pattern.test(joined));
43
47
  }
44
48
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.28.1",
3
+ "version": "0.29.1",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -73,7 +73,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
73
73
 
74
74
  ## Chat bridges
75
75
 
76
- `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).
76
+ `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 (`--help`/`-h` never counts; flags are ignored when matching, so they cannot hide a command; Arcane lists only destructive actions since 2026-09-26, because a routine upgrade the caller asked for made them approve the same thing twice); keep `RISKY` in step with the wrappers when a new interrupting or destructive command appears (every `--confirm` command is covered by construction).
77
77
 
78
78
  ## Next
79
79
 
@@ -105,9 +105,9 @@ 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. 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.
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. When the bridge restarts, open approval requests are marked cancelled and running tasks interrupted in Discord; ask again.
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
- - 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.
110
+ - Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, service restarts, and on Arcane only destructive actions: `down`, deletes, prunes, image, volume, or network removal, `system upgrade`, `gitops delete`; help lookups never ask) 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.
113
113