mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 10:45:22 +00:00
refactor: notify/footer 持久配置收编到 config/,chrome bridge 地址固定
仓库规则要求 config/ 为唯一配置来源、不以环境变量作持久默认值: - pi-notify 改读 config/pi-notify.json(逐字段校验回退,复用原 parsers 钳制) - pi-minimal-footer 改读 config/pi-minimal-footer.json - pi-chrome 移除 PI_CHROME_BRIDGE_HOST/PORT,固定 127.0.0.1:17318 (浏览器端 service_worker.js 硬编码同一地址,单侧可配只会断连) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
0c45b1a438
commit
e996f59469
+1
-1
@@ -5,7 +5,7 @@
|
||||
- Kitty OSC 99 必须保留 source-window focus、Session 间唯一 ID、Base64 payload 和 tmux passthrough;修改 visibility/action、事件生命周期、fallback、模板、隐私或命令时必须同步 README 和测试。
|
||||
- 完成通知默认只在 TUI `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只在 `permissions:ui_prompt` 后立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。非 TUI 输出完整性、策略/AutoReview 静默决定、隐私模式、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
|
||||
- 默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。
|
||||
- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
|
||||
- `/notify on|off` 只覆盖当前 Session;持久默认来自组合包 `config/pi-notify.json`(`src/settings.ts` 读取,文件缺失或字段非法逐字段回退默认)。`messageSource: "none"` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
|
||||
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现,否则会重复通知。
|
||||
|
||||
## 验证
|
||||
|
||||
+19
-15
@@ -28,19 +28,21 @@ Do not separately install upstream when using the bundle, or notifications will
|
||||
- `/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.
|
||||
Command overrides are session-local. Persistent defaults come from the bundle's `config/pi-notify.json`.
|
||||
|
||||
## Configuration
|
||||
|
||||
- `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled.
|
||||
- `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.
|
||||
Persistent defaults are read once per session from `config/pi-notify.json` inside the installed bundle; a missing or invalid file falls back to the defaults shown:
|
||||
|
||||
- `enabled`: `true`/`false`; default `true`.
|
||||
- `title`: title template shared by completion and permission notifications; default `π - {project}`.
|
||||
- `body`: completion-notification body template; default `{message}`. Permission prompts use a bounded `Permission required · <agent> · <surface>` summary.
|
||||
- `messageSource`: `assistant`, `user`, or privacy mode `none`; default `assistant`. Privacy mode also hides permission values and requester details.
|
||||
- `messageMax`: maximum completion-message or permission-value characters before ellipsis; default `80`, maximum `500`.
|
||||
- `minSeconds`: minimum complete run duration; default `3`, range `0`–`3600`. This does not delay permission prompts.
|
||||
- `visibility`: Kitty policy `unfocused`, `invisible`, or `always`; default `always`.
|
||||
- `action`: Kitty click action `focus` or `none`; default `focus`.
|
||||
- `soundCommand`: optional shell command run after the notification. This is trusted local configuration and executes through a shell.
|
||||
|
||||
Visibility policies:
|
||||
|
||||
@@ -70,12 +72,14 @@ The active tmux client type is checked so a stale `KITTY_WINDOW_ID` cannot force
|
||||
- `{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:
|
||||
Example for multiple projects without exposing assistant text on the lock screen (`config/pi-notify.json`):
|
||||
|
||||
```bash
|
||||
export PI_NOTIFY_TITLE='π {status_icon} · {project}'
|
||||
export PI_NOTIFY_MESSAGE_SOURCE=none
|
||||
export PI_NOTIFY_VISIBILITY=unfocused
|
||||
```json
|
||||
{
|
||||
"title": "π {status_icon} · {project}",
|
||||
"messageSource": "none",
|
||||
"visibility": "unfocused"
|
||||
}
|
||||
```
|
||||
|
||||
Kitty must be allowed under **System Settings → Notifications**. Self-built or unsigned Kitty binaries may not be able to publish macOS notifications.
|
||||
|
||||
@@ -18,5 +18,6 @@
|
||||
- 新增时长过滤、启用/隐私/动作设置、状态模板字段和 `/notify` 控制。
|
||||
- 保留 Base64 Kitty payload,加固 OSC 元数据/回退净化,并忽略分离进程的 spawn 错误。
|
||||
- 新增生命周期、多窗口元数据、回退、解析器和配置测试。
|
||||
- 持久配置由 `PI_NOTIFY_*` 环境变量改为组合包 `config/pi-notify.json`(`src/settings.ts` 读取并逐字段回退默认值)。
|
||||
|
||||
上游声明本项目使用 MIT 许可证,但在导入的 commit 和 npm tarball 中都没有包含 `LICENSE` 文件。本目录使用上游作者的 GitHub 身份和 commit 年份补充标准 MIT 文本,使声明的许可证随维护源码一起保留。
|
||||
|
||||
+11
-17
@@ -1,13 +1,6 @@
|
||||
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 { readNotifySettings, type NotifySettings } from "./settings.ts";
|
||||
import { renderTemplate } from "./template.ts";
|
||||
import type { NotificationRuntimeValues, NotifyConfig } from "./types.ts";
|
||||
|
||||
@@ -20,12 +13,13 @@ export function getConfig(
|
||||
messages: unknown[] | undefined,
|
||||
runtime: NotificationRuntimeValues,
|
||||
now = Date.now(),
|
||||
settings: NotifySettings = readNotifySettings(),
|
||||
): NotifyConfig {
|
||||
const durationMs = startedAt === undefined ? 0 : Math.max(0, now - startedAt);
|
||||
const messageMax = parseMessageMax(process.env.PI_NOTIFY_MESSAGE_MAX);
|
||||
const messageMax = settings.messageMax;
|
||||
const userMessage = truncateText(extractMessageText(messages, "user"), messageMax);
|
||||
const assistantMessage = truncateText(extractMessageText(messages, "assistant"), messageMax);
|
||||
const messageSource = parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE);
|
||||
const messageSource = settings.messageSource;
|
||||
const selectedMessage = messageSource === "user" ? userMessage : messageSource === "assistant" ? assistantMessage : "";
|
||||
const message = selectedMessage || "Ready for input";
|
||||
const toolErrorCount = Math.max(0, runtime.toolErrorCount ?? 0);
|
||||
@@ -39,15 +33,15 @@ export function getConfig(
|
||||
};
|
||||
|
||||
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),
|
||||
enabled: settings.enabled,
|
||||
title: renderTemplate(settings.title || DEFAULT_TITLE, ctx, durationMs, values),
|
||||
body: renderTemplate(settings.body || DEFAULT_BODY, ctx, durationMs, values),
|
||||
durationMs,
|
||||
minDurationMs: parseMinDurationMs(process.env.PI_NOTIFY_MIN_SECONDS),
|
||||
minDurationMs: settings.minDurationMs,
|
||||
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,
|
||||
visibility: settings.visibility,
|
||||
action: settings.action,
|
||||
soundCommand: settings.soundCommand,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-7
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
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 { readNotifySettings, type NotifySettings } from "./settings.ts";
|
||||
import { getNotificationBackend, sendNotification } from "./terminal.ts";
|
||||
import type { NotifyConfig } from "./types.ts";
|
||||
|
||||
@@ -11,13 +11,14 @@ type ExtensionDependencies = {
|
||||
createNotificationId: () => string;
|
||||
notify: (config: NotifyConfig) => void;
|
||||
getBackend: typeof getNotificationBackend;
|
||||
settings: NotifySettings;
|
||||
};
|
||||
|
||||
const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
|
||||
|
||||
function formatPermissionPromptBody(raw: unknown): string | undefined {
|
||||
function formatPermissionPromptBody(raw: unknown, settings: NotifySettings): string | undefined {
|
||||
if (!isRecord(raw) || !readNonEmptyString(raw.requestId)) return undefined;
|
||||
if (parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE) === "none") {
|
||||
if (settings.messageSource === "none") {
|
||||
return "Permission confirmation required";
|
||||
}
|
||||
|
||||
@@ -29,7 +30,7 @@ function formatPermissionPromptBody(raw: unknown): string | undefined {
|
||||
|
||||
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))}`;
|
||||
return `${labels.join(" · ")}\n${truncateText(normalizedValue, settings.messageMax)}`;
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | undefined {
|
||||
@@ -47,6 +48,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
|
||||
const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
|
||||
const notify = dependencies.notify ?? sendNotification;
|
||||
const getBackend = dependencies.getBackend ?? getNotificationBackend;
|
||||
const settings = dependencies.settings ?? readNotifySettings();
|
||||
|
||||
return function piNotify(pi: ExtensionAPI): void {
|
||||
const notificationId = createNotificationId();
|
||||
@@ -81,7 +83,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
|
||||
|
||||
pi.on("agent_settled", (_event, ctx) => {
|
||||
try {
|
||||
const config = getConfig(ctx, startedAt, latestMessages, { notificationId, toolErrorCount }, now());
|
||||
const config = getConfig(ctx, startedAt, latestMessages, { notificationId, toolErrorCount }, now(), settings);
|
||||
if (ctx.mode === "tui" && shouldNotify(config, enabledOverride)) notify(config);
|
||||
} finally {
|
||||
resetRun();
|
||||
@@ -89,7 +91,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
|
||||
});
|
||||
|
||||
const unsubscribePermissionPrompt = pi.events.on(PERMISSIONS_UI_PROMPT_CHANNEL, (raw) => {
|
||||
const body = formatPermissionPromptBody(raw);
|
||||
const body = formatPermissionPromptBody(raw, settings);
|
||||
if (!body || currentCtx?.mode !== "tui") return;
|
||||
|
||||
const config = getConfig(
|
||||
@@ -98,6 +100,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
|
||||
undefined,
|
||||
{ notificationId, toolErrorCount },
|
||||
now(),
|
||||
settings,
|
||||
);
|
||||
const permissionConfig = { ...config, body, minDurationMs: 0 };
|
||||
if (shouldNotify(permissionConfig, enabledOverride)) notify(permissionConfig);
|
||||
@@ -119,7 +122,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig(ctx, now() - 1000, undefined, { notificationId, toolErrorCount: 0 }, now());
|
||||
const config = getConfig(ctx, now() - 1000, undefined, { notificationId, toolErrorCount: 0 }, now(), settings);
|
||||
if (action === "test") {
|
||||
if (ctx.mode !== "tui") {
|
||||
ctx.ui.notify("System notifications are only emitted in interactive TUI mode", "warning");
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
parseEnabled,
|
||||
parseKittyVisibility,
|
||||
parseMessageMax,
|
||||
parseMessageSource,
|
||||
parseMinDurationMs,
|
||||
parseNotificationAction,
|
||||
} from "./parsers.ts";
|
||||
import type { KittyVisibility, MessageSource, NotificationAction } from "./types.ts";
|
||||
|
||||
export interface NotifySettings {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
body: string;
|
||||
messageSource: MessageSource;
|
||||
messageMax: number;
|
||||
minDurationMs: number;
|
||||
visibility: KittyVisibility;
|
||||
action: NotificationAction;
|
||||
soundCommand?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_TITLE = "π - {project}";
|
||||
const DEFAULT_BODY = "{message}";
|
||||
|
||||
// Bundle-level `config/` is the single source of truth: the extension reads
|
||||
// `config/pi-notify.json` from inside the installed bundle (resolved relative
|
||||
// to this source file), never environment variables.
|
||||
export const NOTIFY_CONFIG_PATH = fileURLToPath(new URL("../../config/pi-notify.json", import.meta.url));
|
||||
|
||||
function readConfigFile(configPath: string): Record<string, unknown> {
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown;
|
||||
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function readNotifySettings(configPath: string = NOTIFY_CONFIG_PATH): NotifySettings {
|
||||
const raw = readConfigFile(configPath);
|
||||
return {
|
||||
enabled: parseEnabled(optionalString(raw.enabled)),
|
||||
title: typeof raw.title === "string" && raw.title ? raw.title : DEFAULT_TITLE,
|
||||
body: typeof raw.body === "string" && raw.body ? raw.body : DEFAULT_BODY,
|
||||
messageSource: parseMessageSource(optionalString(raw.messageSource)),
|
||||
messageMax: parseMessageMax(optionalString(raw.messageMax)),
|
||||
minDurationMs: parseMinDurationMs(optionalString(raw.minSeconds)),
|
||||
visibility: parseKittyVisibility(optionalString(raw.visibility)),
|
||||
action: parseNotificationAction(optionalString(raw.action)),
|
||||
soundCommand: typeof raw.soundCommand === "string" ? raw.soundCommand.trim() || undefined : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,28 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { describe, it } from "node:test";
|
||||
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { getConfig, shouldNotify } from "../src/config.ts";
|
||||
import type { NotifySettings } from "../src/settings.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;
|
||||
}
|
||||
const defaultSettings = (): NotifySettings => ({
|
||||
enabled: true,
|
||||
title: "π - {project}",
|
||||
body: "{message}",
|
||||
messageSource: "assistant",
|
||||
messageMax: 80,
|
||||
minDurationMs: 3_000,
|
||||
visibility: "always",
|
||||
action: "focus",
|
||||
});
|
||||
|
||||
function context(): ExtensionContext {
|
||||
@@ -45,6 +35,7 @@ describe("config", () => {
|
||||
],
|
||||
runtime,
|
||||
5_000,
|
||||
defaultSettings(),
|
||||
);
|
||||
|
||||
assert.deepEqual(config, {
|
||||
@@ -62,19 +53,20 @@ describe("config", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
}, 2_000, {
|
||||
...defaultSettings(),
|
||||
enabled: false,
|
||||
title: "{status_icon} {status} {tool_error_count}",
|
||||
body: "{message}",
|
||||
messageSource: "none",
|
||||
minDurationMs: 10_500,
|
||||
visibility: "invisible",
|
||||
action: "none",
|
||||
soundCommand: "say done",
|
||||
});
|
||||
|
||||
assert.equal(config.title, "⚠ ready · 2 tool errors 2");
|
||||
assert.equal(config.body, "Ready for input");
|
||||
@@ -88,15 +80,13 @@ describe("config", () => {
|
||||
});
|
||||
|
||||
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,
|
||||
{ ...defaultSettings(), messageSource: "user", messageMax: 5, minDurationMs: 0 },
|
||||
);
|
||||
|
||||
assert.equal(config.body, "hell…");
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { describe, it } from "node:test";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { createPiNotifyExtension } from "../src/index.ts";
|
||||
import type { NotifySettings } from "../src/settings.ts";
|
||||
import type { NotifyConfig } from "../src/types.ts";
|
||||
|
||||
type Handler = (...args: any[]) => any;
|
||||
|
||||
const savedEnv = new Map(
|
||||
["PI_NOTIFY_MIN_SECONDS", "PI_NOTIFY_MESSAGE_SOURCE"].map((key) => [key, process.env[key]]),
|
||||
);
|
||||
const DEFAULT_SETTINGS: NotifySettings = {
|
||||
enabled: true,
|
||||
title: "π - {project}",
|
||||
body: "{message}",
|
||||
messageSource: "assistant",
|
||||
messageMax: 80,
|
||||
minDurationMs: 3_000,
|
||||
visibility: "always",
|
||||
action: "focus",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of savedEnv) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
function harness(mode: ExtensionContext["mode"] = "tui") {
|
||||
function harness(mode: ExtensionContext["mode"] = "tui", settings: Partial<NotifySettings> = {}) {
|
||||
const handlers = new Map<string, Handler>();
|
||||
const eventHandlers = new Map<string, Handler>();
|
||||
const commands = new Map<string, { handler: Handler }>();
|
||||
@@ -45,6 +46,7 @@ function harness(mode: ExtensionContext["mode"] = "tui") {
|
||||
createNotificationId: () => "pi-session-test",
|
||||
notify: (config) => notifications.push(config),
|
||||
getBackend: () => "kitty",
|
||||
settings: { ...DEFAULT_SETTINGS, ...settings },
|
||||
})(pi);
|
||||
|
||||
const ctx = {
|
||||
@@ -93,9 +95,8 @@ describe("pi-notify extension", () => {
|
||||
});
|
||||
|
||||
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);
|
||||
const h = harness(mode, { minDurationMs: 0 });
|
||||
await h.handlers.get("session_start")?.({}, h.ctx);
|
||||
await h.handlers.get("agent_start")?.({}, h.ctx);
|
||||
await h.handlers.get("agent_settled")?.({}, h.ctx);
|
||||
@@ -123,8 +124,7 @@ describe("pi-notify extension", () => {
|
||||
});
|
||||
|
||||
it("notifies immediately when permission confirmation needs human input", async () => {
|
||||
process.env.PI_NOTIFY_MIN_SECONDS = "3600";
|
||||
const h = harness();
|
||||
const h = harness("tui", { minDurationMs: 3_600_000 });
|
||||
await h.handlers.get("session_start")?.({}, h.ctx);
|
||||
|
||||
h.eventHandlers.get("permissions:ui_prompt")?.({
|
||||
@@ -144,8 +144,7 @@ describe("pi-notify extension", () => {
|
||||
});
|
||||
|
||||
it("keeps permission details private and honors the session off override", async () => {
|
||||
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
|
||||
const h = harness();
|
||||
const h = harness("tui", { messageSource: "none" });
|
||||
await h.handlers.get("session_start")?.({}, h.ctx);
|
||||
const prompt = { requestId: "perm-secret", surface: "bash", value: "cat private-file" };
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { readNotifySettings } from "../src/settings.ts";
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
function configPath(contents: string): string {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "pi-notify-settings-"));
|
||||
const path = join(tempDir, "pi-notify.json");
|
||||
writeFileSync(path, contents);
|
||||
return path;
|
||||
}
|
||||
|
||||
const expectedDefaults = {
|
||||
enabled: true,
|
||||
title: "π - {project}",
|
||||
body: "{message}",
|
||||
messageSource: "assistant",
|
||||
messageMax: 80,
|
||||
minDurationMs: 3_000,
|
||||
visibility: "always",
|
||||
action: "focus",
|
||||
soundCommand: undefined,
|
||||
};
|
||||
|
||||
describe("readNotifySettings", () => {
|
||||
it("returns all defaults when the config file is missing", () => {
|
||||
assert.deepEqual(readNotifySettings("/does/not/exist/pi-notify.json"), expectedDefaults);
|
||||
});
|
||||
|
||||
it("returns all defaults for malformed or non-object JSON", () => {
|
||||
for (const contents of ["not json", "[1,2]", "\"hi\"", "42"]) {
|
||||
assert.deepEqual(readNotifySettings(configPath(contents)), expectedDefaults, contents);
|
||||
}
|
||||
});
|
||||
|
||||
it("adopts valid values from the config file", () => {
|
||||
const settings = readNotifySettings(configPath(JSON.stringify({
|
||||
enabled: false,
|
||||
title: "π {status_icon} · {project}",
|
||||
body: "{status}",
|
||||
messageSource: "none",
|
||||
messageMax: 120,
|
||||
minSeconds: 30,
|
||||
visibility: "unfocused",
|
||||
action: "none",
|
||||
soundCommand: "afplay /done.aiff",
|
||||
})));
|
||||
|
||||
assert.deepEqual(settings, {
|
||||
enabled: false,
|
||||
title: "π {status_icon} · {project}",
|
||||
body: "{status}",
|
||||
messageSource: "none",
|
||||
messageMax: 120,
|
||||
minDurationMs: 30_000,
|
||||
visibility: "unfocused",
|
||||
action: "none",
|
||||
soundCommand: "afplay /done.aiff",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back per-field when values have the wrong type or value", () => {
|
||||
const settings = readNotifySettings(configPath(JSON.stringify({
|
||||
enabled: {},
|
||||
title: "",
|
||||
body: null,
|
||||
messageSource: ["assistant"],
|
||||
messageMax: "bogus",
|
||||
minSeconds: "bogus",
|
||||
visibility: "bogus",
|
||||
action: {},
|
||||
})));
|
||||
|
||||
assert.deepEqual(settings, expectedDefaults);
|
||||
});
|
||||
|
||||
it("clamps messageMax and minSeconds to their bounded ranges", () => {
|
||||
const settings = readNotifySettings(configPath(JSON.stringify({ messageMax: 999, minSeconds: 4000 })));
|
||||
assert.equal(settings.messageMax, 500);
|
||||
assert.equal(settings.minDurationMs, 3_600_000);
|
||||
|
||||
const floor = readNotifySettings(configPath(JSON.stringify({ messageMax: -5, minSeconds: -1 })));
|
||||
assert.equal(floor.messageMax, 0);
|
||||
assert.equal(floor.minDurationMs, 0);
|
||||
});
|
||||
|
||||
it("treats an empty or whitespace soundCommand as absent and trims values", () => {
|
||||
for (const value of ["", " "]) {
|
||||
assert.equal(readNotifySettings(configPath(JSON.stringify({ soundCommand: value }))).soundCommand, undefined);
|
||||
}
|
||||
assert.equal(
|
||||
readNotifySettings(configPath(JSON.stringify({ soundCommand: " afplay /done.aiff " }))).soundCommand,
|
||||
"afplay /done.aiff",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user