feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later
This commit is contained in:
971
extensions/agent-dashboard.ts
Normal file
971
extensions/agent-dashboard.ts
Normal file
@@ -0,0 +1,971 @@
|
||||
/**
|
||||
* Agent Dashboard — Unified observability across all agent interfaces
|
||||
*
|
||||
* Passively tracks agent activity from team dispatches, subagent spawns,
|
||||
* and chain pipeline runs. Provides a compact always-visible widget plus
|
||||
* a full-screen overlay with four switchable views.
|
||||
*
|
||||
* Hooks into: dispatch_agent, subagent_create, subagent_continue, run_chain
|
||||
* tool calls and their completions. Completely passive — never blocks.
|
||||
*
|
||||
* Commands:
|
||||
* /dashboard — toggle full-screen overlay
|
||||
* /dashboard clear — reset all tracked state
|
||||
*
|
||||
* Usage: pi -e extensions/agent-dashboard.ts
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import { applyExtensionDefaults } from "./themeMap.ts";
|
||||
|
||||
// ── Data Types ─────────────────────────────────────────────────────────
|
||||
|
||||
type AgentInterface = "team" | "subagent" | "chain";
|
||||
|
||||
interface TrackedAgent {
|
||||
id: string;
|
||||
name: string;
|
||||
iface: AgentInterface;
|
||||
status: "running" | "done" | "error";
|
||||
task: string;
|
||||
startedAt: number;
|
||||
endedAt?: number;
|
||||
elapsed: number;
|
||||
toolCount: number;
|
||||
lastText: string;
|
||||
turnCount: number;
|
||||
chainStep?: number;
|
||||
chainName?: string;
|
||||
teamName?: string;
|
||||
}
|
||||
|
||||
interface AgentRun {
|
||||
id: string;
|
||||
name: string;
|
||||
iface: AgentInterface;
|
||||
task: string;
|
||||
status: "done" | "error";
|
||||
startedAt: number;
|
||||
endedAt: number;
|
||||
duration: number;
|
||||
toolCount: number;
|
||||
resultPreview: string;
|
||||
chainStep?: number;
|
||||
chainName?: string;
|
||||
teamName?: string;
|
||||
}
|
||||
|
||||
interface DashboardStats {
|
||||
totalRuns: number;
|
||||
totalSuccess: number;
|
||||
totalError: number;
|
||||
totalDuration: number;
|
||||
agentRunCounts: Record<string, number>;
|
||||
ifaceCounts: Record<AgentInterface, number>;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const secs = Math.floor(ms / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
const remSecs = secs % 60;
|
||||
if (mins < 60) return `${mins}m ${remSecs}s`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remMins = mins % 60;
|
||||
return `${hrs}h ${remMins}m`;
|
||||
}
|
||||
|
||||
function shortId(): string {
|
||||
return Math.random().toString(36).slice(2, 6);
|
||||
}
|
||||
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length > max ? s.slice(0, max - 1) + "…" : s;
|
||||
}
|
||||
|
||||
function emptyStats(): DashboardStats {
|
||||
return {
|
||||
totalRuns: 0,
|
||||
totalSuccess: 0,
|
||||
totalError: 0,
|
||||
totalDuration: 0,
|
||||
agentRunCounts: {},
|
||||
ifaceCounts: { team: 0, subagent: 0, chain: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Extension ──────────────────────────────────────────────────────────
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
// ── State ──────────────────────────────────────────────────────────
|
||||
|
||||
const activeAgents: Map<string, TrackedAgent> = new Map();
|
||||
let history: AgentRun[] = [];
|
||||
let stats: DashboardStats = emptyStats();
|
||||
let widgetCtx: ExtensionContext | null = null;
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Mapping from toolCallId → tracked agent info (with timestamp for staleness)
|
||||
const pendingCalls: Map<string, { agentId: string; ts: number }> = new Map();
|
||||
|
||||
// Staleness threshold: 10 minutes
|
||||
const STALE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
// Inactivity auto-stop: stop tick after 30s with no active agents
|
||||
let lastActivityTs = Date.now();
|
||||
|
||||
// ── Tracked tool names ─────────────────────────────────────────────
|
||||
|
||||
const TRACKED_TOOLS = new Set([
|
||||
"dispatch_agent",
|
||||
"subagent_create",
|
||||
"subagent_continue",
|
||||
"run_chain",
|
||||
]);
|
||||
|
||||
// ── State Management ───────────────────────────────────────────────
|
||||
|
||||
function clearState() {
|
||||
activeAgents.clear();
|
||||
history = [];
|
||||
stats = emptyStats();
|
||||
pendingCalls.clear();
|
||||
lastActivityTs = Date.now();
|
||||
}
|
||||
|
||||
function addToHistory(agent: TrackedAgent) {
|
||||
const run: AgentRun = {
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
iface: agent.iface,
|
||||
task: agent.task,
|
||||
status: agent.status === "error" ? "error" : "done",
|
||||
startedAt: agent.startedAt,
|
||||
endedAt: agent.endedAt || Date.now(),
|
||||
duration: agent.elapsed,
|
||||
toolCount: agent.toolCount,
|
||||
resultPreview: truncate(agent.lastText, 200),
|
||||
chainStep: agent.chainStep,
|
||||
chainName: agent.chainName,
|
||||
teamName: agent.teamName,
|
||||
};
|
||||
|
||||
history.push(run);
|
||||
// Ring buffer capped at 200
|
||||
if (history.length > 200) {
|
||||
history = history.slice(-200);
|
||||
}
|
||||
|
||||
// Update stats
|
||||
stats.totalRuns++;
|
||||
if (run.status === "done") stats.totalSuccess++;
|
||||
else stats.totalError++;
|
||||
stats.totalDuration += run.duration;
|
||||
stats.agentRunCounts[run.name] = (stats.agentRunCounts[run.name] || 0) + 1;
|
||||
stats.ifaceCounts[run.iface] = (stats.ifaceCounts[run.iface] || 0) + 1;
|
||||
}
|
||||
|
||||
// ── Tick Timer ─────────────────────────────────────────────────────
|
||||
|
||||
function startTick() {
|
||||
if (tickTimer) return;
|
||||
tickTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
// Update elapsed on running agents
|
||||
for (const agent of activeAgents.values()) {
|
||||
if (agent.status === "running") {
|
||||
agent.elapsed = now - agent.startedAt;
|
||||
}
|
||||
}
|
||||
|
||||
// Staleness check: expire pending calls older than 10 minutes
|
||||
for (const [callId, pending] of pendingCalls) {
|
||||
if (now - pending.ts > STALE_TIMEOUT_MS) {
|
||||
pendingCalls.delete(callId);
|
||||
const agent = activeAgents.get(pending.agentId);
|
||||
if (agent && agent.status === "running") {
|
||||
agent.status = "error";
|
||||
agent.endedAt = now;
|
||||
agent.elapsed = now - agent.startedAt;
|
||||
agent.lastText = "Timed out (no completion after 10m)";
|
||||
addToHistory(agent);
|
||||
activeAgents.delete(pending.agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-stop tick after 30s of inactivity (no active agents, no pending calls)
|
||||
if (activeAgents.size === 0 && pendingCalls.size === 0) {
|
||||
if (now - lastActivityTs > 30_000) {
|
||||
stopTick();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
lastActivityTs = now;
|
||||
}
|
||||
|
||||
updateWidget();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopTick() {
|
||||
if (tickTimer) {
|
||||
clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Widget Rendering ───────────────────────────────────────────────
|
||||
|
||||
function updateWidget() {
|
||||
if (!widgetCtx) return;
|
||||
|
||||
try {
|
||||
widgetCtx.ui.setWidget("agent-dashboard", (_tui, theme) => {
|
||||
const container = new Container();
|
||||
const borderFn = (s: string) => theme.fg("accent", s);
|
||||
|
||||
container.addChild(new DynamicBorder(borderFn));
|
||||
|
||||
const headerText = new Text("", 1, 0);
|
||||
container.addChild(headerText);
|
||||
|
||||
const agentLines: Text[] = [];
|
||||
// Pre-allocate up to 4 lines for active agents
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const t = new Text("", 1, 0);
|
||||
agentLines.push(t);
|
||||
container.addChild(t);
|
||||
}
|
||||
|
||||
const hintText = new Text("", 1, 0);
|
||||
container.addChild(hintText);
|
||||
|
||||
container.addChild(new DynamicBorder(borderFn));
|
||||
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const activeCount = activeAgents.size;
|
||||
const doneCount = stats.totalSuccess;
|
||||
const errorCount = stats.totalError;
|
||||
|
||||
// Line 1: summary bar
|
||||
const line1 =
|
||||
theme.fg("accent", " 📊 Dashboard") +
|
||||
theme.fg("dim", " │ Active: ") + theme.fg(activeCount > 0 ? "accent" : "muted", `${activeCount}`) +
|
||||
theme.fg("dim", " │ Done: ") + theme.fg("success", `${doneCount}`) +
|
||||
theme.fg("dim", " │ Errors: ") + theme.fg(errorCount > 0 ? "error" : "muted", `${errorCount}`);
|
||||
headerText.setText(truncateToWidth(line1, width - 4));
|
||||
|
||||
// Active agent lines
|
||||
const agents = Array.from(activeAgents.values());
|
||||
for (let i = 0; i < agentLines.length; i++) {
|
||||
if (i < agents.length) {
|
||||
const a = agents[i];
|
||||
const icon = a.status === "running" ? "⟳"
|
||||
: a.status === "done" ? "✓" : "✗";
|
||||
const statusColor = a.status === "running" ? "accent"
|
||||
: a.status === "done" ? "success" : "error";
|
||||
const ifaceTag = theme.fg("dim", `[${a.iface}]`);
|
||||
const elapsed = theme.fg("muted", fmtDuration(a.elapsed));
|
||||
const tools = theme.fg("dim", `🔧${a.toolCount}`);
|
||||
const lastText = a.lastText
|
||||
? theme.fg("muted", truncate(a.lastText, Math.max(20, width - 60)))
|
||||
: "";
|
||||
|
||||
const line =
|
||||
" " + theme.fg(statusColor, icon) + " " +
|
||||
theme.fg("accent", truncate(a.name, 16)) + " " +
|
||||
ifaceTag + " " +
|
||||
elapsed + " " +
|
||||
tools +
|
||||
(lastText ? theme.fg("dim", " │ ") + lastText : "");
|
||||
agentLines[i].setText(truncateToWidth(line, width - 4));
|
||||
} else {
|
||||
agentLines[i].setText("");
|
||||
}
|
||||
}
|
||||
|
||||
// Hint line
|
||||
const hintLine =
|
||||
theme.fg("dim", " /dashboard") + theme.fg("muted", " — full view") +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", `${stats.totalRuns} total runs`) +
|
||||
(stats.totalDuration > 0
|
||||
? theme.fg("dim", " │ avg ") + theme.fg("muted", fmtDuration(Math.round(stats.totalDuration / Math.max(1, stats.totalRuns))))
|
||||
: "");
|
||||
hintText.setText(truncateToWidth(hintLine, width - 4));
|
||||
|
||||
return container.render(width);
|
||||
},
|
||||
invalidate() {
|
||||
container.invalidate();
|
||||
},
|
||||
};
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── Overlay ────────────────────────────────────────────────────────
|
||||
|
||||
async function openOverlay(ctx: ExtensionContext) {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
let currentView = 0; // 0=Live, 1=History, 2=Interfaces, 3=Stats
|
||||
let scrollOffset = 0;
|
||||
|
||||
const viewNames = ["1:Live", "2:History", "3:Interfaces", "4:Stats"];
|
||||
|
||||
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
||||
return {
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
// ── Header ──
|
||||
lines.push("");
|
||||
const tabs = viewNames.map((name, i) =>
|
||||
i === currentView
|
||||
? theme.fg("accent", theme.bold(`[${name}]`))
|
||||
: theme.fg("dim", `[${name}]`)
|
||||
).join(" ");
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("📊 Agent Dashboard")) +
|
||||
" ".repeat(Math.max(1, width - 20 - viewNames.join(" ").length - 2)) +
|
||||
tabs,
|
||||
width,
|
||||
));
|
||||
lines.push(theme.fg("dim", "─".repeat(width)));
|
||||
|
||||
// ── View content ──
|
||||
const contentLines = renderView(currentView, width, theme, scrollOffset);
|
||||
lines.push(...contentLines);
|
||||
|
||||
// ── Footer controls ──
|
||||
lines.push("");
|
||||
lines.push(theme.fg("dim", "─".repeat(width)));
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "1-4/Tab: views │ j/k: scroll │ c: clear │ q/Esc: close"),
|
||||
width,
|
||||
));
|
||||
lines.push("");
|
||||
|
||||
return lines;
|
||||
},
|
||||
handleInput(data: string) {
|
||||
if (matchesKey(data, "escape") || data === "q") {
|
||||
done(undefined);
|
||||
return;
|
||||
}
|
||||
if (data === "1") { currentView = 0; scrollOffset = 0; }
|
||||
else if (data === "2") { currentView = 1; scrollOffset = 0; }
|
||||
else if (data === "3") { currentView = 2; scrollOffset = 0; }
|
||||
else if (data === "4") { currentView = 3; scrollOffset = 0; }
|
||||
else if (data === "\t") { currentView = (currentView + 1) % 4; scrollOffset = 0; }
|
||||
else if (matchesKey(data, "up") || data === "k") { scrollOffset = Math.max(0, scrollOffset - 1); }
|
||||
else if (matchesKey(data, "down") || data === "j") { scrollOffset++; }
|
||||
else if (matchesKey(data, "pageUp")) { scrollOffset = Math.max(0, scrollOffset - 20); }
|
||||
else if (matchesKey(data, "pageDown")) { scrollOffset += 20; }
|
||||
else if (data === "c") {
|
||||
clearState();
|
||||
scrollOffset = 0;
|
||||
}
|
||||
_tui.requestRender();
|
||||
},
|
||||
invalidate() {},
|
||||
};
|
||||
}, {
|
||||
overlay: true,
|
||||
overlayOptions: { width: "90%", anchor: "center" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── View Renderers ─────────────────────────────────────────────────
|
||||
|
||||
function renderView(view: number, width: number, theme: any, offset: number): string[] {
|
||||
switch (view) {
|
||||
case 0: return renderLiveView(width, theme, offset);
|
||||
case 1: return renderHistoryView(width, theme, offset);
|
||||
case 2: return renderInterfacesView(width, theme, offset);
|
||||
case 3: return renderStatsView(width, theme, offset);
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── View 1: Live ───────────────────────────────────────────────────
|
||||
|
||||
function renderLiveView(width: number, theme: any, offset: number): string[] {
|
||||
const lines: string[] = [];
|
||||
const agents = Array.from(activeAgents.values());
|
||||
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("Active Agents")) +
|
||||
theme.fg("dim", ` (${agents.length} running)`),
|
||||
width,
|
||||
));
|
||||
lines.push("");
|
||||
|
||||
if (agents.length === 0) {
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "No agents currently running. Activity will appear here when"),
|
||||
width,
|
||||
));
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "dispatch_agent, subagent_create, subagent_continue, or run_chain is called."),
|
||||
width,
|
||||
));
|
||||
lines.push("");
|
||||
|
||||
// Show recent completions as context
|
||||
if (history.length > 0) {
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("muted", `Last completed: ${history.length} agents`),
|
||||
width,
|
||||
));
|
||||
const recent = history.slice(-3).reverse();
|
||||
for (const run of recent) {
|
||||
const icon = run.status === "done" ? "✓" : "✗";
|
||||
const color = run.status === "done" ? "success" : "error";
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg(color, `${icon} ${run.name}`) +
|
||||
theme.fg("dim", ` [${run.iface}] `) +
|
||||
theme.fg("muted", fmtDuration(run.duration)) +
|
||||
theme.fg("dim", " — ") +
|
||||
theme.fg("muted", truncate(run.task, 50)),
|
||||
width,
|
||||
));
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
const allLines: string[] = [];
|
||||
for (const agent of agents) {
|
||||
const icon = agent.status === "running" ? "●"
|
||||
: agent.status === "done" ? "✓" : "✗";
|
||||
const statusColor = agent.status === "running" ? "accent"
|
||||
: agent.status === "done" ? "success" : "error";
|
||||
|
||||
// Card top
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "┌─ ") +
|
||||
theme.fg(statusColor, `${icon} ${agent.name}`) +
|
||||
theme.fg("dim", ` [${agent.iface}]`) +
|
||||
(agent.chainName ? theme.fg("dim", ` chain:${agent.chainName}`) : "") +
|
||||
(agent.teamName ? theme.fg("dim", ` team:${agent.teamName}`) : "") +
|
||||
(agent.chainStep !== undefined ? theme.fg("dim", ` step:${agent.chainStep}`) : "") +
|
||||
theme.fg("dim", " ─".repeat(Math.max(0, Math.floor((width - 50) / 2)))),
|
||||
width,
|
||||
));
|
||||
|
||||
// Task
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "│ ") +
|
||||
theme.fg("muted", "Task: ") +
|
||||
theme.fg("accent", truncate(agent.task, width - 20)),
|
||||
width,
|
||||
));
|
||||
|
||||
// Metrics
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "│ ") +
|
||||
theme.fg("muted", "Elapsed: ") + theme.fg("success", fmtDuration(agent.elapsed)) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", "Tools: ") + theme.fg("accent", `${agent.toolCount}`) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", "Turns: ") + theme.fg("accent", `${agent.turnCount}`),
|
||||
width,
|
||||
));
|
||||
|
||||
// Streaming text
|
||||
if (agent.lastText) {
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "│ ") +
|
||||
theme.fg("muted", truncate(agent.lastText, width - 10)),
|
||||
width,
|
||||
));
|
||||
}
|
||||
|
||||
// Card bottom
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("dim", "└" + "─".repeat(Math.max(0, width - 5))),
|
||||
width,
|
||||
));
|
||||
allLines.push("");
|
||||
}
|
||||
|
||||
const visible = allLines.slice(offset);
|
||||
lines.push(...visible);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── View 2: History ────────────────────────────────────────────────
|
||||
|
||||
function renderHistoryView(width: number, theme: any, offset: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("Completed Runs")) +
|
||||
theme.fg("dim", ` (${history.length} total)`),
|
||||
width,
|
||||
));
|
||||
lines.push("");
|
||||
|
||||
if (history.length === 0) {
|
||||
lines.push(truncateToWidth(" " + theme.fg("dim", "No completed runs yet."), width));
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Table header
|
||||
const hdr =
|
||||
theme.fg("accent", " Status") +
|
||||
theme.fg("accent", " │ Name ") +
|
||||
theme.fg("accent", " │ Interface ") +
|
||||
theme.fg("accent", " │ Duration ") +
|
||||
theme.fg("accent", " │ Tools ") +
|
||||
theme.fg("accent", " │ Task");
|
||||
lines.push(truncateToWidth(hdr, width));
|
||||
lines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(80, width - 4))), width));
|
||||
|
||||
// Show newest first
|
||||
const rows: string[] = [];
|
||||
const reversed = [...history].reverse();
|
||||
for (const run of reversed) {
|
||||
const icon = run.status === "done" ? "✓" : "✗";
|
||||
const color = run.status === "done" ? "success" : "error";
|
||||
const ifaceLabel = run.iface.padEnd(9);
|
||||
const nameLabel = truncate(run.name, 14).padEnd(14);
|
||||
const durLabel = fmtDuration(run.duration).padEnd(8);
|
||||
const toolLabel = String(run.toolCount).padStart(5);
|
||||
const taskPreview = truncate(run.task, Math.max(10, width - 70));
|
||||
|
||||
const row =
|
||||
" " + theme.fg(color, ` ${icon} `) +
|
||||
theme.fg("dim", " │ ") + theme.fg("accent", nameLabel) +
|
||||
theme.fg("dim", " │ ") + theme.fg("muted", ifaceLabel) +
|
||||
theme.fg("dim", " │ ") + theme.fg("success", durLabel) +
|
||||
theme.fg("dim", " │ ") + theme.fg("accent", toolLabel) +
|
||||
theme.fg("dim", " │ ") + theme.fg("muted", taskPreview);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const visible = rows.slice(offset);
|
||||
for (const row of visible) {
|
||||
lines.push(truncateToWidth(row, width));
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── View 3: Interfaces ─────────────────────────────────────────────
|
||||
|
||||
function renderInterfacesView(width: number, theme: any, offset: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("Agents by Interface")),
|
||||
width,
|
||||
));
|
||||
lines.push("");
|
||||
|
||||
const ifaceLabels: Record<AgentInterface, string> = {
|
||||
team: "🏢 Team (dispatch_agent)",
|
||||
subagent: "🤖 Subagent (subagent_create/continue)",
|
||||
chain: "🔗 Chain (run_chain)",
|
||||
};
|
||||
|
||||
const allLines: string[] = [];
|
||||
|
||||
for (const iface of ["team", "subagent", "chain"] as AgentInterface[]) {
|
||||
const activeForIface = Array.from(activeAgents.values()).filter(a => a.iface === iface);
|
||||
const historyForIface = history.filter(r => r.iface === iface);
|
||||
const totalCount = stats.ifaceCounts[iface] || 0;
|
||||
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold(ifaceLabels[iface])) +
|
||||
theme.fg("dim", ` — ${activeForIface.length} active, ${totalCount} completed`),
|
||||
width,
|
||||
));
|
||||
allLines.push(truncateToWidth(" " + theme.fg("dim", "─".repeat(Math.min(60, width - 6))), width));
|
||||
|
||||
// Active
|
||||
if (activeForIface.length > 0) {
|
||||
for (const agent of activeForIface) {
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", "● ") +
|
||||
theme.fg("accent", agent.name) +
|
||||
theme.fg("dim", " — ") +
|
||||
theme.fg("success", fmtDuration(agent.elapsed)) +
|
||||
theme.fg("dim", " │ 🔧") + theme.fg("muted", `${agent.toolCount}`) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", truncate(agent.task, 40)),
|
||||
width,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Recent completed (last 5)
|
||||
const recent = historyForIface.slice(-5).reverse();
|
||||
if (recent.length > 0) {
|
||||
for (const run of recent) {
|
||||
const icon = run.status === "done" ? "✓" : "✗";
|
||||
const color = run.status === "done" ? "success" : "error";
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg(color, `${icon} `) +
|
||||
theme.fg("muted", run.name) +
|
||||
theme.fg("dim", " — ") +
|
||||
theme.fg("muted", fmtDuration(run.duration)) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", truncate(run.task, 40)),
|
||||
width,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if (activeForIface.length === 0 && recent.length === 0) {
|
||||
allLines.push(truncateToWidth(" " + theme.fg("dim", "No activity recorded."), width));
|
||||
}
|
||||
|
||||
allLines.push("");
|
||||
}
|
||||
|
||||
const visible = allLines.slice(offset);
|
||||
lines.push(...visible);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── View 4: Stats ──────────────────────────────────────────────────
|
||||
|
||||
function renderStatsView(width: number, theme: any, offset: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("Aggregate Statistics")),
|
||||
width,
|
||||
));
|
||||
lines.push("");
|
||||
|
||||
const avgDur = stats.totalRuns > 0
|
||||
? fmtDuration(Math.round(stats.totalDuration / stats.totalRuns))
|
||||
: "—";
|
||||
const successRate = stats.totalRuns > 0
|
||||
? `${Math.round((stats.totalSuccess / stats.totalRuns) * 100)}%`
|
||||
: "—";
|
||||
|
||||
const allLines: string[] = [];
|
||||
|
||||
// Summary cards
|
||||
allLines.push(truncateToWidth(
|
||||
" " +
|
||||
theme.fg("muted", "Total Runs: ") + theme.fg("accent", `${stats.totalRuns}`) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", "Success: ") + theme.fg("success", `${stats.totalSuccess}`) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", "Errors: ") + theme.fg(stats.totalError > 0 ? "error" : "muted", `${stats.totalError}`) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", "Success Rate: ") + theme.fg("success", successRate),
|
||||
width,
|
||||
));
|
||||
allLines.push(truncateToWidth(
|
||||
" " +
|
||||
theme.fg("muted", "Total Duration: ") + theme.fg("success", fmtDuration(stats.totalDuration)) +
|
||||
theme.fg("dim", " │ ") +
|
||||
theme.fg("muted", "Avg Duration: ") + theme.fg("accent", avgDur),
|
||||
width,
|
||||
));
|
||||
allLines.push("");
|
||||
|
||||
// Interface breakdown
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("Interface Breakdown")),
|
||||
width,
|
||||
));
|
||||
allLines.push("");
|
||||
|
||||
const ifaceTotal = Math.max(1, stats.ifaceCounts.team + stats.ifaceCounts.subagent + stats.ifaceCounts.chain);
|
||||
const barWidth = Math.min(30, Math.floor(width * 0.3));
|
||||
|
||||
for (const [iface, label] of [["team", "Team "], ["subagent", "Subagent "], ["chain", "Chain "]] as [AgentInterface, string][]) {
|
||||
const count = stats.ifaceCounts[iface] || 0;
|
||||
const ratio = count / ifaceTotal;
|
||||
const filled = Math.round(ratio * barWidth);
|
||||
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
|
||||
|
||||
allLines.push(truncateToWidth(
|
||||
" " +
|
||||
theme.fg("accent", label) + " " +
|
||||
theme.fg("success", bar) + " " +
|
||||
theme.fg("muted", `${count}`) +
|
||||
theme.fg("dim", ` (${Math.round(ratio * 100)}%)`),
|
||||
width,
|
||||
));
|
||||
}
|
||||
allLines.push("");
|
||||
|
||||
// Most-used agents bar chart
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("Most-Used Agents")),
|
||||
width,
|
||||
));
|
||||
allLines.push("");
|
||||
|
||||
const agentEntries = Object.entries(stats.agentRunCounts).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
if (agentEntries.length === 0) {
|
||||
allLines.push(truncateToWidth(" " + theme.fg("dim", "No agent runs recorded yet."), width));
|
||||
} else {
|
||||
const maxCount = agentEntries[0][1];
|
||||
for (const [name, count] of agentEntries.slice(0, 15)) {
|
||||
const ratio = maxCount > 0 ? count / maxCount : 0;
|
||||
const filled = Math.round(ratio * barWidth);
|
||||
const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
|
||||
|
||||
allLines.push(truncateToWidth(
|
||||
" " +
|
||||
theme.fg("accent", name.padEnd(16)) + " " +
|
||||
theme.fg("success", bar) + " " +
|
||||
theme.fg("muted", `${count}`),
|
||||
width,
|
||||
));
|
||||
}
|
||||
}
|
||||
allLines.push("");
|
||||
|
||||
// Per-agent average durations
|
||||
if (history.length > 0) {
|
||||
allLines.push(truncateToWidth(
|
||||
" " + theme.fg("accent", theme.bold("Average Duration by Agent")),
|
||||
width,
|
||||
));
|
||||
allLines.push("");
|
||||
|
||||
const durByAgent: Record<string, number[]> = {};
|
||||
for (const run of history) {
|
||||
if (!durByAgent[run.name]) durByAgent[run.name] = [];
|
||||
durByAgent[run.name].push(run.duration);
|
||||
}
|
||||
|
||||
const durEntries = Object.entries(durByAgent).sort((a, b) => {
|
||||
const avgA = a[1].reduce((s, v) => s + v, 0) / a[1].length;
|
||||
const avgB = b[1].reduce((s, v) => s + v, 0) / b[1].length;
|
||||
return avgB - avgA;
|
||||
});
|
||||
|
||||
for (const [name, durations] of durEntries) {
|
||||
const avg = durations.reduce((s, v) => s + v, 0) / durations.length;
|
||||
const min = Math.min(...durations);
|
||||
const max = Math.max(...durations);
|
||||
|
||||
allLines.push(truncateToWidth(
|
||||
" " +
|
||||
theme.fg("accent", name.padEnd(16)) +
|
||||
theme.fg("dim", " avg: ") + theme.fg("success", fmtDuration(Math.round(avg)).padEnd(8)) +
|
||||
theme.fg("dim", " min: ") + theme.fg("muted", fmtDuration(min).padEnd(8)) +
|
||||
theme.fg("dim", " max: ") + theme.fg("muted", fmtDuration(max).padEnd(8)) +
|
||||
theme.fg("dim", " runs: ") + theme.fg("muted", `${durations.length}`),
|
||||
width,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
const visible = allLines.slice(offset);
|
||||
lines.push(...visible);
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
// ── Commands ───────────────────────────────────────────────────────
|
||||
|
||||
pi.registerCommand("dashboard", {
|
||||
description: "Open Agent Dashboard overlay. Args: clear",
|
||||
handler: async (args, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
const arg = (args || "").trim().toLowerCase();
|
||||
|
||||
if (arg === "clear") {
|
||||
stopTick();
|
||||
clearState();
|
||||
startTick();
|
||||
ctx.ui.notify("📊 Dashboard: All data cleared.", "info");
|
||||
updateWidget();
|
||||
return;
|
||||
}
|
||||
|
||||
await openOverlay(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
// ── Event Handlers ─────────────────────────────────────────────────
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
stopTick();
|
||||
widgetCtx = ctx;
|
||||
clearState();
|
||||
startTick();
|
||||
updateWidget();
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async (_event, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("agent_end", async (_event, ctx) => {
|
||||
widgetCtx = ctx;
|
||||
updateWidget();
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, _ctx) => {
|
||||
try {
|
||||
const toolName = event.toolName;
|
||||
if (!TRACKED_TOOLS.has(toolName)) return undefined;
|
||||
|
||||
const input = event.input;
|
||||
const now = Date.now();
|
||||
const callId = event.toolCallId;
|
||||
lastActivityTs = now;
|
||||
|
||||
if (toolName === "dispatch_agent") {
|
||||
const agentName = (input.agent as string) || "unknown";
|
||||
const task = (input.task as string) || "";
|
||||
const id = `team:${agentName}:${shortId()}`;
|
||||
|
||||
const tracked: TrackedAgent = {
|
||||
id,
|
||||
name: agentName,
|
||||
iface: "team",
|
||||
status: "running",
|
||||
task,
|
||||
startedAt: now,
|
||||
elapsed: 0,
|
||||
toolCount: 0,
|
||||
lastText: "",
|
||||
turnCount: 1,
|
||||
teamName: agentName,
|
||||
};
|
||||
|
||||
activeAgents.set(id, tracked);
|
||||
pendingCalls.set(callId, { agentId: id, ts: now });
|
||||
|
||||
} else if (toolName === "subagent_create") {
|
||||
const task = (input.task as string) || "";
|
||||
const id = `sub:create:${shortId()}`;
|
||||
|
||||
const tracked: TrackedAgent = {
|
||||
id,
|
||||
name: "Subagent",
|
||||
iface: "subagent",
|
||||
status: "running",
|
||||
task,
|
||||
startedAt: now,
|
||||
elapsed: 0,
|
||||
toolCount: 0,
|
||||
lastText: "",
|
||||
turnCount: 1,
|
||||
};
|
||||
|
||||
activeAgents.set(id, tracked);
|
||||
pendingCalls.set(callId, { agentId: id, ts: now });
|
||||
|
||||
} else if (toolName === "subagent_continue") {
|
||||
// Always create a new tracking entry using the widget's ID from input
|
||||
const subId = input.id;
|
||||
const prompt = (input.prompt as string) || "";
|
||||
const id = `sub:cont:${subId}:${shortId()}`;
|
||||
|
||||
const tracked: TrackedAgent = {
|
||||
id,
|
||||
name: `Subagent #${subId}`,
|
||||
iface: "subagent",
|
||||
status: "running",
|
||||
task: prompt,
|
||||
startedAt: now,
|
||||
elapsed: 0,
|
||||
toolCount: 0,
|
||||
lastText: "",
|
||||
turnCount: 1,
|
||||
};
|
||||
|
||||
activeAgents.set(id, tracked);
|
||||
pendingCalls.set(callId, { agentId: id, ts: now });
|
||||
|
||||
} else if (toolName === "run_chain") {
|
||||
const task = (input.task as string) || "";
|
||||
const id = `chain:${shortId()}`;
|
||||
|
||||
const tracked: TrackedAgent = {
|
||||
id,
|
||||
name: "chain",
|
||||
iface: "chain",
|
||||
status: "running",
|
||||
task,
|
||||
startedAt: now,
|
||||
elapsed: 0,
|
||||
toolCount: 0,
|
||||
lastText: "",
|
||||
turnCount: 1,
|
||||
chainName: "pipeline",
|
||||
};
|
||||
|
||||
activeAgents.set(id, tracked);
|
||||
pendingCalls.set(callId, { agentId: id, ts: now });
|
||||
}
|
||||
|
||||
// Ensure tick is running when we have active agents
|
||||
startTick();
|
||||
updateWidget();
|
||||
} catch {}
|
||||
|
||||
return undefined;
|
||||
});
|
||||
|
||||
pi.on("tool_execution_end", async (event) => {
|
||||
try {
|
||||
const toolName = event.toolName;
|
||||
if (!TRACKED_TOOLS.has(toolName)) return;
|
||||
|
||||
const now = Date.now();
|
||||
const callId = event.toolCallId;
|
||||
lastActivityTs = now;
|
||||
|
||||
const pending = pendingCalls.get(callId);
|
||||
if (pending) {
|
||||
pendingCalls.delete(callId);
|
||||
|
||||
const agent = activeAgents.get(pending.agentId);
|
||||
if (agent) {
|
||||
agent.status = event.isError ? "error" : "done";
|
||||
agent.endedAt = now;
|
||||
agent.elapsed = now - agent.startedAt;
|
||||
|
||||
// Extract result preview if available
|
||||
try {
|
||||
const result = event.result;
|
||||
if (result?.content) {
|
||||
for (const block of result.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
agent.lastText = block.text.slice(0, 200);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Move to history
|
||||
addToHistory(agent);
|
||||
activeAgents.delete(pending.agentId);
|
||||
}
|
||||
}
|
||||
|
||||
updateWidget();
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
* 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.
|
||||
* 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
|
||||
@@ -20,11 +21,16 @@
|
||||
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 { 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 {
|
||||
@@ -46,6 +52,7 @@ interface AgentState {
|
||||
sessionFile: string | null;
|
||||
runCount: number;
|
||||
timer?: ReturnType<typeof setInterval>;
|
||||
proc?: ChildProcess;
|
||||
}
|
||||
|
||||
// ── Display Name Helper ──────────────────────────
|
||||
@@ -136,6 +143,7 @@ function scanAgentDirs(cwd: string): AgentDef[] {
|
||||
|
||||
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 = "";
|
||||
@@ -144,17 +152,40 @@ export default function (pi: ExtensionAPI) {
|
||||
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) {
|
||||
// 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 {
|
||||
@@ -166,7 +197,6 @@ export default function (pi: ExtensionAPI) {
|
||||
teams = {};
|
||||
}
|
||||
|
||||
// If no teams defined, create a default "all" team
|
||||
if (Object.keys(teams).length === 0) {
|
||||
teams = { all: allAgentDefs.map(d => d.name) };
|
||||
}
|
||||
@@ -196,11 +226,24 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-size grid columns based on team size
|
||||
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[] {
|
||||
@@ -223,7 +266,6 @@ export default function (pi: ExtensionAPI) {
|
||||
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)}%`;
|
||||
@@ -252,7 +294,7 @@ export default function (pi: ExtensionAPI) {
|
||||
];
|
||||
}
|
||||
|
||||
function updateWidget() {
|
||||
function doUpdateWidget() {
|
||||
if (!widgetCtx) return;
|
||||
|
||||
widgetCtx.ui.setWidget("agent-team", (_tui: any, theme: any) => {
|
||||
@@ -302,6 +344,7 @@ export default function (pi: ExtensionAPI) {
|
||||
agentName: string,
|
||||
task: string,
|
||||
ctx: any,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ output: string; exitCode: number; elapsed: number }> {
|
||||
const key = agentName.toLowerCase();
|
||||
const state = agentStates.get(key);
|
||||
@@ -321,29 +364,29 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
|
||||
// Reset state for new run
|
||||
state.status = "running";
|
||||
state.task = task;
|
||||
state.toolCount = 0;
|
||||
state.elapsed = 0;
|
||||
state.lastWork = "";
|
||||
state.contextPct = 0;
|
||||
state.runCount++;
|
||||
updateWidget();
|
||||
scheduleWidgetUpdate();
|
||||
|
||||
const startTime = Date.now();
|
||||
state.timer = setInterval(() => {
|
||||
state.elapsed = Date.now() - startTime;
|
||||
updateWidget();
|
||||
scheduleWidgetUpdate();
|
||||
}, 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",
|
||||
@@ -355,7 +398,6 @@ export default function (pi: ExtensionAPI) {
|
||||
"--session", agentSessionFile,
|
||||
];
|
||||
|
||||
// Continue existing session if we have one
|
||||
if (state.sessionFile) {
|
||||
args.push("-c");
|
||||
}
|
||||
@@ -363,13 +405,44 @@ export default function (pi: ExtensionAPI) {
|
||||
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);
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
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");
|
||||
@@ -388,23 +461,23 @@ export default function (pi: ExtensionAPI) {
|
||||
const full = textChunks.join("");
|
||||
const last = full.split("\n").filter((l: string) => l.trim()).pop() || "";
|
||||
state.lastWork = last;
|
||||
updateWidget();
|
||||
scheduleWidgetUpdate();
|
||||
}
|
||||
} else if (event.type === "tool_execution_start") {
|
||||
state.toolCount++;
|
||||
updateWidget();
|
||||
scheduleWidgetUpdate();
|
||||
} else if (event.type === "message_end") {
|
||||
const msg = event.message;
|
||||
if (msg?.usage && contextWindow > 0) {
|
||||
state.contextPct = ((msg.usage.input || 0) / contextWindow) * 100;
|
||||
updateWidget();
|
||||
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;
|
||||
updateWidget();
|
||||
scheduleWidgetUpdate();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
@@ -415,6 +488,12 @@ export default function (pi: ExtensionAPI) {
|
||||
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);
|
||||
@@ -427,35 +506,45 @@ export default function (pi: ExtensionAPI) {
|
||||
|
||||
clearInterval(state.timer);
|
||||
state.elapsed = Date.now() - startTime;
|
||||
state.status = code === 0 ? "done" : "error";
|
||||
|
||||
// Mark session file as available for resume
|
||||
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() || "";
|
||||
updateWidget();
|
||||
flushWidgetUpdate();
|
||||
|
||||
ctx.ui.notify(
|
||||
`${displayName(state.def.name)} ${state.status} in ${Math.round(state.elapsed / 1000)}s`,
|
||||
state.status === "done" ? "success" : "error"
|
||||
);
|
||||
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`;
|
||||
|
||||
resolve({
|
||||
output: full,
|
||||
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}`;
|
||||
updateWidget();
|
||||
resolve({
|
||||
flushWidgetUpdate();
|
||||
safeResolve({
|
||||
output: `Error spawning agent: ${err.message}`,
|
||||
exitCode: 1,
|
||||
elapsed: Date.now() - startTime,
|
||||
@@ -464,18 +553,18 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
|
||||
// ── dispatch_agent Tool (registered at top level) ──
|
||||
// ── dispatch_agent Tool (single) ─────────────
|
||||
|
||||
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.",
|
||||
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) {
|
||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
||||
const { agent, task } = params as { agent: string; task: string };
|
||||
|
||||
try {
|
||||
@@ -486,7 +575,7 @@ export default function (pi: ExtensionAPI) {
|
||||
});
|
||||
}
|
||||
|
||||
const result = await dispatchAgent(agent, task, ctx);
|
||||
const result = await dispatchAgent(agent, task, ctx, signal);
|
||||
|
||||
const truncated = result.output.length > 8000
|
||||
? result.output.slice(0, 8000) + "\n\n... [truncated]"
|
||||
@@ -534,7 +623,6 @@ export default function (pi: ExtensionAPI) {
|
||||
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 || "?"}`) +
|
||||
@@ -560,6 +648,120 @@ export default function (pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── 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", {
|
||||
@@ -583,7 +785,7 @@ export default function (pi: ExtensionAPI) {
|
||||
const idx = options.indexOf(choice);
|
||||
const name = teamNames[idx];
|
||||
activateTeam(name);
|
||||
updateWidget();
|
||||
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");
|
||||
},
|
||||
@@ -619,7 +821,7 @@ export default function (pi: ExtensionAPI) {
|
||||
if (n >= 1 && n <= 6) {
|
||||
gridCols = n;
|
||||
_ctx.ui.notify(`Grid set to ${gridCols} columns`, "info");
|
||||
updateWidget();
|
||||
flushWidgetUpdate();
|
||||
} else {
|
||||
_ctx.ui.notify("Usage: /agents-grid <1-6>", "error");
|
||||
}
|
||||
@@ -629,7 +831,6 @@ export default function (pi: ExtensionAPI) {
|
||||
// ── 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");
|
||||
@@ -639,7 +840,7 @@ export default function (pi: ExtensionAPI) {
|
||||
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.
|
||||
agents using the dispatch_agent or dispatch_agents tools.
|
||||
|
||||
## Active Team: ${activeTeamName}
|
||||
Members: ${teamMembers}
|
||||
@@ -648,17 +849,20 @@ You can ONLY dispatch to agents listed below. Do not attempt to dispatch to agen
|
||||
## 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
|
||||
- **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 to get work done
|
||||
- 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
|
||||
|
||||
@@ -670,7 +874,6 @@ ${agentCatalog}`,
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -689,14 +892,12 @@ ${agentCatalog}`,
|
||||
|
||||
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"]);
|
||||
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(", ");
|
||||
@@ -708,9 +909,8 @@ ${agentCatalog}`,
|
||||
`/agents-grid <1-6> Set grid column count`,
|
||||
"info",
|
||||
);
|
||||
updateWidget();
|
||||
flushWidgetUpdate();
|
||||
|
||||
// Footer: model | team | context bar
|
||||
_ctx.ui.setFooter((_tui, theme, _footerData) => ({
|
||||
dispose: () => {},
|
||||
invalidate() {},
|
||||
@@ -721,9 +921,13 @@ ${agentCatalog}`,
|
||||
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);
|
||||
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)));
|
||||
|
||||
@@ -731,4 +935,10 @@ ${agentCatalog}`,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
// ── Cleanup on exit ──────────────────────────
|
||||
|
||||
process.on("exit", () => killAllAgents());
|
||||
process.on("SIGINT", () => { killAllAgents(); process.exit(0); });
|
||||
process.on("SIGTERM", () => { killAllAgents(); process.exit(0); });
|
||||
}
|
||||
|
||||
1100
extensions/observatory.ts
Normal file
1100
extensions/observatory.ts
Normal file
File diff suppressed because it is too large
Load Diff
41
extensions/stop.ts
Normal file
41
extensions/stop.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Stop — Immediately interrupt the active chat session
|
||||
*
|
||||
* Registers a /stop slash command that aborts the current agent turn.
|
||||
* Also supports /stop with a reason message for logging clarity.
|
||||
*
|
||||
* Usage: pi -e extensions/stop.ts
|
||||
*
|
||||
* Commands:
|
||||
* /stop — abort the current agent turn immediately
|
||||
* /stop <reason> — abort with a logged reason
|
||||
*/
|
||||
|
||||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { applyExtensionDefaults } from "./themeMap.ts";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let activeCtx: ExtensionContext | undefined;
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
applyExtensionDefaults(import.meta.url, ctx);
|
||||
activeCtx = ctx;
|
||||
});
|
||||
|
||||
pi.on("session_switch", async (_event, ctx) => {
|
||||
activeCtx = ctx;
|
||||
});
|
||||
|
||||
pi.registerCommand("stop", {
|
||||
description: "Immediately interrupt the active agent turn. Usage: /stop [reason]",
|
||||
handler: async (args, ctx) => {
|
||||
activeCtx = ctx;
|
||||
const reason = (args || "").trim();
|
||||
ctx.abort();
|
||||
const msg = reason
|
||||
? `🛑 Session aborted: ${reason}`
|
||||
: "🛑 Session aborted.";
|
||||
ctx.ui.notify(msg, "warning");
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -22,10 +22,12 @@ import { fileURLToPath } from "url";
|
||||
//
|
||||
export const THEME_MAP: Record<string, string> = {
|
||||
"agent-chain": "midnight-ocean", // deep sequential pipeline
|
||||
"agent-dashboard": "tokyo-night", // unified monitoring hub
|
||||
"agent-team": "dracula", // rich orchestration palette
|
||||
"cross-agent": "ocean-breeze", // cross-boundary, connecting
|
||||
"damage-control": "gruvbox", // grounded, earthy safety
|
||||
"minimal": "synthwave", // synthwave by default now!
|
||||
"observatory": "cyberpunk", // futuristic observation deck
|
||||
"pi-pi": "rose-pine", // warm creative meta-agent
|
||||
"pure-focus": "everforest", // calm, distraction-free
|
||||
"purpose-gate": "tokyo-night", // intentional, sharp focus
|
||||
|
||||
Reference in New Issue
Block a user