972 lines
30 KiB
TypeScript
972 lines
30 KiB
TypeScript
/**
|
|
* 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 {}
|
|
});
|
|
}
|