/** * 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), * session naming (/dash-name — renames pi + zellij + dashboard row), and * sends NTFY/Apprise notifications on agent completion and errors. * * State file: ~/.pi/agent/dashboard/.json * - Heartbeat: last_seen_at refreshed every 15s + on every event, so the * TUI can detect dead sessions (stale heartbeat => closed) even when the * terminal is killed without session_shutdown firing. * - Session liveness (session_state open/closed) is decoupled from the * agent-turn status (agent_status idle/running/blocked). */ 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 HOME = process.env.HOME || process.env.XDG_DATA_HOME || "/home/sam"; // Global state files: pi-dashboard viewer reads these across all machines const DASHBOARD_DIR = join(HOME, ".pi", "agent", "dashboard"); // Heartbeat interval: how often we refresh last_seen_at while alive const HEARTBEAT_MS = 15_000; // Zellij auto-generates session names as "-" (all lowercase // words joined by a single hyphen, e.g. "chatty-echidna"). We only auto-inherit // the zellij session name into pi when it does NOT look auto-generated, so // users who create zellij with `zellij -s ` get that name, and everyone // else falls back to the working-directory basename. const AUTO_ZELLIJ_NAME = /^[a-z]+-[a-z]+$/; // pi-subagents names subagent sessions "#<8-hex>" (e.g. "Explore#fb073d02") const SUBAGENT_NAME = /^.+?#[0-9a-f]{8}$/i; // Project-local task file: lives in the pi project's .pi/ directory // Mirrors how pi sessions are organized by working directory function projectTasksDir(): string { const dir = join(process.cwd(), ".pi", "dashboard", "tasks"); ensureDir(dir); return dir; } function projectTodotxt(): string { return join(projectTasksDir(), "todo.txt"); } // ── Types ───────────────────────────────────────────────────────── interface AgentState { session: string; machine: string; cwd: string; agent_type: string; model: string; // Agent-turn status (what the LLM is doing right now) agent_status: "idle" | "running" | "blocked" | "error"; status: string; // legacy alias of agent_status (older viewers) // Session liveness (is the pi process still alive) session_state: "open" | "closed"; session_started_at: string | null; closed_at: string | null; last_seen_at: string; // Agent turn timestamps 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; // Terminal context terminal_type: string; // tmux | zellij | tmux+zellij | direct zellij_session: string | null; tmux_session: string | null; pid: number; is_subagent: boolean; // Legacy fields (kept for compatibility) 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(): Promise { if (!process.env.TMUX) return Promise.resolve(null); 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 Promise.resolve(null); } } function getDefaultSessionName(): string { // Use env, then working directory basename, then fallback const dir = cwd().split("/").pop() || "home"; return dir; } function getZellijSession(): string | null { const z = process.env.ZELLIJ_SESSION_NAME; return z && z.length > 0 ? z : null; } 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(projectTasksDir()); const pri = priority ? `(${priority.toUpperCase()}) ` : ""; const proj = project ? ` +project:${project}` : ""; const agentTag = agent ? ` +agent:${agent}` : ""; const line = `${pri}${title}${proj}${agentTag}\n`; appendFileSync(projectTodotxt(), line, "utf-8"); } function completeTask(title: string): boolean { if (!existsSync(projectTodotxt())) return false; const content = readFileSync(projectTodotxt(), "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(projectTodotxt(), newLines.join("\n"), "utf-8"); return found; } function readTodoList() { if (!existsSync(projectTodotxt())) return { pending: [], done: [] }; const content = readFileSync(projectTodotxt(), "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(projectTasksDir()); const machine = hostname(); const inTmux = !!process.env.TMUX; const zellijName = getZellijSession(); const inZellij = !!zellijName; const terminalType = inTmux && inZellij ? "tmux+zellij" : inTmux ? "tmux" : inZellij ? "zellij" : "direct"; let sessionName = getDefaultSessionName(); let currentModel = "unknown"; let initialized = false; let tmuxSessionName: string | null = null; const now = () => new Date().toISOString(); const state: AgentState = { session: sessionName, machine, cwd: cwd(), agent_type: "unknown", model: currentModel, agent_status: "idle", status: "idle", session_state: "open", session_started_at: null, closed_at: null, last_seen_at: now(), started_at: null, completed_at: null, duration_seconds: 0, tool_calls: 0, current_task: "", last_tool: null, last_tool_at: null, terminal_type: terminalType, zellij_session: zellijName, tmux_session: null, pid: process.pid, is_subagent: false, connection_type: terminalType, 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.cwd = cwd(); state.connection_name = sessionName; state.last_seen_at = now(); if (!state.session_started_at) state.session_started_at = now(); atomicWrite(stateFile(), state); } // Heartbeat so the TUI can detect dead sessions even without session_shutdown. // .unref() so the timer never keeps the pi process alive on its own. const heartbeat = setInterval(() => writeState(), HEARTBEAT_MS); heartbeat.unref(); function notify(title: string, body: string) { // Desktop notification (local machine popup) try { const child = spawn( "notify-send", ["-a", "Pi Dashboard", title, body], { stdio: "ignore", timeout: 3000 } ); child.on("error", () => {}); child.unref(); } catch { // notify-send unavailable (e.g. headless server) } // 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 (HTTP API on .35 — fans out to Telegram/ntfy/email/callmebot) const appriseApi = process.env.APPRISE_API_URL || "http://192.168.20.35:8210/notify/eaef201fc995964c8a4013892563ae79879df4ecddbabdc4f4af1e549d3366e1"; try { const child = spawn( "curl", [ "-s", "-o", "/dev/null", "-X", "POST", "-H", "Content-Type: application/json", "-d", JSON.stringify({ title, body }), appriseApi, ], { stdio: "ignore", timeout: 5000 } ); child.on("error", () => {}); child.unref(); } catch { // Apprise unavailable } } // ── 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 zellij // (unless auto-generated), then cwd basename const piName = pi.getSessionName(); if (piName) { sessionName = piName; } else if (process.env.PI_SESSION) { sessionName = process.env.PI_SESSION; } else { const tmux = await getTmuxSession(); tmuxSessionName = tmux; if (tmux) { sessionName = tmux; } else if (zellijName && !AUTO_ZELLIJ_NAME.test(zellijName)) { sessionName = zellijName; } else { sessionName = getDefaultSessionName(); } } // Set the session name in pi so /tree and /resume use it if (!piName && sessionName !== "default") { pi.setSessionName(sessionName); } state.session = sessionName; state.is_subagent = SUBAGENT_NAME.test(sessionName); state.session_started_at = now(); state.closed_at = null; state.session_state = "open"; 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.agent_status = "running"; state.status = "running"; state.session_state = "open"; state.closed_at = null; state.started_at = now(); 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.agent_status = "idle"; state.status = "idle"; state.completed_at = now(); 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 = now(); 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} ═══`, `Folder: ${state.cwd}`, `Status: ${state.agent_status} (session ${state.session_state})`, `Terminal: ${state.terminal_type}${state.zellij_session ? ` (zellij: ${state.zellij_session})` : ""}`, `Model: ${state.model}`, `Tools: ${state.tool_calls}`, `Session started: ${state.session_started_at || "—"}`, `Last seen: ${state.last_seen_at || "—"}`, `Last tool: ${state.last_tool ? `${state.last_tool} at ${state.last_tool_at}` : "—"}`, `Tasks: ${projectTodotxt()}`, `─────────────────────────────`, `Run "pi-dashboard" in another terminal for the full TUI view.`, `Rename this session (pi + zellij + dashboard): /dash-name `, ]; ctx.ui.notify(lines.join("\n"), "info"); }, }); // ── Session naming: pi + zellij + dashboard row ──────────── // pi's built-in /name cannot be shadowed by extensions and emits no event, // so this is the one command that renames all three together. pi.registerCommand("dash-name", { description: "Rename this session everywhere: pi session, zellij session, dashboard row. Usage: /dash-name ", handler: async (args, ctx) => { const name = (args || "").trim(); if (!name) { ctx.ui.notify( `Current session: "${sessionName}"\nUsage: /dash-name `, "info" ); return; } if (name === sessionName) { ctx.ui.notify(`Session already named "${name}"`, "info"); return; } if (/[/\\]/.test(name)) { ctx.ui.notify(`Invalid name: "${name}" (no path separators allowed)`, "info"); return; } const oldName = sessionName; sessionName = name; // 1. Rename the pi session pi.setSessionName(name); // 2. Rename the zellij session (if we're inside one) if (state.zellij_session) { try { const z = spawn("zellij", ["action", "rename-session", name], { stdio: "ignore", timeout: 3000, }); z.on("error", () => {}); z.unref(); } catch { // zellij unavailable } state.zellij_session = name; } // 3. Rename the dashboard state file so the row keeps its identity const oldFile = join(DASHBOARD_DIR, `${oldName}.json`); stateFile = () => join(DASHBOARD_DIR, `${name}.json`); if (existsSync(oldFile)) { try { renameSync(oldFile, stateFile()); } catch { // ignore rename failures; file will be written fresh below } } state.session = name; state.connection_name = name; state.is_subagent = SUBAGENT_NAME.test(name); writeState(); ctx.ui.notify( `✓ Session renamed: "${oldName}" → "${name}"\n` + (state.zellij_session ? `Zellij session renamed too.` : `Not in zellij — zellij session not renamed.`), "info" ); }, }); // ── Shutdown cleanup ────────────────────────────────────── pi.on("session_shutdown", async () => { state.session_state = "closed"; state.closed_at = now(); state.agent_status = "idle"; state.status = "idle"; if (state.started_at && !state.completed_at) { state.completed_at = now(); 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(); }