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 { existsSync, readFileSync, statSync } from "node:fs";
import { dirname, isAbsolute, join, resolve } from "node:path";
import type {
	ExtensionAPI,
	ExtensionContext,
} 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: {
		kind: "select" | "confirm" | "input" | "editor" | "custom";
		title?: string;
	},
	meta: { cwd?: string; branch?: string; sessionName?: string },
): 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: { messages?: unknown[] },
	meta: {
		cwd?: string;
		model?: { provider?: string; id?: string; name?: string };
		durationMs?: number;
		branch?: string;
		sessionName?: string;
		prompt?: string;
	},
): 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);

	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 === "aborted") {
			return {
				title: "Pi stopped",
				message: formatMessage(
					`Stopped${duration ? ` after ${duration}` : ""}`,
					context,
					prompt,
				),
				severity: "warning",
			};
		}
		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 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,
): Promise<"native" | "fallback" | "failed"> {
	return new Promise((resolveDelivery) => {
		const useFallback = () => {
			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" },
				(error) => {
					if (error) 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) {
	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;

	const closeWhenIdle = (): void => {
		if (shuttingDown && pendingDeliveries === 0) closeDebug();
	};

	const deliver = (notification: AgentDoneNotification): void => {
		const finish = span?.("notification.deliver", {
			platform: process.platform,
			status: notification.severity,
		});
		pendingDeliveries++;
		void deliverNative(
			nativeCommand(
				process.platform,
				notification.title,
				notification.message,
				notification.severity,
			),
			notification.title,
			notification.message,
			notification.severity,
			oscBackend(process.env),
		)
			.then((outcome) => 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") {
			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) => {
		eligibility.start(ctx.mode);
		agentStartedAt ??= Date.now();
	});

	pi.on("agent_end", async (event) => {
		lastAgentEnd = event;
	});

	type PromptEvent = {
		kind: "select" | "confirm" | "input" | "editor" | "custom";
		title?: string;
	};
	const onPromptStart = pi.on as unknown as (
		event: "ui_prompt_start",
		handler: (
			event: PromptEvent,
			ctx: ExtensionContext,
		) => Promise<void> | void,
	) => void;
	const onPromptEnd = pi.on as unknown as (
		event: "ui_prompt_end",
		handler: (
			event: PromptEvent,
			ctx: ExtensionContext,
		) => Promise<void> | void,
	) => void;
	onPromptStart("ui_prompt_start", async (event, ctx) => {
		if (awaitingInput) return;
		awaitingInput = true;
		if (!eligibility.isEligible() || focus.isFocused()) return;
		deliver(
			agentWaitingNotification(event, {
				cwd: ctx.cwd,
				branch: currentGitBranch(ctx.cwd),
				sessionName: pi.getSessionName(),
			}),
		);
	});
	onPromptEnd("ui_prompt_end", async () => {
		awaitingInput = false;
	});

	pi.on("agent_settled", async (_event, ctx) => {
		const shouldNotify = eligibility.settle();
		const focused = shouldNotify && lastAgentEnd ? focus.isFocused() : false;
		if (!shouldNotify) dbg?.("notification.skip", { reason: "ineligible" });
		else if (!lastAgentEnd)
			dbg?.("notification.skip", { reason: "missing_result" });
		else if (focused) dbg?.("notification.skip", { reason: "focused" });
		if (shouldNotify && lastAgentEnd && !focused) {
			const notification = agentDoneNotification(lastAgentEnd, {
				cwd: ctx.cwd,
				model: ctx.model,
				durationMs:
					agentStartedAt === undefined
						? undefined
						: Date.now() - agentStartedAt,
				branch: currentGitBranch(ctx.cwd),
				sessionName: pi.getSessionName(),
				prompt: acceptedPrompt,
			});
			deliver(notification);
		}
		awaitingInput = false;
		agentStartedAt = undefined;
		candidatePrompt = undefined;
		acceptedPrompt = undefined;
		lastAgentEnd = undefined;
	});

	pi.on("session_shutdown", async () => {
		dbg?.("session.shutdown");
		shuttingDown = true;
		try {
			eligibility.clear();
			agentStartedAt = undefined;
			candidatePrompt = undefined;
			acceptedPrompt = undefined;
			lastAgentEnd = undefined;
			awaitingInput = false;
			if (focusAttached) {
				writeUserVar("agent", "");
				writeUserVar("agent_session", "");
				writeUserVar("agent_project", "");
				focus.detach();
				focusAttached = false;
			}
		} finally {
			closeWhenIdle();
		}
	});
}