Files
calvana/extensions/agent-team.ts
IndyDevDan 32dfe122cb 🚀
2026-02-22 20:19:33 -06:00

735 lines
23 KiB
TypeScript

/**
* Agent Team — Dispatcher-only orchestrator with grid dashboard
*
* The primary Pi agent has NO codebase tools. It can ONLY delegate work
* to specialist agents via the `dispatch_agent` tool. Each specialist
* maintains its own Pi session for cross-invocation memory.
*
* Loads agent definitions from agents/*.md, .claude/agents/*.md, .pi/agents/*.md.
* Teams are defined in .pi/agents/teams.yaml — on boot a select dialog lets
* you pick which team to work with. Only team members are available for dispatch.
*
* Commands:
* /agents-team — switch active team
* /agents-list — list loaded agents
* /agents-grid N — set column count (default 2)
*
* Usage: pi -e extensions/agent-team.ts
*/
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { Text, type AutocompleteItem, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
import { spawn } from "child_process";
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Types ────────────────────────────────────────
interface AgentDef {
name: string;
description: string;
tools: string;
systemPrompt: string;
file: string;
}
interface AgentState {
def: AgentDef;
status: "idle" | "running" | "done" | "error";
task: string;
toolCount: number;
elapsed: number;
lastWork: string;
contextPct: number;
sessionFile: string | null;
runCount: number;
timer?: ReturnType<typeof setInterval>;
}
// ── Display Name Helper ──────────────────────────
function displayName(name: string): string {
return name.split("-").map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
}
// ── Teams YAML Parser ────────────────────────────
function parseTeamsYaml(raw: string): Record<string, string[]> {
const teams: Record<string, string[]> = {};
let current: string | null = null;
for (const line of raw.split("\n")) {
const teamMatch = line.match(/^(\S[^:]*):$/);
if (teamMatch) {
current = teamMatch[1].trim();
teams[current] = [];
continue;
}
const itemMatch = line.match(/^\s+-\s+(.+)$/);
if (itemMatch && current) {
teams[current].push(itemMatch[1].trim());
}
}
return teams;
}
// ── Frontmatter Parser ───────────────────────────
function parseAgentFile(filePath: string): AgentDef | null {
try {
const raw = readFileSync(filePath, "utf-8");
const match = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!match) return null;
const frontmatter: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx > 0) {
frontmatter[line.slice(0, idx).trim()] = line.slice(idx + 1).trim();
}
}
if (!frontmatter.name) return null;
return {
name: frontmatter.name,
description: frontmatter.description || "",
tools: frontmatter.tools || "read,grep,find,ls",
systemPrompt: match[2].trim(),
file: filePath,
};
} catch {
return null;
}
}
function scanAgentDirs(cwd: string): AgentDef[] {
const dirs = [
join(cwd, "agents"),
join(cwd, ".claude", "agents"),
join(cwd, ".pi", "agents"),
];
const agents: AgentDef[] = [];
const seen = new Set<string>();
for (const dir of dirs) {
if (!existsSync(dir)) continue;
try {
for (const file of readdirSync(dir)) {
if (!file.endsWith(".md")) continue;
const fullPath = resolve(dir, file);
const def = parseAgentFile(fullPath);
if (def && !seen.has(def.name.toLowerCase())) {
seen.add(def.name.toLowerCase());
agents.push(def);
}
}
} catch {}
}
return agents;
}
// ── Extension ────────────────────────────────────
export default function (pi: ExtensionAPI) {
const agentStates: Map<string, AgentState> = new Map();
let allAgentDefs: AgentDef[] = [];
let teams: Record<string, string[]> = {};
let activeTeamName = "";
let gridCols = 2;
let widgetCtx: any;
let sessionDir = "";
let contextWindow = 0;
function loadAgents(cwd: string) {
// Create session storage dir
sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
// Load all agent definitions
allAgentDefs = scanAgentDirs(cwd);
// Load teams from .pi/agents/teams.yaml
const teamsPath = join(cwd, ".pi", "agents", "teams.yaml");
if (existsSync(teamsPath)) {
try {
teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8"));
} catch {
teams = {};
}
} else {
teams = {};
}
// If no teams defined, create a default "all" team
if (Object.keys(teams).length === 0) {
teams = { all: allAgentDefs.map(d => d.name) };
}
}
function activateTeam(teamName: string) {
activeTeamName = teamName;
const members = teams[teamName] || [];
const defsByName = new Map(allAgentDefs.map(d => [d.name.toLowerCase(), d]));
agentStates.clear();
for (const member of members) {
const def = defsByName.get(member.toLowerCase());
if (!def) continue;
const key = def.name.toLowerCase().replace(/\s+/g, "-");
const sessionFile = join(sessionDir, `${key}.json`);
agentStates.set(def.name.toLowerCase(), {
def,
status: "idle",
task: "",
toolCount: 0,
elapsed: 0,
lastWork: "",
contextPct: 0,
sessionFile: existsSync(sessionFile) ? sessionFile : null,
runCount: 0,
});
}
// Auto-size grid columns based on team size
const size = agentStates.size;
gridCols = size <= 3 ? size : size === 4 ? 2 : 3;
}
// ── Grid Rendering ───────────────────────────
function renderCard(state: AgentState, colWidth: number, theme: any): string[] {
const w = colWidth - 2;
const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max - 3) + "..." : s;
const statusColor = state.status === "idle" ? "dim"
: state.status === "running" ? "accent"
: state.status === "done" ? "success" : "error";
const statusIcon = state.status === "idle" ? "○"
: state.status === "running" ? "●"
: state.status === "done" ? "✓" : "✗";
const name = displayName(state.def.name);
const nameStr = theme.fg("accent", theme.bold(truncate(name, w)));
const nameVisible = Math.min(name.length, w);
const statusStr = `${statusIcon} ${state.status}`;
const timeStr = state.status !== "idle" ? ` ${Math.round(state.elapsed / 1000)}s` : "";
const statusLine = theme.fg(statusColor, statusStr + timeStr);
const statusVisible = statusStr.length + timeStr.length;
// Context bar: 5 blocks + percent
const filled = Math.ceil(state.contextPct / 20);
const bar = "#".repeat(filled) + "-".repeat(5 - filled);
const ctxStr = `[${bar}] ${Math.ceil(state.contextPct)}%`;
const ctxLine = theme.fg("dim", ctxStr);
const ctxVisible = ctxStr.length;
const workRaw = state.task
? (state.lastWork || state.task)
: state.def.description;
const workText = truncate(workRaw, Math.min(50, w - 1));
const workLine = theme.fg("muted", workText);
const workVisible = workText.length;
const top = "┌" + "─".repeat(w) + "┐";
const bot = "└" + "─".repeat(w) + "┘";
const border = (content: string, visLen: number) =>
theme.fg("dim", "│") + content + " ".repeat(Math.max(0, w - visLen)) + theme.fg("dim", "│");
return [
theme.fg("dim", top),
border(" " + nameStr, 1 + nameVisible),
border(" " + statusLine, 1 + statusVisible),
border(" " + ctxLine, 1 + ctxVisible),
border(" " + workLine, 1 + workVisible),
theme.fg("dim", bot),
];
}
function updateWidget() {
if (!widgetCtx) return;
widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => {
const text = new Text("", 0, 1);
return {
render(width: number): string[] {
if (agentStates.size === 0) {
text.setText(theme.fg("dim", "No agents found. Add .md files to agents/"));
return text.render(width);
}
const cols = Math.min(gridCols, agentStates.size);
const gap = 1;
const colWidth = Math.floor((width - gap * (cols - 1)) / cols);
const agents = Array.from(agentStates.values());
const rows: string[][] = [];
for (let i = 0; i < agents.length; i += cols) {
const rowAgents = agents.slice(i, i + cols);
const cards = rowAgents.map(a => renderCard(a, colWidth, theme));
while (cards.length < cols) {
cards.push(Array(6).fill(" ".repeat(colWidth)));
}
const cardHeight = cards[0].length;
for (let line = 0; line < cardHeight; line++) {
rows.push(cards.map(card => card[line] || ""));
}
}
const output = rows.map(cols => cols.join(" ".repeat(gap)));
text.setText(output.join("\n"));
return text.render(width);
},
invalidate() {
text.invalidate();
},
};
});
}
// ── Dispatch Agent (returns Promise) ─────────
function dispatchAgent(
agentName: string,
task: string,
ctx: any,
): Promise<{ output: string; exitCode: number; elapsed: number }> {
const key = agentName.toLowerCase();
const state = agentStates.get(key);
if (!state) {
return Promise.resolve({
output: `Agent "${agentName}" not found. Available: ${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`,
exitCode: 1,
elapsed: 0,
});
}
if (state.status === "running") {
return Promise.resolve({
output: `Agent "${displayName(state.def.name)}" is already running. Wait for it to finish.`,
exitCode: 1,
elapsed: 0,
});
}
state.status = "running";
state.task = task;
state.toolCount = 0;
state.elapsed = 0;
state.lastWork = "";
state.runCount++;
updateWidget();
const startTime = Date.now();
state.timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
updateWidget();
}, 1000);
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
// Session file for this agent
const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `${agentKey}.json`);
// Build args — first run creates session, subsequent runs resume
const args = [
"--mode", "json",
"-p",
"--no-extensions",
"--model", model,
"--tools", state.def.tools,
"--thinking", "off",
"--append-system-prompt", state.def.systemPrompt,
"--session", agentSessionFile,
];
// Continue existing session if we have one
if (state.sessionFile) {
args.push("-c");
}
args.push(task);
const textChunks: string[] = [];
return new Promise((resolve) => {
const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
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) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") {
textChunks.push(delta.delta || "");
const full = textChunks.join("");
const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
state.lastWork = last;
updateWidget();
}
} else if (event.type === "tool_execution_start") {
state.toolCount++;
updateWidget();
} else if (event.type === "message_end") {
const msg = event.message;
if (msg?.usage && contextWindow > 0) {
state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100;
updateWidget();
}
} else if (event.type === "agent_end") {
const msgs = event.messages || [];
const last = [...msgs].reverse().find((m: any) => m.role === "assistant");
if (last?.usage && contextWindow > 0) {
state.contextPct = ((last.usage.input || 0) / contextWindow) * 100;
updateWidget();
}
}
} catch {}
}
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", () => {});
proc.on("close", (code) => {
if (buffer.trim()) {
try {
const event = JSON.parse(buffer);
if (event.type === "message_update") {
const delta = event.assistantMessageEvent;
if (delta?.type === "text_delta") textChunks.push(delta.delta || "");
}
} catch {}
}
clearInterval(state.timer);
state.elapsed = Date.now() - startTime;
state.status = code === 0 ? "done" : "error";
// Mark session file as available for resume
if (code === 0) {
state.sessionFile = agentSessionFile;
}
const full = textChunks.join("");
state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || "";
updateWidget();
ctx.ui.notify(
`${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
state.status === "done" ? "success" : "error"
);
resolve({
output: full,
exitCode: code ?? 1,
elapsed: state.elapsed,
});
});
proc.on("error", (err) => {
clearInterval(state.timer);
state.status = "error";
state.lastWork = `Error: ${err.message}`;
updateWidget();
resolve({
output: `Error spawning agent: ${err.message}`,
exitCode: 1,
elapsed: Date.now() - startTime,
});
});
});
}
// ── dispatch_agent Tool (registered at top level) ──
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch Agent",
description: "Dispatch a task to a specialist agent. The agent will execute the task and return the result. Use the system prompt to see available agent names.",
parameters: Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent to execute" }),
}),
async execute(_toolCallId, params, _signal, onUpdate, ctx) {
const { agent, task } = params as { agent: string; task: string };
try {
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Dispatching to ${agent}...` }],
details: { agent, task, status: "dispatching" },
});
}
const result = await dispatchAgent(agent, task, ctx);
const truncated = result.output.length > 8000
? result.output.slice(0, 8000) + "\n\n... [truncated]"
: result.output;
const status = result.exitCode === 0 ? "done" : "error";
const summary = `[${agent}] ${status} in ${Math.round(result.elapsed / 1000)}s`;
return {
content: [{ type: "text", text: `${summary}\n\n${truncated}` }],
details: {
agent,
task,
status,
elapsed: result.elapsed,
exitCode: result.exitCode,
fullOutput: result.output,
},
};
} catch (err: any) {
return {
content: [{ type: "text", text: `Error dispatching to ${agent}: ${err?.message || err}` }],
details: { agent, task, status: "error", elapsed: 0, exitCode: 1, fullOutput: "" },
};
}
},
renderCall(args, theme) {
const agentName = (args as any).agent || "?";
const task = (args as any).task || "";
const preview = task.length > 60 ? task.slice(0, 57) + "..." : task;
return new Text(
theme.fg("toolTitle", theme.bold("dispatch_agent ")) +
theme.fg("accent", agentName) +
theme.fg("dim", " — ") +
theme.fg("muted", preview),
0, 0,
);
},
renderResult(result, options, theme) {
const details = result.details as any;
if (!details) {
const text = result.content[0];
return new Text(text?.type === "text" ? text.text : "", 0, 0);
}
// Streaming/partial result while agent is still running
if (options.isPartial || details.status === "dispatching") {
return new Text(
theme.fg("accent", `${details.agent || "?"}`) +
theme.fg("dim", " working..."),
0, 0,
);
}
const icon = details.status === "done" ? "✓" : "✗";
const color = details.status === "done" ? "success" : "error";
const elapsed = typeof details.elapsed === "number" ? Math.round(details.elapsed / 1000) : 0;
const header = theme.fg(color, `${icon} ${details.agent}`) +
theme.fg("dim", ` ${elapsed}s`);
if (options.expanded && details.fullOutput) {
const output = details.fullOutput.length > 4000
? details.fullOutput.slice(0, 4000) + "\n... [truncated]"
: details.fullOutput;
return new Text(header + "\n" + theme.fg("muted", output), 0, 0);
}
return new Text(header, 0, 0);
},
});
// ── Commands ─────────────────────────────────
pi.registerCommand("agents-team", {
description: "Select a team to work with",
handler: async (_args, ctx) => {
widgetCtx = ctx;
const teamNames = Object.keys(teams);
if (teamNames.length === 0) {
ctx.ui.notify("No teams defined in .pi/agents/teams.yaml", "warning");
return;
}
const options = teamNames.map(name => {
const members = teams[name].map(m => displayName(m));
return `${name}${members.join(", ")}`;
});
const choice = await ctx.ui.select("Select Team", options);
if (choice === undefined) return;
const idx = options.indexOf(choice);
const name = teamNames[idx];
activateTeam(name);
updateWidget();
ctx.ui.setStatus("agent-team", `Team: ${name} (${agentStates.size})`);
ctx.ui.notify(`Team: ${name}${Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ")}`, "info");
},
});
pi.registerCommand("agents-list", {
description: "List all loaded agents",
handler: async (_args, _ctx) => {
widgetCtx = _ctx;
const names = Array.from(agentStates.values())
.map(s => {
const session = s.sessionFile ? "resumed" : "new";
return `${displayName(s.def.name)} (${s.status}, ${session}, runs: ${s.runCount}): ${s.def.description}`;
})
.join("\n");
_ctx.ui.notify(names || "No agents loaded", "info");
},
});
pi.registerCommand("agents-grid", {
description: "Set grid columns: /agents-grid <1-6>",
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
const items = ["1", "2", "3", "4", "5", "6"].map(n => ({
value: n,
label: `${n} columns`,
}));
const filtered = items.filter(i => i.value.startsWith(prefix));
return filtered.length > 0 ? filtered : items;
},
handler: async (args, _ctx) => {
widgetCtx = _ctx;
const n = parseInt(args?.trim() || "", 10);
if (n >= 1 && n <= 6) {
gridCols = n;
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
updateWidget();
} else {
_ctx.ui.notify("Usage: /agents-grid <1-6>", "error");
}
},
});
// ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
// Build dynamic agent catalog from active team only
const agentCatalog = Array.from(agentStates.values())
.map(s => `### ${displayName(s.def.name)}\n**Dispatch as:** \`${s.def.name}\`\n${s.def.description}\n**Tools:** ${s.def.tools}`)
.join("\n\n");
const teamMembers = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
return {
systemPrompt: `You are a dispatcher agent. You coordinate specialist agents to accomplish tasks.
You do NOT have direct access to the codebase. You MUST delegate all work through
agents using the dispatch_agent tool.
## Active Team: ${activeTeamName}
Members: ${teamMembers}
You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agents outside this team.
## How to Work
- Analyze the user's request and break it into clear sub-tasks
- Choose the right agent(s) for each sub-task
- Dispatch tasks using the dispatch_agent tool
- Review results and dispatch follow-up agents if needed
- If a task fails, try a different agent or adjust the task description
- Summarize the outcome for the user
## Rules
- NEVER try to read, write, or execute code directly — you have no such tools
- ALWAYS use dispatch_agent to get work done
- You can chain agents: use scout to explore, then builder to implement
- You can dispatch the same agent multiple times with different tasks
- Keep tasks focused — one clear objective per dispatch
## Agents
${agentCatalog}`,
};
});
// ── Session Start ────────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
// Clear widgets from previous session
if (widgetCtx) {
widgetCtx.ui.setWidget("agent-team", undefined);
}
widgetCtx = _ctx;
contextWindow = _ctx.model?.contextWindow || 0;
// Wipe old agent session files so subagents start fresh
const sessDir = join(_ctx.cwd, ".pi", "agent-sessions");
if (existsSync(sessDir)) {
for (const f of readdirSync(sessDir)) {
if (f.endsWith(".json")) {
try { unlinkSync(join(sessDir, f)); } catch {}
}
}
}
loadAgents(_ctx.cwd);
// Default to first team — use /agents-team to switch
const teamNames = Object.keys(teams);
if (teamNames.length > 0) {
activateTeam(teamNames[0]);
}
// Lock down to dispatcher-only (tool already registered at top level)
pi.setActiveTools(["dispatch_agent"]);
_ctx.ui.setStatus("agent-team", `Team: ${activeTeamName} (${agentStates.size})`);
const members = Array.from(agentStates.values()).map(s => displayName(s.def.name)).join(", ");
_ctx.ui.notify(
`Team: ${activeTeamName} (${members})\n` +
`Team sets loaded from: .pi/agents/teams.yaml\n\n` +
`/agents-team Select a team\n` +
`/agents-list List active agents and status\n` +
`/agents-grid <1-6> Set grid column count`,
"info",
);
updateWidget();
// Footer: model | team | context bar
_ctx.ui.setFooter((_tui, theme, _footerData) => ({
dispose: () => {},
invalidate() {},
render(width: number): string[] {
const model = _ctx.model?.id || "no-model";
const usage = _ctx.getContextUsage();
const pct = usage ? usage.percent : 0;
const filled = Math.round(pct / 10);
const bar = "#".repeat(filled) + "-".repeat(10 - filled);
const left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") +
theme.fg("accent", activeTeamName);
const right = theme.fg("dim", `[${bar}] ${Math.round(pct)}% `);
const pad = " ".repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(right)));
return [truncateToWidth(left + pad + right, width)];
},
}));
});
}