/** * Pi Done Notify Extension * * Sends a native terminal notification when Pi finishes a prompt * and is waiting for input. Optionally plays a sound. */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; function windowsToastScript(title: string, body: string): string { const type = "Windows.UI.Notifications"; const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`; const template = `[${type}.ToastTemplateType]::ToastText01`; const toast = `[${type}.ToastNotification]::new($xml)`; return [ `${mgr} > $null`, `$xml = [${type}.ToastNotificationManager]::GetTemplateContent(${template})`, `$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode('${body}')) > $null`, `[${type}.ToastNotificationManager]::CreateToastNotifier('${title}').Show(${toast})`, ].join("; "); } function notifyOSC777(title: string, body: string): void { process.stdout.write(`\x1b]777;notify;${title};${body}\x07`); } function notifyOSC99(title: string, body: string): void { process.stdout.write(`\x1b]99;i=1:d=1;${title}\x1b\\`); process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`); } const DEFAULT_DURATION_MS = 5000; function notifyWindows(title: string, body: string): void { const { execFile } = require("child_process"); execFile("powershell.exe", [ "-NoProfile", "-Command", windowsToastScript(title, body), ]); } function notifyDesktop( title: string, body: string, iconPath: string, durationMs = DEFAULT_DURATION_MS ): void { const { execFile } = require("child_process"); execFile( "notify-send", ["-t", `${durationMs}`, "-i", iconPath, title, body], (error: Error | null) => { if (error) { notifyOSC777(title, body); } } ); } const ICON_PATH = "/home/thomasgl/.pi/agent/extensions/assets/pi-logo.svg"; function notify(title: string, body: string): void { // If in SSH session, use OSC escape sequences (they travel to client terminal) if (isSSH()) { // Try OSC 99 (Kitty) first if client might be Kitty, otherwise OSC 777 notifyOSC777(title, body); return; } if (process.platform === "linux") { notifyDesktop(title, body, ICON_PATH); return; } if (process.env.WT_SESSION) { notifyWindows(title, body); } else if (process.env.KITTY_WINDOW_ID) { notifyOSC99(title, body); } else { notifyOSC777(title, body); } } // Detect if running in an SSH session function isSSH(): boolean { return !!( process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY ); } // Send BEL character - travels through SSH to client terminal // Configure your terminal to play a sound on bell function playBell(): void { process.stdout.write("\x07"); } // Sound playback function playSound(soundPath?: string): void { // If in SSH session, send BEL to client terminal instead of playing locally if (isSSH()) { playBell(); return; } const { execFile } = require("child_process"); if (process.platform === "darwin") { // macOS: use afplay with system sound or custom path const sound = soundPath || "/System/Library/Sounds/Glass.aiff"; execFile("afplay", [sound], (error: Error | null) => { if (error) console.error("Failed to play sound:", error); }); } else if (process.platform === "linux") { // Linux: try paplay (PulseAudio), then pw-play (PipeWire), then aplay (ALSA) const sound = soundPath || "/usr/share/sounds/freedesktop/stereo/complete.oga"; execFile("paplay", [sound], (error: Error | null) => { if (error) { execFile("pw-play", [sound], (error2: Error | null) => { if (error2) { execFile("aplay", [sound]); } }); } }); } else if (process.platform === "win32") { // Windows: use PowerShell to play system sound const sound = soundPath || "C:\\Windows\\Media\\Windows Notify System Generic.wav"; execFile("powershell.exe", [ "-NoProfile", "-Command", `(New-Object Media.SoundPlayer '${sound}').PlaySync()`, ]); } } // Settings state interface NotifySettings { soundEnabled: boolean; soundPath?: string; } const CUSTOM_TYPE = "pi-done-notify-settings"; export default function (pi: ExtensionAPI) { let settings: NotifySettings = { soundEnabled: true, }; // Restore settings from session pi.on("session_start", async (_event, ctx) => { for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "custom" && entry.customType === CUSTOM_TYPE) { settings = entry.data as NotifySettings; } } }); // Register /notify command pi.registerCommand("notify", { description: "Configure notification settings (sound on/off, or set sound path)", handler: async (args, ctx) => { const arg = args?.trim().toLowerCase(); if (!arg || arg === "status") { ctx.ui.notify( `Sound: ${settings.soundEnabled ? "on" : "off"}${settings.soundPath ? ` (${settings.soundPath})` : ""}`, "info" ); return; } if (arg === "on") { settings.soundEnabled = true; pi.appendEntry(CUSTOM_TYPE, settings); ctx.ui.notify("Notification sound enabled", "success"); return; } if (arg === "off") { settings.soundEnabled = false; pi.appendEntry(CUSTOM_TYPE, settings); ctx.ui.notify("Notification sound disabled", "info"); return; } // Treat as a sound path settings.soundPath = args?.trim() || undefined; pi.appendEntry(CUSTOM_TYPE, settings); ctx.ui.notify(`Sound path set to: ${settings.soundPath}`, "success"); }, }); // Notify on agent end pi.on("agent_end", async (_event, ctx) => { if (!ctx.hasUI) return; notify("Pi", "Done. Ready for input."); if (settings.soundEnabled) { playSound(settings.soundPath); } }); }