import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir, platform } from "node:os"; import { delimiter, join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; const PROMPTED_EVENT = "guardrails:action:prompted"; const BLOCKED_EVENT = "guardrails:action:blocked"; const STATUS_KEY = "guardrails-notify"; type GuardrailsPromptPayload = { feature?: string; reason?: string; action?: { kind?: string; command?: string; path?: string; origin?: string }; context?: { toolName?: string; input?: Record }; }; function compactAction(payload: GuardrailsPromptPayload): string { const action = payload.action; if (action?.kind === "command" && action.command) return action.command; if (action?.kind === "file" && action.path) return action.path; const input = payload.context?.input; const command = typeof input?.command === "string" ? input.command : undefined; if (command) return command; return payload.reason ?? "approval required"; } function truncate(value: string, max = 140): string { const oneLine = value.replace(/\s+/g, " ").trim(); return oneLine.length > max ? `${oneLine.slice(0, max - 1)}…` : oneLine; } function findOnPath(command: string): string | undefined { for (const dir of (process.env.PATH ?? "").split(delimiter)) { if (!dir) continue; const candidate = join(dir, command); if (existsSync(candidate)) return candidate; } return undefined; } function findCmuxBin(): string | undefined { const candidates = [ process.env.CMUX_BIN, findOnPath("cmux"), `${homedir()}/Applications/cmux.app/Contents/Resources/bin/cmux`, "/Applications/cmux.app/Contents/Resources/bin/cmux", ].filter((value): value is string => Boolean(value)); return candidates.find((candidate) => existsSync(candidate)); } function runNotify(command: string | undefined, args: string[]): boolean { if (!command) return false; execFile(command, args, { timeout: 3000 }, () => {}); return true; } function cmuxNotify(title: string, message: string): boolean { return runNotify(findCmuxBin(), ["notify", "--title", title, "--subtitle", "Guardrails", "--body", message]); } function terminalNotificationSupported(): boolean { if (!process.stdout.isTTY) return false; const termProgram = (process.env.TERM_PROGRAM ?? "").toLowerCase(); const term = (process.env.TERM ?? "").toLowerCase(); return [termProgram, term].some((value) => ["ghostty", "iterm", "wezterm", "foot"].some((name) => value.includes(name)), ); } function cleanOsc(value: string): string { return value.replace(/[\x00-\x1f\x7f;\u001b]/g, " ").replace(/\s+/g, " ").trim(); } function terminalNotify(title: string, message: string): boolean { if (!terminalNotificationSupported()) return false; const termProgram = (process.env.TERM_PROGRAM ?? "").toLowerCase(); const body = cleanOsc(`${title}: ${message}`); if (termProgram.includes("iterm") || termProgram.includes("ghostty")) { process.stdout.write(`\x1b]9;${body}\x07`); return true; } process.stdout.write(`\x1b]777;notify;${cleanOsc(title)};${cleanOsc(message)}\x07`); return true; } function appleScriptString(value: string): string { return JSON.stringify(value); } function nativeNotify(title: string, message: string): boolean { if (platform() === "darwin") { return runNotify(findOnPath("osascript"), [ "-e", `display notification ${appleScriptString(message)} with title ${appleScriptString(title)} subtitle "Guardrails"`, ]); } if (platform() === "linux") { return runNotify(findOnPath("notify-send"), [title, message]); } return false; } function externalNotify(title: string, message: string): void { const backend = (process.env.GUARDRAILS_NOTIFY_BACKEND ?? "auto").toLowerCase(); if (["off", "none", "false", "0"].includes(backend)) return; if (backend === "cmux") { cmuxNotify(title, message); return; } if (backend === "terminal") { terminalNotify(title, message); return; } if (backend === "native") { nativeNotify(title, message); return; } cmuxNotify(title, message) || terminalNotify(title, message) || nativeNotify(title, message); } export default function guardrailsNotify(pi: ExtensionAPI) { let currentCtx: ExtensionContext | undefined; pi.on("session_start", (_event, ctx) => { currentCtx = ctx; }); pi.events.on(PROMPTED_EVENT, (raw) => { const payload = raw as GuardrailsPromptPayload; const feature = payload.feature ?? "guardrails"; const action = truncate(compactAction(payload)); const reason = truncate(payload.reason ?? "approval required", 100); const title = "Pi waiting for approval"; const message = `${feature}: ${reason} • ${action}`; if (process.stdout.isTTY) process.stdout.write("\x07"); currentCtx?.ui.notify(message, "warning"); currentCtx?.ui.setStatus(STATUS_KEY, `Guardrails waiting: ${reason}`); externalNotify(title, message); }); pi.events.on(BLOCKED_EVENT, () => { currentCtx?.ui.setStatus(STATUS_KEY, undefined); }); pi.on("tool_result", () => { currentCtx?.ui.setStatus(STATUS_KEY, undefined); }); }