@astrofoundry/pi-astro 0.23.1 → 0.24.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
@@ -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,8 @@ 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";
14
16
 
15
17
  const ID = "123456789012345678";
16
18
  const OTHER = "223456789012345678";
@@ -89,20 +91,24 @@ describe("commands", () => {
89
91
  const many = { botUserId: ID, specialists: ["dns", "edge"], trigger: "always" as const, repliedToBot: false };
90
92
  const one = { ...many, specialists: ["arcane"] };
91
93
  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" });
94
+ expect(parseCommand("dns list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
95
+ expect(parseCommand(`<@${ID}> edge probe the front door`, many)).toEqual({ kind: "run", specialist: "edge", task: "probe the front door", fresh: false });
96
+ expect(parseCommand("!astro.dns zones", many)).toEqual({ kind: "run", specialist: "dns", task: "zones", fresh: false });
95
97
  expect(parseCommand("help", many)).toEqual({ kind: "help" });
96
98
  expect(parseCommand("Status", many)).toEqual({ kind: "status" });
97
99
  expect(parseCommand("config channel <#1> mention dns", many)).toEqual({ kind: "config", args: ["channel", "<#1>", "mention", "dns"] });
98
100
  expect(parseCommand("dns", many)).toEqual({ kind: "help" });
101
+ expect(parseCommand("dns new list the zones", many)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: true });
102
+ expect(parseCommand("New what changed?", one)).toEqual({ kind: "run", specialist: "arcane", task: "what changed?", fresh: true });
103
+ expect(parseCommand("newer images?", one)).toEqual({ kind: "run", specialist: "arcane", task: "newer images?", fresh: false });
104
+ expect(parseCommand("dns new", many)).toEqual({ kind: "help" });
99
105
  expect(parseCommand("hello everyone", many)).toEqual({ kind: "none" });
100
106
  expect(parseCommand("", many)).toEqual({ kind: "none" });
101
107
  });
102
108
 
103
109
  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" });
110
+ expect(parseCommand("list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
111
+ expect(parseCommand("arcane list the projects", one)).toEqual({ kind: "run", specialist: "arcane", task: "list the projects", fresh: false });
106
112
  expect(parseCommand(`<@${ID}> list the zones`, many)).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
107
113
  expect(parseCommand("list the zones", { ...many, repliedToBot: true })).toEqual({ kind: "needs-prefix", specialists: ["dns", "edge"] });
108
114
  expect(parseCommand(`<@${ID}>`, many)).toEqual({ kind: "help" });
@@ -111,10 +117,10 @@ describe("commands", () => {
111
117
  it("stays silent in mention channels unless addressed", () => {
112
118
  const mention = { ...many, trigger: "mention" as const };
113
119
  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" });
120
+ expect(parseCommand(`<@${ID}> dns list the zones`, mention)).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
121
+ expect(parseCommand("dns list the zones", { ...mention, repliedToBot: true })).toEqual({ kind: "run", specialist: "dns", task: "list the zones", fresh: false });
116
122
  expect(parseCommand("anything", { ...one, trigger: "mention" })).toEqual({ kind: "none" });
117
- expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything" });
123
+ expect(parseCommand(`<@${ID}> anything`, { ...one, trigger: "mention" })).toEqual({ kind: "run", specialist: "arcane", task: "anything", fresh: false });
118
124
  });
119
125
 
120
126
  it("writes help and prefix texts", () => {
@@ -236,3 +242,50 @@ describe("approval server", () => {
236
242
  server.close();
237
243
  });
238
244
  });
245
+
246
+ describe("conversations", () => {
247
+ const now = 1_800_000_000_000;
248
+ it("continues on a reply or within the idle window, otherwise starts fresh", () => {
249
+ let state = startConversation({ conversations: {} }, "1:dns", "dns-a", now);
250
+ state = recordAnswer(state, "1:dns", ["m1"], now);
251
+ const input = { channelId: "1", specialist: "dns", fresh: false, now: now + 60_000 };
252
+ expect(resolveConversation(state, input, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
253
+ expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1 }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
254
+ expect(resolveConversation(state, { ...input, now: now + CONVERSATION_IDLE_MS + 1, repliedTo: "m1" }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-a", resumed: true });
255
+ expect(resolveConversation(state, { ...input, fresh: true }, () => "dns-b")).toEqual({ key: "1:dns", id: "dns-b", resumed: false });
256
+ expect(resolveConversation(state, { ...input, channelId: "2" }, () => "dns-b").resumed).toBe(false);
257
+ expect(recordAnswer(state, "missing", ["x"], now)).toBe(state);
258
+ expect(forgetStale(state, now + CONVERSATION_MAX_AGE_MS + 1).conversations).toEqual({});
259
+ expect(forgetStale(state, now + 1).conversations["1:dns"]).toBeDefined();
260
+ });
261
+
262
+ it("builds Pi-compatible ids and round-trips the state file", () => {
263
+ const id = newConversationId("security", Date.UTC(2026, 8, 16, 20, 55, 0), () => "abc123");
264
+ expect(id).toBe("security-20260916205500-abc123");
265
+ expect(id).toMatch(/^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/);
266
+ const dir = mkdtempSync(join(tmpdir(), "astro-conv-"));
267
+ process.env.ASTRO_DISCORD_DIR = dir;
268
+ try {
269
+ expect(loadState()).toEqual({ conversations: {} });
270
+ const state = recordAnswer(startConversation({ conversations: {} }, "1:dns", id, now), "1:dns", ["m1"], now);
271
+ saveState(state);
272
+ expect(loadState()).toEqual(state);
273
+ writeFileSync(join(dir, "conversations.json"), JSON.stringify({ conversations: { bad: { id: 1 }, ok: { id: "x", lastAt: 1, messageIds: [] } } }));
274
+ expect(Object.keys(loadState().conversations)).toEqual(["ok"]);
275
+ } finally {
276
+ delete process.env.ASTRO_DISCORD_DIR;
277
+ rmSync(dir, { recursive: true, force: true });
278
+ }
279
+ });
280
+ });
281
+
282
+ describe("quotes", () => {
283
+ it("flattens content, embeds, and attachments and appends them to the task", () => {
284
+ 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" }] };
285
+ expect(quoteText(alert)).toBe("Wazuh alert, level 12\nSystem running out of memory.\nagent: arcane\nrule 5108\nAttachments: log.txt");
286
+ expect(quoteText({ content: "x".repeat(5000) })).toHaveLength(4001);
287
+ expect(withQuotes("check this", [])).toBe("check this");
288
+ expect(withQuotes("check this", [{ label: "Forwarded message", message: { content: "" } }])).toBe("check this");
289
+ expect(withQuotes("check this", [{ label: "Forwarded message", message: alert }])).toMatch(/^check this\n\nForwarded message:\nWazuh alert/);
290
+ });
291
+ });
@@ -2,13 +2,16 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
2
2
  import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
3
3
  import { APPROVAL_TOKEN_ENV, APPROVAL_URL_ENV } from "../specialist-gate/index.ts";
4
4
  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";
5
+ import { AGENT_ENV, type ChildSession, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
6
+ import { pruneSessions, sessionDirFor } from "../astro-subagents/sessions.ts";
6
7
  import { ApprovalServer, type Ticket } from "./approvals.ts";
7
8
  import { chunkMessage } from "./chunk.ts";
8
9
  import { applyAdminCommand } from "./admin.ts";
9
10
  import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./commands.ts";
10
11
  import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
12
+ import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
11
13
  import { GatewayClient } from "./gateway.ts";
14
+ import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
12
15
  import { DiscordRest, type MessageComponent } from "./rest.ts";
13
16
 
14
17
  const REACTION = { running: "⏳", waiting: "🔒", done: "✅", failed: "❌" } as const;
@@ -55,7 +58,10 @@ interface IncomingMessage {
55
58
  guild_id?: string;
56
59
  content?: string;
57
60
  author?: { id: string; bot?: boolean };
58
- referenced_message?: { author?: { id: string } } | null;
61
+ /** The message this one replies to; Discord resolves it on every reply event. */
62
+ referenced_message?: QuotedMessage | null;
63
+ /** Copies of forwarded messages, without their authors. */
64
+ message_snapshots?: { message: QuotedMessage }[];
59
65
  }
60
66
 
61
67
  interface ComponentInteraction {
@@ -75,6 +81,7 @@ interface ActiveTask {
75
81
  messageId: string;
76
82
  userId: string;
77
83
  startedAt: number;
84
+ conversation: ResolvedConversation;
78
85
  }
79
86
 
80
87
  function log(line: string): void {
@@ -105,6 +112,7 @@ class Bridge {
105
112
  private readonly active = new Map<string, ActiveTask>();
106
113
  private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
107
114
  private config: DiscordConfig;
115
+ private conversations: ConversationState;
108
116
  private botUserId = "";
109
117
  /** Thread id to parent channel id; null marks a plain channel. */
110
118
  private readonly parents = new Map<string, string | null>();
@@ -115,6 +123,7 @@ class Bridge {
115
123
  this.ctx = ctx;
116
124
  this.token = token;
117
125
  this.config = config;
126
+ this.conversations = loadState();
118
127
  this.rest = new DiscordRest(token);
119
128
  this.approvals = new ApprovalServer({ onTicket: (t) => void this.askApproval(t), onExpired: (t) => void this.expireApproval(t) }, config.approvalTimeoutMinutes * 60_000);
120
129
  }
@@ -220,12 +229,35 @@ class Bridge {
220
229
  if (!canUse(this.config, userId, command.specialist)) return;
221
230
  const agent = agents.find((a) => a.name === `astro.${command.specialist}`);
222
231
  if (!agent) return;
223
- const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: Date.now() };
232
+ const now = Date.now();
233
+ this.conversations = forgetStale(this.conversations, now);
234
+ const conversation = resolveConversation(
235
+ this.conversations,
236
+ { channelId: message.channel_id, specialist: command.specialist, repliedTo: message.referenced_message?.id, fresh: command.fresh, now },
237
+ () => newConversationId(command.specialist, now),
238
+ );
239
+ if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
240
+ saveState(this.conversations);
241
+ const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation };
242
+ const taskText = withQuotes(command.task, this.quotesFor(message, conversation.resumed));
224
243
  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)}`));
244
+ const next = previous.then(() => this.runTask(agent, taskText, task)).catch((err) => log(`run failed: ${err instanceof Error ? err.message : String(err)}`));
226
245
  this.queues.set(command.specialist, next);
227
246
  }
228
247
 
248
+ /** Replied-to and forwarded messages to show the specialist; the bot's own answer only when the conversation holding it is not resumed. */
249
+ private quotesFor(message: IncomingMessage, resumed: boolean): Quote[] {
250
+ const quotes: Quote[] = [];
251
+ const replied = message.referenced_message;
252
+ if (replied) {
253
+ const mine = replied.author?.id === this.botUserId;
254
+ if (!mine) quotes.push({ label: `Replied-to message from ${replied.author?.username ?? replied.author?.id ?? "an unknown author"}`, message: replied });
255
+ else if (!resumed) quotes.push({ label: "My earlier answer, which this message replies to", message: replied });
256
+ }
257
+ for (const snapshot of message.message_snapshots ?? []) quotes.push({ label: "Forwarded message", message: snapshot.message });
258
+ return quotes;
259
+ }
260
+
229
261
  private async runTask(agent: AgentConfig, taskText: string, task: ActiveTask): Promise<void> {
230
262
  task.startedAt = Date.now();
231
263
  this.active.set(task.specialist, task);
@@ -238,7 +270,9 @@ class Bridge {
238
270
  await this.rest.triggerTyping(task.channelId).catch(() => undefined);
239
271
  const typing = setInterval(() => void this.rest.triggerTyping(task.channelId).catch(() => undefined), 8000);
240
272
  typing.unref();
241
- log(`run astro.${task.specialist} for ${task.userId}: ${taskText.slice(0, 200)}`);
273
+ log(`run astro.${task.specialist} for ${task.userId} (${task.conversation.resumed ? "continuing" : "new conversation"} ${task.conversation.id}): ${taskText.slice(0, 200)}`);
274
+ const session: ChildSession = { dir: sessionDirFor(sessionsRoot(), task.specialist), id: task.conversation.id };
275
+ pruneSessions(session.dir);
242
276
  const dirs = defaultDirs();
243
277
  const { skills, missing } = resolveSkills(agent.skills, dirs);
244
278
  if (missing.length > 0) log(`${agent.name}: skill(s) not found: ${missing.join(", ")}`);
@@ -263,6 +297,7 @@ class Bridge {
263
297
  // The child must not become a second Discord bridge.
264
298
  env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
265
299
  extraSystemPrompt: CHANNEL_RULES,
300
+ session,
266
301
  onUpdate: (partial) => {
267
302
  const progress = describeProgress(partial, seen);
268
303
  seen = progress.seen;
@@ -286,26 +321,32 @@ class Bridge {
286
321
  }
287
322
  await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
288
323
  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);
324
+ const answers = await this.deliver(task, placeholder, `${failed ? "❌" : "✅"} **${task.specialist}**\n${output}`, attachment);
325
+ this.conversations = recordAnswer(this.conversations, task.conversation.key, answers, Date.now());
326
+ saveState(this.conversations);
290
327
  }
291
328
 
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> {
329
+ /** 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. */
330
+ private async deliver(task: ActiveTask, placeholder: string | undefined, text: string, attachment?: string): Promise<string[]> {
294
331
  const oversized = attachment !== undefined || text.length > ATTACHMENT_THRESHOLD;
295
332
  if (oversized) {
296
333
  if (placeholder) await this.rest.deleteMessage(task.channelId, placeholder).catch(() => undefined);
297
334
  const file = attachment !== undefined ? { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } : { name: `astro-${task.specialist}-${Date.now()}.md`, content: text };
298
335
  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;
336
+ const posted = await this.rest.createMessage(task.channelId, { content, replyTo: task.messageId, file });
337
+ return [posted.id];
301
338
  }
302
339
  const chunks = chunkMessage(text);
303
340
  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 });
341
+ const ids: string[] = [];
342
+ if (placeholder) {
343
+ await this.rest.editMessage(task.channelId, placeholder, chunks[0]).catch(() => undefined);
344
+ ids.push(placeholder);
345
+ } else ids.push((await this.rest.createMessage(task.channelId, { content: chunks[0], replyTo: task.messageId })).id);
306
346
  for (let i = 1; i < chunks.length; i++) {
307
- await this.rest.createMessage(task.channelId, { content: chunks[i] });
347
+ ids.push((await this.rest.createMessage(task.channelId, { content: chunks[i] })).id);
308
348
  }
349
+ return ids;
309
350
  }
310
351
 
311
352
  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 }[];
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,12 @@ 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");
59
65
  });
60
66
 
61
67
  it("inlines skills after the agent prompt", () => {
@@ -71,16 +71,33 @@ 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;
79
91
  }
80
92
 
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"];
93
+ /** Arguments for the child `pi` process: print mode, JSON events, a session file only when asked. */
94
+ export function buildChildArgs({ agent, task, promptFile, defaults, session }: ChildArgsInput): string[] {
95
+ const args = ["--mode", "json", "-p"];
96
+ if (session) {
97
+ args.push("--session-dir", session.dir);
98
+ if (session.id) args.push("--session-id", session.id);
99
+ else if (session.continue) args.push("--continue");
100
+ } else args.push("--no-session");
84
101
  const model = agent.model ?? defaults.model;
85
102
  if (model) args.push("--model", model);
86
103
  const thinking = agent.thinking ?? (agent.model ? undefined : defaults.thinkingLevel);
@@ -182,6 +199,8 @@ export interface RunOptions {
182
199
  env?: Record<string, string>;
183
200
  /** Text appended to the system prompt, for example channel rules. */
184
201
  extraSystemPrompt?: string;
202
+ /** Saved conversation to continue or start; omitted for a one-off run. */
203
+ session?: ChildSession;
185
204
  }
186
205
 
187
206
  export async function runAgent(options: RunOptions): Promise<RunResult> {
@@ -206,7 +225,8 @@ export async function runAgent(options: RunOptions): Promise<RunResult> {
206
225
  await fs.promises.writeFile(promptFile, prompt, { encoding: "utf-8", mode: 0o600 });
207
226
  }
208
227
  try {
209
- const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults });
228
+ if (options.session) await fs.promises.mkdir(options.session.dir, { recursive: true, mode: 0o700 });
229
+ const args = buildChildArgs({ agent, task, promptFile, defaults: options.defaults, session: options.session });
210
230
  const invocation = (options.invocation ?? piInvocation)(args);
211
231
  const env = buildChildEnv(agent, options.depth, options.remaining, { ...process.env, ...options.env });
212
232
  let aborted = false;
@@ -46,6 +46,7 @@ describe("astro-subagents extension", () => {
46
46
  bundledSkills: join(root, "pkg", "skills"),
47
47
  userAgents: join(root, "user", "agents"),
48
48
  userSkills: join(root, "user", "skills"),
49
+ sessions: join(root, "sessions"),
49
50
  };
50
51
  for (const d of Object.values(dirs)) mkdirSync(d, { recursive: true });
51
52
  writeFileSync(join(dirs.bundledAgents, "arcane.md"), "---\nname: arcane\ndescription: Operates Arcane\n---\nB");
@@ -70,6 +71,16 @@ describe("astro-subagents extension", () => {
70
71
  expect(notify).toHaveBeenCalledWith(expect.stringContaining("depth limit"), "warning");
71
72
  });
72
73
 
74
+ it("posts the /run result without a model turn and names the model in the footer", async () => {
75
+ const pi = makePi();
76
+ astroSubagents(pi as unknown as Parameters<typeof astroSubagents>[0], { dirs, env: {} });
77
+ const setStatus = vi.fn();
78
+ const ctx = { ui: { notify: vi.fn(), setStatus }, hasUI: true, cwd: root, model: { provider: "openai-codex", id: "gpt-6-astra" } };
79
+ await pi.commands.get("run")?.handler("astro.nobody -- x", ctx);
80
+ expect(setStatus).toHaveBeenCalledWith("subagent", expect.stringContaining("astro.nobody on openai-codex/gpt-6-astra"));
81
+ expect(pi.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ customType: "astro-subagents", content: expect.stringContaining("Unknown agent") }), { triggerTurn: false });
82
+ });
83
+
73
84
  it("removes legacy loader copies on session start", async () => {
74
85
  writeFileSync(join(dirs.userAgents, "astro.arcane.md"), "old copy");
75
86
  writeFileSync(join(dirs.userAgents, "mine.md"), "---\nname: mine\ndescription: keep\n---\n");
@@ -82,8 +93,11 @@ describe("astro-subagents extension", () => {
82
93
  });
83
94
 
84
95
  it("parses /run arguments", () => {
85
- expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects" });
86
- expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code" });
96
+ expect(parseRunCommand("astro.arcane -- list projects")).toEqual({ agent: "astro.arcane", task: "list projects", continue: false });
97
+ expect(parseRunCommand("scout find auth code")).toEqual({ agent: "scout", task: "find auth code", continue: false });
98
+ expect(parseRunCommand("astro.security --continue -- do the next step")).toEqual({ agent: "astro.security", task: "do the next step", continue: true });
99
+ expect(parseRunCommand("astro.security --continue next")).toEqual({ agent: "astro.security", task: "next", continue: true });
100
+ expect(parseRunCommand("scout --continue")).toBeNull();
87
101
  expect(parseRunCommand("scout")).toBeNull();
88
102
  expect(pruneLegacyCopies(join(root, "missing"))).toEqual([]);
89
103
  });
@@ -5,7 +5,8 @@ import { StringEnum } from "@earendil-works/pi-ai";
5
5
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { type Static, Type } from "typebox";
7
7
  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";
8
+ import { type ChildSession, childRemaining, currentDepth, type DispatchDefaults, finalOutput, isFailed, resultOutput, type RunResult, runAgent } from "./child.ts";
9
+ import { pruneSessions, sessionDirFor } from "./sessions.ts";
9
10
  import { renderCall, renderResult, type SubagentDetails } from "./render.ts";
10
11
  import { startTicker } from "./ticker.ts";
11
12
 
@@ -77,11 +78,14 @@ export function pruneLegacyCopies(userAgentsDir: string): string[] {
77
78
  }
78
79
 
79
80
  /** 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 {
81
+ /** `/run <agent> [--continue] [--] <task>`; `--continue` resumes the agent's last saved session from this directory. */
82
+ export function parseRunCommand(input: string): { agent: string; task: string; continue: boolean } | null {
81
83
  const trimmed = input.trim();
82
- const match = /^(\S+)\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
84
+ const match = /^(\S+)(\s+--continue)?\s+(?:--\s+)?([\s\S]+)$/.exec(trimmed);
83
85
  if (!match) return null;
84
- return { agent: match[1], task: match[2].trim() };
86
+ const task = match[3].trim();
87
+ if (task === "--continue") return null;
88
+ return { agent: match[1], task, continue: match[2] !== undefined };
85
89
  }
86
90
 
87
91
  export interface GateOptions {
@@ -105,6 +109,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
105
109
  step: number | undefined,
106
110
  signal: AbortSignal | undefined,
107
111
  onUpdate: ((r: RunResult) => void) | undefined,
112
+ session?: ChildSession,
108
113
  ): Promise<RunResult> {
109
114
  const agent = agents.find((a) => a.name === agentName);
110
115
  if (!agent) {
@@ -136,9 +141,16 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
136
141
  step,
137
142
  signal,
138
143
  onUpdate,
144
+ session,
139
145
  });
140
146
  }
141
147
 
148
+ /** Footer label: the agent and the model it will run on. */
149
+ function runLabel(ctx: ExtensionContext, agents: AgentConfig[], agentName: string): string {
150
+ const model = agents.find((a) => a.name === agentName)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
151
+ return model ? `${agentName} on ${model}` : agentName;
152
+ }
153
+
142
154
  if (canDelegate) {
143
155
  const initial = listAgents(process.cwd(), "user");
144
156
  pi.registerTool({
@@ -152,7 +164,13 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
152
164
  ],
153
165
  parameters: Params,
154
166
  async execute(_id, params, signal, onUpdate, ctx) {
155
- const label = params.chain?.length ? `chain of ${params.chain.length}` : params.tasks?.length ? `${params.tasks.length} agents` : (params.agent ?? "subagent");
167
+ const label = params.chain?.length
168
+ ? `chain of ${params.chain.length}`
169
+ : params.tasks?.length
170
+ ? `${params.tasks.length} agents`
171
+ : params.agent
172
+ ? runLabel(ctx, listAgents(ctx.cwd, params.agentScope ?? "user").agents, params.agent)
173
+ : "subagent";
156
174
  const stopTicker = startTicker(ctx, label);
157
175
  try {
158
176
  return await runTool(params, signal, onUpdate, ctx);
@@ -248,7 +266,7 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
248
266
  }
249
267
 
250
268
  pi.registerCommand("run", {
251
- description: "Run an agent once: /run <agent> -- <task>",
269
+ description: "Run an agent: /run <agent> [--continue] -- <task>",
252
270
  handler: async (args, ctx) => {
253
271
  if (!canDelegate) {
254
272
  ctx.ui.notify("subagents: this session may not delegate further (depth limit)", "warning");
@@ -256,23 +274,24 @@ export default function astroSubagents(pi: ExtensionAPI, options: GateOptions =
256
274
  }
257
275
  const parsed = parseRunCommand(args);
258
276
  if (!parsed) {
259
- ctx.ui.notify("usage: /run <agent> -- <task>", "warning");
277
+ ctx.ui.notify("usage: /run <agent> [--continue] -- <task>", "warning");
260
278
  return;
261
279
  }
262
280
  const agents = listAgents(ctx.cwd, "user").agents;
263
- const stopTicker = startTicker(ctx, parsed.agent);
281
+ // Every /run saves its session so a later --continue can pick the conversation up.
282
+ const session: ChildSession = { dir: sessionDirFor(dirs.sessions, parsed.agent), continue: parsed.continue };
283
+ pruneSessions(session.dir);
284
+ const stopTicker = startTicker(ctx, runLabel(ctx, agents, parsed.agent));
264
285
  let result: RunResult;
265
286
  try {
266
- result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined);
287
+ result = await runOne(ctx, agents, parsed.agent, parsed.task, undefined, undefined, undefined, undefined, session);
267
288
  } finally {
268
289
  stopTicker();
269
290
  }
270
- const status = isFailed(result) ? "failed" : "done";
291
+ const status = `${isFailed(result) ? "failed" : "done"}${parsed.continue ? ", continued" : ""}`;
271
292
  if (isFailed(result)) ctx.ui.notify(`${parsed.agent} failed`, "warning");
272
- pi.sendMessage(
273
- { customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true },
274
- { deliverAs: "followUp", triggerTurn: true },
275
- );
293
+ // No turn: the result joins the context for the next prompt without the parent model restating it.
294
+ pi.sendMessage({ customType: "astro-subagents", content: `Result from /run ${parsed.agent} (${status}). Task: ${parsed.task}\n\n${resultOutput(result)}`, display: true }, { triggerTurn: false });
276
295
  },
277
296
  });
278
297
 
@@ -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.1",
3
+ "version": "0.24.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. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows what you may use in that channel; `status` lists running tasks. 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. 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`.