678 lines
21 KiB
TypeScript
678 lines
21 KiB
TypeScript
/**
|
|
* 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/<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";
|
|
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 "<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 {
|
|
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<string | null> {
|
|
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<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 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 <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;
|
|
}
|
|
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();
|
|
}
|