import { loadSkills, loadSkillsFromDir } from "@earendil-works/pi-coding-agent"; import { dirname, isAbsolute, join } from "node:path"; import { fileURLToPath } from "node:url"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { retryFileOperation } from "./file-retry.ts"; import { homedir } from "node:os"; import { resolveRuntimePlan, type ModelRegistryAdapter, type ResolvedRuntimePlan, type ThinkingLevel, } from "./runtime-routing.ts"; import type { NativeLaunchPlan } from "./native-startup.ts"; import { SUBAGENT_ENV_AGENT } from "./operation-env.ts"; import { dirnamePortablePath, isWindowsStylePath, joinPortablePath, resolvePortablePath, } from "./path-utils.ts"; /** Absolute path to `pi-extension/subagents`. */ const SUBAGENTS_DIR = dirname(fileURLToPath(import.meta.url)); /** * Fixed definition of a Built-in subagent type (see CONTEXT.md). The catalog * is hard-coded: there is no user-defined or overridable Agent profile. */ export interface BuiltinSubagentType { name: string; description: string; /** Role system prompt appended to the child's identity. */ body: string; /** Explicit tool allowlist; null means the full toolset. */ tools: string | null; /** Whether this type may launch Nested Subagents. */ spawning: boolean; } export const BUILTIN_SUBAGENT_TYPES: readonly BuiltinSubagentType[] = [ { name: "explore", description: "A fast, read-only agent for searching and analyzing codebases. Spawn when you need to map files, find patterns, or understand code without modifying anything.", body: [ "You are a read-only exploration agent. Search and analyze the codebase and return a concise summary of what you found. You do not modify files.", "", "## Constraints", "- **Read-only.** No writing, editing, or creating files. Use only read and search tools.", "- **Stay on target.** Investigate only what was asked; avoid rabbit holes.", "- **Return a summary, not a dump.** Synthesize into what the caller needs: relevant files, the patterns in play, and direct answers to the question.", "", "## Output", "- The files/modules that matter and why", "- The conventions and patterns in play", "- Direct answers to the questions you were asked", ].join("\n"), tools: "read,bash,web_search,web_fetch", spawning: false, }, { name: "general-purpose", description: "A capable agent for complex, multi-step tasks that require both exploration and action. Spawn when a task needs investigation followed by implementation, multiple dependent steps, or interpretation of results before acting.", body: [ "You are a general-purpose agent for complex, multi-step tasks that require both exploration and action. You have the full tool set and may spawn subagents when a sub-task warrants it.", "", "- Work the task end to end: investigate what's needed, make the changes, and finish.", "- When a step would flood your context with search results or large file contents you won't need again, delegate it to an `explore` subagent and act on the summary.", "- Your final message summarizes what you accomplished and what you changed.", ].join("\n"), tools: null, spawning: true, }, ]; const BUILTIN_TYPES_BY_NAME = new Map( BUILTIN_SUBAGENT_TYPES.map((type) => [type.name, type]), ); /** Look up a Built-in subagent type by name (trimmed). Returns null if unknown. */ export function getBuiltinSubagentType( name: string | undefined, ): BuiltinSubagentType | null { const normalized = name?.trim(); if (!normalized) return null; return BUILTIN_TYPES_BY_NAME.get(normalized) ?? null; } function requireBuiltinSubagentType(name: string | undefined): BuiltinSubagentType { const normalized = name?.trim(); if (!normalized) { throw new Error( "Subagent launch requires a built-in subagent type ('explore' or 'general-purpose').", ); } const type = BUILTIN_TYPES_BY_NAME.get(normalized); if (!type) { throw new Error( `Unknown subagent type "${normalized}". Available types: ${BUILTIN_SUBAGENT_TYPES.map((t) => t.name).join(", ")}.`, ); } return type; } export interface LaunchPolicyRequest { name: string; task: string; agent?: string; systemPrompt?: string; model?: string; thinking?: ThinkingLevel; tools?: string; skills?: string; cwd?: string; } export interface LaunchPolicyContext { parentCwd: string; parentRuntime: { provider: string; modelId: string; thinking: ThinkingLevel; }; modelRegistry: ModelRegistryAdapter; } export interface ResolvedLaunchPolicy { agentDefs: BuiltinSubagentType; runtimePlan: ResolvedRuntimePlan; targetCwd: string; /** Build an argv-based launch plan using operation-owned prompt resources. */ buildLaunchPlan(launchPromptFile: string): NativeLaunchPlan; } export interface LaunchPolicyResolver { resolve(request: LaunchPolicyRequest, context: LaunchPolicyContext): ResolvedLaunchPolicy; } /** Lifecycle tools exposed to types that are allowed to spawn. */ const SUBAGENT_LIFECYCLE_TOOLS = ["subagent", "subagent_cancel"] as const; /** Integration harness opt-in for exercising the working-tree child extension. */ const LOCAL_CHILD_EXTENSION_ENV = "PI_SUBAGENT_TEST_LOCAL_EXTENSION"; /** Resolve the global agent config directory, respecting PI_CODING_AGENT_DIR. */ function getAgentConfigDir(): string { return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"); } function resolveSubagentPaths( requestCwd: string | undefined, parentCwd: string, ): { effectiveCwd: string | null; localAgentDir: string | null; effectiveAgentDir: string } { const effectiveCwd = requestCwd ? isAbsolute(requestCwd) || isWindowsStylePath(requestCwd) ? requestCwd : joinPortablePath(parentCwd, requestCwd) : null; const localAgentDir = effectiveCwd ? joinPortablePath(effectiveCwd, ".pi", "agent") : null; const effectiveAgentDir = localAgentDir && existsSync(localAgentDir) ? localAgentDir : getAgentConfigDir(); return { effectiveCwd, localAgentDir, effectiveAgentDir }; } export function resolveSkillPaths( effectiveSkills: string | undefined, cwd: string, agentDir: string, ): string[] { const names = (effectiveSkills ?? "") .split(",") .map((skill) => skill.trim()) .filter(Boolean); if (names.length === 0) return []; const loaded = new Map(); const addSkills = (skills: Array<{ name: string; filePath: string }>) => { for (const skill of skills) { if (!loaded.has(skill.name)) loaded.set(skill.name, skill.filePath); } }; addSkills(loadSkills({ cwd, agentDir, skillPaths: [], includeDefaults: true }).skills); const extraDirs = [ joinPortablePath(cwd, ".agents", "skills"), joinPortablePath(resolveProjectRoot(cwd), ".agents", "skills"), joinPortablePath(dirnamePortablePath(dirnamePortablePath(agentDir)), ".agents", "skills"), ]; for (const dir of new Set(extraDirs)) { addSkills(loadSkillsFromDir({ dir, source: "subagent" }).skills); } return names.map((name) => { const directPath = resolvePortablePath(cwd, name); if (existsSync(directPath)) return directPath; const path = loaded.get(name); if (!path) { throw new Error(`Skill "${name}" was not found for the Ephemeral Child runtime.`); } return path; }); } function writeSystemPromptFile(filePath: string, identity: string): string { retryFileOperation(() => mkdirSync(dirname(filePath), { recursive: true })); retryFileOperation(() => writeFileSync(filePath, identity, { encoding: "utf8", mode: 0o600, })); return filePath; } export function buildPiAgentArgs(options: { /** Operation-owned temporary prompt resource. */ launchPromptFile: string; identity?: string | null; systemPromptMode?: "append" | "replace"; identityInSystemPrompt: boolean; model?: string; thinking?: string; tools?: string; skillPaths: string[]; }): string[] { const useLocalChildExtension = process.env[LOCAL_CHILD_EXTENSION_ENV] === "1"; const args = [ "--no-session", // Keep Pi's configured extension discovery enabled. The explicit hooks // below are additive, so Ephemeral Children get completion/cancellation // behavior without losing role-folder extensions. "-e", join(SUBAGENTS_DIR, "subagent-done.ts"), ...(useLocalChildExtension ? ["-e", join(SUBAGENTS_DIR, "index.ts")] : []), ]; if (options.model) args.push("--model", options.model); if (options.thinking) args.push("--thinking", options.thinking); if (options.identityInSystemPrompt && options.identity) { args.push( options.systemPromptMode === "replace" ? "--system-prompt" : "--append-system-prompt", writeSystemPromptFile(options.launchPromptFile, options.identity), ); } if (options.tools) args.push("--tools", options.tools); for (const skillPath of options.skillPaths) args.push("--skill", skillPath); return args; } export function buildTaskGuidance(task: string): string { return [ "Complete this standalone task autonomously.", "Do not assume access to a parent transcript; include or request any context you need in this task.", "", task, "", "Your FINAL assistant message should summarize what you accomplished.", ].join("\n"); } /** Resolve the set of denied tool names for a built-in subagent type. */ export function resolveDenyTools(agentDefs: BuiltinSubagentType): Set { const denied = new Set(); if (!agentDefs.spawning) { for (const tool of SUBAGENT_LIFECYCLE_TOOLS) denied.add(tool); } return denied; } /** Build the child --tools allowlist by normalizing the requested tools. */ export function buildSubagentToolAllowlist(effectiveTools?: string): string | null { const requested = (effectiveTools ?? "") .split(",") .map((tool) => tool.trim()) .filter(Boolean); if (requested.length === 0) return null; return [...new Set(requested)].join(","); } /** Nearest project root (`.git` or `.pi`) at or above `startCwd`. */ export function resolveProjectRoot(startCwd: string): string { let dir = startCwd; while (true) { if (existsSync(joinPortablePath(dir, ".git")) || existsSync(joinPortablePath(dir, ".pi"))) return dir; const parent = dirnamePortablePath(dir); if (parent === dir) return startCwd; dir = parent; } } function resolveLaunchPolicy( request: LaunchPolicyRequest, context: LaunchPolicyContext, ): ResolvedLaunchPolicy { const agentName = request.agent?.trim(); const agentDefs = requireBuiltinSubagentType(agentName); const denySet = resolveDenyTools(agentDefs); const runtimePlan = resolveRuntimePlan( { model: request.model, thinking: request.thinking }, {}, context.parentRuntime, context.modelRegistry, ); const { effectiveCwd, localAgentDir } = resolveSubagentPaths( request.cwd, context.parentCwd, ); const targetCwd = effectiveCwd ?? context.parentCwd; const skillPaths = resolveSkillPaths( request.skills, targetCwd, localAgentDir && existsSync(localAgentDir) ? localAgentDir : getAgentConfigDir(), ); const toolAllowlist = buildSubagentToolAllowlist( request.tools == null ? agentDefs.tools ?? undefined : [ request.tools, ...SUBAGENT_LIFECYCLE_TOOLS.filter((tool) => !denySet.has(tool)), ].join(","), ); const identityParts = [agentDefs.body, request.systemPrompt?.trim()] .filter((part): part is string => Boolean(part)); const identity = identityParts.length > 0 ? identityParts.join("\n\n") : null; const identityInSystemPrompt = identity !== null; const baseEnv: Record = {}; if (localAgentDir && existsSync(localAgentDir)) { baseEnv.PI_CODING_AGENT_DIR = localAgentDir; } else if (process.env.PI_CODING_AGENT_DIR) { baseEnv.PI_CODING_AGENT_DIR = process.env.PI_CODING_AGENT_DIR; } if (denySet.size > 0) baseEnv.PI_DENY_TOOLS = [...denySet].join(","); if (process.env[LOCAL_CHILD_EXTENSION_ENV] === "1") { baseEnv[LOCAL_CHILD_EXTENSION_ENV] = "1"; } baseEnv[SUBAGENT_ENV_AGENT] = agentName!; return { agentDefs, runtimePlan, targetCwd, buildLaunchPlan(launchPromptFile) { return { cwd: targetCwd, args: buildPiAgentArgs({ launchPromptFile, identity, systemPromptMode: "append", identityInSystemPrompt, model: runtimePlan.model, thinking: runtimePlan.thinking, tools: toolAllowlist ?? undefined, skillPaths, }), env: baseEnv, task: buildTaskGuidance(request.task), }; }, }; } export function createLaunchPolicy(): LaunchPolicyResolver { return { resolve: resolveLaunchPolicy, }; }