🚀
This commit is contained in:
734
extensions/agent-team.ts
Normal file
734
extensions/agent-team.ts
Normal file
@@ -0,0 +1,734 @@
|
||||
/**
|
||||
* 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)];
|
||||
},
|
||||
}));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user