/** * 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: true, }, { 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.` }], details: undefined }; } if (agent.status === "running") { return { content: [{ type: "text", text: `Sub-agent #${params.id} is still running. Wait for it to finish.` }], details: undefined }; } 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.` }], details: undefined, }; }, }); 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." }], details: undefined }; } 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") }], details: undefined }; }, }); 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.` }], details: undefined }; } 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.` }], details: undefined }; }, }); 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.` }], details: undefined }; } return { content: [{ type: "text", text: "No sub-agent results to collect." }], details: undefined }; } 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")}`, }], details: undefined, }; }, }); // ── 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: true, }); 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); }); }