import { Type } from "@sinclair/typebox"; import { readdirSync, readFileSync, statSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { ExtensionAPI } from "../_shared/pi-api.js"; import { errorResult, getCommandText, getProjectRoot, setTextWidget } from "../_shared/pi-api.js"; import { validateParams } from "../_shared/validation.js"; import { sharedState } from "../_shared/state.js"; import type { AgentDefinition } from "../_shared/types.js"; const RunAgentParams = Type.Object({ name: Type.String({ description: "Agent name or comma-separated names" }), task: Type.String({ description: "Task description", maxLength: 8000 }), mode: Type.Optional(Type.Union([Type.Literal("single"), Type.Literal("parallel"), Type.Literal("chain")], { default: "single" })), context: Type.Optional(Type.String({ description: "Additional context", maxLength: 8000 })), maxTurns: Type.Optional(Type.Integer({ minimum: 1, maximum: 20, default: 5 })), }); const TaskParams = Type.Object({ agent: Type.String({ description: "OMP task agent type" }), tasks: Type.Array(Type.Object({ id: Type.String({ description: "CamelCase task identifier", maxLength: 48 }), description: Type.String({ description: "UI label, not sent to the subagent" }), assignment: Type.String({ description: "Self-contained subagent instructions", maxLength: 16000 }), }), { description: "Tasks to execute in parallel" }), context: Type.Optional(Type.String({ description: "Shared background for each assignment", maxLength: 16000 })), schema: Type.Optional(Type.String({ description: "JTD schema for expected response shape", maxLength: 16000 })), isolated: Type.Optional(Type.Boolean({ description: "Request isolated OMP task execution" })), }); const DEFAULT_AGENTS: AgentDefinition[] = [ { name: "scout", description: "Read-only codebase reconnaissance", allowedTools: ["read", "search", "find", "ast_grep"], risk: "low", readOnly: true }, { name: "planner", description: "Plan and acceptance criteria", allowedTools: ["read", "search", "find"], risk: "low", readOnly: true }, { name: "worker", description: "Implementation agent after approval", allowedTools: ["read", "search", "find", "edit", "write", "bash"], risk: "medium", readOnly: false }, { name: "reviewer", description: "Code review and risk analysis", allowedTools: ["read", "search", "find", "ast_grep", "lsp"], risk: "low", readOnly: true }, { name: "debugger", description: "Debugging assistant", allowedTools: ["read", "search", "find", "lsp"], risk: "medium", readOnly: true }, { name: "security-reviewer", description: "Security review", allowedTools: ["read", "search", "find", "ast_grep"], risk: "low", readOnly: true }, ]; const PACKAGE_AGENTS_DIR = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "agents"); /** Registers the agent runner and refreshes markdown agent definitions at command/tool time. */ export default function agents(pi: ExtensionAPI): void { refreshAgents(process.cwd()); pi.registerTool({ name: "task", description: "OMP-shaped task contract for real subagent execution. Fails closed until OMP TaskTool backend is ported.", parameters: TaskParams, async execute(_toolCallId, params, _signal, _update, ctx) { const valid = validateParams(TaskParams, params); if (!valid.ok) return valid.result; refreshAgents(getProjectRoot(ctx)); return taskNotPorted([valid.value.agent], valid.value.tasks.length, "task"); }, }); pi.registerTool({ name: "runAgent", description: "Legacy alias for the old local agent harness. Fails closed until OMP task backend is ported.", parameters: RunAgentParams, async execute(_toolCallId, params, _signal, _update, ctx) { const valid = validateParams(RunAgentParams, params); if (!valid.ok) return valid.result; refreshAgents(getProjectRoot(ctx)); const names = valid.value.name.split(",").map((name) => name.trim()).filter(Boolean); return taskNotPorted(names, names.length, "runAgent"); }, }); pi.registerCommand("agent", { description: "List agent definitions or show fail-closed task status: /agent list; /agent run reviewer task.", handler: async (args, ctx) => { refreshAgents(getProjectRoot(ctx)); const text = getCommandText(args).trim(); if (text === "list") { setTextWidget(ctx, "agents", [...sharedState.agents.values()].map((agent) => `${agent.name}: ${agent.description} [${agent.allowedTools.join(", ")}]`).join("\n")); return; } const match = /^run\s+(\S+)\s+([\s\S]+)/.exec(text); if (!match) { ctx.ui.notify("Usage: /agent run ", "warn"); return; } const result = taskNotPorted([match[1]!], 1, "agent-command"); setTextWidget(ctx, "agents", result.content[0]?.type === "text" ? result.content[0].text : "agent execution is not available"); }, }); } function refreshAgents(projectRoot: string): void { const discovered = discoverAgentDefinitions(projectRoot); sharedState.agents.clear(); for (const agent of discovered.length ? discovered : DEFAULT_AGENTS) { sharedState.agents.set(agent.name, agent); } } function discoverAgentDefinitions(projectRoot: string): AgentDefinition[] { const agentMap = new Map(); for (const dir of [PACKAGE_AGENTS_DIR, path.join(projectRoot, ".pi", "agents"), path.join(projectRoot, ".agents", "agents")]) { for (const agent of loadAgentsFromDir(dir)) { agentMap.set(agent.name, agent); } } return [...agentMap.values()]; } function loadAgentsFromDir(dir: string): AgentDefinition[] { if (!isDirectory(dir)) return []; const agents: AgentDefinition[] = []; for (const entry of readdirSync(dir, { withFileTypes: true })) { if (!entry.name.endsWith(".md") || (!entry.isFile() && !entry.isSymbolicLink())) continue; const agent = parseAgentMarkdown(readFileSync(path.join(dir, entry.name), "utf8")); if (agent) agents.push(agent); } return agents; } function parseAgentMarkdown(content: string): AgentDefinition | null { const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n/.exec(content); if (!match) return null; const metadata = parseFlatFrontmatter(match[1] ?? ""); const name = asScalar(metadata.name); const description = asScalar(metadata.description); if (!name || !description) return null; const allowedTools = asStringList(metadata.allowedTools ?? metadata.tools); const agent: AgentDefinition = { name, description, allowedTools: allowedTools.length ? allowedTools : ["read", "search", "find"], risk: asRisk(asScalar(metadata.risk)), readOnly: asBoolean(metadata.readOnly, true), }; const modelOverride = asScalar(metadata.modelOverride ?? metadata.model); if (modelOverride) agent.modelOverride = modelOverride; return agent; } function parseFlatFrontmatter(text: string): Record { const result: Record = {}; for (const line of text.split(/\r?\n/)) { const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line.trim()); if (match) result[match[1]!] = match[2]!.trim(); } return result; } function asScalar(value: string | undefined): string | undefined { if (!value) return undefined; return value.replace(/^["']|["']$/g, "").trim() || undefined; } function asStringList(value: string | undefined): string[] { const scalar = asScalar(value); if (!scalar) return []; const withoutBrackets = scalar.replace(/^\[/, "").replace(/\]$/, ""); return withoutBrackets.split(",").map((item) => item.trim().replace(/^["']|["']$/g, "")).filter(Boolean); } function asBoolean(value: string | undefined, fallback: boolean): boolean { const scalar = asScalar(value)?.toLowerCase(); if (scalar === "true") return true; if (scalar === "false") return false; return fallback; } function asRisk(value: string | undefined): AgentDefinition["risk"] { return value === "medium" || value === "high" ? value : "low"; } function isDirectory(candidate: string): boolean { try { return statSync(candidate).isDirectory(); } catch { return false; } } function taskNotPorted(names: string[], taskCount: number, requestedSurface: "task" | "runAgent" | "agent-command") { const missing = names.filter((name) => !sharedState.agents.get(name)); if (missing.length) return errorResult(`Unknown agents: ${missing.join(", ")}`); return errorResult( [ "Agent/task execution is disabled in miloc-pi.", "OMP `task` is the source truth for real subagent discovery, execution, progress, artifacts, isolation, and agent:// outputs.", "This extension only lists local markdown agent definitions until OMP TaskTool is ported here.", ].join("\n"), { owner: "omp-task", ported: false, requestedSurface, agents: names, taskCount, ompSources: [ "packages/coding-agent/src/task/index.ts", "packages/coding-agent/src/task/types.ts", "packages/coding-agent/src/task/discovery.ts", "packages/coding-agent/src/task/executor.ts", "packages/coding-agent/src/prompts/tools/task.md", "docs/tools/task.md", "docs/task-agent-discovery.md", ], }, ); }