@astrofoundry/pi-astro 0.28.0 → 0.29.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.
- package/extensions/astro-discord/index.ts +86 -2
- package/extensions/astro-discord/launcher.ts +20 -0
- package/extensions/astro-discord/lifecycle.test.ts +71 -0
- package/extensions/astro-discord/open-work.ts +73 -0
- package/extensions/astro-discord/setup.ts +2 -1
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +1 -1
- package/specialists/AGENTS.md +1 -1
- package/specialists/README.md +1 -1
- package/specialists/lib/image-updates.test.ts +6 -2
- package/specialists/lib/image-updates.ts +13 -5
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
184
|
+
runScriptText(pi),
|
|
184
185
|
{ mode: 0o700 },
|
|
185
186
|
);
|
|
186
187
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
package/package.json
CHANGED
package/skills/arcane/SKILL.md
CHANGED
|
@@ -36,7 +36,7 @@ The output is JSON with `counts`, `rows`, and `table`. Answer with the `table` f
|
|
|
36
36
|
- `Running` and `Latest` are release tags. `older build`: the running build carries no tag any more. `new build`: no release tag names the newer build. `17 (older build)`: the running build of that major series is no longer tagged.
|
|
37
37
|
- `Update`: `pull` means redeploying the stack with an image pull applies it; `edit tag` means changing the tag in the GitOps repository; `edit digest` means changing the pinned digest in the repository (`latestDigest` in the row holds it); `unknown` has its reason in the notes under the table.
|
|
38
38
|
- A note "major version change" or "newer major ... available" means the update crosses a major version. Read its release notes yourself before asking anything (next section).
|
|
39
|
-
-
|
|
39
|
+
- Private images (the registry refuses anonymous access, such as our own Grimoire builds) are left out of the table and the counts; the summary line counts them and `privateImages` lists them. Mention them only when the caller asks.
|
|
40
40
|
|
|
41
41
|
Apply updates only when asked, one stack at a time, through the operations or GitOps procedure above, and run `update-report` again afterwards to show the result.
|
|
42
42
|
|
package/specialists/AGENTS.md
CHANGED
|
@@ -54,7 +54,7 @@ Wrappers ship as TypeScript and run under Node 24 type stripping: erasable synta
|
|
|
54
54
|
- Every passthrough call writes a private per-call copy of `arcanecli.yml` with `jwt_token` set, because some `arcane-cli` commands validate the config file before reading `ARCANE_TOKEN`. The copy is removed after the call.
|
|
55
55
|
- Every wrapper answers `--help` without reading config, so agents can discover subcommands before anything else is set up.
|
|
56
56
|
- `main()` waits for stdout and stderr to flush before `process.exit`: on a pipe, exiting right after a write drops everything past the 64 KiB buffer, which cut large answers such as `projects list --all` until 2026-09-25 (`lib/flush.test.ts`).
|
|
57
|
-
- `arcane update-report [--all]` is `lib/image-updates.ts` over `lib/registry.ts`: a fresh `images updates check --all`, then `projects list --all` for the stack and service of each image reference, then the registries' tag lists and manifest digests (anonymous bearer tokens from each registry's challenge; digests by HEAD, which Docker Hub does not count as a pull; lscr.io is read from ghcr.io). A floating or major-series tag is resolved to the release tag whose digest matches the running and the latest build, searching the newest tags of the three commonest release shapes; a fixed tag is compared with the newest tag of the same shape; a digest pin with the newest release. Checks older than one minute before the run count as stale and show as unknown. Private images
|
|
57
|
+
- `arcane update-report [--all]` is `lib/image-updates.ts` over `lib/registry.ts`: a fresh `images updates check --all`, then `projects list --all` for the stack and service of each image reference, then the registries' tag lists and manifest digests (anonymous bearer tokens from each registry's challenge; digests by HEAD, which Docker Hub does not count as a pull; lscr.io is read from ghcr.io). A floating or major-series tag is resolved to the release tag whose digest matches the running and the latest build, searching the newest tags of the three commonest release shapes; a fixed tag is compared with the newest tag of the same shape; a digest pin with the newest release. Checks older than one minute before the run count as stale and show as unknown. Private images (anonymous access refused with 401 or 403) are excluded from rows, counts, and table, listed in `privateImages`, and counted in the summary line. The table is fixed-width text for a code block, because Discord renders no Markdown tables.
|
|
58
58
|
- `arcane release-notes <stack>[/<service>]` is `lib/releases.ts`: a single-image check and `resolveRef` give the running and latest versions, `HttpRegistry.labels` reads `org.opencontainers.image.source` from the latest image (index to linux/amd64 manifest to config blob), and the anonymous GitHub releases API (60 requests an hour, up to 3 pages of 100) gives the releases in between, oldest first. `condenseNotes` keeps each release under 12,000 characters by section priority; `capTotal` drops the oldest releases past 40,000 characters in total.
|
|
59
59
|
|
|
60
60
|
## Gate
|
package/specialists/README.md
CHANGED
|
@@ -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. 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
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`.
|
|
@@ -100,7 +100,7 @@ describe("resolveRef", () => {
|
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
it("falls back to the digest result for private images and marks bad checks unknown", async () => {
|
|
103
|
-
expect(await resolveRef("ghcr.io/astrofoundry/app:main", entry("A1", "A2"), registry, NOW)).toMatchObject({ status: "update", latest: "new build",
|
|
103
|
+
expect(await resolveRef("ghcr.io/astrofoundry/app:main", entry("A1", "A2"), registry, NOW)).toMatchObject({ status: "update", latest: "new build", private: true });
|
|
104
104
|
expect(await resolveRef("nginx:1.31.4-alpine", undefined, registry, NOW)).toMatchObject({ status: "unknown", note: "not in the fresh check" });
|
|
105
105
|
expect(await resolveRef("nginx:1.31.4-alpine", entry("N", "N", { checkTime: "2026-09-24T00:00:00Z" }), registry, NOW)).toMatchObject({ status: "unknown", note: expect.stringContaining("stale") });
|
|
106
106
|
expect(await resolveRef("nginx:1.31.4-alpine", entry("N", "N", { error: "timeout" }), registry, NOW)).toMatchObject({ status: "unknown", note: "check failed: timeout" });
|
|
@@ -120,17 +120,21 @@ describe("buildUpdateReport", () => {
|
|
|
120
120
|
{ name: "profiles", status: "running", updateInfo: { serviceUpdates: { profiles: { imageRef: "nginx:1.31.4-alpine" } } } },
|
|
121
121
|
{ name: "canary", status: "stopped", updateInfo: { serviceUpdates: { canary: { imageRef: "busybox:latest" } } } },
|
|
122
122
|
{ name: "empty", status: "running" },
|
|
123
|
+
{ name: "ours", status: "running", updateInfo: { serviceUpdates: { api: { imageRef: "ghcr.io/astrofoundry/app:main" }, worker: { imageRef: "ghcr.io/astrofoundry/app:main" } } } },
|
|
123
124
|
],
|
|
124
125
|
};
|
|
125
126
|
const check = {
|
|
126
127
|
"lscr.io/linuxserver/prowlarr:latest": entry("D1", "D2"),
|
|
127
128
|
"nginx:1.31.4-alpine": entry("N", "N"),
|
|
128
129
|
"busybox:latest": entry("B", "B"),
|
|
130
|
+
"ghcr.io/astrofoundry/app:main": entry("A1", "A2"),
|
|
129
131
|
};
|
|
130
132
|
const report = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: false });
|
|
131
133
|
expect(report.counts).toEqual({ update: 1, release: 0, rebuild: 0, unknown: 1, "up-to-date": 2 });
|
|
132
134
|
expect(report.rows.map((r) => `${r.stack}/${r.service}:${r.status}`)).toEqual(["media/prowlarr:update", "empty/empty:unknown"]);
|
|
133
|
-
expect(report.table).toMatch(/^Fresh check 2026-09-25T18:30:00Z: 4 services in
|
|
135
|
+
expect(report.table).toMatch(/^Fresh check 2026-09-25T18:30:00Z: 4 services in 5 stacks, 3 images\. 1 private images \(2 services\) excluded\./);
|
|
136
|
+
expect(report.privateImages).toEqual(["ghcr.io/astrofoundry/app:main"]);
|
|
137
|
+
expect(report.rows.some((r) => r.stack === "ours")).toBe(false);
|
|
134
138
|
expect(report.table).toContain("media/prowlarr prowlarr 2.0.5.5160-ls123 2.1.0.5190-ls124 pull");
|
|
135
139
|
expect(report.table).toContain("* empty: Arcane lists no images for this stack");
|
|
136
140
|
const all = await buildUpdateReport({ check, projects, registry, startedAt: NOW, all: true });
|
|
@@ -34,6 +34,8 @@ export interface UpdateReport {
|
|
|
34
34
|
checkedAt: string;
|
|
35
35
|
stacks: number;
|
|
36
36
|
images: number;
|
|
37
|
+
/** Private image references, excluded from `rows`, `counts`, and the table. */
|
|
38
|
+
privateImages: string[];
|
|
37
39
|
counts: Record<UpdateStatus, number>;
|
|
38
40
|
rows: UpdateRow[];
|
|
39
41
|
table: string;
|
|
@@ -150,6 +152,8 @@ interface Resolution {
|
|
|
150
152
|
action: string;
|
|
151
153
|
note: string;
|
|
152
154
|
latestDigest?: string;
|
|
155
|
+
/** The registry refused anonymous access: an image of our own, left out of the report. */
|
|
156
|
+
private?: boolean;
|
|
153
157
|
}
|
|
154
158
|
|
|
155
159
|
/** Resolves one image reference to running and latest versions from the fresh check and the registry's tags. */
|
|
@@ -175,7 +179,8 @@ export async function resolveRef(refText: string, entry: CheckEntry | undefined,
|
|
|
175
179
|
tags = (await registry.tags(ref)).map(parseTag).filter((t): t is TagVersion => t !== null);
|
|
176
180
|
} catch (err) {
|
|
177
181
|
const status = err instanceof RegistryError ? err.status : 0;
|
|
178
|
-
|
|
182
|
+
if (status === 401 || status === 403) return { ...digestOnly("private image, the registry needs credentials"), private: true };
|
|
183
|
+
return digestOnly(err instanceof Error ? err.message : String(err));
|
|
179
184
|
}
|
|
180
185
|
|
|
181
186
|
const digests = new Map<string, string | null>();
|
|
@@ -243,7 +248,7 @@ export async function resolveRef(refText: string, entry: CheckEntry | undefined,
|
|
|
243
248
|
const sameShape = tags.filter((t) => shapeKey(t) === shapeKey(r)).sort(byVersionDesc).slice(0, PER_SHAPE);
|
|
244
249
|
latest = (await findTag(entry.latestDigest, sameShape)) ?? latest;
|
|
245
250
|
}
|
|
246
|
-
return { kind, running, latest, status: "update", action: "pull", note: majorChange(running, latest) ? "major version change:
|
|
251
|
+
return { kind, running, latest, status: "update", action: "pull", note: majorChange(running, latest) ? "major version change: read its release notes before pulling" : "" };
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
async function mapLimit<T, R>(items: readonly T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
@@ -347,7 +352,9 @@ export async function buildUpdateReport(input: { check: unknown; projects: unkno
|
|
|
347
352
|
await mapLimit(refs, CONCURRENCY, async (ref) => {
|
|
348
353
|
resolved.set(ref, await resolveRef(ref, entries[ref], registry, input.startedAt));
|
|
349
354
|
});
|
|
350
|
-
const
|
|
355
|
+
const privateImages = refs.filter((ref) => resolved.get(ref)?.private === true);
|
|
356
|
+
const listed = services.filter((s) => s.ref === null || !privateImages.includes(s.ref));
|
|
357
|
+
const rows: UpdateRow[] = listed.map((s) => {
|
|
351
358
|
if (s.ref === null) return { stack: s.stack, service: s.service, ref: "", image: "?", kind: "floating", running: "?", latest: "?", status: "unknown", action: "", note: "Arcane lists no images for this stack" };
|
|
352
359
|
const r = resolved.get(s.ref) as Resolution & { kind: RefKind };
|
|
353
360
|
const note = [r.note, s.stopped ? "stack is stopped" : ""].filter(Boolean).join("; ");
|
|
@@ -359,9 +366,10 @@ export async function buildUpdateReport(input: { check: unknown; projects: unkno
|
|
|
359
366
|
const shown = input.all ? rows : rows.filter((r) => r.status !== "up-to-date" || r.note.includes("newer major"));
|
|
360
367
|
const checkedAt = new Date(input.startedAt).toISOString().replace(/\.\d+Z$/, "Z");
|
|
361
368
|
const stacks = new Set(services.map((s) => s.stack)).size;
|
|
369
|
+
const excluded = privateImages.length === 0 ? "" : ` ${privateImages.length} private images (${services.length - listed.length} services) excluded.`;
|
|
362
370
|
const summary =
|
|
363
|
-
`Fresh check ${checkedAt}: ${
|
|
371
|
+
`Fresh check ${checkedAt}: ${listed.length} services in ${stacks} stacks, ${refs.length - privateImages.length} images.${excluded} ` +
|
|
364
372
|
`${counts.update} new builds, ${counts.release} new releases, ${counts.rebuild} rebuilds, ${counts.unknown} unknown, ${counts["up-to-date"]} up to date` +
|
|
365
373
|
(input.all ? "." : " (not listed unless a newer major exists).");
|
|
366
|
-
return { checkedAt, stacks, images: refs.length, counts, rows: shown, table: renderTable(shown, summary) };
|
|
374
|
+
return { checkedAt, stacks, images: refs.length - privateImages.length, privateImages, counts, rows: shown, table: renderTable(shown, summary) };
|
|
367
375
|
}
|