/** * Agent discovery and configuration. * * Copied from pi's official subagent reference example * (earendil-works/pi: packages/coding-agent/examples/extensions/subagent/agents.ts) * with Chorus customizations: `discoverAgents` also loads from a * package-relative `agents/` directory (BUNDLED_DIR) so the 3 Chorus reviewer * agents that ship inside this package are discovered with ZERO manual copy * into ~/.pi/agent/agents/, and reviewer tool lists include discovered native * Chorus read/comment tools when building a Pi 1.x hard allowlist. */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent"; import { isRoleToolAllowed } from "../../lib/role-policy.ts"; export type AgentScope = "user" | "project" | "both"; export interface AgentConfig { name: string; description: string; tools?: string[]; subagentOnlyExtensions?: string[]; model?: string; systemPrompt: string; source: "user" | "project"; filePath: string; } export interface AgentDiscoveryResult { agents: AgentConfig[]; projectAgentsDir: string | null; } /** * Packaged role gateways need no host-specific expansion. Legacy reviewer * overrides receive only direct operations permitted by the shared policy. */ export function expandReviewerTools( agentName: string, tools: string[] | undefined, availableToolNames: readonly string[], ): string[] | undefined { const reviewers = ["chorus-proposal-reviewer", "chorus-task-reviewer", "chorus-code-reviewer"]; if (!tools) return reviewers.includes(agentName) ? [] : undefined; if (!reviewers.includes(agentName)) return [...tools]; const excluded = ["mcp", "mcpScript", "codemode", "tool_search", "chorus_work"]; const canonicalName = (name: string) => name.startsWith("mcp__") ? name.slice(name.lastIndexOf("__") + 2) : name.startsWith("chorus_chorus_") ? name.slice("chorus_".length) : name; const expanded = new Set(tools.filter((name) => !excluded.includes(name) && ( name === "chorus_review" || (!name.startsWith("mcp__") && !name.startsWith("chorus_")) || isRoleToolAllowed("reviewer", canonicalName(name)) ))); if (expanded.has("chorus_review")) return [...expanded]; for (const name of availableToolNames) { if (isRoleToolAllowed("reviewer", canonicalName(name))) expanded.add(name); } if (tools.includes("codemode") && availableToolNames.includes("codemode") && !availableToolNames.some((name) => name.startsWith("chorus_")) && [...expanded].some((name) => name.startsWith("mcp__") && name.includes("__chorus_"))) { expanded.add("codemode"); } return [...expanded]; } /** * chorus-pi customization: the package's own `agents/` directory, resolved * relative to this module. This file lives at `/extensions/subagent/agents.ts`, * so the bundled `agents/` dir is TWO levels up (`../../agents`). The 3 reviewer * agents (chorus-{code,task,proposal}-reviewer.md) ship there and load as * user-scope agents without any `cp` into ~/.pi/agent/agents/. */ const BUNDLED_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "agents"); /** * Raw agent frontmatter. Values are `unknown` because `parseFrontmatter` runs a * real YAML parser, so any scalar or collection can appear here. * * A type alias rather than an interface: `parseFrontmatter` constrains its * parameter to `Record`, and only an alias picks up the * implicit index signature that satisfies it. */ type AgentFrontmatter = { name?: unknown; description?: unknown; tools?: unknown; subagentOnlyExtensions?: unknown; model?: unknown; }; /** * Normalize a frontmatter `tools` value to a list of tool names. * * Both spellings are valid YAML and both are in use: * * tools: read, bash # string * tools: [read, bash] # array * * so accept either. Anything else (a number, a map, a nested list) yields no * tools rather than throwing: this runs inside agent discovery, where a single * bad file must not take down every other agent in the same directory. */ function parseToolList(value: unknown): string[] | undefined { const raw = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : []; const tools = raw .filter((t): t is string => typeof t === "string") .map((t) => t.trim()) .filter(Boolean); return tools.length > 0 ? tools : undefined; } function loadAgentsFromDir(dir: string, source: "user" | "project"): 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.isFile() && !entry.isSymbolicLink()) continue; const filePath = path.join(dir, entry.name); let content: string; try { content = fs.readFileSync(filePath, "utf-8"); } catch { continue; } const { frontmatter, body } = parseFrontmatter(content); if (typeof frontmatter.name !== "string" || typeof frontmatter.description !== "string") { continue; } agents.push({ name: frontmatter.name, description: frontmatter.description, tools: parseToolList(frontmatter.tools), subagentOnlyExtensions: parseToolList(frontmatter.subagentOnlyExtensions)?.map((extension) => path.resolve(path.dirname(filePath), extension)), model: typeof frontmatter.model === "string" ? frontmatter.model : undefined, systemPrompt: body, source, filePath, }); } return agents; } function isDirectory(p: string): boolean { try { return fs.statSync(p).isDirectory(); } catch { return false; } } function findNearestProjectAgentsDir(cwd: string): string | null { let currentDir = cwd; while (true) { const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents"); if (isDirectory(candidate)) return candidate; const parentDir = path.dirname(currentDir); if (parentDir === currentDir) return null; currentDir = parentDir; } } export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult { const userDir = path.join(getAgentDir(), "agents"); const projectAgentsDir = findNearestProjectAgentsDir(cwd); // User scope = the package's bundled reviewer agents PLUS the user's own // ~/.pi/agent/agents. The user dir is loaded LAST so a same-named user agent // overrides the bundled one (customization wins). chorus-pi customization. const userAgents = scope === "project" ? [] : [...loadAgentsFromDir(BUNDLED_DIR, "user"), ...loadAgentsFromDir(userDir, "user")]; const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project"); const agentMap = new Map(); if (scope === "both") { for (const agent of userAgents) agentMap.set(agent.name, agent); for (const agent of projectAgents) agentMap.set(agent.name, agent); } else if (scope === "user") { for (const agent of userAgents) agentMap.set(agent.name, agent); } else { for (const agent of projectAgents) agentMap.set(agent.name, agent); } return { agents: Array.from(agentMap.values()), projectAgentsDir }; } export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } { if (agents.length === 0) return { text: "none", remaining: 0 }; const listed = agents.slice(0, maxItems); const remaining = agents.length - listed.length; return { text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "), remaining, }; }