repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/os-notifier/index.ts
Rawimport { 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();
}
});
}