/** * Nexus — The unified Pi intelligence layer v2 * * Combines model routing + sub-agent orchestration into one seamless system. * The main agent stays fast and interactive. Complex work is automatically * offloaded to sub-agents. The model auto-adapts to task complexity. * * New in v2: * ✦ Context Bridge — sub-agents get conversation context preamble * ✦ Persistent memory (.pi/memory.md) injected into every turn * ✦ nexus_remember — store project facts mid-session * ✦ nexus_plan — decompose + auto-spawn parallel agents * ✦ Self-healing sub-agents — auto-retry once on recoverable errors * ✦ Smart model routing per sub-agent role * ✦ LLM synthesis in nexus_collect * ✦ OS notifications with sound + cost * ✦ Session summary on shutdown * ✦ /nx plan|memory|retry|chain commands * * Usage: * pi -e extensions/nexus.ts * * Commands: * /nx — Full dashboard * /nx spawn — Manual sub-agent spawn * /nx plan — Decompose & spawn optimal agents * /nx memory — Show .pi/memory.md * /nx retry — Manually retry failed agent * /nx chain — Continue agent with new task * /nx kill — Kill sub-agent * /nx clear — Clear all sub-agents * /nx mode — interactive | hybrid | delegator * /nx auto on|off — Toggle auto-delegation * /nx lock — Lock model (disable auto-routing) * /nx unlock — Unlock model * /nx tier 1|2|3 — Force model tier */ import type { AssistantMessage as AssistantMsg } from "@mariozechner/pi-ai"; import { complete } from "@mariozechner/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { DynamicBorder } from "@mariozechner/pi-coding-agent"; import { Container, Text, truncateToWidth, visibleWidth, type AutocompleteItem } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; import { execSync, spawn as cpSpawn, type ChildProcess } from "child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs"; import { basename, join } from "path"; import { applyExtensionDefaults } from "./themeMap.ts"; // ══════════════════════════════════════════════════════════════════════════ // CONSTANTS // ══════════════════════════════════════════════════════════════════════════ const AGENT_TIMEOUT_MS = 10 * 60 * 1000; const WIDGET_THROTTLE_MS = 400; const CONTEXT_WARN_PCT = 70; const MAX_RESULT_LEN = 12000; const MEMORY_FILE = ".pi/memory.md"; const SESSIONS_DIR = ".pi/sessions"; // ══════════════════════════════════════════════════════════════════════════ // MODEL TIERS // ══════════════════════════════════════════════════════════════════════════ interface Tier { name: string; icon: string; provider: string; modelId: string; thinking: "off" | "low" | "medium" | "high"; inputCost: number; outputCost: number; } const TIERS: Tier[] = [ { name: "Haiku", icon: "⚡", provider: "anthropic", modelId: "claude-haiku-4-5", thinking: "off", inputCost: 0.80, outputCost: 4.00 }, { name: "Sonnet", icon: "⚖️", provider: "anthropic", modelId: "claude-sonnet-4-6", thinking: "low", inputCost: 3.00, outputCost: 15.00 }, { name: "Opus", icon: "🧠", provider: "anthropic", modelId: "claude-opus-4-6", thinking: "high", inputCost: 15.00, outputCost: 75.00 }, ]; // ══════════════════════════════════════════════════════════════════════════ // SMART MODEL ROUTING FOR SUB-AGENTS // ══════════════════════════════════════════════════════════════════════════ function selectSubAgentModel(role: string, task: string): string { const r = role.toLowerCase(); const t = task.toLowerCase(); if (r === "scout" || r === "reviewer") return "anthropic/claude-haiku-4-5"; if (/architect|design|security audit|security review|threat model/.test(t)) return "anthropic/claude-opus-4-6"; return "anthropic/claude-sonnet-4-6"; // general, builder, tester } // ══════════════════════════════════════════════════════════════════════════ // MEMORY HELPERS // ══════════════════════════════════════════════════════════════════════════ function readMemory(cwd: string): string { const p = join(cwd, MEMORY_FILE); return existsSync(p) ? readFileSync(p, "utf-8") : ""; } function updateMemory(cwd: string, key: string, value: string, mode: "append" | "replace" = "replace"): void { const p = join(cwd, MEMORY_FILE); const dir = join(cwd, ".pi"); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); let content = existsSync(p) ? readFileSync(p, "utf-8") : ""; const heading = `## ${key}`; const idx = content.indexOf(heading); if (idx === -1) { // Append new section content = content.trimEnd() + `\n\n${heading}\n${value}\n`; } else { // Find end of section (next ## or EOF) const nextSection = content.indexOf("\n## ", idx + heading.length); const sectionEnd = nextSection === -1 ? content.length : nextSection; if (mode === "replace") { content = content.slice(0, idx) + `${heading}\n${value}\n` + content.slice(sectionEnd); } else { // Append within section const insert = sectionEnd === content.length ? content.length : sectionEnd; content = content.slice(0, insert) + `\n${value}` + content.slice(insert); } } writeFileSync(p, content, "utf-8"); } // ══════════════════════════════════════════════════════════════════════════ // CONTEXT BRIDGE // ══════════════════════════════════════════════════════════════════════════ async function buildContextBridge(ctx: ExtensionContext, task: string): Promise { const MAX_CHARS = 3000; const parts: string[] = []; // Recent conversation turns const branch = ctx.sessionManager.getBranch(); const recent = branch.slice(-12).filter((e: any) => e.type === "message"); const turns: string[] = []; for (const entry of recent) { const msg = (entry as any).message; const textContent = Array.isArray(msg.content) ? msg.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("") : String(msg.content || ""); if (!textContent.trim()) continue; const label = msg.role === "user" ? "User" : "Assistant"; turns.push(`${label}: ${textContent.slice(0, 400)}`); } if (turns.length > 0) { parts.push(`[CONVERSATION CONTEXT]\n${turns.slice(-6).join("\n\n")}`); } // Key Facts let gitBranch = "unknown"; let fileList = ""; try { gitBranch = execSync("git branch --show-current", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch {} try { fileList = execSync("ls -la", { cwd: ctx.cwd, stdio: ["ignore", "pipe", "ignore"] }) .toString().trim().split("\n").slice(0, 20).join("\n"); } catch {} const keyFacts = [ `cwd: ${ctx.cwd}`, `git branch: ${gitBranch}`, fileList ? `\nFiles:\n${fileList}` : "", ].filter(Boolean).join("\n"); parts.push(`[KEY FACTS]\n${keyFacts}`); const preamble = parts.join("\n\n"); const capped = preamble.length > MAX_CHARS ? preamble.slice(0, MAX_CHARS) + "\n...[truncated]" : preamble; return `${capped}\n\n[TASK]\n${task}`; } // ══════════════════════════════════════════════════════════════════════════ // LLM-POWERED TASK CLASSIFIER // ══════════════════════════════════════════════════════════════════════════ const CLASSIFIER_SYSTEM_PROMPT = `You are a task complexity classifier for a coding assistant. Given a user prompt, respond with ONLY a JSON object (no markdown, no explanation): {"tier": <1|2|3>, "thinking": "", "offload": , "reason": ""} Classification rules: TIER 1 (simple — use cheapest model): - Quick questions, yes/no answers, explanations - Reading/viewing files, checking status, listing things - Small single-line edits, formatting, renaming - Conversational responses (thanks, ok, etc.) - Simple lookups or searches TIER 2 (medium — use balanced model): - Writing new functions, classes, or components - Debugging, fixing bugs, error resolution - Refactoring single files - Writing tests for specific code - Database queries, API endpoint creation - Docker/CI configuration changes TIER 3 (complex — use most powerful model): - Architecture design, system planning - Multi-file refactoring or migrations - Security audits, performance optimization - Complex algorithmic problems - Designing new systems from scratch - Tasks requiring deep reasoning or trade-off analysis THINKING LEVEL: - "off" → Simple/conversational. - "low" → Moderate task. - "medium" → Tricky bug, subtle logic, multi-step plan. - "high" → Architecture, security audit, complex algorithm. OFFLOAD (spawn sub-agent instead of inline): - Set true when the task would generate lots of context (multi-file changes, codebase-wide operations) - Set true when the task is independent and can run in background - Set true when "across all files", "entire codebase", parallel work is mentioned - Set false for conversational, quick, or interactive tasks - Set false when user needs immediate back-and-forth AGENT LIMITS: - Max recommended concurrent agents: 3 - If already at 3+ running agents, prefer offload=false unless critical. Be concise. The "reason" should be under 10 words.`; interface ClassificationResult { tier: number; thinking: "off" | "low" | "medium" | "high"; offload: boolean; reason: string; } const classificationCache = new Map(); const CACHE_TTL_MS = 60_000; async function classifyWithLLM( prompt: string, ctx: ExtensionContext, contextPercent: number | null, activeAgentCount: number, memorySnippet: string, ): Promise { const cacheKey = prompt.trim().toLowerCase().slice(0, 200); const cached = classificationCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) return cached.result; const classifier = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); if (!classifier) return { tier: 2, thinking: "low", offload: false, reason: "classifier unavailable" }; const apiKey = await ctx.modelRegistry.getApiKey(classifier); if (!apiKey) return { tier: 2, thinking: "low", offload: false, reason: "no API key" }; let userMsg = prompt; const notes: string[] = []; if (contextPercent !== null && contextPercent > CONTEXT_WARN_PCT) { notes.push(`Main context at ${Math.round(contextPercent)}% — prefer offload=true for heavy tasks.`); } if (activeAgentCount >= 3) { notes.push(`Already ${activeAgentCount} running agents — prefer offload=false unless critical.`); } if (memorySnippet) { notes.push(`Tech context from memory: ${memorySnippet.slice(0, 200)}`); } if (notes.length > 0) userMsg += `\n\n[SYSTEM NOTES]\n${notes.join("\n")}`; try { const response = await complete(classifier, { systemPrompt: CLASSIFIER_SYSTEM_PROMPT, messages: [{ role: "user" as const, content: userMsg, timestamp: Date.now() }], }, { reasoning: "off" }); const text = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text).join(""); const jsonStr = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); const parsed = JSON.parse(jsonStr); const validThinking = ["off", "low", "medium", "high"]; const result: ClassificationResult = { tier: Math.max(1, Math.min(3, parsed.tier || 2)), thinking: validThinking.includes(parsed.thinking) ? parsed.thinking : "low", offload: Boolean(parsed.offload), reason: String(parsed.reason || "classified").slice(0, 50), }; classificationCache.set(cacheKey, { result, timestamp: Date.now() }); return result; } catch (err: any) { return { tier: 2, thinking: "low", offload: false, reason: `classify error: ${err?.message?.slice(0, 30)}` }; } } // ══════════════════════════════════════════════════════════════════════════ // SUB-AGENT TYPES // ══════════════════════════════════════════════════════════════════════════ type AgentMode = "interactive" | "delegator" | "hybrid"; type SubStatus = "queued" | "running" | "done" | "error" | "killed"; interface SubAgent { id: number; status: SubStatus; task: string; role: string; tools: string; textChunks: string[]; toolCount: number; elapsed: number; startTime: number; sessionFile: string; turnCount: number; proc?: ChildProcess; timer?: ReturnType; inputTokens: number; outputTokens: number; cost: number; retryCount: number; lastError?: string; } interface RoleDef { name: string; description: string; tools: string; systemPrompt: string; } const ROLES: RoleDef[] = [ { name: "general", description: "General-purpose coding agent", tools: "read,bash,edit,write,grep,find,ls", systemPrompt: "You are a focused coding agent. Complete the given task efficiently. Be thorough but concise." }, { name: "scout", description: "Fast recon (read-only)", tools: "read,grep,find,ls", systemPrompt: "You are a scout. Investigate the codebase quickly and report findings. Do NOT modify files." }, { name: "builder", description: "Implementation & code generation", tools: "read,write,edit,bash,grep,find,ls", systemPrompt: "You are a builder. Implement changes thoroughly. Clean, minimal code. Follow existing patterns." }, { name: "reviewer", description: "Code review (read-only)", tools: "read,grep,find,ls", systemPrompt: "You are a reviewer. Analyze code for bugs, security, performance, style. Be specific about line numbers." }, { name: "tester", description: "Test writing & execution", tools: "read,write,edit,bash,grep,find,ls", systemPrompt: "You are a tester. Write and run tests. Cover edge cases. Use the project's existing test framework." }, ]; // ══════════════════════════════════════════════════════════════════════════ // EXTENSION // ══════════════════════════════════════════════════════════════════════════ export default function nexus(pi: ExtensionAPI) { // ── Router State ─────────────────────────────────────────────────────── let currentTier = 1; let lastThinking: "off" | "low" | "medium" | "high" = "low"; let tierLocked = false; let totalSwitches = 0; let turnsSinceSwitch = 0; let consecutiveErrors = 0; let totalToolCalls = 0; let editWriteThisTurn = 0; let toolsThisTurn = 0; let turnCount = 0; let savedVsOpus = 0; let classifierCost = 0; const tierHistory: Array<{ tier: number; reason: string; turn: number }> = []; // ── Agent State ──────────────────────────────────────────────────────── const agents: Map = new Map(); const activeProcs: Set = new Set(); let nextId = 1; let widgetCtx: ExtensionContext | null = null; let mode: AgentMode = "hybrid"; let autoDelegate = true; let sessionDir = ""; let customRoles: RoleDef[] = []; let projectMemory = ""; let cwdGlobal = ""; let sessionStartTime = Date.now(); // ── Widget throttle ──────────────────────────────────────────────────── let wDirty = false; let wTimer: ReturnType | null = null; function scheduleWidget() { wDirty = true; if (wTimer) return; wTimer = setTimeout(() => { wTimer = null; if (wDirty) { wDirty = false; renderWidget(); } }, WIDGET_THROTTLE_MS); } function flushWidget() { if (wTimer) { clearTimeout(wTimer); wTimer = null; } wDirty = false; renderWidget(); } // ── Helpers ──────────────────────────────────────────────────────────── function getAllRoles(): RoleDef[] { return [...ROLES, ...customRoles]; } function findRole(name: string): RoleDef { return getAllRoles().find(r => r.name.toLowerCase() === name.toLowerCase()) || ROLES[0]; } function makeSessionFile(id: number): string { if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true }); return join(sessionDir, `nexus-${id}-${Date.now()}.jsonl`); } function loadCustomRoles(cwd: string) { customRoles = []; const seen = new Set(ROLES.map(r => r.name)); for (const dir of [join(cwd, ".pi", "agents"), join(cwd, "agents")]) { if (!existsSync(dir)) continue; try { for (const file of readdirSync(dir)) { if (!file.endsWith(".md")) continue; const raw = readFileSync(join(dir, file), "utf-8"); const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); if (!m) continue; const fm: Record = {}; for (const line of m[1].split("\n")) { const i = line.indexOf(":"); if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim(); } if (!fm.name || seen.has(fm.name)) continue; seen.add(fm.name); customRoles.push({ name: fm.name, description: fm.description || "", tools: fm.tools || "read,grep,find,ls", systemPrompt: m[2].trim() }); } } catch {} } } // ── Model Switching ──────────────────────────────────────────────────── async function switchTier(target: number, reason: string, ctx?: ExtensionContext, thinkingOverride?: "off" | "low" | "medium" | "high") { const t = Math.max(0, Math.min(2, target)); const tier = TIERS[t]; const model = ctx?.modelRegistry.find(tier.provider, tier.modelId); if (!model) return; const thinking = thinkingOverride ?? tier.thinking; const tierChanged = t !== currentTier; const thinkingChanged = thinking !== lastThinking; if (!tierChanged && !thinkingChanged) return; if (tierChanged) { const ok = await pi.setModel(model); if (!ok) return; } pi.setThinkingLevel(thinking); lastThinking = thinking; const prev = currentTier; currentTier = t; if (tierChanged) totalSwitches++; turnsSinceSwitch = 0; const thinkTag = thinkingOverride ? ` 🧠${thinking}` : ""; tierHistory.push({ tier: t, reason: `${TIERS[prev].icon}→${tier.icon} ${reason}${thinkTag}`, turn: turnCount }); } // ── Widget Rendering ─────────────────────────────────────────────────── function renderWidget() { if (!widgetCtx) return; if (agents.size === 0) { widgetCtx.ui.setWidget("nexus-agents", undefined); return; } widgetCtx.ui.setWidget("nexus-agents", (_tui: any, theme: any) => { const container = new Container(); const content = new Text("", 0, 0); container.addChild(content); return { render(width: number): string[] { const lines: string[] = []; const tier = TIERS[currentTier]; const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; lines.push(theme.fg("dim", "─".repeat(width))); lines.push( theme.fg("accent", ` ✦ Nexus`) + theme.fg("dim", ` ${tier.icon} ${tier.name} · ${modeIcon} ${mode} · `) + theme.fg("accent", `${agents.size}`) + theme.fg("dim", " agents") ); for (const [, a] of agents) { const icon = a.status === "running" ? "●" : a.status === "done" ? "✓" : a.status === "queued" ? "◌" : "✗"; const color = a.status === "running" ? "accent" : a.status === "done" ? "success" : a.status === "queued" ? "dim" : "error"; const elapsed = a.status === "running" ? Math.round((Date.now() - a.startTime) / 1000) : Math.round(a.elapsed / 1000); const task = a.task.length > 50 ? a.task.slice(0, 47) + "..." : a.task; const turn = a.turnCount > 1 ? ` t${a.turnCount}` : ""; const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; lines.push( theme.fg(color, ` ${icon} #${a.id}`) + theme.fg("dim", ` [${a.role}${turn}${retry}] ${elapsed}s · ${a.toolCount} tools`) + (a.cost > 0 ? theme.fg("warning", ` $${a.cost.toFixed(4)}`) : "") ); lines.push(theme.fg("muted", ` ${task}`)); if (a.status === "running" && a.textChunks.length > 0) { const last = a.textChunks.join("").split("\n").filter((l: string) => l.trim()).pop() || ""; if (last) lines.push(theme.fg("dim", ` → ${last.slice(0, width - 8)}`)); } } lines.push(theme.fg("dim", "─".repeat(width))); content.setText(lines.join("\n")); return container.render(width); }, invalidate() { container.invalidate(); }, }; }); } // ── Sub-Agent Process ────────────────────────────────────────────────── function processLine(a: SubAgent, line: string) { if (!line.trim()) return; try { const e = JSON.parse(line); if (e.type === "message_update" && e.assistantMessageEvent?.type === "text_delta") { a.textChunks.push(e.assistantMessageEvent.delta || ""); scheduleWidget(); } else if (e.type === "tool_execution_start") { a.toolCount++; scheduleWidget(); } else if (e.type === "message_end" || e.type === "agent_end") { const msg = e.message || (e.messages || []).reverse().find((m: any) => m.role === "assistant"); if (msg?.usage) { a.inputTokens = msg.usage.input || 0; a.outputTokens = msg.usage.output || 0; a.cost = msg.usage.cost?.total || 0; scheduleWidget(); } } } catch {} } function spawnProcess(a: SubAgent, prompt: string, _ctx: ExtensionContext, isCont: boolean): Promise<{ output: string; exitCode: number }> { a.status = "running"; a.startTime = Date.now(); scheduleWidget(); const role = findRole(a.role); const model = selectSubAgentModel(a.role, a.task); const args = [ "--mode", "json", "-p", "--no-extensions", "--no-skills", "--model", model, "--tools", role.tools, "--thinking", "low", "--append-system-prompt", role.systemPrompt, "--session", a.sessionFile, ]; if (isCont) args.push("-c"); args.push(prompt); return new Promise(resolve => { let done = false; const fin = (val: { output: string; exitCode: number }) => { if (!done) { done = true; resolve(val); } }; const proc = cpSpawn("pi", args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env } }); a.proc = proc; activeProcs.add(proc); a.timer = setInterval(() => { a.elapsed = Date.now() - a.startTime; scheduleWidget(); }, 1000); const timeout = setTimeout(() => { try { proc.kill("SIGTERM"); } catch {} setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); }, AGENT_TIMEOUT_MS); let buf = ""; proc.stdout!.setEncoding("utf-8"); proc.stdout!.on("data", (chunk: string) => { buf += chunk; const lines = buf.split("\n"); buf = lines.pop() || ""; for (const l of lines) processLine(a, l); }); proc.stderr!.setEncoding("utf-8"); proc.stderr!.on("data", () => {}); proc.on("close", code => { clearTimeout(timeout); activeProcs.delete(proc); a.proc = undefined; if (a.timer) clearInterval(a.timer); a.elapsed = Date.now() - a.startTime; if (buf.trim()) processLine(a, buf); a.status = a.elapsed >= AGENT_TIMEOUT_MS ? "error" : (code === 0 ? "done" : "error"); flushWidget(); fin({ output: a.textChunks.join(""), exitCode: code ?? 1 }); }); proc.on("error", err => { clearTimeout(timeout); activeProcs.delete(proc); a.proc = undefined; if (a.timer) clearInterval(a.timer); a.status = "error"; flushWidget(); fin({ output: `Error: ${err.message}`, exitCode: 1 }); }); }); } function notifyOS(title: string, message: string, status: "success" | "error" | "info" = "info") { const sound = status === "error" ? "Basso" : "Glass"; try { cpSpawn("osascript", [ "-e", `display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}" sound name "${sound}"`, ], { stdio: "ignore", detached: true }).unref(); } catch {} process.stdout.write("\x07"); } async function autoExtractMemory(output: string, task: string, ctx: ExtensionContext): Promise { if (!output || output.length < 100) return; const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); if (!haiku) return; const apiKey = await ctx.modelRegistry.getApiKey(haiku); if (!apiKey) return; try { const response = await complete(haiku, { systemPrompt: `Extract key technical facts from this agent output that would be useful to remember for future sessions. Focus on: tech stack discovered, bugs fixed, architectural decisions, file structure, conventions. Output ONLY a JSON object: {"key": "category_name", "value": "concise fact"} or null if nothing worth remembering.`, messages: [{ role: "user" as const, content: `Task: ${task}\n\nOutput:\n${output.slice(0, 3000)}`, timestamp: Date.now() }], }, { reasoning: "off" }); const text = response.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join("").trim(); const jsonStr = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); if (jsonStr === "null" || !jsonStr) return; const parsed = JSON.parse(jsonStr); if (parsed?.key && parsed?.value) { updateMemory(ctx.cwd, parsed.key, parsed.value, "append"); projectMemory = readMemory(ctx.cwd); } } catch {} } function isRecoverableError(output: string): boolean { const lower = output.slice(-500).toLowerCase(); return ( lower.includes("file not found") || lower.includes("no such file") || lower.includes("command not found") || lower.includes("enoent") || lower.includes("timeout") || lower.includes("connection refused") || lower.includes("permission denied") ); } function spawnAndDeliver(a: SubAgent, prompt: string, ctx: ExtensionContext, isCont: boolean) { spawnProcess(a, prompt, ctx, isCont).then(async ({ output, exitCode }) => { // Self-healing: retry once on recoverable errors if (exitCode !== 0 && a.retryCount === 0 && isRecoverableError(output)) { const errorSnippet = output.slice(-500); a.retryCount = 1; a.lastError = errorSnippet; a.textChunks = []; a.toolCount = 0; a.status = "queued"; ctx.ui.notify(`#${a.id} auto-retrying (recoverable error detected)...`, "info"); const retryPrompt = `${prompt}\n\n[RETRY CONTEXT: Previous attempt failed with this error — please handle it gracefully]\n${errorSnippet}`; spawnProcess(a, retryPrompt, ctx, isCont).then(async (retryResult) => { await deliverResult(a, retryResult.output, retryResult.exitCode, ctx); }); return; } await deliverResult(a, output, exitCode, ctx); }); } async function deliverResult(a: SubAgent, output: string, exitCode: number, ctx: ExtensionContext) { const status = exitCode === 0 ? "✓" : "✗"; const elapsed = Math.round(a.elapsed / 1000); const turn = a.turnCount > 1 ? ` (Turn ${a.turnCount})` : ""; const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; const truncated = output.length > MAX_RESULT_LEN ? output.slice(0, MAX_RESULT_LEN) + "\n\n... [truncated]" : output; ctx.ui.notify(`#${a.id} [${a.role}] ${status} ${elapsed}s`, exitCode === 0 ? "info" : "error"); notifyOS( `Nexus #${a.id} [${a.role}] ${status}`, `${elapsed}s · ${a.toolCount} tools · $${a.cost.toFixed(4)} · ${a.task.slice(0, 50)}`, exitCode === 0 ? "success" : "error", ); pi.sendMessage({ customType: "nexus-result", content: `**Sub-agent #${a.id}** [${a.role}]${turn}${retry} — ${status} ${elapsed}s | ${a.toolCount} tools | $${a.cost.toFixed(4)}\n\n${truncated}`, display: true, }, { deliverAs: "followUp", triggerTurn: true }); // Auto-extract memory on success if (exitCode === 0) { await autoExtractMemory(output, a.task, ctx); } } // ══════════════════════════════════════════════════════════════════════ // TOOLS // ══════════════════════════════════════════════════════════════════════ const roleList = ROLES.map(r => `${r.name} (${r.description})`).join(", "); pi.registerTool({ name: "nexus_spawn", label: "Spawn Sub-Agent", description: `Spawn a background sub-agent for heavy tasks. Preserves main chat context. Results auto-deliver when done.\n\nRoles: ${roleList}. Custom roles from .pi/agents/*.md also available.`, promptGuidelines: [ "Spawn sub-agents for multi-file, long-running, or context-heavy work", "Use role='scout' for exploration, 'builder' for code, 'reviewer' for review, 'tester' for tests", "Spawn multiple agents in parallel for independent tasks", "Continue chatting while sub-agents work — results arrive automatically", ], parameters: Type.Object({ task: Type.String({ description: "Complete task description — sub-agent receives conversation context automatically" }), role: Type.Optional(Type.String({ description: "Agent role. Default: general" })), }), async execute(_id, params, _sig, onUpdate, ctx) { widgetCtx = ctx; const id = nextId++; const a: SubAgent = { id, status: "queued", task: params.task, role: params.role || "general", tools: findRole(params.role || "general").tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0, }; agents.set(id, a); if (onUpdate) onUpdate({ content: [{ type: "text", text: `Spawning #${id} [${a.role}]...` }], details: { id, role: a.role } }); const enrichedPrompt = await buildContextBridge(ctx, params.task); spawnAndDeliver(a, enrichedPrompt, ctx, false); return { content: [{ type: "text", text: `Sub-agent #${id} [${a.role}] running in background. Continue chatting.` }], details: { id, role: a.role } }; }, renderCall(args, theme) { const r = (args as any).role || "general"; const t = ((args as any).task || "").slice(0, 55); return new Text(theme.fg("toolTitle", theme.bold("nexus_spawn ")) + theme.fg("accent", `[${r}] `) + theme.fg("muted", t), 0, 0); }, renderResult(result, _opts, theme) { const d = result.details as any; if (!d) return undefined; return new Text(theme.fg("success", `◉ #${d.id}`) + theme.fg("dim", ` [${d.role}] background`), 0, 0); }, }); pi.registerTool({ name: "nexus_continue", label: "Continue Sub-Agent", description: "Continue a finished sub-agent's conversation with follow-up instructions.", parameters: Type.Object({ id: Type.Number({ description: "Sub-agent ID" }), prompt: Type.String({ description: "Follow-up instructions" }), }), async execute(_cid, params, _sig, _upd, ctx) { widgetCtx = ctx; const a = agents.get(params.id); if (!a) return { content: [{ type: "text", text: `No #${params.id} found.` }], details: undefined }; if (a.status === "running") return { content: [{ type: "text", text: `#${params.id} still running.` }], details: undefined }; a.textChunks = []; a.toolCount = 0; a.elapsed = 0; a.turnCount++; spawnAndDeliver(a, params.prompt, ctx, true); return { content: [{ type: "text", text: `#${params.id} continuing (Turn ${a.turnCount}).` }], details: undefined }; }, }); pi.registerTool({ name: "nexus_status", label: "Agent Status", description: "Check all sub-agents.", parameters: Type.Object({}), async execute() { if (agents.size === 0) return { content: [{ type: "text", text: "No sub-agents." }], details: undefined }; const lines = Array.from(agents.values()).map(a => { const e = a.status === "running" ? Math.round((Date.now() - a.startTime) / 1000) : Math.round(a.elapsed / 1000); const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; return `#${a.id} [${a.role}${retry}] ${a.status} — ${e}s, ${a.toolCount} tools, $${a.cost.toFixed(4)}\n ${a.task}`; }); return { content: [{ type: "text", text: lines.join("\n") }], details: undefined }; }, }); pi.registerTool({ name: "nexus_kill", label: "Kill Sub-Agent", description: "Kill/remove a sub-agent.", parameters: Type.Object({ id: Type.Number() }), async execute(_cid, params) { const a = agents.get(params.id); if (!a) return { content: [{ type: "text", text: `No #${params.id}.` }], details: undefined }; if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} if (a.timer) clearInterval(a.timer); agents.delete(params.id); flushWidget(); return { content: [{ type: "text", text: `#${params.id} removed.` }], details: undefined }; }, }); pi.registerTool({ name: "nexus_collect", label: "Collect Results", description: "Collect and synthesize results from all finished sub-agents.", parameters: Type.Object({ synthesize: Type.Optional(Type.Boolean({ description: "Use LLM to synthesize results. Default: true" })), }), async execute(_id, params, _sig, _upd, ctx) { const done = Array.from(agents.values()).filter(a => a.status === "done" || a.status === "error"); if (done.length === 0) return { content: [{ type: "text", text: "No results to collect." }], details: undefined }; let totalCost = 0; const parts = done.map(a => { const out = a.textChunks.join(""); const trunc = out.length > 6000 ? out.slice(0, 6000) + "\n... [truncated]" : out; totalCost += a.cost; return `## ${a.status === "done" ? "✓" : "✗"} #${a.id} [${a.role}] ${Math.round(a.elapsed / 1000)}s\nTask: ${a.task}\n\n${trunc}`; }); const rawOutput = `${done.length} results ($${totalCost.toFixed(4)})\n\n${parts.join("\n\n---\n\n")}`; const shouldSynthesize = params.synthesize !== false; if (!shouldSynthesize) return { content: [{ type: "text", text: rawOutput }], details: undefined }; // LLM synthesis with Haiku try { const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); if (!haiku) return { content: [{ type: "text", text: rawOutput }], details: undefined }; const apiKey = await ctx.modelRegistry.getApiKey(haiku); if (!apiKey) return { content: [{ type: "text", text: rawOutput }], details: undefined }; const response = await complete(haiku, { systemPrompt: `You are synthesizing results from multiple parallel coding agents. Given their outputs, produce a concise synthesis with these sections: ## Executive Summary ## Key Findings ## Action Items ## Open Questions Be specific and actionable. Reference specific files/lines where relevant.`, messages: [{ role: "user" as const, content: rawOutput.slice(0, 8000), timestamp: Date.now() }], }, { reasoning: "off" }); const synthesis = response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map(c => c.text).join(""); return { content: [{ type: "text", text: `${synthesis}\n\n---\n\n### Raw Outputs\n${rawOutput}` }], details: undefined }; } catch { return { content: [{ type: "text", text: rawOutput }], details: undefined }; } }, }); pi.registerTool({ name: "nexus_remember", label: "Store Memory", description: "Store important project facts in persistent memory (.pi/memory.md). Use for tech stack, conventions, key decisions, recurring issues.", parameters: Type.Object({ key: Type.String({ description: "Memory category e.g. 'tech_stack', 'conventions', 'bug_fixes'" }), value: Type.String({ description: "What to remember" }), mode: Type.Optional(Type.Union([Type.Literal("append"), Type.Literal("replace")])), }), async execute(_id, params, _sig, _upd, ctx) { updateMemory(ctx.cwd, params.key, params.value, params.mode || "replace"); projectMemory = readMemory(ctx.cwd); return { content: [{ type: "text", text: `Stored in memory: [${params.key}] ${params.value.slice(0, 80)}` }], details: undefined }; }, }); pi.registerTool({ name: "nexus_plan", label: "Plan & Spawn", description: "Decompose a complex goal into parallel + sequential subtasks, then spawn optimal agents for each.", parameters: Type.Object({ goal: Type.String({ description: "High-level goal to accomplish" }), context: Type.Optional(Type.String({ description: "Additional context" })), }), async execute(_id, params, _sig, onUpdate, ctx) { widgetCtx = ctx; if (onUpdate) onUpdate({ content: [{ type: "text", text: "Planning task decomposition..." }], details: undefined }); const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); if (!haiku) return { content: [{ type: "text", text: "Planner unavailable (no Haiku model)." }], details: undefined }; const apiKey = await ctx.modelRegistry.getApiKey(haiku); if (!apiKey) return { content: [{ type: "text", text: "No API key for planner." }], details: undefined }; const availableRoles = getAllRoles().map(r => r.name).join(", "); const planPrompt = `Decompose this goal into subtasks for a coding agent team. Goal: ${params.goal} ${params.context ? `Context: ${params.context}` : ""} Available roles: ${availableRoles} cwd: ${ctx.cwd} Respond with ONLY JSON (no markdown): { "parallel": [{"role": "...", "task": "..."}], "sequential": [{"role": "...", "task": "...", "dependsOn": "parallel"}] } Rules: - parallel: tasks that can run simultaneously (e.g. scout different areas, review different files) - sequential: tasks that need parallel results first (e.g. builder after scouts report) - max 3 parallel tasks, max 2 sequential - tasks must be self-contained with enough context to execute - use scout for exploration, builder for implementation, reviewer/tester for QA`; try { const response = await complete(haiku, { systemPrompt: "You are a task planning expert for a software engineering team.", messages: [{ role: "user" as const, content: planPrompt, timestamp: Date.now() }], }, { reasoning: "off" }); const text = response.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join(""); const jsonStr = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); const plan = JSON.parse(jsonStr); const parallel: Array<{ role: string; task: string }> = plan.parallel || []; const sequential: Array<{ role: string; task: string }> = plan.sequential || []; if (onUpdate) { const planSummary = [ `Plan for: ${params.goal}`, `Parallel (${parallel.length}): ${parallel.map(p => `[${p.role}] ${p.task.slice(0, 40)}`).join(", ")}`, sequential.length > 0 ? `Sequential (${sequential.length}): ${sequential.map(s => `[${s.role}] ${s.task.slice(0, 40)}`).join(", ")}` : "", ].filter(Boolean).join("\n"); onUpdate({ content: [{ type: "text", text: planSummary }], details: undefined }); } const spawnedIds: number[] = []; // Spawn parallel agents for (const p of parallel) { const id = nextId++; const role = findRole(p.role); const a: SubAgent = { id, status: "queued", task: p.task, role: p.role, tools: role.tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0, }; agents.set(id, a); spawnedIds.push(id); const enrichedPrompt = await buildContextBridge(ctx, p.task); spawnAndDeliver(a, enrichedPrompt, ctx, false); } // Queue sequential agents (spawn after a short note — they'll wait for parallel in the real world, // but here we spawn them with a note about depending on parallel results) for (const s of sequential) { const id = nextId++; const role = findRole(s.role); const a: SubAgent = { id, status: "queued", task: s.task, role: s.role, tools: role.tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0, }; agents.set(id, a); spawnedIds.push(id); const enrichedPrompt = await buildContextBridge(ctx, `${s.task}\n\n[Note: This task depends on parallel agents completing first. Parallel agent IDs: ${spawnedIds.slice(0, parallel.length).join(", ")}]`); spawnAndDeliver(a, enrichedPrompt, ctx, false); } const summary = `Plan executed: ${parallel.length} parallel + ${sequential.length} sequential agents spawned (IDs: ${spawnedIds.join(", ")})`; return { content: [{ type: "text", text: summary }], details: { plan, spawnedIds } }; } catch (err: any) { return { content: [{ type: "text", text: `Planning failed: ${err?.message}. Try /nx spawn manually.` }], details: undefined }; } }, }); // ══════════════════════════════════════════════════════════════════════ // CORE ROUTING LOGIC — LLM-POWERED // ══════════════════════════════════════════════════════════════════════ pi.on("before_agent_start", async (event, ctx) => { widgetCtx = ctx; const usage = ctx.getContextUsage(); const contextPct = usage?.percent ?? null; const activeAgentCount = Array.from(agents.values()).filter(a => a.status === "running").length; // Extract memory snippet for classifier context const memorySnippet = projectMemory ? projectMemory.slice(0, 300) : ""; // ── Step 1: LLM Classification ───────────────────────────────────── let classification: ClassificationResult | null = null; if (!tierLocked || (autoDelegate && mode !== "interactive")) { ctx.ui.setWorkingMessage("classifying task..."); classification = await classifyWithLLM(event.prompt, ctx, contextPct, activeAgentCount, memorySnippet); ctx.ui.setWorkingMessage(); classifierCost += (150 * 0.80 + 30 * 4.00) / 1_000_000; } // ── Step 2: Model Routing ────────────────────────────────────────── if (!tierLocked && classification) { let target = classification.tier - 1; let thinking = classification.thinking; if (target === 0 && totalToolCalls > 10) target = 1; if (consecutiveErrors >= 2 && target < 2) { target = Math.min(target + 1, 2); if (thinking === "off" || thinking === "low") thinking = "medium"; } if (contextPct !== null && contextPct > 80 && target < 1) target = 1; await switchTier(target, classification.reason, ctx, thinking); } // ── Step 3: Build system prompt addons ──────────────────────────── let systemAddons = ""; // Always inject project memory if available if (projectMemory.trim()) { systemAddons += `\n\n## Project Memory\n${projectMemory}`; } // ── Step 4: Auto-Delegation ──────────────────────────────────────── if (classification?.offload && autoDelegate && (mode === "hybrid" || mode === "delegator")) { if (contextPct !== null && contextPct > CONTEXT_WARN_PCT) { ctx.ui.setStatus("nexus-ctx", `⚠️ Context ${Math.round(contextPct)}%`); } else { ctx.ui.setStatus("nexus-ctx", undefined); } const roles = getAllRoles().map(r => `${r.name}: ${r.description}`).join("\n"); const contextWarn = (contextPct !== null && contextPct > CONTEXT_WARN_PCT) ? `\n⚠️ CONTEXT AT ${Math.round(contextPct)}% — strongly prefer sub-agents for this work.\n` : ""; return { systemPrompt: event.systemPrompt + systemAddons + `\n\n## Nexus: Sub-Agent Delegation Recommended The task classifier determined this task should be **offloaded to a sub-agent** (reason: "${classification.reason}"). You have these tools: nexus_spawn, nexus_continue, nexus_plan, nexus_status, nexus_kill, nexus_collect, nexus_remember. ${contextWarn} **Strongly prefer spawning a sub-agent** for this task rather than doing it inline. This preserves the main chat context. Available roles:\n${roles} Sub-agents run in background with their own context. You can spawn multiple for parallel work. Results auto-deliver.`, }; } else { ctx.ui.setStatus("nexus-ctx", undefined); if (systemAddons) { return { systemPrompt: event.systemPrompt + systemAddons }; } } }); // ── Turn & Tool Tracking ─────────────────────────────────────────────── pi.on("turn_start", async () => { toolsThisTurn = 0; editWriteThisTurn = 0; turnCount++; turnsSinceSwitch++; }); pi.on("tool_execution_start", async (event) => { toolsThisTurn++; totalToolCalls++; if (event.toolName === "edit" || event.toolName === "write") editWriteThisTurn++; }); pi.on("tool_execution_end", async (event, ctx) => { if (event.isError) { consecutiveErrors++; if (consecutiveErrors >= 2 && !tierLocked && currentTier < 2) { await switchTier(currentTier + 1, `${consecutiveErrors} consecutive errors`, ctx); } } else { consecutiveErrors = 0; } }); pi.on("turn_end", async (_event, ctx) => { if (!tierLocked) { if (editWriteThisTurn >= 4 && currentTier < 2) { await switchTier(2, `heavy: ${editWriteThisTurn} edits/writes`, ctx); } else if (toolsThisTurn >= 6 && currentTier < 1) { await switchTier(1, `busy: ${toolsThisTurn} tool calls`, ctx); } } const opusCost = (2000 * TIERS[2].inputCost + 1000 * TIERS[2].outputCost) / 1e6; const actualCost = (2000 * TIERS[currentTier].inputCost + 1000 * TIERS[currentTier].outputCost) / 1e6; savedVsOpus += opusCost - actualCost; }); // ── /nx Command ──────────────────────────────────────────────────────── pi.registerCommand("nx", { description: "Nexus controls: /nx [spawn|plan|memory|retry|chain|kill|clear|mode|auto|lock|unlock|tier]", getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { const items = [ { value: "spawn ", label: "spawn " }, { value: "plan ", label: "plan " }, { value: "memory", label: "show memory" }, { value: "retry ", label: "retry " }, { value: "chain ", label: "chain " }, { value: "kill ", label: "kill " }, { value: "clear", label: "clear all agents" }, { value: "mode hybrid", label: "mode hybrid" }, { value: "mode interactive", label: "mode interactive" }, { value: "mode delegator", label: "mode delegator" }, { value: "auto on", label: "auto on" }, { value: "auto off", label: "auto off" }, { value: "lock", label: "lock model" }, { value: "unlock", label: "unlock model" }, { value: "tier ", label: "tier 1|2|3" }, ]; return items.filter(i => i.value.startsWith(prefix)); }, async handler(args, ctx) { widgetCtx = ctx; const parts = (args || "").trim().split(/\s+/); const sub = parts[0]?.toLowerCase(); if (!sub) { const tier = TIERS[currentTier]; const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; const usage = ctx.getContextUsage(); const pct = usage?.percent ? Math.round(usage.percent) : "?"; const agentLines = agents.size === 0 ? " (none)" : Array.from(agents.values()).map(a => { const e = a.status === "running" ? Math.round((Date.now() - a.startTime) / 1000) : Math.round(a.elapsed / 1000); const retry = a.retryCount > 0 ? ` ↺${a.retryCount}` : ""; return ` #${a.id} [${a.role}${retry}] ${a.status} ${e}s $${a.cost.toFixed(4)} — ${a.task.slice(0, 50)}`; }).join("\n"); const lastRoute = tierHistory.length > 0 ? tierHistory.slice(-3).map(h => ` Turn ${h.turn}: ${h.reason}`).join("\n") : " (none yet)"; const memFile = join(ctx.cwd, MEMORY_FILE); const memStatus = existsSync(memFile) ? `exists (${Math.round(readFileSync(memFile, "utf-8").length / 1024 * 10) / 10}KB)` : "none"; pi.sendMessage({ customType: "nexus-dashboard", content: [ ``, `✦ Nexus Dashboard v2`, ` Model: ${tier.icon} ${tier.name} [${lastThinking}]${tierLocked ? " 🔒" : ""}`, ` Mode: ${modeIcon} ${mode} | Auto: ${autoDelegate ? "on" : "off"} | Context: ${pct}%`, ` Switches: ${totalSwitches} | Saved: $${savedVsOpus.toFixed(4)} | Classifier: $${classifierCost.toFixed(4)}`, ` Memory: ${memStatus}`, ``, ` Sub-Agents (${agents.size}):`, agentLines, ``, ` Recent Routing:`, lastRoute, ``, ` /nx spawn|plan|memory|retry|chain|kill|clear|mode|auto|lock|unlock|tier`, ``, ].join("\n"), display: true, }); return; } if (sub === "spawn") { const task = parts.slice(1).join(" ").trim(); if (!task) { ctx.ui.notify("Usage: /nx spawn ", "error"); return; } const id = nextId++; const a: SubAgent = { id, status: "queued", task, role: "general", tools: ROLES[0].tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0 }; agents.set(id, a); const enrichedPrompt = await buildContextBridge(ctx, task); spawnAndDeliver(a, enrichedPrompt, ctx, false); ctx.ui.notify(`#${id} spawned`, "info"); } else if (sub === "plan") { const goal = parts.slice(1).join(" ").trim(); if (!goal) { ctx.ui.notify("Usage: /nx plan ", "error"); return; } ctx.ui.notify(`Planning: ${goal.slice(0, 50)}...`, "info"); // Run planning inline const haiku = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); if (!haiku) { ctx.ui.notify("Planner unavailable (no Haiku model)", "error"); return; } const apiKey = await ctx.modelRegistry.getApiKey(haiku); if (!apiKey) { ctx.ui.notify("No API key for planner", "error"); return; } try { const availableRoles = getAllRoles().map(r => r.name).join(", "); const resp = await complete(haiku, { systemPrompt: "You are a task planning expert. Decompose goals into parallel and sequential coding subtasks. Respond ONLY with JSON, no markdown.", messages: [{ role: "user" as const, content: `Goal: ${goal}\nRoles: ${availableRoles}\ncwd: ${ctx.cwd}\n\nRespond with JSON:\n{"parallel":[{"role":"...","task":"..."}],"sequential":[{"role":"...","task":"...","dependsOn":"parallel"}]}\nMax 3 parallel, 2 sequential. Make tasks self-contained.`, timestamp: Date.now() }], }, { reasoning: "off" }); const text = resp.content.filter((c): c is { type: "text"; text: string } => c.type === "text").map(c => c.text).join(""); const plan = JSON.parse(text.replace(/```json?\n?/g, "").replace(/```/g, "").trim()); const parallel: Array<{ role: string; task: string }> = plan.parallel || []; const sequential: Array<{ role: string; task: string }> = plan.sequential || []; const spawnedIds: number[] = []; for (const p of parallel) { const id = nextId++; const role = findRole(p.role); const a: SubAgent = { id, status: "queued", task: p.task, role: p.role, tools: role.tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0 }; agents.set(id, a); spawnedIds.push(id); const ep = await buildContextBridge(ctx, p.task); spawnAndDeliver(a, ep, ctx, false); } for (const s of sequential) { const id = nextId++; const role = findRole(s.role); const a: SubAgent = { id, status: "queued", task: s.task, role: s.role, tools: role.tools, textChunks: [], toolCount: 0, elapsed: 0, startTime: Date.now(), sessionFile: makeSessionFile(id), turnCount: 1, inputTokens: 0, outputTokens: 0, cost: 0, retryCount: 0 }; agents.set(id, a); spawnedIds.push(id); const ep = await buildContextBridge(ctx, `${s.task}\n\n[Depends on parallel agents: ${spawnedIds.slice(0, parallel.length).join(", ")}]`); spawnAndDeliver(a, ep, ctx, false); } ctx.ui.notify(`Plan: ${parallel.length} parallel + ${sequential.length} sequential spawned (${spawnedIds.join(", ")})`, "info"); } catch (err: any) { ctx.ui.notify(`Plan failed: ${err?.message?.slice(0, 60)}`, "error"); } } else if (sub === "memory") { const mem = readMemory(ctx.cwd); pi.sendMessage({ customType: "nexus-memory", content: mem ? `**Project Memory** (.pi/memory.md)\n\n${mem}` : "No memory yet. Use nexus_remember or ask the agent to remember something.", display: true, }); } else if (sub === "retry") { const id = parseInt(parts[1], 10); const a = agents.get(id); if (!a) { ctx.ui.notify(`No #${id}`, "error"); return; } if (a.status === "running") { ctx.ui.notify(`#${id} is still running`, "error"); return; } a.textChunks = []; a.toolCount = 0; a.elapsed = 0; a.retryCount++; a.status = "queued"; ctx.ui.notify(`#${id} retrying (manual)`, "info"); const enrichedPrompt = await buildContextBridge(ctx, a.task); spawnAndDeliver(a, enrichedPrompt, ctx, false); } else if (sub === "chain") { const id = parseInt(parts[1], 10); const task = parts.slice(2).join(" ").trim(); if (!id || !task) { ctx.ui.notify("Usage: /nx chain ", "error"); return; } const a = agents.get(id); if (!a) { ctx.ui.notify(`No #${id}`, "error"); return; } if (a.status === "running") { ctx.ui.notify(`#${id} still running`, "error"); return; } a.textChunks = []; a.toolCount = 0; a.elapsed = 0; a.turnCount++; ctx.ui.notify(`#${id} chaining: ${task.slice(0, 40)}`, "info"); spawnAndDeliver(a, task, ctx, true); } else if (sub === "kill") { const id = parseInt(parts[1], 10); const a = agents.get(id); if (!a) { ctx.ui.notify(`No #${id}`, "error"); return; } if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} if (a.timer) clearInterval(a.timer); agents.delete(id); flushWidget(); ctx.ui.notify(`#${id} killed`, "info"); } else if (sub === "clear") { for (const [, a] of agents) { if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} if (a.timer) clearInterval(a.timer); } agents.clear(); nextId = 1; flushWidget(); ctx.ui.notify("Cleared", "info"); } else if (sub === "mode") { const v = parts[1]?.toLowerCase() as AgentMode; if (["interactive", "hybrid", "delegator"].includes(v)) { mode = v; ctx.ui.notify(`Mode: ${v}`, "info"); } else ctx.ui.notify("Usage: /nx mode interactive|hybrid|delegator", "error"); } else if (sub === "auto") { if (parts[1] === "on") { autoDelegate = true; ctx.ui.notify("Auto ON", "info"); } else if (parts[1] === "off") { autoDelegate = false; ctx.ui.notify("Auto OFF", "info"); } else ctx.ui.notify("Usage: /nx auto on|off", "error"); } else if (sub === "lock") { tierLocked = true; ctx.ui.notify("🔒 Model locked", "info"); } else if (sub === "unlock") { tierLocked = false; ctx.ui.notify("🔓 Model unlocked", "info"); } else if (sub === "tier") { const n = parseInt(parts[1], 10); if (n >= 1 && n <= 3) { await switchTier(n - 1, `manual /nx tier ${n}`, ctx); ctx.ui.notify(`${TIERS[n - 1].icon} ${TIERS[n - 1].name}`, "info"); } else ctx.ui.notify("Usage: /nx tier 1|2|3", "error"); } else { ctx.ui.notify("Unknown. Try: /nx spawn|plan|memory|retry|chain|kill|clear|mode|auto|lock|unlock|tier", "error"); } }, }); // ── Session Start ────────────────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { applyExtensionDefaults(import.meta.url, ctx); widgetCtx = ctx; cwdGlobal = ctx.cwd; sessionStartTime = Date.now(); sessionDir = join(ctx.cwd, ".pi", "agent-sessions", "nexus"); loadCustomRoles(ctx.cwd); // Load persistent memory projectMemory = readMemory(ctx.cwd); // Cleanup previous for (const [, a] of agents) { if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} if (a.timer) clearInterval(a.timer); } agents.clear(); nextId = 1; // Set initial model (Sonnet = balanced start) const init = TIERS[1]; const model = ctx.modelRegistry.find(init.provider, init.modelId); if (model) { await pi.setModel(model); pi.setThinkingLevel(init.thinking); } currentTier = 1; // Footer ctx.ui.setFooter((_tui, theme, footerData) => { const unsub = footerData.onBranchChange(() => _tui.requestRender()); return { dispose: unsub, invalidate() {}, render(width: number): string[] { const tier = TIERS[currentTier]; const lockStr = tierLocked ? " 🔒" : ""; const usage = ctx.getContextUsage(); const pct = usage?.percent ?? 0; const filled = Math.round(pct / 10) || 1; let tokIn = 0, tokOut = 0, cost = 0; for (const entry of ctx.sessionManager.getBranch()) { if (entry.type === "message" && (entry as any).message.role === "assistant") { const m = (entry as any).message as AssistantMsg; tokIn += m.usage.input; tokOut += m.usage.output; cost += m.usage.cost.total; } } for (const [, a] of agents) cost += a.cost; cost += classifierCost; const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; const l1L = theme.fg("dim", " ") + theme.fg("accent", `${tier.icon} ${tier.name}`) + theme.fg("dim", ` [${lastThinking}]${lockStr} `) + theme.fg("warning", "[") + theme.fg("success", "#".repeat(filled)) + theme.fg("dim", "-".repeat(10 - filled)) + theme.fg("warning", "]") + theme.fg("dim", " ") + theme.fg("accent", `${Math.round(pct)}%`); const l1R = theme.fg("success", fmt(tokIn)) + theme.fg("dim", " in ") + theme.fg("accent", fmt(tokOut)) + theme.fg("dim", " out ") + theme.fg("warning", `$${cost.toFixed(4)} `); const p1 = " ".repeat(Math.max(1, width - visibleWidth(l1L) - visibleWidth(l1R))); const dir = basename(ctx.cwd); const branch = footerData.getGitBranch(); const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; const running = Array.from(agents.values()).filter(a => a.status === "running").length; const memExists = existsSync(join(ctx.cwd, MEMORY_FILE)); const l2L = theme.fg("dim", ` ${dir}`) + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : "") + theme.fg("dim", ` · ${modeIcon} ${mode}`) + (memExists ? theme.fg("dim", " · 🧠mem") : ""); const l2R = (running > 0 ? theme.fg("accent", `● ${running} running `) : "") + (agents.size > 0 ? theme.fg("dim", `${agents.size} agents `) : "") + (savedVsOpus > 0 ? theme.fg("success", `saved $${savedVsOpus.toFixed(3)} `) : ""); const p2 = " ".repeat(Math.max(1, width - visibleWidth(l2L) - visibleWidth(l2R))); return [ truncateToWidth(l1L + p1 + l1R, width, ""), truncateToWidth(l2L + p2 + l2R, width, ""), ]; }, }; }); const roles = getAllRoles().map(r => r.name).join(", "); const memStatus = projectMemory ? ` · 🧠 memory loaded` : ""; ctx.ui.notify( `✦ Nexus v2 active [${mode}] — LLM-powered routing\n` + `Model: ${TIERS[1].icon} ${TIERS[1].name} (Haiku classifies → auto-routes)\n` + `Roles: ${roles}${memStatus}\n` + `/nx — Dashboard & controls`, "info", ); }); // ── Session Shutdown — Write Summary ─────────────────────────────────── pi.on("session_shutdown", async () => { killAll(); // Write session summary try { if (!cwdGlobal) return; const sessDir = join(cwdGlobal, SESSIONS_DIR); if (!existsSync(sessDir)) mkdirSync(sessDir, { recursive: true }); const now = new Date(); const pad = (n: number) => String(n).padStart(2, "0"); const filename = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}.md`; const elapsed = Math.round((Date.now() - sessionStartTime) / 1000); let mainCost = 0; let agentCost = 0; for (const [, a] of agents) agentCost += a.cost; const done = Array.from(agents.values()).filter(a => a.status === "done"); const errored = Array.from(agents.values()).filter(a => a.status === "error"); const modelsUsed = new Set(); modelsUsed.add(TIERS[currentTier].name); for (const [, a] of agents) modelsUsed.add(selectSubAgentModel(a.role, a.task).split("/").pop() || "unknown"); const summary = [ `# Nexus Session — ${now.toISOString().slice(0, 16).replace("T", " ")}`, ``, `## Overview`, `- **Elapsed**: ${elapsed}s`, `- **Total Cost**: $${(mainCost + agentCost + classifierCost).toFixed(4)} (main + $${agentCost.toFixed(4)} agents + $${classifierCost.toFixed(4)} classifier)`, `- **Model Switches**: ${totalSwitches}`, `- **Tool Calls**: ${totalToolCalls}`, ``, `## Sub-Agents`, `- Completed: ${done.length}`, `- Failed: ${errored.length}`, done.length > 0 ? `\n### Completed Tasks\n${done.map(a => `- [${a.role}] ${a.task.slice(0, 80)}`).join("\n")}` : "", errored.length > 0 ? `\n### Failed Tasks\n${errored.map(a => `- [${a.role}] ${a.task.slice(0, 80)}${a.lastError ? ` — ${a.lastError.slice(0, 100)}` : ""}`).join("\n")}` : "", ``, `## Models Used`, Array.from(modelsUsed).map(m => `- ${m}`).join("\n"), ``, `## Routing History`, tierHistory.slice(-10).map(h => `- Turn ${h.turn}: ${h.reason}`).join("\n") || "- (none)", ].filter(s => s !== "").join("\n"); writeFileSync(join(sessDir, filename), summary, "utf-8"); } catch {} }); // ── Cleanup ──────────────────────────────────────────────────────────── function killAll() { for (const p of activeProcs) try { p.kill("SIGTERM"); } catch {} setTimeout(() => { for (const p of activeProcs) try { p.kill("SIGKILL"); } catch {} }, 3000); } process.on("exit", killAll); process.on("SIGINT", () => { killAll(); process.exit(0); }); process.on("SIGTERM", () => { killAll(); process.exit(0); }); }