Files
calvana/extensions/agent-team.ts
Omair Saleh 2fb612b1df feat: calvana application microsite + ship-log extension
- Static site: /manifesto, /live, /hire pages
- Ship-log Pi extension: calvana_ship, calvana_oops, calvana_deploy tools
- Docker + nginx deploy to calvana.quikcue.com
- Terminal-ish dark aesthetic, mobile responsive
- Auto-updating /live page from extension state
2026-03-02 18:03:22 +08:00

945 lines
29 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 (single) or
* `dispatch_agents` tool (parallel batch). 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, type ChildProcess } from "child_process";
import { readdirSync, readFileSync, existsSync, mkdirSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { applyExtensionDefaults } from "./themeMap.ts";
// ── Constants ────────────────────────────────────
const AGENT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes per dispatch
const WIDGET_THROTTLE_MS = 500; // max widget refresh rate
// ── 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>;
proc?: ChildProcess;
}
// ── 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();
const activeProcesses: Set<ChildProcess> = new Set();
let allAgentDefs: AgentDef[] = [];
let teams: Record<string, string[]> = {};
let activeTeamName = "";
let gridCols = 2;
let widgetCtx: any;
let sessionDir = "";
let contextWindow = 0;
// ── Throttled Widget Update ──────────────────
let widgetDirty = false;
let widgetTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleWidgetUpdate() {
widgetDirty = true;
if (widgetTimer) return; // already scheduled
widgetTimer = setTimeout(() => {
widgetTimer = null;
if (widgetDirty) {
widgetDirty = false;
doUpdateWidget();
}
}, WIDGET_THROTTLE_MS);
}
function flushWidgetUpdate() {
if (widgetTimer) {
clearTimeout(widgetTimer);
widgetTimer = null;
}
widgetDirty = false;
doUpdateWidget();
}
function loadAgents(cwd: string) {
sessionDir = join(cwd, ".pi", "agent-sessions");
if (!existsSync(sessionDir)) {
mkdirSync(sessionDir, { recursive: true });
}
allAgentDefs = scanAgentDirs(cwd);
const teamsPath = join(cwd, ".pi", "agents", "teams.yaml");
if (existsSync(teamsPath)) {
try {
teams = parseTeamsYaml(readFileSync(teamsPath, "utf-8"));
} catch {
teams = {};
}
} else {
teams = {};
}
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,
});
}
const size = agentStates.size;
gridCols = size <= 3 ? size : size === 4 ? 2 : 3;
}
// ── Kill all tracked child processes ─────────
function killAllAgents() {
for (const proc of activeProcesses) {
try { proc.kill("SIGTERM"); } catch {}
}
// Force kill after 3s
setTimeout(() => {
for (const proc of activeProcesses) {
try { proc.kill("SIGKILL"); } catch {}
}
}, 3000);
}
// ── 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;
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 doUpdateWidget() {
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,
signal?: AbortSignal,
): 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,
});
}
// Reset state for new run
state.status = "running";
state.task = task;
state.toolCount = 0;
state.elapsed = 0;
state.lastWork = "";
state.contextPct = 0;
state.runCount++;
scheduleWidgetUpdate();
const startTime = Date.now();
state.timer = setInterval(() => {
state.elapsed = Date.now() - startTime;
scheduleWidgetUpdate();
}, 1000);
const model = ctx.model
? `${ctx.model.provider}/${ctx.model.id}`
: "openrouter/google/gemini-3-flash-preview";
const agentKey = state.def.name.toLowerCase().replace(/\s+/g, "-");
const agentSessionFile = join(sessionDir, `${agentKey}.json`);
const args = [
"--mode", "json",
"-p",
"--no-extensions",
"--model", model,
"--tools", state.def.tools,
"--thinking", "off",
"--append-system-prompt", state.def.systemPrompt,
"--session", agentSessionFile,
];
if (state.sessionFile) {
args.push("-c");
}
args.push(task);
const textChunks: string[] = [];
let resolved = false;
return new Promise((promiseResolve) => {
// Guard against double-resolve
const safeResolve = (val: { output: string; exitCode: number; elapsed: number }) => {
if (resolved) return;
resolved = true;
promiseResolve(val);
};
const proc = spawn("pi", args, {
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env },
});
state.proc = proc;
activeProcesses.add(proc);
// ── Timeout guard ──
const timeout = setTimeout(() => {
try { proc.kill("SIGTERM"); } catch {}
// Force kill after 3s if still alive
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000);
}, AGENT_TIMEOUT_MS);
// ── AbortSignal support ──
const onAbort = () => {
try { proc.kill("SIGTERM"); } catch {}
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 3000);
};
if (signal) {
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort, { once: true });
}
}
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;
scheduleWidgetUpdate();
}
} else if (event.type === "tool_execution_start") {
state.toolCount++;
scheduleWidgetUpdate();
} else if (event.type === "message_end") {
const msg = event.message;
if (msg?.usage && contextWindow > 0) {
state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100;
scheduleWidgetUpdate();
}
} 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;
scheduleWidgetUpdate();
}
}
} catch {}
}
});
proc.stderr!.setEncoding("utf-8");
proc.stderr!.on("data", () => {});
proc.on("close", (code) => {
clearTimeout(timeout);
if (signal) signal.removeEventListener?.("abort", onAbort);
activeProcesses.delete(proc);
state.proc = undefined;
// Process any remaining buffer
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;
const timedOut = state.elapsed >= AGENT_TIMEOUT_MS;
state.status = timedOut ? "error" : (code === 0 ? "done" : "error");
if (code === 0) {
state.sessionFile = agentSessionFile;
}
const full = textChunks.join("");
state.lastWork = full.split("\n").filter((l: string) => l.trim()).pop() || "";
flushWidgetUpdate();
const statusMsg = timedOut
? `${displayName(state.def.name)} timed out after ${Math.round(AGENT_TIMEOUT_MS / 1000)}s`
: `${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`;
ctx.ui.notify(statusMsg, state.status === "done" ? "success" : "error");
const output = timedOut
? full + "\n\n[TIMED OUT after " + Math.round(AGENT_TIMEOUT_MS / 1000) + "s]"
: full;
safeResolve({
output,
exitCode: code ?? 1,
elapsed: state.elapsed,
});
});
proc.on("error", (err) => {
clearTimeout(timeout);
if (signal) signal.removeEventListener?.("abort", onAbort);
activeProcesses.delete(proc);
state.proc = undefined;
clearInterval(state.timer);
state.status = "error";
state.lastWork = `Error: ${err.message}`;
flushWidgetUpdate();
safeResolve({
output: `Error spawning agent: ${err.message}`,
exitCode: 1,
elapsed: Date.now() - startTime,
});
});
});
}
// ── dispatch_agent Tool (single) ─────────────
pi.registerTool({
name: "dispatch_agent",
label: "Dispatch Agent",
description: "Dispatch a task to a single specialist agent. The agent executes the task and returns the result. For dispatching multiple agents in parallel, use dispatch_agents instead.",
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, signal);
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);
}
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);
},
});
// ── dispatch_agents Tool (parallel batch) ────
pi.registerTool({
name: "dispatch_agents",
label: "Dispatch Agents (Parallel)",
description: "Dispatch tasks to multiple specialist agents in parallel. All agents run simultaneously and results are returned together. Much faster than sequential dispatch_agent calls when tasks are independent.",
parameters: Type.Object({
dispatches: Type.Array(
Type.Object({
agent: Type.String({ description: "Agent name (case-insensitive)" }),
task: Type.String({ description: "Task description for the agent" }),
}),
{ description: "Array of {agent, task} pairs to dispatch in parallel", minItems: 1 },
),
}),
async execute(_toolCallId, params, signal, onUpdate, ctx) {
const { dispatches } = params as { dispatches: { agent: string; task: string }[] };
const agentNames = dispatches.map(d => d.agent).join(", ");
if (onUpdate) {
onUpdate({
content: [{ type: "text", text: `Dispatching ${dispatches.length} agents in parallel: ${agentNames}` }],
details: { dispatches, status: "dispatching", count: dispatches.length },
});
}
// Launch all in parallel
const promises = dispatches.map(({ agent, task }) =>
dispatchAgent(agent, task, ctx, signal).then(result => ({
agent,
task,
...result,
}))
);
const results = await Promise.all(promises);
const summaryParts: string[] = [];
const allDetails: any[] = [];
for (const r of results) {
const status = r.exitCode === 0 ? "done" : "error";
const truncated = r.output.length > 4000
? r.output.slice(0, 4000) + "\n... [truncated]"
: r.output;
summaryParts.push(`## [${r.agent}] ${status} in ${Math.round(r.elapsed / 1000)}s\n\n${truncated}`);
allDetails.push({
agent: r.agent,
task: r.task,
status,
elapsed: r.elapsed,
exitCode: r.exitCode,
fullOutput: r.output,
});
}
const doneCount = results.filter(r => r.exitCode === 0).length;
const header = `Parallel dispatch complete: ${doneCount}/${results.length} succeeded`;
return {
content: [{ type: "text", text: `${header}\n\n${summaryParts.join("\n\n---\n\n")}` }],
details: {
dispatches: allDetails,
status: "complete",
count: results.length,
succeeded: doneCount,
},
};
},
renderCall(args, theme) {
const dispatches = (args as any).dispatches || [];
const names = dispatches.map((d: any) => d.agent || "?").join(", ");
return new Text(
theme.fg("toolTitle", theme.bold("dispatch_agents ")) +
theme.fg("accent", `[${dispatches.length}] `) +
theme.fg("muted", names),
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);
}
if (options.isPartial || details.status === "dispatching") {
return new Text(
theme.fg("accent", `● Parallel dispatch`) +
theme.fg("dim", ` ${details.count || "?"} agents working...`),
0, 0,
);
}
const header = theme.fg("success", `${details.succeeded}`) +
theme.fg("dim", `/${details.count} agents completed`);
if (options.expanded && Array.isArray(details.dispatches)) {
const lines = details.dispatches.map((d: any) => {
const icon = d.status === "done" ? "✓" : "✗";
const color = d.status === "done" ? "success" : "error";
return theme.fg(color, ` ${icon} ${d.agent}`) +
theme.fg("dim", ` ${Math.round(d.elapsed / 1000)}s`);
});
return new Text(header + "\n" + lines.join("\n"), 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);
flushWidgetUpdate();
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");
flushWidgetUpdate();
} else {
_ctx.ui.notify("Usage: /agents-grid <1-6>", "error");
}
},
});
// ── System Prompt Override ───────────────────
pi.on("before_agent_start", async (_event, _ctx) => {
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 or dispatch_agents tools.
## 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
- **Use dispatch_agents for independent parallel tasks** — this is much faster
- Use dispatch_agent for sequential tasks where order matters
- 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 or dispatch_agents to get work done
- **Prefer dispatch_agents when tasks are independent** — parallelism saves time
- 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
- Each agent has a ${Math.round(AGENT_TIMEOUT_MS / 1000)}s timeout — break large tasks into smaller ones
## Agents
${agentCatalog}`,
};
});
// ── Session Start ────────────────────────────
pi.on("session_start", async (_event, _ctx) => {
applyExtensionDefaults(import.meta.url, _ctx);
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);
const teamNames = Object.keys(teams);
if (teamNames.length > 0) {
activateTeam(teamNames[0]);
}
pi.setActiveTools(["dispatch_agent", "dispatch_agents"]);
_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",
);
flushWidgetUpdate();
_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 running = Array.from(agentStates.values()).filter(s => s.status === "running").length;
const runningStr = running > 0 ? theme.fg("accent", `${running} running`) : "";
const left = theme.fg("dim", ` ${model}`) +
theme.fg("muted", " · ") +
theme.fg("accent", activeTeamName) +
runningStr;
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)];
},
}));
});
// ── Cleanup on exit ──────────────────────────
process.on("exit", () => killAllAgents());
process.on("SIGINT", () => { killAllAgents(); process.exit(0); });
process.on("SIGTERM", () => { killAllAgents(); process.exit(0); });
}