/** * Agent discovery and configuration */ import { execSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import type { AcceptanceInput, OutputMode } from "../shared/types.ts"; import { getAgentDir } from "../shared/utils.ts"; import { KNOWN_FIELDS } from "./agent-serializer.ts"; import { parseChain, parseJsonChain } from "./chain-serializer.ts"; import { mergeAgentsForScope } from "./agent-selection.ts"; import { parseFrontmatter } from "./frontmatter.ts"; import { buildRuntimeName, parsePackageName } from "./identity.ts"; export { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./identity.ts"; export type AgentScope = "user" | "project" | "both"; export type AgentSource = "builtin" | "package" | "user" | "project"; type SystemPromptMode = "append" | "replace"; export type AgentDefaultContext = "fresh" | "fork"; export function defaultSystemPromptMode(name: string): SystemPromptMode { return name === "delegate" ? "append" : "replace"; } export function defaultInheritProjectContext(name: string): boolean { return name === "delegate"; } export function defaultInheritSkills(): boolean { return false; } export interface BuiltinAgentOverrideBase { model?: string; fallbackModels?: string[]; thinking?: string; systemPromptMode: SystemPromptMode; inheritProjectContext: boolean; inheritSkills: boolean; defaultContext?: AgentDefaultContext; disabled?: boolean; systemPrompt: string; skills?: string[]; tools?: string[]; mcpDirectTools?: string[]; subagentOnlyExtensions?: string[]; completionGuard?: boolean; } interface BuiltinAgentOverrideConfig { model?: string | false; fallbackModels?: string[] | false; thinking?: string | false; systemPromptMode?: SystemPromptMode; inheritProjectContext?: boolean; inheritSkills?: boolean; defaultContext?: AgentDefaultContext | false; disabled?: boolean; systemPrompt?: string; skills?: string[] | false; tools?: string[] | false; subagentOnlyExtensions?: string[] | false; completionGuard?: boolean; } interface BuiltinAgentOverrideInfo { scope: "user" | "project"; path: string; base: BuiltinAgentOverrideBase; } export interface AgentConfig { name: string; localName?: string; packageName?: string; description: string; tools?: string[]; mcpDirectTools?: string[]; model?: string; fallbackModels?: string[]; thinking?: string; systemPromptMode: SystemPromptMode; inheritProjectContext: boolean; inheritSkills: boolean; defaultContext?: AgentDefaultContext; systemPrompt: string; source: AgentSource; filePath: string; skills?: string[]; extensions?: string[]; subagentOnlyExtensions?: string[]; output?: string; defaultReads?: string[]; defaultProgress?: boolean; interactive?: boolean; maxSubagentDepth?: number; completionGuard?: boolean; disabled?: boolean; extraFields?: Record; override?: BuiltinAgentOverrideInfo; } interface SubagentSettings { overrides: Record; disableBuiltins?: boolean; } const EMPTY_SUBAGENT_SETTINGS: SubagentSettings = { overrides: {} }; export interface ChainStepConfig { agent?: string; task?: string; phase?: string; label?: string; as?: string; outputSchema?: string | Record; output?: string | false; outputMode?: OutputMode; reads?: string[] | false; model?: string; skills?: string[] | false; progress?: boolean; parallel?: unknown; expand?: unknown; collect?: unknown; concurrency?: number; failFast?: boolean; worktree?: boolean; acceptance?: AcceptanceInput; } export interface ChainConfig { name: string; localName?: string; packageName?: string; description: string; source: AgentSource; filePath: string; steps: ChainStepConfig[]; extraFields?: Record; } export interface ChainDiscoveryDiagnostic { source: AgentSource; filePath: string; error: string; } interface AgentDiscoveryResult { agents: AgentConfig[]; projectAgentsDir: string | null; } function getUserChainDir(): string { return path.join(getAgentDir(), "chains"); } interface PackageSubagentPaths { agents: string[]; chains: string[]; } let cachedGlobalNpmRoot: string | null = null; function readJsonFileBestEffort(filePath: string): unknown { try { return JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch { // Installed package scans are opportunistic; bad third-party manifests // should not break local agent discovery. return null; } } function readOptionalJsonFile(filePath: string): unknown { try { return JSON.parse(fs.readFileSync(filePath, "utf-8")); } catch (error) { const code = typeof error === "object" && error !== null && "code" in error ? (error as { code?: unknown }).code : undefined; if (code === "ENOENT") return null; throw error; } } function isSafePackagePath(value: string): boolean { return value.length > 0 && !path.isAbsolute(value) && value.split(/[\\/]/).every((part) => part.length > 0 && part !== "." && part !== ".."); } function parseNpmPackageName(source: string): string | undefined { const spec = source.slice(4).trim(); if (!spec) return undefined; const match = spec.match(/^(@?[^@]+(?:\/[^@]+)?)(?:@(.+))?$/); const packageName = match?.[1] ?? spec; return isSafePackagePath(packageName) ? packageName : undefined; } function stripGitRef(repoPath: string): string { const atIndex = repoPath.indexOf("@"); const hashIndex = repoPath.indexOf("#"); const refIndex = [atIndex, hashIndex].filter((index) => index >= 0).sort((a, b) => a - b)[0]; return refIndex === undefined ? repoPath : repoPath.slice(0, refIndex); } function parseGitPackagePath(source: string): { host: string; repoPath: string } | undefined { const spec = source.slice(4).trim(); if (!spec) return undefined; let host = ""; let repoPath = ""; const scpLike = spec.match(/^git@([^:]+):(.+)$/); if (scpLike) { host = scpLike[1] ?? ""; repoPath = scpLike[2] ?? ""; } else if (/^[a-z][a-z0-9+.-]*:\/\//i.test(spec)) { try { const url = new URL(spec); host = url.hostname; repoPath = url.pathname.replace(/^\/+/, ""); } catch { return undefined; } } else { const slashIndex = spec.indexOf("/"); if (slashIndex < 0) return undefined; host = spec.slice(0, slashIndex); repoPath = spec.slice(slashIndex + 1); } const normalizedPath = stripGitRef(repoPath).replace(/\.git$/, "").replace(/^\/+/, ""); if (!host || !isSafePackagePath(host) || !isSafePackagePath(normalizedPath) || normalizedPath.split(/[\\/]/).length < 2) { return undefined; } return { host, repoPath: normalizedPath }; } function resolveSettingsPackageRoot(source: string, baseDir: string): string | undefined { const trimmed = source.trim(); if (!trimmed) return undefined; if (trimmed.startsWith("git:")) { const parsed = parseGitPackagePath(trimmed); return parsed ? path.join(baseDir, "git", parsed.host, parsed.repoPath) : undefined; } if (trimmed.startsWith("npm:")) { const packageName = parseNpmPackageName(trimmed); return packageName ? path.join(baseDir, "npm", "node_modules", packageName) : undefined; } const normalized = trimmed.startsWith("file:") ? trimmed.slice(5) : trimmed; if (normalized === "~") return os.homedir(); if (normalized.startsWith("~/")) return path.join(os.homedir(), normalized.slice(2)); if (path.isAbsolute(normalized)) return normalized; if (normalized === "." || normalized === ".." || normalized.startsWith("./") || normalized.startsWith("../")) { return path.resolve(baseDir, normalized); } return undefined; } function getGlobalNpmRoot(): string | null { if (cachedGlobalNpmRoot !== null) return cachedGlobalNpmRoot; try { cachedGlobalNpmRoot = fs.realpathSync(execSync("npm root -g", { encoding: "utf-8", timeout: 5000 }).trim()); return cachedGlobalNpmRoot; } catch { cachedGlobalNpmRoot = ""; return null; } } function stringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0); } function extractSubagentPathsFromPackageRoot(packageRoot: string): PackageSubagentPaths { const packageJsonPath = path.join(packageRoot, "package.json"); const pkg = readJsonFileBestEffort(packageJsonPath); if (!pkg || typeof pkg !== "object" || Array.isArray(pkg)) return { agents: [], chains: [] }; const roots: Record[] = []; const piSubagents = (pkg as { "pi-subagents"?: unknown })["pi-subagents"]; if (piSubagents && typeof piSubagents === "object" && !Array.isArray(piSubagents)) { roots.push(piSubagents as Record); } const pi = (pkg as { pi?: unknown }).pi; if (pi && typeof pi === "object" && !Array.isArray(pi)) { const subagents = (pi as { subagents?: unknown }).subagents; if (subagents && typeof subagents === "object" && !Array.isArray(subagents)) { roots.push(subagents as Record); } } const agents: string[] = []; const chains: string[] = []; for (const root of roots) { for (const entry of stringArray(root.agents)) agents.push(path.resolve(packageRoot, entry)); for (const entry of stringArray(root.chains)) chains.push(path.resolve(packageRoot, entry)); } return { agents, chains }; } function collectPackageRootsFromNodeModules(nodeModulesDir: string): string[] { const roots: string[] = []; if (!fs.existsSync(nodeModulesDir)) return roots; let entries: fs.Dirent[]; try { entries = fs.readdirSync(nodeModulesDir, { withFileTypes: true }); } catch { return roots; } for (const entry of entries) { if (entry.name.startsWith(".")) continue; if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; if (entry.name.startsWith("@")) { const scopeDir = path.join(nodeModulesDir, entry.name); let scopeEntries: fs.Dirent[]; try { scopeEntries = fs.readdirSync(scopeDir, { withFileTypes: true }); } catch { continue; } for (const scopeEntry of scopeEntries) { if (scopeEntry.name.startsWith(".")) continue; if (!scopeEntry.isDirectory() && !scopeEntry.isSymbolicLink()) continue; roots.push(path.join(scopeDir, scopeEntry.name)); } continue; } roots.push(path.join(nodeModulesDir, entry.name)); } return roots; } function collectSettingsPackageRoots(settingsFile: string, baseDir: string): string[] { const settings = readOptionalJsonFile(settingsFile); if (!settings || typeof settings !== "object" || Array.isArray(settings)) return []; const packages = (settings as { packages?: unknown }).packages; if (!Array.isArray(packages)) return []; const roots: string[] = []; for (const entry of packages) { const packageSource = typeof entry === "string" ? entry : typeof entry === "object" && entry !== null && typeof (entry as { source?: unknown }).source === "string" ? (entry as { source: string }).source : undefined; if (!packageSource) continue; const packageRoot = resolveSettingsPackageRoot(packageSource, baseDir); if (packageRoot) roots.push(packageRoot); } return roots; } function collectPackageSubagentPaths(cwd: string, options: { includeUser: boolean; includeProject: boolean } = { includeUser: true, includeProject: true }): PackageSubagentPaths { const agentDir = getAgentDir(); const projectRoot = findNearestProjectRoot(cwd) ?? cwd; const packageRoots = [ projectRoot, ]; if (options.includeProject) { packageRoots.push( ...collectPackageRootsFromNodeModules(path.join(projectRoot, ".pi", "npm", "node_modules")), ...collectSettingsPackageRoots(path.join(projectRoot, ".pi", "settings.json"), path.join(projectRoot, ".pi")), ); } if (options.includeUser) { packageRoots.push( ...collectPackageRootsFromNodeModules(path.join(agentDir, "npm", "node_modules")), ...collectSettingsPackageRoots(path.join(agentDir, "settings.json"), agentDir), ); } if (options.includeUser) { const globalRoot = getGlobalNpmRoot(); if (globalRoot) packageRoots.push(...collectPackageRootsFromNodeModules(globalRoot)); } const seenRoots = new Set(); const seenAgents = new Set(); const seenChains = new Set(); const agents: string[] = []; const chains: string[] = []; for (const packageRoot of packageRoots) { const resolvedRoot = path.resolve(packageRoot); if (seenRoots.has(resolvedRoot)) continue; seenRoots.add(resolvedRoot); const paths = extractSubagentPathsFromPackageRoot(resolvedRoot); for (const agentDir of paths.agents) { if (seenAgents.has(agentDir)) continue; seenAgents.add(agentDir); agents.push(agentDir); } for (const chainDir of paths.chains) { if (seenChains.has(chainDir)) continue; seenChains.add(chainDir); chains.push(chainDir); } } return { agents, chains }; } function splitToolList(rawTools: string[] | undefined): { tools?: string[]; mcpDirectTools?: string[] } { const mcpDirectTools: string[] = []; const tools: string[] = []; for (const tool of rawTools ?? []) { if (tool.startsWith("mcp:")) { mcpDirectTools.push(tool.slice(4)); } else { tools.push(tool); } } return { ...(tools.length > 0 ? { tools } : {}), ...(mcpDirectTools.length > 0 ? { mcpDirectTools } : {}), }; } function joinToolList(config: Pick): string[] | undefined { const joined = [ ...(config.tools ?? []), ...(config.mcpDirectTools ?? []).map((tool) => `mcp:${tool}`), ]; return joined.length > 0 ? joined : undefined; } function arraysEqual(a: string[] | undefined, b: string[] | undefined): boolean { if (!a && !b) return true; if (!a || !b) return false; if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return false; } return true; } function cloneOverrideBase(agent: AgentConfig): BuiltinAgentOverrideBase { return { model: agent.model, fallbackModels: agent.fallbackModels ? [...agent.fallbackModels] : undefined, thinking: agent.thinking, systemPromptMode: agent.systemPromptMode, inheritProjectContext: agent.inheritProjectContext, inheritSkills: agent.inheritSkills, defaultContext: agent.defaultContext, disabled: agent.disabled, systemPrompt: agent.systemPrompt, skills: agent.skills ? [...agent.skills] : undefined, tools: agent.tools ? [...agent.tools] : undefined, mcpDirectTools: agent.mcpDirectTools ? [...agent.mcpDirectTools] : undefined, subagentOnlyExtensions: agent.subagentOnlyExtensions ? [...agent.subagentOnlyExtensions] : undefined, completionGuard: agent.completionGuard, }; } function cloneOverrideValue(override: BuiltinAgentOverrideConfig): BuiltinAgentOverrideConfig { return { ...(override.model !== undefined ? { model: override.model } : {}), ...(override.fallbackModels !== undefined ? { fallbackModels: override.fallbackModels === false ? false : [...override.fallbackModels] } : {}), ...(override.thinking !== undefined ? { thinking: override.thinking } : {}), ...(override.systemPromptMode !== undefined ? { systemPromptMode: override.systemPromptMode } : {}), ...(override.inheritProjectContext !== undefined ? { inheritProjectContext: override.inheritProjectContext } : {}), ...(override.inheritSkills !== undefined ? { inheritSkills: override.inheritSkills } : {}), ...(override.defaultContext !== undefined ? { defaultContext: override.defaultContext } : {}), ...(override.disabled !== undefined ? { disabled: override.disabled } : {}), ...(override.systemPrompt !== undefined ? { systemPrompt: override.systemPrompt } : {}), ...(override.skills !== undefined ? { skills: override.skills === false ? false : [...override.skills] } : {}), ...(override.tools !== undefined ? { tools: override.tools === false ? false : [...override.tools] } : {}), ...(override.subagentOnlyExtensions !== undefined ? { subagentOnlyExtensions: override.subagentOnlyExtensions === false ? false : [...override.subagentOnlyExtensions] } : {}), ...(override.completionGuard !== undefined ? { completionGuard: override.completionGuard } : {}), }; } function findNearestProjectRoot(cwd: string): string | null { let currentDir = cwd; while (true) { if (isDirectory(path.join(currentDir, ".pi")) || isDirectory(path.join(currentDir, ".agents"))) { return currentDir; } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) return null; currentDir = parentDir; } } function getUserAgentSettingsPath(): string { return path.join(getAgentDir(), "settings.json"); } function getProjectAgentSettingsPath(cwd: string): string | null { const projectRoot = findNearestProjectRoot(cwd); return projectRoot ? path.join(projectRoot, ".pi", "settings.json") : null; } function readSettingsFileStrict(filePath: string): Record { if (!fs.existsSync(filePath)) return {}; let raw: string; try { raw = fs.readFileSync(filePath, "utf-8"); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to read settings file '${filePath}': ${message}`, { cause: error }); } let parsed: unknown; try { parsed = JSON.parse(raw); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to parse settings file '${filePath}': ${message}`, { cause: error }); } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`Settings file '${filePath}' must contain a JSON object.`); } return parsed as Record; } function writeSettingsFile(filePath: string, settings: Record): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); fs.writeFileSync(filePath, JSON.stringify(settings, null, 2) + "\n", "utf-8"); } function parseOverrideStringArrayOrFalse( value: unknown, meta: { filePath: string; name: string; field: string }, ): string[] | false | undefined { if (value === undefined) return undefined; if (value === false) return false; if (!Array.isArray(value)) { throw new Error(`Builtin override '${meta.name}' in '${meta.filePath}' has invalid '${meta.field}'; expected an array of strings or false.`); } const items: string[] = []; for (const item of value) { if (typeof item !== "string") { throw new Error(`Builtin override '${meta.name}' in '${meta.filePath}' has invalid '${meta.field}'; expected an array of strings or false.`); } const trimmed = item.trim(); if (trimmed) items.push(trimmed); } return items; } function parseBuiltinOverrideEntry( name: string, value: unknown, filePath: string, ): BuiltinAgentOverrideConfig | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(`Builtin override '${name}' in '${filePath}' must be an object.`); } const input = value as Record; const override: BuiltinAgentOverrideConfig = {}; if ("model" in input) { if (typeof input.model === "string" || input.model === false) override.model = input.model; else throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'model'; expected a string or false.`); } if ("thinking" in input) { if (typeof input.thinking === "string" || input.thinking === false) override.thinking = input.thinking; else throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'thinking'; expected a string or false.`); } if ("systemPromptMode" in input) { if (input.systemPromptMode === "append" || input.systemPromptMode === "replace") { override.systemPromptMode = input.systemPromptMode; } else { throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'systemPromptMode'; expected 'append' or 'replace'.`); } } if ("inheritProjectContext" in input) { if (typeof input.inheritProjectContext === "boolean") { override.inheritProjectContext = input.inheritProjectContext; } else { throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'inheritProjectContext'; expected a boolean.`); } } if ("inheritSkills" in input) { if (typeof input.inheritSkills === "boolean") { override.inheritSkills = input.inheritSkills; } else { throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'inheritSkills'; expected a boolean.`); } } if ("defaultContext" in input) { if (input.defaultContext === "fresh" || input.defaultContext === "fork" || input.defaultContext === false) { override.defaultContext = input.defaultContext; } else { throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'defaultContext'; expected 'fresh', 'fork', or false.`); } } if ("disabled" in input) { if (typeof input.disabled === "boolean") { override.disabled = input.disabled; } else { throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'disabled'; expected a boolean.`); } } if ("completionGuard" in input) { if (typeof input.completionGuard === "boolean") { override.completionGuard = input.completionGuard; } else { throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'completionGuard'; expected a boolean.`); } } if ("systemPrompt" in input) { if (typeof input.systemPrompt === "string") override.systemPrompt = input.systemPrompt; else throw new Error(`Builtin override '${name}' in '${filePath}' has invalid 'systemPrompt'; expected a string.`); } const fallbackModels = parseOverrideStringArrayOrFalse(input.fallbackModels, { filePath, name, field: "fallbackModels" }); if (fallbackModels !== undefined) override.fallbackModels = fallbackModels; const skills = parseOverrideStringArrayOrFalse(input.skills, { filePath, name, field: "skills" }); if (skills !== undefined) override.skills = skills; const tools = parseOverrideStringArrayOrFalse(input.tools, { filePath, name, field: "tools" }); if (tools !== undefined) override.tools = tools; const subagentOnlyExtensions = parseOverrideStringArrayOrFalse(input.subagentOnlyExtensions, { filePath, name, field: "subagentOnlyExtensions" }); if (subagentOnlyExtensions !== undefined) override.subagentOnlyExtensions = subagentOnlyExtensions; return Object.keys(override).length > 0 ? override : undefined; } function readSubagentSettings(filePath: string | null): SubagentSettings { if (!filePath) return EMPTY_SUBAGENT_SETTINGS; const settings = readSettingsFileStrict(filePath); const subagents = settings.subagents; if (!subagents || typeof subagents !== "object" || Array.isArray(subagents)) return EMPTY_SUBAGENT_SETTINGS; const subagentsObject = subagents as Record; let disableBuiltins: boolean | undefined; if ("disableBuiltins" in subagentsObject) { if (typeof subagentsObject.disableBuiltins === "boolean") { disableBuiltins = subagentsObject.disableBuiltins; } else { throw new Error(`Subagent settings in '${filePath}' have invalid 'disableBuiltins'; expected a boolean.`); } } const parsed: Record = {}; const agentOverrides = subagentsObject.agentOverrides; if (!agentOverrides || typeof agentOverrides !== "object" || Array.isArray(agentOverrides)) { return { overrides: parsed, disableBuiltins }; } for (const [name, value] of Object.entries(agentOverrides)) { const override = parseBuiltinOverrideEntry(name, value, filePath); if (override) parsed[name] = override; } return { overrides: parsed, disableBuiltins }; } function applyBuiltinOverride( agent: AgentConfig, override: BuiltinAgentOverrideConfig, meta: { scope: "user" | "project"; path: string }, ): AgentConfig { const next: AgentConfig = { ...agent, override: { ...meta, base: cloneOverrideBase(agent) }, }; if (override.model !== undefined) next.model = override.model === false ? undefined : override.model; if (override.fallbackModels !== undefined) { next.fallbackModels = override.fallbackModels === false ? undefined : [...override.fallbackModels]; } if (override.thinking !== undefined) next.thinking = override.thinking === false ? undefined : override.thinking; if (override.systemPromptMode !== undefined) next.systemPromptMode = override.systemPromptMode; if (override.inheritProjectContext !== undefined) next.inheritProjectContext = override.inheritProjectContext; if (override.inheritSkills !== undefined) next.inheritSkills = override.inheritSkills; if (override.defaultContext !== undefined) next.defaultContext = override.defaultContext === false ? undefined : override.defaultContext; if (override.disabled !== undefined) next.disabled = override.disabled; if (override.systemPrompt !== undefined) next.systemPrompt = override.systemPrompt; if (override.skills !== undefined) next.skills = override.skills === false ? undefined : [...override.skills]; if (override.tools !== undefined) { const { tools, mcpDirectTools } = splitToolList(override.tools === false ? [] : override.tools); next.tools = tools; next.mcpDirectTools = mcpDirectTools; } if (override.subagentOnlyExtensions !== undefined) { next.subagentOnlyExtensions = override.subagentOnlyExtensions === false ? undefined : [...override.subagentOnlyExtensions]; } if (override.completionGuard !== undefined) next.completionGuard = override.completionGuard; return next; } function applyBuiltinOverrides( builtinAgents: AgentConfig[], userSettings: SubagentSettings, projectSettings: SubagentSettings, userSettingsPath: string, projectSettingsPath: string | null, ): AgentConfig[] { const projectBulkDisabled = projectSettings.disableBuiltins === true && projectSettingsPath !== null; const userBulkDisabled = projectSettings.disableBuiltins === undefined && userSettings.disableBuiltins === true; return builtinAgents.map((agent) => { const projectOverride = projectSettings.overrides[agent.name]; if (projectOverride && projectSettingsPath) { return applyBuiltinOverride(agent, projectOverride, { scope: "project", path: projectSettingsPath }); } if (projectBulkDisabled && projectSettingsPath) { return applyBuiltinOverride(agent, { disabled: true }, { scope: "project", path: projectSettingsPath }); } const userOverride = userSettings.overrides[agent.name]; if (userOverride) { return applyBuiltinOverride(agent, userOverride, { scope: "user", path: userSettingsPath }); } if (userBulkDisabled) { return applyBuiltinOverride(agent, { disabled: true }, { scope: "user", path: userSettingsPath }); } return agent; }); } export function buildBuiltinOverrideConfig( base: BuiltinAgentOverrideBase, draft: Pick, ): BuiltinAgentOverrideConfig | undefined { const override: BuiltinAgentOverrideConfig = {}; if (draft.model !== base.model) override.model = draft.model ?? false; if (!arraysEqual(draft.fallbackModels, base.fallbackModels)) override.fallbackModels = draft.fallbackModels ? [...draft.fallbackModels] : false; if (draft.thinking !== base.thinking) override.thinking = draft.thinking ?? false; if (draft.systemPromptMode !== base.systemPromptMode) override.systemPromptMode = draft.systemPromptMode; if (draft.inheritProjectContext !== base.inheritProjectContext) override.inheritProjectContext = draft.inheritProjectContext; if (draft.inheritSkills !== base.inheritSkills) override.inheritSkills = draft.inheritSkills; if (draft.defaultContext !== base.defaultContext) override.defaultContext = draft.defaultContext ?? false; if (draft.disabled !== base.disabled) override.disabled = draft.disabled ?? false; if (draft.systemPrompt !== base.systemPrompt) override.systemPrompt = draft.systemPrompt; if (!arraysEqual(draft.skills, base.skills)) override.skills = draft.skills ? [...draft.skills] : false; const baseTools = joinToolList(base); const draftTools = joinToolList(draft); if (!arraysEqual(draftTools, baseTools)) override.tools = draftTools ? [...draftTools] : false; if (!arraysEqual(draft.subagentOnlyExtensions, base.subagentOnlyExtensions)) { override.subagentOnlyExtensions = draft.subagentOnlyExtensions ? [...draft.subagentOnlyExtensions] : false; } if ((draft.completionGuard !== false) !== (base.completionGuard !== false)) { override.completionGuard = draft.completionGuard !== false; } return Object.keys(override).length > 0 ? override : undefined; } export function saveBuiltinAgentOverride( cwd: string, name: string, scope: "user" | "project", override: BuiltinAgentOverrideConfig, ): string { const filePath = scope === "project" ? getProjectAgentSettingsPath(cwd) : getUserAgentSettingsPath(); if (!filePath) throw new Error("Project override is not available here. No project config root was found."); const settings = readSettingsFileStrict(filePath); const subagents = settings.subagents && typeof settings.subagents === "object" && !Array.isArray(settings.subagents) ? { ...(settings.subagents as Record) } : {}; const agentOverrides = subagents.agentOverrides && typeof subagents.agentOverrides === "object" && !Array.isArray(subagents.agentOverrides) ? { ...(subagents.agentOverrides as Record) } : {}; agentOverrides[name] = cloneOverrideValue(override); subagents.agentOverrides = agentOverrides; settings.subagents = subagents; writeSettingsFile(filePath, settings); return filePath; } export function removeBuiltinAgentOverride(cwd: string, name: string, scope: "user" | "project"): string { const filePath = scope === "project" ? getProjectAgentSettingsPath(cwd) : getUserAgentSettingsPath(); if (!filePath) throw new Error("Project override is not available here. No project config root was found."); if (!fs.existsSync(filePath)) return filePath; const settings = readSettingsFileStrict(filePath); const subagents = settings.subagents; if (!subagents || typeof subagents !== "object" || Array.isArray(subagents)) return filePath; const nextSubagents = { ...(subagents as Record) }; const agentOverrides = nextSubagents.agentOverrides; if (!agentOverrides || typeof agentOverrides !== "object" || Array.isArray(agentOverrides)) return filePath; const nextOverrides = { ...(agentOverrides as Record) }; delete nextOverrides[name]; if (Object.keys(nextOverrides).length > 0) nextSubagents.agentOverrides = nextOverrides; else delete nextSubagents.agentOverrides; if (Object.keys(nextSubagents).length > 0) settings.subagents = nextSubagents; else delete settings.subagents; writeSettingsFile(filePath, settings); return filePath; } function listFilesRecursive(dir: string, predicate: (fileName: string) => boolean): string[] { const files: string[] = []; if (!fs.existsSync(dir)) return files; let entries: fs.Dirent[]; try { entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { return files; } for (const entry of entries) { const filePath = path.join(dir, entry.name); if (entry.isDirectory()) { files.push(...listFilesRecursive(filePath, predicate)); continue; } if (!entry.isFile() && !entry.isSymbolicLink()) continue; if (!predicate(entry.name)) continue; files.push(filePath); } return files; } function isLegacyAgentSkillPath(rootDir: string, filePath: string): boolean { const relative = path.relative(rootDir, filePath); const parts = relative.split(path.sep).map((part) => part.toLowerCase()); if (path.basename(rootDir).toLowerCase() === ".agents") { parts.unshift(".agents"); } return parts.some((part, index) => part === ".agents" && parts[index + 1] === "skills"); } function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] { const agents: AgentConfig[] = []; for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) { if (isLegacyAgentSkillPath(dir, filePath)) { continue; } let content: string; try { content = fs.readFileSync(filePath, "utf-8"); } catch { continue; } const { frontmatter, body } = parseFrontmatter(content); if (!frontmatter.name || !frontmatter.description) { continue; } const localName = frontmatter.name; const parsedPackage = parsePackageName(frontmatter.package, `Agent '${localName}' package`); if (parsedPackage.error) continue; const packageName = parsedPackage.packageName; const runtimeName = buildRuntimeName(localName, packageName); 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); } } } 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); const fallbackModels = frontmatter.fallbackModels ?.split(",") .map((model) => model.trim()) .filter(Boolean); const systemPromptMode = frontmatter.systemPromptMode === "replace" ? "replace" : frontmatter.systemPromptMode === "append" ? "append" : defaultSystemPromptMode(localName); const inheritProjectContext = frontmatter.inheritProjectContext === "true" ? true : frontmatter.inheritProjectContext === "false" ? false : defaultInheritProjectContext(localName); const inheritSkills = frontmatter.inheritSkills === "true" ? true : frontmatter.inheritSkills === "false" ? false : defaultInheritSkills(); const defaultContext = frontmatter.defaultContext === "fork" ? "fork" as const : frontmatter.defaultContext === "fresh" ? "fresh" as const : undefined; let extensions: string[] | undefined; if (frontmatter.extensions !== undefined) { extensions = frontmatter.extensions .split(",") .map((e) => e.trim()) .filter(Boolean); } let subagentOnlyExtensions: string[] | undefined; if (frontmatter.subagentOnlyExtensions !== undefined) { subagentOnlyExtensions = frontmatter.subagentOnlyExtensions .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; } const parsedMaxSubagentDepth = Number(frontmatter.maxSubagentDepth); const completionGuard = frontmatter.completionGuard === "false" ? false : frontmatter.completionGuard === "true" ? true : undefined; agents.push({ name: runtimeName, localName, packageName, description: frontmatter.description, tools: tools.length > 0 ? tools : undefined, mcpDirectTools: mcpDirectTools.length > 0 ? mcpDirectTools : undefined, model: frontmatter.model, fallbackModels: fallbackModels && fallbackModels.length > 0 ? fallbackModels : undefined, thinking: frontmatter.thinking, systemPromptMode, inheritProjectContext, inheritSkills, defaultContext, systemPrompt: body, source, filePath, skills: skills && skills.length > 0 ? skills : undefined, extensions, subagentOnlyExtensions, output: frontmatter.output, defaultReads: defaultReads && defaultReads.length > 0 ? defaultReads : undefined, defaultProgress: frontmatter.defaultProgress === "true", interactive: frontmatter.interactive === "true", maxSubagentDepth: Number.isInteger(parsedMaxSubagentDepth) && parsedMaxSubagentDepth >= 0 ? parsedMaxSubagentDepth : undefined, completionGuard, extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined, }); } return agents; } function loadChainsFromDir(dir: string, source: AgentSource): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } { const chains = new Map(); const diagnostics: ChainDiscoveryDiagnostic[] = []; for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".chain.md") || fileName.endsWith(".chain.json"))) { let content: string; try { content = fs.readFileSync(filePath, "utf-8"); } catch { continue; } try { const chain = filePath.endsWith(".chain.json") ? parseJsonChain(content, source, filePath) : parseChain(content, source, filePath); const existing = chains.get(chain.name); if (existing && existing.filePath.endsWith(".chain.json") && filePath.endsWith(".chain.md")) continue; chains.set(chain.name, chain); } catch (error) { diagnostics.push({ source, filePath, error: error instanceof Error ? error.message : String(error) }); continue; } } return { chains: Array.from(chains.values()), diagnostics }; } function isDirectory(p: string): boolean { try { return fs.statSync(p).isDirectory(); } catch { return false; } } function resolveNearestProjectAgentDirs(cwd: string): { readDirs: string[]; preferredDir: string | null } { const projectRoot = findNearestProjectRoot(cwd); if (!projectRoot) return { readDirs: [], preferredDir: null }; const legacyDir = path.join(projectRoot, ".agents"); const preferredDir = path.join(projectRoot, ".pi", "agents"); const readDirs: string[] = []; if (isDirectory(legacyDir)) readDirs.push(legacyDir); if (isDirectory(preferredDir)) readDirs.push(preferredDir); return { readDirs, preferredDir, }; } function resolveNearestProjectChainDirs(cwd: string): { readDirs: string[]; preferredDir: string | null } { const projectRoot = findNearestProjectRoot(cwd); if (!projectRoot) return { readDirs: [], preferredDir: null }; const preferredDir = path.join(projectRoot, ".pi", "chains"); return { readDirs: isDirectory(preferredDir) ? [preferredDir] : [], preferredDir, }; } const BUILTIN_AGENTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "agents"); export const EXTRA_AGENT_DIRS_ENV = "PI_SUBAGENT_EXTRA_AGENT_DIRS"; // Additional read-only directories to scan for agent definitions, supplied by the // launcher via PI_SUBAGENT_EXTRA_AGENT_DIRS (PATH-style, split on os/path delimiter). // Lets a hermetic wrapper (e.g. a Nix-store install) expose bundled agents without // copying or symlinking them into the writable agent dir. Loaded as "user" source, // at lower precedence than agents the user placed in their own agent dir. function extraUserAgentDirs(): string[] { const raw = process.env[EXTRA_AGENT_DIRS_ENV]; if (!raw) return []; return raw .split(path.delimiter) .map((dir) => dir.trim()) .filter((dir) => dir.length > 0); } export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult { const userDirOld = path.join(getAgentDir(), "agents"); const userDirNew = path.join(os.homedir(), ".agents"); const { readDirs: projectAgentDirs, preferredDir: projectAgentsDir } = resolveNearestProjectAgentDirs(cwd); const userSettingsPath = getUserAgentSettingsPath(); const projectSettingsPath = getProjectAgentSettingsPath(cwd); const userSettings = scope === "project" ? EMPTY_SUBAGENT_SETTINGS : readSubagentSettings(userSettingsPath); const projectSettings = scope === "user" ? EMPTY_SUBAGENT_SETTINGS : readSubagentSettings(projectSettingsPath); const packageSubagentPaths = collectPackageSubagentPaths(cwd, { includeUser: scope !== "project", includeProject: scope !== "user", }); const builtinAgents = applyBuiltinOverrides( loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"), userSettings, projectSettings, userSettingsPath, projectSettingsPath, ); const userAgentsExtra = scope === "project" ? [] : extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user")); const userAgentsOld = scope === "project" ? [] : loadAgentsFromDir(userDirOld, "user"); const userAgentsNew = scope === "project" ? [] : loadAgentsFromDir(userDirNew, "user"); const userAgents = [...userAgentsExtra, ...userAgentsOld, ...userAgentsNew]; const projectAgents = scope === "user" ? [] : projectAgentDirs.flatMap((dir) => loadAgentsFromDir(dir, "project")); const packageAgents = packageSubagentPaths.agents.flatMap((dir) => loadAgentsFromDir(dir, "package")); const agents = mergeAgentsForScope(scope, userAgents, projectAgents, builtinAgents, packageAgents) .filter((agent) => agent.disabled !== true); return { agents, projectAgentsDir }; } export function discoverAgentsAll(cwd: string): { builtin: AgentConfig[]; package: AgentConfig[]; user: AgentConfig[]; project: AgentConfig[]; chains: ChainConfig[]; chainDiagnostics: ChainDiscoveryDiagnostic[]; userDir: string; projectDir: string | null; userChainDir: string; projectChainDir: string | null; userSettingsPath: string; projectSettingsPath: string | null; } { const userDirOld = path.join(getAgentDir(), "agents"); const userDirNew = path.join(os.homedir(), ".agents"); const userChainDir = getUserChainDir(); const { readDirs: projectDirs, preferredDir: projectDir } = resolveNearestProjectAgentDirs(cwd); const { readDirs: projectChainDirs, preferredDir: projectChainDir } = resolveNearestProjectChainDirs(cwd); const userSettingsPath = getUserAgentSettingsPath(); const projectSettingsPath = getProjectAgentSettingsPath(cwd); const userSettings = readSubagentSettings(userSettingsPath); const projectSettings = readSubagentSettings(projectSettingsPath); const packageSubagentPaths = collectPackageSubagentPaths(cwd); const builtin = applyBuiltinOverrides( loadAgentsFromDir(BUILTIN_AGENTS_DIR, "builtin"), userSettings, projectSettings, userSettingsPath, projectSettingsPath, ); const user = [ ...extraUserAgentDirs().flatMap((dir) => loadAgentsFromDir(dir, "user")), ...loadAgentsFromDir(userDirOld, "user"), ...loadAgentsFromDir(userDirNew, "user"), ]; const packageMap = new Map(); for (const dir of packageSubagentPaths.agents) { for (const agent of loadAgentsFromDir(dir, "package")) { if (!packageMap.has(agent.name)) packageMap.set(agent.name, agent); } } const packageAgents = Array.from(packageMap.values()); const projectMap = new Map(); for (const dir of projectDirs) { for (const agent of loadAgentsFromDir(dir, "project")) { projectMap.set(agent.name, agent); } } const project = Array.from(projectMap.values()); const chainMap = new Map(); const packageChainDiagnostics: ChainDiscoveryDiagnostic[] = []; const packageChainMap = new Map(); for (const dir of packageSubagentPaths.chains) { const loaded = loadChainsFromDir(dir, "package"); packageChainDiagnostics.push(...loaded.diagnostics); for (const chain of loaded.chains) { if (!packageChainMap.has(chain.name)) packageChainMap.set(chain.name, chain); } } const projectChainDiagnostics: ChainDiscoveryDiagnostic[] = []; for (const dir of projectChainDirs) { const loaded = loadChainsFromDir(dir, "project"); projectChainDiagnostics.push(...loaded.diagnostics); for (const chain of loaded.chains) { chainMap.set(chain.name, chain); } } const userChains = loadChainsFromDir(userChainDir, "user"); const chains = [ ...Array.from(packageChainMap.values()), ...userChains.chains, ...Array.from(chainMap.values()), ]; const chainDiagnostics = [ ...packageChainDiagnostics, ...userChains.diagnostics, ...projectChainDiagnostics, ]; const userDir = process.env.PI_CODING_AGENT_DIR ? userDirOld : fs.existsSync(userDirNew) ? userDirNew : userDirOld; return { builtin, package: packageAgents, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath }; }