feat: add Kitty-aware completion notifications

This commit is contained in:
叶林立
2026-08-24 20:13:12 +08:00
parent d3bf562189
commit d65ce3800c
24 changed files with 3120 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 smoose
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+80
View File
@@ -0,0 +1,80 @@
# 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.
## Behavior
- Waits for Pi's `agent_settled` event, so retries, automatic compaction, and queued follow-ups do not notify early.
- Uses Kitty OSC 99 with Base64 payloads, an ID unique to each Pi session, and explicit `a=focus`.
- Defaults to `o=unfocused`: no notification 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.
- 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.
The root `my-pi` bundle loads this local source directly. For the unmodified upstream package, use:
```bash
pi install npm:@smoose/pi-notify
```
Do not separately install upstream when using the bundle, or notifications will be duplicated.
## Commands
- `/notify on`: enable notifications for the current session.
- `/notify off`: disable notifications for the current session.
- `/notify test`: send a notification immediately; click it to verify exact Kitty-window focus.
- `/notify status`: show the active backend, visibility policy, and duration threshold.
Command overrides are session-local. Use environment variables for persistent defaults.
## 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_VISIBILITY`: Kitty policy `unfocused`, `invisible`, or `always`; default `unfocused`.
- `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.
Visibility policies:
- `unfocused`: notify whenever the originating Kitty window/pane does not have keyboard focus.
- `invisible`: notify only when the originating window/pane is not visible.
- `always`: notify even while typing in the originating window.
Inside tmux, enable passthrough and restart the tmux server after changing the setting:
```tmux
set -g allow-passthrough all
```
The active tmux client type is checked so a stale `KITTY_WINDOW_ID` cannot force the Kitty backend for a non-Kitty client.
## Template variables
- `{project}`: current directory name.
- `{cwd}`: current working directory.
- `{model}`: active model as `provider/id`.
- `{model_short}`: active model ID.
- `{message}`: selected message, or `Ready for input` when source is `none` or text is unavailable.
- `{user_message}`: latest user text, truncated.
- `{assistant_message}`: latest settled assistant text, truncated.
- `{duration}` / `{duration_ms}`: complete duration including retries.
- `{status}`: `ready`, or `ready · N tool errors` when tools reported errors.
- `{status_icon}`: `✓` or `⚠`. This reports observed tool errors, not a definitive task-success verdict.
- `{tool_error_count}`: observed tool error count.
Example for multiple projects without exposing assistant text on the lock screen:
```bash
export PI_NOTIFY_TITLE='π {status_icon} · {project}'
export PI_NOTIFY_MESSAGE_SOURCE=none
export PI_NOTIFY_VISIBILITY=unfocused
```
Kitty must be allowed under **System Settings → Notifications**. Self-built or unsigned Kitty binaries may not be able to publish macOS notifications.
+22
View File
@@ -0,0 +1,22 @@
# Upstream source
- Project: `@smoose/pi-notify`
- Repository: <https://github.com/smoosex/pi-notify>
- Imported npm version: `0.1.1`
- Imported commit: `3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`
- Commit date: 2026-06-02
- npm tarball and repository source were compared before import.
The source is maintained directly in this repository. It is not a submodule and does not retain an embedded `.git` directory or upstream build artifacts.
## Local changes
- Notify on `agent_settled` rather than `agent_end`, preserving elapsed time and the latest messages across retries.
- Restrict terminal output to TUI mode so OSC sequences cannot corrupt RPC, JSON, or print output.
- Give each Pi session a stable unique OSC 99 notification ID.
- Make Kitty click-to-focus explicit and default visibility to the source-window-aware `unfocused` policy.
- Add duration filtering, enable/privacy/action settings, status template fields, and `/notify` controls.
- Keep Base64 Kitty payloads, harden OSC metadata/fallback sanitization, and ignore detached-process spawn errors.
- Add lifecycle, multi-window metadata, fallback, parser, and configuration tests.
Upstream declares the project under the MIT license but did not include a `LICENSE` file at the imported commit or in the npm tarball. This directory adds the standard MIT text using the upstream author's GitHub identity and commit year so the declared license travels with the maintained source.
+2007
View File
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
{
"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.",
"type": "module",
"keywords": [
"pi-package",
"pi-extension",
"notification"
],
"license": "MIT",
"pi": {
"extensions": [
"./src/index.ts"
]
},
"files": [
"src/",
"README.md",
"UPSTREAM.md",
"LICENSE"
],
"repository": {
"type": "git",
"url": "git+https://github.com/smoosex/pi-notify.git"
},
"homepage": "https://github.com/smoosex/pi-notify",
"bugs": {
"url": "https://github.com/smoosex/pi-notify/issues"
},
"peerDependencies": {
"@earendil-works/pi-coding-agent": "*"
},
"devDependencies": {
"@earendil-works/pi-coding-agent": "0.84.2",
"@types/node": "24.10.13",
"typescript": "6.0.3"
},
"scripts": {
"test": "node --experimental-strip-types --test tests/*.test.ts",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm run test"
},
"engines": {
"node": ">=22.19.0"
}
}
+56
View File
@@ -0,0 +1,56 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { extractMessageText, truncateText } from "./messages.ts";
import {
parseEnabled,
parseKittyVisibility,
parseMessageMax,
parseMessageSource,
parseMinDurationMs,
parseNotificationAction,
} from "./parsers.ts";
import { renderTemplate } from "./template.ts";
import type { NotificationRuntimeValues, NotifyConfig } from "./types.ts";
const DEFAULT_TITLE = "π - {project}";
const DEFAULT_BODY = "{message}";
export function getConfig(
ctx: ExtensionContext | undefined,
startedAt: number | undefined,
messages: unknown[] | undefined,
runtime: NotificationRuntimeValues,
now = Date.now(),
): NotifyConfig {
const durationMs = startedAt === undefined ? 0 : Math.max(0, now - startedAt);
const messageMax = parseMessageMax(process.env.PI_NOTIFY_MESSAGE_MAX);
const userMessage = truncateText(extractMessageText(messages, "user"), messageMax);
const assistantMessage = truncateText(extractMessageText(messages, "assistant"), messageMax);
const messageSource = parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE);
const selectedMessage = messageSource === "user" ? userMessage : messageSource === "assistant" ? assistantMessage : "";
const message = selectedMessage || "Ready for input";
const toolErrorCount = Math.max(0, runtime.toolErrorCount ?? 0);
const values = {
user_message: userMessage,
assistant_message: assistantMessage,
message,
status: toolErrorCount > 0 ? `ready · ${toolErrorCount} tool error${toolErrorCount === 1 ? "" : "s"}` : "ready",
status_icon: toolErrorCount > 0 ? "⚠" : "✓",
tool_error_count: String(toolErrorCount),
};
return {
enabled: parseEnabled(process.env.PI_NOTIFY_ENABLED),
title: renderTemplate(process.env.PI_NOTIFY_TITLE || DEFAULT_TITLE, ctx, durationMs, values),
body: renderTemplate(process.env.PI_NOTIFY_BODY || DEFAULT_BODY, ctx, durationMs, values),
durationMs,
minDurationMs: parseMinDurationMs(process.env.PI_NOTIFY_MIN_SECONDS),
notificationId: runtime.notificationId,
visibility: parseKittyVisibility(process.env.PI_NOTIFY_VISIBILITY),
action: parseNotificationAction(process.env.PI_NOTIFY_ACTION),
soundCommand: process.env.PI_NOTIFY_SOUND_CMD?.trim() || undefined,
};
}
export function shouldNotify(config: NotifyConfig, enabledOverride?: boolean): boolean {
return (enabledOverride ?? config.enabled) && config.durationMs >= config.minDurationMs;
}
+91
View File
@@ -0,0 +1,91 @@
import { randomUUID } from "node:crypto";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { getConfig, shouldNotify } from "./config.ts";
import { getNotificationBackend, sendNotification } from "./terminal.ts";
import type { NotifyConfig } from "./types.ts";
type ExtensionDependencies = {
now: () => number;
createNotificationId: () => string;
notify: (config: NotifyConfig) => void;
getBackend: typeof getNotificationBackend;
};
export function createPiNotifyExtension(dependencies: Partial<ExtensionDependencies> = {}) {
const now = dependencies.now ?? Date.now;
const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
const notify = dependencies.notify ?? sendNotification;
const getBackend = dependencies.getBackend ?? getNotificationBackend;
return function piNotify(pi: ExtensionAPI): void {
const notificationId = createNotificationId();
let startedAt: number | undefined;
let latestMessages: unknown[] | undefined;
let toolErrorCount = 0;
let enabledOverride: boolean | undefined;
const resetRun = (): void => {
startedAt = undefined;
latestMessages = undefined;
toolErrorCount = 0;
};
pi.on("agent_start", () => {
startedAt ??= now();
});
pi.on("tool_execution_end", (event) => {
if (event.isError) toolErrorCount++;
});
pi.on("agent_end", (event) => {
latestMessages = event.messages;
});
pi.on("agent_settled", (_event, ctx) => {
try {
const config = getConfig(ctx, startedAt, latestMessages, { notificationId, toolErrorCount }, now());
if (ctx.mode === "tui" && shouldNotify(config, enabledOverride)) notify(config);
} finally {
resetRun();
}
});
pi.registerCommand("notify", {
description: "Control Kitty/macOS completion 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");
return;
}
const config = getConfig(ctx, now() - 1000, undefined, { notificationId, toolErrorCount: 0 }, now());
if (action === "test") {
if (ctx.mode !== "tui") {
ctx.ui.notify("System notifications are only emitted in interactive TUI mode", "warning");
return;
}
notify({ ...config, enabled: true, minDurationMs: 0, body: "Kitty notification test — click to focus this window" });
ctx.ui.notify(`Test sent through ${getBackend()}`, "info");
return;
}
if (action === "status") {
const enabled = enabledOverride ?? config.enabled;
ctx.ui.notify(
`Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · min ${config.minDurationMs / 1000}s`,
"info",
);
return;
}
ctx.ui.notify("Usage: /notify on|off|test|status", "warning");
},
});
};
}
export default createPiNotifyExtension();
+42
View File
@@ -0,0 +1,42 @@
export function extractMessageText(messages: unknown[] | undefined, role: "user" | "assistant"): string {
if (!messages) return "";
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!isRecord(message) || message.role !== role) continue;
const text = normalizeMessageText(extractContentText(message.content));
if (text) return text;
}
return "";
}
export function truncateText(text: string, maxLength: number): string {
const chars = Array.from(text);
if (chars.length <= maxLength) return text;
if (maxLength <= 0) return "";
if (maxLength === 1) return "…";
return `${chars.slice(0, maxLength - 1).join("")}`;
}
function extractContentText(content: unknown): string {
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((block) => {
if (!isRecord(block)) return "";
return block.type === "text" && typeof block.text === "string" ? block.text : "";
})
.filter(Boolean)
.join(" ");
}
function normalizeMessageText(text: string): string {
return text.replace(/\s+/g, " ").trim();
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
+44
View File
@@ -0,0 +1,44 @@
import type { KittyVisibility, MessageSource, NotificationAction } from "./types.ts";
export function parseMessageSource(value: string | undefined): MessageSource {
const normalized = value?.trim().toLowerCase();
if (normalized === "user" || normalized === "none") return normalized;
return "assistant";
}
export function parseMessageMax(value: string | undefined): number {
return parseBoundedInteger(value, 80, 0, 500);
}
export function parseMinDurationMs(value: string | undefined): number {
const seconds = parseBoundedNumber(value, 3, 0, 3600);
return Math.round(seconds * 1000);
}
export function parseEnabled(value: string | undefined): boolean {
if (value === undefined) return true;
return !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
}
export function parseKittyVisibility(value: string | undefined): KittyVisibility {
const normalized = value?.trim().toLowerCase();
if (normalized === "always" || normalized === "invisible") return normalized;
return "unfocused";
}
export function parseNotificationAction(value: string | undefined): NotificationAction {
return value?.trim().toLowerCase() === "none" ? "none" : "focus";
}
function parseBoundedInteger(value: string | undefined, fallback: number, min: number, max: number): number {
const parsed = Number.parseInt(value || String(fallback), 10);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(parsed, max));
}
function parseBoundedNumber(value: string | undefined, fallback: number, min: number, max: number): number {
const normalized = value?.trim();
const parsed = Number(normalized ? normalized : fallback);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(min, Math.min(parsed, max));
}
+41
View File
@@ -0,0 +1,41 @@
import { basename } from "node:path";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type { TemplateValues } from "./types.ts";
export function renderTemplate(
template: string,
ctx: ExtensionContext | undefined,
durationMs: number,
extraValues: TemplateValues = {},
): string {
const cwd = ctx?.cwd || process.cwd();
const values: TemplateValues = {
cwd,
project: basename(cwd),
model: formatModel(ctx),
model_short: formatModelShort(ctx),
duration: formatDuration(durationMs),
duration_ms: String(durationMs),
...extraValues,
};
return template.replace(/\{([a-z_]+)\}/g, (match, key) => values[key] ?? match);
}
function formatModel(ctx: ExtensionContext | undefined): string {
if (!ctx?.model) return "unknown";
return `${ctx.model.provider}/${ctx.model.id}`;
}
function formatModelShort(ctx: ExtensionContext | undefined): string {
return ctx?.model?.id || "unknown";
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const minutes = Math.floor(ms / 60_000);
const seconds = Math.round((ms % 60_000) / 1000);
return `${minutes}m ${seconds}s`;
}
+127
View File
@@ -0,0 +1,127 @@
import { execFileSync, spawn } from "node:child_process";
import type { NotificationBackend, NotifyConfig } from "./types.ts";
const ESC = "\x1b";
const BEL = "\x07";
const ST = `${ESC}\\`;
type NotifierDependencies = {
spawn: typeof spawn;
readTmuxClientInfo: () => string | undefined;
platform: NodeJS.Platform;
};
export function createNotifier(dependencies: Partial<NotifierDependencies> = {}) {
const spawnCommand = dependencies.spawn ?? spawn;
const tmuxClientInfo = dependencies.readTmuxClientInfo ?? readTmuxClientInfo;
const platform = dependencies.platform ?? process.platform;
return {
getBackend(): NotificationBackend {
return detectNotificationBackend(tmuxClientInfo, platform);
},
sendNotification(config: NotifyConfig): void {
try {
const backend = detectNotificationBackend(tmuxClientInfo, platform);
if (backend === "kitty") notifyKitty(config);
else if (backend === "apple-script") notifyAppleScript(config.title, config.body, spawnCommand);
else notifyOsc777(config.title, config.body);
runSoundHook(config.soundCommand, spawnCommand);
} catch {}
},
};
}
const defaultNotifier = createNotifier();
export function sendNotification(config: NotifyConfig): void {
defaultNotifier.sendNotification(config);
}
export function getNotificationBackend(): NotificationBackend {
return defaultNotifier.getBackend();
}
export function detectNotificationBackend(
readClientInfo: () => string | undefined = readTmuxClientInfo,
platform: NodeJS.Platform = process.platform,
): NotificationBackend {
if (isKitty(readClientInfo)) return "kitty";
return platform === "darwin" ? "apple-script" : "osc-777";
}
function isKitty(readClientInfo: () => string | undefined): boolean {
if (!process.env.TMUX) return Boolean(process.env.KITTY_WINDOW_ID);
const info = readClientInfo()?.toLowerCase();
if (info) return info.includes("kitty");
return Boolean(process.env.KITTY_WINDOW_ID);
}
function readTmuxClientInfo(): string | undefined {
try {
return execFileSync("tmux", ["display-message", "-p", "#{client_termname} #{client_termtype}"], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
} catch {
return undefined;
}
}
function notifyKitty(config: NotifyConfig): void {
const safeTitle = Buffer.from(config.title, "utf8").toString("base64");
const safeBody = Buffer.from(config.body, "utf8").toString("base64");
const id = sanitizeMetadataValue(config.notificationId);
writeSequence(
`${ESC}]99;i=${id}:d=0:e=1:o=${config.visibility}:a=${config.action};${safeTitle}${ST}`,
);
writeSequence(`${ESC}]99;i=${id}:p=body:e=1;${safeBody}${ST}`);
}
function notifyAppleScript(title: string, body: string, spawnCommand: typeof spawn): void {
const script = `display notification ${appleScriptString(body)} with title ${appleScriptString(title)}`;
spawnDetached(spawnCommand, "osascript", ["-e", script]);
}
function notifyOsc777(title: string, body: string): void {
process.stdout.write(`${ESC}]777;notify;${sanitizeOsc777(title)};${sanitizeOsc777(body)}${BEL}`);
}
function writeSequence(sequence: string): void {
process.stdout.write(wrapForTmux(sequence));
}
function wrapForTmux(sequence: string): string {
if (!process.env.TMUX) return sequence;
return `${ESC}Ptmux;${sequence.replaceAll(ESC, ESC + ESC)}${ST}`;
}
function sanitizeMetadataValue(value: string): string {
const sanitized = value.replace(/[^A-Za-z0-9_.-]/g, "-").slice(0, 128);
return sanitized || `pi-${process.pid}`;
}
function sanitizeOsc777(value: string): string {
return value.replace(/[\x00-\x1f\x7f;]/g, " ").replace(/\s+/g, " ").trim();
}
function appleScriptString(value: string): string {
return `"${value.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\\/g, "\\\\").replace(/"/g, '\\"').trim()}"`;
}
function runSoundHook(command: string | undefined, spawnCommand: typeof spawn): void {
if (!command) return;
spawnDetached(spawnCommand, command, [], true);
}
function spawnDetached(spawnCommand: typeof spawn, command: string, args: string[], shell = false): void {
const child = spawnCommand(command, args, {
shell,
detached: true,
stdio: "ignore",
});
child.on?.("error", () => {});
child.unref();
}
+24
View File
@@ -0,0 +1,24 @@
export type MessageSource = "user" | "assistant" | "none";
export type KittyVisibility = "always" | "unfocused" | "invisible";
export type NotificationAction = "focus" | "none";
export type NotificationBackend = "kitty" | "apple-script" | "osc-777";
export type NotifyConfig = {
enabled: boolean;
title: string;
body: string;
durationMs: number;
minDurationMs: number;
notificationId: string;
visibility: KittyVisibility;
action: NotificationAction;
soundCommand?: string;
};
export type NotificationRuntimeValues = {
notificationId: string;
toolErrorCount?: number;
};
export type TemplateValues = Record<string, string>;
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { getConfig, shouldNotify } from "../src/config.ts";
const envKeys = [
"PI_NOTIFY_ENABLED",
"PI_NOTIFY_TITLE",
"PI_NOTIFY_BODY",
"PI_NOTIFY_MESSAGE_SOURCE",
"PI_NOTIFY_MESSAGE_MAX",
"PI_NOTIFY_MIN_SECONDS",
"PI_NOTIFY_VISIBILITY",
"PI_NOTIFY_ACTION",
"PI_NOTIFY_SOUND_CMD",
] as const;
const savedEnv = new Map(envKeys.map((key) => [key, process.env[key]]));
afterEach(() => {
for (const key of envKeys) {
const value = savedEnv.get(key);
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function context(): ExtensionContext {
return {
cwd: "/tmp/pi-notify",
model: { provider: "openai", id: "gpt-4.1" },
} as ExtensionContext;
}
const runtime = { notificationId: "pi-session", toolErrorCount: 0 };
describe("config", () => {
it("builds safe Kitty defaults from the settled assistant output", () => {
const config = getConfig(
context(),
800,
[
{ role: "user", content: "please fix this" },
{ role: "assistant", content: "fixed it" },
],
runtime,
5_000,
);
assert.deepEqual(config, {
enabled: true,
title: "π - pi-notify",
body: "fixed it",
durationMs: 4_200,
minDurationMs: 3_000,
notificationId: "pi-session",
visibility: "unfocused",
action: "focus",
soundCommand: undefined,
});
assert.equal(shouldNotify(config), true);
});
it("supports privacy, threshold, focus, and status template settings", () => {
process.env.PI_NOTIFY_ENABLED = "off";
process.env.PI_NOTIFY_TITLE = "{status_icon} {status} {tool_error_count}";
process.env.PI_NOTIFY_BODY = "{message}";
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
process.env.PI_NOTIFY_MIN_SECONDS = "10.5";
process.env.PI_NOTIFY_VISIBILITY = "invisible";
process.env.PI_NOTIFY_ACTION = "none";
process.env.PI_NOTIFY_SOUND_CMD = " say done ";
const config = getConfig(context(), 1_000, [{ role: "assistant", content: "secret" }], {
notificationId: "pi-session",
toolErrorCount: 2,
}, 2_000);
assert.equal(config.title, "⚠ ready · 2 tool errors 2");
assert.equal(config.body, "Ready for input");
assert.equal(config.enabled, false);
assert.equal(config.minDurationMs, 10_500);
assert.equal(config.visibility, "invisible");
assert.equal(config.action, "none");
assert.equal(config.soundCommand, "say done");
assert.equal(shouldNotify(config), false);
assert.equal(shouldNotify(config, true), false, "the minimum duration still applies to command overrides");
});
it("applies selected-message truncation and a session override", () => {
process.env.PI_NOTIFY_MESSAGE_SOURCE = "user";
process.env.PI_NOTIFY_MESSAGE_MAX = "5";
process.env.PI_NOTIFY_MIN_SECONDS = "0";
const config = getConfig(
context(),
1_000,
[{ role: "user", content: "hello world" }],
runtime,
2_000,
);
assert.equal(config.body, "hell…");
assert.equal(shouldNotify(config, false), false);
assert.equal(shouldNotify(config, true), true);
});
});
+106
View File
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { createPiNotifyExtension } from "../src/index.ts";
import type { NotifyConfig } from "../src/types.ts";
type Handler = (...args: any[]) => any;
const savedMinSeconds = process.env.PI_NOTIFY_MIN_SECONDS;
afterEach(() => {
if (savedMinSeconds === undefined) delete process.env.PI_NOTIFY_MIN_SECONDS;
else process.env.PI_NOTIFY_MIN_SECONDS = savedMinSeconds;
});
function harness(mode: ExtensionContext["mode"] = "tui") {
const handlers = new Map<string, Handler>();
const commands = new Map<string, { handler: Handler }>();
const notifications: NotifyConfig[] = [];
const uiMessages: string[] = [];
let now = 1_000;
const pi = {
on(name: string, handler: Handler) {
handlers.set(name, handler);
},
registerCommand(name: string, command: { handler: Handler }) {
commands.set(name, command);
},
} as unknown as ExtensionAPI;
createPiNotifyExtension({
now: () => now,
createNotificationId: () => "pi-session-test",
notify: (config) => notifications.push(config),
getBackend: () => "kitty",
})(pi);
const ctx = {
mode,
hasUI: mode === "tui" || mode === "rpc",
cwd: "/tmp/project-a",
model: { provider: "openai", id: "gpt-test" },
ui: {
notify(message: string) {
uiMessages.push(message);
},
},
} as unknown as ExtensionContext;
return {
handlers,
commands,
notifications,
uiMessages,
ctx,
setNow(value: number) {
now = value;
},
};
}
describe("pi-notify extension", () => {
it("notifies once only after retries and follow-ups have settled", async () => {
const h = harness();
await h.handlers.get("agent_start")?.({}, h.ctx);
h.setNow(2_000);
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "retrying" }] }, h.ctx);
await h.handlers.get("agent_start")?.({}, h.ctx);
await h.handlers.get("tool_execution_end")?.({ isError: true }, h.ctx);
h.setNow(5_000);
await h.handlers.get("agent_end")?.({ messages: [{ role: "assistant", content: "finally done" }] }, h.ctx);
assert.equal(h.notifications.length, 0, "agent_end must not notify before Pi is settled");
await h.handlers.get("agent_settled")?.({}, h.ctx);
assert.equal(h.notifications.length, 1);
assert.equal(h.notifications[0]?.body, "finally done");
assert.equal(h.notifications[0]?.durationMs, 4_000, "duration includes retries");
assert.equal(h.notifications[0]?.notificationId, "pi-session-test");
});
it("does not write terminal notifications in RPC or non-interactive modes", async () => {
process.env.PI_NOTIFY_MIN_SECONDS = "0";
for (const mode of ["rpc", "json", "print"] as const) {
const h = harness(mode);
await h.handlers.get("agent_start")?.({}, h.ctx);
await h.handlers.get("agent_settled")?.({}, h.ctx);
assert.equal(h.notifications.length, 0, mode);
}
});
it("registers session controls and a test notification", async () => {
const h = harness();
const command = h.commands.get("notify");
assert.ok(command);
await command.handler("off", h.ctx);
assert.match(h.uiMessages.at(-1) ?? "", /off/);
await command.handler("test", h.ctx);
assert.equal(h.notifications.length, 1);
assert.match(h.notifications[0]?.body ?? "", /click to focus this window/);
await command.handler("status", h.ctx);
assert.match(h.uiMessages.at(-1) ?? "", /kitty/);
});
});
+38
View File
@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { extractMessageText, truncateText } from "../src/messages.ts";
describe("messages", () => {
it("extracts the latest text message for a role", () => {
const messages = [
{ role: "user", content: "first user" },
{ role: "assistant", content: [{ type: "text", text: "first assistant" }] },
{ role: "user", content: [{ type: "text", text: "latest\nuser" }] },
];
assert.equal(extractMessageText(messages, "user"), "latest user");
assert.equal(extractMessageText(messages, "assistant"), "first assistant");
});
it("ignores non-text content blocks", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "image", text: "ignore me" },
{ type: "text", text: "keep me" },
],
},
];
assert.equal(extractMessageText(messages, "assistant"), "keep me");
});
it("truncates by unicode code points", () => {
assert.equal(truncateText("hello", 10), "hello");
assert.equal(truncateText("hello", 4), "hel…");
assert.equal(truncateText("🤖abc", 3), "🤖a…");
assert.equal(truncateText("hello", 1), "…");
assert.equal(truncateText("hello", 0), "");
});
});
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
parseEnabled,
parseKittyVisibility,
parseMessageMax,
parseMessageSource,
parseMinDurationMs,
parseNotificationAction,
} from "../src/parsers.ts";
describe("parsers", () => {
it("parses message source including privacy mode", () => {
assert.equal(parseMessageSource("assistant"), "assistant");
assert.equal(parseMessageSource(" user "), "user");
assert.equal(parseMessageSource("none"), "none");
assert.equal(parseMessageSource("invalid"), "assistant");
});
it("parses and clamps numeric settings", () => {
assert.equal(parseMessageMax(undefined), 80);
assert.equal(parseMessageMax("12"), 12);
assert.equal(parseMessageMax("-1"), 0);
assert.equal(parseMessageMax("999"), 500);
assert.equal(parseMessageMax("nope"), 80);
assert.equal(parseMinDurationMs(undefined), 3_000);
assert.equal(parseMinDurationMs(" "), 3_000);
assert.equal(parseMinDurationMs("1.25"), 1_250);
assert.equal(parseMinDurationMs("9999"), 3_600_000);
});
it("uses fail-safe defaults for boolean and Kitty behavior settings", () => {
assert.equal(parseEnabled(undefined), true);
assert.equal(parseEnabled("off"), false);
assert.equal(parseEnabled("yes"), true);
assert.equal(parseKittyVisibility(undefined), "unfocused");
assert.equal(parseKittyVisibility("always"), "always");
assert.equal(parseKittyVisibility("invalid"), "unfocused");
assert.equal(parseNotificationAction("none"), "none");
assert.equal(parseNotificationAction("invalid"), "focus");
});
});
+27
View File
@@ -0,0 +1,27 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { renderTemplate } from "../src/template.ts";
function context(): ExtensionContext {
return {
cwd: "/tmp/pi-notify",
model: { provider: "ollama", id: "qwen3" },
} as ExtensionContext;
}
describe("template", () => {
it("renders built-in values and keeps unknown placeholders", () => {
const output = renderTemplate(
"{project} {cwd} {model} {model_short} {duration} {duration_ms} {missing}",
context(),
4200,
);
assert.equal(output, "/tmp/pi-notify".split("/").at(-1) + " /tmp/pi-notify ollama/qwen3 qwen3 4.2s 4200 {missing}");
});
it("allows extra values to override built-ins", () => {
assert.equal(renderTemplate("{project} {message}", context(), 0, { project: "custom", message: "done" }), "custom done");
});
});
+120
View File
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";
import { createNotifier, detectNotificationBackend } from "../src/terminal.ts";
import type { NotifyConfig } from "../src/types.ts";
const ESC = "\x1b";
const ST = `${ESC}\\`;
const savedEnv = {
KITTY_WINDOW_ID: process.env.KITTY_WINDOW_ID,
TMUX: process.env.TMUX,
};
let originalWrite: typeof process.stdout.write;
let output = "";
beforeEach(() => {
delete process.env.KITTY_WINDOW_ID;
delete process.env.TMUX;
});
afterEach(() => {
process.stdout.write = originalWrite;
output = "";
for (const [key, value] of Object.entries(savedEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function captureStdout(): void {
originalWrite = process.stdout.write;
process.stdout.write = ((chunk: string | Uint8Array) => {
output += chunk.toString();
return true;
}) as typeof process.stdout.write;
}
function config(title = "Title", body = "Body"): NotifyConfig {
return {
enabled: true,
title,
body,
durationMs: 5_000,
minDurationMs: 3_000,
notificationId: "pi-session:unsafe",
visibility: "unfocused",
action: "focus",
};
}
function captureSpawn(): { calls: unknown[][]; spawn: typeof import("node:child_process").spawn } {
const calls: unknown[][] = [];
const spawn = ((...args: unknown[]) => {
calls.push(args);
return { on() {}, unref() {} };
}) as unknown as typeof import("node:child_process").spawn;
return { calls, spawn };
}
describe("terminal notifications", () => {
it("writes encoded Kitty OSC 99 payloads with exact-window focus metadata", () => {
process.env.KITTY_WINDOW_ID = "1";
const notifier = createNotifier();
captureStdout();
notifier.sendNotification(config("π - pi-notify", "done\nnow"));
const title = Buffer.from("π - pi-notify", "utf8").toString("base64");
const body = Buffer.from("done\nnow", "utf8").toString("base64");
assert.equal(
output,
`${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}` +
`${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`,
);
});
it("wraps Kitty OSC 99 sequences for tmux passthrough", () => {
process.env.TMUX = "/tmp/tmux";
const notifier = createNotifier({ readTmuxClientInfo: () => "xterm-kitty xterm-kitty" });
captureStdout();
notifier.sendNotification(config("Pi", "done"));
const title = Buffer.from("Pi", "utf8").toString("base64");
const body = Buffer.from("done", "utf8").toString("base64");
const titleSequence = `${ESC}]99;i=pi-session-unsafe:d=0:e=1:o=unfocused:a=focus;${title}${ST}`.replaceAll(ESC, ESC + ESC);
const bodySequence = `${ESC}]99;i=pi-session-unsafe:p=body:e=1;${body}${ST}`.replaceAll(ESC, ESC + ESC);
assert.equal(output, `${ESC}Ptmux;${titleSequence}${ST}${ESC}Ptmux;${bodySequence}${ST}`);
});
it("uses AppleScript safely on macOS when Kitty is not detected", () => {
const { calls, spawn } = captureSpawn();
const notifier = createNotifier({ spawn, platform: "darwin" });
captureStdout();
notifier.sendNotification(config('Pi "notify"', "done\\now"));
assert.equal(output, "");
assert.deepEqual(calls, [
[
"osascript",
["-e", 'display notification "done\\\\now" with title "Pi \\"notify\\""'],
{ shell: false, detached: true, stdio: "ignore" },
],
]);
});
it("does not let a stale Kitty variable override the active tmux client", () => {
process.env.TMUX = "/tmp/tmux";
process.env.KITTY_WINDOW_ID = "stale";
assert.equal(
detectNotificationBackend(() => "xterm-ghostty ghostty", "darwin"),
"apple-script",
);
});
it("uses a sanitized OSC 777 fallback outside macOS", () => {
const notifier = createNotifier({ platform: "linux" });
captureStdout();
notifier.sendNotification(config("Pi;bad", "done\u0007now"));
assert.equal(output, `${ESC}]777;notify;Pi bad;done now\x07`);
});
});
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"types": ["node"],
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["src/**/*.ts", "tests/**/*.ts"]
}