From e9c533f18684084d517be7405234e03d51016495 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 08:26:37 -0500 Subject: [PATCH 1/5] feat: add TUI dashboard extension for at-a-glance system status Renders a persistent widget above the editor showing: - Pi version (with update indicator if behind latest npm) - Slack bridge status (live HTTP probe) - Session health (control-agent, sentry-agent, dev-agents) - Todo stats (active/done/total) - Worktree count - Current model and uptime Refreshes every 30s with zero LLM token cost. Admin can attach to the running baudbot tmux session and see health without sending any messages. Also adds /dashboard command for immediate refresh. --- pi/extensions/dashboard.ts | 542 +++++++++++++++++++++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 pi/extensions/dashboard.ts diff --git a/pi/extensions/dashboard.ts b/pi/extensions/dashboard.ts new file mode 100644 index 0000000..82aee69 --- /dev/null +++ b/pi/extensions/dashboard.ts @@ -0,0 +1,542 @@ +/** + * Baudbot Dashboard Extension + * + * Renders a persistent status widget above the editor so an admin can + * see system health at a glance WITHOUT querying the agent. + * + * Displays: + * • Pi version (running vs latest from npm) + * • Slack bridge status (up/down via HTTP probe) + * • Sessions (control-agent, sentry-agent, dev-agents) + * • Active todos (in-progress count) + * • Worktrees (active count) + * • Uptime (how long this session has been running) + * • Current model + * + * Refreshes automatically every 30 seconds with zero LLM token cost. + * Use /dashboard to force an immediate refresh. + */ + +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { existsSync, readdirSync, readFileSync, readlinkSync, statSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { execSync } from "node:child_process"; + +const REFRESH_INTERVAL_MS = 30_000; // 30 seconds +const SOCKET_DIR = join(homedir(), ".pi", "session-control"); +const WORKTREES_DIR = join(homedir(), "workspace", "worktrees"); +const TODOS_DIR = join(homedir(), ".pi", "todos"); +const BRIDGE_URL = "http://127.0.0.1:7890/send"; +const BAUDBOT_DEPLOY = "/opt/baudbot"; + +// ── Data types ────────────────────────────────────────────────────────────── + +interface LastEvent { + source: string; // "slack", "chat", "heartbeat", "sentry", "rpc", etc. + summary: string; // short description + time: Date; +} + +interface HeartbeatInfo { + enabled: boolean; + lastRunAt: number | null; + totalRuns: number; + healthy: boolean; // last check had no failures +} + +interface DashboardData { + piVersion: string; + piLatest: string | null; + baudbotVersion: string | null; + baudbotSha: string | null; + bridgeUp: boolean; + bridgeType: string | null; + sessions: { name: string; alive: boolean }[]; + devAgentCount: number; + devAgentNames: string[]; + todosInProgress: number; + todosDone: number; + todosTotal: number; + worktreeCount: number; + uptimeMs: number; + lastRefresh: Date; + heartbeat: HeartbeatInfo; + lastEvent: LastEvent | null; +} + +// ── Data collectors ───────────────────────────────────────────────────────── + +function getBaudbotVersion(): { version: string | null; sha: string | null } { + try { + const currentLink = join(BAUDBOT_DEPLOY, "current"); + const target = readlinkSync(currentLink); + // target is like /opt/baudbot/releases/ + const sha = target.split("/").pop() ?? null; + + let version: string | null = null; + try { + const pkg = JSON.parse(readFileSync(join(currentLink, "package.json"), "utf-8")); + version = pkg.version ?? null; + } catch {} + + return { version, sha: sha ? sha.substring(0, 7) : null }; + } catch { + return { version: null, sha: null }; + } +} + +function getPiVersion(): string { + try { + // process.execPath is the node binary: /bin/node + // pi is installed at: /lib/node_modules/@mariozechner/pi-coding-agent/ + const prefix = join(process.execPath, "..", ".."); + const piPkg = join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent", "package.json"); + const pkg = JSON.parse(readFileSync(piPkg, "utf-8")); + return pkg.version ?? "?"; + } catch { + return "?"; + } +} + +let cachedLatestVersion: string | null = null; +let lastVersionCheck = 0; +const VERSION_CHECK_INTERVAL = 3600_000; // 1 hour + +async function getPiLatestVersion(): Promise { + const now = Date.now(); + if (cachedLatestVersion && now - lastVersionCheck < VERSION_CHECK_INTERVAL) { + return cachedLatestVersion; + } + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const res = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest", { + signal: controller.signal, + }); + clearTimeout(timeout); + if (res.ok) { + const data = (await res.json()) as { version?: string }; + cachedLatestVersion = data.version ?? null; + lastVersionCheck = now; + } + } catch { + // keep cached value + } + return cachedLatestVersion; +} + +function detectBridgeType(): string | null { + try { + const out = execSync("ps -eo args 2>/dev/null | grep -E 'broker-bridge|bridge\\.mjs' | grep -v grep", { + encoding: "utf-8", timeout: 3000, + }).trim(); + if (out.includes("broker-bridge")) return "broker"; + if (out.includes("bridge.mjs")) return "socket"; + return null; + } catch { + return null; + } +} + +async function checkBridge(): Promise { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 3000); + const res = await fetch(BRIDGE_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + signal: controller.signal, + }); + clearTimeout(timeout); + return res.status === 400; + } catch { + return false; + } +} + +function getSessions(): { name: string; alive: boolean }[] { + const results: { name: string; alive: boolean }[] = []; + const expected = ["control-agent", "sentry-agent"]; + + try { + const files = readdirSync(SOCKET_DIR); + const aliases = files.filter((f) => f.endsWith(".alias")); + + for (const alias of expected) { + const aliasFile = `${alias}.alias`; + if (!aliases.includes(aliasFile)) { + results.push({ name: alias, alive: false }); + continue; + } + try { + const target = readlinkSync(join(SOCKET_DIR, aliasFile)); + const sockPath = join(SOCKET_DIR, target); + results.push({ name: alias, alive: existsSync(sockPath) }); + } catch { + results.push({ name: alias, alive: false }); + } + } + } catch { + for (const alias of expected) { + results.push({ name: alias, alive: false }); + } + } + + return results; +} + +function getDevAgents(): { count: number; names: string[] } { + try { + const files = readdirSync(SOCKET_DIR); + const agents = files + .filter((f) => f.endsWith(".alias") && f.startsWith("dev-agent-")) + .map((f) => f.replace(".alias", "")); + return { count: agents.length, names: agents }; + } catch { + return { count: 0, names: [] }; + } +} + +function getTodoStats(): { inProgress: number; done: number; total: number } { + let inProgress = 0; + let done = 0; + let total = 0; + + if (!existsSync(TODOS_DIR)) return { inProgress, done, total }; + + try { + const files = readdirSync(TODOS_DIR).filter((f) => f.endsWith(".md")); + total = files.length; + for (const file of files) { + try { + const content = readFileSync(join(TODOS_DIR, file), "utf-8"); + if (content.includes('"status": "in-progress"')) inProgress++; + else if (content.includes('"status": "done"')) done++; + } catch { + continue; + } + } + } catch {} + + return { inProgress, done, total }; +} + +function getWorktreeCount(): number { + if (!existsSync(WORKTREES_DIR)) return 0; + try { + return readdirSync(WORKTREES_DIR).filter((entry) => { + try { return statSync(join(WORKTREES_DIR, entry)).isDirectory(); } + catch { return false; } + }).length; + } catch { + return 0; + } +} + +function readHeartbeatState(ctx: ExtensionContext): HeartbeatInfo { + const info: HeartbeatInfo = { enabled: true, lastRunAt: null, totalRuns: 0, healthy: true }; + + // Read the latest heartbeat-state entry from the session + for (const entry of ctx.sessionManager.getEntries()) { + const e = entry as { type: string; customType?: string; data?: any }; + if (e.type === "custom" && e.customType === "heartbeat-state" && e.data) { + if (typeof e.data.lastRunAt === "number") info.lastRunAt = e.data.lastRunAt; + if (typeof e.data.totalRuns === "number") info.totalRuns = e.data.totalRuns; + if (Array.isArray(e.data.lastFailures)) info.healthy = e.data.lastFailures.length === 0; + } + } + + // Check env for enabled state + const env = process.env.HEARTBEAT_ENABLED?.trim().toLowerCase(); + info.enabled = !(env === "0" || env === "false" || env === "no"); + + return info; +} + +// ── Rendering ─────────────────────────────────────────────────────────────── + +function formatAgo(date: Date): string { + const ms = Date.now() - date.getTime(); + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + if (h > 0) return `${h}h ${m % 60}m ago`; + if (m > 0) return `${m}m ago`; + if (s > 10) return `${s}s ago`; + return "just now"; +} + +function formatUptime(ms: number): string { + const s = Math.floor(ms / 1000); + const m = Math.floor(s / 60); + const h = Math.floor(m / 60); + const d = Math.floor(h / 24); + if (d > 0) return `${d}d ${h % 24}h ${m % 60}m`; + if (h > 0) return `${h}h ${m % 60}m`; + if (m > 0) return `${m}m`; + return `${s}s`; +} + +function pad(left: string, right: string, width: number, indent: number = 2): string { + const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right) - indent); + return truncateToWidth(`${left}${" ".repeat(gap)}${right}${"".padEnd(indent)}`, width); +} + +function renderDashboard( + data: DashboardData, + theme: ExtensionContext["ui"]["theme"], + width: number +): string[] { + const lines: string[] = []; + const dim = (s: string) => theme.fg("dim", s); + const bar = "─"; + + // ── Top border with title ── + const title = " baudbot "; + const titleStyled = theme.fg("accent", theme.bold(title)); + const titleLen = visibleWidth(title); + const sideL = Math.max(1, Math.floor((width - titleLen) / 2)); + const sideR = Math.max(1, width - sideL - titleLen); + lines.push(truncateToWidth(dim(bar.repeat(sideL)) + titleStyled + dim(bar.repeat(sideR)), width)); + + // ── Row 1: baudbot version │ pi version │ bridge │ uptime ── + let bbDisplay: string; + if (data.baudbotVersion && data.baudbotSha) { + bbDisplay = dim(`v${data.baudbotVersion}`) + dim(`@${data.baudbotSha}`); + } else if (data.baudbotSha) { + bbDisplay = dim(`@${data.baudbotSha}`); + } else { + bbDisplay = dim("?"); + } + + let piDisplay: string; + if (data.piLatest && data.piLatest !== data.piVersion) { + piDisplay = theme.fg("warning", `v${data.piVersion}*`); + } else if (data.piLatest) { + piDisplay = theme.fg("success", `v${data.piVersion}`); + } else { + piDisplay = dim(`v${data.piVersion}`); + } + + const bridgeIcon = data.bridgeUp ? theme.fg("success", "●") : theme.fg("error", "●"); + const bridgeLabel = data.bridgeUp ? "up" : theme.fg("error", "DOWN"); + const bridgeTypeStr = data.bridgeType ? dim(` ${data.bridgeType}`) : ""; + + const row1Left = ` baudbot ${bbDisplay} ${dim("│")} pi ${piDisplay} ${dim("│")} ${bridgeIcon} bridge ${bridgeLabel}${bridgeTypeStr}`; + const row1Right = dim(`up ${formatUptime(data.uptimeMs)}`); + lines.push(pad(row1Left, row1Right, width)); + + // ── Row 2: sessions ── + const parts: string[] = []; + for (const s of data.sessions) { + const icon = s.alive ? theme.fg("success", "●") : theme.fg("error", "●"); + const label = s.alive ? dim(s.name) : theme.fg("error", s.name); + parts.push(`${icon} ${label}`); + } + if (data.devAgentCount > 0) { + parts.push( + theme.fg("accent", `● ${data.devAgentCount} dev-agent${data.devAgentCount > 1 ? "s" : ""}`) + ); + } + + const row2Left = ` ${parts.join(" ")}`; + lines.push(pad(row2Left, "", width)); + + // ── Row 3: todos │ worktrees │ refresh time ── + const todoParts: string[] = []; + if (data.todosInProgress > 0) { + todoParts.push(theme.fg("accent", `${data.todosInProgress} active`)); + } + todoParts.push(dim(`${data.todosDone} done`)); + todoParts.push(dim(`${data.todosTotal} total`)); + + const todoStr = `todos ${todoParts.join(dim(" / "))}`; + + const wtIcon = data.worktreeCount > 0 ? theme.fg("accent", "●") : dim("○"); + const wtLabel = data.worktreeCount > 0 + ? theme.fg("accent", `${data.worktreeCount}`) + : dim("0"); + const wtStr = `${wtIcon} ${wtLabel} worktree${data.worktreeCount !== 1 ? "s" : ""}`; + + const refreshTime = data.lastRefresh.toLocaleTimeString("en-US", { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }); + + const row3Left = ` ${todoStr} ${dim("│")} ${wtStr}`; + const row3Right = dim(`⟳ ${refreshTime}`); + lines.push(pad(row3Left, row3Right, width)); + + // ── Row 4: heartbeat │ last event ── + let hbStr: string; + if (!data.heartbeat.enabled) { + hbStr = `${theme.fg("warning", "♥")} ${theme.fg("warning", "paused")}`; + } else if (data.heartbeat.lastRunAt) { + const ago = formatAgo(new Date(data.heartbeat.lastRunAt)); + const icon = data.heartbeat.healthy ? theme.fg("success", "♥") : theme.fg("error", "♥"); + const label = data.heartbeat.healthy ? dim(ago) : theme.fg("error", ago); + hbStr = `${icon} ${label}`; + } else { + hbStr = `${dim("♥")} ${dim("pending")}`; + } + + let eventStr: string; + if (data.lastEvent) { + const ago = formatAgo(data.lastEvent.time); + const src = dim(`[${data.lastEvent.source}]`); + const summary = truncateToWidth(data.lastEvent.summary, 40); + eventStr = `${src} ${summary} ${dim(ago)}`; + } else { + eventStr = dim("no events yet"); + } + + const row4Left = ` heartbeat ${hbStr} ${dim("│")} ${eventStr}`; + lines.push(pad(row4Left, "", width)); + + // ── Bottom border ── + lines.push(truncateToWidth(dim(bar.repeat(width)), width)); + + return lines; +} + +// ── Extension ─────────────────────────────────────────────────────────────── + +export default function dashboardExtension(pi: ExtensionAPI): void { + let timer: ReturnType | null = null; + const startTime = Date.now(); + const piVersion = getPiVersion(); + + // Mutable data ref — widget's render() reads from this on every frame + let data: DashboardData | null = null; + let savedCtx: ExtensionContext | null = null; + let lastEvent: LastEvent | null = null; + + async function refresh() { + const [bridgeUp, piLatest] = await Promise.all([ + checkBridge(), + getPiLatestVersion(), + ]); + + const sessions = getSessions(); + const devAgents = getDevAgents(); + const todoStats = getTodoStats(); + const worktreeCount = getWorktreeCount(); + + const baudbot = getBaudbotVersion(); + + const bridgeType = detectBridgeType(); + + const heartbeat = savedCtx ? readHeartbeatState(savedCtx) : { enabled: true, lastRunAt: null, totalRuns: 0, healthy: true }; + + data = { + piVersion, + piLatest, + baudbotVersion: baudbot.version, + baudbotSha: baudbot.sha, + bridgeUp, + bridgeType, + sessions, + devAgentCount: devAgents.count, + devAgentNames: devAgents.names, + todosInProgress: todoStats.inProgress, + todosDone: todoStats.done, + todosTotal: todoStats.total, + worktreeCount, + uptimeMs: Date.now() - startTime, + lastRefresh: new Date(), + heartbeat, + lastEvent, + }; + } + + function installWidget(ctx: ExtensionContext) { + if (!ctx.hasUI) return; + + ctx.ui.setWidget("baudbot-dashboard", (_tui, theme) => ({ + render(width: number): string[] { + if (!data) { + return [ + theme.fg("dim", "─".repeat(width)), + theme.fg("dim", " baudbot dashboard loading…"), + theme.fg("dim", "─".repeat(width)), + ]; + } + // Update uptime live on every render + data.uptimeMs = Date.now() - startTime; + return renderDashboard(data, theme, width); + }, + invalidate() {}, + })); + } + + // /dashboard command — force immediate refresh + pi.registerCommand("dashboard", { + description: "Refresh the baudbot status dashboard", + handler: async (_args, ctx) => { + await refresh(); + ctx.ui.notify("Dashboard refreshed", "info"); + }, + }); + + pi.on("session_start", async (_event, ctx) => { + savedCtx = ctx; + await refresh(); + installWidget(ctx); + + // Periodic refresh + timer = setInterval(async () => { + try { await refresh(); } + catch {} + }, REFRESH_INTERVAL_MS); + }); + + // Track last event from inbound messages + pi.on("message_start", async (event) => { + const msg = event.message as any; + if (!msg) return; + + if (msg.role === "user") { + const text = Array.isArray(msg.content) + ? msg.content.find((c: any) => c.type === "text")?.text ?? "" + : String(msg.content ?? ""); + + if (text.includes("EXTERNAL_UNTRUSTED_CONTENT")) { + // Slack message — extract source info + const fromMatch = text.match(/From:\s*(<@[^>]+>|[^\n]+)/); + const from = fromMatch ? fromMatch[1].trim() : "user"; + lastEvent = { source: "slack", summary: from, time: new Date() }; + } else if (text.length > 0) { + const preview = text.substring(0, 50).replace(/\n/g, " "); + lastEvent = { source: "chat", summary: preview, time: new Date() }; + } + } else if (msg.customType === "heartbeat") { + lastEvent = { source: "heartbeat", summary: "health check fired", time: new Date() }; + } else if (msg.customType === "session-message") { + // RPC / session-control message + const text = String(msg.content ?? "").substring(0, 50).replace(/\n/g, " "); + if (text.includes("EXTERNAL_UNTRUSTED_CONTENT")) { + const fromMatch = text.match(/From:\s*(<@[^>]+>|[^\n]+)/); + const from = fromMatch ? fromMatch[1].trim() : "unknown"; + lastEvent = { source: "slack", summary: from, time: new Date() }; + } else if (text.includes("sentry") || text.includes("Sentry")) { + lastEvent = { source: "sentry", summary: text.substring(0, 40), time: new Date() }; + } else { + lastEvent = { source: "rpc", summary: text || "message", time: new Date() }; + } + } + + // Update dashboard data immediately + if (data && lastEvent) { + data.lastEvent = lastEvent; + } + }); + + pi.on("session_shutdown", async () => { + if (timer) { + clearInterval(timer); + timer = null; + } + }); +} From aa3df5976e194587ca5aaf01a529352ba5b46360 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 08:46:00 -0500 Subject: [PATCH 2/5] fix: track last event via before_agent_start for all message sources message_start only fires for user/assistant/toolResult messages, not custom messages from pi.sendMessage(). Slack messages arrive as session-message custom type and were being missed. before_agent_start fires for ALL inbound messages that trigger an agent turn, including custom messages from the bridge/heartbeat. Also improved the event summary to show the actual message body excerpt alongside the sender. --- pi/extensions/dashboard.ts | 55 +++++++++++++++----------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/pi/extensions/dashboard.ts b/pi/extensions/dashboard.ts index 82aee69..26a1d94 100644 --- a/pi/extensions/dashboard.ts +++ b/pi/extensions/dashboard.ts @@ -492,42 +492,31 @@ export default function dashboardExtension(pi: ExtensionAPI): void { }, REFRESH_INTERVAL_MS); }); - // Track last event from inbound messages - pi.on("message_start", async (event) => { - const msg = event.message as any; - if (!msg) return; - - if (msg.role === "user") { - const text = Array.isArray(msg.content) - ? msg.content.find((c: any) => c.type === "text")?.text ?? "" - : String(msg.content ?? ""); - - if (text.includes("EXTERNAL_UNTRUSTED_CONTENT")) { - // Slack message — extract source info - const fromMatch = text.match(/From:\s*(<@[^>]+>|[^\n]+)/); - const from = fromMatch ? fromMatch[1].trim() : "user"; - lastEvent = { source: "slack", summary: from, time: new Date() }; - } else if (text.length > 0) { - const preview = text.substring(0, 50).replace(/\n/g, " "); - lastEvent = { source: "chat", summary: preview, time: new Date() }; - } - } else if (msg.customType === "heartbeat") { + // Track last event from inbound messages. + // before_agent_start fires for ALL inbound messages — user prompts, custom + // messages (session-message from Slack bridge, heartbeat), etc. + pi.on("before_agent_start", async (event) => { + const prompt = event.prompt ?? ""; + + if (prompt.includes("EXTERNAL_UNTRUSTED_CONTENT")) { + // Slack message via bridge — extract sender + const fromMatch = prompt.match(/From:\s*(<@[^>]+>|[^\n]+)/); + const from = fromMatch ? fromMatch[1].trim() : "user"; + // Extract the actual message content after the --- separator + const bodyMatch = prompt.match(/---\n([\s\S]*?)<<>>/); + const body = bodyMatch ? bodyMatch[1].trim().substring(0, 40).replace(/\n/g, " ") : ""; + const summary = body ? `${from}: ${body}` : from; + lastEvent = { source: "slack", summary, time: new Date() }; + } else if (prompt.includes("Heartbeat")) { lastEvent = { source: "heartbeat", summary: "health check fired", time: new Date() }; - } else if (msg.customType === "session-message") { - // RPC / session-control message - const text = String(msg.content ?? "").substring(0, 50).replace(/\n/g, " "); - if (text.includes("EXTERNAL_UNTRUSTED_CONTENT")) { - const fromMatch = text.match(/From:\s*(<@[^>]+>|[^\n]+)/); - const from = fromMatch ? fromMatch[1].trim() : "unknown"; - lastEvent = { source: "slack", summary: from, time: new Date() }; - } else if (text.includes("sentry") || text.includes("Sentry")) { - lastEvent = { source: "sentry", summary: text.substring(0, 40), time: new Date() }; - } else { - lastEvent = { source: "rpc", summary: text || "message", time: new Date() }; - } + } else if (prompt.includes("#bots-sentry") || prompt.includes("Sentry")) { + const preview = prompt.substring(0, 50).replace(/\n/g, " "); + lastEvent = { source: "sentry", summary: preview, time: new Date() }; + } else if (prompt.length > 0) { + const preview = prompt.substring(0, 50).replace(/\n/g, " "); + lastEvent = { source: "chat", summary: preview, time: new Date() }; } - // Update dashboard data immediately if (data && lastEvent) { data.lastEvent = lastEvent; } From ec2003e3b5fcf3618168b0c45d8d2e4ac61edda8 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 14:45:16 -0500 Subject: [PATCH 3/5] fix: address PR #163 review comments on dashboard extension - Drop grep pipe from detectBridgeType, just use ps + JS includes - Log refresh errors instead of silently swallowing them - Guard ctx.ui.notify with ctx.hasUI check for headless environments --- pi/skills/debug-agent/debug-dashboard.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pi/skills/debug-agent/debug-dashboard.ts b/pi/skills/debug-agent/debug-dashboard.ts index 683aaa8..d77a536 100644 --- a/pi/skills/debug-agent/debug-dashboard.ts +++ b/pi/skills/debug-agent/debug-dashboard.ts @@ -131,7 +131,7 @@ async function getPiLatestVersion(): Promise { function detectBridgeType(): string | null { try { - const out = execSync("ps -eo args 2>/dev/null | grep -E 'broker-bridge|bridge\\.mjs' | grep -v grep", { + const out = execSync("ps -eo args", { encoding: "utf-8", timeout: 3000, }).trim(); if (out.includes("broker-bridge")) return "broker"; @@ -718,7 +718,7 @@ export default function dashboardExtension(pi: ExtensionAPI): void { description: "Refresh the baudbot status dashboard", handler: async (_args, ctx) => { await refresh(); - ctx.ui.notify("Dashboard refreshed", "info"); + if (ctx.hasUI) ctx.ui.notify("Dashboard refreshed", "info"); }, }); @@ -730,7 +730,7 @@ export default function dashboardExtension(pi: ExtensionAPI): void { timer = setInterval(async () => { try { await refresh(); } - catch {} + catch (err) { console.error("Dashboard refresh failed:", err); } }, REFRESH_INTERVAL_MS); }); From 96dc97290b61ba1070d304305e037b759107de48 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 17:20:32 -0500 Subject: [PATCH 4/5] debug-agent: improve dashboard uptime display - Show bridge process uptime inline: bridge broker (up 23m) - Show per-agent session uptimes: control-agent (up 15m) - Remove redundant service uptime (was same as bridge uptime) - Remove extra bottom border line that created visual gap - Parse bridge uptime from ps etime for accurate process lifetime - Parse agent uptimes from session file creation time --- pi/skills/debug-agent/debug-dashboard.ts | 133 +++++++++++++++++++---- 1 file changed, 109 insertions(+), 24 deletions(-) diff --git a/pi/skills/debug-agent/debug-dashboard.ts b/pi/skills/debug-agent/debug-dashboard.ts index d77a536..19242cb 100644 --- a/pi/skills/debug-agent/debug-dashboard.ts +++ b/pi/skills/debug-agent/debug-dashboard.ts @@ -62,14 +62,15 @@ interface DashboardData { baudbotSha: string | null; bridgeUp: boolean; bridgeType: string | null; - sessions: { name: string; alive: boolean }[]; + bridgeUptimeMs: number | null; + sessions: { name: string; alive: boolean; uptimeMs: number | null }[]; devAgentCount: number; devAgentNames: string[]; todosInProgress: number; todosDone: number; todosTotal: number; worktreeCount: number; - uptimeMs: number; + serviceUptimeMs: number | null; lastRefresh: Date; heartbeat: HeartbeatInfo; lastEvent: LastEvent | null; @@ -131,7 +132,7 @@ async function getPiLatestVersion(): Promise { function detectBridgeType(): string | null { try { - const out = execSync("ps -eo args", { + const out = execSync("ps -eo args 2>/dev/null | grep -E 'broker-bridge|bridge\\.mjs' | grep -v grep", { encoding: "utf-8", timeout: 3000, }).trim(); if (out.includes("broker-bridge")) return "broker"; @@ -142,6 +143,37 @@ function detectBridgeType(): string | null { } } +function getBridgeUptime(): number | null { + try { + const out = execSync("ps -eo etime,cmd 2>/dev/null | grep -E 'broker-bridge|bridge\\.mjs' | grep -v grep", { + encoding: "utf-8", timeout: 3000, + }).trim(); + if (!out) return null; + + // Parse etime format: [[dd-]hh:]mm:ss + const etimeStr = out.split(/\s+/)[0]; + const parts = etimeStr.split(/[-:]/); + + let seconds = 0; + if (parts.length === 4) { + // dd-hh:mm:ss + seconds = parseInt(parts[0]) * 86400 + parseInt(parts[1]) * 3600 + parseInt(parts[2]) * 60 + parseInt(parts[3]); + } else if (parts.length === 3) { + // hh:mm:ss + seconds = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]); + } else if (parts.length === 2) { + // mm:ss + seconds = parseInt(parts[0]) * 60 + parseInt(parts[1]); + } else { + return null; + } + + return seconds * 1000; + } catch { + return null; + } +} + async function checkBridge(): Promise { try { const controller = new AbortController(); @@ -159,8 +191,32 @@ async function checkBridge(): Promise { } } -function getSessions(): { name: string; alive: boolean }[] { - const results: { name: string; alive: boolean }[] = []; +function getSessionUptime(sessionName: string): number | null { + try { + const aliasFile = join(SOCKET_DIR, `${sessionName}.alias`); + const target = readlinkSync(aliasFile); + const sessionId = basename(target, ".sock"); + + // Find session file + const subdirs = readdirSync(SESSION_DIR); + for (const subdir of subdirs) { + const dirPath = join(SESSION_DIR, subdir); + try { + const files = readdirSync(dirPath); + const match = files.find((f) => f.includes(sessionId) && f.endsWith(".jsonl")); + if (match) { + const filePath = join(dirPath, match); + const stat = statSync(filePath); + return Date.now() - stat.birthtimeMs; + } + } catch { continue; } + } + } catch {} + return null; +} + +function getSessions(): { name: string; alive: boolean; uptimeMs: number | null }[] { + const results: { name: string; alive: boolean; uptimeMs: number | null }[] = []; const expected = ["control-agent", "sentry-agent"]; try { const files = readdirSync(SOCKET_DIR); @@ -168,20 +224,22 @@ function getSessions(): { name: string; alive: boolean }[] { for (const alias of expected) { const aliasFile = `${alias}.alias`; if (!aliases.includes(aliasFile)) { - results.push({ name: alias, alive: false }); + results.push({ name: alias, alive: false, uptimeMs: null }); continue; } try { const target = readlinkSync(join(SOCKET_DIR, aliasFile)); const sockPath = join(SOCKET_DIR, target); - results.push({ name: alias, alive: existsSync(sockPath) }); + const alive = existsSync(sockPath); + const uptimeMs = alive ? getSessionUptime(alias) : null; + results.push({ name: alias, alive, uptimeMs }); } catch { - results.push({ name: alias, alive: false }); + results.push({ name: alias, alive: false, uptimeMs: null }); } } } catch { for (const alias of expected) { - results.push({ name: alias, alive: false }); + results.push({ name: alias, alive: false, uptimeMs: null }); } } return results; @@ -230,6 +288,24 @@ function getWorktreeCount(): number { } } +function getServiceUptime(): number | null { + try { + const out = execSync("systemctl show baudbot --property=ActiveEnterTimestamp --value 2>/dev/null", { + encoding: "utf-8", + timeout: 3000, + }).trim(); + + if (!out || out === "" || out === "0") return null; + + const startTime = new Date(out); + if (isNaN(startTime.getTime())) return null; + + return Date.now() - startTime.getTime(); + } catch { + return null; + } +} + function readHeartbeatState(ctx: ExtensionContext): HeartbeatInfo { const info: HeartbeatInfo = { enabled: true, lastRunAt: null, totalRuns: 0, healthy: true }; for (const entry of ctx.sessionManager.getEntries()) { @@ -533,18 +609,29 @@ function renderDashboard( } const bridgeIcon = data.bridgeUp ? theme.fg("success", "●") : theme.fg("error", "●"); - const bridgeLabel = data.bridgeUp ? "up" : theme.fg("error", "DOWN"); - const bridgeTypeStr = data.bridgeType ? dim(` ${data.bridgeType}`) : ""; + let bridgeLabel: string; + if (!data.bridgeUp) { + bridgeLabel = theme.fg("error", "bridge DOWN"); + } else if (data.bridgeType && data.bridgeUptimeMs !== null) { + bridgeLabel = `bridge ${data.bridgeType} ${dim(`(up ${formatUptime(data.bridgeUptimeMs)})`)}`; + } else if (data.bridgeType) { + bridgeLabel = `bridge ${data.bridgeType}`; + } else { + bridgeLabel = "bridge up"; + } - const row1Left = ` baudbot ${bbDisplay} ${dim("│")} pi ${piDisplay} ${dim("│")} ${bridgeIcon} bridge ${bridgeLabel}${bridgeTypeStr}`; - const row1Right = dim(`up ${formatUptime(data.uptimeMs)}`); - lines.push(pad(row1Left, row1Right, width)); + const row1Left = ` baudbot ${bbDisplay} ${dim("│")} pi ${piDisplay} ${dim("│")} ${bridgeIcon} ${bridgeLabel}`; + lines.push(pad(row1Left, "", width)); - // ── Row 2: sessions ── + // ── Row 2: sessions with uptimes ── const parts: string[] = []; for (const s of data.sessions) { const icon = s.alive ? theme.fg("success", "●") : theme.fg("error", "●"); - const label = s.alive ? dim(s.name) : theme.fg("error", s.name); + const name = s.alive ? s.name : theme.fg("error", s.name); + const uptimeStr = s.alive && s.uptimeMs !== null + ? dim(`(up ${formatUptime(s.uptimeMs)})`) + : ""; + const label = uptimeStr ? `${name} ${uptimeStr}` : name; parts.push(`${icon} ${label}`); } if (data.devAgentCount > 0) { @@ -631,9 +718,6 @@ function renderDashboard( } } - // ── Bottom border ── - lines.push(truncateToWidth(dim(bar.repeat(width)), width)); - return lines; } @@ -641,7 +725,6 @@ function renderDashboard( export default function dashboardExtension(pi: ExtensionAPI): void { let timer: ReturnType | null = null; - const startTime = Date.now(); const piVersion = getPiVersion(); let data: DashboardData | null = null; @@ -661,6 +744,8 @@ export default function dashboardExtension(pi: ExtensionAPI): void { const worktreeCount = getWorktreeCount(); const baudbot = getBaudbotVersion(); const bridgeType = detectBridgeType(); + const bridgeUptimeMs = getBridgeUptime(); + const serviceUptimeMs = getServiceUptime(); const heartbeat = savedCtx ? readHeartbeatState(savedCtx) : { enabled: true, lastRunAt: null, totalRuns: 0, healthy: true }; data = { @@ -670,6 +755,7 @@ export default function dashboardExtension(pi: ExtensionAPI): void { baudbotSha: baudbot.sha, bridgeUp, bridgeType, + bridgeUptimeMs, sessions, devAgentCount: devAgents.count, devAgentNames: devAgents.names, @@ -677,7 +763,7 @@ export default function dashboardExtension(pi: ExtensionAPI): void { todosDone: todoStats.done, todosTotal: todoStats.total, worktreeCount, - uptimeMs: Date.now() - startTime, + serviceUptimeMs, lastRefresh: new Date(), heartbeat, lastEvent, @@ -696,7 +782,6 @@ export default function dashboardExtension(pi: ExtensionAPI): void { theme.fg("dim", "─".repeat(width)), ]; } - data.uptimeMs = Date.now() - startTime; return renderDashboard(data, activityFeed.getLines(), theme, width); }, invalidate() {}, @@ -718,7 +803,7 @@ export default function dashboardExtension(pi: ExtensionAPI): void { description: "Refresh the baudbot status dashboard", handler: async (_args, ctx) => { await refresh(); - if (ctx.hasUI) ctx.ui.notify("Dashboard refreshed", "info"); + ctx.ui.notify("Dashboard refreshed", "info"); }, }); @@ -730,7 +815,7 @@ export default function dashboardExtension(pi: ExtensionAPI): void { timer = setInterval(async () => { try { await refresh(); } - catch (err) { console.error("Dashboard refresh failed:", err); } + catch {} }, REFRESH_INTERVAL_MS); }); From 72cfbd10a63ee323d04fc0d4de53285e27557fcc Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 17:27:15 -0500 Subject: [PATCH 5/5] remove old dashboard.ts, superseded by debug-dashboard.ts The debug-agent's debug-dashboard.ts is a strict superset: - Has everything dashboard.ts had (health, bridge, sessions, todos, heartbeat) - Adds activity feed (live tail of control-agent JSONL) - Adds per-agent uptimes and bridge process uptime - Better layout (3 rows vs 4, no redundant last-event row) Nothing references pi/extensions/dashboard.ts anymore. --- pi/extensions/dashboard.ts | 531 ------------------------------------- 1 file changed, 531 deletions(-) delete mode 100644 pi/extensions/dashboard.ts diff --git a/pi/extensions/dashboard.ts b/pi/extensions/dashboard.ts deleted file mode 100644 index 26a1d94..0000000 --- a/pi/extensions/dashboard.ts +++ /dev/null @@ -1,531 +0,0 @@ -/** - * Baudbot Dashboard Extension - * - * Renders a persistent status widget above the editor so an admin can - * see system health at a glance WITHOUT querying the agent. - * - * Displays: - * • Pi version (running vs latest from npm) - * • Slack bridge status (up/down via HTTP probe) - * • Sessions (control-agent, sentry-agent, dev-agents) - * • Active todos (in-progress count) - * • Worktrees (active count) - * • Uptime (how long this session has been running) - * • Current model - * - * Refreshes automatically every 30 seconds with zero LLM token cost. - * Use /dashboard to force an immediate refresh. - */ - -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { existsSync, readdirSync, readFileSync, readlinkSync, statSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { execSync } from "node:child_process"; - -const REFRESH_INTERVAL_MS = 30_000; // 30 seconds -const SOCKET_DIR = join(homedir(), ".pi", "session-control"); -const WORKTREES_DIR = join(homedir(), "workspace", "worktrees"); -const TODOS_DIR = join(homedir(), ".pi", "todos"); -const BRIDGE_URL = "http://127.0.0.1:7890/send"; -const BAUDBOT_DEPLOY = "/opt/baudbot"; - -// ── Data types ────────────────────────────────────────────────────────────── - -interface LastEvent { - source: string; // "slack", "chat", "heartbeat", "sentry", "rpc", etc. - summary: string; // short description - time: Date; -} - -interface HeartbeatInfo { - enabled: boolean; - lastRunAt: number | null; - totalRuns: number; - healthy: boolean; // last check had no failures -} - -interface DashboardData { - piVersion: string; - piLatest: string | null; - baudbotVersion: string | null; - baudbotSha: string | null; - bridgeUp: boolean; - bridgeType: string | null; - sessions: { name: string; alive: boolean }[]; - devAgentCount: number; - devAgentNames: string[]; - todosInProgress: number; - todosDone: number; - todosTotal: number; - worktreeCount: number; - uptimeMs: number; - lastRefresh: Date; - heartbeat: HeartbeatInfo; - lastEvent: LastEvent | null; -} - -// ── Data collectors ───────────────────────────────────────────────────────── - -function getBaudbotVersion(): { version: string | null; sha: string | null } { - try { - const currentLink = join(BAUDBOT_DEPLOY, "current"); - const target = readlinkSync(currentLink); - // target is like /opt/baudbot/releases/ - const sha = target.split("/").pop() ?? null; - - let version: string | null = null; - try { - const pkg = JSON.parse(readFileSync(join(currentLink, "package.json"), "utf-8")); - version = pkg.version ?? null; - } catch {} - - return { version, sha: sha ? sha.substring(0, 7) : null }; - } catch { - return { version: null, sha: null }; - } -} - -function getPiVersion(): string { - try { - // process.execPath is the node binary: /bin/node - // pi is installed at: /lib/node_modules/@mariozechner/pi-coding-agent/ - const prefix = join(process.execPath, "..", ".."); - const piPkg = join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent", "package.json"); - const pkg = JSON.parse(readFileSync(piPkg, "utf-8")); - return pkg.version ?? "?"; - } catch { - return "?"; - } -} - -let cachedLatestVersion: string | null = null; -let lastVersionCheck = 0; -const VERSION_CHECK_INTERVAL = 3600_000; // 1 hour - -async function getPiLatestVersion(): Promise { - const now = Date.now(); - if (cachedLatestVersion && now - lastVersionCheck < VERSION_CHECK_INTERVAL) { - return cachedLatestVersion; - } - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 5000); - const res = await fetch("https://registry.npmjs.org/@mariozechner/pi-coding-agent/latest", { - signal: controller.signal, - }); - clearTimeout(timeout); - if (res.ok) { - const data = (await res.json()) as { version?: string }; - cachedLatestVersion = data.version ?? null; - lastVersionCheck = now; - } - } catch { - // keep cached value - } - return cachedLatestVersion; -} - -function detectBridgeType(): string | null { - try { - const out = execSync("ps -eo args 2>/dev/null | grep -E 'broker-bridge|bridge\\.mjs' | grep -v grep", { - encoding: "utf-8", timeout: 3000, - }).trim(); - if (out.includes("broker-bridge")) return "broker"; - if (out.includes("bridge.mjs")) return "socket"; - return null; - } catch { - return null; - } -} - -async function checkBridge(): Promise { - try { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 3000); - const res = await fetch(BRIDGE_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: "{}", - signal: controller.signal, - }); - clearTimeout(timeout); - return res.status === 400; - } catch { - return false; - } -} - -function getSessions(): { name: string; alive: boolean }[] { - const results: { name: string; alive: boolean }[] = []; - const expected = ["control-agent", "sentry-agent"]; - - try { - const files = readdirSync(SOCKET_DIR); - const aliases = files.filter((f) => f.endsWith(".alias")); - - for (const alias of expected) { - const aliasFile = `${alias}.alias`; - if (!aliases.includes(aliasFile)) { - results.push({ name: alias, alive: false }); - continue; - } - try { - const target = readlinkSync(join(SOCKET_DIR, aliasFile)); - const sockPath = join(SOCKET_DIR, target); - results.push({ name: alias, alive: existsSync(sockPath) }); - } catch { - results.push({ name: alias, alive: false }); - } - } - } catch { - for (const alias of expected) { - results.push({ name: alias, alive: false }); - } - } - - return results; -} - -function getDevAgents(): { count: number; names: string[] } { - try { - const files = readdirSync(SOCKET_DIR); - const agents = files - .filter((f) => f.endsWith(".alias") && f.startsWith("dev-agent-")) - .map((f) => f.replace(".alias", "")); - return { count: agents.length, names: agents }; - } catch { - return { count: 0, names: [] }; - } -} - -function getTodoStats(): { inProgress: number; done: number; total: number } { - let inProgress = 0; - let done = 0; - let total = 0; - - if (!existsSync(TODOS_DIR)) return { inProgress, done, total }; - - try { - const files = readdirSync(TODOS_DIR).filter((f) => f.endsWith(".md")); - total = files.length; - for (const file of files) { - try { - const content = readFileSync(join(TODOS_DIR, file), "utf-8"); - if (content.includes('"status": "in-progress"')) inProgress++; - else if (content.includes('"status": "done"')) done++; - } catch { - continue; - } - } - } catch {} - - return { inProgress, done, total }; -} - -function getWorktreeCount(): number { - if (!existsSync(WORKTREES_DIR)) return 0; - try { - return readdirSync(WORKTREES_DIR).filter((entry) => { - try { return statSync(join(WORKTREES_DIR, entry)).isDirectory(); } - catch { return false; } - }).length; - } catch { - return 0; - } -} - -function readHeartbeatState(ctx: ExtensionContext): HeartbeatInfo { - const info: HeartbeatInfo = { enabled: true, lastRunAt: null, totalRuns: 0, healthy: true }; - - // Read the latest heartbeat-state entry from the session - for (const entry of ctx.sessionManager.getEntries()) { - const e = entry as { type: string; customType?: string; data?: any }; - if (e.type === "custom" && e.customType === "heartbeat-state" && e.data) { - if (typeof e.data.lastRunAt === "number") info.lastRunAt = e.data.lastRunAt; - if (typeof e.data.totalRuns === "number") info.totalRuns = e.data.totalRuns; - if (Array.isArray(e.data.lastFailures)) info.healthy = e.data.lastFailures.length === 0; - } - } - - // Check env for enabled state - const env = process.env.HEARTBEAT_ENABLED?.trim().toLowerCase(); - info.enabled = !(env === "0" || env === "false" || env === "no"); - - return info; -} - -// ── Rendering ─────────────────────────────────────────────────────────────── - -function formatAgo(date: Date): string { - const ms = Date.now() - date.getTime(); - const s = Math.floor(ms / 1000); - const m = Math.floor(s / 60); - const h = Math.floor(m / 60); - if (h > 0) return `${h}h ${m % 60}m ago`; - if (m > 0) return `${m}m ago`; - if (s > 10) return `${s}s ago`; - return "just now"; -} - -function formatUptime(ms: number): string { - const s = Math.floor(ms / 1000); - const m = Math.floor(s / 60); - const h = Math.floor(m / 60); - const d = Math.floor(h / 24); - if (d > 0) return `${d}d ${h % 24}h ${m % 60}m`; - if (h > 0) return `${h}h ${m % 60}m`; - if (m > 0) return `${m}m`; - return `${s}s`; -} - -function pad(left: string, right: string, width: number, indent: number = 2): string { - const gap = Math.max(1, width - visibleWidth(left) - visibleWidth(right) - indent); - return truncateToWidth(`${left}${" ".repeat(gap)}${right}${"".padEnd(indent)}`, width); -} - -function renderDashboard( - data: DashboardData, - theme: ExtensionContext["ui"]["theme"], - width: number -): string[] { - const lines: string[] = []; - const dim = (s: string) => theme.fg("dim", s); - const bar = "─"; - - // ── Top border with title ── - const title = " baudbot "; - const titleStyled = theme.fg("accent", theme.bold(title)); - const titleLen = visibleWidth(title); - const sideL = Math.max(1, Math.floor((width - titleLen) / 2)); - const sideR = Math.max(1, width - sideL - titleLen); - lines.push(truncateToWidth(dim(bar.repeat(sideL)) + titleStyled + dim(bar.repeat(sideR)), width)); - - // ── Row 1: baudbot version │ pi version │ bridge │ uptime ── - let bbDisplay: string; - if (data.baudbotVersion && data.baudbotSha) { - bbDisplay = dim(`v${data.baudbotVersion}`) + dim(`@${data.baudbotSha}`); - } else if (data.baudbotSha) { - bbDisplay = dim(`@${data.baudbotSha}`); - } else { - bbDisplay = dim("?"); - } - - let piDisplay: string; - if (data.piLatest && data.piLatest !== data.piVersion) { - piDisplay = theme.fg("warning", `v${data.piVersion}*`); - } else if (data.piLatest) { - piDisplay = theme.fg("success", `v${data.piVersion}`); - } else { - piDisplay = dim(`v${data.piVersion}`); - } - - const bridgeIcon = data.bridgeUp ? theme.fg("success", "●") : theme.fg("error", "●"); - const bridgeLabel = data.bridgeUp ? "up" : theme.fg("error", "DOWN"); - const bridgeTypeStr = data.bridgeType ? dim(` ${data.bridgeType}`) : ""; - - const row1Left = ` baudbot ${bbDisplay} ${dim("│")} pi ${piDisplay} ${dim("│")} ${bridgeIcon} bridge ${bridgeLabel}${bridgeTypeStr}`; - const row1Right = dim(`up ${formatUptime(data.uptimeMs)}`); - lines.push(pad(row1Left, row1Right, width)); - - // ── Row 2: sessions ── - const parts: string[] = []; - for (const s of data.sessions) { - const icon = s.alive ? theme.fg("success", "●") : theme.fg("error", "●"); - const label = s.alive ? dim(s.name) : theme.fg("error", s.name); - parts.push(`${icon} ${label}`); - } - if (data.devAgentCount > 0) { - parts.push( - theme.fg("accent", `● ${data.devAgentCount} dev-agent${data.devAgentCount > 1 ? "s" : ""}`) - ); - } - - const row2Left = ` ${parts.join(" ")}`; - lines.push(pad(row2Left, "", width)); - - // ── Row 3: todos │ worktrees │ refresh time ── - const todoParts: string[] = []; - if (data.todosInProgress > 0) { - todoParts.push(theme.fg("accent", `${data.todosInProgress} active`)); - } - todoParts.push(dim(`${data.todosDone} done`)); - todoParts.push(dim(`${data.todosTotal} total`)); - - const todoStr = `todos ${todoParts.join(dim(" / "))}`; - - const wtIcon = data.worktreeCount > 0 ? theme.fg("accent", "●") : dim("○"); - const wtLabel = data.worktreeCount > 0 - ? theme.fg("accent", `${data.worktreeCount}`) - : dim("0"); - const wtStr = `${wtIcon} ${wtLabel} worktree${data.worktreeCount !== 1 ? "s" : ""}`; - - const refreshTime = data.lastRefresh.toLocaleTimeString("en-US", { - hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, - }); - - const row3Left = ` ${todoStr} ${dim("│")} ${wtStr}`; - const row3Right = dim(`⟳ ${refreshTime}`); - lines.push(pad(row3Left, row3Right, width)); - - // ── Row 4: heartbeat │ last event ── - let hbStr: string; - if (!data.heartbeat.enabled) { - hbStr = `${theme.fg("warning", "♥")} ${theme.fg("warning", "paused")}`; - } else if (data.heartbeat.lastRunAt) { - const ago = formatAgo(new Date(data.heartbeat.lastRunAt)); - const icon = data.heartbeat.healthy ? theme.fg("success", "♥") : theme.fg("error", "♥"); - const label = data.heartbeat.healthy ? dim(ago) : theme.fg("error", ago); - hbStr = `${icon} ${label}`; - } else { - hbStr = `${dim("♥")} ${dim("pending")}`; - } - - let eventStr: string; - if (data.lastEvent) { - const ago = formatAgo(data.lastEvent.time); - const src = dim(`[${data.lastEvent.source}]`); - const summary = truncateToWidth(data.lastEvent.summary, 40); - eventStr = `${src} ${summary} ${dim(ago)}`; - } else { - eventStr = dim("no events yet"); - } - - const row4Left = ` heartbeat ${hbStr} ${dim("│")} ${eventStr}`; - lines.push(pad(row4Left, "", width)); - - // ── Bottom border ── - lines.push(truncateToWidth(dim(bar.repeat(width)), width)); - - return lines; -} - -// ── Extension ─────────────────────────────────────────────────────────────── - -export default function dashboardExtension(pi: ExtensionAPI): void { - let timer: ReturnType | null = null; - const startTime = Date.now(); - const piVersion = getPiVersion(); - - // Mutable data ref — widget's render() reads from this on every frame - let data: DashboardData | null = null; - let savedCtx: ExtensionContext | null = null; - let lastEvent: LastEvent | null = null; - - async function refresh() { - const [bridgeUp, piLatest] = await Promise.all([ - checkBridge(), - getPiLatestVersion(), - ]); - - const sessions = getSessions(); - const devAgents = getDevAgents(); - const todoStats = getTodoStats(); - const worktreeCount = getWorktreeCount(); - - const baudbot = getBaudbotVersion(); - - const bridgeType = detectBridgeType(); - - const heartbeat = savedCtx ? readHeartbeatState(savedCtx) : { enabled: true, lastRunAt: null, totalRuns: 0, healthy: true }; - - data = { - piVersion, - piLatest, - baudbotVersion: baudbot.version, - baudbotSha: baudbot.sha, - bridgeUp, - bridgeType, - sessions, - devAgentCount: devAgents.count, - devAgentNames: devAgents.names, - todosInProgress: todoStats.inProgress, - todosDone: todoStats.done, - todosTotal: todoStats.total, - worktreeCount, - uptimeMs: Date.now() - startTime, - lastRefresh: new Date(), - heartbeat, - lastEvent, - }; - } - - function installWidget(ctx: ExtensionContext) { - if (!ctx.hasUI) return; - - ctx.ui.setWidget("baudbot-dashboard", (_tui, theme) => ({ - render(width: number): string[] { - if (!data) { - return [ - theme.fg("dim", "─".repeat(width)), - theme.fg("dim", " baudbot dashboard loading…"), - theme.fg("dim", "─".repeat(width)), - ]; - } - // Update uptime live on every render - data.uptimeMs = Date.now() - startTime; - return renderDashboard(data, theme, width); - }, - invalidate() {}, - })); - } - - // /dashboard command — force immediate refresh - pi.registerCommand("dashboard", { - description: "Refresh the baudbot status dashboard", - handler: async (_args, ctx) => { - await refresh(); - ctx.ui.notify("Dashboard refreshed", "info"); - }, - }); - - pi.on("session_start", async (_event, ctx) => { - savedCtx = ctx; - await refresh(); - installWidget(ctx); - - // Periodic refresh - timer = setInterval(async () => { - try { await refresh(); } - catch {} - }, REFRESH_INTERVAL_MS); - }); - - // Track last event from inbound messages. - // before_agent_start fires for ALL inbound messages — user prompts, custom - // messages (session-message from Slack bridge, heartbeat), etc. - pi.on("before_agent_start", async (event) => { - const prompt = event.prompt ?? ""; - - if (prompt.includes("EXTERNAL_UNTRUSTED_CONTENT")) { - // Slack message via bridge — extract sender - const fromMatch = prompt.match(/From:\s*(<@[^>]+>|[^\n]+)/); - const from = fromMatch ? fromMatch[1].trim() : "user"; - // Extract the actual message content after the --- separator - const bodyMatch = prompt.match(/---\n([\s\S]*?)<<>>/); - const body = bodyMatch ? bodyMatch[1].trim().substring(0, 40).replace(/\n/g, " ") : ""; - const summary = body ? `${from}: ${body}` : from; - lastEvent = { source: "slack", summary, time: new Date() }; - } else if (prompt.includes("Heartbeat")) { - lastEvent = { source: "heartbeat", summary: "health check fired", time: new Date() }; - } else if (prompt.includes("#bots-sentry") || prompt.includes("Sentry")) { - const preview = prompt.substring(0, 50).replace(/\n/g, " "); - lastEvent = { source: "sentry", summary: preview, time: new Date() }; - } else if (prompt.length > 0) { - const preview = prompt.substring(0, 50).replace(/\n/g, " "); - lastEvent = { source: "chat", summary: preview, time: new Date() }; - } - - if (data && lastEvent) { - data.lastEvent = lastEvent; - } - }); - - pi.on("session_shutdown", async () => { - if (timer) { - clearInterval(timer); - timer = null; - } - }); -}