/** * Core execution logic for running subagents */ import type { Message } from "@earendil-works/pi-ai"; 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 { AgentConfig } from "./agents.js"; import type { AgentProgress, ArtifactPaths, RunSyncOptions, SingleResult } from "./types.js"; import { ensureArtifactsDir, getArtifactPaths, writeArtifact, writeMetadata } from "./artifacts.js"; import { createJsonlWriter } from "./jsonl-writer.js"; import { getPiSpawnCommand } from "./pi-spawn.js"; import { buildSkillInjection, resolveSkillsAsync } from "./skills.js"; import { DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_MAX_OUTPUT, getSubagentDepthEnv, truncateOutput } from "./types.js"; import { detectSubagentError, extractTextFromContent, extractToolArgsPreview, findLatestSessionFile, getFinalOutput, writePrompt, } from "./utils.js"; const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"]; export function applyThinkingSuffix(model: string | undefined, thinking: string | undefined): string | undefined { if (!model || !thinking || thinking === "off") { return model; } const colonIdx = model.lastIndexOf(":"); if (colonIdx !== -1 && THINKING_LEVELS.includes(model.substring(colonIdx + 1))) { return model; } return `${model}:${thinking}`; } /** * Run a subagent synchronously (blocking until complete) */ export async function runSync( runtimeCwd: string, agents: AgentConfig[], agentName: string, task: string, options: RunSyncOptions, ): Promise { const { cwd, signal, onUpdate, maxOutput, artifactsDir, artifactConfig, runId, index, modelOverride, idleTimeoutMs } = options; const agent = agents.find((a) => a.name === agentName); if (!agent) { return { agent: agentName, error: `Unknown agent: ${agentName}`, exitCode: 1, messages: [], task, usage: { cacheRead: 0, cacheWrite: 0, cost: 0, input: 0, output: 0, turns: 0, }, }; } const args = ["--mode", "json", "-p"]; const shareEnabled = options.share === true; const sessionEnabled = Boolean(options.sessionDir) || shareEnabled; if (!sessionEnabled) { args.push("--no-session"); } if (options.sessionDir) { try { fs.mkdirSync(options.sessionDir, { recursive: true }); } catch {} args.push("--session-dir", options.sessionDir); } const effectiveModel = modelOverride ?? agent.model; const modelArg = applyThinkingSuffix(effectiveModel, agent.thinking); // Use --models (not --model) because pi CLI silently ignores --model // Without a companion --provider flag. --models resolves the provider // Automatically via resolveModelScope. See: #8 if (modelArg) { args.push("--models", modelArg); } const toolExtensionPaths: string[] = []; // Only pi's 7 builtin tools can be passed via --tools. // Extension-registered tools (e.g. read_full) are not in allTools // And get silently dropped when passed as --tools because the // Whitelist is applied before extensions load. const BUILTIN_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]); if (agent.tools?.length) { const builtinTools: string[] = []; for (const tool of agent.tools) { if (tool.includes("/") || tool.endsWith(".ts") || tool.endsWith(".js")) { toolExtensionPaths.push(tool); } else if (BUILTIN_TOOL_NAMES.has(tool)) { builtinTools.push(tool); } // Else: extension-registered tool (e.g. read_full): let the // Extension register it naturally; don't pass via --tools. } if (builtinTools.length > 0) { args.push("--tools", builtinTools.join(",")); } } if (agent.extensions !== undefined) { args.push("--no-extensions"); for (const extPath of agent.extensions) { args.push("--extension", extPath); } } else { for (const extPath of toolExtensionPaths) { args.push("--extension", extPath); } } const skillNames = options.skills ?? agent.skills ?? []; const { resolved: resolvedSkills, missing: missingSkills } = await resolveSkillsAsync(skillNames, cwd ?? runtimeCwd); // When explicit skills are specified (via options or agent config), disable // Pi's own skill discovery so the spawned process doesn't inject the full // catalog. This mirrors how extensions are scoped above. if (skillNames.length > 0) { args.push("--no-skills"); } let systemPrompt = agent.systemPrompt?.trim() || ""; if (resolvedSkills.length > 0) { const skillInjection = buildSkillInjection(resolvedSkills); systemPrompt = systemPrompt ? `${systemPrompt}\n\n${skillInjection}` : skillInjection; } let tmpDir: string | null = null; if (systemPrompt) { const tmp = writePrompt(agent.name, systemPrompt); tmpDir = tmp.dir; args.push("--append-system-prompt", tmp.path); } // When the task is too long for a CLI argument (Windows ENAMETOOLONG), // Write it to a temp file and use pi's @file syntax instead. const TASK_ARG_LIMIT = 8000; if (task.length > TASK_ARG_LIMIT) { if (!tmpDir) { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-")); } const taskFilePath = path.join(tmpDir, "task.md"); fs.writeFileSync(taskFilePath, `Task: ${task}`, { mode: 0o600 }); args.push(`@${taskFilePath}`); } else { args.push(`Task: ${task}`); } const result: SingleResult = { agent: agentName, exitCode: 0, messages: [], model: modelArg, modelCategory: options.modelCategory, modelSource: options.modelSource, skills: resolvedSkills.length > 0 ? resolvedSkills.map((s) => s.name) : undefined, skillsWarning: missingSkills.length > 0 ? `Skills not found: ${missingSkills.join(", ")}` : undefined, task, usage: { cacheRead: 0, cacheWrite: 0, cost: 0, input: 0, output: 0, turns: 0, }, }; const progress: AgentProgress = { agent: agentName, durationMs: 0, index: index ?? 0, recentOutput: [], recentTools: [], skills: resolvedSkills.length > 0 ? resolvedSkills.map((s) => s.name) : undefined, status: "running", task, tokens: 0, toolCount: 0, }; result.progress = progress; const startTime = Date.now(); let artifactPathsResult: ArtifactPaths | undefined; let jsonlPath: string | undefined; if (artifactsDir && artifactConfig?.enabled !== false) { artifactPathsResult = getArtifactPaths(artifactsDir, runId, agentName, index); ensureArtifactsDir(artifactsDir); if (artifactConfig?.includeInput !== false) { writeArtifact(artifactPathsResult.inputPath, `# Task for ${agentName}\n\n${task}`); } if (artifactConfig?.includeJsonl !== false) { ({ jsonlPath } = artifactPathsResult); } } const spawnEnv = { ...process.env, ...getSubagentDepthEnv() }; const mcpDirect = agent.mcpDirectTools; if (mcpDirect?.length) { spawnEnv.MCP_DIRECT_TOOLS = mcpDirect.join(","); } else { spawnEnv.MCP_DIRECT_TOOLS = "__none__"; } let closeJsonlWriter: (() => Promise) | undefined; const exitCode = await new Promise((resolve) => { const spawnSpec = getPiSpawnCommand(args); const proc = spawn(spawnSpec.command, spawnSpec.args, { cwd: cwd ?? runtimeCwd, env: spawnEnv, stdio: ["ignore", "pipe", "pipe"], }); const jsonlWriter = createJsonlWriter(jsonlPath, proc.stdout); closeJsonlWriter = () => jsonlWriter.close(); let buf = ""; // Throttled update mechanism - consolidates all updates let lastUpdateTime = 0; let updatePending = false; let pendingTimer: ReturnType | null = null; let processClosed = false; const UPDATE_THROTTLE_MS = 50; // Reduced from 75ms for faster responsiveness // Idle-timeout watchdog: kills the process if no activity for N ms. // Default 15 min; 0 disables. Agent frontmatter can override: `idleTimeoutMs: 1800000` const idleTimeout = idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; let idleTimer: ReturnType | null = null; let idleKilled = false; const resetIdleTimer = () => { if (idleTimeout <= 0 || processClosed) { return; } if (idleTimer) { clearTimeout(idleTimer); } idleTimer = setTimeout(() => { if (processClosed) { return; } idleKilled = true; result.error = `Idle timeout: no activity for ${Math.round(idleTimeout / 60_000)} min`; progress.error = result.error; progress.status = "failed"; proc.kill("SIGTERM"); setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000); }, idleTimeout); }; // Start the initial idle timer (first activity will reset it) resetIdleTimer(); const scheduleUpdate = () => { if (!onUpdate || processClosed) { return; } const now = Date.now(); const elapsed = now - lastUpdateTime; if (elapsed >= UPDATE_THROTTLE_MS) { // Enough time passed, update immediately // Clear any pending timer to avoid double-updates if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } lastUpdateTime = now; updatePending = false; progress.durationMs = now - startTime; onUpdate({ content: [ { text: getFinalOutput(result.messages) || "(running...)", type: "text", }, ], details: { mode: "single", progress: [progress], results: [result] }, }); } else if (!updatePending) { // Schedule update for later updatePending = true; pendingTimer = setTimeout(() => { pendingTimer = null; if (updatePending && !processClosed) { updatePending = false; lastUpdateTime = Date.now(); progress.durationMs = Date.now() - startTime; onUpdate({ content: [ { text: getFinalOutput(result.messages) || "(running...)", type: "text", }, ], details: { mode: "single", progress: [progress], results: [result], }, }); } }, UPDATE_THROTTLE_MS - elapsed); } }; const processLine = (line: string) => { if (!line.trim()) { return; } jsonlWriter.writeLine(line); try { const evt = JSON.parse(line) as { type?: string; message?: Message; toolName?: string; args?: unknown; }; const now = Date.now(); progress.durationMs = now - startTime; if (evt.type === "tool_execution_start") { progress.toolCount++; progress.currentTool = evt.toolName; progress.currentToolArgs = extractToolArgsPreview((evt.args || {}) as Record); // Tool start is important - update immediately by forcing throttle reset lastUpdateTime = 0; resetIdleTimer(); scheduleUpdate(); } if (evt.type === "tool_execution_end") { if (progress.currentTool) { progress.recentTools.unshift({ args: progress.currentToolArgs || "", endMs: now, tool: progress.currentTool, }); if (progress.recentTools.length > 5) { progress.recentTools.pop(); } } progress.currentTool = undefined; progress.currentToolArgs = undefined; resetIdleTimer(); scheduleUpdate(); } if (evt.type === "message_end" && evt.message) { result.messages.push(evt.message); if (evt.message.role === "assistant") { result.usage.turns++; const u = evt.message.usage; if (u) { result.usage.input += u.input || 0; result.usage.output += u.output || 0; result.usage.cacheRead += u.cacheRead || 0; result.usage.cacheWrite += u.cacheWrite || 0; result.usage.cost += u.cost?.total || 0; progress.tokens = result.usage.input + result.usage.output; } if (!result.model && evt.message.model) { result.model = evt.message.model; } if (evt.message.errorMessage) { result.error = evt.message.errorMessage; } const text = extractTextFromContent(evt.message.content); if (text) { const lines = text .split("\n") .filter((l) => l.trim()) .slice(-10); // Append to existing recentOutput (keep last 50 total) - mutate in place for efficiency progress.recentOutput.push(...lines); if (progress.recentOutput.length > 50) { progress.recentOutput.splice(0, progress.recentOutput.length - 50); } } } resetIdleTimer(); scheduleUpdate(); } if (evt.type === "tool_result_end" && evt.message) { result.messages.push(evt.message); // Also capture tool result text in recentOutput for streaming display const toolText = extractTextFromContent(evt.message.content); if (toolText) { const toolLines = toolText .split("\n") .filter((l) => l.trim()) .slice(-10); // Append to existing recentOutput (keep last 50 total) - mutate in place for efficiency progress.recentOutput.push(...toolLines); if (progress.recentOutput.length > 50) { progress.recentOutput.splice(0, progress.recentOutput.length - 50); } } resetIdleTimer(); scheduleUpdate(); } } catch { // Count unparseable lines: corrupted output shouldn't be silently lost result.parseErrors = (result.parseErrors ?? 0) + 1; } }; let stderrBuf = ""; proc.stdout.on("data", (d) => { buf += d.toString(); const lines = buf.split("\n"); buf = lines.pop() || ""; lines.forEach(processLine); // Also schedule an update on data received (handles streaming output) resetIdleTimer(); scheduleUpdate(); }); proc.stderr.on("data", (d) => { stderrBuf += d.toString(); }); proc.on("close", (code) => { processClosed = true; if (pendingTimer) { clearTimeout(pendingTimer); pendingTimer = null; } if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (buf.trim()) { processLine(buf); } if (code !== 0 && stderrBuf.trim() && !result.error) { result.error = stderrBuf.trim(); } // Provide a fallback error for non-zero exits with no error details if (code !== 0 && !result.error) { result.error = `Subagent process exited with code ${code} (no stderr output captured)`; } resolve(code ?? 0); }); proc.on("error", (err) => { result.error = result.error || `Subagent process error: ${err.message}`; resolve(1); }); if (signal) { const kill = () => { proc.kill("SIGTERM"); setTimeout(() => !proc.killed && proc.kill("SIGKILL"), 3000); }; if (signal.aborted) { kill(); result.aborted = true; } else { signal.addEventListener( "abort", () => { result.aborted = true; kill(); }, { once: true }, ); } } }); if (closeJsonlWriter) { try { await closeJsonlWriter(); } catch {} } if (tmpDir) { fs.rmSync(tmpDir, { recursive: true, force: true }); } result.exitCode = exitCode; // Check for internal errors even when exit code is 0 (or non-zero without error details) if (!result.error || exitCode === 0) { const errInfo = detectSubagentError(result.messages); if (errInfo.hasError) { result.exitCode = errInfo.exitCode ?? 1; result.error = errInfo.details ? `${errInfo.errorType} failed (exit ${errInfo.exitCode}): ${errInfo.details}` : `${errInfo.errorType} failed with exit code ${errInfo.exitCode}`; } } progress.status = result.exitCode === 0 ? "completed" : "failed"; progress.durationMs = Date.now() - startTime; if (result.error) { progress.error = result.error; if (progress.currentTool) { progress.failedTool = progress.currentTool; } } result.progress = progress; result.progressSummary = { durationMs: progress.durationMs, tokens: progress.tokens, toolCount: progress.toolCount, }; if (artifactPathsResult && artifactConfig?.enabled !== false) { result.artifactPaths = artifactPathsResult; const fullOutput = getFinalOutput(result.messages); if (artifactConfig?.includeOutput !== false) { writeArtifact(artifactPathsResult.outputPath, fullOutput); } if (artifactConfig?.includeMetadata !== false) { writeMetadata(artifactPathsResult.metadataPath, { agent: agentName, durationMs: progress.durationMs, error: result.error, exitCode: result.exitCode, model: result.model, runId, skills: result.skills, skillsWarning: result.skillsWarning, task, timestamp: Date.now(), toolCount: progress.toolCount, usage: result.usage, }); } if (maxOutput) { const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput }; const truncationResult = truncateOutput(fullOutput, config, artifactPathsResult.outputPath); if (truncationResult.truncated) { result.truncation = truncationResult; } } } else if (maxOutput) { const config = { ...DEFAULT_MAX_OUTPUT, ...maxOutput }; const fullOutput = getFinalOutput(result.messages); const truncationResult = truncateOutput(fullOutput, config); if (truncationResult.truncated) { result.truncation = truncationResult; } } if (options.sessionDir) { const sessionFile = findLatestSessionFile(options.sessionDir); if (sessionFile) { result.sessionFile = sessionFile; // HTML export disabled - module resolution issues with global pi installation // Users can still access the session file directly } } if (options.onUsage) { options.onUsage(result.usage); } return result; }