From e9c533f18684084d517be7405234e03d51016495 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 08:26:37 -0500 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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/7] 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; - } - }); -} From 47f9f2873dfa6a88cc080b8a1a74313ef6cdf8c6 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Wed, 25 Feb 2026 08:40:18 -0500 Subject: [PATCH 6/7] fix: wrap crypto_box_seal_open to catch libsodium decryption errors When broker messages are encrypted with old/wrong keys, libsodium throws 'incorrect key pair for the given ciphertext' before the plaintext null check. This bypasses isPoisonMessageError() detection, causing poison messages to retry indefinitely and block the queue. Wrap crypto_box_seal_open in try-catch to convert all decryption failures into the expected 'failed to decrypt broker envelope' error format that poison message handling recognizes and auto-acks. Fixes stuck message Ev0AGW0HEKGB after baudbot-services-beta broker deploy. --- slack-bridge/broker-bridge.mjs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/slack-bridge/broker-bridge.mjs b/slack-bridge/broker-bridge.mjs index 2b42e38..734f2b8 100755 --- a/slack-bridge/broker-bridge.mjs +++ b/slack-bridge/broker-bridge.mjs @@ -740,11 +740,18 @@ function verifyBrokerEnvelope(message) { } function decryptEnvelope(message) { - const plaintext = sodium.crypto_box_seal_open( - fromBase64(message.encrypted), - cryptoState.serverBoxPublicKey, - cryptoState.serverBoxSecretKey, - ); + let plaintext; + try { + plaintext = sodium.crypto_box_seal_open( + fromBase64(message.encrypted), + cryptoState.serverBoxPublicKey, + cryptoState.serverBoxSecretKey, + ); + } catch (err) { + // Wrap libsodium errors (e.g., "incorrect key pair for the given ciphertext") + // into a format that isPoisonMessageError() can detect + throw new Error(`failed to decrypt broker envelope (message_id: ${message.message_id || "unknown"})`); + } if (!plaintext) { throw new Error(`failed to decrypt broker envelope (message_id: ${message.message_id || "unknown"})`); } From 8909995bb7099ad59d24c3cf47ac9f05811b0d47 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Wed, 25 Feb 2026 08:41:13 -0500 Subject: [PATCH 7/7] test: add decryption failure poison message test Add test case that verifies poison messages with crypto_box_seal_open failures (e.g., 'incorrect key pair for the given ciphertext') are properly detected and auto-acked by the bridge. This test encrypts a message with wrong keys to simulate the scenario where broker keys have changed (e.g., after baudbot-services-beta deploy) and old messages can't be decrypted. All 13 broker-bridge integration tests pass. --- test/broker-bridge.integration.test.mjs | 133 ++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/test/broker-bridge.integration.test.mjs b/test/broker-bridge.integration.test.mjs index ca808c3..07b36db 100644 --- a/test/broker-bridge.integration.test.mjs +++ b/test/broker-bridge.integration.test.mjs @@ -308,6 +308,139 @@ describe("broker pull bridge semi-integration", () => { expect(valid).toBe(true); }); + it("acks poison messages with decryption failures (wrong keys)", async () => { + await sodium.ready; + + let pullCount = 0; + let ackPayload = null; + + // Generate valid encryption with mismatched keys to trigger "incorrect key pair" + const wrongBoxKeypair = sodium.crypto_box_keypair(); + const serverBoxKeypair = sodium.crypto_box_seed_keypair(new Uint8Array(Buffer.alloc(32, 11))); + const brokerSignKeypair = sodium.crypto_sign_seed_keypair(new Uint8Array(Buffer.alloc(32, 15))); + + const payload = JSON.stringify({ source: "slack", type: "message", payload: { text: "test" }, broker_timestamp: 123 }); + // Encrypt with WRONG key (wrongBoxKeypair) but bridge will try to decrypt with serverBoxKeypair + const encrypted = sodium.crypto_box_seal(new TextEncoder().encode(payload), wrongBoxKeypair.publicKey); + const encryptedB64 = toBase64(encrypted); + + const broker = createServer(async (req, res) => { + if (req.method === "POST" && req.url === "/api/inbox/pull") { + pullCount += 1; + const brokerTimestamp = Math.floor(Date.now() / 1000); + + const canonical = canonicalizeEnvelope("T123BROKER", brokerTimestamp, encryptedB64); + const signature = sodium.crypto_sign_detached(canonical, brokerSignKeypair.privateKey); + + const messages = pullCount === 1 + ? [{ + message_id: "m-decrypt-fail-1", + workspace_id: "T123BROKER", + encrypted: encryptedB64, + broker_timestamp: brokerTimestamp, + broker_signature: toBase64(signature), + }] + : []; + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, messages })); + return; + } + + if (req.method === "POST" && req.url === "/api/inbox/ack") { + let raw = ""; + for await (const chunk of req) raw += chunk; + ackPayload = JSON.parse(raw); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, acked: ackPayload.message_ids?.length ?? 0 })); + return; + } + + if (req.method === "POST" && req.url === "/api/send") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true, ts: "1234.5678" })); + return; + } + + res.writeHead(404, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: "not found" })); + }); + + await new Promise((resolve) => broker.listen(0, "127.0.0.1", resolve)); + servers.push(broker); + + const address = broker.address(); + if (!address || typeof address === "string") { + throw new Error("failed to get broker test server address"); + } + const brokerUrl = `http://127.0.0.1:${address.port}`; + + const testFileDir = path.dirname(fileURLToPath(import.meta.url)); + const repoRoot = path.dirname(testFileDir); + const bridgePath = path.join(repoRoot, "slack-bridge", "broker-bridge.mjs"); + const bridgeCwd = path.join(repoRoot, "slack-bridge"); + + let bridgeStdout = ""; + let bridgeStderr = ""; + let bridgeExit = null; + + const bridge = spawn("node", [bridgePath], { + cwd: bridgeCwd, + env: { + ...cleanEnv(), + SLACK_BROKER_URL: brokerUrl, + SLACK_BROKER_WORKSPACE_ID: "T123BROKER", + SLACK_BROKER_SERVER_PRIVATE_KEY: toBase64(serverBoxKeypair.privateKey), + SLACK_BROKER_SERVER_PUBLIC_KEY: toBase64(serverBoxKeypair.publicKey), + SLACK_BROKER_SERVER_SIGNING_PRIVATE_KEY: b64(32, 13), + SLACK_BROKER_PUBLIC_KEY: b64(32, 14), + SLACK_BROKER_SIGNING_PUBLIC_KEY: toBase64(brokerSignKeypair.publicKey), + SLACK_BROKER_ACCESS_TOKEN: "test-broker-token", + SLACK_ALLOWED_USERS: "U_ALLOWED", + SLACK_BROKER_POLL_INTERVAL_MS: "50", + BRIDGE_API_PORT: "0", + }, + stdio: ["ignore", "pipe", "pipe"], + }); + + bridge.stdout.on("data", (chunk) => { + bridgeStdout += chunk.toString(); + }); + bridge.stderr.on("data", (chunk) => { + bridgeStderr += chunk.toString(); + }); + const bridgeExited = new Promise((_, reject) => { + bridge.on("error", (err) => { + if (ackPayload !== null) return; + reject(new Error(`bridge spawn error: ${err.message}; stdout=${bridgeStdout}; stderr=${bridgeStderr}`)); + }); + bridge.on("exit", (code, signal) => { + bridgeExit = { code, signal }; + if (ackPayload !== null) return; + reject(new Error(`bridge exited early: code=${code} signal=${signal}; stdout=${bridgeStdout}; stderr=${bridgeStderr}`)); + }); + }); + + children.push(bridge); + + const ackWait = waitFor( + () => ackPayload !== null, + 10_000, + 50, + `timeout waiting for ack; pullCount=${pullCount}; exit=${JSON.stringify(bridgeExit)}; stdout=${bridgeStdout}; stderr=${bridgeStderr}`, + ); + + await Promise.race([ackWait, bridgeExited]); + + expect(ackPayload.workspace_id).toBe("T123BROKER"); + expect(ackPayload.protocol_version).toBe("2026-02-1"); + expect(ackPayload.message_ids).toContain("m-decrypt-fail-1"); + + // Verify that the bridge logged a decryption error before acking + expect(bridgeStderr).toContain("failed to decrypt"); + }); + it("forwards user messages to agent in fire-and-forget mode without get_message/turn_end RPCs", async () => { await sodium.ready;