diff --git a/extensions/dashboard.ts b/extensions/dashboard.ts index 51ad03b..1df2ee7 100644 --- a/extensions/dashboard.ts +++ b/extensions/dashboard.ts @@ -4,8 +4,16 @@ * 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. + * 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"; @@ -30,6 +38,19 @@ 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 { @@ -46,9 +67,18 @@ function projectTodotxt(): string { interface AgentState { session: string; machine: string; + cwd: string; agent_type: string; model: string; - status: "idle" | "running" | "completed" | "error"; + // 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; @@ -56,6 +86,13 @@ interface AgentState { 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; @@ -71,12 +108,8 @@ function ensureDir(dir: string) { } } -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 +function getTmuxSession(): Promise { + if (!process.env.TMUX) return Promise.resolve(null); try { const child = spawn("tmux", ["display-message", "-p", "#S"], { stdio: ["ignore", "pipe", "pipe"], @@ -93,7 +126,7 @@ function getTmuxSession(): string | null { child.on("error", () => resolve(null)); }); } catch { - return null; + return Promise.resolve(null); } } @@ -103,10 +136,9 @@ function getDefaultSessionName(): string { return dir; } -async function getTmuxSessionName(): Promise { - const tmux = await getTmuxSession(); - if (tmux) return tmux; - return getDefaultSessionName(); +function getZellijSession(): string | null { + const z = process.env.ZELLIJ_SESSION_NAME; + return z && z.length > 0 ? z : null; } function atomicWrite(filePath: string, data: object) { @@ -180,18 +212,29 @@ export default function (pi: ExtensionAPI) { const machine = hostname(); const inTmux = !!process.env.TMUX; - const connectionType = inTmux ? "tmux" : "direct"; + 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, @@ -199,7 +242,12 @@ export default function (pi: ExtensionAPI) { current_task: "", last_tool: null, last_tool_at: null, - connection_type: connectionType, + 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, @@ -212,10 +260,18 @@ export default function (pi: ExtensionAPI) { 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 { @@ -286,21 +342,35 @@ export default function (pi: ExtensionAPI) { // ── 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 + // 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 { - sessionName = await getTmuxSessionName(); + 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 /name works and shows in tree + // 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; @@ -321,8 +391,11 @@ export default function (pi: ExtensionAPI) { // ── Agent lifecycle ──────────────────────────────────────── pi.on("agent_start", async () => { + state.agent_status = "running"; state.status = "running"; - state.started_at = new Date().toISOString(); + state.session_state = "open"; + state.closed_at = null; + state.started_at = now(); state.completed_at = null; state.duration_seconds = 0; state.tool_calls = 0; @@ -332,8 +405,9 @@ export default function (pi: ExtensionAPI) { }); pi.on("agent_end", async () => { - state.status = "completed"; - state.completed_at = new Date().toISOString(); + 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(); @@ -354,7 +428,7 @@ export default function (pi: ExtensionAPI) { pi.on("tool_call", async (event) => { state.last_tool = event.toolName; - state.last_tool_at = new Date().toISOString(); + state.last_tool_at = now(); state.tool_calls++; writeState(); }); @@ -483,26 +557,99 @@ export default function (pi: ExtensionAPI) { handler: async (_args, ctx) => { const lines = [ `═══ Pi Dashboard — ${sessionName} ═══`, - `Status: ${state.status}`, + `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}`, - `Start: ${state.started_at || "—"}`, - `Last: ${state.last_tool ? `${state.last_tool} at ${state.last_tool_at}` : "—"}`, + `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; + } + + 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 () => { - // Mark as completed so it stays visible in the dashboard - state.status = "completed"; - state.completed_at = new Date().toISOString(); - if (state.started_at) { + 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);