@astrofoundry/pi-astro 0.23.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,7 +57,7 @@ pi # launch; confirm [Extensions] lists astro-subagents, grimoire
57
57
  ## What's inside
58
58
 
59
59
  **Tools** (LLM-callable):
60
- - `subagent` - delegate a task to any agent, run several in parallel, or chain them (`{previous}` carries the prior output). Each agent runs in its own `pi` process with its own tool allowlist and skills; `/run <agent> -- <task>` does the same from the prompt, `/agents` lists what is available
60
+ - `subagent` - delegate a task to any agent, run several in parallel, or chain them (`{previous}` carries the prior output). Each agent runs in its own `pi` process with its own tool allowlist and skills; `/run <agent> -- <task>` does the same from the prompt and saves the agent's session, `/run <agent> --continue -- <task>` resumes it, `/agents` lists what is available
61
61
  - `grimoire` - search indexed technical documentation via the grimoire CLI
62
62
  - `edit` - replaces pi's built-in with batch multi-file edits and Codex-style patch mode, preflight validation, atomic rollback
63
63
  - `gemini_image` - generate or edit images via Google Gemini native models and Imagen 4; cost-estimated confirmation before every call
@@ -0,0 +1,65 @@
1
+ import { createHash } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import type { QuotedMessage } from "./quote.ts";
6
+
7
+ /** An image on a Discord message, by its CDN url. */
8
+ export interface ImageAttachment {
9
+ filename: string;
10
+ url: string;
11
+ contentType: string;
12
+ size: number;
13
+ }
14
+
15
+ export const MAX_IMAGES = 4;
16
+ export const MAX_IMAGE_BYTES = 8 * 1024 * 1024;
17
+
18
+ /** Image attachments of the message and of the messages it quotes, oldest quote first, within the count and size limits. */
19
+ export function imageAttachments(messages: readonly QuotedMessage[]): ImageAttachment[] {
20
+ const images: ImageAttachment[] = [];
21
+ for (const message of messages) {
22
+ for (const a of message.attachments ?? []) {
23
+ if (!a.url || !a.content_type?.startsWith("image/") || (a.size ?? 0) > MAX_IMAGE_BYTES) continue;
24
+ images.push({ filename: a.filename, url: a.url, contentType: a.content_type, size: a.size ?? 0 });
25
+ }
26
+ }
27
+ return images.slice(0, MAX_IMAGES);
28
+ }
29
+
30
+ export interface DownloadedImages {
31
+ dir: string;
32
+ files: string[];
33
+ failed: string[];
34
+ }
35
+
36
+ /** Saves the images to a private temp directory, named by an index and a hash so filenames from Discord never reach the file system. */
37
+ export async function downloadImages(images: readonly ImageAttachment[], fetchImpl: typeof fetch = fetch): Promise<DownloadedImages> {
38
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "astro-discord-images-"));
39
+ const files: string[] = [];
40
+ const failed: string[] = [];
41
+ for (const [index, image] of images.entries()) {
42
+ try {
43
+ const response = await fetchImpl(image.url);
44
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
45
+ const bytes = Buffer.from(await response.arrayBuffer());
46
+ if (bytes.length > MAX_IMAGE_BYTES) throw new Error("larger than the limit");
47
+ const name = `${index + 1}-${createHash("sha256").update(image.url).digest("hex").slice(0, 12)}${path.extname(image.filename).toLowerCase().replace(/[^.a-z0-9]/g, "")}`;
48
+ const file = path.join(dir, name);
49
+ await fs.writeFile(file, bytes, { mode: 0o600 });
50
+ files.push(file);
51
+ } catch {
52
+ failed.push(image.filename);
53
+ }
54
+ }
55
+ return { dir, files, failed };
56
+ }
57
+
58
+ /** Task text telling the specialist which images travel with it and which could not be fetched. */
59
+ export function describeImages(task: string, images: readonly ImageAttachment[], failed: readonly string[]): string {
60
+ const lines: string[] = [];
61
+ const fetched = images.filter((i) => !failed.includes(i.filename));
62
+ if (fetched.length > 0) lines.push(`Images attached to the message, included with this task: ${fetched.map((i) => i.filename).join(", ")}.`);
63
+ if (failed.length > 0) lines.push(`Images that could not be fetched: ${failed.join(", ")}.`);
64
+ return lines.length === 0 ? task : `${task}\n\n${lines.join("\n")}`;
65
+ }
@@ -1,7 +1,7 @@
1
1
  import type { Trigger } from "./config.ts";
2
2
 
3
3
  export type Command =
4
- | { kind: "run"; specialist: string; task: string }
4
+ | { kind: "run"; specialist: string; task: string; fresh: boolean }
5
5
  | { kind: "help" }
6
6
  | { kind: "status" }
7
7
  | { kind: "config"; args: string[] }
@@ -34,15 +34,19 @@ export function parseCommand(content: string, context: CommandContext): Command
34
34
  if (word === "status") return { kind: "status" };
35
35
  if (word === "config") return { kind: "config", args: rest };
36
36
  const named = word.replace(/^astro\./, "");
37
- if (context.specialists.includes(named)) {
38
- const task = text.slice(first.length).trim();
39
- return task.length > 0 ? { kind: "run", specialist: named, task } : { kind: "help" };
40
- }
41
- if (context.specialists.length === 1) return { kind: "run", specialist: context.specialists[0], task: text };
37
+ if (context.specialists.includes(named)) return runCommand(named, text.slice(first.length).trim());
38
+ if (context.specialists.length === 1) return runCommand(context.specialists[0], text);
42
39
  if (addressed) return { kind: "needs-prefix", specialists: [...context.specialists] };
43
40
  return { kind: "none" };
44
41
  }
45
42
 
43
+ /** A task; a leading `new` asks for a fresh conversation instead of continuing the recent one. */
44
+ function runCommand(specialist: string, text: string): Command {
45
+ const fresh = /^new(\s|$)/i.test(text);
46
+ const task = fresh ? text.replace(/^new\s*/i, "").trim() : text;
47
+ return task.length > 0 ? { kind: "run", specialist, task, fresh } : { kind: "help" };
48
+ }
49
+
46
50
  /** Prompt paragraph appended for tasks that arrive from a chat channel. */
47
51
  export const CHANNEL_RULES = `## Channel rules
48
52
 
@@ -54,6 +58,7 @@ export function helpText(allowed: readonly string[], present: readonly string[],
54
58
  const lines = [how];
55
59
  if (present.length > 1) lines.push(...allowed.map((name) => `• \`${name}\``));
56
60
  lines.push("Reactions: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed.");
61
+ lines.push("A reply to my answer, or another task for the same specialist within 30 minutes, continues that conversation; start the task with `new` for a fresh one.");
57
62
  const commands = ["`help` this message", "`status` running tasks"];
58
63
  if (canConfig) commands.push("`config show`", "`config channel <#channel> <always|mention> <*|name,name>`", "`config access <specialist> add|remove <@user>`");
59
64
  lines.push(`Commands: ${commands.join(", ")}.`);
@@ -0,0 +1,115 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { configDir } from "./config.ts";
5
+
6
+ /** A later task for the same specialist in the same channel continues the conversation within this window. */
7
+ export const CONVERSATION_IDLE_MS = 30 * 60 * 1000;
8
+ /** Conversations quiet for longer are forgotten; their session files are pruned on the same schedule. */
9
+ export const CONVERSATION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
10
+ const REMEMBERED_ANSWERS = 50;
11
+
12
+ export interface Conversation {
13
+ /** Pi session id of the child that holds this conversation. */
14
+ id: string;
15
+ /** When the conversation started or the specialist last answered. */
16
+ lastAt: number;
17
+ /** The bot's answer messages; a reply to any of them continues here. */
18
+ messageIds: string[];
19
+ }
20
+
21
+ export interface ConversationState {
22
+ /** One live conversation per `<channelId>:<specialist>`. */
23
+ conversations: Record<string, Conversation>;
24
+ }
25
+
26
+ export function conversationKey(channelId: string, specialist: string): string {
27
+ return `${channelId}:${specialist}`;
28
+ }
29
+
30
+ export function statePath(): string {
31
+ return join(configDir(), "conversations.json");
32
+ }
33
+
34
+ export function sessionsRoot(): string {
35
+ return join(configDir(), "sessions");
36
+ }
37
+
38
+ function isConversation(value: unknown): value is Conversation {
39
+ if (typeof value !== "object" || value === null) return false;
40
+ const c = value as Record<string, unknown>;
41
+ return typeof c.id === "string" && typeof c.lastAt === "number" && Array.isArray(c.messageIds) && c.messageIds.every((m) => typeof m === "string");
42
+ }
43
+
44
+ /** Reads the saved state; an unreadable or malformed file starts empty. */
45
+ export function loadState(): ConversationState {
46
+ let raw: unknown;
47
+ try {
48
+ raw = JSON.parse(readFileSync(statePath(), "utf-8"));
49
+ } catch {
50
+ return { conversations: {} };
51
+ }
52
+ const conversations: Record<string, Conversation> = {};
53
+ const source = typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>).conversations : undefined;
54
+ if (typeof source === "object" && source !== null) {
55
+ for (const [key, value] of Object.entries(source as Record<string, unknown>)) if (isConversation(value)) conversations[key] = value;
56
+ }
57
+ return { conversations };
58
+ }
59
+
60
+ export function saveState(state: ConversationState): void {
61
+ const path = statePath();
62
+ const tmp = `${path}.tmp`;
63
+ writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
64
+ renameSync(tmp, path);
65
+ }
66
+
67
+ /** A session id Pi accepts: specialist, UTC timestamp, random suffix. */
68
+ export function newConversationId(specialist: string, now: number, random: () => string = () => randomBytes(3).toString("hex")): string {
69
+ const stamp = new Date(now).toISOString().replace(/\D/g, "").slice(0, 14);
70
+ return `${specialist}-${stamp}-${random()}`;
71
+ }
72
+
73
+ export interface ResolveInput {
74
+ channelId: string;
75
+ specialist: string;
76
+ /** Id of the message this one replies to, if any. */
77
+ repliedTo?: string;
78
+ /** The user asked for a fresh conversation. */
79
+ fresh: boolean;
80
+ now: number;
81
+ }
82
+
83
+ export interface ResolvedConversation {
84
+ key: string;
85
+ id: string;
86
+ resumed: boolean;
87
+ }
88
+
89
+ /** The conversation a task belongs to: the one it replies to, else the recent one, else a new one. */
90
+ export function resolveConversation(state: ConversationState, input: ResolveInput, newId: () => string): ResolvedConversation {
91
+ const key = conversationKey(input.channelId, input.specialist);
92
+ const current = state.conversations[key];
93
+ if (!input.fresh && current) {
94
+ const repliesHere = input.repliedTo !== undefined && current.messageIds.includes(input.repliedTo);
95
+ if (repliesHere || input.now - current.lastAt < CONVERSATION_IDLE_MS) return { key, id: current.id, resumed: true };
96
+ }
97
+ return { key, id: newId(), resumed: false };
98
+ }
99
+
100
+ export function startConversation(state: ConversationState, key: string, id: string, now: number): ConversationState {
101
+ return { conversations: { ...state.conversations, [key]: { id, lastAt: now, messageIds: [] } } };
102
+ }
103
+
104
+ /** Remembers the bot's answer messages and refreshes the idle window. */
105
+ export function recordAnswer(state: ConversationState, key: string, messageIds: string[], now: number): ConversationState {
106
+ const current = state.conversations[key];
107
+ if (!current) return state;
108
+ const merged = [...current.messageIds, ...messageIds].slice(-REMEMBERED_ANSWERS);
109
+ return { conversations: { ...state.conversations, [key]: { ...current, lastAt: now, messageIds: merged } } };
110
+ }
111
+
112
+ export function forgetStale(state: ConversationState, now: number): ConversationState {
113
+ const conversations = Object.fromEntries(Object.entries(state.conversations).filter(([, c]) => now - c.lastAt < CONVERSATION_MAX_AGE_MS));
114
+ return { conversations };
115
+ }
@@ -11,6 +11,9 @@ import { INTENTS, initialState, reduce } from "./gateway.ts";
11
11
  import type { RunResult } from "../astro-subagents/child.ts";
12
12
  import { failureReply } from "./index.ts";
13
13
  import { DiscordRest } from "./rest.ts";
14
+ import { CONVERSATION_IDLE_MS, CONVERSATION_MAX_AGE_MS, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, startConversation } from "./conversations.ts";
15
+ import { quoteText, withQuotes } from "./quote.ts";
16
+ import { MAX_IMAGE_BYTES, describeImages, downloadImages, imageAttachments } from "./attachments.ts";
14
17
 
15
18
  const ID = "123456789012345678";
16
19
  const OTHER = "223456789012345678";
@@ -89,20 +92,24 @@ describe("commands", () => {
89
92
  const many = { botUserId: ID, specialists: ["dns", "edge"], trigger: "always" as const, repliedToBot: false };
90
93
  const one = { ...many, specialists: ["arcane"] };
91
94
  it("parses prefixed tasks, mentions, help, status, and config", () => {
92
- expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
93
- expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door" });
94
- expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones" });
95
+ expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
96
+ expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door", fresh: false });
97
+ expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones", fresh: false });
95
98
  expect(parseCommand("help", many)).toEqual({ kind: "help" });
96
99
  expect(parseCommand("Status", many)).toEqual({ kind: "status" });
97
100
  expect(parseCommand("config channel <#1> mention dns", many)).toEqual({ kind: "config", args: ["channel", "<#1>", "mention", "dns"] });
98
101
  expect(parseCommand("dns", many)).toEqual({ kind: "help" });
102
+ expect(parseCommand("dns new list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: true });
103
+ expect(parseCommand("New what changed?", one)).toEqual({ kind: "run", specialist: "arcane", task: "what changed?", fresh: true });
104
+ expect(parseCommand("newer images?", one)).toEqual({ kind: "run", specialist: "arcane", task: "newer images?", fresh: false });
105
+ expect(parseCommand("dns new", many)).toEqual({ kind: "help" });
99
106
  expect(parseCommand("hello everyone", many)).toEqual({ kind: "none" });
100
107
  expect(parseCommand("", many)).toEqual({ kind: "none" });
101
108
  });
102
109
 
103
110
  it("needs no prefix with one specialist and asks for one when addressed with several", () => {
104
- expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
105
- expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects" });
111
+ expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
112
+ expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
106
113
  expect(parseCommand(`<@${ID}> list the zones`, many)).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
107
114
  expect(parseCommand("list the zones", { ...many, repliedToBot: true })).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
108
115
  expect(parseCommand(`<@${ID}>`, many)).toEqual({ kind: "help" });
@@ -111,10 +118,10 @@ describe("commands", () => {
111
118
  it("stays silent in mention channels unless addressed", () => {
112
119
  const mention = { ...many, trigger: "mention" as const };
113
120
  expect(parseCommand("dns list the zones", mention)).toEqual({ kind: "none" });
114
- expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
115
- expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones" });
121
+ expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
122
+ expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
116
123
  expect(parseCommand("anything", { ...one, trigger: "mention" })).toEqual({ kind: "none" });
117
- expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything" });
124
+ expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything", fresh: false });
118
125
  });
119
126
 
120
127
  it("writes help and prefix texts", () => {
@@ -236,3 +243,81 @@ describe("approval server", () => {
236
243
  server.close();
237
244
  });
238
245
  });
246
+
247
+ describe("conversations", () => {
248
+ const now = 1_800_000_000_000;
249
+ it("continues on a reply or within the idle window, otherwise starts fresh", () => {
250
+ let state = startConversation({ conversations: {} }, "1:dns", "dns-a", now);
251
+ state = recordAnswer(state, "1:dns", ["m1"], now);
252
+ const input = { channelId: "1", specialist: "dns", fresh: false, now: now + 60_000 };
253
+ expect(resolveConversation(state, input, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
254
+ expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1 }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
255
+ expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1, repliedTo: "m1" }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
256
+ expect(resolveConversation(state, { ...input, fresh: true }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
257
+ expect(resolveConversation(state, { ...input, channelId: "2" }, () => "dns-b").resumed).toBe(false);
258
+ expect(recordAnswer(state, "missing", ["x"], now)).toBe(state);
259
+ expect(forgetStale(state, now + CONVERSATION_MAX_AGE_MS + 1).conversations).toEqual({});
260
+ expect(forgetStale(state, now + 1).conversations["1:dns"]).toBeDefined();
261
+ });
262
+
263
+ it("builds Pi-compatible ids and round-trips the state file", () => {
264
+ const id = newConversationId("security", Date.UTC(2026, 8, 16, 20, 55, 0), () => "abc123");
265
+ expect(id).toBe("security-20260916205500-abc123");
266
+ expect(id).toMatch(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/);
267
+ const dir = mkdtempSync(join(tmpdir(), "astro-conv-"));
268
+ process.env.ASTRO_DISCORD_DIR = dir;
269
+ try {
270
+ expect(loadState()).toEqual({ conversations: {} });
271
+ const state = recordAnswer(startConversation({ conversations: {} }, "1:dns", id, now), "1:dns", ["m1"], now);
272
+ saveState(state);
273
+ expect(loadState()).toEqual(state);
274
+ writeFileSync(join(dir, "conversations.json"), JSON.stringify({ conversations: { bad: { id: 1 }, ok: { id: "x", lastAt: 1, messageIds: [] } } }));
275
+ expect(Object.keys(loadState().conversations)).toEqual(["ok"]);
276
+ } finally {
277
+ delete process.env.ASTRO_DISCORD_DIR;
278
+ rmSync(dir, { recursive: true, force: true });
279
+ }
280
+ });
281
+ });
282
+
283
+ describe("quotes", () => {
284
+ it("flattens content, embeds, and attachments and appends them to the task", () => {
285
+ const alert = { content: "", embeds: [{ title: "Wazuh alert, level 12", description: "System running out of memory.", fields: [{ name: "agent", value: "arcane" }], footer: { text: "rule 5108" } }], attachments: [{ filename: "log.txt" }] };
286
+ expect(quoteText(alert)).toBe("Wazuh alert, level 12\nSystem running out of memory.\nagent: arcane\nrule 5108\nAttachments: log.txt");
287
+ expect(quoteText({ content: "x".repeat(5000) })).toHaveLength(4001);
288
+ expect(withQuotes("check this", [])).toBe("check this");
289
+ expect(withQuotes("check this", [{ label: "Forwarded message", message: { content: "" } }])).toBe("check this");
290
+ expect(withQuotes("check this", [{ label: "Forwarded message", message: alert }])).toMatch(/^check this\n\nForwarded message:\nWazuh alert/);
291
+ });
292
+ });
293
+
294
+ describe("image attachments", () => {
295
+ const png = { filename: "alert.png", url: "https://cdn.example/alert.png", content_type: "image/png", size: 1000 };
296
+ const image = { filename: png.filename, url: png.url, contentType: png.content_type, size: png.size };
297
+ it("keeps images within the limits, in message order", () => {
298
+ const big = { ...png, filename: "big.png", size: MAX_IMAGE_BYTES + 1 };
299
+ const text = { filename: "log.txt", url: "https://cdn.example/log.txt", content_type: "text/plain", size: 10 };
300
+ const many = Array.from({ length: 6 }, (_, i) => ({ ...png, filename: `${i}.png` }));
301
+ expect(imageAttachments([{ attachments: [text, big, png] }, { attachments: [{ ...png, filename: "quoted.jpg" }] }]).map((i) => i.filename)).toEqual(["alert.png", "quoted.jpg"]);
302
+ expect(imageAttachments([{ attachments: many }])).toHaveLength(4);
303
+ expect(imageAttachments([{}])).toEqual([]);
304
+ });
305
+
306
+ it("downloads to a private temp dir and reports failures", async () => {
307
+ const fetchImpl = vi.fn(async (url: string | URL | Request) => {
308
+ const u = String(url);
309
+ if (u.endsWith("missing.png")) return new Response("nope", { status: 404 });
310
+ return new Response(new Uint8Array([137, 80, 78, 71]), { status: 200 });
311
+ }) as unknown as typeof fetch;
312
+ const result = await downloadImages([image, { ...image, filename: "missing.png", url: "https://cdn.example/missing.png" }], fetchImpl);
313
+ try {
314
+ expect(result.files).toHaveLength(1);
315
+ expect(result.files[0]).toMatch(/\/1-[0-9a-f]{12}\.png$/);
316
+ expect(result.failed).toEqual(["missing.png"]);
317
+ expect(describeImages("check this", [image, { ...image, filename: "missing.png" }], result.failed)).toBe("check this\n\nImages attached to the message, included with this task: alert.png.\nImages that could not be fetched: missing.png.");
318
+ expect(describeImages("check this", [], [])).toBe("check this");
319
+ } finally {
320
+ rmSync(result.dir, { recursive: true, force: true });
321
+ }
322
+ });
323
+ });
@@ -1,14 +1,19 @@
1
+ import { promises as fs } from "node:fs";
1
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
3
  import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
3
4
  import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
4
5
  import { type AgentConfig, defaultDirs, discoverAgents, resolveSkills } from "../astro-subagents/agents.ts";
5
- import { AGENT_ENV, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
6
+ import { AGENT_ENV, type ChildSession, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
7
+ import { pruneSessions, sessionDirFor } from "../astro-subagents/sessions.ts";
6
8
  import { ApprovalServer, type Ticket } from "./approvals.ts";
7
9
  import { chunkMessage } from "./chunk.ts";
8
10
  import { applyAdminCommand } from "./admin.ts";
9
11
  import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
10
12
  import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
13
+ import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
14
+ import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
11
15
  import { GatewayClient } from "./gateway.ts";
16
+ import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
12
17
  import { DiscordRest, type MessageComponent } from "./rest.ts";
13
18
 
14
19
  const REACTION = { running: "⏳", waiting: "🔒", done: "✅", failed: "❌" } as const;
@@ -55,7 +60,11 @@ interface IncomingMessage {
55
60
  guild_id?: string;
56
61
  content?: string;
57
62
  author?: { id: string; bot?: boolean };
58
- referenced_message?: { author?: { id: string } } | null;
63
+ attachments?: QuotedMessage["attachments"];
64
+ /** The message this one replies to; Discord resolves it on every reply event. */
65
+ referenced_message?: QuotedMessage | null;
66
+ /** Copies of forwarded messages, without their authors. */
67
+ message_snapshots?: { message: QuotedMessage }[];
59
68
  }
60
69
 
61
70
  interface ComponentInteraction {
@@ -75,6 +84,8 @@ interface ActiveTask {
75
84
  messageId: string;
76
85
  userId: string;
77
86
  startedAt: number;
87
+ conversation: ResolvedConversation;
88
+ images: ImageAttachment[];
78
89
  }
79
90
 
80
91
  function log(line: string): void {
@@ -105,6 +116,7 @@ class Bridge {
105
116
  private readonly active = new Map<string, ActiveTask>();
106
117
  private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
107
118
  private config: DiscordConfig;
119
+ private conversations: ConversationState;
108
120
  private botUserId = "";
109
121
  /** Thread id to parent channel id; null marks a plain channel. */
110
122
  private readonly parents = new Map<string, string | null>();
@@ -115,6 +127,7 @@ class Bridge {
115
127
  this.ctx = ctx;
116
128
  this.token = token;
117
129
  this.config = config;
130
+ this.conversations = loadState();
118
131
  this.rest = new DiscordRest(token);
119
132
  this.approvals = new ApprovalServer({ onTicket: (t) => void this.askApproval(t), onExpired: (t) => void this.expireApproval(t) }, config.approvalTimeoutMinutes * 60_000);
120
133
  }
@@ -220,12 +233,37 @@ class Bridge {
220
233
  if (!canUse(this.config, userId, command.specialist)) return;
221
234
  const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
222
235
  if (!agent) return;
223
- const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: Date.now() };
236
+ const now = Date.now();
237
+ this.conversations = forgetStale(this.conversations, now);
238
+ const conversation = resolveConversation(
239
+ this.conversations,
240
+ { channelId: message.channel_id, specialist: command.specialist, repliedTo: message.referenced_message?.id, fresh: command.fresh, now },
241
+ () => newConversationId(command.specialist, now),
242
+ );
243
+ if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
244
+ saveState(this.conversations);
245
+ const quotes = this.quotesFor(message, conversation.resumed);
246
+ const images = imageAttachments([message, ...quotes.map((q) => q.message)]);
247
+ const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation, images };
248
+ const taskText = withQuotes(command.task, quotes);
224
249
  const previous = this.queues.get(command.specialist) ?? Promise.resolve();
225
- const next = previous.then(() => this.runTask(agent, command.task, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
250
+ const next = previous.then(() => this.runTask(agent, taskText, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
226
251
  this.queues.set(command.specialist, next);
227
252
  }
228
253
 
254
+ /** Replied-to and forwarded messages to show the specialist; the bot's own answer only when the conversation holding it is not resumed. */
255
+ private quotesFor(message: IncomingMessage, resumed: boolean): Quote[] {
256
+ const quotes: Quote[] = [];
257
+ const replied = message.referenced_message;
258
+ if (replied) {
259
+ const mine = replied.author?.id === this.botUserId;
260
+ if (!mine) quotes.push({ label: `Replied-to message from ${replied.author?.username ?? replied.author?.id ?? "an unknown author"}`, message: replied });
261
+ else if (!resumed) quotes.push({ label: "My earlier answer, which this message replies to", message: replied });
262
+ }
263
+ for (const snapshot of message.message_snapshots ?? []) quotes.push({ label: "Forwarded message", message: snapshot.message });
264
+ return quotes;
265
+ }
266
+
229
267
  private async runTask(agent: AgentConfig, taskText: string, task: ActiveTask): Promise<void> {
230
268
  task.startedAt = Date.now();
231
269
  this.active.set(task.specialist, task);
@@ -238,7 +276,12 @@ class Bridge {
238
276
  await this.rest.triggerTyping(task.channelId).catch(() => undefined);
239
277
  const typing = setInterval(() => void this.rest.triggerTyping(task.channelId).catch(() => undefined), 8000);
240
278
  typing.unref();
241
- log(`run astro.${task.specialist} for ${task.userId}: ${taskText.slice(0, 200)}`);
279
+ log(`run astro.${task.specialist} for ${task.userId} (${task.conversation.resumed ? "continuing" : "new conversation"} ${task.conversation.id}): ${taskText.slice(0, 200)}`);
280
+ const session: ChildSession = { dir: sessionDirFor(sessionsRoot(), task.specialist), id: task.conversation.id };
281
+ pruneSessions(session.dir);
282
+ const images = task.images.length > 0 ? await downloadImages(task.images) : undefined;
283
+ if (images && images.failed.length > 0) log(`astro.${task.specialist}: could not fetch ${images.failed.join(", ")}`);
284
+ const prompt = images ? describeImages(taskText, task.images, images.failed) : taskText;
242
285
  const dirs = defaultDirs();
243
286
  const { skills, missing } = resolveSkills(agent.skills, dirs);
244
287
  if (missing.length > 0) log(`${agent.name}: skill(s) not found: ${missing.join(", ")}`);
@@ -255,7 +298,8 @@ class Bridge {
255
298
  const result = await runAgent({
256
299
  agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
257
300
  skills,
258
- task: taskText,
301
+ task: prompt,
302
+ files: images?.files,
259
303
  cwd: process.cwd(),
260
304
  depth,
261
305
  remaining: childRemaining(remaining, agent),
@@ -263,6 +307,7 @@ class Bridge {
263
307
  // The child must not become a second Discord bridge.
264
308
  env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
265
309
  extraSystemPrompt: CHANNEL_RULES,
310
+ session,
266
311
  onUpdate: (partial) => {
267
312
  const progress = describeProgress(partial, seen);
268
313
  seen = progress.seen;
@@ -283,29 +328,36 @@ class Bridge {
283
328
  } finally {
284
329
  clearInterval(typing);
285
330
  this.active.delete(task.specialist);
331
+ if (images) await fs.rm(images.dir, { recursive: true, force: true }).catch(() => undefined);
286
332
  }
287
333
  await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
288
334
  await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
289
- await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
335
+ const answers = await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
336
+ this.conversations = recordAnswer(this.conversations, task.conversation.key, answers, Date.now());
337
+ saveState(this.conversations);
290
338
  }
291
339
 
292
- /** Turns the "working" placeholder into the answer: edit it in place when it fits, otherwise replace it with chunks or an attachment. */
293
- private async deliver(task: ActiveTask, placeholder: string | undefined, text: string, attachment?: string): Promise<void> {
340
+ /** Turns the "working" placeholder into the answer: edit it in place when it fits, otherwise replace it with chunks or an attachment. Returns the answer message ids. */
341
+ private async deliver(task: ActiveTask, placeholder: string | undefined, text: string, attachment?: string): Promise<string[]> {
294
342
  const oversized = attachment !== undefined || text.length > ATTACHMENT_THRESHOLD;
295
343
  if (oversized) {
296
344
  if (placeholder) await this.rest.deleteMessage(task.channelId, placeholder).catch(() => undefined);
297
345
  const file = attachment !== undefined ? { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } : { name: `astro-${task.specialist}-${Date.now()}.md`, content: text };
298
346
  const content = attachment !== undefined ? text.slice(0, 1900) : `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
299
- await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, file });
300
- return;
347
+ const posted = await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, file });
348
+ return [posted.id];
301
349
  }
302
350
  const chunks = chunkMessage(text);
303
351
  if (chunks.length === 0) chunks.push(text);
304
- if (placeholder) await this.rest.editMessage(task.channelId, placeholder, chunks[0]).catch(() => undefined);
305
- else await this.rest.createMessage(task.channelId, { content: chunks[0], replyTo: task.messageId });
352
+ const ids: string[] = [];
353
+ if (placeholder) {
354
+ await this.rest.editMessage(task.channelId, placeholder, chunks[0]).catch(() => undefined);
355
+ ids.push(placeholder);
356
+ } else ids.push((await this.rest.createMessage(task.channelId, { content: chunks[0], replyTo: task.messageId })).id);
306
357
  for (let i = 1; i < chunks.length; i++) {
307
- await this.rest.createMessage(task.channelId, { content: chunks[i] });
358
+ ids.push((await this.rest.createMessage(task.channelId, { content: chunks[i] })).id);
308
359
  }
360
+ return ids;
309
361
  }
310
362
 
311
363
  private async askApproval(ticket: Ticket): Promise<void> {
@@ -0,0 +1,39 @@
1
+ /** The parts of a Discord message worth quoting to a specialist. */
2
+ export interface QuotedMessage {
3
+ id?: string;
4
+ content?: string;
5
+ embeds?: { title?: string; description?: string; fields?: { name: string; value: string }[]; footer?: { text: string }; author?: { name?: string } }[];
6
+ attachments?: { filename: string; url?: string; content_type?: string; size?: number }[];
7
+ author?: { id: string; username?: string; bot?: boolean };
8
+ }
9
+
10
+ const QUOTE_LIMIT = 4000;
11
+
12
+ /** Plain text of a message: content, then each embed's texts, then attachment names. */
13
+ export function quoteText(message: QuotedMessage): string {
14
+ const parts: string[] = [];
15
+ const content = message.content?.trim();
16
+ if (content) parts.push(content);
17
+ for (const embed of message.embeds ?? []) {
18
+ const lines = [embed.author?.name, embed.title, embed.description, ...(embed.fields ?? []).map((f) => `${f.name}: ${f.value}`), embed.footer?.text];
19
+ for (const line of lines) {
20
+ const text = line?.trim();
21
+ if (text) parts.push(text);
22
+ }
23
+ }
24
+ if (message.attachments && message.attachments.length > 0) parts.push(`Attachments: ${message.attachments.map((a) => a.filename).join(", ")}`);
25
+ const text = parts.join("\n");
26
+ return text.length > QUOTE_LIMIT ? `${text.slice(0, QUOTE_LIMIT)}…` : text;
27
+ }
28
+
29
+ export interface Quote {
30
+ label: string;
31
+ message: QuotedMessage;
32
+ }
33
+
34
+ /** The task with the replied-to and forwarded messages appended, so the specialist sees what "this" refers to. */
35
+ export function withQuotes(task: string, quotes: readonly Quote[]): string {
36
+ const blocks = quotes.map((q) => ({ label: q.label, text: quoteText(q.message) })).filter((q) => q.text.length > 0);
37
+ if (blocks.length === 0) return task;
38
+ return `${task}\n\n${blocks.map((q) => `${q.label}:\n${q.text}`).join("\n\n")}`;
39
+ }
@@ -67,6 +67,7 @@ describe("discovery", () => {
67
67
  bundledSkills: join(root, "pkg", "skills"),
68
68
  userAgents: join(root, "user", "agents"),
69
69
  userSkills: join(root, "user", "skills"),
70
+ sessions: join(root, "sessions"),
70
71
  };
71
72
  for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true });
72
73
  writeFileSync(join(dirs.bundledAgents, "arcane.md"), "---\nname: arcane\ndescription: bundled\nskills: arcane\n---\nB");
@@ -39,6 +39,8 @@ export interface AgentDirs {
39
39
  bundledSkills: string;
40
40
  userAgents: string;
41
41
  userSkills: string;
42
+ /** Root for saved `/run` sessions, one folder per agent. */
43
+ sessions: string;
42
44
  }
43
45
 
44
46
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -50,6 +52,7 @@ export function defaultDirs(): AgentDirs {
50
52
  bundledSkills: path.join(packageRoot, "skills"),
51
53
  userAgents: path.join(getAgentDir(), "agents"),
52
54
  userSkills: path.join(getAgentDir(), "skills"),
55
+ sessions: path.join(getAgentDir(), "subagent-sessions"),
53
56
  };
54
57
  }
55
58
 
@@ -56,6 +56,14 @@ describe("child arguments", () => {
56
56
  it("does not pass the parent's thinking level to an agent that pins its own model", () => {
57
57
  const pinned: AgentConfig = { ...agent, model: "m" };
58
58
  expect(buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: { thinkingLevel: "high" } })).not.toContain("--thinking");
59
+ const saved = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, session: { dir: "/s/x", continue: true } });
60
+ expect(saved.slice(0, 6)).toEqual(["--mode", "json", "-p", "--session-dir", "/s/x", "--continue"]);
61
+ expect(saved).not.toContain("--no-session");
62
+ const exact = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, session: { dir: "/s/x", id: "dns-1", continue: true } });
63
+ expect(exact.slice(3, 7)).toEqual(["--session-dir", "/s/x", "--session-id", "dns-1"]);
64
+ expect(exact).not.toContain("--continue");
65
+ const withFiles = buildChildArgs({ agent: pinned, task: "t", promptFile: null, defaults: {}, files: ["/tmp/a.png", "/tmp/b.png"] });
66
+ expect(withFiles.slice(-4)).toEqual(["--", "@/tmp/a.png", "@/tmp/b.png", "Task: t"]);
59
67
  });
60
68
 
61
69
  it("inlines skills after the agent prompt", () => {
@@ -71,16 +71,35 @@ export function composeSystemPrompt(agent: AgentConfig, skills: ResolvedSkill[],
71
71
  return parts.filter((p) => p.length > 0).join("\n\n");
72
72
  }
73
73
 
74
+ /**
75
+ * Where the child keeps its conversation. Without one the child is ephemeral.
76
+ * `id` opens that exact session (created when missing); otherwise `continue`
77
+ * resumes the most recent session in `dir` started from the same working directory.
78
+ */
79
+ export interface ChildSession {
80
+ dir: string;
81
+ continue?: boolean;
82
+ id?: string;
83
+ }
84
+
74
85
  export interface ChildArgsInput {
75
86
  agent: AgentConfig;
76
87
  task: string;
77
88
  promptFile: string | null;
78
89
  defaults: DispatchDefaults;
90
+ session?: ChildSession;
91
+ /** Local files passed as `@file` prompt inputs; Pi attaches images by content type. */
92
+ files?: string[];
79
93
  }
80
94
 
81
- /** Arguments for the child `pi` process: print mode, JSON events, no session file. */
82
- export function buildChildArgs({ agent, task, promptFile, defaults }: ChildArgsInput): string[] {
83
- const args = ["--mode", "json", "-p", "--no-session"];
95
+ /** Arguments for the child `pi` process: print mode, JSON events, a session file only when asked. */
96
+ export function buildChildArgs({ agent, task, promptFile, defaults, session, files }: ChildArgsInput): string[] {
97
+ const args = ["--mode", "json", "-p"];
98
+ if (session) {
99
+ args.push("--session-dir", session.dir);
100
+ if (session.id) args.push("--session-id", session.id);
101
+ else if (session.continue) args.push("--continue");
102
+ } else args.push("--no-session");
84
103
  const model = agent.model ?? defaults.model;
85
104
  if (model) args.push("--model", model);
86
105
  const thinking = agent.thinking ?? (agent.model ? undefined : defaults.thinkingLevel);
@@ -89,7 +108,7 @@ export function buildChildArgs({ agent, task, promptFile, defaults }: ChildArgsI
89
108
  if (!agent.inheritProjectContext) args.push("--no-context-files");
90
109
  if (!agent.inheritSkills) args.push("--no-skills");
91
110
  if (promptFile) args.push(agent.systemPromptMode === "append" ? "--append-system-prompt" : "--system-prompt", promptFile);
92
- args.push("--", `Task: ${task}`);
111
+ args.push("--", ...(files ?? []).map((file) => `@${file}`), `Task: ${task}`);
93
112
  return args;
94
113
  }
95
114
 
@@ -182,6 +201,10 @@ export interface RunOptions {
182
201
  env?: Record<string, string>;
183
202
  /** Text appended to the system prompt, for example channel rules. */
184
203
  extraSystemPrompt?: string;
204
+ /** Saved conversation to continue or start; omitted for a one-off run. */
205
+ session?: ChildSession;
206
+ /** Local files (images) attached to the task. */
207
+ files?: string[];
185
208
  }
186
209
 
187
210
  export async function runAgent(options: RunOptions): Promise<RunResult> {
@@ -206,7 +229,8 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
206
229
  await fs.promises.writeFile(promptFile, prompt, { encoding: "utf-8", mode: 0o600 });
207
230
  }
208
231
  try {
209
- const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults });
232
+ if (options.session) await fs.promises.mkdir(options.session.dir, { recursive: true, mode: 0o700 });
233
+ const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults, session: options.session, files: options.files });
210
234
  const invocation = (options.invocation ?? piInvocation)(args);
211
235
  const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
212
236
  let aborted = false;
@@ -4,7 +4,7 @@ import { join } from "node:path";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { AgentDirs } from "./agents.ts";
6
6
  import { DEPTH_ENV, REMAINING_ENV } from "./child.ts";
7
- import astroSubagents, { parseRunCommand, pruneLegacyCopies } from "./index.ts";
7
+ import astroSubagents, { parseRunCommand, pruneLegacyCopies, runEntryText } from "./index.ts";
8
8
 
9
9
  type Handler = (event: Record<string, unknown>, ctx: unknown) => Promise<unknown>;
10
10
 
@@ -16,6 +16,8 @@ interface CapturedPi {
16
16
  registerCommand: (name: string, opts: { handler: (args: string, ctx: unknown) => Promise<void> | void }) => void;
17
17
  on: (event: string, handler: Handler) => void;
18
18
  sendMessage: ReturnType<typeof vi.fn>;
19
+ appendEntry: ReturnType<typeof vi.fn>;
20
+ registerEntryRenderer: ReturnType<typeof vi.fn>;
19
21
  }
20
22
 
21
23
  function makePi(): CapturedPi {
@@ -32,6 +34,8 @@ function makePi(): CapturedPi {
32
34
  handlers[event] = handler;
33
35
  },
34
36
  sendMessage: vi.fn(),
37
+ appendEntry: vi.fn(),
38
+ registerEntryRenderer: vi.fn(),
35
39
  };
36
40
  }
37
41
 
@@ -46,6 +50,7 @@ describe("astro-subagents extension", () => {
46
50
  bundledSkills: join(root, "pkg", "skills"),
47
51
  userAgents: join(root, "user", "agents"),
48
52
  userSkills: join(root, "user", "skills"),
53
+ sessions: join(root, "sessions"),
49
54
  };
50
55
  for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true });
51
56
  writeFileSync(join(dirs.bundledAgents, "arcane.md"), "---\nname: arcane\ndescription: Operates Arcane\n---\nB");
@@ -77,6 +82,9 @@ describe("astro-subagents extension", () => {
77
82
  const ctx = { ui: { notify: vi.fn(), setStatus }, hasUI: true, cwd: root, model: { provider: "openai-codex", id: "gpt-6-astra" } };
78
83
  await pi.commands.get("run")?.handler("astro.nobody -- x", ctx);
79
84
  expect(setStatus).toHaveBeenCalledWith("subagent", expect.stringContaining("astro.nobody on openai-codex/gpt-6-astra"));
85
+ expect(pi.appendEntry).toHaveBeenCalledWith("astro-subagents-run", { agent: "astro.nobody", task: "x", continue: false });
86
+ expect(pi.registerEntryRenderer).toHaveBeenCalledWith("astro-subagents-run", expect.any(Function));
87
+ expect(runEntryText({ agent: "astro.dns", task: "zones", continue: true })).toBe("/run astro.dns --continue -- zones");
80
88
  expect(pi.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "astro-subagents", content: expect.stringContaining("Unknown agent") }), { triggerTurn: false });
81
89
  });
82
90
 
@@ -92,8 +100,11 @@ describe("astro-subagents extension", () => {
92
100
  });
93
101
 
94
102
  it("parses /run arguments", () => {
95
- expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects" });
96
- expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code" });
103
+ expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects", continue: false });
104
+ expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code", continue: false });
105
+ expect(parseRunCommand("astro.security --continue -- do the next step")).toEqual({ agent: "astro.security", task: "do the next step", continue: true });
106
+ expect(parseRunCommand("astro.security --continue next")).toEqual({ agent: "astro.security", task: "next", continue: true });
107
+ expect(parseRunCommand("scout --continue")).toBeNull();
97
108
  expect(parseRunCommand("scout")).toBeNull();
98
109
  expect(pruneLegacyCopies(join(root, "missing"))).toEqual([]);
99
110
  });
@@ -3,9 +3,11 @@ import * as path from "node:path";
3
3
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
4
4
  import { StringEnum } from "@earendil-works/pi-ai";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { Box, Text } from "@earendil-works/pi-tui";
6
7
  import { type Static, Type } from "typebox";
7
8
  import { type AgentConfig, type AgentDirs, type AgentScope, BUNDLED_NAMESPACE, defaultDirs, discoverAgents, formatAgentList, resolveSkills } from "./agents.ts";
8
- import { childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
9
+ import { type ChildSession, childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
10
+ import { pruneSessions, sessionDirFor } from "./sessions.ts";
9
11
  import { renderCall, renderResult, type SubagentDetails } from "./render.ts";
10
12
  import { startTicker } from "./ticker.ts";
11
13
 
@@ -77,11 +79,25 @@ export function pruneLegacyCopies(userAgentsDir: string): string[] {
77
79
  }
78
80
 
79
81
  /** Parses `/run <agent> -- <task>` (the `--` is optional when the task has no leading dash). */
80
- export function parseRunCommand(input: string): { agent: string; task: string } | null {
82
+ /** `/run <agent> [--continue] [--] <task>`; `--continue` resumes the agent's last saved session from this directory. */
83
+ export function parseRunCommand(input: string): { agent: string; task: string; continue: boolean } | null {
81
84
  const trimmed = input.trim();
82
- const match = /^(\S+)\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
85
+ const match = /^(\S+)(\s+--continue)?\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
83
86
  if (!match) return null;
84
- return { agent: match[1], task: match[2].trim() };
87
+ const task = match[3].trim();
88
+ if (task === "--continue") return null;
89
+ return { agent: match[1], task, continue: match[2] !== undefined };
90
+ }
91
+
92
+ /** Transcript entry for a `/run` invocation; shown to the user, never sent to the model. */
93
+ export interface RunEntry {
94
+ agent: string;
95
+ task: string;
96
+ continue: boolean;
97
+ }
98
+
99
+ export function runEntryText(entry: RunEntry): string {
100
+ return `/run ${entry.agent}${entry.continue ? " --continue" : ""} -- ${entry.task}`;
85
101
  }
86
102
 
87
103
  export interface GateOptions {
@@ -105,6 +121,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
105
121
  step: number | undefined,
106
122
  signal: AbortSignal | undefined,
107
123
  onUpdate: ((r: RunResult) => void) | undefined,
124
+ session?: ChildSession,
108
125
  ): Promise<RunResult> {
109
126
  const agent = agents.find((a) => a.name === agentName);
110
127
  if (!agent) {
@@ -136,6 +153,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
136
153
  step,
137
154
  signal,
138
155
  onUpdate,
156
+ session,
139
157
  });
140
158
  }
141
159
 
@@ -259,8 +277,16 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
259
277
  return { content: [{ type: "text", text: finalOutput(result.messages) || "(no output)" }], details: details("single", [result]) };
260
278
  }
261
279
 
280
+ // Slash commands are not echoed in the transcript; show the invocation the way a user message looks.
281
+ pi.registerEntryRenderer<RunEntry>("astro-subagents-run", (entry, _options, theme) => {
282
+ if (!entry.data) return undefined;
283
+ const box = new Box(1, 0, (text) => theme.bg("userMessageBg", text));
284
+ box.addChild(new Text(theme.fg("userMessageText", runEntryText(entry.data)), 0, 0));
285
+ return box;
286
+ });
287
+
262
288
  pi.registerCommand("run", {
263
- description: "Run an agent once: /run <agent> -- <task>",
289
+ description: "Run an agent: /run <agent> [--continue] -- <task>",
264
290
  handler: async (args, ctx) => {
265
291
  if (!canDelegate) {
266
292
  ctx.ui.notify("subagents: this session may not delegate further (depth limit)", "warning");
@@ -268,18 +294,22 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
268
294
  }
269
295
  const parsed = parseRunCommand(args);
270
296
  if (!parsed) {
271
- ctx.ui.notify("usage: /run <agent> -- <task>", "warning");
297
+ ctx.ui.notify("usage: /run <agent> [--continue] -- <task>", "warning");
272
298
  return;
273
299
  }
274
300
  const agents = listAgents(ctx.cwd, "user").agents;
301
+ pi.appendEntry<RunEntry>("astro-subagents-run", { agent: parsed.agent, task: parsed.task, continue: parsed.continue });
302
+ // Every /run saves its session so a later --continue can pick the conversation up.
303
+ const session: ChildSession = { dir: sessionDirFor(dirs.sessions, parsed.agent), continue: parsed.continue };
304
+ pruneSessions(session.dir);
275
305
  const stopTicker = startTicker(ctx, runLabel(ctx, agents, parsed.agent));
276
306
  let result: RunResult;
277
307
  try {
278
- result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
308
+ result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined, session);
279
309
  } finally {
280
310
  stopTicker();
281
311
  }
282
- const status = isFailed(result) ? "failed" : "done";
312
+ const status = `${isFailed(result) ? "failed" : "done"}${parsed.continue ? ", continued" : ""}`;
283
313
  if (isFailed(result)) ctx.ui.notify(`${parsed.agent} failed`, "warning");
284
314
  // No turn: the result joins the context for the next prompt without the parent model restating it.
285
315
  pi.sendMessage({ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true }, { triggerTurn: false });
@@ -0,0 +1,32 @@
1
+ import { mkdtempSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { pruneSessions, sessionDirFor } from "./sessions.ts";
6
+
7
+ describe("child sessions", () => {
8
+ let root: string;
9
+ beforeEach(() => {
10
+ root = mkdtempSync(join(tmpdir(), "astro-sessions-"));
11
+ });
12
+ afterEach(() => rmSync(root, { recursive: true, force: true }));
13
+
14
+ it("names one directory per agent", () => {
15
+ expect(sessionDirFor("/s", "astro.security")).toBe("/s/astro.security");
16
+ expect(sessionDirFor("/s", "a b/c")).toBe("/s/a_b_c");
17
+ });
18
+
19
+ it("prunes old session files and leaves recent ones and other files", () => {
20
+ const now = 1_800_000_000_000;
21
+ const old = join(root, "old.jsonl");
22
+ const fresh = join(root, "fresh.jsonl");
23
+ const other = join(root, "notes.txt");
24
+ for (const f of [old, fresh, other]) writeFileSync(f, "x");
25
+ utimesSync(old, new Date(now - 8 * 86_400_000), new Date(now - 8 * 86_400_000));
26
+ utimesSync(other, new Date(now - 8 * 86_400_000), new Date(now - 8 * 86_400_000));
27
+ utimesSync(fresh, new Date(now - 60_000), new Date(now - 60_000));
28
+ expect(pruneSessions(root, 7 * 86_400_000, now)).toBe(1);
29
+ expect(readdirSync(root).sort()).toEqual(["fresh.jsonl", "notes.txt"]);
30
+ expect(pruneSessions(join(root, "missing"), 1, now)).toBe(0);
31
+ });
32
+ });
@@ -0,0 +1,34 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ /** Saved child sessions older than this are deleted before a run. */
5
+ export const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
6
+
7
+ /** Directory holding one agent's saved child sessions. */
8
+ export function sessionDirFor(root: string, agentName: string): string {
9
+ return path.join(root, agentName.replace(/[^\w.-]+/g, "_"));
10
+ }
11
+
12
+ /** Deletes session files not modified within `maxAgeMs`; a missing directory holds nothing to prune. */
13
+ export function pruneSessions(dir: string, maxAgeMs = SESSION_MAX_AGE_MS, now = Date.now()): number {
14
+ let names: string[];
15
+ try {
16
+ names = fs.readdirSync(dir);
17
+ } catch {
18
+ return 0;
19
+ }
20
+ let removed = 0;
21
+ for (const name of names) {
22
+ if (!name.endsWith(".jsonl")) continue;
23
+ const file = path.join(dir, name);
24
+ try {
25
+ if (now - fs.statSync(file).mtimeMs > maxAgeMs) {
26
+ fs.unlinkSync(file);
27
+ removed++;
28
+ }
29
+ } catch {
30
+ // The file vanished between readdir and stat; nothing left to prune.
31
+ }
32
+ }
33
+ return removed;
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.23.2",
3
+ "version": "0.25.0",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -14,7 +14,7 @@ A specialist is a Pi subagent that is the only way to operate one area of the ho
14
14
  | `astro.proxmox` | Proxmox VE on Pulsar | API token: `guests`, `guest`, `get <path>`, `tasks`; `start`, `shutdown|reboot|stop --confirm`; snapshots create, delete, rollback; `set` of CPU, memory, options, network. Pulsar key: `host-status`, `host-journal`, `updates`, `guest-exec <vmid> <status|journal|df|updates>` |
15
15
  | `astro.inference` | llama.cpp on Nexus, Hermes on LXC 101, Europa health | `nexus-status|start|stop|restart|log|disk`; `hermes-status|journal|errors|restart|api-health|version|guest-status|snapshots`, `hermes-releases`, `hermes-upgrade --confirm`; `europa-health` |
16
16
 
17
- Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both from the bundled `astro-subagents` extension). Specialists may call each other once (`maxSubagentDepth: 1`). They run on `openai-codex/gpt-6-astra` with `xhigh` thinking, set by `model` and `thinking` in each `agents/<name>.md`; an agent without these fields inherits the caller's model and thinking. Each tool takes `args`, an array of strings; `["--help"]` lists the subcommands.
17
+ Call one with `/run astro.<name> -- <task>` or through the `subagent` tool (both from the bundled `astro-subagents` extension). `/run` saves the specialist's session under `~/.pi/agent/subagent-sessions/<agent>/`; `/run astro.<name> --continue -- <task>` resumes the last one started from the same directory, so the specialist keeps what it saw. Sessions older than 7 days are deleted. Specialists may call each other once (`maxSubagentDepth: 1`). They run on `openai-codex/gpt-6-astra` with `xhigh` thinking, set by `model` and `thinking` in each `agents/<name>.md`; an agent without these fields inherits the caller's model and thinking. Each tool takes `args`, an array of strings; `["--help"]` lists the subcommands.
18
18
 
19
19
  ## How it is secured
20
20
 
@@ -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). Long answers arrive as a `.md` attachment.
108
+ - Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment.
109
109
  - Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
110
110
  - Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, Arcane redeploys, service restarts) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
111
111
  - Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.