// ABOUTME: Task Board Viewer — opens a GUI browser window showing a live Kanban board of agent work. // ABOUTME: Polls Commander MCP tools for tasks, agents, messages, and groups. Auto-refreshes every 3 seconds. import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { Text } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { execSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http"; import { outputLine } from "./lib/output-box.ts"; import { applyExtensionDefaults } from "./lib/themeMap.ts"; import { generateBoardViewerHTML } from "./lib/board-viewer-html.ts"; import { registerActiveViewer, clearActiveViewer, notifyViewerOpen } from "./lib/viewer-session.ts"; // ── Types ──────────────────────────────────────────────────────────── interface BoardResult { action: "closed"; } interface BoardData { tasks: any[]; agents: any[]; messages: any[]; groups: any[]; readyTasks: any[]; connected: boolean; timestamp: string; error?: string; localMode?: boolean; localTitle?: string; } // ── Commander Data Helpers ─────────────────────────────────────────── /** * Call a Commander MCP tool via the global client set by commander-mcp.ts. * Returns the parsed result or null on failure. */ async function callCommander(toolName: string, params: Record): Promise { const g = globalThis as any; const client = g.__piCommanderClient; if (!client) return null; try { const result = await client.callTool(toolName, params, 8000); // MCP results come as { content: [{ type: "text", text: "..." }] } if (result?.content?.[0]?.text) { try { return JSON.parse(result.content[0].text); } catch { return result.content[0].text; } } return result; } catch { return null; } } /** * Read local tasks from the tasks extension (globalThis.__piTaskList). * Always available regardless of Commander status. */ function getLocalTasks(): { tasks: any[]; title?: string } { const g = globalThis as any; const taskList = g.__piTaskList as { tasks: { id: number; text: string; status: string }[]; title?: string; remaining: number; total: number } | undefined; const now = new Date().toISOString(); const statusMap: Record = { idle: "pending", inprogress: "working", done: "completed" }; const tasks = (taskList?.tasks || []).map((t) => ({ task_id: t.id, description: t.text, status: statusMap[t.status] || t.status, created_at: now, updated_at: now, })); return { tasks, title: taskList?.title }; } /** * Gather board data — always local-first. * Local tasks are the primary data source. Commander data is layered in when available. */ async function gatherBoardData(): Promise { const g = globalThis as any; const local = getLocalTasks(); // Always return local tasks — this is the local-first board return { tasks: local.tasks, agents: [], messages: [], groups: [], readyTasks: [], connected: false, localMode: true, localTitle: local.title, timestamp: new Date().toISOString(), }; } // ── HTTP Server ────────────────────────────────────────────────────── function startBoardServer( title: string, ): Promise<{ port: number; server: Server; waitForResult: () => Promise }> { return new Promise((resolveSetup) => { let resolveResult: (result: BoardResult) => void; let settled = false; const settle = (result: BoardResult) => { if (settled) return; settled = true; resolveResult!(result); }; const resultPromise = new Promise((res) => { resolveResult = res; }); const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); res.setHeader("Access-Control-Allow-Headers", "Content-Type"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } const url = new URL(req.url || "/", `http://localhost`); // Serve the main HTML page if (req.method === "GET" && url.pathname === "/") { const port = (server.address() as any)?.port || 0; const html = generateBoardViewerHTML({ title, port }); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.end(html); return; } // Serve the logo image if (req.method === "GET" && url.pathname === "/logo.png") { try { const logoPath = join(dirname(fileURLToPath(import.meta.url)), "assets", "agent-logo.png"); const logoData = readFileSync(logoPath); res.writeHead(200, { "Content-Type": "image/png", "Cache-Control": "public, max-age=3600" }); res.end(logoData); } catch { res.writeHead(404); res.end(); } return; } // ── Main data endpoint ────────────────────────────── if (req.method === "GET" && url.pathname === "/api/board-data") { try { const data = await gatherBoardData(); res.writeHead(200, { "Content-Type": "application/json", "Cache-Control": "no-cache", }); res.end(JSON.stringify(data)); } catch (err: any) { res.writeHead(500, { "Content-Type": "application/json" }); res.end(JSON.stringify({ tasks: [], agents: [], messages: [], groups: [], readyTasks: [], connected: false, timestamp: new Date().toISOString(), error: err.message, })); } return; } // ── Close the viewer ──────────────────────────────── if (req.method === "POST" && url.pathname === "/result") { let body = ""; req.on("data", (chunk: string) => { body += chunk; }); req.on("end", () => { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); settle({ action: "closed" }); }); return; } // 404 res.writeHead(404); res.end("Not found"); }); server.on("close", () => { settle({ action: "closed" }); }); server.listen(0, "127.0.0.1", () => { const addr = server.address() as any; resolveSetup({ port: addr.port, server, waitForResult: () => resultPromise, }); }); }); } function openBrowser(url: string): void { try { execSync(`open "${url}"`, { stdio: "ignore" }); } catch { try { execSync(`xdg-open "${url}"`, { stdio: "ignore" }); } catch { try { execSync(`start "${url}"`, { stdio: "ignore" }); } catch {} } } } // ── Tool Parameters ────────────────────────────────────────────────── const ShowBoardParams = Type.Object({ title: Type.Optional(Type.String({ description: "Title for the board (default: 'Task Board')" })), }); // ── Extension ──────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { let activeServer: Server | null = null; let activeSession: { kind: "board"; title: string; url: string; server: Server; onClose: () => void } | null = null; function cleanupServer() { const server = activeServer; activeServer = null; if (server) { try { server.close(); } catch {} } if (activeSession) { clearActiveViewer(activeSession); activeSession = null; } } // ── Core board launcher ────────────────────────────────────────── async function launchBoard( ctx: ExtensionContext, title: string, ): Promise { // Clean up any previous server cleanupServer(); // Start server const { port, server } = await startBoardServer(title); activeServer = server; const url = `http://127.0.0.1:${port}`; activeSession = { kind: "board", title, url, server, onClose: () => { activeServer = null; activeSession = null; }, }; registerActiveViewer(activeSession); // Open the browser openBrowser(url); notifyViewerOpen(ctx, activeSession); return url; } // ── show_board tool ────────────────────────────────────────────── pi.registerTool({ name: "show_board", label: "Show Board", description: "Open a live task board in the browser. Shows a Kanban-style view of local tasks " + "(Pending → Working → Completed → Failed). Auto-refreshes every 3 seconds.\n\n" + "The board runs as a lightweight background web server. Unlike other viewers, " + "it stays open and keeps refreshing — close the browser tab when done.\n\n" + "Shows local tasks from the tasks extension — no Commander required.", parameters: ShowBoardParams, async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const { title = "Task Board" } = params as { title?: string }; const url = await launchBoard(ctx, title); return { content: [{ type: "text" as const, text: `Task board opened at ${url}\n\nThe board auto-refreshes every 3 seconds. Close the browser tab when done.\n\nFeatures:\n- Kanban columns: Pending → Working → Completed → Failed\n- Agent chips: click to filter by agent\n- Activity feed: recent mailbox messages\n- Group progress: task group completion bars\n- Keyboard: R=refresh, Esc=clear filter`, }], }; }, renderCall(args, theme) { const titleArg = (args as any).title || "Task Board"; const text = theme.fg("toolTitle", theme.bold("show_board ")) + theme.fg("accent", titleArg); return new Text(outputLine(theme, "accent", text), 0, 0); }, renderResult(result, _options, theme) { const text = result.content[0]; const firstLine = text?.type === "text" ? text.text.split("\n")[0] : ""; return new Text( outputLine(theme, "success", firstLine), 0, 0, ); }, }); // ── /board command ─────────────────────────────────────────────── pi.registerCommand("board", { description: "Open the live task board in the browser", handler: async (args, ctx) => { if (!ctx.hasUI) { ctx.ui.notify("/board requires interactive mode", "error"); return; } const title = args.trim() || "Task Board"; const url = await launchBoard(ctx, title); ctx.ui.notify(`Task board opened at ${url}`, "info"); }, }); // ── Session lifecycle ──────────────────────────────────────────── pi.on("session_start", async (_event, ctx) => { applyExtensionDefaults(import.meta.url, ctx); }); pi.on("session_shutdown", async () => { cleanupServer(); }); }