/** * Pi Dashboard Extension * * Collects agent state and writes it to ~/.pi/agent/dashboard/ for * the external pi-dashboard TUI viewer to display. * * Also provides task tracking tools (register_task, complete_task, list_tasks) * and sends NTFY/Apprise notifications on agent completion and errors. */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, appendFileSync, } from "node:fs"; import { spawn } from "node:child_process"; import { hostname } from "node:os"; import { join } from "node:path"; import { cwd } from "node:process"; // ── Constants ───────────────────────────────────────────────────── const DASHBOARD_DIR = join( process.env.HOME || process.env.XDG_DATA_HOME || "/home/sam", ".pi", "agent", "dashboard" ); const TASKS_DIR = join(DASHBOARD_DIR, "tasks"); const TODO_FILE = join(TASKS_DIR, "todo.txt"); // ── Types ───────────────────────────────────────────────────────── interface AgentState { session: string; machine: string; agent_type: string; model: string; status: "idle" | "running" | "completed" | "error"; started_at: string | null; completed_at: string | null; duration_seconds: number; tool_calls: number; current_task: string; last_tool: string | null; last_tool_at: string | null; connection_type: string; connection_name: string; blocked: boolean; blocked_prompt: string | null; cost_estimate: number; } // ── Helpers ─────────────────────────────────────────────────────── function ensureDir(dir: string) { if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } } function getTmuxSession(): string | null { // TMUX env looks like: TMUX=/tmp/tmux-1000/default,12345 const tmuxEnv = process.env.TMUX; if (!tmuxEnv) return null; // The second half after comma is the client PID, but we can get // the session name from the TMUX_PANE or just parse it try { const child = spawn("tmux", ["display-message", "-p", "#S"], { stdio: ["ignore", "pipe", "pipe"], timeout: 2000, }); return new Promise((resolve) => { let output = ""; child.stdout.on("data", (data: Buffer) => { output += data.toString(); }); child.on("close", (code: number | null) => { resolve(code === 0 ? output.trim() : null); }); child.on("error", () => resolve(null)); }); } catch { return null; } } function getDefaultSessionName(): string { // Use env, then working directory basename, then fallback const dir = cwd().split("/").pop() || "home"; return dir; } async function getTmuxSessionName(): Promise { const tmux = await getTmuxSession(); if (tmux) return tmux; return getDefaultSessionName(); } function atomicWrite(filePath: string, data: object) { const content = JSON.stringify(data, null, 2) + "\n"; const tmpPath = filePath + ".tmp"; writeFileSync(tmpPath, content, "utf-8"); try { renameSync(tmpPath, filePath); } catch { // Fallback: some filesystems don't support atomic rename writeFileSync(filePath, content, "utf-8"); } } function parseTodoTitle(line: string): string { return line .replace(/^\([A-Z]\)\s*/, "") // remove priority: (A) Task name .replace(/\s+\+project:\S+/g, "") // remove +project:tag .replace(/\s+\+agent:\S+/g, "") // remove +agent:tag .replace(/^x\s+\d{4}-\d{2}-\d{2}\s*/, "") // remove "x 2026-07-27 " .trim(); } function appendToTodo( title: string, project?: string, priority?: string, agent?: string ) { ensureDir(TASKS_DIR); const pri = priority ? `(${priority.toUpperCase()}) ` : ""; const proj = project ? ` +project:${project}` : ""; const agentTag = agent ? ` +agent:${agent}` : ""; const line = `${pri}${title}${proj}${agentTag}\n`; appendFileSync(TODO_FILE, line, "utf-8"); } function completeTask(title: string): boolean { if (!existsSync(TODO_FILE)) return false; const content = readFileSync(TODO_FILE, "utf-8"); const lines = content.split("\n"); const now = new Date().toISOString().slice(0, 10); let found = false; const newLines = lines.map((line) => { if (found) return line; if (line.startsWith("x ")) return line; // already done if (parseTodoTitle(line) === title || line.includes(title)) { found = true; return `x ${now} ${line}`; } return line; }); writeFileSync(TODO_FILE, newLines.join("\n"), "utf-8"); return found; } function readTodoList() { if (!existsSync(TODO_FILE)) return { pending: [], done: [] }; const content = readFileSync(TODO_FILE, "utf-8"); const lines = content.split("\n").filter(Boolean); const pending = lines.filter((l) => !l.startsWith("x ")); const done = lines.filter((l) => l.startsWith("x ")); return { pending, done }; } // ── Extension ───────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { ensureDir(DASHBOARD_DIR); ensureDir(TASKS_DIR); const machine = hostname(); const inTmux = !!process.env.TMUX; const connectionType = inTmux ? "tmux" : "direct"; let sessionName = getDefaultSessionName(); let currentModel = "unknown"; let initialized = false; const state: AgentState = { session: sessionName, machine, agent_type: "unknown", model: currentModel, status: "idle", started_at: null, completed_at: null, duration_seconds: 0, tool_calls: 0, current_task: "", last_tool: null, last_tool_at: null, connection_type: connectionType, connection_name: sessionName, blocked: false, blocked_prompt: null, cost_estimate: 0, }; let stateFile = () => join(DASHBOARD_DIR, `${sessionName}.json`); function writeState() { if (!initialized) return; state.session = sessionName; state.model = currentModel; state.connection_name = sessionName; atomicWrite(stateFile(), state); } function notify(title: string, body: string) { // NTFY notification const ntfyTopic = process.env.PI_NTFY_TOPIC; if (ntfyTopic) { try { const child = spawn( "curl", [ "-s", "-o", "/dev/null", "-H", `Title: ${title}`, "-d", body, ntfyTopic, ], { stdio: "ignore", timeout: 3000 } ); child.on("error", () => {}); child.unref(); } catch { // NTFY unavailable } } // Apprise notification try { const child = spawn("apprise", ["-b", `${title}: ${body}`], { stdio: "ignore", timeout: 3000, }); child.on("error", () => {}); child.unref(); } catch { // Apprise not installed } } // ── Resolve session name and initialize after runtime is ready ── pi.on("session_start", async () => { // Try pi's session name first, then env, then tmux, then cwd const piName = pi.getSessionName(); if (piName) { sessionName = piName; } else if (process.env.PI_SESSION) { sessionName = process.env.PI_SESSION; } else { sessionName = await getTmuxSessionName(); } // Set the session name in pi so /name works and shows in tree if (!piName && sessionName !== "default") { pi.setSessionName(sessionName); } state.connection_name = sessionName; stateFile = () => join(DASHBOARD_DIR, `${sessionName}.json`); initialized = true; writeState(); }); // ── Model tracking ───────────────────────────────────────── pi.on("model_select", async (event) => { if (event.model) { const provider = event.model.provider || "unknown"; const modelId = event.model.id || "unknown"; currentModel = `${provider}/${modelId}`; } writeState(); }); // ── Agent lifecycle ──────────────────────────────────────── pi.on("agent_start", async () => { state.status = "running"; state.started_at = new Date().toISOString(); state.completed_at = null; state.duration_seconds = 0; state.tool_calls = 0; state.blocked = false; state.blocked_prompt = null; writeState(); }); pi.on("agent_end", async () => { state.status = "completed"; state.completed_at = new Date().toISOString(); if (state.started_at) { const start = new Date(state.started_at).getTime(); const end = new Date(state.completed_at).getTime(); state.duration_seconds = Math.round((end - start) / 1000); } state.blocked = false; state.blocked_prompt = null; writeState(); const dur = state.duration_seconds; const mins = Math.floor(dur / 60); const secs = dur % 60; notify( `✓ ${sessionName} completed`, `${state.tool_calls} tool calls in ${mins}m ${secs}s` ); }); pi.on("tool_call", async (event) => { state.last_tool = event.toolName; state.last_tool_at = new Date().toISOString(); state.tool_calls++; writeState(); }); // ── Task management tools ────────────────────────────────── pi.registerTool({ name: "register_task", label: "Register Task", description: "Register a new task that will appear in the Pi Dashboard task board (Tuxedo). " + "Tasks can optionally have a priority (A-C) and a project name for grouping.", parameters: Type.Object({ title: Type.String({ description: "Task title — concise, actionable description", }), project: Type.Optional( Type.String({ description: "Project name for grouping tasks (e.g., 'auth', 'deploy', 'refactor')", }) ), priority: Type.Optional( Type.Union( [ Type.Literal("A"), Type.Literal("B"), Type.Literal("C"), ], { description: "Priority: A (highest), B (medium), C (lowest)", } ) ), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { appendToTodo(params.title, params.project, params.priority, sessionName); state.current_task = params.title; writeState(); return { content: [ { type: "text" as const, text: `✓ Task registered: ${params.title}`, }, ], details: {}, }; }, }); pi.registerTool({ name: "complete_task", label: "Complete Task", description: "Mark a task as completed by its title. The task will show as done in the task board.", parameters: Type.Object({ title: Type.String({ description: "Title of the task to mark as completed", }), }), async execute(_toolCallId, params, _signal, _onUpdate, _ctx) { const found = completeTask(params.title); if (found) { return { content: [ { type: "text" as const, text: `✓ Task completed: ${params.title}` }, ], details: {}, }; } return { content: [ { type: "text" as const, text: `⚠ Task not found: "${params.title}". Use list_tasks to see all registered tasks.`, }, ], details: {}, }; }, }); pi.registerTool({ name: "list_tasks", label: "List Tasks", description: "List all registered tasks, grouped by status (pending / completed).", parameters: Type.Object({}), async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) { const { pending, done } = readTodoList(); let result = ""; if (pending.length === 0 && done.length === 0) { result = "No tasks registered yet. Use the `register_task` tool to create one."; } else { result = `## Tasks\n\n`; if (pending.length > 0) { result += `### Pending (${pending.length})\n`; for (const task of pending) { result += `- ${task}\n`; } result += "\n"; } if (done.length > 0) { result += `### Completed (${done.length})\n`; for (const task of done) { result += `- ${task}\n`; } } } return { content: [{ type: "text" as const, text: result }], details: {}, }; }, }); // ── Dashboard command ────────────────────────────────────── pi.registerCommand("dashboard", { description: "Show dashboard state for this session", handler: async (_args, ctx) => { const lines = [ `═══ Pi Dashboard — ${sessionName} ═══`, `Status: ${state.status}`, `Model: ${state.model}`, `Tools: ${state.tool_calls}`, `Start: ${state.started_at || "—"}`, `Last: ${state.last_tool ? `${state.last_tool} at ${state.last_tool_at}` : "—"}`, `Tasks: ${TODO_FILE}`, `─────────────────────────────`, `Run "pi-dashboard" in another terminal for the full TUI view.`, ]; ctx.ui.notify(lines.join("\n"), "info"); }, }); // ── Shutdown cleanup ────────────────────────────────────── pi.on("session_shutdown", async () => { // Mark as completed so it stays visible in the dashboard state.status = "completed"; state.completed_at = new Date().toISOString(); if (state.started_at) { const start = new Date(state.started_at).getTime(); const end = new Date(state.completed_at).getTime(); state.duration_seconds = Math.round((end - start) / 1000); } writeState(); // Notify const dur = state.duration_seconds; const mins = Math.floor(dur / 60); const secs = dur % 60; notify( `✓ ${sessionName} finished`, `${state.tool_calls} tool calls in ${mins}m ${secs}s` ); }); // ── Initial write ───────────────────────────────────────── // Write a state file immediately so the session appears even // before an agent_start event fires writeState(); }