/** * Agent discovery and configuration */ import * as fs from "node:fs"; import * as path from "node:path"; import { mergeAgentsForScope } from "./agent-selection.js"; import { KNOWN_FIELDS } from "./agent-serializer.js"; import { parseChain } from "./chain-serializer.js"; import { getUserAgentsDir } from "./paths.js"; import { findNearestProjectAgentsDir, findProjectAgentsDirs } from "./project-agents-storage.js"; export type AgentScope = "user" | "project" | "both"; export type AgentSource = "builtin" | "user" | "project"; export interface AgentConfig { name: string; description: string; tools?: string[]; mcpDirectTools?: string[]; model?: string; thinking?: string; /** Idle timeout in ms: kill the agent if it produces no output for this long. * Default: 15 min (from DEFAULT_IDLE_TIMEOUT_MS). Set to 0 to disable. */ idleTimeoutMs?: number; systemPrompt: string; source: AgentSource; filePath: string; skills?: string[]; extensions?: string[]; // Chain behavior fields output?: string; defaultReads?: string[]; defaultProgress?: boolean; interactive?: boolean; extraFields?: Record; } export interface ChainStepConfig { agent: string; task: string; output?: string | false; reads?: string[] | false; model?: string; skills?: string[] | false; progress?: boolean; } export interface ChainConfig { name: string; description: string; source: AgentSource; filePath: string; steps: ChainStepConfig[]; extraFields?: Record; } export interface AgentDiscoveryResult { agents: AgentConfig[]; projectAgentsDir: string; } function parseFrontmatter(content: string): { frontmatter: Record; body: string } { const frontmatter: Record = {}; const normalized = content.replaceAll(/\r\n/g, "\n"); if (!normalized.startsWith("---")) { return { body: normalized, frontmatter }; } const endIndex = normalized.indexOf("\n---", 3); if (endIndex === -1) { return { body: normalized, frontmatter }; } const frontmatterBlock = normalized.slice(4, endIndex); const body = normalized.slice(endIndex + 4).trim(); for (const line of frontmatterBlock.split("\n")) { const match = line.match(/^([\w-]+):\s*(.*)$/); if (match) { let value = match[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } frontmatter[match[1]] = value; } } return { body, frontmatter }; } function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] { const agents: AgentConfig[] = []; if (!fs.existsSync(dir)) { return agents; } let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return agents; } for (const entry of entries) { if (!entry.name.endsWith(".md")) { continue; } if (entry.name.endsWith(".chain.md")) { continue; } if (!entry.isFile() && !entry.isSymbolicLink()) { continue; } const filePath = path.join(dir, entry.name); let content: string; try { content = fs.readFileSync(filePath, "utf8"); } catch { continue; } const { frontmatter, body } = parseFrontmatter(content); if (!frontmatter.name || !frontmatter.description) { continue; } const rawTools = frontmatter.tools ?.split(",") .map((t) => t.trim()) .filter(Boolean); const mcpDirectTools: string[] = []; const tools: string[] = []; if (rawTools) { for (const tool of rawTools) { if (tool.startsWith("mcp:")) { mcpDirectTools.push(tool.slice(4)); } else { tools.push(tool); } } } // Parse defaultReads as comma-separated list (like tools) const defaultReads = frontmatter.defaultReads ?.split(",") .map((f) => f.trim()) .filter(Boolean); const skillStr = frontmatter.skill || frontmatter.skills; const skills = skillStr ?.split(",") .map((s) => s.trim()) .filter(Boolean); let extensions: string[] | undefined; if (frontmatter.extensions !== undefined) { extensions = frontmatter.extensions .split(",") .map((e) => e.trim()) .filter(Boolean); } const extraFields: Record = {}; for (const [key, value] of Object.entries(frontmatter)) { if (!KNOWN_FIELDS.has(key)) { extraFields[key] = value; } } agents.push({ name: frontmatter.name, description: frontmatter.description, tools: tools.length > 0 ? tools : undefined, mcpDirectTools: mcpDirectTools.length > 0 ? mcpDirectTools : undefined, model: frontmatter.model, thinking: frontmatter.thinking, idleTimeoutMs: frontmatter.idleTimeoutMs ? Number(frontmatter.idleTimeoutMs) : undefined, systemPrompt: body, source, filePath, skills: skills && skills.length > 0 ? skills : undefined, extensions, // Chain behavior fields output: frontmatter.output, defaultReads: defaultReads && defaultReads.length > 0 ? defaultReads : undefined, defaultProgress: frontmatter.defaultProgress === "true", interactive: frontmatter.interactive === "true", extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined, }); } return agents; } function loadChainsFromDir(dir: string, source: AgentSource): ChainConfig[] { const chains: ChainConfig[] = []; if (!fs.existsSync(dir)) { return chains; } let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return chains; } for (const entry of entries) { if (!entry.name.endsWith(".chain.md")) { continue; } if (!entry.isFile() && !entry.isSymbolicLink()) { continue; } const filePath = path.join(dir, entry.name); let content: string; try { content = fs.readFileSync(filePath, "utf8"); } catch { continue; } try { chains.push(parseChain(content, source, filePath)); } catch { continue; } } return chains; } const BUILTIN_AGENTS_DIR = path.join(import.meta.dirname, "agents"); /** Read the nearest project `subagents.excludeBuiltins` setting. */ function readExcludeBuiltinsFlag(cwd: string): boolean { let current = path.resolve(cwd); while (true) { const settingsPath = path.join(current, ".pi", "settings.json"); if (fs.existsSync(settingsPath)) { try { const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as { subagents?: { excludeBuiltins?: unknown }; }; return settings.subagents?.excludeBuiltins === true; } catch { return false; } } const parent = path.dirname(current); if (parent === current) { return false; } current = parent; } } function mergeNamedConfigs(groups: T[][]): T[] { const merged = new Map(); for (const group of groups) { for (const item of group) { if (!merged.has(item.name)) { merged.set(item.name, item); } } } return [...merged.values()]; } function loadAgentsFromDirs(dirs: string[], source: AgentSource): AgentConfig[] { const groups: AgentConfig[][] = []; for (const dir of dirs) { groups.push(loadAgentsFromDir(dir, source)); } return mergeNamedConfigs(groups); } function loadChainsFromDirs(dirs: string[], source: AgentSource): ChainConfig[] { const groups: ChainConfig[][] = []; for (const dir of dirs) { groups.push(loadChainsFromDir(dir, source)); } return mergeNamedConfigs(groups); } export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult { const userDir = getUserAgentsDir(); const projectAgentsDir = findNearestProjectAgentsDir(cwd); const projectAgentDirs = findProjectAgentsDirs(cwd); const excludeBuiltins = readExcludeBuiltinsFlag(cwd); const builtinAgents = excludeBuiltins ? [] : loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"); const userAgents = scope === "project" || excludeBuiltins ? [] : loadAgentsFromDir(userDir, "user"); const projectAgents = scope === "user" ? [] : loadAgentsFromDirs(projectAgentDirs, "project"); const agents = mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents); return { agents, projectAgentsDir }; } export function discoverAgentsAll(cwd: string): { builtin: AgentConfig[]; user: AgentConfig[]; project: AgentConfig[]; chains: ChainConfig[]; userDir: string; projectDir: string; } { const userDir = getUserAgentsDir(); const projectDir = findNearestProjectAgentsDir(cwd); const projectDirs = findProjectAgentsDirs(cwd); const excludeBuiltins = readExcludeBuiltinsFlag(cwd); const builtin = excludeBuiltins ? [] : loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"); const user = excludeBuiltins ? [] : loadAgentsFromDir(userDir, "user"); const project = loadAgentsFromDirs(projectDirs, "project"); const chains = mergeNamedConfigs([loadChainsFromDir(userDir, "user"), loadChainsFromDirs(projectDirs, "project")]); return { builtin, chains, project, projectDir, user, userDir }; }