/** * pi-stop-notify * * Fires when the pi agent stops (event: `agent_end`) and emits: * - terminal OSC notification (OSC 777 / OSC 99 / Windows toast) * - macOS sound via `afplay` (configurable, off on non-macOS) * - optional native macOS notification via `osascript` * * Heuristic: if the last assistant text ends with "?", title becomes * "Pi has a question" — otherwise "Pi finished". * * Env vars: * PI_STOP_NOTIFY_SOUND path to .aiff/.wav (default: /System/Library/Sounds/Glass.aiff) * set to "off" to disable sound * PI_STOP_NOTIFY_NATIVE "0" to disable osascript native popup (macOS only, default on) * PI_STOP_NOTIFY_OSC "off" to disable terminal OSC notification * PI_STOP_NOTIFY_NAME explicit name prefix (defaults to basename of session cwd) */ import type { AgentEndEvent, ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { execFile } from "node:child_process"; import { platform } from "node:os"; import { basename } from "node:path"; const DEFAULT_SOUND = "/System/Library/Sounds/Glass.aiff"; function osc777(title: string, body: string): void { process.stdout.write(`\x1b]777;notify;${escapeOsc(title)};${escapeOsc(body)}\x07`); } function osc99(title: string, body: string): void { process.stdout.write(`\x1b]99;i=1:d=0;${escapeOsc(title)}\x1b\\`); process.stdout.write(`\x1b]99;i=1:p=body;${escapeOsc(body)}\x1b\\`); } function windowsToast(title: string, body: string): void { const t = "Windows.UI.Notifications"; const mgr = `[${t}.ToastNotificationManager, ${t}, ContentType = WindowsRuntime]`; const template = `[${t}.ToastTemplateType]::ToastText02`; const script = [ `${mgr} > $null`, `$xml = [${t}.ToastNotificationManager]::GetTemplateContent(${template})`, `$nodes = $xml.GetElementsByTagName('text')`, `$nodes[0].AppendChild($xml.CreateTextNode('${escapePosh(title)}')) > $null`, `$nodes[1].AppendChild($xml.CreateTextNode('${escapePosh(body)}')) > $null`, `[${t}.ToastNotificationManager]::CreateToastNotifier('pi').Show([${t}.ToastNotification]::new($xml))`, ].join("; "); execFile("powershell.exe", ["-NoProfile", "-Command", script], () => {}); } function escapeOsc(s: string): string { // strip BEL / ESC so we don't break the escape sequence return s.replace(/[\x07\x1b]/g, ""); } function escapePosh(s: string): string { return s.replace(/'/g, "''"); } function emitOsc(title: string, body: string): void { if (process.env.PI_STOP_NOTIFY_OSC === "off") return; if (process.env.WT_SESSION) return windowsToast(title, body); if (process.env.KITTY_WINDOW_ID) return osc99(title, body); osc777(title, body); } function playSound(): void { const sound = process.env.PI_STOP_NOTIFY_SOUND ?? DEFAULT_SOUND; if (sound === "off") return; if (platform() !== "darwin") return; execFile("afplay", [sound], () => {}); } function nativeMacNotify(title: string, body: string): void { if (process.env.PI_STOP_NOTIFY_NATIVE === "0") return; if (platform() !== "darwin") return; const t = title.replace(/"/g, '\\"'); const b = body.replace(/"/g, '\\"'); execFile("osascript", ["-e", `display notification "${b}" with title "${t}"`], () => {}); } function sessionName(cwd: string): string { const override = process.env.PI_STOP_NOTIFY_NAME; if (override && override.trim()) return override.trim(); const name = basename(cwd || "").trim(); return name || "Pi"; } function classify(event: AgentEndEvent, cwd: string): { title: string; body: string } { const lastText = extractLastAssistantText(event); const trimmed = lastText.trim(); const isQuestion = /\?\s*$/.test(trimmed); const preview = trimmed.length > 120 ? `${trimmed.slice(0, 117)}...` : trimmed; const name = sessionName(cwd); return { title: isQuestion ? `${name} has a question` : `${name} finished`, body: preview || (isQuestion ? "Awaiting your input" : "Task complete"), }; } function extractLastAssistantText(event: AgentEndEvent): string { const messages = event.messages ?? []; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i] as unknown as { role?: string; content?: unknown }; if (msg?.role !== "assistant") continue; const text = stringifyContent(msg.content); if (text) return text; } return ""; } function stringifyContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; const parts: string[] = []; for (const block of content) { if (!block || typeof block !== "object") continue; const b = block as { type?: string; text?: string }; if (b.type === "text" && typeof b.text === "string") parts.push(b.text); } return parts.join("\n"); } export default function (pi: ExtensionAPI) { pi.on("agent_end", async (event, ctx) => { const { title, body } = classify(event, ctx.cwd); emitOsc(title, body); playSound(); nativeMacNotify(title, body); }); }