/** * General utility functions for the subagent extension */ import type { Message } from "@earendil-works/pi-ai"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AsyncStatus, DisplayItem, ErrorInfo } from "./types.js"; // ============================================================================ // File System Utilities // ============================================================================ // Cache for status file reads - avoid re-reading unchanged files const statusCache = new Map(); const COMMAND_TOOL_NAMES = new Set(["bash", "native_shell"]); const COMMAND_EXIT_CODE_REGEX = /exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i; const FATAL_COMMAND_OUTPUT_PATTERNS = [ /command not found/i, /permission denied/i, /no such file or directory/i, /segmentation fault/i, /killed|terminated/i, /out of memory/i, /connection refused/i, /timeout/i, ]; /** * Read async job status from disk (with mtime-based caching) */ export function readStatus(asyncDir: string): AsyncStatus | null { const statusPath = path.join(asyncDir, "status.json"); try { const stat = fs.statSync(statusPath); const cached = statusCache.get(statusPath); if (cached && cached.mtime === stat.mtimeMs) { return cached.status; } const content = fs.readFileSync(statusPath, "utf8"); const status = JSON.parse(content) as AsyncStatus; statusCache.set(statusPath, { mtime: stat.mtimeMs, status }); // Limit cache size to prevent memory leaks if (statusCache.size > 50) { const firstKey = statusCache.keys().next().value; if (firstKey) { statusCache.delete(firstKey); } } return status; } catch { return null; } } // Cache for output tail reads - avoid re-reading unchanged files const outputTailCache = new Map(); /** * Get the last N lines from an output file (with mtime/size-based caching) */ export function getOutputTail(outputFile: string | undefined, maxLines: number = 3): string[] { if (!outputFile) { return []; } let fd: number | null = null; try { const stat = fs.statSync(outputFile); if (stat.size === 0) { return []; } // Check cache using both mtime and size (size changes more frequently during writes) const cached = outputTailCache.get(outputFile); if (cached && cached.mtime === stat.mtimeMs && cached.size === stat.size) { return cached.lines; } const tailBytes = 4096; const start = Math.max(0, stat.size - tailBytes); fd = fs.openSync(outputFile, "r"); const buffer = Buffer.alloc(Math.min(tailBytes, stat.size)); fs.readSync(fd, buffer, 0, buffer.length, start); const content = buffer.toString("utf8"); const allLines = content.split("\n").filter((l) => l.trim()); const lines = allLines.slice(-maxLines).map((l) => l.slice(0, 120) + (l.length > 120 ? "..." : "")); // Cache the result outputTailCache.set(outputFile, { lines, mtime: stat.mtimeMs, size: stat.size, }); // Limit cache size if (outputTailCache.size > 20) { const firstKey = outputTailCache.keys().next().value; if (firstKey) { outputTailCache.delete(firstKey); } } return lines; } catch { return []; } finally { if (fd !== null) { try { fs.closeSync(fd); } catch {} } } } /** * Get human-readable last activity time for a file */ export function getLastActivity(outputFile: string | undefined): string { if (!outputFile) { return ""; } try { // Single stat call - throws if file doesn't exist const stat = fs.statSync(outputFile); const ago = Date.now() - stat.mtimeMs; if (ago < 1000) { return "active now"; } if (ago < 60_000) { return `active ${Math.floor(ago / 1000)}s ago`; } return `active ${Math.floor(ago / 60_000)}m ago`; } catch { return ""; } } /** * Find a file/directory by prefix in a directory */ export function findByPrefix(dir: string, prefix: string, suffix?: string): string | null { if (!fs.existsSync(dir)) { return null; } const entries = fs.readdirSync(dir).filter((entry) => entry.startsWith(prefix)); if (suffix) { const withSuffix = entries.filter((entry) => entry.endsWith(suffix)); return withSuffix.length > 0 ? path.join(dir, withSuffix.toSorted()[0]) : null; } if (entries.length === 0) { return null; } return path.join(dir, entries.toSorted()[0]); } /** * Find the latest session file in a directory */ export function findLatestSessionFile(sessionDir: string): string | null { if (!fs.existsSync(sessionDir)) { return null; } const files = fs .readdirSync(sessionDir) .filter((f) => f.endsWith(".jsonl")) .map((f) => { const filePath = path.join(sessionDir, f); return { mtime: fs.statSync(filePath).mtimeMs, path: filePath, }; }) .toSorted((a, b) => b.mtime - a.mtime); return files.length > 0 ? files[0].path : null; } /** * Write a prompt to a temporary file */ export function writePrompt(agent: string, prompt: string): { dir: string; path: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-")); const p = path.join(dir, `${agent.replaceAll(/[^\w.-]/g, "_")}.md`); fs.writeFileSync(p, prompt, { mode: 0o600 }); return { dir, path: p }; } // ============================================================================ // Message Parsing Utilities // ============================================================================ /** * Get the final text output from a list of messages */ export function getFinalOutput(messages: Message[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { for (const part of msg.content) { if (part.type === "text") { return part.text; } } } } return ""; } /** * Extract display items (text and tool calls) from messages */ export function getDisplayItems(messages: Message[]): DisplayItem[] { const items: DisplayItem[] = []; for (const msg of messages) { if (msg.role === "assistant") { for (const part of msg.content) { if (part.type === "text") { items.push({ type: "text", text: part.text }); } else if (part.type === "toolCall") { items.push({ type: "tool", name: part.name, args: part.arguments }); } } } } return items; } /** * Detect errors in subagent execution from messages (only errors with no subsequent success) */ export function detectSubagentError(messages: Message[]): ErrorInfo { // Step 1: Find the last assistant message with text content. // If the agent produced a text response after encountering errors, // It had a chance to recover: only errors AFTER this point matter. let lastAssistantTextIndex = -1; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { const hasText = Array.isArray(msg.content) && msg.content.some((c) => c.type === "text" && "text" in c && (c.text as string).trim().length > 0); if (hasText) { lastAssistantTextIndex = i; break; } } } // Step 2: Only scan tool results AFTER the last assistant text message. // Errors before the agent's final response are implicitly recovered. const scanStart = lastAssistantTextIndex >= 0 ? lastAssistantTextIndex + 1 : 0; // Step 3: Check tool results in the post-response window for (let i = messages.length - 1; i >= scanStart; i--) { const msg = messages[i]; if (msg.role !== "toolResult") { continue; } if ((msg as unknown as { isError: boolean }).isError) { const text = msg.content.find((c) => c.type === "text"); const details = text && "text" in text ? text.text : undefined; const exitMatch = details?.match(COMMAND_EXIT_CODE_REGEX); return { details: details?.slice(0, 200), errorType: (msg as unknown as { toolName: string }).toolName || "tool", exitCode: exitMatch ? parseInt(exitMatch[1], 10) : 1, hasError: true, }; } const { toolName } = msg as unknown as { toolName: string }; if (!COMMAND_TOOL_NAMES.has(toolName)) { continue; } const text = msg.content.find((c) => c.type === "text"); if (!text || !("text" in text)) { continue; } const output = text.text; const exitMatch = output.match(COMMAND_EXIT_CODE_REGEX); if (exitMatch) { const code = Number.parseInt(exitMatch[1], 10); if (code !== 0) { return { details: output.slice(0, 200), errorType: toolName, exitCode: code, hasError: true, }; } } // NOTE: These patterns can match legitimate output (grep results, logs, // Testing). With the assistant-message check above, most false positives // Are mitigated since the agent will have responded after routine errors. for (const pattern of FATAL_COMMAND_OUTPUT_PATTERNS) { if (pattern.test(output)) { return { details: output.slice(0, 200), errorType: toolName, exitCode: 1, hasError: true, }; } } } return { hasError: false }; } /** * Extract a preview of tool arguments for display */ export function extractToolArgsPreview(args: Record): string { // Handle MCP tool calls - show server/tool info if (args.tool && typeof args.tool === "string") { const server = args.server && typeof args.server === "string" ? `${args.server}/` : ""; const toolArgs = args.args && typeof args.args === "string" ? ` ${args.args.slice(0, 40)}` : ""; return `${server}${args.tool}${toolArgs}`; } const previewKeys = ["command", "path", "file_path", "pattern", "query", "url", "task", "describe", "search"]; for (const key of previewKeys) { if (args[key] && typeof args[key] === "string") { const value = args[key] as string; return value.length > 60 ? `${value.slice(0, 57)}...` : value; } } // Fallback: show first string value found for (const [key, value] of Object.entries(args)) { if (typeof value === "string" && value.length > 0) { const preview = value.length > 50 ? `${value.slice(0, 47)}...` : value; return `${key}=${preview}`; } } return ""; } /** * Extract text content from various message content formats */ export function extractTextFromContent(content: unknown): string { if (!content) { return ""; } // Handle string content directly if (typeof content === "string") { return content; } // Handle array content if (!Array.isArray(content)) { return ""; } const texts: string[] = []; for (const part of content) { if (part && typeof part === "object") { // Handle { type: "text", text: "..." } if ("type" in part && part.type === "text" && "text" in part) { texts.push(String(part.text)); } // Handle { type: "tool_result", content: "..." } else if ("type" in part && part.type === "tool_result" && "content" in part) { const inner = extractTextFromContent(part.content); if (inner) { texts.push(inner); } } // Handle { text: "..." } without type else if ("text" in part) { texts.push(String(part.text)); } } } return texts.join("\n"); } // ============================================================================ // Concurrency Utilities // ============================================================================ /** * Map over items with limited concurrency */ export async function mapConcurrent( items: T[], limit: number, fn: (item: T, i: number) => Promise, staggerMs = 150, ): Promise { // Clamp to at least 1; NaN/undefined/0/negative all become 1 const safeLimit = Math.max(1, Math.floor(limit) || 1); const results: R[] = new Array(items.length); let next = 0; async function worker(workerIndex: number): Promise { if (staggerMs > 0 && workerIndex > 0) { await new Promise((r) => setTimeout(r, workerIndex * staggerMs)); } while (next < items.length) { const i = next++; results[i] = await fn(items[i], i); } } const workers = Array.from({ length: Math.min(safeLimit, items.length) }, (_, wi) => worker(wi)); await Promise.all(workers); return results; }