feat: add improved pi agent with observatory, dashboard, and pledge-now-pay-later

This commit is contained in:
Azreen Jamal
2026-03-01 23:41:24 +08:00
parent ae242436c9
commit f832b913d5
99 changed files with 20949 additions and 74 deletions

View 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 {}
});
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

41
extensions/stop.ts Normal file
View 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");
},
});
}

View File

@@ -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