/** * Type definitions for the subagent extension */ import type { Message } from "@earendil-works/pi-ai"; import * as os from "node:os"; import * as path from "node:path"; // ============================================================================ // Basic Types // ============================================================================ export interface MaxOutputConfig { bytes?: number; lines?: number; } export interface TruncationResult { text: string; truncated: boolean; originalBytes?: number; originalLines?: number; artifactPath?: string; } export interface Usage { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number; } export interface TokenUsage { input: number; output: number; total: number; } // ============================================================================ // Skills // ============================================================================ export interface ResolvedSkill { name: string; path: string; content: string; source: "project" | "user"; } // ============================================================================ // Progress Tracking // ============================================================================ export interface AgentProgress { index: number; agent: string; status: "pending" | "running" | "completed" | "failed"; task: string; skills?: string[]; currentTool?: string; currentToolArgs?: string; recentTools: { tool: string; args: string; endMs: number }[]; recentOutput: string[]; toolCount: number; tokens: number; durationMs: number; error?: string; failedTool?: string; } export interface ProgressSummary { toolCount: number; tokens: number; durationMs: number; } // ============================================================================ // Results // ============================================================================ export interface SingleResult { agent: string; task: string; exitCode: number; messages: Message[]; usage: Usage; model?: string; modelSource?: "runtime-override" | "frontmatter-model" | "delegated-category" | "session-default"; modelCategory?: string; error?: string; /** Whether the subagent was aborted via signal (vs failed naturally). */ aborted?: boolean; /** Number of unparseable JSONL lines received from the subprocess. */ parseErrors?: number; sessionFile?: string; skills?: string[]; skillsWarning?: string; progress?: AgentProgress; progressSummary?: ProgressSummary; artifactPaths?: ArtifactPaths; truncation?: TruncationResult; } export interface Details { mode: "single" | "parallel" | "chain" | "management"; results: SingleResult[]; asyncId?: string; asyncDir?: string; progress?: AgentProgress[]; progressSummary?: ProgressSummary; artifacts?: { dir: string; files: ArtifactPaths[]; }; truncation?: { truncated: boolean; originalBytes?: number; originalLines?: number; artifactPath?: string; }; // Chain metadata for observability chainAgents?: string[]; // Agent names in order, e.g., ["scout", "planner"] totalSteps?: number; // Total steps in chain currentStepIndex?: number; // 0-indexed current step (for running chains) } // ============================================================================ // Artifacts // ============================================================================ export interface ArtifactPaths { inputPath: string; outputPath: string; jsonlPath: string; metadataPath: string; } export interface ArtifactConfig { enabled: boolean; includeInput: boolean; includeOutput: boolean; includeJsonl: boolean; includeMetadata: boolean; cleanupDays: number; } // ============================================================================ // Async Execution // ============================================================================ export interface AsyncStatus { runId: string; mode: "single" | "chain"; state: "queued" | "running" | "complete" | "failed"; startedAt: number; endedAt?: number; lastUpdate?: number; currentStep?: number; steps?: { agent: string; status: string; startedAt?: number; endedAt?: number; durationMs?: number; error?: string; tokens?: TokenUsage; skills?: string[]; }[]; sessionDir?: string; outputFile?: string; totalTokens?: TokenUsage; sessionFile?: string; } export interface AsyncJobState { asyncId: string; asyncDir: string; status: "queued" | "running" | "complete" | "failed"; mode?: "single" | "chain"; agents?: string[]; currentStep?: number; stepsTotal?: number; startedAt?: number; updatedAt?: number; sessionDir?: string; outputFile?: string; totalTokens?: TokenUsage; sessionFile?: string; } // ============================================================================ // Display // ============================================================================ export type DisplayItem = | { type: "text"; text: string } | { type: "tool"; name: string; args: Record }; // ============================================================================ // Error Handling // ============================================================================ export interface ErrorInfo { hasError: boolean; exitCode?: number; errorType?: string; details?: string; } // ============================================================================ // Execution Options // ============================================================================ export interface RunSyncOptions { cwd?: string; signal?: AbortSignal; onUpdate?: (r: import("@earendil-works/pi-agent-core").AgentToolResult
) => void; maxOutput?: MaxOutputConfig; artifactsDir?: string; artifactConfig?: ArtifactConfig; runId: string; index?: number; sessionDir?: string; share?: boolean; /** Override the agent's default model (format: "provider/id" or just "id") */ modelOverride?: string; modelSource?: SingleResult["modelSource"]; modelCategory?: string; /** Skills to inject (overrides agent default if provided) */ skills?: string[]; /** Idle timeout in ms: kill the agent if it produces no output for this long. * Default: 15 min. Set to 0 to disable. Override per-agent via frontmatter: `idleTimeoutMs: 1800000`. */ idleTimeoutMs?: number; /** Called when usage data is finalized (for budget tracking across subagent calls) */ onUsage?: (usage: SingleResult["usage"]) => void; } export interface ExtensionConfig { asyncByDefault?: boolean; defaultSessionDir?: string; projectAgentStorageMode?: "shared" | "project"; projectAgentSharedRoot?: string; } // ============================================================================ // Constants // ============================================================================ export const DEFAULT_MAX_OUTPUT: Required = { bytes: 200 * 1024, lines: 5000, }; export const DEFAULT_ARTIFACT_CONFIG: ArtifactConfig = { cleanupDays: 7, enabled: true, includeInput: true, includeJsonl: false, includeMetadata: true, includeOutput: true, }; export const MAX_PARALLEL = 8; export const MAX_CONCURRENCY = 4; /** Default idle timeout: kill a subagent if it produces no output for this long. * 15 min: enough for slow OCR/vision tasks but catches truly stuck agents. * Override per-agent via frontmatter: `idleTimeoutMs: 1200000` (20 min). */ export const DEFAULT_IDLE_TIMEOUT_MS = 15 * 60 * 1000; export const RESULTS_DIR = path.join(os.tmpdir(), "pi-async-subagent-results"); export const ASYNC_DIR = path.join(os.tmpdir(), "pi-async-subagent-runs"); export const WIDGET_KEY = "subagent-async"; export const POLL_INTERVAL_MS = 250; export const MAX_WIDGET_JOBS = 4; export const DEFAULT_SUBAGENT_MAX_DEPTH = 2; // ============================================================================ // Recursion Depth Guard // ============================================================================ export function checkSubagentDepth(): { blocked: boolean; depth: number; maxDepth: number; } { const depth = Number(process.env.PI_SUBAGENT_DEPTH ?? "0"); const maxDepth = Number(process.env.PI_SUBAGENT_MAX_DEPTH ?? String(DEFAULT_SUBAGENT_MAX_DEPTH)); const blocked = Number.isFinite(depth) && Number.isFinite(maxDepth) && depth >= maxDepth; return { blocked, depth, maxDepth }; } export function getSubagentDepthEnv(): Record { const parentDepth = Number(process.env.PI_SUBAGENT_DEPTH ?? "0"); const nextDepth = Number.isFinite(parentDepth) ? parentDepth + 1 : 1; return { PI_SUBAGENT_DEPTH: String(nextDepth), PI_SUBAGENT_MAX_DEPTH: process.env.PI_SUBAGENT_MAX_DEPTH ?? String(DEFAULT_SUBAGENT_MAX_DEPTH), }; } // ============================================================================ // Utility Functions // ============================================================================ export function formatBytes(bytes: number): string { if (bytes < 1024) { return `${bytes}B`; } if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)}KB`; } return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; } export function truncateOutput( output: string, config: Required, artifactPath?: string, ): TruncationResult { const lines = output.split("\n"); const bytes = Buffer.byteLength(output, "utf8"); if (bytes <= config.bytes && lines.length <= config.lines) { return { text: output, truncated: false }; } let truncatedLines = lines; if (lines.length > config.lines) { truncatedLines = lines.slice(0, config.lines); } let result = truncatedLines.join("\n"); if (Buffer.byteLength(result, "utf8") > config.bytes) { let low = 0; let high = result.length; while (low < high) { const mid = Math.floor((low + high + 1) / 2); if (Buffer.byteLength(result.slice(0, mid), "utf8") <= config.bytes) { low = mid; } else { high = mid - 1; } } result = result.slice(0, low); } const keptLines = result.split("\n").length; const marker = `[TRUNCATED: showing first ${keptLines} of ${lines.length} lines, ${formatBytes( Buffer.byteLength(result), )} of ${formatBytes(bytes)}${artifactPath ? ` - full output at ${artifactPath}` : ""}]\n`; return { artifactPath, originalBytes: bytes, originalLines: lines.length, text: marker + result, truncated: true, }; }