// agents/install.ts — the framework's AGENT registry + installer. The // framework SHIPS the agents it depends on (queue_review spawns the reviewer, // queue_dispatch spawns the worker) — each agent is a canonical body // (agents//canonical.md, version-stamped) projected into each backend's // agent directory with that backend's frontmatter dialect. The prompt is // canonical; the frontmatter/settings are per-backend. // // Projections: opencode gets BOTH agents (no builtin worker/reviewer of ours — // 'worker' is free, the reviewer is orchestrator-reviewer to avoid clobbering // pi-subagents' builtin reviewer). pi gets ONLY the reviewer (the framework // uses pi-subagents' builtin worker). // // Idempotent + version-stamped: activation re-installs on framework upgrades, // skips when current. Consumers may edit per-backend frontmatter freely; the // canonical BODY is framework-managed between markers. Never throws. import { readFileSync, writeFileSync, existsSync, mkdirSync, symlinkSync, lstatSync } from "node:fs"; import { join, dirname, relative } from "node:path"; import { homedir } from "node:os"; // --------------------------------------------------------------------------- // Agent registry // --------------------------------------------------------------------------- interface AgentDef { /** Agent file name (frontmatter `name:` + file name). */ name: string; /** Directory under agents/ holding canonical.md. */ dir: string; description: string; /** pi-subagents tools allowlist (projectToPi agents only). */ piTools: string; /** The agent's ROLE mutability (KEY: AUTOPILOT-92): true for the framework's * read-only gate (the reviewer inspects; it never writes), false for the * worker. This is the registry fact dispatch pairing reads — NOT a scan of * the tool list (the reviewer's allowlist deliberately includes `bash` for * read-only git inspection). */ readOnly: boolean; opencodeMode: "all" | "subagent" | "primary"; /** opencode permission block, YAML body ALREADY indented two spaces. */ opencodePermission: string; /** Project to pi (the runtime's agent scope)? Reviewer yes; the worker is * pi-subagents' builtin — no collision there, no projection. */ projectToPi: boolean; } const REVIEWER_DESC = "Orchestrator framework review agent — gates worker completion with an evidence-based Verdict: PASS/FAIL/NO-CHANGE contract. Read-only."; const WORKER_DESC = "Orchestrator framework implementation agent — executes queue-dispatched tasks with worktree isolation + commit-early discipline. Full tools."; const AGENTS: Record = { reviewer: { name: "orchestrator-reviewer", dir: "reviewer", description: REVIEWER_DESC, // bash for READ-ONLY git inspection (git show/log/cat-file on commits that // exist only as objects — the read tool cannot decompress loose objects). // The canonical's read-only rule constrains it to non-mutating commands. piTools: "read, grep, find, ls, bash", readOnly: true, opencodeMode: "subagent", opencodePermission: ` "*": deny read: "*": "allow" "*.env": "deny" "*.env.*": "deny" "*.env.template": "allow" "*.env.example": "allow" list: allow glob: allow grep: allow bash: "git status*": allow "git diff*": allow "git log*": allow "git show*": allow "git cat-file*": allow "git ls-tree*": allow "git reflog*": allow "git branch*": allow "git rev-parse*": allow "*": deny`, projectToPi: true, }, worker: { name: "worker", dir: "worker", description: WORKER_DESC, piTools: "", readOnly: false, opencodeMode: "all", // Full allow: headless `oc run` workers cannot answer interactive // permission prompts — 'ask' would stall the run. The task prompt is the // behavioral contract (worktree isolation, commit-early). external_directory // lets the worker create its private worktree (../-work) + scratch in // /tmp — the repo itself comes from queue_dispatch's cwd. opencodePermission: ` read: allow list: allow glob: allow grep: allow webfetch: allow bash: allow edit: allow write: allow todoread: allow todowrite: allow task: allow external_directory: "*": allow`, projectToPi: false, }, }; // --------------------------------------------------------------------------- // Canonical bodies + version stamps // --------------------------------------------------------------------------- const VERSION_RE = //; /** The canonical body of a framework agent (agents//canonical.md). */ export function canonicalBody(agent: keyof typeof AGENTS | string = "reviewer"): string { return readFileSync(join(__dirname, AGENTS[agent]?.dir ?? agent, "canonical.md"), "utf8"); } /** Parse the version stamp out of a canonical body or an installed file. */ export function canonicalVersion(text: string): number { const m = text.match(VERSION_RE); return m ? Number(m[2]) : 0; } function marker(agent: AgentDef): { open: string; close: string } { return { open: ``, close: "", }; } // --------------------------------------------------------------------------- // Dialect builders (frontmatter per backend + synced canonical body) // --------------------------------------------------------------------------- /** pi-subagents dialect. */ export function buildPiFile(body: string, agentName = "orchestrator-reviewer"): string { const def = Object.values(AGENTS).find((a) => a.name === agentName) ?? AGENTS.reviewer; return `--- name: ${def.name} description: ${def.description} tools: ${def.piTools} thinking: high systemPromptMode: replace inheritProjectContext: true inheritSkills: false --- ${marker(def).open} ${body.trim()} ${marker(def).close} `; } /** OpenCode dialect (mode + permission per agent). */ export function buildOpenCodeFile(body: string, agentName = "orchestrator-reviewer"): string { const def = Object.values(AGENTS).find((a) => a.name === agentName) ?? AGENTS.reviewer; return `--- description: ${def.description} mode: ${def.opencodeMode} permission: ${def.opencodePermission} --- ${marker(def).open} ${body.trim()} ${marker(def).close} `; } /** Claude Code dialect. */ export function buildClaudeFile(body: string, agentName = "orchestrator-reviewer"): string { const def = Object.values(AGENTS).find((a) => a.name === agentName) ?? AGENTS.reviewer; const tools = def.projectToPi ? "Read, Grep, Glob, LS" : "Read, Grep, Glob, LS, Bash, Edit, Write"; return `--- description: ${def.description} tools: ${tools} --- ${marker(def).open} ${body.trim()} ${marker(def).close} `; } export interface InstallOptions { /** Caller-chosen real-file location (+ symlinks from modeAgentsDirs) for a * shared+mode-symlink layout. When absent, the pi-standard user-agent scope * is used (no symlinks — a real file per mode dir). */ sharedAgentsDir?: string; /** Explicit mode agent dirs. Default: the pi runtime's own agent scope. */ modeAgentsDirs?: string[]; force?: boolean; log?: (line: string) => void; } export interface InstallResult { installed: boolean; // wrote/updated the file (false when current or unable) skipped: boolean; // already current (no write) or no target dir path: string; // where the real agent file lives } // --------------------------------------------------------------------------- // Installers // --------------------------------------------------------------------------- function writeFile(content: string, file: string, agentName: string, log: (l: string) => void): void { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, content); log(`${agentName}: installed v${canonicalVersion(content)} → ${file}`); } /** True when a file-system entry exists at the path (symlink included — do * not follow). */ function linkEntryExists(p: string): boolean { try { lstatSync(p); return true; } catch { return false; } } /** Install (or refresh) the PI reviewer agent. Idempotent + version-stamped. * Never throws — callers (extension activation) must not break the host. */ export function installPiReviewer(opts: InstallOptions = {}): InstallResult { try { const def = AGENTS.reviewer; const body = canonicalBody("reviewer"); const version = canonicalVersion(body); const content = buildPiFile(body, def.name); // pi OWNS where its agents live — we resolve, we don't invent. The pi // runtime's own mode swap (PI_CODING_AGENT_DIR, set by the launcher) or // the documented default user-agent scope (~/.pi/agent/agents) decides. // Callers with a different layout pass explicit dirs. const shared = opts.sharedAgentsDir ?? null; // shared+mode-symlink is a caller convention, not a default const modeDirs = opts.modeAgentsDirs !== undefined ? opts.modeAgentsDirs : [join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi/agent"), "agents")]; const log = opts.log ?? (() => {}); const isCurrent = (file: string): boolean => existsSync(file) && !opts.force && canonicalVersion(readFileSync(file, "utf8")) >= version; const ensureSymlinks = (realFile: string): void => { for (const modeDir of modeDirs) { const link = join(modeDir, `${def.name}.md`); if (linkEntryExists(link)) continue; // existing file/link — leave it mkdirSync(modeDir, { recursive: true }); symlinkSync(relative(dirname(link), realFile), link); } }; if (shared && existsSync(shared)) { const sharedFile = join(shared, `${def.name}.md`); ensureSymlinks(sharedFile); if (isCurrent(sharedFile)) return { installed: false, skipped: true, path: sharedFile }; writeFile(content, sharedFile, def.name, log); return { installed: true, skipped: false, path: sharedFile }; } const modeDir = modeDirs[0]; if (!modeDir) return { installed: false, skipped: true, path: "" }; // no target — the host decides const file = join(modeDir, `${def.name}.md`); if (isCurrent(file)) return { installed: false, skipped: true, path: file }; writeFile(content, file, def.name, log); return { installed: true, skipped: false, path: file }; } catch (err) { opts.log?.(`orchestrator-reviewer: install failed: ${err instanceof Error ? err.message : String(err)}`); return { installed: false, skipped: true, path: "" }; } } /** Install (or refresh) an OPENCODE agent projection (reviewer + worker). * Real files in ~/.config/opencode/agents/ (opencode reads the dir directly; * no symlink convention). Idempotent + version-stamped. Never throws. */ export function installOpenCodeAgent(agent: keyof typeof AGENTS, opts: { agentsDir?: string; force?: boolean; log?: (line: string) => void } = {}): InstallResult { try { const def = AGENTS[agent]; const body = canonicalBody(agent); const version = canonicalVersion(body); const content = buildOpenCodeFile(body, def.name); const agentsDir = opts.agentsDir ?? join(homedir(), ".config/opencode/agents"); const file = join(agentsDir, `${def.name}.md`); if (existsSync(file) && !opts.force && canonicalVersion(readFileSync(file, "utf8")) >= version) { return { installed: false, skipped: true, path: file }; } writeFile(content, file, def.name, opts.log ?? (() => {})); return { installed: true, skipped: false, path: file }; } catch (err) { opts.log?.(`${agent}: opencode install failed: ${err instanceof Error ? err.message : String(err)}`); return { installed: false, skipped: true, path: "" }; } } /** True when the installed pi reviewer already carries the current body — * used by tests (the optional modeAgentsDirs injects temp dirs so a live * install on this machine doesn't mask results). */ export function piReviewerCurrent(sharedAgentsDir?: string, modeAgentsDirs?: string[]): boolean { const name = AGENTS.reviewer.name; const shared = sharedAgentsDir ?? ""; // absent → check only the mode scopes const modes = modeAgentsDirs ?? [join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi/agent"), "agents")]; const candidates = [...(shared ? [join(shared, `${name}.md`)] : []), ...modes.map((d) => join(d, `${name}.md`))]; for (const candidate of candidates) { try { if (existsSync(candidate) && canonicalVersion(readFileSync(candidate, "utf8")) >= canonicalVersion(canonicalBody("reviewer"))) return true; } catch { // unreadable candidate — skip } } return false; } export const REVIEWER_AGENT_NAME = AGENTS.reviewer.name; export const WORKER_AGENT_NAME = AGENTS.worker.name; // --------------------------------------------------------------------------- // Agent resolution (KEY: AUTOPILOT-92) // --------------------------------------------------------------------------- /** The ONE resolved spawn agent every dispatch lane uses: the registry name * plus whether its role is read-only. This is the single resolution path — * no lane re-derives "may this agent mutate work?" from the name string. */ export interface ResolvedAgent { name: string; readOnly: boolean; } /** Resolve an agent NAME against the registry (the same definitions that * build every installed agent file). Unknown names resolve to null, so a * caller can tell "a known read-only agent" from "a name this framework's * registry cannot judge" (a user-owned agent) instead of inventing a verdict. */ export function resolveAgent(name: string): ResolvedAgent | null { const def = Object.values(AGENTS).find((a) => a.name === name); return def ? { name: def.name, readOnly: def.readOnly } : null; } /** Resolve the ONE agent a lane of `role` spawns with: the configured name * (config `workerAgents`/`reviewerAgents`) when it is set, else the framework * default for that role. An unknown configured name keeps its own identity and * is treated as mutable — the registry never over-refuses a user-owned agent * it cannot judge; a KNOWN read-only agent is exactly what dispatch pairing * refuses for mutating work (see framework/dispatch-pairing.ts). Before this, * `workerAgents` was probe-checked but never used to SELECT the spawn agent. */ export function resolveSpawnAgent(role: "worker" | "review", configuredName?: string): ResolvedAgent { const fallback = role === "review" ? AGENTS.reviewer.name : AGENTS.worker.name; const name = configuredName?.trim() ? configuredName.trim() : fallback; return resolveAgent(name) ?? { name, readOnly: false }; }