notifications
This commit is contained in:
@@ -1,207 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* Pi Done Notify Extension
|
* Pi Done Notify Extension
|
||||||
*
|
*
|
||||||
* Sends a native terminal notification when Pi finishes a prompt
|
* Sends a notification when Pi finishes a prompt.
|
||||||
* and is waiting for input. Optionally plays a sound.
|
* If on SSH/remote, SSHs back to Linux PC to play sound + show notification.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||||
|
|
||||||
function windowsToastScript(title: string, body: string): string {
|
// ============ CONFIGURATION ============
|
||||||
const type = "Windows.UI.Notifications";
|
const REMOTE_HOST = "linux-pc"; // SSH host for remote notifications
|
||||||
const mgr = `[${type}.ToastNotificationManager, ${type}, ContentType = WindowsRuntime]`;
|
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 template = `[${type}.ToastTemplateType]::ToastText01`;
|
const LOCAL_SOUND_MAC = "/System/Library/Sounds/Glass.aiff";
|
||||||
const toast = `[${type}.ToastNotification]::new($xml)`;
|
const LOCAL_SOUND_LINUX = "/usr/share/sounds/freedesktop/stereo/complete.oga";
|
||||||
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 {
|
function isSSH(): boolean {
|
||||||
return !!(
|
if (process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY) {
|
||||||
process.env.SSH_CONNECTION ||
|
return true;
|
||||||
process.env.SSH_CLIENT ||
|
}
|
||||||
process.env.SSH_TTY
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send BEL character - travels through SSH to client terminal
|
function notifyRemote(): void {
|
||||||
// Configure your terminal to play a sound on bell
|
const { exec } = require("child_process");
|
||||||
function playBell(): void {
|
exec(`ssh -o ConnectTimeout=2 -o BatchMode=yes ${REMOTE_HOST} "${REMOTE_COMMAND}"`, { timeout: 5000 });
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyLocal(): void {
|
||||||
const { execFile } = require("child_process");
|
const { execFile } = require("child_process");
|
||||||
|
|
||||||
if (process.platform === "darwin") {
|
if (process.platform === "darwin") {
|
||||||
// macOS: use afplay with system sound or custom path
|
execFile("afplay", [LOCAL_SOUND_MAC]);
|
||||||
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") {
|
} else if (process.platform === "linux") {
|
||||||
// Linux: try paplay (PulseAudio), then pw-play (PipeWire), then aplay (ALSA)
|
execFile("paplay", [LOCAL_SOUND_LINUX]);
|
||||||
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
|
function sendNotification(): void {
|
||||||
interface NotifySettings {
|
if (isSSH() || REMOTE_HOST) {
|
||||||
soundEnabled: boolean;
|
notifyRemote();
|
||||||
soundPath?: string;
|
} else {
|
||||||
|
notifyLocal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CUSTOM_TYPE = "pi-done-notify-settings";
|
|
||||||
|
|
||||||
export default function (pi: ExtensionAPI) {
|
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) => {
|
pi.on("agent_end", async (_event, ctx) => {
|
||||||
if (!ctx.hasUI) return;
|
if (!ctx.hasUI) return;
|
||||||
|
sendNotification();
|
||||||
|
});
|
||||||
|
|
||||||
notify("Pi", "Done. Ready for input.");
|
// Simple test command
|
||||||
|
pi.registerCommand("notify", {
|
||||||
if (settings.soundEnabled) {
|
description: "Test notification",
|
||||||
playSound(settings.soundPath);
|
handler: async (_args, ctx) => {
|
||||||
}
|
sendNotification();
|
||||||
|
ctx.ui.notify("Notification sent!", "info");
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,3 +50,9 @@ Host mac-attio
|
|||||||
LocalForward 443 localhost:443
|
LocalForward 443 localhost:443
|
||||||
LocalForward 8081 localhost:8081
|
LocalForward 8081 localhost:8081
|
||||||
LocalForward 8082 localhost:8082
|
LocalForward 8082 localhost:8082
|
||||||
|
|
||||||
|
Host linux-pc
|
||||||
|
HostName 192.168.1.80
|
||||||
|
User thomasgl
|
||||||
|
IdentityFile ~/.ssh/linux-pc
|
||||||
|
AddKeysToAgent yes
|
||||||
|
|||||||
Reference in New Issue
Block a user