feat(pi-notify): notify on permission prompts

This commit is contained in:
云服务部-叶林立
2026-08-28 16:35:33 +08:00
parent b83413d670
commit 8002d7c213
6 changed files with 157 additions and 33 deletions
+10 -9
View File
@@ -1,14 +1,15 @@
# pi-notify
Kitty-first completion notifications for Pi. This repository maintains a source import of
`@smoose/pi-notify@0.1.1` with local reliability and multi-window improvements.
Kitty-first completion and permission-input notifications for Pi. This repository maintains a source import of
`@smoose/pi-notify@0.1.1` with local reliability, permission-bus integration, and multi-window improvements.
## Behavior
- Waits for Pi's `agent_settled` event, so retries, automatic compaction, and queued follow-ups do not notify early.
- Waits for Pi's `agent_settled` event for completion notifications, so retries, automatic compaction, and queued follow-ups do not notify early.
- Listens for `permissions:ui_prompt` and notifies immediately only when `pi-permission-system` is about to ask the human; policy and AutoReview decisions that need no user response stay silent.
- Uses Kitty OSC 99 with Base64 payloads, an ID unique to each Pi session, and explicit `a=focus`.
- Defaults to `o=always`: notifications remain visible while the originating Kitty window/pane has keyboard focus; clicking a notification returns to that exact source window.
- Measures the entire run across automatic retries and ignores runs shorter than three seconds by default.
- Measures the entire run across automatic retries and ignores completion runs shorter than three seconds by default; permission prompts bypass this duration threshold.
- Emits terminal sequences only in interactive TUI mode, never into JSON, print, or RPC output.
- Uses macOS AppleScript outside Kitty and a sanitized OSC 777 fallback outside macOS.
@@ -32,11 +33,11 @@ Command overrides are session-local. Use environment variables for persistent de
## Configuration
- `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled.
- `PI_NOTIFY_TITLE`: title template; default `π - {project}`.
- `PI_NOTIFY_BODY`: body template; default `{message}`.
- `PI_NOTIFY_MESSAGE_SOURCE`: `assistant`, `user`, or privacy mode `none`; default `assistant`.
- `PI_NOTIFY_MESSAGE_MAX`: maximum message characters before ellipsis; default `80`, maximum `500`.
- `PI_NOTIFY_MIN_SECONDS`: minimum complete run duration; default `3`, range `0``3600`.
- `PI_NOTIFY_TITLE`: title template shared by completion and permission notifications; default `π - {project}`.
- `PI_NOTIFY_BODY`: completion-notification body template; default `{message}`. Permission prompts use a bounded `Permission required · <agent> · <surface>` summary.
- `PI_NOTIFY_MESSAGE_SOURCE`: `assistant`, `user`, or privacy mode `none`; default `assistant`. Privacy mode also hides permission values and requester details.
- `PI_NOTIFY_MESSAGE_MAX`: maximum completion-message or permission-value characters before ellipsis; default `80`, maximum `500`.
- `PI_NOTIFY_MIN_SECONDS`: minimum complete run duration; default `3`, range `0``3600`. This does not delay permission prompts.
- `PI_NOTIFY_VISIBILITY`: Kitty policy `unfocused`, `invisible`, or `always`; default `always`.
- `PI_NOTIFY_ACTION`: Kitty click action `focus` or `none`; default `focus`.
- `PI_NOTIFY_SOUND_CMD`: optional shell command run after the notification. This is trusted local configuration and executes through a shell.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@smoose/pi-notify",
"version": "0.1.1-my-pi.1",
"description": "Kitty-aware completion notifications for Pi with exact-window focus and settled-agent delivery.",
"description": "Kitty-aware completion and permission-input notifications for Pi with exact-window focus.",
"type": "module",
"keywords": [
"pi-package",
+62 -5
View File
@@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { getConfig, shouldNotify } from "./config.ts";
import { truncateText } from "./messages.ts";
import { parseMessageMax, parseMessageSource } from "./parsers.ts";
import { getNotificationBackend, sendNotification } from "./terminal.ts";
import type { NotifyConfig } from "./types.ts";
@@ -11,6 +13,35 @@ type ExtensionDependencies = {
getBackend: typeof getNotificationBackend;
};
const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
function formatPermissionPromptBody(raw: unknown): string | undefined {
if (!isRecord(raw) || !readNonEmptyString(raw.requestId)) return undefined;
if (parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE) === "none") {
return "Permission confirmation required";
}
const surface = readNonEmptyString(raw.surface);
const value = readNonEmptyString(raw.value);
const forwarding = isRecord(raw.forwarding) ? raw.forwarding : undefined;
const agentName = readNonEmptyString(forwarding?.requesterAgentName) ?? readNonEmptyString(raw.agentName);
const labels = ["Permission required", agentName, surface].filter((value): value is string => Boolean(value));
if (!value) return labels.join(" · ");
const normalizedValue = value.replace(/\s+/g, " ").trim();
return `${labels.join(" · ")}\n${truncateText(normalizedValue, parseMessageMax(process.env.PI_NOTIFY_MESSAGE_MAX))}`;
}
function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function createPiNotifyExtension(dependencies: Partial<ExtensionDependencies> = {}) {
const now = dependencies.now ?? Date.now;
const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
@@ -23,6 +54,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
let latestMessages: unknown[] | undefined;
let toolErrorCount = 0;
let enabledOverride: boolean | undefined;
let currentCtx: ExtensionContext | undefined;
const resetRun = (): void => {
startedAt = undefined;
@@ -30,7 +62,12 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
toolErrorCount = 0;
};
pi.on("agent_start", () => {
pi.on("session_start", (_event, ctx) => {
currentCtx = ctx;
});
pi.on("agent_start", (_event, ctx) => {
currentCtx = ctx;
startedAt ??= now();
});
@@ -51,14 +88,34 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
}
});
const unsubscribePermissionPrompt = pi.events.on(PERMISSIONS_UI_PROMPT_CHANNEL, (raw) => {
const body = formatPermissionPromptBody(raw);
if (!body || currentCtx?.mode !== "tui") return;
const config = getConfig(
currentCtx,
undefined,
undefined,
{ notificationId, toolErrorCount },
now(),
);
const permissionConfig = { ...config, body, minDurationMs: 0 };
if (shouldNotify(permissionConfig, enabledOverride)) notify(permissionConfig);
});
pi.on("session_shutdown", () => {
currentCtx = undefined;
unsubscribePermissionPrompt();
});
pi.registerCommand("notify", {
description: "Control Kitty/macOS completion notifications: on, off, test, status",
description: "Control Kitty/macOS completion and permission notifications: on, off, test, status",
handler: async (args, ctx) => {
const action = args.trim().toLowerCase() || "status";
if (action === "on" || action === "off") {
enabledOverride = action === "on";
ctx.ui.notify(`Completion notifications ${action}`, "info");
ctx.ui.notify(`Notifications ${action}`, "info");
return;
}
@@ -76,7 +133,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
if (action === "status") {
const enabled = enabledOverride ?? config.enabled;
ctx.ui.notify(
`Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · min ${config.minDurationMs / 1000}s`,
`Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · completion min ${config.minDurationMs / 1000}s`,
"info",
);
return;
+66 -3
View File
@@ -6,15 +6,20 @@ import type { NotifyConfig } from "../src/types.ts";
type Handler = (...args: any[]) => any;
const savedMinSeconds = process.env.PI_NOTIFY_MIN_SECONDS;
const savedEnv = new Map(
["PI_NOTIFY_MIN_SECONDS", "PI_NOTIFY_MESSAGE_SOURCE"].map((key) => [key, process.env[key]]),
);
afterEach(() => {
if (savedMinSeconds === undefined) delete process.env.PI_NOTIFY_MIN_SECONDS;
else process.env.PI_NOTIFY_MIN_SECONDS = savedMinSeconds;
for (const [key, value] of savedEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function harness(mode: ExtensionContext["mode"] = "tui") {
const handlers = new Map<string, Handler>();
const eventHandlers = new Map<string, Handler>();
const commands = new Map<string, { handler: Handler }>();
const notifications: NotifyConfig[] = [];
const uiMessages: string[] = [];
@@ -24,6 +29,12 @@ function harness(mode: ExtensionContext["mode"] = "tui") {
on(name: string, handler: Handler) {
handlers.set(name, handler);
},
events: {
on(name: string, handler: Handler) {
eventHandlers.set(name, handler);
return () => eventHandlers.delete(name);
},
},
registerCommand(name: string, command: { handler: Handler }) {
commands.set(name, command);
},
@@ -50,6 +61,7 @@ function harness(mode: ExtensionContext["mode"] = "tui") {
return {
handlers,
eventHandlers,
commands,
notifications,
uiMessages,
@@ -84,8 +96,14 @@ describe("pi-notify extension", () => {
process.env.PI_NOTIFY_MIN_SECONDS = "0";
for (const mode of ["rpc", "json", "print"] as const) {
const h = harness(mode);
await h.handlers.get("session_start")?.({}, h.ctx);
await h.handlers.get("agent_start")?.({}, h.ctx);
await h.handlers.get("agent_settled")?.({}, h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.({
requestId: "perm-non-tui",
surface: "bash",
value: "git push",
});
assert.equal(h.notifications.length, 0, mode);
}
});
@@ -103,4 +121,49 @@ describe("pi-notify extension", () => {
await command.handler("status", h.ctx);
assert.match(h.uiMessages.at(-1) ?? "", /kitty/);
});
it("notifies immediately when permission confirmation needs human input", async () => {
process.env.PI_NOTIFY_MIN_SECONDS = "3600";
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.({
requestId: "perm-1",
source: "tool_call",
surface: "bash",
value: "git push\norigin main",
agentName: null,
forwarding: { requesterAgentName: "reviewer", requesterSessionId: "child-1" },
});
assert.equal(h.notifications.length, 1);
assert.equal(h.notifications[0]?.body, "Permission required · reviewer · bash\ngit push origin main");
assert.equal(h.notifications[0]?.durationMs, 0);
assert.equal(h.notifications[0]?.minDurationMs, 0, "permission prompts bypass the completion threshold");
assert.equal(h.notifications[0]?.notificationId, "pi-session-test");
});
it("keeps permission details private and honors the session off override", async () => {
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx);
const prompt = { requestId: "perm-secret", surface: "bash", value: "cat private-file" };
h.eventHandlers.get("permissions:ui_prompt")?.(prompt);
assert.equal(h.notifications[0]?.body, "Permission confirmation required");
await h.commands.get("notify")?.handler("off", h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.(prompt);
assert.equal(h.notifications.length, 1);
});
it("ignores malformed permission broadcasts and unsubscribes on shutdown", async () => {
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.({ surface: "bash", value: "git push" });
assert.equal(h.notifications.length, 0);
await h.handlers.get("session_shutdown")?.({}, h.ctx);
assert.equal(h.eventHandlers.has("permissions:ui_prompt"), false);
});
});