/** * General utility functions for the subagent extension */ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { Message } from "@earendil-works/pi-ai"; import { formatToolCall } from "./formatters.ts"; import type { AgentProgress, AsyncStatus, ChainOutputMap, Details, DisplayItem, ErrorInfo, NestedRunSummary, SingleResult, ToolCallSummary, Usage } from "./types.ts"; // ============================================================================ // File System Utilities // ============================================================================ const DEFAULT_CONFIG_DIR_NAME = ".selesai"; const PI_CODING_AGENT_PACKAGE_NAME = "@selesai/code"; function stripThinkingTagsFromText(text: string): string { return text.replace(/[\s\S]*?<\/think>/g, "").replace(/^[\s\S]*$/, ""); } export const SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV = "SELESAI_SUBAGENTS_SELESAI_PACKAGE_ROOT"; export function resolveWatchPath( watchPath: string, nativeRealpath: (filePath: string) => string = fs.realpathSync.native, ): string { // libuv's Windows watcher cannot mix 8.3 registration paths with long event paths. try { return nativeRealpath(watchPath); } catch { return watchPath; } } function validConfigDirName(value: unknown): string | undefined { return typeof value === "string" && value.trim() ? value : undefined; } function readConfigDirNameFromPackageRoot(packageRoot: string | undefined): string | undefined { if (!packageRoot) return undefined; try { const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8")) as { name?: unknown; piConfig?: { configDir?: unknown }; }; if (pkg.name !== PI_CODING_AGENT_PACKAGE_NAME) return undefined; return validConfigDirName(pkg.piConfig?.configDir); } catch { return undefined; } } function resolveConfigDirNameFromPackageJson(entryPoint = process.argv[1], packageRoot = process.env[SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV]): string | undefined { const packageRootValue = readConfigDirNameFromPackageRoot(packageRoot); if (packageRootValue) return packageRootValue; if (!entryPoint) return undefined; try { let dir = path.dirname(fs.realpathSync(entryPoint)); while (dir !== path.dirname(dir)) { const value = readConfigDirNameFromPackageRoot(dir); if (value) return value; dir = path.dirname(dir); } } catch { // Package metadata lookup is best-effort; detached runners must not fail here. } return undefined; } export function resolveConfigDirName(codingAgentModule?: unknown, entryPoint?: string, packageRoot?: string): string { const moduleValue = codingAgentModule && typeof codingAgentModule === "object" ? validConfigDirName((codingAgentModule as { CONFIG_DIR_NAME?: unknown }).CONFIG_DIR_NAME) : undefined; return moduleValue ?? resolveConfigDirNameFromPackageJson(entryPoint, packageRoot) ?? DEFAULT_CONFIG_DIR_NAME; } let cachedConfigDirName: { entryPoint: string | undefined; packageRoot: string | undefined; value: string } | undefined; export function getConfigDirName(): string { const entryPoint = process.argv[1]; const packageRoot = process.env[SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV]; if (cachedConfigDirName && cachedConfigDirName.entryPoint === entryPoint && cachedConfigDirName.packageRoot === packageRoot) { return cachedConfigDirName.value; } const value = resolveConfigDirName(undefined, entryPoint, packageRoot); cachedConfigDirName = { entryPoint, packageRoot, value }; return value; } export function getProjectConfigDir(projectRoot: string): string { return path.join(projectRoot, getConfigDirName()); } export function getAgentDir(): string { const configured = process.env.SELESAI_CODING_AGENT_DIR; if (configured === "~") return os.homedir(); if (configured?.startsWith("~/")) return path.join(os.homedir(), configured.slice(2)); return configured || path.join(os.homedir(), getConfigDirName(), "agent"); } const statusCache = new Map(); function getErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } export function resolveChildCwd(baseCwd: string, childCwd: string | undefined): string { if (!childCwd) return baseCwd; return path.isAbsolute(childCwd) ? childCwd : path.resolve(baseCwd, childCwd); } function isNotFoundError(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT"; } /** * Read async job status from disk (with mtime-based caching) */ export function readStatus(asyncDir: string): AsyncStatus | null { const statusPath = path.join(asyncDir, "status.json"); let stat: fs.Stats; try { stat = fs.statSync(statusPath); } catch (error) { if (isNotFoundError(error)) return null; throw new Error(`Failed to inspect async status file '${statusPath}': ${getErrorMessage(error)}`, { cause: error instanceof Error ? error : undefined, }); } const cached = statusCache.get(statusPath); if ( cached && cached.mtime === stat.mtimeMs && cached.ctime === stat.ctimeMs && cached.size === stat.size && cached.ino === stat.ino ) { return cached.status; } let content: string; try { content = fs.readFileSync(statusPath, "utf-8"); } catch (error) { if (isNotFoundError(error)) return null; throw new Error(`Failed to read async status file '${statusPath}': ${getErrorMessage(error)}`, { cause: error instanceof Error ? error : undefined, }); } let status: AsyncStatus; try { status = JSON.parse(content) as AsyncStatus; } catch (error) { throw new Error(`Failed to parse async status file '${statusPath}': ${getErrorMessage(error)}`, { cause: error instanceof Error ? error : undefined, }); } statusCache.set(statusPath, { mtime: stat.mtimeMs, ctime: stat.ctimeMs, size: stat.size, ino: stat.ino, status, }); if (statusCache.size > 50) { const firstKey = statusCache.keys().next().value; if (firstKey) statusCache.delete(firstKey); } return status; } const outputTailCache = new Map(); /** * Get the last N lines from an output file (with mtime/size-based caching) */ 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 []; 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("utf-8"); const allLines = content.split("\n").filter((l) => l.trim()); const lines = allLines.slice(-maxLines).map((l) => l.slice(0, 120) + (l.length > 120 ? "..." : "")); outputTailCache.set(outputFile, { mtime: stat.mtimeMs, size: stat.size, lines }); if (outputTailCache.size > 20) { const firstKey = outputTailCache.keys().next().value; if (firstKey) outputTailCache.delete(firstKey); } return lines; } catch { // Output tails are UI-only hints; unreadable or missing files should render as no tail. return []; } finally { if (fd !== null) { try { fs.closeSync(fd); } catch { // Closing the best-effort tail file handle should not surface over the main status view. } } } } /** * Get human-readable last activity time for a file */ export function getLastActivity(outputFile: string | undefined): string { if (!outputFile) return ""; try { const stat = fs.statSync(outputFile); const ago = Date.now() - stat.mtimeMs; if (ago < 1000) return "active now"; if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`; return `active ${Math.floor(ago / 60000)}m ago`; } catch { // Last-activity text is best effort; missing files should simply omit the hint. return ""; } } /** * 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 { path: filePath, mtime: fs.statSync(filePath).mtimeMs, }; }) .sort((a, b) => b.mtime - a.mtime); return files.length > 0 ? files[0].path : null; } /** * Write a prompt to a temporary file */ 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.replace(/[^\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 { const validTextParts: string[] = []; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role !== "assistant") continue; const hasAssistantError = ("errorMessage" in msg && typeof msg.errorMessage === "string" && msg.errorMessage.length > 0) || ("stopReason" in msg && msg.stopReason === "error"); if (hasAssistantError) continue; const messageText = msg.content .filter((part) => part.type === "text" && part.text.trim().length > 0) .map((part) => part.type === "text" ? part.text : "") .join("\n"); for (let j = msg.content.length - 1; j >= 0; j--) { const part = msg.content[j]; if (part.type !== "text" || part.text.trim().length === 0) continue; validTextParts.push(part.text); if (/```acceptance[-_]report\s*\n[\s\S]*?```/i.test(part.text)) return messageText; for (const match of part.text.matchAll(/```(?:json|jsonc|json5)\s*\n([\s\S]*?)```/gi)) { const body = match[1] ?? ""; if (/"(?:criteriaSatisfied|criteria_satisfied)"/.test(body) && /"(?:changedFiles|changed_files|testsAddedOrUpdated|tests_added_or_updated|commandsRun|commands_run|validationOutput|validation_output|residualRisks|residual_risks|noStagedFiles|no_staged_files|diffSummary|diff_summary|reviewFindings|review_findings|manualNotes|manual_notes)"/.test(body)) { return messageText; } } if (/ACCEPTANCE_REPORT\s*:/i.test(part.text)) return messageText; } } return validTextParts[0] ?? ""; } export function getSingleResultOutput(result: Pick): string { return result.finalOutput ?? getFinalOutput(result.messages ?? []); } /** * Extract display items (text and tool calls) from messages */ export function getDisplayItems(messages: Message[] | undefined): DisplayItem[] { if (!messages || messages.length === 0) return []; 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; } function compactCompletedProgress(progress: AgentProgress): AgentProgress { if (progress.status === "running") return progress; return { index: progress.index, agent: progress.agent, status: progress.status, activityState: progress.activityState, task: progress.task, skills: progress.skills, toolCount: progress.toolCount, tokens: progress.tokens, durationMs: progress.durationMs, error: progress.error, failedTool: progress.failedTool, recentTools: [], recentOutput: [], }; } function extractToolCallSummaries(messages: Message[] | undefined): ToolCallSummary[] { if (!messages?.length) return []; const summaries: ToolCallSummary[] = []; for (const msg of messages) { if (msg.role !== "assistant") continue; for (const part of msg.content) { if (part.type !== "toolCall") continue; const args = typeof part.arguments === "object" && part.arguments !== null && !Array.isArray(part.arguments) ? part.arguments : {}; summaries.push({ text: formatToolCall(part.name, args), expandedText: formatToolCall(part.name, args, true), }); } } return summaries; } export function sumResultsUsage(results: SingleResult[]): Usage { const usage: Usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 }; for (const result of results) { usage.input += result.usage.input; usage.output += result.usage.output; usage.cacheRead += result.usage.cacheRead; usage.cacheWrite += result.usage.cacheWrite; usage.cost += result.usage.cost; usage.turns += result.usage.turns; } return usage; } function addNestedCost(total: NonNullable, children: NestedRunSummary[] | undefined): void { for (const child of children ?? []) { if (child.totalCost) { total.inputTokens += child.totalCost.inputTokens; total.outputTokens += child.totalCost.outputTokens; total.costUsd += child.totalCost.costUsd; continue; } addNestedCost(total, child.children); for (const step of child.steps ?? []) addNestedCost(total, step.children); } } /** Sum input tokens, output tokens, and cost across a set of SingleResults. */ export function sumResultsCost(results: SingleResult[]): NonNullable { const total = { inputTokens: 0, outputTokens: 0, costUsd: 0 }; for (const result of results) { total.inputTokens += result.usage.input; total.outputTokens += result.usage.output; total.costUsd += result.usage.cost; addNestedCost(total, result.children); } return total; } export function compactForegroundResult(result: SingleResult): SingleResult { if (result.progress?.status === "running") return result; const toolCalls = result.toolCalls?.length ? result.toolCalls : extractToolCallSummaries(result.messages); return { ...result, messages: undefined, progress: undefined, toolCalls: toolCalls.length ? toolCalls : undefined, // Reference-first terminal details: once an authoritative saved output path // exists, drop the raw final output and truncation marker from the model- // visible projection; consumers recover the file from `savedOutputPath`. // Explicit `outputMode: "inline"` is the sole legacy full-text opt-out and // keeps its final output in the terminal projection (e.g. delegation v1 // `response.output` stays populated). finalOutput: result.savedOutputPath && result.outputMode !== "inline" ? undefined : result.finalOutput, truncation: result.savedOutputPath && result.outputMode !== "inline" ? undefined : result.truncation, }; } /** * Strip chain `details.outputs` text/structured payloads from the terminal * projection while retaining the output names and step metadata. Chain output * bindings themselves remain reference-first in the completion content and * `{outputs.name}` interpolation (see outputEntryFromResult). */ function compactChainOutputs(outputs: ChainOutputMap | undefined): ChainOutputMap | undefined { if (!outputs) return undefined; const compact: ChainOutputMap = {}; for (const [name, entry] of Object.entries(outputs)) { compact[name] = { agent: entry.agent, stepIndex: entry.stepIndex, text: "", }; } return compact; } export function compactForegroundDetails(details: Details): Details { return { ...details, outputs: compactChainOutputs(details.outputs), results: details.results.map(compactForegroundResult), progress: details.progress ? details.progress.map(compactCompletedProgress) : undefined, }; } /** * Streaming counterparts to compactForegroundResult / compactCompletedProgress. * * The completed-compaction helpers above bail out while a child is still * `running`, so a long or deeply nested fan-out streams full, unbounded progress on * every tick. Pi serializes each streamed `tool_execution_update` as a single * child-stdout line, which the parent reads under `MAX_CHILD_PENDING_LINE_BYTES`; * an unbounded running snapshot can cross that cap and kill the child with * `protocol_output_limit`. * * These bound the STREAMED snapshot only. The final returned result keeps the full * live progress and message transcript, and every live-display consumer already * reads just the last few entries (`recentTools.slice(-3)`, `recentOutput.slice(-5)`). */ export const MAX_STREAMED_RECENT_TOOLS = 32; export const MAX_STREAMED_TOOL_CALLS = 64; export const MAX_STREAMED_OUTPUT_LINE_CHARS = 2000; /** Keep only the most recent tool-history entries in a streamed snapshot. */ export function boundStreamedRecentTools(recentTools: AgentProgress["recentTools"]): AgentProgress["recentTools"] { return recentTools.slice(-MAX_STREAMED_RECENT_TOOLS).map((tool) => ({ ...tool })); } /** Cap per-line length of recent output so one long line can't inflate a snapshot. */ export function boundStreamedRecentOutput(recentOutput: string[]): string[] { return recentOutput.map((line) => line.length > MAX_STREAMED_OUTPUT_LINE_CHARS ? `${line.slice(0, MAX_STREAMED_OUTPUT_LINE_CHARS)}… [truncated]` : line, ); } /** * Compact tool-call summaries for a streamed snapshot, standing in for the * unbounded `messages` transcript. Prefers an existing `toolCalls` summary, else * derives one from `messages`; bounded to the most recent calls. */ export function boundStreamedToolCalls(result: Pick): ToolCallSummary[] | undefined { const summaries = result.toolCalls?.length ? result.toolCalls : extractToolCallSummaries(result.messages); if (!summaries.length) return undefined; return summaries.slice(-MAX_STREAMED_TOOL_CALLS).map((summary) => ({ ...summary })); } export function hasEmptyTerminalAssistantResponse(messages: Message[]): boolean { const lastAssistant = messages.findLast((message) => message.role === "assistant"); return lastAssistant?.role === "assistant" && Array.isArray(lastAssistant.content) && lastAssistant.content.length === 0 && lastAssistant.usage.output === 0; } /** * Detect errors in subagent execution from messages (only errors with no subsequent success) */ export function detectSubagentError(messages: Message[]): ErrorInfo { let recoveryIndex = -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 && typeof c.text === "string" && c.text.trim().length > 0, ); if (hasText) { recoveryIndex = i; break; } } } const terminalAssistantIndex = messages.findLastIndex((message) => message.role === "assistant"); const terminalAssistant = messages[terminalAssistantIndex]; const cleanTerminalStop = terminalAssistant?.role === "assistant" && terminalAssistant.stopReason === "stop" && !terminalAssistant.errorMessage && !terminalAssistant.content.some((part) => part.type === "toolCall"); if (cleanTerminalStop) { for (let i = terminalAssistantIndex - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role !== "toolResult") continue; if (msg.isError !== true) recoveryIndex = Math.max(recoveryIndex, i); break; } } const scanStart = recoveryIndex >= 0 ? recoveryIndex + 1 : 0; for (let i = messages.length - 1; i >= scanStart; i--) { const msg = messages[i]; if (msg.role !== "toolResult") continue; const toolName = "toolName" in msg && typeof msg.toolName === "string" ? msg.toolName : undefined; const isError = "isError" in msg && msg.isError === true; if (!isError) continue; const text = msg.content.find((c) => c.type === "text"); const details = text && "text" in text ? text.text : undefined; const exitMatch = details?.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i); return { hasError: true, exitCode: exitMatch ? parseInt(exitMatch[1], 10) : 1, errorType: toolName || "tool", details: details?.slice(0, 200), }; } return { hasError: false }; } /** * Extract a preview of tool arguments for display */ export function extractToolArgsPreview(args: Record): string { const truncatePreview = (value: string, maxLength: number): string => value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value; const stringifyPreviewValue = (value: unknown): string | undefined => { if (typeof value === "string" && value.trim().length > 0) return value; if (typeof value === "number" || typeof value === "boolean") return String(value); return undefined; }; const previewArray = (value: unknown): string | undefined => { if (!Array.isArray(value) || value.length === 0) return undefined; const first = stringifyPreviewValue(value[0]); if (!first) return undefined; const suffix = value.length > 1 ? ` (+${value.length - 1} more)` : ""; return `${first}${suffix}`; }; // 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 queriesPreview = previewArray(args.queries); if (queriesPreview) return truncatePreview(queriesPreview, 60); if (typeof args.query === "string" && args.query.trim().length > 0) return truncatePreview(args.query, 60); if (typeof args.workflow === "string" && args.workflow.trim().length > 0) return `workflow=${truncatePreview(args.workflow, 48)}`; if (typeof args.url === "string" && args.url.trim().length > 0) return truncatePreview(args.url, 60); const urlsPreview = previewArray(args.urls); if (urlsPreview) return truncatePreview(urlsPreview, 60); if (typeof args.prompt === "string" && args.prompt.trim().length > 0) return truncatePreview(args.prompt, 60); 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 truncatePreview(value, 60); } } // Fallback: show first string value found for (const [key, value] of Object.entries(args)) { const arrayPreview = previewArray(value); if (arrayPreview) return `${key}=${truncatePreview(arrayPreview, 50)}`; if (typeof value === "string" && value.length > 0) { const preview = truncatePreview(value, 50); return `${key}=${preview}`; } } return ""; } /** * Extract text content from various message content formats */ export function extractTextFromContent(content: unknown): string { if (!content) return ""; // Selesai normalizes providers that embed blocks in text output. if (typeof content === "string") return stripThinkingTagsFromText(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) { const text = stripThinkingTagsFromText(String(part.text)); if (text) texts.push(text); } else if ("type" in part && part.type === "thinking") { continue; } // 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) { const text = stripThinkingTagsFromText(String(part.text)); if (text) texts.push(text); } } } return texts.join("\n"); } // ============================================================================ // Concurrency Utilities // ============================================================================ export { mapConcurrent } from "../runs/shared/parallel-utils.ts";