/** * Agent configs and chain runner. * Spawns pi subprocesses in JSON mode to run specialized agents. */ import { spawn } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; export interface AgentConfig { name: string; description: string; tools: string[]; systemPrompt: string; } export interface AgentResult { agent: string; output: string; exitCode: number; stderr: string; } export interface ChainStep { agent: string; task: string; } export interface RunOptions { signal?: AbortSignal; /** Override model (e.g. "ollama/llama3.1") */ model?: string; } export type ProgressCallback = (step: number, total: number, agent: string, status: string) => void; // ─── Embedded Agent Configs ──────────────────────────────────────────── export const AGENTS: Record = { scout: { name: "scout", description: "Fast codebase recon and exploration", tools: ["read", "grep", "find", "ls"], systemPrompt: `You are a scout agent for Hive. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, dependencies, file types, and key entry points. When analyzing for features, identify missing functionality, refactoring opportunities, and cleanup tasks.`, }, planner: { name: "planner", description: "Feature planning and architecture", tools: ["read", "grep", "find", "ls"], systemPrompt: `You are a planner agent for Hive. Analyze requirements and produce clear, actionable feature specifications. Each feature must be independent (1-4 hours of work), have a clear title, description, dependencies, and status. Output features in the exact features.md format: ## Feature N: - Description: <What and why> - Dependencies: <Feature N, or "none"> - Status: pending Do NOT modify files. Plan only.`, }, builder: { name: "builder", description: "Feature implementation", tools: ["read", "write", "edit", "bash", "grep", "find", "ls"], systemPrompt: `You are a builder agent for Hive. Implement one feature at a time from features.md. Follow existing patterns in the codebase. Write clean, minimal code. Test your work when possible. After implementing, update the feature status in features.md from "in_progress" to "done".`, }, reviewer: { name: "reviewer", description: "Code review and validation", tools: ["read", "grep", "find", "ls", "bash"], systemPrompt: `You are a reviewer agent for Hive. Review implementations for bugs, style issues, missing edge cases, and correctness. Run tests if available. Report findings clearly with file paths and line numbers. Do NOT modify files directly — report issues for the builder to fix.`, }, }; // ─── Provider Detection ──────────────────────────────────────────────── export interface ProviderInfo { name: string; envVar: string; model?: string; } const HIVE_CONFIG_PATH = path.join(os.homedir(), ".hive", "config.json"); export function loadHiveConfig(): Record<string, string> { try { return JSON.parse(fs.readFileSync(HIVE_CONFIG_PATH, "utf-8")); } catch { return {}; } } export function saveHiveConfig(config: Record<string, string>): void { const dir = path.dirname(HIVE_CONFIG_PATH); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(HIVE_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); } /** * Apply config keys to process.env so pi picks them up. */ export function applyHiveConfig(): void { const config = loadHiveConfig(); for (const [key, value] of Object.entries(config)) { if (key.endsWith("_API_KEY") || key.endsWith("_HOST") || key === "OLLAMA_HOST") { if (!process.env[key]) { process.env[key] = value; } } } } /** * Detect which AI provider is available. * Checks env vars first, then ~/.hive/config.json. * Returns null if none found. */ export function detectProvider(): ProviderInfo | null { applyHiveConfig(); if (process.env.ANTHROPIC_API_KEY) { return { name: "Anthropic", envVar: "ANTHROPIC_API_KEY" }; } if (process.env.OPENAI_API_KEY) { return { name: "OpenAI", envVar: "OPENAI_API_KEY" }; } if (process.env.GOOGLE_API_KEY) { return { name: "Google Gemini", envVar: "GOOGLE_API_KEY" }; } if (process.env.OLLAMA_HOST) { return { name: "Ollama (local)", envVar: "OLLAMA_HOST", model: "ollama/llama3.1" }; } return null; } // ─── Runner ──────────────────────────────────────────────────────────── function writeTempPrompt(agentName: string, prompt: string): { dir: string; file: string } { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hive-agent-")); const file = path.join(dir, `${agentName}-system.md`); fs.writeFileSync(file, prompt, { encoding: "utf-8", mode: 0o600 }); return { dir, file }; } function cleanupTemp(dir: string, file: string) { try { fs.unlinkSync(file); } catch { /* ignore */ } try { fs.rmdirSync(dir); } catch { /* ignore */ } } /** * Run a single agent as a pi subprocess. * Returns the final assistant text output. */ export async function runAgent( agentName: string, task: string, cwd: string, opts?: RunOptions, ): Promise<AgentResult> { const config = AGENTS[agentName]; if (!config) { return { agent: agentName, output: "", exitCode: 1, stderr: `Unknown agent: ${agentName}` }; } const args: string[] = ["--mode", "json", "-p", "--no-session"]; args.push("--tools", config.tools.join(",")); if (opts?.model) { args.push("--model", opts.model); } const tmp = writeTempPrompt(config.name, config.systemPrompt); args.push("--append-system-prompt", tmp.file); args.push(`Task: ${task}`); const result: AgentResult = { agent: agentName, output: "", exitCode: 0, stderr: "" }; try { const exitCode = await new Promise<number>((resolve) => { const proc = spawn("pi", args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"] }); let buffer = ""; proc.stdout.on("data", (data) => { buffer += data.toString(); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.trim()) continue; try { const event = JSON.parse(line); if (event.type === "message_end" && event.message?.role === "assistant") { for (const part of event.message.content) { if (part.type === "text") result.output = part.text; } } } catch { /* skip non-JSON lines */ } } }); proc.stderr.on("data", (data) => { result.stderr += data.toString(); }); proc.on("close", (code) => { if (buffer.trim()) { try { const event = JSON.parse(buffer); if (event.type === "message_end" && event.message?.role === "assistant") { for (const part of event.message.content) { if (part.type === "text") result.output = part.text; } } } catch { /* ignore */ } } resolve(code ?? 0); }); proc.on("error", () => resolve(1)); if (opts?.signal) { const kill = () => { proc.kill("SIGTERM"); setTimeout(() => { if (!proc.killed) proc.kill("SIGKILL"); }, 5000); }; if (opts.signal.aborted) kill(); else opts.signal.addEventListener("abort", kill, { once: true }); } }); result.exitCode = exitCode; } finally { cleanupTemp(tmp.dir, tmp.file); } return result; } /** * Run a chain of agents sequentially. * Each step's task can contain {previous} which is replaced with the prior output. */ export async function runChain( steps: ChainStep[], cwd: string, opts?: RunOptions, onProgress?: ProgressCallback, ): Promise<AgentResult> { let previousOutput = ""; const results: AgentResult[] = []; for (let i = 0; i < steps.length; i++) { const step = steps[i]; onProgress?.(i + 1, steps.length, step.agent, "running"); const task = step.task.replace(/\{previous\}/g, previousOutput); const result = await runAgent(step.agent, task, cwd, opts); results.push(result); if (result.exitCode !== 0) { onProgress?.(i + 1, steps.length, step.agent, "failed"); return { agent: step.agent, output: result.output || result.stderr || `Agent ${step.agent} failed with exit code ${result.exitCode}`, exitCode: result.exitCode, stderr: result.stderr, }; } previousOutput = result.output; onProgress?.(i + 1, steps.length, step.agent, "done"); } return { agent: "chain", output: previousOutput, exitCode: 0, stderr: "", }; }