add nexus, hyperloop, model-router extensions and update themeMap, justfile
This commit is contained in:
@@ -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 <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); });
|
||||
}
|
||||
@@ -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 <n> — 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<string, { result: Classification; ts: number }>();
|
||||
const CACHE_TTL = 60_000;
|
||||
|
||||
async function classify(prompt: string, ctx: ExtensionContext): Promise<Classification> {
|
||||
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",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 <task> — Manual sub-agent spawn
|
||||
* /nx kill <id> — Kill sub-agent
|
||||
* /nx clear — Clear all sub-agents
|
||||
* /nx mode <m> — 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": "<off|low|medium|high>", "offload": <true|false>, "reason": "<brief 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<string, { result: ClassificationResult; timestamp: number }>();
|
||||
const CACHE_TTL_MS = 60_000; // 1 minute
|
||||
|
||||
async function classifyWithLLM(
|
||||
prompt: string,
|
||||
ctx: ExtensionContext,
|
||||
contextPercent: number | null,
|
||||
): Promise<ClassificationResult> {
|
||||
// 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<typeof setInterval>;
|
||||
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<number, SubAgent> = new Map();
|
||||
const activeProcs: Set<ChildProcess> = 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<typeof setTimeout> | 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<string, string> = {};
|
||||
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 <task>" },
|
||||
{ value: "kill ", label: "kill <id>" },
|
||||
{ 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 <task>", "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); });
|
||||
}
|
||||
@@ -39,6 +39,9 @@ export const THEME_MAP: Record<string, string> = {
|
||||
"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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 166 KiB |
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user