984 lines
34 KiB
TypeScript
984 lines
34 KiB
TypeScript
/**
|
|
* 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 <task> — Manually spawn a sub-agent
|
|
* /hl kill <id> — Kill a running sub-agent
|
|
* /hl clear — Clear all sub-agents
|
|
* /hl auto on|off — Toggle auto-delegation suggestions
|
|
* /hl mode <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<typeof setInterval>;
|
|
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<number, SubAgent> = new Map();
|
|
const activeProcesses: Set<ChildProcess> = 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<typeof setTimeout> | 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<string, string> = {};
|
|
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 <task> — Spawn a sub-agent" },
|
|
{ value: "kill ", label: "kill <id> — 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 <task>", "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 <id>", "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); });
|
|
}
|