import { CONFIG_DIR_NAME, getAgentDir, type ExtensionAPI, type ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { existsSync, readFileSync } from "node:fs"; import { execFileSync, spawn, spawnSync } from "node:child_process"; import { join } from "node:path"; import { homedir } from "node:os"; const MAX_SUMMARY_CHARS = 120; const DEFAULT_TITLE = "AI task ready"; const DEFAULT_SOUND_NAME = "Glass"; const SOUNDS_DIR = "/System/Library/Sounds"; const APPLE_TERMINAL_BUNDLE_ID = "com.apple.Terminal"; const FOCUSING_ACTIVATION_TYPES = new Set([ "contentsClicked", "actionClicked", "additionalActionClicked", ]); const CONFIG_FILE_NAME = "ding.json"; const INLINE_DING_COMMAND = /\/ding\b/i; const TERM_PROGRAM_BUNDLE_IDS: Record = { Apple_Terminal: APPLE_TERMINAL_BUNDLE_ID, "iTerm.app": "com.googlecode.iterm2", WezTerm: "com.github.wez.wezterm", vscode: "com.microsoft.VSCode", WarpTerminal: "dev.warp.Warp-Stable", }; type TerminalTab = { windowId: string; tabIndex: string; }; type NotifyOptions = { title?: string; sound?: boolean | string; notifyWhenFocused?: boolean; noFocusOnClick?: boolean; activateBundleId?: string; }; type ResolvedSound = { path: string; alerterName?: string; }; type DingConfig = { autoNotify: boolean; sound: boolean | string; notifyWhenFocused: boolean; focusOnClick: boolean; activateBundleId?: string; title: string; }; const DEFAULT_CONFIG: DingConfig = { autoNotify: true, sound: DEFAULT_SOUND_NAME, notifyWhenFocused: false, focusOnClick: true, title: DEFAULT_TITLE, }; function compact(text: string): string { const normalized = text.trim().replace(/\s+/g, " ") || "Task completed"; if (normalized.length <= MAX_SUMMARY_CHARS) return normalized; return `${normalized.slice(0, MAX_SUMMARY_CHARS - 1).trimEnd()}…`; } function inlineDingSummary(prompt: string): string | undefined { const match = INLINE_DING_COMMAND.exec(prompt); if (!match) return undefined; const beforeCommand = prompt.slice(0, match.index).trim(); const afterCommand = prompt .slice(match.index + match[0].length) .replace(/^[\s:,-]+/, "") .trim(); if (afterCommand && !/^[.!?]+$/.test(afterCommand)) return afterCommand; return beforeCommand.replace(/\b(?:with|using)\s*$/i, "").trim() || "Task completed"; } function readConfigFile(path: string): Partial { if (!existsSync(path)) return {}; try { const parsed = JSON.parse(readFileSync(path, "utf8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch (error) { console.error(`Failed to load ding config from ${path}: ${error}`); return {}; } } function loadDingConfig(ctx: ExtensionContext): DingConfig { const globalConfigPath = join(getAgentDir(), CONFIG_FILE_NAME); const projectConfigPath = join(ctx.cwd, CONFIG_DIR_NAME, CONFIG_FILE_NAME); const projectConfig = ctx.isProjectTrusted() ? readConfigFile(projectConfigPath) : {}; return { ...DEFAULT_CONFIG, ...readConfigFile(globalConfigPath), ...projectConfig, }; } function notifyOptionsFromConfig(config: DingConfig): NotifyOptions { return { title: config.title, sound: config.sound, notifyWhenFocused: config.notifyWhenFocused, noFocusOnClick: !config.focusOnClick, activateBundleId: config.activateBundleId, }; } function commandExists(command: string): boolean { return spawnSync("which", [command], { stdio: "ignore" }).status === 0; } function osascriptArgs(scriptLines: string[], args: string[] = []): string[] { return scriptLines.flatMap((line) => ["-e", line]).concat(args); } function runOsascriptCapture(scriptLines: string[], args: string[] = []): string | undefined { if (!commandExists("osascript")) return undefined; try { const output = execFileSync("osascript", osascriptArgs(scriptLines, args), { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); return output || undefined; } catch { return undefined; } } function runOsascript(scriptLines: string[], args: string[] = []): void { if (!commandExists("osascript")) return; spawn("osascript", osascriptArgs(scriptLines, args), { stdio: "ignore", detached: true, }).unref(); } function frontmostBundleId(): string | undefined { return runOsascriptCapture([ 'tell application "System Events" to get bundle identifier of first application process whose frontmost is true', ]); } function targetBundleId(override?: string): string | undefined { if (override) return override; const termProgram = process.env.TERM_PROGRAM ?? ""; if (TERM_PROGRAM_BUNDLE_IDS[termProgram]) return TERM_PROGRAM_BUNDLE_IDS[termProgram]; return frontmostBundleId(); } function currentProcessTty(): string | undefined { let pid = process.pid; const seen = new Set(); for (let i = 0; i < 30; i += 1) { if (pid <= 1 || seen.has(pid)) return undefined; seen.add(pid); let output: string; try { output = execFileSync("ps", ["-p", String(pid), "-o", "ppid=", "-o", "tty="], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }).trim(); } catch { return undefined; } const [parentPidText, tty] = output.split(/\s+/); if (!parentPidText) return undefined; if (tty && !["??", "?", "-"].includes(tty)) { return tty.startsWith("/dev/") ? tty : `/dev/${tty}`; } const parentPid = Number.parseInt(parentPidText, 10); if (Number.isNaN(parentPid)) return undefined; pid = parentPid; } return undefined; } function parseTerminalTab(output?: string): TerminalTab | undefined { if (!output?.includes(":")) return undefined; const [windowId, tabIndex] = output.split(":", 2); if (!windowId || !tabIndex) return undefined; return { windowId, tabIndex }; } function terminalTabForTty(tty: string): TerminalTab | undefined { const output = runOsascriptCapture( [ "on run argv", "set targetTty to item 1 of argv", 'tell application "Terminal"', "repeat with w in windows", "repeat with i from 1 to count of tabs of w", "set t to tab i of w", "try", "if tty of t is targetTty then return (id of w as text) & \":\" & (i as text)", "end try", "end repeat", "end repeat", "end tell", "return \"\"", "end run", ], [tty], ); return parseTerminalTab(output); } function selectedFrontTerminalTab(): TerminalTab | undefined { const output = runOsascriptCapture([ 'tell application "Terminal"', "if not (exists front window) then return \"\"", "set w to front window", "set wid to id of w", "set tabIndex to 1", "repeat with i from 1 to count of tabs of w", "if selected of tab i of w then set tabIndex to i", "end repeat", "return (wid as text) & \":\" & (tabIndex as text)", "end tell", ]); return parseTerminalTab(output); } function currentTerminalTab(): TerminalTab | undefined { if (process.env.TERM_PROGRAM !== "Apple_Terminal") return undefined; const tty = currentProcessTty(); if (tty) { const tab = terminalTabForTty(tty); if (tab) return tab; } return selectedFrontTerminalTab(); } function terminalTabIsFrontmost(tab: TerminalTab): boolean { if (frontmostBundleId() !== APPLE_TERMINAL_BUNDLE_ID) return false; const output = runOsascriptCapture( [ "on run argv", "set targetWindowId to item 1 of argv as integer", "set targetTabIndex to item 2 of argv as integer", 'tell application "Terminal"', "if not (exists front window) then return \"false\"", "set w to front window", "if id of w is not targetWindowId then return \"false\"", "if targetTabIndex > (count of tabs of w) then return \"false\"", "return (selected of tab targetTabIndex of w) as text", "end tell", "end run", ], [tab.windowId, tab.tabIndex], ); return output === "true"; } function focusTerminalTab(tab: TerminalTab): void { runOsascript( [ "on run argv", "set targetWindowId to item 1 of argv as integer", "set targetTabIndex to item 2 of argv as integer", 'tell application "Terminal"', "activate", "set targetWindow to missing value", "repeat with w in windows", "if id of w is targetWindowId then", "set targetWindow to w", "exit repeat", "end if", "end repeat", "if targetWindow is not missing value then", "set index of targetWindow to 1", "if targetTabIndex ≤ (count of tabs of targetWindow) then", "set selected of tab targetTabIndex of targetWindow to true", "end if", "end if", "end tell", "end run", ], [tab.windowId, tab.tabIndex], ); } function expandHome(path: string): string { return path === "~" || path.startsWith("~/") ? path.replace(/^~/, homedir()) : path; } function resolveSound(sound: boolean | string | undefined): ResolvedSound | undefined { if (sound === false) return undefined; const rawSound = typeof sound === "string" ? sound : DEFAULT_SOUND_NAME; const trimmed = rawSound.trim() || DEFAULT_SOUND_NAME; if (trimmed.startsWith("/") || trimmed.startsWith("~/")) { return { path: expandHome(trimmed) }; } const systemSoundName = trimmed.replace(/\.aiff$/i, ""); return { path: `${SOUNDS_DIR}/${systemSoundName}.aiff`, alerterName: systemSoundName, }; } function playSound(sound: ResolvedSound | undefined): boolean { if (!sound || !commandExists("afplay") || !existsSync(sound.path)) return false; spawn("afplay", [sound.path], { stdio: "ignore", detached: true, }).unref(); return true; } function sendAppleNotification(summary: string, title: string): void { runOsascript( [ "on run argv", "display notification (item 1 of argv) with title (item 2 of argv)", "end run", ], [summary, title], ); } function sendAlerterInBackground( summary: string, title: string, sound: ResolvedSound | undefined, senderBundleId?: string, terminalTab?: TerminalTab, ): boolean { if (!commandExists("alerter")) return false; const args = ["--title", title, "--message", summary, "--json"]; if (sound?.alerterName) args.push("--sound", sound.alerterName); if (senderBundleId) args.push("--sender", senderBundleId); const child = spawn("alerter", args, { stdio: ["ignore", "pipe", "ignore"] }); let stdout = ""; child.stdout?.on("data", (chunk: Buffer) => { stdout += chunk.toString("utf8"); }); child.on("close", () => { if (!terminalTab) return; let activationType: string | undefined; try { activationType = JSON.parse(stdout || "{}").activationType; } catch { activationType = undefined; } if (activationType && FOCUSING_ACTIVATION_TYPES.has(activationType)) { focusTerminalTab(terminalTab); } }); return true; } function notify(summaryText: string, options: NotifyOptions = {}): void { if (!commandExists("osascript")) return; const summary = compact(summaryText); const title = compact(options.title ?? DEFAULT_TITLE); const sound = resolveSound(options.sound); const originatingTerminalTab = currentTerminalTab(); const terminalTab = options.noFocusOnClick ? undefined : originatingTerminalTab; const activateBundleId = options.noFocusOnClick ? undefined : targetBundleId(options.activateBundleId); if ( !options.notifyWhenFocused && originatingTerminalTab && terminalTabIsFrontmost(originatingTerminalTab) ) { playSound(sound); return; } if (sendAlerterInBackground(summary, title, sound, activateBundleId, terminalTab)) { if (sound && !sound.alerterName) playSound(sound); return; } playSound(sound); sendAppleNotification(summary, title); } export default function (pi: ExtensionAPI) { let lastPrompt: string | undefined; let manualSummary: string | undefined; pi.on("before_agent_start", async (event, ctx) => { const config = loadDingConfig(ctx); const inlineSummary = inlineDingSummary(event.prompt); lastPrompt = config.autoNotify ? event.prompt : undefined; manualSummary = inlineSummary; }); pi.on("agent_settled", async (_event, ctx) => { if (!ctx.isIdle()) return; const config = loadDingConfig(ctx); const summary = manualSummary ?? (config.autoNotify ? lastPrompt : undefined); lastPrompt = undefined; manualSummary = undefined; if (!summary?.trim()) return; notify(summary, notifyOptionsFromConfig(config)); }); pi.registerCommand("ding", { description: "Send a ding notification now", handler: async (args, ctx) => { const config = loadDingConfig(ctx); notify(args || lastPrompt || "Task completed", { ...notifyOptionsFromConfig(config), notifyWhenFocused: true, }); }, }); }