import { execFile } from "node:child_process"; import { accessSync, constants, existsSync, readFileSync, statSync, } from "node:fs"; import { delimiter, dirname, isAbsolute, join, resolve } from "node:path"; import type { AgentSettledEvent, ExtensionAPI, ExtensionContext, UIPromptStartEvent, } from "@earendil-works/pi-coding-agent"; import * as focus from "./focus.js"; import { closeDebug, dbg, span } from "./src/debug.ts"; export type NotificationSeverity = "info" | "warning" | "error"; export type OscBackend = "osc777" | "osc99"; export interface NativeCommand { command: string; args: string[]; env?: NodeJS.ProcessEnv; } type AgentDoneNotification = { title: string; message: string; severity: NotificationSeverity; }; const PROMPT_LIMIT = 58; const ERROR_LIMIT = 120; function compact(value: unknown, limit = Number.POSITIVE_INFINITY): string { if (typeof value !== "string") return ""; const text = value.replace(/[\x00-\x1f\x7f-\x9f]+/g, " ").trim(); if (text.length <= limit) return text; return `${text.slice(0, limit).trimEnd()}…`; } function formatDuration(ms: number | undefined): string { if (ms === undefined || !Number.isFinite(ms) || ms < 0) return ""; if (ms < 1000) return "<1s"; const seconds = Math.round(ms / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const rest = seconds % 60; return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`; } function basename(path: string): string { return ( path .replace(/[/\\]+$/, "") .split(/[/\\]/) .pop() || path ); } function textContent(value: unknown): string { if (typeof value === "string") return value; if (!Array.isArray(value)) return ""; return value .map((part) => { const item = part as { type?: unknown; text?: unknown } | undefined; return item?.type === "text" && typeof item.text === "string" ? item.text : ""; }) .filter(Boolean) .join(" "); } function promptSnippet(messages: unknown[]): string { for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] as | { role?: unknown; content?: unknown } | undefined; if (message?.role === "user") return compact(textContent(message.content), PROMPT_LIMIT); } return ""; } function formatMessage( head: string, context: string[], prompt: string, ): string { const line = [head, ...context].filter(Boolean).join(" • "); return prompt ? `${line}\n↳ ${prompt}` : line; } export function currentGitBranch(cwd: string): string { try { let dir = resolve(cwd); while (true) { const dotGit = join(dir, ".git"); if (existsSync(dotGit)) { const stat = statSync(dotGit); const gitDir = stat.isDirectory() ? dotGit : resolveGitFile(dotGit, dir); const head = readFileSync(join(gitDir, "HEAD"), "utf8").trim(); if (head.startsWith("ref: refs/heads/")) { return head.slice("ref: refs/heads/".length); } return /^[0-9a-f]{7,}$/i.test(head) ? head.slice(0, 7) : ""; } const parent = dirname(dir); if (parent === dir) return ""; dir = parent; } } catch { return ""; } } function resolveGitFile(dotGit: string, worktreeDir: string): string { const text = readFileSync(dotGit, "utf8").trim(); const prefix = "gitdir:"; if (!text.toLowerCase().startsWith(prefix)) return dotGit; const target = text.slice(prefix.length).trim(); return isAbsolute(target) ? target : resolve(worktreeDir, target); } export function agentWaitingNotification( event: Pick, meta: { cwd?: string | undefined; branch?: string | undefined; sessionName?: string | undefined; }, ): AgentDoneNotification { const kind = { select: "Selection", confirm: "Confirmation", input: "Input", editor: "Editor", custom: "Request", }[event.kind]; return { title: "Pi needs input", message: [ kind, compact(event.title, ERROR_LIMIT), meta.cwd ? compact(basename(meta.cwd)) : "", compact(meta.branch), compact(meta.sessionName), ] .filter(Boolean) .join(" • "), severity: "warning", }; } export function agentDoneNotification( event: Pick & { messages?: unknown[] }, meta: { cwd?: string | undefined; model?: { provider?: string; id?: string; name?: string } | undefined; durationMs?: number | undefined; branch?: string | undefined; sessionName?: string | undefined; prompt?: string | undefined; }, ): AgentDoneNotification { const messages = Array.isArray(event.messages) ? event.messages : []; const duration = formatDuration(meta.durationMs); const model = meta.model ? compact(meta.model.name) || [compact(meta.model.provider), compact(meta.model.id)] .filter(Boolean) .join("/") : ""; const context = [ meta.cwd ? compact(basename(meta.cwd)) : "", compact(meta.branch), compact(meta.sessionName), model, ].filter(Boolean); const prompt = promptSnippet(messages) || compact(meta.prompt, PROMPT_LIMIT); if (event.aborted) { return { title: "Pi stopped", message: formatMessage( `Stopped${duration ? ` after ${duration}` : ""}`, context, prompt, ), severity: "warning", }; } for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i] as | { role?: unknown; stopReason?: unknown; errorMessage?: unknown } | undefined; if (message?.role !== "assistant") continue; if (message.stopReason === "error") { return { title: "Pi failed", message: formatMessage( `Failed${duration ? ` after ${duration}` : ""}`, [compact(message.errorMessage, ERROR_LIMIT), ...context], prompt, ), severity: "error", }; } break; } return { title: "Pi finished", message: formatMessage( `Ready${duration ? ` after ${duration}` : ""}`, context, prompt, ), severity: "info", }; } function urgency( severity: NotificationSeverity, ): "low" | "normal" | "critical" { if (severity === "info") return "low"; if (severity === "warning") return "normal"; return "critical"; } const MACOS_SCRIPT = [ "on run argv", "display notification (item 2 of argv) with title (item 1 of argv)", "end run", ].join("\n"); const WINDOWS_SCRIPT = [ "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null", "$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)", "$xml.GetElementsByTagName('text')[0].AppendChild($xml.CreateTextNode($env:PI_OS_NOTIFIER_TITLE)) > $null", "$xml.GetElementsByTagName('text')[1].AppendChild($xml.CreateTextNode($env:PI_OS_NOTIFIER_BODY)) > $null", "$toast = [Windows.UI.Notifications.ToastNotification]::new($xml)", "[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('Pi').Show($toast)", ].join("; "); export function nativeCommand( platform: "linux" | "darwin" | "win32", title: string, body: string, severity: NotificationSeverity, ): NativeCommand; export function nativeCommand( platform: string, title: string, body: string, severity: NotificationSeverity, ): NativeCommand | undefined; export function nativeCommand( platform: string, title: string, body: string, severity: NotificationSeverity, ): NativeCommand | undefined { if (platform === "linux") { return { command: "notify-send", args: ["--app-name=Pi", `--urgency=${urgency(severity)}`, title, body], }; } if (platform === "darwin") { return { command: "osascript", args: ["-e", MACOS_SCRIPT, "--", title, body], }; } if (platform === "win32") { return { command: "powershell.exe", args: [ "-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(WINDOWS_SCRIPT, "utf16le").toString("base64"), ], env: { ...process.env, PI_OS_NOTIFIER_TITLE: title, PI_OS_NOTIFIER_BODY: body, }, }; } return undefined; } export function isTermux(env: NodeJS.ProcessEnv = process.env): boolean { return ( !!env["TERMUX_VERSION"] || /\/com\.termux\/files\/usr\/?$/.test(env["PREFIX"] ?? "") ); } export function termuxCommand(title: string, body: string): NativeCommand { return { command: "termux-notification", args: ["--title", title, "--content", body, "--priority", "high"], }; } function hasTermuxCli(name: string): boolean { return (process.env["PATH"] ?? "").split(delimiter).some((directory) => { if (!directory) return false; try { const path = join(directory, name); accessSync(path, constants.X_OK); return statSync(path).isFile(); } catch { return false; } }); } const TERMUX_SETUP = "Install the termux-api CLI package and Termux:API app from the same source as Termux; enable Android notifications for Termux:API. Android can silently block notifications, even when the CLI reports success."; export function deliverVibration(timeoutMs = 5000): Promise { return new Promise((resolveDelivery) => { try { execFile( "termux-vibrate", ["-d", "500"], { timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 16 * 1024 }, (error, stdout, stderr) => { resolveDelivery( !error && !/error|permission|denied|not granted|not installed/i.test( `${stdout}\n${stderr}`, ), ); }, ); } catch { resolveDelivery(false); } }); } export function oscBackend( env: Record, ): OscBackend { return env["KITTY_WINDOW_ID"] ? "osc99" : "osc777"; } export function oscUserVar(name: string, value: string): string { return `\x1b]1337;SetUserVar=${name}=${Buffer.from(value).toString("base64")}\x07`; } function writeUserVar(name: string, value: string): void { try { process.stdout.write(oscUserVar(name, value)); } catch { // Terminal metadata is best-effort. } } function severityPrefix(severity: NotificationSeverity): string { return severity === "warning" ? "⚠️ " : severity === "error" ? "❌ " : ""; } function oscNotification( backend: OscBackend, title: string, body: string, severity: NotificationSeverity, ): string { const message = `${severityPrefix(severity)}${body}`; if (backend === "osc99") { return `\x1b]99;i=1:d=0;${title}\x1b\\\x1b]99;i=1:p=body;${message}\x1b\\`; } return `\x1b]777;notify;${title};${message}\x07`; } export function deliverNative( command: NativeCommand | undefined, title: string, body: string, severity: NotificationSeverity, fallback: OscBackend, write: (value: string) => unknown = (value) => process.stdout.write(value), timeoutMs = 5000, onNativeFailure?: () => void, ): Promise<"native" | "fallback" | "failed"> { return new Promise((resolveDelivery) => { const useFallback = () => { onNativeFailure?.(); try { write(oscNotification(fallback, title, body, severity)); resolveDelivery("fallback"); } catch { // No backend remains. resolveDelivery("failed"); } }; if (!command) { useFallback(); return; } try { execFile( command.command, command.args, { env: command.env, timeout: timeoutMs, killSignal: "SIGKILL", maxBuffer: 16 * 1024, }, (error, stdout, stderr) => { // Termux:API can report JSON permission errors with exit status zero. const apiError = command.command === "termux-notification" && /error|permission|denied|not granted|not installed/i.test( `${stdout}\n${stderr}`, ); if (error || apiError) useFallback(); else resolveDelivery("native"); }, ); } catch { useFallback(); } }); } type InputSource = "interactive" | "rpc" | "extension" | string; type StreamingBehavior = "steer" | "followUp" | undefined; export class TurnEligibility { private active = false; private activeTui = false; private current = false; private pending = false; private prepared = false; input(source: InputSource, _behavior?: StreamingBehavior): void { if (this.active) return; this.pending = source === "interactive"; } prepare(): void { if (this.active) { this.current ||= this.activeTui && this.pending; this.pending = false; return; } this.prepared = this.pending; this.pending = false; } start(mode: string): void { if (this.active) return; this.active = true; this.activeTui = mode === "tui"; this.current = this.activeTui && this.prepared; this.prepared = false; } settle(): boolean { const eligible = this.isEligible(); this.reset(); return eligible; } isEligible(): boolean { return this.current; } clear(): void { this.reset(); } private reset(): void { this.active = false; this.activeTui = false; this.current = false; this.pending = false; this.prepared = false; } } export default function (pi: ExtensionAPI): void { const eligibility = new TurnEligibility(); let agentStartedAt: number | undefined; let candidatePrompt: string | undefined; let acceptedPrompt: string | undefined; let lastAgentEnd: { messages?: unknown[] } | undefined; let focusAttached = false; let awaitingInput = false; let pendingDeliveries = 0; let shuttingDown = false; let runGeneration = 0; let promptGeneration = 0; const termux = isTermux(); const warnings = new Set(); const warn = (ctx: ExtensionContext, key: string, message: string): void => { if (shuttingDown || warnings.has(key)) return; warnings.add(key); ctx.ui.notify(`os-notifier: ${message}`, "warning"); }; const isUnfocused = async (ctx: ExtensionContext): Promise => { if (!termux) return !focus.isFocused(); let focused: boolean | undefined; try { const { termuxFocused } = await import("./termux-focus.ts"); focused = await termuxFocused(); } catch { // Missing/corrupt runtime modules must not turn unknown focus into alerts. } if (focused === undefined) warn( ctx, "focus", "Android focus query failed or timed out; alerts suppressed. Check the packaged android/focus.jar and Android runtime compatibility, then reload Pi.", ); return focused === false; }; const closeWhenIdle = (): void => { if (shuttingDown && pendingDeliveries === 0) closeDebug(); }; const deliver = ( notification: AgentDoneNotification, ctx: ExtensionContext, ): void => { const finish = span?.("notification.deliver", { platform: process.platform, status: notification.severity, }); pendingDeliveries++; const delivery = deliverNative( termux ? termuxCommand(notification.title, notification.message) : nativeCommand( process.platform, notification.title, notification.message, notification.severity, ), notification.title, notification.message, notification.severity, oscBackend(process.env), undefined, 5000, termux ? () => warn( ctx, "delivery", `Termux notification failed or timed out. ${TERMUX_SETUP} Check Android battery restrictions if background delivery stalls.`, ) : undefined, ); void Promise.all([ delivery, termux ? deliverVibration() : Promise.resolve(true), ]) .then(([outcome, vibrated]) => { if (!vibrated) warn( ctx, "vibration", `Termux vibration failed or timed out. Check termux-vibrate on PATH and Termux:API app permissions. Silent mode and Do Not Disturb are respected.`, ); finish?.("finish", { kind: "delivery", outcome }); }) .catch(() => finish?.("finish", { kind: "delivery", outcome: "failed" })) .finally(() => { pendingDeliveries--; closeWhenIdle(); }); }; pi.on("session_start", async (_event, ctx) => { dbg?.("session.start", { mode: ctx.mode }); if (ctx.mode === "tui") { if (termux) { const missing = ["termux-notification", "termux-vibrate"].filter( (name) => !hasTermuxCli(name), ); if (missing.length) warn( ctx, "cli", `${missing.join(", ")} is missing from PATH. ${TERMUX_SETUP}`, ); } if (!termux) focus.attach(); writeUserVar("agent", "pi"); writeUserVar("agent_session", pi.getSessionName() ?? ""); writeUserVar("agent_project", basename(ctx.cwd)); focusAttached = true; } }); pi.on("session_info_changed", async (event) => { if (focusAttached) writeUserVar("agent_session", event.name ?? ""); }); pi.on("input", async (event) => { eligibility.input(event.source, event.streamingBehavior); if (event.streamingBehavior === undefined) { candidatePrompt = event.source === "interactive" ? event.text : undefined; } }); pi.on("before_agent_start", async () => { eligibility.prepare(); acceptedPrompt = candidatePrompt; candidatePrompt = undefined; }); pi.on("agent_start", async (_event, ctx) => { runGeneration++; eligibility.start(ctx.mode); agentStartedAt ??= Date.now(); }); pi.on("agent_end", async (event) => { lastAgentEnd = event; }); pi.on("ui_prompt_start", async (event, ctx) => { if (awaitingInput) return; awaitingInput = true; const prompt = ++promptGeneration; const run = runGeneration; if (!eligibility.isEligible()) return; const unfocused = await isUnfocused(ctx); if ( !unfocused || shuttingDown || prompt !== promptGeneration || run !== runGeneration ) return; deliver( agentWaitingNotification(event, { cwd: ctx.cwd, branch: currentGitBranch(ctx.cwd), sessionName: pi.getSessionName(), }), ctx, ); }); pi.on("ui_prompt_end", async () => { awaitingInput = false; promptGeneration++; }); pi.on("agent_settled", async (event, ctx) => { const shouldNotify = eligibility.settle(); const result = lastAgentEnd ?? (event.aborted ? { messages: [] } : undefined); const durationMs = agentStartedAt === undefined ? undefined : Date.now() - agentStartedAt; const prompt = acceptedPrompt; const run = runGeneration; awaitingInput = false; promptGeneration++; agentStartedAt = undefined; candidatePrompt = undefined; acceptedPrompt = undefined; lastAgentEnd = undefined; const focused = shouldNotify && result ? !(await isUnfocused(ctx)) : false; if (shuttingDown || run !== runGeneration) return; if (!shouldNotify) dbg?.("notification.skip", { reason: "ineligible" }); else if (!result) dbg?.("notification.skip", { reason: "missing_result" }); else if (focused) dbg?.("notification.skip", { reason: "focused" }); if (shouldNotify && result && !focused) { const notification = agentDoneNotification( { ...result, aborted: event.aborted }, { cwd: ctx.cwd, model: ctx.model, durationMs, branch: currentGitBranch(ctx.cwd), sessionName: pi.getSessionName(), prompt, }, ); deliver(notification, ctx); } }); pi.on("session_shutdown", async () => { dbg?.("session.shutdown"); shuttingDown = true; runGeneration++; promptGeneration++; try { eligibility.clear(); agentStartedAt = undefined; candidatePrompt = undefined; acceptedPrompt = undefined; lastAgentEnd = undefined; awaitingInput = false; if (focusAttached) { writeUserVar("agent", ""); writeUserVar("agent_session", ""); writeUserVar("agent_project", ""); if (!termux) focus.detach(); focusAttached = false; } } finally { closeWhenIdle(); } }); }