Add dashboard.ts extension (pi dashboard state collection) + sync models.json with OmniRoute model list
This commit is contained in:
492
extensions/dashboard.ts
Normal file
492
extensions/dashboard.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* 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<string | null>((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<string> {
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user