@astrofoundry/pi-astro 0.22.3 → 0.22.4

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.
@@ -7,6 +7,8 @@ import { chunkMessage, fenceState } from "./chunk.ts";
7
7
  import { helpText, parseCommand } from "./commands.ts";
8
8
  import { allowedSpecialists, canUse, loadConfig, loadToken, validateConfig } from "./config.ts";
9
9
  import { INTENTS, initialState, reduce } from "./gateway.ts";
10
+ import type { RunResult } from "../astro-subagents/child.ts";
11
+ import { failureReply } from "./index.ts";
10
12
  import { DiscordRest } from "./rest.ts";
11
13
 
12
14
  const ID = "123456789012345678";
@@ -147,6 +149,17 @@ describe("rest", () => {
147
149
  });
148
150
  });
149
151
 
152
+ describe("failure reply", () => {
153
+ it("names the reason and keeps stderr as an attachment", () => {
154
+ const base: RunResult = { agent: "astro.dns", agentSource: "bundled", task: "t", exitCode: 1, messages: [], stderr: "", usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 } };
155
+ expect(failureReply({ ...base, errorMessage: "timed out after 30 min" })).toEqual({ text: "timed out after 30 min" });
156
+ expect(failureReply({ ...base, stderr: "noise\n" })).toEqual({ text: "the specialist process exited with code 1", attachment: "noise" });
157
+ expect(failureReply({ ...base, stopReason: "aborted" as RunResult["stopReason"] }).text).toBe("the run was stopped");
158
+ const withWords = failureReply({ ...base, messages: [{ role: "assistant", content: [{ type: "text", text: "I asked for approval." }] } as never] });
159
+ expect(withWords.text).toContain("Last words of the specialist:\nI asked for approval.");
160
+ });
161
+ });
162
+
150
163
  describe("approval server", () => {
151
164
  it("creates tickets, reports decisions, expires, and rejects bad tokens", async () => {
152
165
  const tickets: Ticket[] = [];
@@ -2,7 +2,7 @@ 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 { type DispatchDefaults, childRemaining, currentDepth, isFailed, resultOutput, runAgent } from "../astro-subagents/child.ts";
5
+ import { AGENT_ENV, type DispatchDefaults, type RunResult, childRemaining, currentDepth, finalOutput, isFailed, runAgent } from "../astro-subagents/child.ts";
6
6
  import { ApprovalServer, type Ticket } from "./approvals.ts";
7
7
  import { chunkMessage } from "./chunk.ts";
8
8
  import { CHANNEL_RULES, helpText, parseCommand } from "./commands.ts";
@@ -13,6 +13,40 @@ import { DiscordRest, type MessageComponent } from "./rest.ts";
13
13
  const REACTION = { running: "ā³", waiting: "šŸ”’", done: "āœ…", failed: "āŒ" } as const;
14
14
  /** Answers longer than this go as a file attachment with a short summary. */
15
15
  const ATTACHMENT_THRESHOLD = 6000;
16
+ /** Upper bound for one specialist run started from chat; the wrappers' own timeouts are shorter. */
17
+ const TASK_TIMEOUT_MINUTES = 30;
18
+
19
+ interface ToolCallPart {
20
+ type?: string;
21
+ name?: string;
22
+ arguments?: unknown;
23
+ }
24
+
25
+ /** One log line per new message: tool calls with their arguments, tool results, and assistant text lengths. */
26
+ function describeProgress(result: RunResult, seen: number): { lines: string[]; seen: number } {
27
+ const lines: string[] = [];
28
+ for (const message of result.messages.slice(seen)) {
29
+ if (message.role === "assistant") {
30
+ for (const part of message.content as ToolCallPart[]) {
31
+ if (part.type === "toolCall") lines.push(`tool ${part.name ?? "?"} ${JSON.stringify(part.arguments ?? {}).slice(0, 300)}`);
32
+ else if (part.type === "text") lines.push(`assistant text ${((part as { text?: string }).text ?? "").length} chars`);
33
+ }
34
+ } else if (message.role === "toolResult") {
35
+ const { toolName, isError } = message as { toolName?: string; isError?: boolean };
36
+ lines.push(`result ${toolName ?? "?"}${isError ? " (error)" : ""}`);
37
+ }
38
+ }
39
+ return { lines, seen: result.messages.length };
40
+ }
41
+
42
+ /** Reply for a failed run: the reason in one line, the specialist's last words if any, raw stderr only as an attachment. */
43
+ export function failureReply(result: RunResult): { text: string; attachment?: string } {
44
+ const reason = result.errorMessage?.trim() || (result.stopReason === "aborted" ? "the run was stopped" : `the specialist process exited with code ${result.exitCode}`);
45
+ const lastWords = finalOutput(result.messages).trim();
46
+ const text = lastWords.length > 0 ? `${reason}\n\nLast words of the specialist:\n${lastWords}` : reason;
47
+ const stderr = result.stderr.trim();
48
+ return stderr.length > 0 ? { text, attachment: stderr } : { text };
49
+ }
16
50
 
17
51
  interface IncomingMessage {
18
52
  id: string;
@@ -162,21 +196,35 @@ class Bridge {
162
196
  const { depth, remaining } = currentDepth();
163
197
  let failed = true;
164
198
  let output: string;
199
+ let attachment: string | undefined;
200
+ let seen = 0;
165
201
  try {
166
202
  const result = await runAgent({
167
- agent,
203
+ agent: { ...agent, timeoutMinutes: agent.timeoutMinutes ?? TASK_TIMEOUT_MINUTES },
168
204
  skills,
169
205
  task: taskText,
170
206
  cwd: process.cwd(),
171
207
  depth,
172
208
  remaining: childRemaining(remaining, agent),
173
209
  defaults,
174
- env: { [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
210
+ // The child must not become a second Discord bridge.
211
+ env: { [ACTIVATION_ENV]: "0", [APPROVAL_URL_ENV]: this.approvals.url, [APPROVAL_TOKEN_ENV]: this.approvals.token, ASTRO_CHANNEL: "discord" },
175
212
  extraSystemPrompt: CHANNEL_RULES,
213
+ onUpdate: (partial) => {
214
+ const progress = describeProgress(partial, seen);
215
+ seen = progress.seen;
216
+ for (const line of progress.lines) log(`astro.${task.specialist}: ${line}`);
217
+ },
176
218
  });
177
219
  failed = isFailed(result);
178
- output = resultOutput(result);
179
- log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, cost ${result.usage.cost.toFixed(4)}`);
220
+ if (failed) {
221
+ const reply = failureReply(result);
222
+ output = reply.text;
223
+ attachment = reply.attachment;
224
+ } else {
225
+ output = finalOutput(result.messages) || "(no output)";
226
+ }
227
+ log(`astro.${task.specialist} ${failed ? "failed" : "done"} in ${Math.round((Date.now() - task.startedAt) / 1000)} s, ${result.usage.turns} turns, model spend ${result.usage.cost.toFixed(4)} USD`);
180
228
  } catch (err) {
181
229
  output = err instanceof Error ? err.message : String(err);
182
230
  } finally {
@@ -184,10 +232,14 @@ class Bridge {
184
232
  }
185
233
  await this.rest.removeOwnReaction(task.channelId, task.messageId, REACTION.running).catch(() => undefined);
186
234
  await this.rest.addReaction(task.channelId, task.messageId, failed ? REACTION.failed : REACTION.done).catch(() => undefined);
187
- await this.post(task, `${failed ? "āŒ" : "āœ…"} **${task.specialist}**\n${output}`);
235
+ await this.post(task, `${failed ? "āŒ" : "āœ…"} **${task.specialist}**\n${output}`, attachment);
188
236
  }
189
237
 
190
- private async post(task: ActiveTask, text: string): Promise<void> {
238
+ private async post(task: ActiveTask, text: string, attachment?: string): Promise<void> {
239
+ if (attachment !== undefined) {
240
+ await this.rest.createMessage(task.channelId, { content: text.slice(0, 1900), replyTo: task.messageId, file: { name: `astro-${task.specialist}-stderr-${Date.now()}.txt`, content: attachment } });
241
+ return;
242
+ }
191
243
  if (text.length > ATTACHMENT_THRESHOLD) {
192
244
  const summary = `${text.slice(0, 1200).trim()}\n… full answer attached (${text.length} characters).`;
193
245
  await this.rest.createMessage(task.channelId, { content: summary, replyTo: task.messageId, file: { name: `astro-${task.specialist}-${Date.now()}.md`, content: text } });
@@ -252,7 +304,7 @@ class Bridge {
252
304
  * the headless LaunchAgent host sets; interactive sessions never connect.
253
305
  */
254
306
  export default function astroDiscord(pi: ExtensionAPI): void {
255
- if (process.env[ACTIVATION_ENV] !== "1") return;
307
+ if (process.env[ACTIVATION_ENV] !== "1" || process.env[AGENT_ENV]) return;
256
308
  pi.on("session_start", async (_event, ctx: ExtensionContext) => {
257
309
  const loaded = loadConfig();
258
310
  if (!loaded.config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrofoundry/pi-astro",
3
- "version": "0.22.3",
3
+ "version": "0.22.4",
4
4
  "description": "Personal pi customizations (extensions, subagents, skills, prompts, themes) for the pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"