/** * Async execution logic for subagent tool */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { spawn } from "node:child_process"; import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { AgentConfig } from "./agents.js"; import type { AvailableModelRef } from "./model-routing.js"; import type { RunnerStep } from "./parallel-utils.js"; import type { ChainStep, ParallelStep, SequentialStep, StepOverrides } from "./settings.js"; import type { ArtifactConfig, Details, MaxOutputConfig } from "./types.js"; import { applyThinkingSuffix } from "./execution.js"; import { resolveSubagentModelResolution } from "./model-routing.js"; import { resolvePiPackageRoot } from "./pi-spawn.js"; import { isParallelStep, resolveStepBehavior } from "./settings.js"; import { injectSingleOutputInstruction, resolveSingleOutputPath } from "./single-output.js"; import { buildSkillInjection, normalizeSkillInput, resolveSkillsAsync } from "./skills.js"; import { ASYNC_DIR, RESULTS_DIR } from "./types.js"; const require = createRequire(import.meta.url); const piPackageRoot = resolvePiPackageRoot(); const jitiCliPath: string | undefined = (() => { const candidates: (() => string)[] = [ () => path.join(path.dirname(require.resolve("jiti/package.json")), "lib/jiti-cli.mjs"), () => path.join(path.dirname(require.resolve("@mariozechner/jiti/package.json")), "lib/jiti-cli.mjs"), () => { const piEntry = fs.realpathSync(process.argv[1]); const piRequire = createRequire(piEntry); return path.join(path.dirname(piRequire.resolve("@mariozechner/jiti/package.json")), "lib/jiti-cli.mjs"); }, ]; for (const candidate of candidates) { try { const p = candidate(); if (fs.existsSync(p)) { return p; } } catch {} } return; })(); export interface AsyncExecutionContext { pi: ExtensionAPI; cwd: string; currentSessionId: string; currentModel?: string; availableModels?: AvailableModelRef[]; } export interface AsyncChainParams { chain: ChainStep[]; agents: AgentConfig[]; ctx: AsyncExecutionContext; cwd?: string; maxOutput?: MaxOutputConfig; artifactsDir?: string; artifactConfig: ArtifactConfig; shareEnabled: boolean; sessionRoot?: string; chainSkills?: string[]; } export interface AsyncSingleParams { agent: string; task: string; agentConfig: AgentConfig; ctx: AsyncExecutionContext; cwd?: string; maxOutput?: MaxOutputConfig; artifactsDir?: string; artifactConfig: ArtifactConfig; shareEnabled: boolean; sessionRoot?: string; skills?: string[]; output?: string | false; } export interface AsyncExecutionResult { content: { type: "text"; text: string }[]; details: Details; isError?: boolean; } /** * Check if jiti is available for async execution */ export function isAsyncAvailable(): boolean { return jitiCliPath !== undefined; } /** * Spawn the async runner process */ function spawnRunner(cfg: object, suffix: string, cwd: string): number | undefined { if (!jitiCliPath) { return undefined; } const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), `pi-async-cfg-`)); const cfgPath = path.join(tmpDir, `${suffix}.json`); fs.writeFileSync(cfgPath, JSON.stringify(cfg), { mode: 0o600 }); const runner = path.join(import.meta.dirname, "subagent-runner.ts"); const proc = spawn("node", [jitiCliPath, runner, cfgPath], { cwd, detached: true, stdio: "ignore", windowsHide: true, }); proc.unref(); return proc.pid; } /** * Execute a chain asynchronously */ export async function executeAsyncChain(id: string, params: AsyncChainParams): Promise { const { chain, agents, ctx, cwd, maxOutput, artifactsDir, artifactConfig, shareEnabled, sessionRoot } = params; const chainSkills = params.chainSkills ?? []; // Validate all agents exist before building steps for (const s of chain) { const stepAgents = isParallelStep(s) ? s.parallel.map((t) => t.agent) : [(s as SequentialStep).agent]; for (const agentName of stepAgents) { if (!agents.some((x) => x.name === agentName)) { return { content: [{ type: "text", text: `Unknown agent: ${agentName}` }], details: { mode: "chain" as const, results: [] }, isError: true, }; } } } const asyncDir = path.join(ASYNC_DIR, id); try { fs.mkdirSync(asyncDir, { recursive: true }); } catch {} /** Build a resolved runner step from a SequentialStep */ const buildSeqStep = async (s: SequentialStep) => { const a = agents.find((x) => x.name === s.agent)!; const stepSkillInput = normalizeSkillInput(s.skill); const stepOverrides: StepOverrides = { skills: stepSkillInput }; const behavior = resolveStepBehavior(a, stepOverrides, chainSkills); const skillNames = behavior.skills === false ? [] : behavior.skills; const { resolved: resolvedSkills } = await resolveSkillsAsync(skillNames, s.cwd ?? cwd ?? ctx.cwd); let systemPrompt = a.systemPrompt?.trim() || null; if (resolvedSkills.length > 0) { const injection = buildSkillInjection(resolvedSkills); systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection; } // Resolve output path and inject instruction into task // Use step's cwd if specified, otherwise fall back to chain-level cwd const outputPath = resolveSingleOutputPath(s.output, ctx.cwd, s.cwd ?? cwd); const task = injectSingleOutputInstruction(s.task ?? "{previous}", outputPath); const modelResolution = resolveSubagentModelResolution(a, ctx.availableModels ?? [], s.model, { currentModel: ctx.currentModel, taskText: s.task, }); return { agent: s.agent, cwd: s.cwd, extensions: a.extensions, mcpDirectTools: a.mcpDirectTools, model: applyThinkingSuffix(modelResolution.model, a.thinking), outputPath, skills: resolvedSkills.map((r) => r.name), systemPrompt, task, tools: a.tools, }; }; // Build runner steps: sequential steps become flat objects, // Parallel steps become { parallel: [...], concurrency?, failFast? } const steps: RunnerStep[] = []; for (const s of chain) { if (isParallelStep(s)) { const parallel = []; for (const t of s.parallel) { parallel.push( await buildSeqStep({ agent: t.agent, task: t.task, cwd: t.cwd, skill: t.skill, model: t.model, output: t.output, }), ); } steps.push({ concurrency: s.concurrency, failFast: s.failFast, continueOnError: s.continueOnError, parallel, }); continue; } steps.push(await buildSeqStep(s as SequentialStep)); } const runnerCwd = cwd ?? ctx.cwd; const pid = spawnRunner( { artifactConfig, artifactsDir: artifactConfig.enabled ? artifactsDir : undefined, asyncDir, cwd: runnerCwd, id, maxOutput, piPackageRoot, placeholder: "{previous}", resultPath: path.join(RESULTS_DIR, `${id}.json`), sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined, sessionId: ctx.currentSessionId, share: shareEnabled, steps, }, id, runnerCwd, ); if (pid) { const firstStep = chain[0]; const firstAgents = isParallelStep(firstStep) ? firstStep.parallel.map((t) => t.agent) : [(firstStep as SequentialStep).agent]; ctx.pi.events.emit("subagent:started", { agent: firstAgents[0], asyncDir, chain: chain.map((s) => isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent, ), cwd: runnerCwd, id, pid, task: isParallelStep(firstStep) ? firstStep.parallel[0]?.task?.slice(0, 50) : (firstStep as SequentialStep).task?.slice(0, 50), }); } // Build chain description with parallel groups shown as [agent1+agent2] const chainDesc = chain .map((s) => (isParallelStep(s) ? `[${s.parallel.map((t) => t.agent).join("+")}]` : (s as SequentialStep).agent)) .join(" -> "); return { content: [{ text: `Async chain: ${chainDesc} [${id}]`, type: "text" }], details: { asyncDir, asyncId: id, mode: "chain", results: [] }, }; } /** * Execute a single agent asynchronously */ export async function executeAsyncSingle(id: string, params: AsyncSingleParams): Promise { const { agent, task, agentConfig, ctx, cwd, maxOutput, artifactsDir, artifactConfig, shareEnabled, sessionRoot } = params; const skillNames = params.skills ?? agentConfig.skills ?? []; const { resolved: resolvedSkills } = await resolveSkillsAsync(skillNames, cwd ?? ctx.cwd); let systemPrompt = agentConfig.systemPrompt?.trim() || null; if (resolvedSkills.length > 0) { const injection = buildSkillInjection(resolvedSkills); systemPrompt = systemPrompt ? `${systemPrompt}\n\n${injection}` : injection; } const asyncDir = path.join(ASYNC_DIR, id); try { fs.mkdirSync(asyncDir, { recursive: true }); } catch {} const runnerCwd = cwd ?? ctx.cwd; const outputPath = resolveSingleOutputPath(params.output, ctx.cwd, cwd); const taskWithOutputInstruction = injectSingleOutputInstruction(task, outputPath); const modelResolution = resolveSubagentModelResolution(agentConfig, ctx.availableModels ?? [], undefined, { currentModel: ctx.currentModel, taskText: params.task, }); const pid = spawnRunner( { artifactConfig, artifactsDir: artifactConfig.enabled ? artifactsDir : undefined, asyncDir, cwd: runnerCwd, id, maxOutput, piPackageRoot, placeholder: "{previous}", resultPath: path.join(RESULTS_DIR, `${id}.json`), sessionDir: sessionRoot ? path.join(sessionRoot, `async-${id}`) : undefined, sessionId: ctx.currentSessionId, share: shareEnabled, steps: [ { agent, task: taskWithOutputInstruction, cwd, model: applyThinkingSuffix(modelResolution.model, agentConfig.thinking), tools: agentConfig.tools, extensions: agentConfig.extensions, mcpDirectTools: agentConfig.mcpDirectTools, systemPrompt, skills: resolvedSkills.map((r) => r.name), outputPath, }, ], }, id, runnerCwd, ); if (pid) { ctx.pi.events.emit("subagent:started", { agent, asyncDir, cwd: runnerCwd, id, pid, task: task?.slice(0, 50), }); } return { content: [{ text: `Async: ${agent} [${id}]`, type: "text" }], details: { asyncDir, asyncId: id, mode: "single", results: [] }, }; }