/** * Pi Toast — dunst desktop notifications via dunst-recv. * * /notify on → enable (default) * /notify off → disable (persists for all sessions in same cwd) * /notify → show status * * Backend: dunst-recv CLI (handles sanitization, truncation, DBus detection) * Session naming: human-readable title-cased task names * Per-cwd persistence via .pi/.pi-toast-off flag file */ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { SessionManager } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import { execFile } from "node:child_process"; import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; // ── Config ────────────────────────────────────────────────── const OFF_FILENAME = ".pi-toast-off"; const MAX_NAME_LEN = 50; const MAX_TITLE_LEN = 50; const MAX_BODY_LEN = 60; const DUNST_RECV = "dunst-recv"; const APP_NAME = "pi-agent"; // ── State ─────────────────────────────────────────────────── let enabled = true; let sessionNamed = false; // ── File helpers ───────────────────────────────────────────── function offFile(cwd: string): string { return join(cwd, ".pi", OFF_FILENAME); } function ensureDir(path: string): void { try { mkdirSync(path, { recursive: true }); } catch (err: unknown) { // Ignore EEXIST only — rethrow permission errors, disk full, etc. if ((err as NodeJS.ErrnoException)?.code !== "EEXIST") throw err; } } // ── Filler strip patterns ──────────────────────────────────── // Leading filler phrases — stripped case-insensitively from start const LEADING_FILLERS: RegExp[] = [ /^i\s+would\s+like\s+you\s+to\s+/i, /^i['']d\s+like\s+you\s+to\s+/i, /^i\s+need\s+you\s+to\s+/i, /^can\s+you\s+please\s+/i, /^could\s+you\s+please\s+/i, /^would\s+you\s+please\s+/i, /^i\s+want\s+you\s+to\s+/i, /^could\s+you\s+/i, /^would\s+you\s+/i, /^can\s+you\s+/i, /^i\s+need\s+/i, /^i\s+have\s+/i, /^please\s+/i, /^help\s+me\s+/i, ]; // Trailing filler — stripped case-insensitively from end const TRAILING_FILLER = /\s+(?:please|thanks|thank\s+you)[!.]*$/i; // Words kept lowercase in title-case (unless first or last) const MINOR_WORDS = new Set([ "a", "an", "the", "and", "but", "or", "nor", "for", "so", "yet", "in", "on", "at", "to", "by", "of", "with", "from", "up", "about", "into", "through", "over", "after", "under", "between", "as", "per", "via", "than", ]); // Known lowercase tech terms that should stay lowercase in session names const LOWERCASE_TECH = new Set([ "npm", "npx", "yarn", "pnpm", "pip", "git", "ssh", "http", "https", "json", "yaml", "xml", "html", "css", "js", "ts", "jsx", "tsx", "sql", "bash", "zsh", "fish", "unix", "linux", "macos", "ios", "android", "docker", "redis", "nginx", "postgres", "mysql", "node", "react", "vue", "svelte", "swift", "kotlin", "rust", "eslint", "prettier", "webpack", "vite", "babel", "pi", "dunst", "cron", "sudo", "curl", "wget", ]); // ── Session naming ────────────────────────────────────────── /** * Strip leading and trailing filler phrases from user input. */ function stripFillers(text: string): string { let result = text.trim(); // Strip leading fillers for (const pattern of LEADING_FILLERS) { result = result.replace(pattern, ""); } // Strip trailing fillers result = result.replace(TRAILING_FILLER, ""); // Collapse whitespace return result.replace(/\s+/g, " ").trim(); } /** * Title-case a phrase: capitalize significant words, keep minor words lowercase. * Always capitalizes first and last word. * Preserves tech terms: all-uppercase acronyms (API, DB, CI/CD) and mixed-case * identifiers (OAuth2, iOS, npm). */ function titleCase(phrase: string, original: string): string { const originalWords = original.split(/\s+/); // Build a lookup: lowercase alpha form → exact original form // For terms like "CI/CD" → "cicd", "OAuth2" → "oauth" const preserved = new Map(); for (const w of originalWords) { const alpha = w.replace(/[^a-zA-Z]/g, ""); // Preserve if it's an acronym (all uppercase 2+ chars after stripping non-alpha) // or has mixed case (uppercase beyond first char) if (/^[A-Z]{2,}$/.test(alpha) || /[A-Z]/.test(alpha.slice(1))) { preserved.set(alpha.toLowerCase(), w); } } const words = phrase.split(" "); return words .map((word, i) => { const alpha = word.replace(/[^a-zA-Z]/g, ""); // Check if this matches a preserved tech term const preservedForm = preserved.get(alpha.toLowerCase()); if (preservedForm) return preservedForm; const lower = word.toLowerCase(); // Always capitalize first and last word (unless it's a known lowercase tech term) if (i === 0 || i === words.length - 1) { if (LOWERCASE_TECH.has(lower)) return lower; return lower.charAt(0).toUpperCase() + lower.slice(1); } // Keep minor words and known lowercase tech terms lowercase if (MINOR_WORDS.has(lower) || LOWERCASE_TECH.has(lower)) { return lower; } // Capitalize significant words return lower.charAt(0).toUpperCase() + lower.slice(1); }) .join(" "); } /** * Convert user's first message into a human-readable session name. * * Examples: * "I need you to fix the Docker networking config" → "Fix the Docker Networking Config" * "please create a backup script for the database" → "Create a Backup Script for the Database" * "add auth middleware to the API" → "Add Auth Middleware to the API" */ function humanName(text: string): string { // Take first line only, strip filler const cleaned = stripFillers(text.split("\n")[0]!.trim()); if (!cleaned || cleaned.length < 2) return "New Task"; // Remove leading/trailing punctuation that survived stripping const trimmed = cleaned.replace(/^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$/g, "").trim(); if (!trimmed) return "New Task"; // Title-case it (pass original for acronym detection) let name = titleCase(trimmed, cleaned); // Truncate to MAX_NAME_LEN at word boundary name = truncate(name, MAX_NAME_LEN); return name || "New Task"; } /** * Check if a session name already exists among recent sessions. * Returns the number of existing matches (0 = unique). */ async function nameCollisionCount(cwd: string, name: string): Promise { try { const sessions = await SessionManager.list(cwd); const normalized = name.toLowerCase(); let count = 0; for (const s of sessions) { const existing = s.name ?? s.firstMessage; if (!existing) continue; // Exact match or base match (for already-suffixed names like "Foo (2)") const base = existing.replace(/\s*\(\d+\)\s*$/, "").trim(); if ( existing.toLowerCase() === normalized || base.toLowerCase() === normalized ) { count++; } } return count; } catch { return 0; } } // ── Truncation helpers ─────────────────────────────────────── /** * Truncate text gracefully at a word boundary, appending ellipsis if needed. */ function truncate(text: string, maxLen: number): string { if (text.length <= maxLen) return text; const cut = text.lastIndexOf(" ", maxLen); return (cut > 0 ? text.slice(0, cut) : text.slice(0, maxLen - 1)) + "…"; } // ── Notification dispatch ─────────────────────────────────── /** * Send a notification via dunst-recv CLI. * All sanitization and DBus detection is handled by dunst-recv's linter. * Title and body are truncated to fit the toast display area. */ function notify( title: string, body: string, urgency: "low" | "normal" | "critical", timeoutMs: number, ): void { const truncatedTitle = truncate(title, MAX_TITLE_LEN); const truncatedBody = truncate(body, MAX_BODY_LEN); const child = execFile(DUNST_RECV, [ "-t", truncatedTitle, "-m", truncatedBody, "-u", urgency, "-a", APP_NAME, "-T", String(timeoutMs), ]); // Swallow spawn errors gracefully — dunst-recv may not be installed child.on("error", () => {}); child.unref(); } // ── Message extraction helpers ────────────────────────────── function extractFirstLine(content: unknown): string | undefined { if (typeof content === "string") { const line = content.split("\n")[0]!.trim(); return line.length > 0 ? line : undefined; } if (Array.isArray(content)) { const block = (content as Array<{ type?: string; text?: string }>).find( (c) => c.type === "text" && typeof c.text === "string", ); return block ? extractFirstLine(block.text) : undefined; } return undefined; } /** * Extract a short summary from the last assistant message. * Returns the last meaningful line (skips blank lines and code fences). */ function extractAssistantSummary( messages: Array<{ role: string; content: unknown }>, ): string | undefined { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]!; if (m.role !== "assistant") continue; const content = m.content; if (typeof content === "string") { const lines = content.split("\n").filter((l) => { const trimmed = l.trim(); return trimmed.length > 0 && !trimmed.startsWith("```"); }); // Take the last meaningful line for (let j = lines.length - 1; j >= 0; j--) { const line = lines[j]!.trim(); // Skip conversational filler like "Let me know" / "What's next" if ( /^(let me know|what'?s next|done|what now|anything else)/i.test(line) ) continue; return line; } return lines[lines.length - 1]?.trim(); } if (Array.isArray(content)) { const textBlocks = content.filter( (c: { type?: string; text?: string }) => c.type === "text" && typeof c.text === "string", ); if (textBlocks.length > 0) { const lastText = (textBlocks[textBlocks.length - 1] as { text: string }) .text; const lines = lastText.split("\n").filter((l) => l.trim().length > 0); for (let j = lines.length - 1; j >= 0; j--) { const line = lines[j]!.trim(); if ( /^(let me know|what'?s next|done|what now|anything else)/i.test( line, ) ) continue; return line; } return lines[lines.length - 1]?.trim(); } } } return undefined; } // ── Extension ─────────────────────────────────────────────── export default function (pi: ExtensionAPI) { enabled = true; sessionNamed = false; // ── session_start: Re-read enabled state per cwd ───────── pi.on("session_start", async (_event, ctx) => { const offPath = offFile(ctx.cwd); enabled = !existsSync(offPath); sessionNamed = !!pi.getSessionName(); }); // ── input: Set human-readable session name on first message pi.on("input", async (event, ctx) => { if (sessionNamed || !enabled) return { action: "continue" }; if (event.source === "extension") return { action: "continue" }; // Skip if session already has a name if (pi.getSessionName()) { sessionNamed = true; return { action: "continue" }; } const raw = event.text.trim(); if (!raw) return { action: "continue" }; // Generate human-readable name let name = humanName(raw); // Check for collisions — append disambiguator if needed const collisions = await nameCollisionCount(ctx.cwd, name); if (collisions > 0) { name = `${name} (${collisions + 1})`; } pi.setSessionName(name); sessionNamed = true; return { action: "continue" }; }); // ── /notify command ────────────────────────────────────── pi.registerCommand("notify", { description: "Toggle desktop notifications: /notify on | off", handler: async (args, ctx) => { const cmd = args.trim().toLowerCase(); if (cmd === "on") { enabled = true; ensureDir(join(ctx.cwd, ".pi")); try { unlinkSync(offFile(ctx.cwd)); } catch { /* already gone */ } notify("pi-agent", "Notifications on", "normal", 5000); ctx.ui.notify("Toasts on", "info"); } else if (cmd === "off") { enabled = false; ensureDir(join(ctx.cwd, ".pi")); writeFileSync(offFile(ctx.cwd), "", "utf8"); notify("pi-agent", "Notifications off", "low", 5000); ctx.ui.notify("Toasts off — /notify on to re-enable", "info"); } else { ctx.ui.notify(enabled ? "Toasts on" : "Toasts off", "info"); } }, }); // ── notify tool (for LLM) ──────────────────────────────── pi.registerTool({ name: "notify", label: "Notify", description: "Send a toast notification. Keep it short.", promptSnippet: "Send a toast notification", promptGuidelines: [ "Use notify to proactively alert the user.", "priority: low = info, normal = done, critical = needs response.", "title ≤ 50 chars, message ≤ 60 chars.", ], parameters: Type.Object({ title: Type.String({ description: "Short title (≤ 50 chars)" }), message: Type.String({ description: "Brief message (≤ 60 chars)" }), priority: Type.Optional( Type.Union( [ Type.Literal("low"), Type.Literal("normal"), Type.Literal("critical"), ], { description: "Default: normal" }, ), ), }), async execute(_id, params) { if (!enabled) { return { content: [ { type: "text", text: "Toasts disabled. /notify on to re-enable." }, ], details: { sent: false }, }; } const title = truncate(params.title, MAX_TITLE_LEN); const message = truncate(params.message, MAX_BODY_LEN); const urgency = params.priority ?? "normal"; const timeout = urgency === "critical" ? 0 : 7000; notify(title, message, urgency, timeout); return { content: [{ type: "text", text: `Sent: ${title} (${urgency})` }], details: { sent: true, priority: urgency }, }; }, }); // ── agent_end: Task completion toast ───────────────────── pi.on("agent_end", async (event) => { if (!enabled) return; const sessionName = pi.getSessionName() || "Task"; // Prefer last assistant message summary, fallback to user task const assistantSummary = extractAssistantSummary( (event as { messages?: Array<{ role: string; content: unknown }> }) .messages ?? [], ); const userMsg = ( event as { messages?: Array<{ role: string; content: unknown }> } ).messages?.find((m) => m.role === "user"); const body = assistantSummary ?? extractFirstLine(userMsg?.content) ?? "Ready"; notify(sessionName, body, "normal", 7000); }); // ── ask_user_question: Critical sticky toast ───────────── pi.on("tool_call", async (event) => { if (!enabled || event.toolName !== "ask_user_question") return; const sessionName = pi.getSessionName() || "Task"; const q = (event.input as Record)?.questions as | Array<{ question?: string }> | undefined; const firstQuestion = q?.[0]?.question ?? "Input needed"; notify(sessionName, firstQuestion, "critical", 0); }); }