dashboard: heartbeat + cwd + session times + /dash-name (pi+zellij rename) + subagent detection
This commit is contained in:
@@ -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/<session>.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 "<adjective>-<animal>" (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 <name>` 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 "<Base>#<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<string | null> {
|
||||
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<string> {
|
||||
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 <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 <name>",
|
||||
handler: async (args, ctx) => {
|
||||
const name = (args || "").trim();
|
||||
if (!name) {
|
||||
ctx.ui.notify(
|
||||
`Current session: "${sessionName}"\nUsage: /dash-name <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);
|
||||
|
||||
Reference in New Issue
Block a user