diff --git a/extensions/hyperloop.ts b/extensions/hyperloop.ts new file mode 100644 index 0000000..bf7da55 --- /dev/null +++ b/extensions/hyperloop.ts @@ -0,0 +1,983 @@ +/** + * Hyperloop — Context-preserving sub-agent orchestrator + * + * The main agent stays lightweight and interactive. Heavy work is delegated + * to background sub-agents that run in isolated Pi sessions, preserving + * the main chat's context window. + * + * Key design: + * 1. Main agent KEEPS all its tools (read, bash, edit, write) for quick inline work + * 2. Heavy/complex tasks are offloaded to sub-agents via tools + * 3. Sub-agents run asynchronously — main chat stays responsive + * 4. Results stream back as follow-up messages when ready + * 5. Auto-delegation: detects when a task should be offloaded (configurable) + * 6. Sub-agents have persistent sessions for multi-turn continuations + * 7. Live widget dashboard shows all running/completed agents + * + * Auto-delegation triggers: + * - "across all files", "entire codebase", "every file" → offload + * - Explicit multi-step plans with 5+ steps → offload + * - User says "background", "async", "offload" → offload + * - Main context > 70% full → suggest offloading + * + * Tools (available to LLM): + * hyperloop_spawn — Spawn a sub-agent with a task + * hyperloop_continue — Continue a finished sub-agent's conversation + * hyperloop_status — Check status of all sub-agents + * hyperloop_kill — Kill/remove a sub-agent + * hyperloop_collect — Collect results from finished sub-agents + * + * Commands: + * /hl — Dashboard: show all sub-agents + * /hl spawn — Manually spawn a sub-agent + * /hl kill — Kill a running sub-agent + * /hl clear — Clear all sub-agents + * /hl auto on|off — Toggle auto-delegation suggestions + * /hl mode — Set mode: interactive (default), delegator, hybrid + * + * Modes: + * interactive — Main agent does everything, spawns sub-agents only when asked + * delegator — Main agent primarily delegates, keeps minimal tools + * hybrid — Main agent handles simple tasks inline, auto-offloads complex ones + * + * Usage: pi -e extensions/hyperloop.ts + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext, ToolCallEvent } 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 { spawn as cpSpawn, type ChildProcess } from "child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync } from "fs"; +import { basename, join } from "path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Constants ────────────────────────────────────────────────────────────── + +const AGENT_TIMEOUT_MS = 10 * 60 * 1000; // 10 min per sub-agent +const WIDGET_THROTTLE_MS = 400; +const CONTEXT_WARN_PCT = 70; // suggest offload above this +const MAX_RESULT_LENGTH = 12000; // truncate sub-agent results + +// ── Types ────────────────────────────────────────────────────────────────── + +type AgentMode = "interactive" | "delegator" | "hybrid"; +type SubStatus = "queued" | "running" | "done" | "error" | "killed"; + +interface SubAgent { + id: number; + status: SubStatus; + task: string; + role: string; // "general", "scout", "builder", etc. + tools: string; // comma-separated tool list + textChunks: string[]; + toolCount: number; + elapsed: number; + startTime: number; + sessionFile: string; + turnCount: number; + proc?: ChildProcess; + timer?: ReturnType; + exitCode?: number; + // Context tracking + inputTokens: number; + outputTokens: number; + cost: number; +} + +interface AgentDef { + name: string; + description: string; + tools: string; + systemPrompt: string; +} + +// ── Agent Definitions (built-in roles) ───────────────────────────────────── + +const BUILTIN_ROLES: AgentDef[] = [ + { + 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 in your output.", + }, + { + name: "scout", + description: "Fast recon and codebase exploration (read-only)", + tools: "read,grep,find,ls", + systemPrompt: "You are a scout agent. Investigate the codebase quickly and report findings concisely. Do NOT modify any files. Focus on structure, patterns, and key entry points.", + }, + { + name: "builder", + description: "Implementation and code generation", + tools: "read,write,edit,bash,grep,find,ls", + systemPrompt: "You are a builder agent. Implement the requested changes thoroughly. Write clean, minimal code. Follow existing patterns in the codebase. Test your work when possible.", + }, + { + name: "reviewer", + description: "Code review and quality analysis (read-only)", + tools: "read,grep,find,ls", + systemPrompt: "You are a reviewer agent. Analyze code for bugs, security issues, performance problems, and style inconsistencies. Be specific about line numbers and suggest fixes.", + }, + { + name: "tester", + description: "Test writing and execution", + tools: "read,write,edit,bash,grep,find,ls", + systemPrompt: "You are a testing agent. Write and run tests for the specified code. Cover edge cases. Use the project's existing test framework and patterns.", + }, +]; + +// ── Auto-delegation patterns ─────────────────────────────────────────────── + +const OFFLOAD_PATTERNS = [ + /\b(across|entire|whole|every|all)\s+(files?|codebase|project|modules?|components?)\b/i, + /\b(refactor|migrate|rewrite|overhaul)\s+(the\s+)?(entire|whole|full)\b/i, + /\b(search|find|grep)\s+(and\s+)?(replace|update|change)\s+(across|in all|everywhere)\b/i, + /\b(background|async|offload|delegate|spawn)\b/i, + /\bstep\s+[5-9]\b/i, // multi-step plan indicator + /\b(in parallel|simultaneously|concurrently)\b/i, +]; + +function shouldSuggestOffload(prompt: string): boolean { + return OFFLOAD_PATTERNS.some(p => p.test(prompt)); +} + +// ── Extension ────────────────────────────────────────────────────────────── + +export default function hyperloop(pi: ExtensionAPI) { + const agents: Map = new Map(); + const activeProcesses: Set = new Set(); + let nextId = 1; + let widgetCtx: any = null; + let mode: AgentMode = "hybrid"; + let autoDelegate = true; + let sessionDir = ""; + let customRoles: AgentDef[] = []; + + // ── Widget Throttle ──────────────────────────────────────────────────── + + let widgetDirty = false; + let widgetTimer: ReturnType | null = null; + + function scheduleWidget() { + widgetDirty = true; + if (widgetTimer) return; + widgetTimer = setTimeout(() => { + widgetTimer = null; + if (widgetDirty) { + widgetDirty = false; + renderWidget(); + } + }, WIDGET_THROTTLE_MS); + } + + function flushWidget() { + if (widgetTimer) { + clearTimeout(widgetTimer); + widgetTimer = null; + } + widgetDirty = false; + renderWidget(); + } + + // ── Load custom roles from .pi/agents/ ───────────────────────────────── + + function loadCustomRoles(cwd: string) { + const dirs = [ + join(cwd, ".pi", "agents"), + join(cwd, "agents"), + ]; + customRoles = []; + const seen = new Set(BUILTIN_ROLES.map(r => r.name)); + + for (const dir of dirs) { + 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 match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); + if (!match) continue; + const fm: Record = {}; + for (const line of match[1].split("\n")) { + const idx = line.indexOf(":"); + if (idx > 0) fm[line.slice(0, idx).trim()] = line.slice(idx + 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: match[2].trim(), + }); + } + } catch {} + } + } + + function getAllRoles(): AgentDef[] { + return [...BUILTIN_ROLES, ...customRoles]; + } + + function findRole(name: string): AgentDef { + const all = getAllRoles(); + return all.find(r => r.name.toLowerCase() === name.toLowerCase()) || BUILTIN_ROLES[0]; + } + + // ── Session File Management ──────────────────────────────────────────── + + function makeSessionFile(id: number): string { + if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true }); + return join(sessionDir, `hyperloop-${id}-${Date.now()}.jsonl`); + } + + // ── Widget Rendering ─────────────────────────────────────────────────── + + function renderWidget() { + if (!widgetCtx) return; + + if (agents.size === 0) { + widgetCtx.ui.setWidget("hyperloop", undefined); + return; + } + + widgetCtx.ui.setWidget("hyperloop", (_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 modeLabel = mode === "hybrid" ? "⚡ hybrid" + : mode === "delegator" ? "📡 delegator" : "💬 interactive"; + + lines.push( + theme.fg("dim", "─".repeat(width)) + ); + lines.push( + theme.fg("accent", " ◉ Hyperloop") + + theme.fg("dim", ` [${modeLabel}]`) + + theme.fg("dim", ` · ${agents.size} agent${agents.size !== 1 ? "s" : ""}`) + + (autoDelegate ? theme.fg("success", " · auto") : theme.fg("dim", " · manual")) + ); + + for (const [, agent] of agents) { + const statusIcon = agent.status === "queued" ? "◌" + : agent.status === "running" ? "●" + : agent.status === "done" ? "✓" + : agent.status === "killed" ? "⊘" : "✗"; + const statusColor = agent.status === "running" ? "accent" + : agent.status === "done" ? "success" + : agent.status === "queued" ? "dim" : "error"; + + const elapsed = agent.status === "running" + ? Math.round((Date.now() - agent.startTime) / 1000) + : Math.round(agent.elapsed / 1000); + + const taskPreview = agent.task.length > 50 + ? agent.task.slice(0, 47) + "..." + : agent.task; + + const turnLabel = agent.turnCount > 1 ? ` t${agent.turnCount}` : ""; + + // Main status line + lines.push( + theme.fg(statusColor, ` ${statusIcon} #${agent.id}`) + + theme.fg("dim", ` [${agent.role}${turnLabel}]`) + + theme.fg("dim", ` ${elapsed}s`) + + theme.fg("dim", ` · ${agent.toolCount} tools`) + + (agent.cost > 0 ? theme.fg("warning", ` · $${agent.cost.toFixed(4)}`) : "") + ); + + // Task description + lines.push( + theme.fg("muted", ` ${taskPreview}`) + ); + + // Live output (last line) for running agents + if (agent.status === "running" && agent.textChunks.length > 0) { + const fullText = agent.textChunks.join(""); + const lastLine = fullText.split("\n").filter((l: string) => l.trim()).pop() || ""; + if (lastLine) { + const trimmed = lastLine.length > width - 6 + ? lastLine.slice(0, width - 9) + "..." + : lastLine; + lines.push(theme.fg("dim", ` → ${trimmed}`)); + } + } + } + + lines.push(theme.fg("dim", "─".repeat(width))); + + content.setText(lines.join("\n")); + return container.render(width); + }, + invalidate() { container.invalidate(); }, + }; + }); + } + + // ── Process JSON Stream from Sub-Agent ───────────────────────────────── + + function processLine(agent: SubAgent, line: string) { + if (!line.trim()) return; + try { + const event = JSON.parse(line); + if (event.type === "message_update") { + const delta = event.assistantMessageEvent; + if (delta?.type === "text_delta") { + agent.textChunks.push(delta.delta || ""); + scheduleWidget(); + } + } else if (event.type === "tool_execution_start") { + agent.toolCount++; + scheduleWidget(); + } else if (event.type === "message_end" || event.type === "agent_end") { + const msg = event.message || (event.messages || []).reverse().find((m: any) => m.role === "assistant"); + if (msg?.usage) { + agent.inputTokens = msg.usage.input || 0; + agent.outputTokens = msg.usage.output || 0; + agent.cost = msg.usage.cost?.total || 0; + scheduleWidget(); + } + } + } catch {} + } + + // ── Spawn Sub-Agent Process ──────────────────────────────────────────── + + function spawnSubAgent( + agent: SubAgent, + prompt: string, + ctx: ExtensionContext, + isContinuation: boolean, + ): Promise<{ output: string; exitCode: number }> { + agent.status = "running"; + agent.startTime = Date.now(); + scheduleWidget(); + + const role = findRole(agent.role); + + // Use a cheaper model for sub-agents to save cost + const model = ctx.model + ? `${ctx.model.provider}/${ctx.model.id}` + : "anthropic/claude-sonnet-4-6"; + + const args = [ + "--mode", "json", + "-p", + "--no-extensions", + "--no-skills", + "--model", model, + "--tools", role.tools, + "--thinking", "low", + "--append-system-prompt", role.systemPrompt, + "--session", agent.sessionFile, + ]; + + if (isContinuation) { + args.push("-c"); + } + + args.push(prompt); + + return new Promise((resolve) => { + let resolved = false; + const safeResolve = (val: { output: string; exitCode: number }) => { + if (resolved) return; + resolved = true; + resolve(val); + }; + + const proc = cpSpawn("pi", args, { + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env }, + }); + + agent.proc = proc; + activeProcesses.add(proc); + + agent.timer = setInterval(() => { + agent.elapsed = Date.now() - agent.startTime; + scheduleWidget(); + }, 1000); + + const timeout = setTimeout(() => { + try { proc.kill("SIGTERM"); } catch {} + setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000); + }, AGENT_TIMEOUT_MS); + + let buffer = ""; + + proc.stdout!.setEncoding("utf-8"); + proc.stdout!.on("data", (chunk: string) => { + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) processLine(agent, line); + }); + + proc.stderr!.setEncoding("utf-8"); + proc.stderr!.on("data", () => {}); + + proc.on("close", (code) => { + clearTimeout(timeout); + activeProcesses.delete(proc); + agent.proc = undefined; + if (agent.timer) clearInterval(agent.timer); + agent.elapsed = Date.now() - agent.startTime; + agent.exitCode = code ?? 1; + + if (buffer.trim()) processLine(agent, buffer); + + const timedOut = agent.elapsed >= AGENT_TIMEOUT_MS; + agent.status = timedOut ? "error" : (code === 0 ? "done" : "error"); + flushWidget(); + + const output = agent.textChunks.join(""); + safeResolve({ output, exitCode: code ?? 1 }); + }); + + proc.on("error", (err) => { + clearTimeout(timeout); + activeProcesses.delete(proc); + agent.proc = undefined; + if (agent.timer) clearInterval(agent.timer); + agent.status = "error"; + flushWidget(); + safeResolve({ output: `Error: ${err.message}`, exitCode: 1 }); + }); + }); + } + + // ── Fire-and-forget spawn (delivers result as follow-up) ─────────────── + + function spawnAndDeliver(agent: SubAgent, prompt: string, ctx: ExtensionContext, isContinuation: boolean) { + spawnSubAgent(agent, prompt, ctx, isContinuation).then(({ output, exitCode }) => { + const status = exitCode === 0 ? "✓ done" : "✗ error"; + const elapsed = Math.round(agent.elapsed / 1000); + const turnLabel = agent.turnCount > 1 ? ` (Turn ${agent.turnCount})` : ""; + + const truncated = output.length > MAX_RESULT_LENGTH + ? output.slice(0, MAX_RESULT_LENGTH) + "\n\n... [truncated — full output in sub-agent session]" + : output; + + ctx.ui.notify( + `Sub-agent #${agent.id} [${agent.role}] ${status} in ${elapsed}s`, + exitCode === 0 ? "info" : "error", + ); + + pi.sendMessage({ + customType: "hyperloop-result", + content: `**Sub-agent #${agent.id}** [${agent.role}]${turnLabel} — ${status} in ${elapsed}s | ${agent.toolCount} tool calls | $${agent.cost.toFixed(4)}\n\n${truncated}`, + display: "assistant", + }, { deliverAs: "followUp", triggerTurn: true }); + }); + } + + // ── LLM Tools ────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "hyperloop_spawn", + label: "Spawn Sub-Agent", + description: `Spawn a background sub-agent to execute a task without consuming main chat context. The sub-agent runs asynchronously — you can continue chatting while it works. Results are delivered automatically when finished. + +Available roles: ${BUILTIN_ROLES.map(r => `${r.name} (${r.description})`).join(", ")}. Custom roles from .pi/agents/*.md are also available. + +Use this for: +- Complex multi-file operations that would flood the context +- Long-running tasks (tests, builds, migrations) +- Parallel independent tasks (spawn multiple) +- When context usage is high and you need to preserve it`, + promptGuidelines: [ + "Spawn sub-agents for heavy/multi-file work to preserve main context", + "Use role='scout' for exploration, 'builder' for implementation, 'reviewer' for code review, 'tester' for tests", + "You can spawn multiple sub-agents in parallel for independent tasks", + "Sub-agent results are delivered automatically — continue chatting while they work", + ], + parameters: Type.Object({ + task: Type.String({ description: "Complete task description. Be specific — the sub-agent has no context from this conversation." }), + role: Type.Optional(Type.String({ description: "Agent role: general, scout, builder, reviewer, tester, or custom role name. Default: general" })), + }), + + async execute(_callId, params, _signal, onUpdate, ctx) { + widgetCtx = ctx; + const task = params.task; + const role = params.role || "general"; + + const id = nextId++; + const agent: SubAgent = { + id, + status: "queued", + task, + role, + tools: findRole(role).tools, + textChunks: [], + toolCount: 0, + elapsed: 0, + startTime: Date.now(), + sessionFile: makeSessionFile(id), + turnCount: 1, + inputTokens: 0, + outputTokens: 0, + cost: 0, + }; + agents.set(id, agent); + + if (onUpdate) { + onUpdate({ + content: [{ type: "text", text: `Spawning sub-agent #${id} [${role}]...` }], + details: { id, role, task, status: "spawning" }, + }); + } + + // Fire and forget — result delivered via sendMessage + spawnAndDeliver(agent, task, ctx, false); + + return { + content: [{ type: "text", text: `Sub-agent #${id} [${role}] spawned and running in background. You'll receive results automatically when it finishes. Continue chatting normally.` }], + details: { id, role, task, status: "running" }, + }; + }, + + renderCall(args, theme) { + const role = (args as any).role || "general"; + const task = (args as any).task || ""; + const preview = task.length > 55 ? task.slice(0, 52) + "..." : task; + return new Text( + theme.fg("toolTitle", theme.bold("hyperloop_spawn ")) + + theme.fg("accent", `[${role}] `) + + theme.fg("muted", preview), + 0, 0, + ); + }, + + renderResult(result, options, theme) { + const d = result.details as any; + if (!d) return undefined; + if (options.isPartial || d.status === "spawning") { + return new Text(theme.fg("accent", `● #${d.id} [${d.role}]`) + theme.fg("dim", " spawning..."), 0, 0); + } + return new Text( + theme.fg("success", `◉ #${d.id}`) + + theme.fg("dim", ` [${d.role}] running in background`), + 0, 0, + ); + }, + }); + + pi.registerTool({ + name: "hyperloop_continue", + label: "Continue Sub-Agent", + description: "Continue a finished sub-agent's conversation with follow-up instructions. The sub-agent retains its full conversation history.", + parameters: Type.Object({ + id: Type.Number({ description: "Sub-agent ID to continue" }), + prompt: Type.String({ description: "Follow-up instructions" }), + }), + + async execute(_callId, params, _signal, _onUpdate, ctx) { + widgetCtx = ctx; + const agent = agents.get(params.id); + if (!agent) { + return { content: [{ type: "text", text: `No sub-agent #${params.id} found.` }] }; + } + if (agent.status === "running") { + return { content: [{ type: "text", text: `Sub-agent #${params.id} is still running. Wait for it to finish.` }] }; + } + + agent.textChunks = []; + agent.toolCount = 0; + agent.elapsed = 0; + agent.turnCount++; + + spawnAndDeliver(agent, params.prompt, ctx, true); + + return { + content: [{ type: "text", text: `Sub-agent #${params.id} continuing (Turn ${agent.turnCount}). Results will be delivered when ready.` }], + }; + }, + }); + + pi.registerTool({ + name: "hyperloop_status", + label: "Sub-Agent Status", + description: "Check status of all sub-agents.", + parameters: Type.Object({}), + + async execute() { + if (agents.size === 0) { + return { content: [{ type: "text", text: "No sub-agents." }] }; + } + + const lines: string[] = []; + for (const [, a] of agents) { + const elapsed = a.status === "running" + ? Math.round((Date.now() - a.startTime) / 1000) + : Math.round(a.elapsed / 1000); + const turnLabel = a.turnCount > 1 ? ` (Turn ${a.turnCount})` : ""; + lines.push( + `#${a.id} [${a.role}${turnLabel}] ${a.status.toUpperCase()} — ${elapsed}s, ${a.toolCount} tools, $${a.cost.toFixed(4)}`, + ` Task: ${a.task}`, + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + }); + + pi.registerTool({ + name: "hyperloop_kill", + label: "Kill Sub-Agent", + description: "Kill a running sub-agent or remove a finished one.", + parameters: Type.Object({ + id: Type.Number({ description: "Sub-agent ID" }), + }), + + async execute(_callId, params) { + const agent = agents.get(params.id); + if (!agent) { + return { content: [{ type: "text", text: `No sub-agent #${params.id} found.` }] }; + } + + if (agent.proc && agent.status === "running") { + try { agent.proc.kill("SIGTERM"); } catch {} + agent.status = "killed"; + } + if (agent.timer) clearInterval(agent.timer); + + if (widgetCtx) widgetCtx.ui.setWidget(`sub-${params.id}`, undefined); + agents.delete(params.id); + flushWidget(); + + return { content: [{ type: "text", text: `Sub-agent #${params.id} removed.` }] }; + }, + }); + + pi.registerTool({ + name: "hyperloop_collect", + label: "Collect Results", + description: "Collect and summarize results from all finished sub-agents. Useful after spawning multiple parallel agents.", + parameters: Type.Object({}), + + async execute() { + const finished = Array.from(agents.values()).filter(a => a.status === "done" || a.status === "error"); + if (finished.length === 0) { + const running = Array.from(agents.values()).filter(a => a.status === "running"); + if (running.length > 0) { + return { content: [{ type: "text", text: `${running.length} sub-agent(s) still running. Wait for them to finish.` }] }; + } + return { content: [{ type: "text", text: "No sub-agent results to collect." }] }; + } + + const parts: string[] = []; + let totalCost = 0; + for (const a of finished) { + const output = a.textChunks.join(""); + const truncated = output.length > 6000 + ? output.slice(0, 6000) + "\n... [truncated]" + : output; + const status = a.status === "done" ? "✓" : "✗"; + parts.push(`## ${status} Sub-agent #${a.id} [${a.role}] — ${Math.round(a.elapsed / 1000)}s\nTask: ${a.task}\n\n${truncated}`); + totalCost += a.cost; + } + + return { + content: [{ + type: "text", + text: `Collected ${finished.length} results (total cost: $${totalCost.toFixed(4)})\n\n${parts.join("\n\n---\n\n")}`, + }], + }; + }, + }); + + // ── Auto-Delegation (hybrid mode) ────────────────────────────────────── + + pi.on("before_agent_start", async (event, ctx) => { + if (!autoDelegate || mode === "interactive") return; + + // Check context pressure + const usage = ctx.getContextUsage(); + if (usage && usage.percent && usage.percent > CONTEXT_WARN_PCT) { + ctx.ui.setStatus("hyperloop-warn", `⚠️ Context ${Math.round(usage.percent)}% — consider offloading heavy tasks`); + } else { + ctx.ui.setStatus("hyperloop-warn", undefined); + } + + // In hybrid mode, nudge the system prompt to encourage delegation + if (mode === "hybrid" || mode === "delegator") { + const shouldOffload = shouldSuggestOffload(event.prompt); + const contextHigh = usage && usage.percent && usage.percent > CONTEXT_WARN_PCT; + + if (shouldOffload || contextHigh) { + const roles = getAllRoles().map(r => `${r.name}: ${r.description}`).join("\n"); + return { + systemPrompt: event.systemPrompt + `\n\n## Hyperloop Sub-Agent System +You have access to a sub-agent system (hyperloop_spawn, hyperloop_continue, hyperloop_status, hyperloop_kill, hyperloop_collect). + +**The current task appears complex or context-heavy.** Consider spawning sub-agents for: +- Multi-file operations (use role="builder") +- Codebase exploration (use role="scout") +- Code review (use role="reviewer") +- Test writing (use role="tester") +- Any task that would generate lots of context + +${contextHigh ? `⚠️ CONTEXT IS ${Math.round(usage!.percent!)}% FULL — strongly prefer sub-agents for any significant work.\n` : ""} +Available roles:\n${roles} + +Sub-agents run in background. You can spawn multiple in parallel. Results auto-deliver as follow-up messages. Keep your main responses concise.`, + }; + } + } + }); + + // ── /hl Command ──────────────────────────────────────────────────────── + + pi.registerCommand("hl", { + description: "Hyperloop controls: /hl [spawn|kill|clear|auto|mode]", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const items = [ + { value: "spawn ", label: "spawn — Spawn a sub-agent" }, + { value: "kill ", label: "kill — Kill a sub-agent" }, + { value: "clear", label: "clear — Remove all sub-agents" }, + { value: "auto on", label: "auto on — Enable auto-delegation" }, + { value: "auto off", label: "auto off — Disable auto-delegation" }, + { value: "mode interactive", label: "mode interactive — Manual only" }, + { value: "mode hybrid", label: "mode hybrid — Smart auto-offload" }, + { value: "mode delegator", label: "mode delegator — Prefer delegation" }, + ]; + 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 || sub === "status") { + // Show dashboard + const modeLabel = mode === "hybrid" ? "⚡ hybrid" : mode === "delegator" ? "📡 delegator" : "💬 interactive"; + const autoLabel = autoDelegate ? "on" : "off"; + const agentList = agents.size === 0 + ? " No sub-agents" + : Array.from(agents.values()).map(a => { + const elapsed = a.status === "running" + ? Math.round((Date.now() - a.startTime) / 1000) + : Math.round(a.elapsed / 1000); + return ` #${a.id} [${a.role}] ${a.status} — ${elapsed}s, ${a.toolCount} tools, $${a.cost.toFixed(4)}\n ${a.task}`; + }).join("\n"); + + const usage = ctx.getContextUsage(); + const pct = usage?.percent ? Math.round(usage.percent) : "?"; + + pi.sendMessage({ + customType: "hyperloop-dashboard", + content: `\n◉ Hyperloop Dashboard\n Mode: ${modeLabel} | Auto: ${autoLabel} | Context: ${pct}%\n Agents: ${agents.size}\n\n${agentList}\n\n Commands: /hl spawn|kill|clear|auto|mode`, + display: "assistant", + }); + return; + } + + if (sub === "spawn") { + const task = parts.slice(1).join(" ").trim(); + if (!task) { + ctx.ui.notify("Usage: /hl spawn ", "error"); + return; + } + + const id = nextId++; + const agent: SubAgent = { + id, + status: "queued", + task, + role: "general", + tools: BUILTIN_ROLES[0].tools, + textChunks: [], + toolCount: 0, + elapsed: 0, + startTime: Date.now(), + sessionFile: makeSessionFile(id), + turnCount: 1, + inputTokens: 0, + outputTokens: 0, + cost: 0, + }; + agents.set(id, agent); + spawnAndDeliver(agent, task, ctx, false); + ctx.ui.notify(`Sub-agent #${id} spawned`, "info"); + return; + } + + if (sub === "kill") { + const id = parseInt(parts[1], 10); + if (isNaN(id)) { + ctx.ui.notify("Usage: /hl kill ", "error"); + return; + } + const agent = agents.get(id); + if (!agent) { + ctx.ui.notify(`No sub-agent #${id}`, "error"); + return; + } + if (agent.proc) try { agent.proc.kill("SIGTERM"); } catch {} + if (agent.timer) clearInterval(agent.timer); + agents.delete(id); + flushWidget(); + ctx.ui.notify(`Sub-agent #${id} killed`, "info"); + return; + } + + 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("All sub-agents cleared", "info"); + return; + } + + if (sub === "auto") { + const val = parts[1]?.toLowerCase(); + if (val === "on") { autoDelegate = true; ctx.ui.notify("Auto-delegation ON", "info"); } + else if (val === "off") { autoDelegate = false; ctx.ui.notify("Auto-delegation OFF", "info"); } + else ctx.ui.notify("Usage: /hl auto on|off", "error"); + return; + } + + if (sub === "mode") { + const val = parts[1]?.toLowerCase() as AgentMode; + if (["interactive", "delegator", "hybrid"].includes(val)) { + mode = val; + ctx.ui.notify(`Mode: ${val}`, "info"); + flushWidget(); + } else { + ctx.ui.notify("Usage: /hl mode interactive|hybrid|delegator", "error"); + } + return; + } + + ctx.ui.notify("Unknown command. Try: /hl spawn|kill|clear|auto|mode", "error"); + }, + }); + + // ── Session Lifecycle ────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + widgetCtx = ctx; + sessionDir = join(ctx.cwd, ".pi", "agent-sessions", "hyperloop"); + + loadCustomRoles(ctx.cwd); + + // Clean up any previous agents + for (const [, a] of agents) { + if (a.proc) try { a.proc.kill("SIGTERM"); } catch {} + if (a.timer) clearInterval(a.timer); + } + agents.clear(); + nextId = 1; + + // Footer + ctx.ui.setFooter((_tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => _tui.requestRender()); + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + const model = ctx.model?.id || "no-model"; + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const filled = Math.round(pct / 10) || 1; + const bar = "#".repeat(filled) + "-".repeat(10 - filled); + + const running = Array.from(agents.values()).filter(a => a.status === "running").length; + const total = agents.size; + const modeIcon = mode === "hybrid" ? "⚡" : mode === "delegator" ? "📡" : "💬"; + + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + // Add sub-agent costs + for (const [, a] of agents) cost += a.cost; + + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + + const l1Left = + theme.fg("dim", ` ${model} `) + + 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 l1Right = + theme.fg("success", `${fmt(tokIn)}`) + + theme.fg("dim", " in ") + + theme.fg("accent", `${fmt(tokOut)}`) + + theme.fg("dim", " out ") + + theme.fg("warning", `$${cost.toFixed(4)}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + const dir = basename(ctx.cwd); + const branch = footerData.getGitBranch(); + const l2Left = + theme.fg("dim", ` ${dir}`) + + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : "") + + theme.fg("dim", ` · ${modeIcon} ${mode}`); + + const agentStatus = total > 0 + ? (running > 0 + ? theme.fg("accent", `● ${running} running`) + theme.fg("dim", ` / ${total} total `) + : theme.fg("dim", `${total} agents `)) + : theme.fg("dim", "no agents "); + + const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(agentStatus))); + const line2 = truncateToWidth(l2Left + pad2 + agentStatus, width, ""); + + return [line1, line2]; + }, + }; + }); + + const roles = getAllRoles().map(r => r.name).join(", "); + ctx.ui.notify( + `◉ Hyperloop active [${mode} mode]\n` + + `Roles: ${roles}\n` + + `/hl — Dashboard & controls`, + "info", + ); + }); + + // ── Cleanup ──────────────────────────────────────────────────────────── + + function killAll() { + for (const proc of activeProcesses) { + try { proc.kill("SIGTERM"); } catch {} + } + setTimeout(() => { + for (const proc of activeProcesses) { + try { proc.kill("SIGKILL"); } catch {} + } + }, 3000); + } + + pi.on("session_shutdown", async () => { killAll(); }); + process.on("exit", killAll); + process.on("SIGINT", () => { killAll(); process.exit(0); }); + process.on("SIGTERM", () => { killAll(); process.exit(0); }); +} diff --git a/extensions/model-router.ts b/extensions/model-router.ts new file mode 100644 index 0000000..0e5361b --- /dev/null +++ b/extensions/model-router.ts @@ -0,0 +1,361 @@ +/** + * Model Router — LLM-powered automatic model selection + * + * Uses Haiku (~200ms, ~$0.001/call) to classify every prompt's complexity, + * then routes to the optimal model + thinking level. No regex guessing. + * + * Tiers: + * ⚡ Haiku — Simple Q&A, file reads, small edits, quick lookups + * ⚖️ Sonnet — Code generation, debugging, refactoring, multi-file work + * 🧠 Opus — Architecture, complex reasoning, large-scale changes + * + * Mid-turn escalation: + * - 4+ edits/writes → escalate to Opus + * - 2+ consecutive errors → escalate (model struggling) + * - 6+ tool calls in one turn → escalate to at least Sonnet + * + * Usage: pi -e extensions/model-router.ts + * + * Commands: + * /router — Show routing state & stats + * /router lock — Lock current model (disable auto-routing) + * /router unlock — Re-enable auto-routing + * /router tier — Force tier: 1=haiku, 2=sonnet, 3=opus + */ + +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { complete } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { basename } from "node:path"; +import { applyExtensionDefaults } from "./themeMap.ts"; + +// ── Model Tiers ──────────────────────────────────────────────────────────── + +interface ModelTier { + name: string; + icon: string; + provider: string; + modelId: string; + thinking: "off" | "low" | "medium" | "high"; + inputCost: number; + outputCost: number; +} + +const TIERS: ModelTier[] = [ + { 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 }, +]; + +// ── LLM Classifier ──────────────────────────────────────────────────────── + +const CLASSIFIER_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>, "reason": "<5 words max>"} + +TIER 1 (simple): Quick questions, yes/no, read/list/check files, small edits, conversational ("thanks", "ok"), simple lookups. +TIER 2 (medium): Write functions/classes, fix bugs, refactor single files, write tests, create endpoints, docker/CI config. +TIER 3 (complex): Architecture design, multi-file refactoring/migration, security audits, performance optimization, complex algorithms, system design from scratch.`; + +interface Classification { tier: number; reason: string } + +const cache = new Map(); +const CACHE_TTL = 60_000; + +async function classify(prompt: string, ctx: ExtensionContext): Promise { + const key = prompt.trim().toLowerCase().slice(0, 200); + const c = cache.get(key); + if (c && Date.now() - c.ts < CACHE_TTL) return c.result; + + const model = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!model) return { tier: 2, reason: "no classifier" }; + const apiKey = await ctx.modelRegistry.getApiKey(model); + if (!apiKey) return { tier: 2, reason: "no key" }; + + try { + const resp = await complete(model, { + systemPrompt: CLASSIFIER_PROMPT, + messages: [{ role: "user" as const, content: prompt, 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 json = text.replace(/```json?\n?/g, "").replace(/```/g, "").trim(); + const parsed = JSON.parse(json); + const result: Classification = { + tier: Math.max(1, Math.min(3, parsed.tier || 2)), + reason: String(parsed.reason || "classified").slice(0, 50), + }; + + cache.set(key, { result, ts: Date.now() }); + return result; + } catch { + return { tier: 2, reason: "classify failed" }; + } +} + +// ── Router State ─────────────────────────────────────────────────────────── + +interface RouterState { + currentTier: number; + locked: boolean; + totalSwitches: number; + turnsSinceLast: number; + consecutiveErrors: number; + toolCallsThisTurn: number; + editWriteThisTurn: number; + totalToolCalls: number; + totalErrors: number; + tierHistory: Array<{ tier: number; reason: string; turn: number }>; + turnCount: number; + savedVsAlwaysOpus: number; + classifierCost: number; +} + +// ── Extension ────────────────────────────────────────────────────────────── + +export default function modelRouter(pi: ExtensionAPI) { + const state: RouterState = { + currentTier: 1, + locked: false, + totalSwitches: 0, + turnsSinceLast: 0, + consecutiveErrors: 0, + toolCallsThisTurn: 0, + editWriteThisTurn: 0, + totalToolCalls: 0, + totalErrors: 0, + tierHistory: [], + turnCount: 0, + savedVsAlwaysOpus: 0, + classifierCost: 0, + }; + + async function switchToTier(tierIndex: number, reason: string, ctx?: ExtensionContext) { + const clamped = Math.max(0, Math.min(2, tierIndex)); + if (clamped === state.currentTier) return; + const tier = TIERS[clamped]; + const model = ctx?.modelRegistry.find(tier.provider, tier.modelId); + if (!model) return; + const success = await pi.setModel(model); + if (!success) return; + pi.setThinkingLevel(tier.thinking); + const prev = state.currentTier; + state.currentTier = clamped; + state.totalSwitches++; + state.turnsSinceLast = 0; + state.tierHistory.push({ + tier: clamped, + reason: `${TIERS[prev].icon}→${tier.icon} ${reason}`, + turn: state.turnCount, + }); + } + + // ── LLM Classification on every prompt ───────────────────────────────── + + pi.on("before_agent_start", async (event, ctx) => { + if (state.locked) return; + + ctx.ui.setWorkingMessage("classifying..."); + const result = await classify(event.prompt, ctx); + ctx.ui.setWorkingMessage(); + + // Track classifier cost (~180 tokens per call at Haiku rates) + state.classifierCost += (150 * 0.80 + 30 * 4.00) / 1_000_000; + + let target = result.tier - 1; // 1-3 → 0-2 + + // Context-aware overrides + if (target === 0 && state.totalToolCalls > 10) target = 1; + if (state.consecutiveErrors >= 2 && target < 2) target = Math.min(target + 1, 2); + + if (target !== state.currentTier) { + await switchToTier(target, result.reason, ctx); + } + }); + + // ── Turn & Tool Tracking ─────────────────────────────────────────────── + + pi.on("turn_start", async () => { + state.toolCallsThisTurn = 0; + state.editWriteThisTurn = 0; + state.turnCount++; + state.turnsSinceLast++; + }); + + pi.on("tool_execution_start", async (event) => { + state.toolCallsThisTurn++; + state.totalToolCalls++; + if (event.toolName === "edit" || event.toolName === "write") state.editWriteThisTurn++; + }); + + pi.on("tool_execution_end", async (event, ctx) => { + if (event.isError) { + state.consecutiveErrors++; + state.totalErrors++; + if (state.consecutiveErrors >= 2 && !state.locked && state.currentTier < 2) { + await switchToTier(state.currentTier + 1, `${state.consecutiveErrors} errors`, ctx); + } + } else { + state.consecutiveErrors = 0; + } + }); + + pi.on("turn_end", async (_event, ctx) => { + if (!state.locked) { + if (state.editWriteThisTurn >= 4 && state.currentTier < 2) { + await switchToTier(2, `heavy: ${state.editWriteThisTurn} edits`, ctx); + } else if (state.toolCallsThisTurn >= 6 && state.currentTier < 1) { + await switchToTier(1, `busy: ${state.toolCallsThisTurn} tools`, ctx); + } + } + const opusCost = (2000 * TIERS[2].inputCost + 1000 * TIERS[2].outputCost) / 1e6; + const actualCost = (2000 * TIERS[state.currentTier].inputCost + 1000 * TIERS[state.currentTier].outputCost) / 1e6; + state.savedVsAlwaysOpus += opusCost - actualCost; + }); + + // ── Session Start ────────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + + const initialTier = TIERS[1]; + const model = ctx.modelRegistry.find(initialTier.provider, initialTier.modelId); + if (model) { + await pi.setModel(model); + pi.setThinkingLevel(initialTier.thinking); + } + + ctx.ui.setFooter((tui, theme, footerData) => { + const unsub = footerData.onBranchChange(() => tui.requestRender()); + return { + dispose: unsub, + invalidate() {}, + render(width: number): string[] { + const tier = TIERS[state.currentTier]; + const lockIcon = state.locked ? " 🔒" : ""; + + let tokIn = 0, tokOut = 0, cost = 0; + for (const entry of ctx.sessionManager.getBranch()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + cost += state.classifierCost; + const fmt = (n: number) => n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`; + const usage = ctx.getContextUsage(); + const pct = usage?.percent ?? 0; + const filled = Math.round(pct / 10) || 1; + + const l1Left = + theme.fg("dim", " ") + + theme.fg("accent", `${tier.icon} ${tier.name}`) + + theme.fg("dim", ` [${tier.thinking}]${lockIcon} `) + + 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 l1Right = + theme.fg("success", `${fmt(tokIn)}`) + + theme.fg("dim", " in ") + + theme.fg("accent", `${fmt(tokOut)}`) + + theme.fg("dim", " out ") + + theme.fg("warning", `$${cost.toFixed(4)}`) + + theme.fg("dim", " "); + + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(l1Left) - visibleWidth(l1Right))); + const line1 = truncateToWidth(l1Left + pad1 + l1Right, width, ""); + + const dir = basename(ctx.cwd); + const branch = footerData.getGitBranch(); + const lastReason = state.tierHistory.length > 0 + ? state.tierHistory[state.tierHistory.length - 1].reason + : "start: balanced"; + + const l2Left = + theme.fg("dim", ` ${dir}`) + + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : "") + + theme.fg("dim", " · ") + + theme.fg("accent", `${state.totalSwitches}`) + + theme.fg("dim", " switches") + + (state.savedVsAlwaysOpus > 0 ? theme.fg("dim", " · saved ") + theme.fg("success", `$${state.savedVsAlwaysOpus.toFixed(4)}`) : ""); + + const l2Right = theme.fg("dim", truncateToWidth(lastReason, Math.floor(width * 0.4), "…") + " "); + + const pad2 = " ".repeat(Math.max(1, width - visibleWidth(l2Left) - visibleWidth(l2Right))); + const line2 = truncateToWidth(l2Left + pad2 + l2Right, width, ""); + + return [line1, line2]; + }, + }; + }); + + ctx.ui.notify( + `Model Router active — LLM-powered (Haiku classifier)\n` + + `Start: ${TIERS[1].icon} ${TIERS[1].name} · Auto-routes per prompt\n` + + `/router — Status & controls`, + "info", + ); + }); + + // ── /router Command ──────────────────────────────────────────────────── + + pi.registerCommand("router", { + description: "Model router status & controls. Usage: /router [lock|unlock|tier 1|2|3]", + async handler(args: string, ctx) { + const parts = args.trim().split(/\s+/); + const sub = parts[0]?.toLowerCase(); + + if (sub === "lock") { state.locked = true; ctx.ui.notify("🔒 Locked", "info"); return; } + if (sub === "unlock") { state.locked = false; ctx.ui.notify("🔓 Unlocked", "info"); return; } + if (sub === "tier") { + const n = parseInt(parts[1], 10); + if (n >= 1 && n <= 3) { + await switchToTier(n - 1, `manual /router tier ${n}`, ctx); + ctx.ui.notify(`${TIERS[n - 1].icon} ${TIERS[n - 1].name}`, "info"); + return; + } + ctx.ui.notify("Usage: /router tier 1|2|3", "warning"); return; + } + + const tier = TIERS[state.currentTier]; + const history = state.tierHistory.length > 0 + ? state.tierHistory.slice(-5).map(h => ` Turn ${h.turn}: ${h.reason}`).join("\n") + : " (none)"; + + pi.sendMessage({ + customType: "router-status", + content: [ + ``, + ` ┌─── Model Router (LLM-powered) ───┐`, + ` │ Current: ${tier.icon} ${tier.name.padEnd(22)}│`, + ` │ Thinking: ${tier.thinking.padEnd(21)}│`, + ` │ Locked: ${(state.locked ? "yes 🔒" : "no").padEnd(23)}│`, + ` │ Switches: ${String(state.totalSwitches).padEnd(21)}│`, + ` │ Turns: ${String(state.turnCount).padEnd(23)}│`, + ` │ Tool calls: ${String(state.totalToolCalls).padEnd(18)}│`, + ` │ Errors: ${String(state.totalErrors).padEnd(22)}│`, + ` │ Saved vs Opus: $${state.savedVsAlwaysOpus.toFixed(4).padEnd(14)}│`, + ` │ Classifier cost: $${state.classifierCost.toFixed(4).padEnd(12)}│`, + ` └────────────────────────────────────┘`, + ``, + ` Recent routing:`, + history, + ``, + ` /router lock | unlock | tier 1|2|3`, + ``, + ].join("\n"), + display: "assistant", + }); + }, + }); +} diff --git a/extensions/nexus.ts b/extensions/nexus.ts new file mode 100644 index 0000000..ee4f33f --- /dev/null +++ b/extensions/nexus.ts @@ -0,0 +1,984 @@ +/** + * Nexus — The unified Pi intelligence layer + * + * 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. + * + * Model routing uses a REAL LLM call (Haiku, ~200ms) to classify task + * complexity — no brittle regex patterns. The classifier returns a tier + * (1-3) and whether to offload to a sub-agent. + * + * Features: + * ✦ LLM-powered task classification (Haiku as router brain) + * ✦ Auto model routing (Haiku → Sonnet → Opus based on classification) + * ✦ Sub-agent spawning for heavy work (preserves main context) + * ✦ Auto-delegation when classifier says to offload + * ✦ Context pressure monitoring (warns & suggests offload at 70%+) + * ✦ Mid-turn escalation on errors/heavy tool usage + * ✦ Live dashboard widget showing all agents + * ✦ Cost tracking across main + sub-agents + * ✦ Persistent sub-agent sessions for multi-turn continuations + * + * Usage: + * pi -e extensions/nexus.ts + * alias pn='pi -e extensions/nexus.ts' + * + * Commands: + * /nx — Full dashboard + * /nx spawn — Manual sub-agent spawn + * /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, getModel } 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 { spawn as cpSpawn, type ChildProcess } from "child_process"; +import { existsSync, mkdirSync, readdirSync, readFileSync } 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; + +// ══════════════════════════════════════════════════════════════════════════ +// 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 }, +]; + +// ══════════════════════════════════════════════════════════════════════════ +// 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 (extended reasoning budget — set independently of tier): +- "off" → Simple/conversational. No benefit from reasoning. +- "low" → Moderate task. Light reasoning helpful (most Tier 2). +- "medium" → Tricky bug, subtle logic, multi-step plan (upper Tier 2 / lower Tier 3). +- "high" → Architecture, security audit, complex algorithm, deep trade-offs (Tier 3). + +Rule of thumb: Tier 1 → "off". Tier 2 → "low" or "medium". Tier 3 → "medium" or "high". +Only use "high" when extended chain-of-thought would meaningfully improve correctness. + +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 + +Be concise. The "reason" should be under 10 words.`; + +interface ClassificationResult { + tier: number; + thinking: "off" | "low" | "medium" | "high"; + offload: boolean; + reason: string; +} + +// Cache recent classifications to avoid duplicate calls +const classificationCache = new Map(); +const CACHE_TTL_MS = 60_000; // 1 minute + +async function classifyWithLLM( + prompt: string, + ctx: ExtensionContext, + contextPercent: number | null, +): Promise { + // Check cache (normalize by trimming + lowercasing) + 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; + } + + // Use Haiku for classification — fast and cheap (~0.001 cents per classification) + const classifier = ctx.modelRegistry.find("anthropic", "claude-haiku-4-5"); + if (!classifier) { + // Fallback: simple heuristic if Haiku unavailable + 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" }; + } + + // Build context message with additional signals + let userMsg = prompt; + if (contextPercent !== null && contextPercent > CONTEXT_WARN_PCT) { + userMsg += `\n\n[SYSTEM NOTE: Main context is at ${Math.round(contextPercent)}% capacity. Prefer offload=true for heavy tasks.]`; + } + + try { + const response = await complete(classifier, { + systemPrompt: CLASSIFIER_SYSTEM_PROMPT, + messages: [{ + role: "user" as const, + content: userMsg, + timestamp: Date.now(), + }], + }, { + reasoning: "off", + }); + + // Extract text from response + const text = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map(c => c.text) + .join(""); + + // Parse JSON from response (handle potential markdown wrapping) + 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), + }; + + // Cache it + classificationCache.set(cacheKey, { result, timestamp: Date.now() }); + + return result; + } catch (err: any) { + // On any failure, default to Sonnet, no offload + 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; +} + +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; // Track cost of Haiku classifier calls + 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[] = []; + + // ── 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}` : ""; + + lines.push( + theme.fg(color, ` ${icon} #${a.id}`) + + theme.fg("dim", ` [${a.role}${turn}] ${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); + // Sub-agents use Sonnet for balanced cost/performance + const model = "anthropic/claude-sonnet-4-6"; + + 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 spawnAndDeliver(a: SubAgent, prompt: string, ctx: ExtensionContext, isCont: boolean) { + spawnProcess(a, prompt, ctx, isCont).then(({ output, exitCode }) => { + const status = exitCode === 0 ? "✓" : "✗"; + const elapsed = Math.round(a.elapsed / 1000); + const turn = a.turnCount > 1 ? ` (Turn ${a.turnCount})` : ""; + 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"); + + pi.sendMessage({ + customType: "nexus-result", + content: `**Sub-agent #${a.id}** [${a.role}]${turn} — ${status} ${elapsed}s | ${a.toolCount} tools | $${a.cost.toFixed(4)}\n\n${truncated}`, + display: "assistant", + }, { deliverAs: "followUp", triggerTurn: true }); + }); + } + + // ── LLM 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 has no context from this conversation" }), + 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, + }; + agents.set(id, a); + if (onUpdate) onUpdate({ content: [{ type: "text", text: `Spawning #${id} [${a.role}]...` }], details: { id, role: a.role } }); + spawnAndDeliver(a, params.task, 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.` }] }; + if (a.status === "running") return { content: [{ type: "text", text: `#${params.id} still running.` }] }; + 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}).` }] }; + }, + }); + + 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." }] }; + 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); + return `#${a.id} [${a.role}] ${a.status} — ${e}s, ${a.toolCount} tools, $${a.cost.toFixed(4)}\n ${a.task}`; + }); + return { content: [{ type: "text", text: lines.join("\n") }] }; + }, + }); + + 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}.` }] }; + 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.` }] }; + }, + }); + + pi.registerTool({ + name: "nexus_collect", + label: "Collect Results", + description: "Collect results from all finished sub-agents.", + parameters: Type.Object({}), + async execute() { + 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." }] }; + 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\n${a.task}\n\n${trunc}`; + }); + return { content: [{ type: "text", text: `${done.length} results ($${totalCost.toFixed(4)})\n\n${parts.join("\n\n---\n\n")}` }] }; + }, + }); + + // ══════════════════════════════════════════════════════════════════════ + // CORE ROUTING LOGIC — LLM-POWERED + // ══════════════════════════════════════════════════════════════════════ + + pi.on("before_agent_start", async (event, ctx) => { + widgetCtx = ctx; + const usage = ctx.getContextUsage(); + const contextPct = usage?.percent ?? null; + + // ── 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); + ctx.ui.setWorkingMessage(); // restore default + + // Track classifier cost (Haiku: ~150 input + ~30 output tokens per call) + classifierCost += (150 * 0.80 + 30 * 4.00) / 1_000_000; + } + + // ── Step 2: Model Routing ────────────────────────────────────────── + if (!tierLocked && classification) { + let target = classification.tier - 1; // Convert 1-3 to 0-2 + let thinking = classification.thinking; + + // Context-aware adjustments + // If we've been doing heavy tool work, don't drop to Haiku + if (target === 0 && totalToolCalls > 10) target = 1; + + // Error escalation override + if (consecutiveErrors >= 2 && target < 2) { + target = Math.min(target + 1, 2); + if (thinking === "off" || thinking === "low") thinking = "medium"; + } + + // Context pressure: bump tier if context is getting full + if (contextPct !== null && contextPct > 80 && target < 1) { + target = 1; // At least Sonnet when context is pressured + } + + await switchTier(target, classification.reason, ctx, thinking); + } + + // ── Step 3: Auto-Delegation ──────────────────────────────────────── + if (classification?.offload && autoDelegate && (mode === "hybrid" || mode === "delegator")) { + // Context pressure warning + 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 + `\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_status, nexus_kill, nexus_collect. +${contextWarn} +**Strongly prefer spawning a sub-agent** for this task rather than doing it inline. This preserves the main chat context for continued interaction. + +Available roles:\n${roles} + +Sub-agents run in background with their own context. You can spawn multiple for parallel work. Results auto-deliver. Keep your main response brief — explain what you're delegating and why.`, + }; + } else { + ctx.ui.setStatus("nexus-ctx", undefined); + } + }); + + // ── Turn & Tool Tracking (mid-conversation escalation) ───────────────── + + 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++; + // Mid-turn escalation on repeated errors + 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) { + // Escalate on heavy tool usage + 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); + } + + // De-escalate after quiet period (but only if classifier agrees next time) + // We just track turns since switch — the classifier handles de-escalation naturally + } + + // Cost savings estimate + 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|kill|clear|mode|auto|lock|unlock|tier]", + getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => { + const items = [ + { value: "spawn ", label: "spawn " }, + { 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); + return ` #${a.id} [${a.role}] ${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)"; + + pi.sendMessage({ + customType: "nexus-dashboard", + content: [ + ``, + `✦ Nexus Dashboard`, + ` 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)}`, + ``, + ` Sub-Agents (${agents.size}):`, + agentLines, + ``, + ` Recent Routing:`, + lastRoute, + ``, + ` /nx spawn|kill|clear|mode|auto|lock|unlock|tier`, + ``, + ].join("\n"), + display: "assistant", + }); + 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 }; + agents.set(id, a); + spawnAndDeliver(a, task, ctx, false); + ctx.ui.notify(`#${id} spawned`, "info"); + } 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|kill|clear|mode|auto|lock|unlock|tier", "error"); + } + }, + }); + + // ── Session Start ────────────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + applyExtensionDefaults(import.meta.url, ctx); + widgetCtx = ctx; + sessionDir = join(ctx.cwd, ".pi", "agent-sessions", "nexus"); + loadCustomRoles(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.message.role === "assistant") { + const m = entry.message as AssistantMsg; + tokIn += m.usage.input; + tokOut += m.usage.output; + cost += m.usage.cost.total; + } + } + // Add sub-agent costs + classifier cost + 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 l2L = theme.fg("dim", ` ${dir}`) + + (branch ? theme.fg("dim", " ") + theme.fg("warning", "(") + theme.fg("success", branch) + theme.fg("warning", ")") : "") + + theme.fg("dim", ` · ${modeIcon} ${mode}`); + 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(", "); + ctx.ui.notify( + `✦ Nexus active [${mode}] — LLM-powered routing\n` + + `Model: ${TIERS[1].icon} ${TIERS[1].name} (Haiku classifies → auto-routes)\n` + + `Roles: ${roles}\n` + + `/nx — Dashboard & controls`, + "info", + ); + }); + + // ── 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); + } + pi.on("session_shutdown", async () => killAll()); + process.on("exit", killAll); + process.on("SIGINT", () => { killAll(); process.exit(0); }); + process.on("SIGTERM", () => { killAll(); process.exit(0); }); +} diff --git a/extensions/themeMap.ts b/extensions/themeMap.ts index e2f8331..f1c7c5f 100644 --- a/extensions/themeMap.ts +++ b/extensions/themeMap.ts @@ -39,6 +39,9 @@ export const THEME_MAP: Record = { "tool-counter": "synthwave", // techy metrics "tool-counter-widget":"synthwave", // same family "easymode": "catppuccin-mocha", // soft, welcoming for beginners + "model-router": "cyberpunk", // adaptive routing, futuristic + "hyperloop": "midnight-ocean", // deep orchestration, async vibes + "nexus": "tokyo-night", // unified intelligence layer }; // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/justfile b/justfile index e225a1e..7f8b7b5 100644 --- a/justfile +++ b/justfile @@ -93,6 +93,10 @@ ext-easymode: ext-easymode-themed: pi -e extensions/easymode.ts -e extensions/theme-cycler.ts +# 21. Model Router: LLM-powered automatic model selection +ext-model-router: + pi -e extensions/model-router.ts -e extensions/theme-cycler.ts + # utils # Open pi with one or more stacked extensions in a new terminal: just open minimal tool-counter diff --git a/screenshots/cr-donation-form.png b/screenshots/cr-donation-form.png new file mode 100644 index 0000000..335070e Binary files /dev/null and b/screenshots/cr-donation-form.png differ diff --git a/screenshots/cr-form-filled.png b/screenshots/cr-form-filled.png new file mode 100644 index 0000000..0681868 Binary files /dev/null and b/screenshots/cr-form-filled.png differ diff --git a/screenshots/cr-homepage.png b/screenshots/cr-homepage.png new file mode 100644 index 0000000..5c67458 Binary files /dev/null and b/screenshots/cr-homepage.png differ diff --git a/telegram-bot/bot.err b/telegram-bot/bot.err index 41b5212..6723144 100644 --- a/telegram-bot/bot.err +++ b/telegram-bot/bot.err @@ -44,3 +44,22 @@ Network error: The operation timed out. Network error: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the sec Network error: The operation timed out. Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The socket connection was closed unexpectedly. For more information, pass `verbose: true` in the sec +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out. +Network error: The operation timed out.