/** * 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 { stripThinkingTagsFromText } from "../../../../utils/thinking-tags.js"; // ponytail: .js not .ts — extension .ts is copied verbatim into dist/, only compiled .js exists there import { formatToolCall } from "./formatters.ts"; import type { AgentProgress, AsyncStatus, Details, DisplayItem, ErrorInfo, NestedRunSummary, SingleResult, ToolCallSummary, Usage } from "./types.ts"; // ============================================================================ // File System Utilities // ============================================================================ const DEFAULT_CONFIG_DIR_NAME = ".selesai"; const SELESAI_CODING_AGENT_PACKAGE_NAME = "@selesai/code"; export const SELESAI_CODING_AGENT_PACKAGE_ROOT_ENV = "SELESAI_SUBAGENTS_SELESAI_PACKAGE_ROOT"; 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 !== SELESAI_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; } export function getConfigDirName(): string { return resolveConfigDirName(); } 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) { 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, 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; 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 part.text; for (const match of part.text.matchAll(/```(?:json|jsonc|json5)\s*\n([\s\S]*?)```/gi)) { const body = match[1] ?? ""; if (/"criteriaSatisfied"/.test(body) && /"(?:changedFiles|testsAddedOrUpdated|commandsRun|validationOutput|residualRisks|noStagedFiles|diffSummary|reviewFindings|manualNotes)"/.test(body)) { return part.text; } } if (/ACCEPTANCE_REPORT\s*:/i.test(part.text)) return part.text; } } 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, }; } export function compactForegroundDetails(details: Details): Details { return { ...details, results: details.results.map(compactForegroundResult), progress: details.progress ? details.progress.map(compactCompletedProgress) : undefined, }; } /** * Detect errors in subagent execution from messages (only errors with no subsequent success) */ export function detectSubagentError(messages: Message[]): ErrorInfo { 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 && typeof c.text === "string" && c.text.trim().length > 0, ); if (hasText) { lastAssistantTextIndex = i; break; } } } const scanStart = lastAssistantTextIndex >= 0 ? lastAssistantTextIndex + 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) { 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), }; } if (toolName !== "bash") continue; const text = msg.content.find((c) => c.type === "text"); if (!text || !("text" in text)) continue; const output = text.text; const exitMatch = output.match(/exit(?:ed)?\s*(?:with\s*)?(?:code|status)?\s*[:\s]?\s*(\d+)/i); if (exitMatch) { const code = parseInt(exitMatch[1], 10); if (code !== 0) { return { hasError: true, exitCode: code, errorType: "bash", details: output.slice(0, 200) }; } } // 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. const fatalPatterns = [ /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, ]; for (const pattern of fatalPatterns) { if (pattern.test(output)) { return { hasError: true, exitCode: 1, errorType: "bash", details: output.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 ""; // Handle string content directly 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";