Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/os-notifier/index.ts

Raw
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<UIPromptStartEvent, "kind" | "title">,
	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<AgentSettledEvent, "aborted"> & { 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<boolean> {
	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<string, string | undefined>,
): 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<string>();
	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<boolean> => {
		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();
		}
	});
}