diff --git a/README.md b/README.md index 81e033b..5873815 100644 --- a/README.md +++ b/README.md @@ -366,12 +366,12 @@ compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-pe `permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的 决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`, 并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty -返回原始来源;仍可通过环境变量改用 `unfocused` 或 `invisible`。 +返回原始来源;仍可通过 `visibility` 改用 `unfocused` 或 `invisible`。 默认忽略不足 3 秒的短任务,但权限确认通知不受该时长门槛影响。`/notify on|off|test|status` 可控制 -当前 Session 的两类通知;持久默认通过 `PI_NOTIFY_ENABLED`、`PI_NOTIFY_MIN_SECONDS`、 -`PI_NOTIFY_VISIBILITY`、`PI_NOTIFY_ACTION`、`PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。 -`PI_NOTIFY_MESSAGE_SOURCE=none` 会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要 +当前 Session 的两类通知;持久默认通过组合包 `config/pi-notify.json` 的 `enabled`、`minSeconds`、 +`visibility`、`action`、`messageSource` 等字段配置。`messageSource: "none"` +会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要 `set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见 [`pi-notify/README.md`](pi-notify/README.md)。 diff --git a/config/pi-minimal-footer.json b/config/pi-minimal-footer.json new file mode 100644 index 0000000..da7defb --- /dev/null +++ b/config/pi-minimal-footer.json @@ -0,0 +1,4 @@ +{ + "showCwd": true, + "showBranch": true +} diff --git a/config/pi-notify.json b/config/pi-notify.json new file mode 100644 index 0000000..9a4a71e --- /dev/null +++ b/config/pi-notify.json @@ -0,0 +1,11 @@ +{ + "enabled": true, + "title": "π - {project}", + "body": "{message}", + "messageSource": "assistant", + "messageMax": 80, + "minSeconds": 3, + "visibility": "always", + "action": "focus", + "soundCommand": "" +} diff --git a/pi-chrome/UPSTREAM.md b/pi-chrome/UPSTREAM.md index dd5d2c1..e4aa63b 100644 --- a/pi-chrome/UPSTREAM.md +++ b/pi-chrome/UPSTREAM.md @@ -20,5 +20,6 @@ - `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。 - click/type/fill/key 上的 `includeSnapshot=true` 会保存动作后的快照并返回其捕获路径,而不是把快照嵌入工具结果。 - 授权后的 `before_agent_start` 不再返回 `systemPrompt`:Chrome 使用提示写入 `event.systemPromptOptions.sections.chrome_profile_bridge` 结构化分区,由组合包单一提示词出口 `extensions/system-prompt.ts` 统一组装,顺序由 `config/prompt/sections.jsonc` 控制;未授权时不贡献任何提示词内容。 +- 移除 `PI_CHROME_BRIDGE_HOST`/`PI_CHROME_BRIDGE_PORT` 环境变量,host/port 固定为 `127.0.0.1:17318`(Companion 扩展 `service_worker.js` 同样硬编码该地址,单侧可配只会断连且非回环 host 有安全风险)。 同步上游时,请以导入 commit 为基准比较并选择性移植改动。不得整体覆盖这些本地生命周期、隐私和 Context Mode 集成。 diff --git a/pi-chrome/extensions/chrome-profile-bridge/index.ts b/pi-chrome/extensions/chrome-profile-bridge/index.ts index 0f8b8b6..8f8914d 100644 --- a/pi-chrome/extensions/chrome-profile-bridge/index.ts +++ b/pi-chrome/extensions/chrome-profile-bridge/index.ts @@ -64,8 +64,8 @@ const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__"; // reload) so a /reload — which tears down and re-evaluates the module — does not silently drop // an active /chrome authorize grant. const PI_CHROME_AUTH_KEY = "__piChromeProfileBridgeAuth__"; -const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1"; -const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318"); +const DEFAULT_HOST = "127.0.0.1"; +const DEFAULT_PORT = 17318; const DEFAULT_TIMEOUT_MS = 30_000; const MAX_TEXT_CHARS = 30_000; const MAX_ELEMENTS = 80; diff --git a/pi-minimal-footer/README.md b/pi-minimal-footer/README.md index 8534cd2..be864e0 100644 --- a/pi-minimal-footer/README.md +++ b/pi-minimal-footer/README.md @@ -34,14 +34,14 @@ pi install npm:@ogulcancelik/pi-minimal-footer ## Configuration -Environment variables (all optional): +Persistent defaults are read from `config/pi-minimal-footer.json` inside the installed bundle; a missing or invalid file falls back to the defaults shown: -| Variable | Description | Default | -| ------------------------------- | -------------------------------------------------------- | ------- | -| `PI_MINIMAL_FOOTER_SHOW_CWD` | Show current working directory in footer status line | `1` | -| `PI_MINIMAL_FOOTER_SHOW_BRANCH` | Show git branch/dirty/ahead/behind in footer status line | `1` | +| Field | Description | Default | +| ------------ | -------------------------------------------------------- | ------- | +| `showCwd` | Show current working directory in footer status line | `true` | +| `showBranch` | Show git branch/dirty/ahead/behind in footer status line | `true` | -Accepted false values: `0`, `false`, `no`, `off` (case-insensitive). +Only `true`/`false` values are honoured; anything else falls back to the default for that field. ## How it works diff --git a/pi-minimal-footer/UPSTREAM.md b/pi-minimal-footer/UPSTREAM.md index 98b3d30..0fd73c1 100644 --- a/pi-minimal-footer/UPSTREAM.md +++ b/pi-minimal-footer/UPSTREAM.md @@ -5,4 +5,4 @@ - 导入包版本:`@ogulcancelik/pi-minimal-footer@0.1.10` - 导入日期:2026-08-19 -本目录由 `my-pi` 直接维护;它不是 submodule,也不保留上游 Git 元数据。本地改动新增了 Pi 扩展状态渲染(把 `ctx.ui.setStatus()` 发布的每个非空状态渲染为普通 footer 片段)。 +本目录由 `my-pi` 直接维护;它不是 submodule,也不保留上游 Git 元数据。本地改动新增了 Pi 扩展状态渲染(把 `ctx.ui.setStatus()` 发布的每个非空状态渲染为普通 footer 片段),并把 showCwd/showBranch 持久配置由 `PI_MINIMAL_FOOTER_*` 环境变量改为组合包 `config/pi-minimal-footer.json`。 diff --git a/pi-minimal-footer/index.ts b/pi-minimal-footer/index.ts index dd29007..213ff04 100644 --- a/pi-minimal-footer/index.ts +++ b/pi-minimal-footer/index.ts @@ -15,6 +15,7 @@ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { collectFooterStatusItems } from "./status.ts"; +import { readFooterSettings } from "./settings.ts"; // ============ Types ============ @@ -43,19 +44,6 @@ interface GitCache { const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes const usageCache = new Map(); // keyed by provider -// ============ Env Flags ============ - -function parseBooleanEnv(value: string | undefined, fallback: boolean): boolean { - if (value === undefined) return fallback; - - const normalized = value.trim().toLowerCase(); - if (!normalized) return fallback; - - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - return fallback; -} - // ============ Identity Cache ============ let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved @@ -754,9 +742,8 @@ export default function (pi: ExtensionAPI) { const BAR_FILLED = "━"; const BAR_EMPTY = "─"; - // Optional visibility toggles (default: enabled) - const showCwd = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_CWD, true); - const showBranch = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_BRANCH, true); + // Optional visibility toggles from bundle config (default: enabled) + const { showCwd, showBranch } = readFooterSettings(); function formatTokenCount(tokens: number): string { if (tokens >= 1_000_000) { diff --git a/pi-minimal-footer/settings.ts b/pi-minimal-footer/settings.ts new file mode 100644 index 0000000..60ca5d3 --- /dev/null +++ b/pi-minimal-footer/settings.ts @@ -0,0 +1,30 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +export interface FooterSettings { + showCwd: boolean; + showBranch: boolean; +} + +// Bundle-level `config/` is the single source of truth: the extension reads +// `config/pi-minimal-footer.json` from inside the installed bundle (resolved +// relative to this source file), never environment variables. +export const FOOTER_CONFIG_PATH = fileURLToPath( + new URL("../config/pi-minimal-footer.json", import.meta.url), +); + +export function readFooterSettings(configPath: string = FOOTER_CONFIG_PATH): FooterSettings { + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown; + const raw = + parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + return { + showCwd: typeof raw.showCwd === "boolean" ? raw.showCwd : true, + showBranch: typeof raw.showBranch === "boolean" ? raw.showBranch : true, + }; + } catch { + return { showCwd: true, showBranch: true }; + } +} diff --git a/pi-minimal-footer/test/settings.test.ts b/pi-minimal-footer/test/settings.test.ts new file mode 100644 index 0000000..0f0e3eb --- /dev/null +++ b/pi-minimal-footer/test/settings.test.ts @@ -0,0 +1,51 @@ +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 { readFooterSettings } from "../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-minimal-footer-settings-")); + const path = join(tempDir, "pi-minimal-footer.json"); + writeFileSync(path, contents); + return path; +} + +const expectedDefaults = { showCwd: true, showBranch: true }; + +describe("readFooterSettings", () => { + it("returns all defaults when the config file is missing", () => { + assert.deepEqual(readFooterSettings("/does/not/exist/pi-minimal-footer.json"), expectedDefaults); + }); + + it("returns all defaults for malformed or non-object JSON", () => { + for (const contents of ["not json", "[1,2]", "42"]) { + assert.deepEqual(readFooterSettings(configPath(contents)), expectedDefaults, contents); + } + }); + + it("adopts explicit boolean values", () => { + assert.deepEqual( + readFooterSettings(configPath(JSON.stringify({ showCwd: false, showBranch: false }))), + { showCwd: false, showBranch: false }, + ); + }); + + it("falls back per-field for non-boolean values", () => { + assert.deepEqual( + readFooterSettings(configPath(JSON.stringify({ showCwd: "no", showBranch: 0 }))), + expectedDefaults, + ); + }); +}); diff --git a/pi-notify/AGENTS.md b/pi-notify/AGENTS.md index cc47ae1..76e493f 100644 --- a/pi-notify/AGENTS.md +++ b/pi-notify/AGENTS.md @@ -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` 实现,否则会重复通知。 ## 验证 diff --git a/pi-notify/README.md b/pi-notify/README.md index c16126b..f95ef59 100644 --- a/pi-notify/README.md +++ b/pi-notify/README.md @@ -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 · · ` 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 · · ` 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. diff --git a/pi-notify/UPSTREAM.md b/pi-notify/UPSTREAM.md index 739b6fa..9bb221c 100644 --- a/pi-notify/UPSTREAM.md +++ b/pi-notify/UPSTREAM.md @@ -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 文本,使声明的许可证随维护源码一起保留。 diff --git a/pi-notify/src/config.ts b/pi-notify/src/config.ts index ccbe653..f3a7672 100644 --- a/pi-notify/src/config.ts +++ b/pi-notify/src/config.ts @@ -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, }; } diff --git a/pi-notify/src/index.ts b/pi-notify/src/index.ts index 87f7cce..a03d1a9 100644 --- a/pi-notify/src/index.ts +++ b/pi-notify/src/index.ts @@ -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 `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 { 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 { - 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 { + try { + const parsed = JSON.parse(readFileSync(configPath, "utf8")) as unknown; + return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } 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, + }; +} diff --git a/pi-notify/tests/config.test.ts b/pi-notify/tests/config.test.ts index 57c1709..cfce8a6 100644 --- a/pi-notify/tests/config.test.ts +++ b/pi-notify/tests/config.test.ts @@ -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…"); diff --git a/pi-notify/tests/index.test.ts b/pi-notify/tests/index.test.ts index 993c5c4..1edd8d1 100644 --- a/pi-notify/tests/index.test.ts +++ b/pi-notify/tests/index.test.ts @@ -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 = {}) { const handlers = new Map(); const eventHandlers = new Map(); const commands = new Map(); @@ -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" }; diff --git a/pi-notify/tests/settings.test.ts b/pi-notify/tests/settings.test.ts new file mode 100644 index 0000000..31c58a3 --- /dev/null +++ b/pi-notify/tests/settings.test.ts @@ -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", + ); + }); +});