mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat(pi-notify): notify on permission prompts
This commit is contained in:
+10
-9
@@ -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,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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user