import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { AgentToolResult } from "@earendil-works/pi-agent-core"; import type { Message } from "@earendil-works/pi-ai"; import { StringEnum } from "@earendil-works/pi-ai"; import { type ExtensionAPI, getMarkdownTheme, withFileMutationQueue } from "@earendil-works/pi-coding-agent"; import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; import { type AgentConfig, type AgentScope, discoverAgents } from "./agents.ts"; import { AgentManager } from "./agent-manager.ts"; import { Workspace } from "./workspace.ts"; import { resolveAgentModel } from "./model-resolver.ts"; import { loadAgentSettings, registerAgentsCommand } from "./settings-page.ts"; export const MAX_PARALLEL_TASKS = 8; export const MAX_CONCURRENCY = 4; export const COLLAPSED_ITEM_COUNT = 10; export const PER_TASK_OUTPUT_CAP = 50 * 1024; export function formatTokens(count: number): string { if (count < 1000) return count.toString(); if (count < 10000) return `${(count / 1000).toFixed(1)}k`; if (count < 1000000) return `${Math.round(count / 1000)}k`; return `${(count / 1000000).toFixed(1)}M`; } export function formatUsageStats( usage: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens?: number; turns?: number; }, model?: string, ): string { const parts: string[] = []; if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`); if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`); if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`); if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`); if (usage.contextTokens && usage.contextTokens > 0) { parts.push(`ctx:${formatTokens(usage.contextTokens)}`); } if (model) parts.push(model); return parts.join(" "); } export function hasReportedUsage(usage: UsageStats): boolean { return usage.input > 0 || usage.output > 0 || usage.cacheRead > 0 || usage.cacheWrite > 0; } export function formatManagedUsage(usage: UsageStats, compact = false): string { const turns = compact ? `${usage.turns}t` : `${usage.turns} turn${usage.turns === 1 ? "" : "s"}`; if (usage.turns > 0 && !hasReportedUsage(usage)) return `${turns}, usage unavailable`; const parts = [turns]; if (usage.input) parts.push(`↑${formatTokens(usage.input)}`); if (usage.output) parts.push(`↓${formatTokens(usage.output)}`); if (usage.cacheRead) parts.push(`cached ${formatTokens(usage.cacheRead)}`); if (usage.cacheWrite) parts.push(`cache write ${formatTokens(usage.cacheWrite)}`); parts.push(usage.cost > 0 ? `$${usage.cost.toFixed(4)}` : "cost unavailable"); return parts.join(compact ? " " : ", "); } export function formatManagedCost(usage: UsageStats): string { if (usage.turns > 0 && !hasReportedUsage(usage)) return "Unavailable (provider did not report usage)"; if (usage.cost === 0 && hasReportedUsage(usage)) return "Unavailable (pricing is not configured or provider did not report cost)"; return `$${usage.cost.toFixed(4)}`; } export function formatManagedTokens(usage: UsageStats): string { if (usage.turns > 0 && !hasReportedUsage(usage)) return "Unavailable (provider did not report usage)"; const cache = usage.cacheRead || usage.cacheWrite ? `, ${usage.cacheRead} cache read, ${usage.cacheWrite} cache write` : ""; return `${usage.input} input, ${usage.output} output${cache}`; } export function formatToolCall( toolName: string, args: Record, themeFg: (color: any, text: string) => string, ): string { const shortenPath = (p: string) => { const home = os.homedir(); return p.startsWith(home) ? `~${p.slice(home.length)}` : p; }; switch (toolName) { case "bash": { const command = (args.command as string) || "..."; const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command; return themeFg("muted", "$ ") + themeFg("toolOutput", preview); } case "read": { const rawPath = (args.file_path || args.path || "...") as string; const filePath = shortenPath(rawPath); const offset = args.offset as number | undefined; const limit = args.limit as number | undefined; let text = themeFg("accent", filePath); if (offset !== undefined || limit !== undefined) { const startLine = offset ?? 1; const endLine = limit !== undefined ? startLine + limit - 1 : ""; text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`); } return themeFg("muted", "read ") + text; } case "write": { const rawPath = (args.file_path || args.path || "...") as string; const filePath = shortenPath(rawPath); const content = (args.content || "") as string; const lines = content.split("\n").length; let text = themeFg("muted", "write ") + themeFg("accent", filePath); if (lines > 1) text += themeFg("dim", ` (${lines} lines)`); return text; } case "edit": { const rawPath = (args.file_path || args.path || "...") as string; return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath)); } case "ls": { const rawPath = (args.path || ".") as string; return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath)); } case "find": { const pattern = (args.pattern || "*") as string; const rawPath = (args.path || ".") as string; return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`); } case "grep": { const pattern = (args.pattern || "") as string; const rawPath = (args.path || ".") as string; return ( themeFg("muted", "grep ") + themeFg("accent", `/${pattern}/`) + themeFg("dim", ` in ${shortenPath(rawPath)}`) ); } default: { const argsStr = JSON.stringify(args); const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr; return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`); } } } export interface UsageStats { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; contextTokens: number; turns: number; } export interface SingleResult { sessionFile?: string; agent: string; agentSource: "user" | "project" | "unknown"; task: string; exitCode: number; messages: Message[]; stderr: string; usage: UsageStats; model?: string; responseModel?: string; thinkingLevel?: string; stopReason?: string; errorMessage?: string; yieldReason?: string; step?: number; } export interface SubagentDetails { mode: "single" | "parallel" | "chain"; agentScope: AgentScope; projectAgentsDir: string | null; results: SingleResult[]; } 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 ""; } export function isFailedResult(result: SingleResult): boolean { return result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted" || result.stopReason === "yielded"; } export function getResultOutput(result: SingleResult): string { if (result.stopReason === "yielded") { const output = getFinalOutput(result.messages); return `${result.yieldReason ?? "Worker yielded its token tranche."}${result.sessionFile ? `\nSaved session: ${result.sessionFile}` : ""}${output ? `\n\n${output}` : ""}`; } if (isFailedResult(result)) { return result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; } return getFinalOutput(result.messages) || "(no output)"; } export function truncateParallelOutput(output: string): string { const byteLength = Buffer.byteLength(output, "utf8"); if (byteLength <= PER_TASK_OUTPUT_CAP) return output; let truncated = output.slice(0, PER_TASK_OUTPUT_CAP); while (Buffer.byteLength(truncated, "utf8") > PER_TASK_OUTPUT_CAP) { truncated = truncated.slice(0, -1); } return `${truncated}\n\n[Output truncated: ${byteLength - Buffer.byteLength(truncated, "utf8")} bytes omitted. Full output preserved in tool details.]`; } export type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record }; 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: "toolCall", name: part.name, args: part.arguments }); } } } return items; }