pi-subagents 0.13.4 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/utils.ts CHANGED
@@ -6,13 +6,12 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import type { Message } from "@mariozechner/pi-ai";
9
- import type { AsyncStatus, DisplayItem, ErrorInfo, SingleResult } from "./types.ts";
9
+ import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, SingleResult } from "./types.ts";
10
10
 
11
11
  // ============================================================================
12
12
  // File System Utilities
13
13
  // ============================================================================
14
14
 
15
- // Cache for status file reads - avoid re-reading unchanged files
16
15
  const statusCache = new Map<string, { mtime: number; status: AsyncStatus }>();
17
16
 
18
17
  function getErrorMessage(error: unknown): string {
@@ -74,7 +73,6 @@ export function readStatus(asyncDir: string): AsyncStatus | null {
74
73
  return status;
75
74
  }
76
75
 
77
- // Cache for output tail reads - avoid re-reading unchanged files
78
76
  const outputTailCache = new Map<string, { mtime: number; size: number; lines: string[] }>();
79
77
 
80
78
  /**
@@ -87,7 +85,6 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
87
85
  const stat = fs.statSync(outputFile);
88
86
  if (stat.size === 0) return [];
89
87
 
90
- // Check cache using both mtime and size (size changes more frequently during writes)
91
88
  const cached = outputTailCache.get(outputFile);
92
89
  if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) {
93
90
  return cached.lines;
@@ -102,9 +99,7 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
102
99
  const allLines = content.split("\n").filter((l) => l.trim());
103
100
  const lines = allLines.slice(-maxLines).map((l) => l.slice(0, 120) + (l.length > 120 ? "..." : ""));
104
101
 
105
- // Cache the result
106
102
  outputTailCache.set(outputFile, { mtime: stat.mtimeMs, size: stat.size, lines });
107
- // Limit cache size
108
103
  if (outputTailCache.size > 20) {
109
104
  const firstKey = outputTailCache.keys().next().value;
110
105
  if (firstKey) outputTailCache.delete(firstKey);
@@ -112,12 +107,15 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
112
107
 
113
108
  return lines;
114
109
  } catch {
110
+ // Output tails are UI-only hints; unreadable or missing files should render as no tail.
115
111
  return [];
116
112
  } finally {
117
113
  if (fd !== null) {
118
114
  try {
119
115
  fs.closeSync(fd);
120
- } catch {}
116
+ } catch {
117
+ // Closing the best-effort tail file handle should not surface over the main status view.
118
+ }
121
119
  }
122
120
  }
123
121
  }
@@ -125,16 +123,16 @@ export function getOutputTail(outputFile: string | undefined, maxLines: number =
125
123
  /**
126
124
  * Get human-readable last activity time for a file
127
125
  */
128
- export function getLastActivity(outputFile: string | undefined): string {
126
+ export function getLastActivity(outputFile: string | undefined): string {
129
127
  if (!outputFile) return "";
130
128
  try {
131
- // Single stat call - throws if file doesn't exist
132
129
  const stat = fs.statSync(outputFile);
133
130
  const ago = Date.now() - stat.mtimeMs;
134
131
  if (ago < 1000) return "active now";
135
132
  if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`;
136
133
  return `active ${Math.floor(ago / 60000)}m ago`;
137
134
  } catch {
135
+ // Last-activity text is best effort; missing files should simply omit the hint.
138
136
  return "";
139
137
  }
140
138
  }
@@ -201,13 +199,14 @@ export function getFinalOutput(messages: Message[]): string {
201
199
  }
202
200
 
203
201
  export function getSingleResultOutput(result: Pick<SingleResult, "finalOutput" | "messages">): string {
204
- return result.finalOutput ?? getFinalOutput(result.messages);
202
+ return result.finalOutput ?? getFinalOutput(result.messages ?? []);
205
203
  }
206
204
 
207
205
  /**
208
206
  * Extract display items (text and tool calls) from messages
209
207
  */
210
- export function getDisplayItems(messages: Message[]): DisplayItem[] {
208
+ export function getDisplayItems(messages: Message[] | undefined): DisplayItem[] {
209
+ if (!messages || messages.length === 0) return [];
211
210
  const items: DisplayItem[] = [];
212
211
  for (const msg of messages) {
213
212
  if (msg.role === "assistant") {
@@ -220,19 +219,53 @@ export function getDisplayItems(messages: Message[]): DisplayItem[] {
220
219
  return items;
221
220
  }
222
221
 
222
+ function compactCompletedProgress(progress: AgentProgress): AgentProgress {
223
+ if (progress.status === "running") return progress;
224
+ return {
225
+ index: progress.index,
226
+ agent: progress.agent,
227
+ status: progress.status,
228
+ task: progress.task,
229
+ skills: progress.skills,
230
+ toolCount: progress.toolCount,
231
+ tokens: progress.tokens,
232
+ durationMs: progress.durationMs,
233
+ error: progress.error,
234
+ failedTool: progress.failedTool,
235
+ recentTools: [],
236
+ recentOutput: [],
237
+ };
238
+ }
239
+
240
+ export function compactForegroundResult(result: SingleResult): SingleResult {
241
+ if (result.progress?.status === "running") return result;
242
+ return {
243
+ ...result,
244
+ messages: undefined,
245
+ progress: undefined,
246
+ };
247
+ }
248
+
249
+ export function compactForegroundDetails(details: Details): Details {
250
+ return {
251
+ ...details,
252
+ results: details.results.map(compactForegroundResult),
253
+ progress: details.progress
254
+ ? details.progress.map(compactCompletedProgress)
255
+ : undefined,
256
+ };
257
+ }
258
+
223
259
  /**
224
260
  * Detect errors in subagent execution from messages (only errors with no subsequent success)
225
261
  */
226
262
  export function detectSubagentError(messages: Message[]): ErrorInfo {
227
- // Step 1: Find the last assistant message with text content.
228
- // If the agent produced a text response after encountering errors,
229
- // it had a chance to recover — only errors AFTER this point matter.
230
263
  let lastAssistantTextIndex = -1;
231
264
  for (let i = messages.length - 1; i >= 0; i--) {
232
265
  const msg = messages[i];
233
266
  if (msg.role === "assistant") {
234
267
  const hasText = Array.isArray(msg.content) && msg.content.some(
235
- (c) => c.type === "text" && "text" in c && (c.text as string).trim().length > 0,
268
+ (c) => c.type === "text" && "text" in c && typeof c.text === "string" && c.text.trim().length > 0,
236
269
  );
237
270
  if (hasText) {
238
271
  lastAssistantTextIndex = i;
@@ -241,28 +274,26 @@ export function detectSubagentError(messages: Message[]): ErrorInfo {
241
274
  }
242
275
  }
243
276
 
244
- // Step 2: Only scan tool results AFTER the last assistant text message.
245
- // Errors before the agent's final response are implicitly recovered.
246
277
  const scanStart = lastAssistantTextIndex >= 0 ? lastAssistantTextIndex + 1 : 0;
247
278
 
248
- // Step 3: Check tool results in the post-response window
249
279
  for (let i = messages.length - 1; i >= scanStart; i--) {
250
280
  const msg = messages[i];
251
281
  if (msg.role !== "toolResult") continue;
282
+ const toolName = "toolName" in msg && typeof msg.toolName === "string" ? msg.toolName : undefined;
283
+ const isError = "isError" in msg && msg.isError === true;
252
284
 
253
- if ((msg as any).isError) {
285
+ if (isError) {
254
286
  const text = msg.content.find((c) => c.type === "text");
255
287
  const details = text && "text" in text ? text.text : undefined;
256
288
  const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i);
257
289
  return {
258
290
  hasError: true,
259
291
  exitCode: exitMatch ? parseInt(exitMatch[1], 10) : 1,
260
- errorType: (msg as any).toolName || "tool",
292
+ errorType: toolName || "tool",
261
293
  details: details?.slice(0, 200),
262
294
  };
263
295
  }
264
296
 
265
- const toolName = (msg as any).toolName;
266
297
  if (toolName !== "bash") continue;
267
298
 
268
299
  const text = msg.content.find((c) => c.type === "text");