From 8e3127a0554ab6a0416d2a63880d9465e8a66586 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 08:26:37 -0500 Subject: [PATCH 1/4] 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 4d1d0cb941e6553135dd3843f84049896f2033b2 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 08:46:00 -0500 Subject: [PATCH 2/4] 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 a7f97c47199fca594092f8ffa73bf391e4cce62c Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 10:56:37 -0500 Subject: [PATCH 3/4] feat: add debug-agent skill for admin observability Moves the dashboard extension from pi/extensions/ (auto-loaded by all sessions) into a dedicated debug-agent skill that's only loaded when an admin attaches to the system. The debug-agent provides: - Live dashboard widget showing system health at a glance - Activity feed tailing the control-agent's session JSONL - Quick reference for logs, sockets, tmux sessions, deploy paths Launch via: pi --skill ~/.pi/agent/skills/debug-agent \ -e ~/.pi/agent/skills/debug-agent/debug-dashboard.ts \ "/skill:debug-agent" This gives baudbot three agent types: - control-agent: always-on, handles Slack/routing/delegation - sentry-agent: incident triage from Sentry alerts - debug-agent: admin observability when attached via SSH --- pi/skills/debug-agent/SKILL.md | 55 +++ .../debug-agent/debug-dashboard.ts} | 402 ++++++++++++++---- 2 files changed, 374 insertions(+), 83 deletions(-) create mode 100644 pi/skills/debug-agent/SKILL.md rename pi/{extensions/dashboard.ts => skills/debug-agent/debug-dashboard.ts} (60%) diff --git a/pi/skills/debug-agent/SKILL.md b/pi/skills/debug-agent/SKILL.md new file mode 100644 index 0000000..4e0e350 --- /dev/null +++ b/pi/skills/debug-agent/SKILL.md @@ -0,0 +1,55 @@ +--- +name: debug-agent +description: Debug agent — observe control-agent activity and system health. Launched via `baudbot session attach`. +--- + +# Debug Agent + +You are a **debug observer** attached to a live Baudbot system. Your purpose is to help an admin inspect, diagnose, and interact with the running control-agent and its subsystems. + +## Launch + +```bash +pi --skill ~/.pi/agent/skills/debug-agent -e ~/.pi/agent/skills/debug-agent/debug-dashboard.ts "/skill:debug-agent" +``` + +Or via `baudbot session attach` (which runs the above). + +## What you see + +The dashboard widget above the editor shows live system state: +- **Health metrics**: versions, bridge status, sessions, todos, worktrees, heartbeat +- **Activity feed**: real-time stream of what the control-agent is doing (tool calls, messages, incoming Slack events) + +The activity feed tails the control-agent's session JSONL file — it updates automatically as the control-agent works. + +## What you can do + +- **Read logs**: `~/.pi/agent/logs/slack-bridge.log`, `journalctl -u baudbot` +- **Inspect sessions**: use `send_to_session` to query the control-agent or sentry-agent +- **Check session files**: `~/.pi/agent/sessions/` contains full conversation history as JSONL +- **Review todos**: use the `todo` tool to see work items +- **Run diagnostics**: check bridge health, socket state, process trees +- **Make code changes**: edit extensions, skills, configs — same tools as any agent + +## What you should NOT do + +- Don't send disruptive messages to the control-agent while it's mid-task (check activity feed first) +- Don't kill processes unless asked — the bridge and agents have their own lifecycle management +- Don't modify protected files (`bin/`, `hooks/`, `start.sh`, etc.) + +## Quick reference + +| What | Where | +|------|-------| +| Control-agent socket | `~/.pi/session-control/control-agent.alias` | +| Bridge logs | `~/.pi/agent/logs/slack-bridge.log` | +| Bridge tmux | `tmux attach -t slack-bridge` | +| Session files | `~/.pi/agent/sessions/--home-baudbot_agent--/` | +| Todos | `~/.pi/todos/` | +| Deploy dir | `/opt/baudbot/current` → releases/SHA | +| Systemd | `systemctl status baudbot` (needs sudo) | + +## Commands + +- `/dashboard` — force-refresh the health metrics diff --git a/pi/extensions/dashboard.ts b/pi/skills/debug-agent/debug-dashboard.ts similarity index 60% rename from pi/extensions/dashboard.ts rename to pi/skills/debug-agent/debug-dashboard.ts index 26a1d94..683aaa8 100644 --- a/pi/extensions/dashboard.ts +++ b/pi/skills/debug-agent/debug-dashboard.ts @@ -1,41 +1,43 @@ /** * 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. + * Renders a persistent status widget above the editor showing: + * • System health: versions, bridge, sessions, todos, worktrees, heartbeat + * • Control-agent activity feed: live stream of what the control-agent is doing * - * 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 + * The activity feed tails the control-agent's JSONL session file and shows + * recent assistant text, tool calls, and incoming messages. If this IS the + * control-agent session, it hooks events directly instead of file-watching. * - * Refreshes automatically every 30 seconds with zero LLM token cost. + * Refreshes health metrics 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 { + existsSync, readdirSync, readFileSync, readlinkSync, statSync, + watch as fsWatch, openSync, readSync, fstatSync, closeSync, +} from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, basename } from "node:path"; import { execSync } from "node:child_process"; -const REFRESH_INTERVAL_MS = 30_000; // 30 seconds +const REFRESH_INTERVAL_MS = 30_000; const SOCKET_DIR = join(homedir(), ".pi", "session-control"); +const SESSION_DIR = join(homedir(), ".pi", "agent", "sessions"); 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"; +const ACTIVITY_BUFFER_SIZE = 8; // max lines in activity feed + // ── Data types ────────────────────────────────────────────────────────────── interface LastEvent { - source: string; // "slack", "chat", "heartbeat", "sentry", "rpc", etc. - summary: string; // short description + source: string; + summary: string; time: Date; } @@ -43,7 +45,14 @@ interface HeartbeatInfo { enabled: boolean; lastRunAt: number | null; totalRuns: number; - healthy: boolean; // last check had no failures + healthy: boolean; +} + +interface ActivityLine { + time: Date; + icon: string; // "💬" "🔧" "📥" "📝" "🤔" etc (for themed rendering) + type: "text" | "tool" | "incoming" | "thinking" | "compaction"; + text: string; } interface DashboardData { @@ -72,15 +81,12 @@ 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 }; @@ -89,8 +95,6 @@ function getBaudbotVersion(): { version: string | null; sha: string | 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")); @@ -102,7 +106,7 @@ function getPiVersion(): string { let cachedLatestVersion: string | null = null; let lastVersionCheck = 0; -const VERSION_CHECK_INTERVAL = 3600_000; // 1 hour +const VERSION_CHECK_INTERVAL = 3600_000; async function getPiLatestVersion(): Promise { const now = Date.now(); @@ -121,9 +125,7 @@ async function getPiLatestVersion(): Promise { cachedLatestVersion = data.version ?? null; lastVersionCheck = now; } - } catch { - // keep cached value - } + } catch {} return cachedLatestVersion; } @@ -160,11 +162,9 @@ async function checkBridge(): Promise { 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)) { @@ -184,7 +184,6 @@ function getSessions(): { name: string; alive: boolean }[] { results.push({ name: alias, alive: false }); } } - return results; } @@ -204,9 +203,7 @@ 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; @@ -215,12 +212,9 @@ function getTodoStats(): { inProgress: number; done: number; total: number } { 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 { continue; } } } catch {} - return { inProgress, done, total }; } @@ -238,8 +232,6 @@ function getWorktreeCount(): number { 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) { @@ -248,14 +240,226 @@ function readHeartbeatState(ctx: ExtensionContext): HeartbeatInfo { 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; } +// ── Activity feed: JSONL file tailer ──────────────────────────────────────── + +/** + * Find the control-agent's session UUID from its socket alias. + */ +function getControlAgentSessionId(): string | null { + try { + const aliasPath = join(SOCKET_DIR, "control-agent.alias"); + const target = readlinkSync(aliasPath); // "UUID.sock" + return basename(target, ".sock"); + } catch { + return null; + } +} + +/** + * Find the session JSONL file for a given session UUID. + * Session files are named: _.jsonl + */ +function findSessionFile(sessionId: string): string | null { + try { + // Walk all session subdirs + 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) return join(dirPath, match); + } catch { continue; } + } + } catch {} + return null; +} + +/** + * Parse a JSONL entry into an ActivityLine (or null if not interesting). + */ +function parseActivityEntry(line: string): ActivityLine | null { + try { + const entry = JSON.parse(line); + const ts = entry.timestamp ? new Date(entry.timestamp) : new Date(); + + if (entry.type === "summary") { + return { time: ts, icon: "📋", type: "compaction", text: "context compacted" }; + } + + if (entry.type !== "message") return null; + + const msg = entry.message; + if (!msg) return null; + + const content = msg.content; + + if (msg.role === "assistant") { + if (!Array.isArray(content)) return null; + + // Show tool calls + for (const c of content) { + if (c.type === "toolCall") { + const name = c.name ?? "?"; + const args = c.arguments ?? {}; + let detail = ""; + if (name === "bash" && args.command) { + // Show the command, strip comments + detail = String(args.command).split("\n")[0].replace(/^#.*/, "").trim().substring(0, 60); + } else if (name === "read" && args.path) { + detail = String(args.path).split("/").slice(-2).join("/"); + } else if (name === "edit" && args.path) { + detail = String(args.path).split("/").slice(-2).join("/"); + } else if (name === "write" && args.path) { + detail = String(args.path).split("/").slice(-2).join("/"); + } else { + detail = Object.keys(args).slice(0, 2).join(","); + } + return { time: ts, icon: "⚡", type: "tool", text: `${name} ${detail}` }; + } + } + + // Show text (abbreviated) + for (const c of content) { + if (c.type === "text" && c.text) { + const text = c.text.replace(/\n/g, " ").substring(0, 80); + return { time: ts, icon: "💬", type: "text", text }; + } + } + + // Show thinking + for (const c of content) { + if (c.type === "thinking" && c.thinking) { + const text = c.thinking.replace(/\n/g, " ").substring(0, 60); + return { time: ts, icon: "🧠", type: "thinking", text }; + } + } + } + + if (msg.role === "user") { + const text = typeof content === "string" ? content : ""; + if (text.includes("EXTERNAL_UNTRUSTED_CONTENT")) { + const fromMatch = text.match(/From:\s*(<@[^>]+>|[^\n]+)/); + const from = fromMatch ? fromMatch[1].trim() : "user"; + return { time: ts, icon: "📩", type: "incoming", text: `slack: ${from}` }; + } + if (text.includes("Heartbeat")) { + return { time: ts, icon: "♥", type: "incoming", text: "heartbeat check" }; + } + // Skip other user messages (tool results fill the feed otherwise) + if (text.length > 0 && text.length < 200) { + return { time: ts, icon: "📝", type: "incoming", text: text.replace(/\n/g, " ").substring(0, 60) }; + } + } + + return null; + } catch { + return null; + } +} + +/** + * Activity feed manager — tails a JSONL file and maintains a ring buffer. + */ +class ActivityFeed { + private buffer: ActivityLine[] = []; + private fileOffset = 0; + private sessionFile: string | null = null; + private watcher: ReturnType | null = null; + private pollTimer: ReturnType | null = null; + + /** Start tailing a session file */ + start(sessionFile: string) { + this.sessionFile = sessionFile; + + // Seed with last N entries from the existing file + this.seedFromFile(sessionFile); + + // Watch for changes via fs.watch + fallback poll + try { + this.watcher = fsWatch(sessionFile, { persistent: false }, () => { + this.readNewEntries(); + }); + } catch { + // fs.watch may not work on all systems + } + + // Poll every 2 seconds as fallback (fs.watch can miss events) + this.pollTimer = setInterval(() => this.readNewEntries(), 2000); + } + + /** Seed buffer from end of existing file */ + private seedFromFile(filePath: string) { + try { + const content = readFileSync(filePath, "utf-8"); + const lines = content.split("\n").filter((l) => l.trim().length > 0); + this.fileOffset = Buffer.byteLength(content, "utf-8"); + + // Parse last ~50 entries to find interesting ones + const tail = lines.slice(-50); + for (const line of tail) { + const activity = parseActivityEntry(line); + if (activity) this.push(activity); + } + } catch {} + } + + /** Read new bytes appended to the file */ + private readNewEntries() { + if (!this.sessionFile) return; + try { + const fd = openSync(this.sessionFile, "r"); + try { + const stat = fstatSync(fd); + if (stat.size <= this.fileOffset) return; + + const newSize = stat.size - this.fileOffset; + const buf = Buffer.alloc(newSize); + readSync(fd, buf, 0, newSize, this.fileOffset); + this.fileOffset = stat.size; + + const text = buf.toString("utf-8"); + const lines = text.split("\n").filter((l) => l.trim().length > 0); + for (const line of lines) { + const activity = parseActivityEntry(line); + if (activity) this.push(activity); + } + } finally { + closeSync(fd); + } + } catch {} + } + + /** Push to ring buffer */ + private push(item: ActivityLine) { + this.buffer.push(item); + if (this.buffer.length > ACTIVITY_BUFFER_SIZE) { + this.buffer.shift(); + } + } + + /** Add an activity line directly (for same-session events) */ + addDirect(item: ActivityLine) { + this.push(item); + } + + /** Get current buffer */ + getLines(): readonly ActivityLine[] { + return this.buffer; + } + + /** Stop watching */ + stop() { + if (this.watcher) { this.watcher.close(); this.watcher = null; } + if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } + } +} + // ── Rendering ─────────────────────────────────────────────────────────────── function formatAgo(date: Date): string { @@ -280,6 +484,12 @@ function formatUptime(ms: number): string { return `${s}s`; } +function formatTime(date: Date): string { + return date.toLocaleTimeString("en-US", { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }); +} + 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); @@ -287,8 +497,9 @@ function pad(left: string, right: string, width: number, indent: number = 2): st function renderDashboard( data: DashboardData, + activity: readonly ActivityLine[], theme: ExtensionContext["ui"]["theme"], - width: number + width: number, ): string[] { const lines: string[] = []; const dim = (s: string) => theme.fg("dim", s); @@ -341,35 +552,23 @@ function renderDashboard( theme.fg("accent", `● ${data.devAgentCount} dev-agent${data.devAgentCount > 1 ? "s" : ""}`) ); } + lines.push(pad(` ${parts.join(" ")}`, "", width)); - const row2Left = ` ${parts.join(" ")}`; - lines.push(pad(row2Left, "", width)); - - // ── Row 3: todos │ worktrees │ refresh time ── + // ── Row 3: todos │ worktrees │ heartbeat ── 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)); + const wtStr = `${wtIcon} ${wtLabel} wt`; - // ── Row 4: heartbeat │ last event ── let hbStr: string; if (!data.heartbeat.enabled) { hbStr = `${theme.fg("warning", "♥")} ${theme.fg("warning", "paused")}`; @@ -382,18 +581,55 @@ function renderDashboard( 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 refreshTime = data.lastRefresh.toLocaleTimeString("en-US", { + hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, + }); + + const row3Left = ` ${todoStr} ${dim("│")} ${wtStr} ${dim("│")} ${hbStr}`; + const row3Right = dim(`⟳ ${refreshTime}`); + lines.push(pad(row3Left, row3Right, width)); - const row4Left = ` heartbeat ${hbStr} ${dim("│")} ${eventStr}`; - lines.push(pad(row4Left, "", width)); + // ── Activity feed ── + if (activity.length > 0) { + // Thin separator + const actLabel = " activity "; + const actLabelStyled = dim(actLabel); + const actLabelLen = visibleWidth(actLabel); + const actSideL = 2; + const actSideR = Math.max(1, width - actSideL - actLabelLen); + lines.push(truncateToWidth(dim(bar.repeat(actSideL)) + actLabelStyled + dim(bar.repeat(actSideR)), width)); + + for (const item of activity) { + const time = dim(formatTime(item.time)); + let icon: string; + let text: string; + switch (item.type) { + case "tool": + icon = theme.fg("accent", "⚡"); + text = dim(item.text); + break; + case "incoming": + icon = theme.fg("success", "→"); + text = theme.fg("success", item.text); + break; + case "thinking": + icon = dim("…"); + text = dim(item.text); + break; + case "compaction": + icon = dim("◇"); + text = dim(item.text); + break; + case "text": + default: + icon = dim("│"); + text = item.text; + break; + } + const lineText = ` ${time} ${icon} ${text}`; + lines.push(truncateToWidth(lineText, width)); + } + } // ── Bottom border ── lines.push(truncateToWidth(dim(bar.repeat(width)), width)); @@ -408,10 +644,10 @@ export default function dashboardExtension(pi: ExtensionAPI): void { 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; + const activityFeed = new ActivityFeed(); async function refresh() { const [bridgeUp, piLatest] = await Promise.all([ @@ -423,11 +659,8 @@ export default function dashboardExtension(pi: ExtensionAPI): void { 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 = { @@ -463,14 +696,23 @@ export default function dashboardExtension(pi: ExtensionAPI): void { theme.fg("dim", "─".repeat(width)), ]; } - // Update uptime live on every render data.uptimeMs = Date.now() - startTime; - return renderDashboard(data, theme, width); + return renderDashboard(data, activityFeed.getLines(), theme, width); }, invalidate() {}, })); } + function startActivityFeed() { + const sessionId = getControlAgentSessionId(); + if (!sessionId) return; + + const sessionFile = findSessionFile(sessionId); + if (!sessionFile) return; + + activityFeed.start(sessionFile); + } + // /dashboard command — force immediate refresh pi.registerCommand("dashboard", { description: "Refresh the baudbot status dashboard", @@ -484,25 +726,21 @@ export default function dashboardExtension(pi: ExtensionAPI): void { savedCtx = ctx; await refresh(); installWidget(ctx); + startActivityFeed(); - // 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. + // Track last event + feed activity from our own session's inbound messages 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; @@ -523,9 +761,7 @@ export default function dashboardExtension(pi: ExtensionAPI): void { }); pi.on("session_shutdown", async () => { - if (timer) { - clearInterval(timer); - timer = null; - } + if (timer) { clearInterval(timer); timer = null; } + activityFeed.stop(); }); } From 68494fa1a7601f3db6c420b98d976588fe014411 Mon Sep 17 00:00:00 2001 From: Baudbot Date: Tue, 24 Feb 2026 12:09:26 -0500 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20flaky=20test=20cleanup=20=E2=80=94?= =?UTF-8?q?=20wait=20for=20child=20exit=20+=20retry=20rmSync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broker bridge integration test could fail with ENOTEMPTY when rmSync raced with child processes still writing to temp dirs. Add a small delay after SIGTERM and use maxRetries/retryDelay on rmSync. --- test/broker-bridge.integration.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/broker-bridge.integration.test.mjs b/test/broker-bridge.integration.test.mjs index 549d2b5..ca808c3 100644 --- a/test/broker-bridge.integration.test.mjs +++ b/test/broker-bridge.integration.test.mjs @@ -69,11 +69,13 @@ describe("broker pull bridge semi-integration", () => { for (const child of children) { if (!child.killed) child.kill("SIGTERM"); } + // Give child processes a moment to exit so they stop writing to tempDirs + await new Promise((resolve) => setTimeout(resolve, 100)); for (const server of servers) { await new Promise((resolve) => server.close(() => resolve(undefined))); } for (const dir of tempDirs) { - rmSync(dir, { recursive: true, force: true }); + rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); } children.length = 0; servers.length = 0;