/** * Dynamic Chain Extension — discovers `.chain.json` files and registers * a slash command for each chain automatically. * * Chain files live in `~/.pi/agent/chains/.chain.json`. Each file defines: * - name: chain identifier (used as slash command name) * - description: displayed in help * - context: "fresh" | "fork" (passed to subagent) * - ... * - chain: [{ agent, task, ... }, ...] * * Usage: * / */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent" import { join } from "node:path" import { existsSync, readdirSync, readFileSync } from "node:fs" import path from "node:path" import os from "node:os" interface ChainFile { name: string description: string context?: "fresh" | "fork" chain: ChainStep[] } interface ChainStep { agent: string task: string model?: string outputMode?: "inline" | "file-only" output?: string | boolean reads?: string | boolean skills?: string | boolean progress?: string | boolean } /** * Resolve chain directories from settings, with sensible defaults. * Priority: * 1. ~/.pi/agent/chains (default) * 2. ./.pi/agent/chains (project-local, if it exists) */ function resolveChainDirs(): string[] { const dirs: string[] = [] // Default global chains directory const basePath = process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent") const normalizedBasePath = basePath.startsWith('~') ? path.join(os.homedir(), basePath.slice(1)) : basePath const defaultDir = path.join(normalizedBasePath, "chains") dirs.push(defaultDir) // Default project-local chain directory const projectDir = join(".pi", "agent", "chains") if (existsSync(projectDir) && readdirSync(projectDir).length > 0) { dirs.push(projectDir) } return dirs } /** * Load all chain definitions from the discovered directories. * Returns a map of chainName → ChainFile. */ function discoverChains(): Map { const chains = new Map() const dirs = resolveChainDirs() for (const dir of dirs) { if (!existsSync(dir)) continue const files = readdirSync(dir).filter((f: string) => f.endsWith(".chain.json")) for (const file of files) { try { const raw = readFileSync(join(dir, file), "utf-8") const chain: ChainFile = JSON.parse(raw) // Only register if the file has a valid name and chain array if (chain.name && Array.isArray(chain.chain)) { // Deduplicate: later directories override earlier ones chains.set(chain.name, chain) } } catch (err) { // Silently skip malformed files (with a notification) console.warn( `[chains] Skipping malformed chain file: ${file}`, (err as Error).message ) } } } return chains } /** * Transform a chain file's chain entries into the subagent tool's * chain format, substituting {task} with the actual user input for the * first agent step, and {previous} for all subsequent ones. */ function buildChainFromFile(chain: ChainFile, task: string): ChainStep[] { return chain.chain.map(step => { const resolvedTask = step.task.replace(/\{task\}/g, task) return { ...step, task: resolvedTask } }) } /** * Build the text message that triggers the subagent chain tool. */ function buildSubagentCall(chainRoot: ChainFile, task: string): string { const subagentParams = { context: chainRoot.context ?? "fresh", ...chainRoot, chain: buildChainFromFile(chainRoot, task) } return `Before doing anything else, forwarding the user's request as the task verbatim to the subagent chain. Call this exactly and do not stringify the chain: subagent(${JSON.stringify(subagentParams)})` } export default function (pi: ExtensionAPI) { const chains = discoverChains() for (const [cmdName, chain] of chains) { pi.registerCommand(cmdName, { description: chain.description || `Run chain: ${cmdName}`, handler: async (args: any, ctx: any) => { const task = (args || "").trim() if (!task) { ctx.ui.notify( `Usage: /${cmdName} \n${chain.description || ""}`, "warning" ) return } const text = buildSubagentCall(chain, task) pi.sendMessage( { customType: `chain:${cmdName}`, content: [{ type: "text", text }], display: true, }, { triggerTurn: true } ) }, }) } }