diff --git a/pi/files/agent/extensions/pi-done-notify.ts b/pi/files/agent/extensions/pi-done-notify.ts index c596fae..c980d84 100644 --- a/pi/files/agent/extensions/pi-done-notify.ts +++ b/pi/files/agent/extensions/pi-done-notify.ts @@ -1,207 +1,70 @@ /** * Pi Done Notify Extension * - * Sends a native terminal notification when Pi finishes a prompt - * and is waiting for input. Optionally plays a sound. + * Sends a notification when Pi finishes a prompt. + * If on SSH/remote, SSHs back to Linux PC to play sound + show notification. */ 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("; "); -} +// ============ CONFIGURATION ============ +const REMOTE_HOST = "linux-pc"; // SSH host for remote notifications +const REMOTE_COMMAND = "paplay /usr/share/sounds/freedesktop/stereo/window-attention.oga & notify-send -i ~/.pi/agent/extensions/assets/pi-logo.svg 'Pi' 'Done. Ready for input.'"; +const LOCAL_SOUND_MAC = "/System/Library/Sounds/Glass.aiff"; +const LOCAL_SOUND_LINUX = "/usr/share/sounds/freedesktop/stereo/complete.oga"; +// ======================================= -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; + if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) { + return true; } + // Check for sshd-session process (works in tmux/zellij) + try { + const { execSync } = require("child_process"); + const result = execSync("pgrep -u $USER -f sshd-session 2>/dev/null", { + encoding: "utf-8", + timeout: 1000, + }); + return result.trim().length > 0; + } catch { + return false; + } +} +function notifyRemote(): void { + const { exec } = require("child_process"); + exec(`ssh -o ConnectTimeout=2 -o BatchMode=yes ${REMOTE_HOST} "${REMOTE_COMMAND}"`, { timeout: 5000 }); +} + +function notifyLocal(): void { 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); - }); + execFile("afplay", [LOCAL_SOUND_MAC]); } 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()`, - ]); + execFile("paplay", [LOCAL_SOUND_LINUX]); } } -// Settings state -interface NotifySettings { - soundEnabled: boolean; - soundPath?: string; +function sendNotification(): void { + if (isSSH() || REMOTE_HOST) { + notifyRemote(); + } else { + notifyLocal(); + } } -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; + sendNotification(); + }); - notify("Pi", "Done. Ready for input."); - - if (settings.soundEnabled) { - playSound(settings.soundPath); - } + // Simple test command + pi.registerCommand("notify", { + description: "Test notification", + handler: async (_args, ctx) => { + sendNotification(); + ctx.ui.notify("Notification sent!", "info"); + }, }); } diff --git a/ssh/files/config b/ssh/files/config index 2b602c3..6299eb0 100644 --- a/ssh/files/config +++ b/ssh/files/config @@ -50,3 +50,9 @@ Host mac-attio LocalForward 443 localhost:443 LocalForward 8081 localhost:8081 LocalForward 8082 localhost:8082 + +Host linux-pc + HostName 192.168.1.80 + User thomasgl + IdentityFile ~/.ssh/linux-pc + AddKeysToAgent yes