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:
云服务部-叶林立
2026-10-21 19:16:47 -07:00
co-authored by Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 0c45b1a438
commit e996f59469
19 changed files with 366 additions and 121 deletions
+4 -4
View File
@@ -366,12 +366,12 @@ compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-pe
`permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的 `permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的
决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`, 决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`,
并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty 并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty
返回原始来源;仍可通过环境变量改用 `unfocused` 或 `invisible`。 返回原始来源;仍可通过 `visibility` 改用 `unfocused` 或 `invisible`。
默认忽略不足 3 秒的短任务,但权限确认通知不受该时长门槛影响。`/notify on|off|test|status` 可控制 默认忽略不足 3 秒的短任务,但权限确认通知不受该时长门槛影响。`/notify on|off|test|status` 可控制
当前 Session 的两类通知;持久默认通过 `PI_NOTIFY_ENABLED`、`PI_NOTIFY_MIN_SECONDS`、 当前 Session 的两类通知;持久默认通过组合包 `config/pi-notify.json` 的 `enabled`、`minSeconds`、
`PI_NOTIFY_VISIBILITY`、`PI_NOTIFY_ACTION`、`PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。 `visibility`、`action`、`messageSource` 等字段配置。`messageSource: "none"`
`PI_NOTIFY_MESSAGE_SOURCE=none` 会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要 会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要
`set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见 `set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见
[`pi-notify/README.md`](pi-notify/README.md)。 [`pi-notify/README.md`](pi-notify/README.md)。
+4
View File
@@ -0,0 +1,4 @@
{
"showCwd": true,
"showBranch": true
}
+11
View File
@@ -0,0 +1,11 @@
{
"enabled": true,
"title": "π - {project}",
"body": "{message}",
"messageSource": "assistant",
"messageMax": 80,
"minSeconds": 3,
"visibility": "always",
"action": "focus",
"soundCommand": ""
}
+1
View File
@@ -20,5 +20,6 @@
- `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。 - `chrome_find` 返回紧凑的排序匹配,同时把完整结构化快照保留在 Session 本地捕获中,供可选的 `ctx_execute_file` 分析使用。
- click/type/fill/key 上的 `includeSnapshot=true` 会保存动作后的快照并返回其捕获路径,而不是把快照嵌入工具结果。 - 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` 控制;未授权时不贡献任何提示词内容。 - 授权后的 `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 集成。 同步上游时,请以导入 commit 为基准比较并选择性移植改动。不得整体覆盖这些本地生命周期、隐私和 Context Mode 集成。
@@ -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 // reload) so a /reload — which tears down and re-evaluates the module — does not silently drop
// an active /chrome authorize grant. // an active /chrome authorize grant.
const PI_CHROME_AUTH_KEY = "__piChromeProfileBridgeAuth__"; const PI_CHROME_AUTH_KEY = "__piChromeProfileBridgeAuth__";
const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1"; const DEFAULT_HOST = "127.0.0.1";
const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318"); const DEFAULT_PORT = 17318;
const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TIMEOUT_MS = 30_000;
const MAX_TEXT_CHARS = 30_000; const MAX_TEXT_CHARS = 30_000;
const MAX_ELEMENTS = 80; const MAX_ELEMENTS = 80;
+6 -6
View File
@@ -34,14 +34,14 @@ pi install npm:@ogulcancelik/pi-minimal-footer
## Configuration ## 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 | | Field | Description | Default |
| ------------------------------- | -------------------------------------------------------- | ------- | | ------------ | -------------------------------------------------------- | ------- |
| `PI_MINIMAL_FOOTER_SHOW_CWD` | Show current working directory in footer status line | `1` | | `showCwd` | Show current working directory in footer status line | `true` |
| `PI_MINIMAL_FOOTER_SHOW_BRANCH` | Show git branch/dirty/ahead/behind in footer status line | `1` | | `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 ## How it works
+1 -1
View File
@@ -5,4 +5,4 @@
- 导入包版本:`@ogulcancelik/pi-minimal-footer@0.1.10` - 导入包版本:`@ogulcancelik/pi-minimal-footer@0.1.10`
- 导入日期:2026-08-19 - 导入日期: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`。
+3 -16
View File
@@ -15,6 +15,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { collectFooterStatusItems } from "./status.ts"; import { collectFooterStatusItems } from "./status.ts";
import { readFooterSettings } from "./settings.ts";
// ============ Types ============ // ============ Types ============
@@ -43,19 +44,6 @@ interface GitCache {
const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes const USAGE_REFRESH_INTERVAL = 5 * 60_000; // 5 minutes
const usageCache = new Map<string, UsageSnapshot>(); // keyed by provider const usageCache = new Map<string, UsageSnapshot>(); // 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 ============ // ============ Identity Cache ============
let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved let codexEmailPrefix: string | null | undefined; // undefined = not yet resolved
@@ -754,9 +742,8 @@ export default function (pi: ExtensionAPI) {
const BAR_FILLED = "━"; const BAR_FILLED = "━";
const BAR_EMPTY = "─"; const BAR_EMPTY = "─";
// Optional visibility toggles (default: enabled) // Optional visibility toggles from bundle config (default: enabled)
const showCwd = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_CWD, true); const { showCwd, showBranch } = readFooterSettings();
const showBranch = parseBooleanEnv(process.env.PI_MINIMAL_FOOTER_SHOW_BRANCH, true);
function formatTokenCount(tokens: number): string { function formatTokenCount(tokens: number): string {
if (tokens >= 1_000_000) { if (tokens >= 1_000_000) {
+30
View File
@@ -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<string, unknown>)
: {};
return {
showCwd: typeof raw.showCwd === "boolean" ? raw.showCwd : true,
showBranch: typeof raw.showBranch === "boolean" ? raw.showBranch : true,
};
} catch {
return { showCwd: true, showBranch: true };
}
}
+51
View File
@@ -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,
);
});
});
+1 -1
View File
@@ -5,7 +5,7 @@
- Kitty OSC 99 必须保留 source-window focus、Session 间唯一 ID、Base64 payload 和 tmux passthrough;修改 visibility/action、事件生命周期、fallback、模板、隐私或命令时必须同步 README 和测试。 - 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 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。 - 完成通知默认只在 TUI `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只在 `permissions:ui_prompt` 后立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。非 TUI 输出完整性、策略/AutoReview 静默决定、隐私模式、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
- 默认 `o=always`、`a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。 - 默认 `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` 实现,否则会重复通知。 - 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现,否则会重复通知。
## 验证 ## 验证
+19 -15
View File
@@ -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 test`: send a notification immediately; click it to verify exact Kitty-window focus.
- `/notify status`: show the active backend, visibility policy, and duration threshold. - `/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 ## Configuration
- `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled. 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:
- `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. - `enabled`: `true`/`false`; default `true`.
- `PI_NOTIFY_MESSAGE_SOURCE`: `assistant`, `user`, or privacy mode `none`; default `assistant`. Privacy mode also hides permission values and requester details. - `title`: title template shared by completion and permission notifications; default `π - {project}`.
- `PI_NOTIFY_MESSAGE_MAX`: maximum completion-message or permission-value characters before ellipsis; default `80`, maximum `500`. - `body`: completion-notification body template; default `{message}`. Permission prompts use a bounded `Permission required · <agent> · <surface>` summary.
- `PI_NOTIFY_MIN_SECONDS`: minimum complete run duration; default `3`, range `0`–`3600`. This does not delay permission prompts. - `messageSource`: `assistant`, `user`, or privacy mode `none`; default `assistant`. Privacy mode also hides permission values and requester details.
- `PI_NOTIFY_VISIBILITY`: Kitty policy `unfocused`, `invisible`, or `always`; default `always`. - `messageMax`: maximum completion-message or permission-value characters before ellipsis; default `80`, maximum `500`.
- `PI_NOTIFY_ACTION`: Kitty click action `focus` or `none`; default `focus`. - `minSeconds`: minimum complete run duration; default `3`, range `0`–`3600`. This does not delay permission prompts.
- `PI_NOTIFY_SOUND_CMD`: optional shell command run after the notification. This is trusted local configuration and executes through a shell. - `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: 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. - `{status_icon}`: `✓` or `⚠`. This reports observed tool errors, not a definitive task-success verdict.
- `{tool_error_count}`: observed tool error count. - `{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 ```json
export PI_NOTIFY_TITLE='π {status_icon} · {project}' {
export PI_NOTIFY_MESSAGE_SOURCE=none "title": "π {status_icon} · {project}",
export PI_NOTIFY_VISIBILITY=unfocused "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. Kitty must be allowed under **System Settings → Notifications**. Self-built or unsigned Kitty binaries may not be able to publish macOS notifications.
+1
View File
@@ -18,5 +18,6 @@
- 新增时长过滤、启用/隐私/动作设置、状态模板字段和 `/notify` 控制。 - 新增时长过滤、启用/隐私/动作设置、状态模板字段和 `/notify` 控制。
- 保留 Base64 Kitty payload,加固 OSC 元数据/回退净化,并忽略分离进程的 spawn 错误。 - 保留 Base64 Kitty payload,加固 OSC 元数据/回退净化,并忽略分离进程的 spawn 错误。
- 新增生命周期、多窗口元数据、回退、解析器和配置测试。 - 新增生命周期、多窗口元数据、回退、解析器和配置测试。
- 持久配置由 `PI_NOTIFY_*` 环境变量改为组合包 `config/pi-notify.json`(`src/settings.ts` 读取并逐字段回退默认值)。
上游声明本项目使用 MIT 许可证,但在导入的 commit 和 npm tarball 中都没有包含 `LICENSE` 文件。本目录使用上游作者的 GitHub 身份和 commit 年份补充标准 MIT 文本,使声明的许可证随维护源码一起保留。 上游声明本项目使用 MIT 许可证,但在导入的 commit 和 npm tarball 中都没有包含 `LICENSE` 文件。本目录使用上游作者的 GitHub 身份和 commit 年份补充标准 MIT 文本,使声明的许可证随维护源码一起保留。
+11 -17
View File
@@ -1,13 +1,6 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { extractMessageText, truncateText } from "./messages.ts"; import { extractMessageText, truncateText } from "./messages.ts";
import { import { readNotifySettings, type NotifySettings } from "./settings.ts";
parseEnabled,
parseKittyVisibility,
parseMessageMax,
parseMessageSource,
parseMinDurationMs,
parseNotificationAction,
} from "./parsers.ts";
import { renderTemplate } from "./template.ts"; import { renderTemplate } from "./template.ts";
import type { NotificationRuntimeValues, NotifyConfig } from "./types.ts"; import type { NotificationRuntimeValues, NotifyConfig } from "./types.ts";
@@ -20,12 +13,13 @@ export function getConfig(
messages: unknown[] | undefined, messages: unknown[] | undefined,
runtime: NotificationRuntimeValues, runtime: NotificationRuntimeValues,
now = Date.now(), now = Date.now(),
settings: NotifySettings = readNotifySettings(),
): NotifyConfig { ): NotifyConfig {
const durationMs = startedAt === undefined ? 0 : Math.max(0, now - startedAt); 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 userMessage = truncateText(extractMessageText(messages, "user"), messageMax);
const assistantMessage = truncateText(extractMessageText(messages, "assistant"), 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 selectedMessage = messageSource === "user" ? userMessage : messageSource === "assistant" ? assistantMessage : "";
const message = selectedMessage || "Ready for input"; const message = selectedMessage || "Ready for input";
const toolErrorCount = Math.max(0, runtime.toolErrorCount ?? 0); const toolErrorCount = Math.max(0, runtime.toolErrorCount ?? 0);
@@ -39,15 +33,15 @@ export function getConfig(
}; };
return { return {
enabled: parseEnabled(process.env.PI_NOTIFY_ENABLED), enabled: settings.enabled,
title: renderTemplate(process.env.PI_NOTIFY_TITLE || DEFAULT_TITLE, ctx, durationMs, values), title: renderTemplate(settings.title || DEFAULT_TITLE, ctx, durationMs, values),
body: renderTemplate(process.env.PI_NOTIFY_BODY || DEFAULT_BODY, ctx, durationMs, values), body: renderTemplate(settings.body || DEFAULT_BODY, ctx, durationMs, values),
durationMs, durationMs,
minDurationMs: parseMinDurationMs(process.env.PI_NOTIFY_MIN_SECONDS), minDurationMs: settings.minDurationMs,
notificationId: runtime.notificationId, notificationId: runtime.notificationId,
visibility: parseKittyVisibility(process.env.PI_NOTIFY_VISIBILITY), visibility: settings.visibility,
action: parseNotificationAction(process.env.PI_NOTIFY_ACTION), action: settings.action,
soundCommand: process.env.PI_NOTIFY_SOUND_CMD?.trim() || undefined, soundCommand: settings.soundCommand,
}; };
} }
+10 -7
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { getConfig, shouldNotify } from "./config.ts"; import { getConfig, shouldNotify } from "./config.ts";
import { truncateText } from "./messages.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 { getNotificationBackend, sendNotification } from "./terminal.ts";
import type { NotifyConfig } from "./types.ts"; import type { NotifyConfig } from "./types.ts";
@@ -11,13 +11,14 @@ type ExtensionDependencies = {
createNotificationId: () => string; createNotificationId: () => string;
notify: (config: NotifyConfig) => void; notify: (config: NotifyConfig) => void;
getBackend: typeof getNotificationBackend; getBackend: typeof getNotificationBackend;
settings: NotifySettings;
}; };
const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt"; 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 (!isRecord(raw) || !readNonEmptyString(raw.requestId)) return undefined;
if (parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE) === "none") { if (settings.messageSource === "none") {
return "Permission confirmation required"; return "Permission confirmation required";
} }
@@ -29,7 +30,7 @@ function formatPermissionPromptBody(raw: unknown): string | undefined {
if (!value) return labels.join(" · "); if (!value) return labels.join(" · ");
const normalizedValue = value.replace(/\s+/g, " ").trim(); 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 { 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 createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
const notify = dependencies.notify ?? sendNotification; const notify = dependencies.notify ?? sendNotification;
const getBackend = dependencies.getBackend ?? getNotificationBackend; const getBackend = dependencies.getBackend ?? getNotificationBackend;
const settings = dependencies.settings ?? readNotifySettings();
return function piNotify(pi: ExtensionAPI): void { return function piNotify(pi: ExtensionAPI): void {
const notificationId = createNotificationId(); const notificationId = createNotificationId();
@@ -81,7 +83,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
pi.on("agent_settled", (_event, ctx) => { pi.on("agent_settled", (_event, ctx) => {
try { 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); if (ctx.mode === "tui" && shouldNotify(config, enabledOverride)) notify(config);
} finally { } finally {
resetRun(); resetRun();
@@ -89,7 +91,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
}); });
const unsubscribePermissionPrompt = pi.events.on(PERMISSIONS_UI_PROMPT_CHANNEL, (raw) => { 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; if (!body || currentCtx?.mode !== "tui") return;
const config = getConfig( const config = getConfig(
@@ -98,6 +100,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
undefined, undefined,
{ notificationId, toolErrorCount }, { notificationId, toolErrorCount },
now(), now(),
settings,
); );
const permissionConfig = { ...config, body, minDurationMs: 0 }; const permissionConfig = { ...config, body, minDurationMs: 0 };
if (shouldNotify(permissionConfig, enabledOverride)) notify(permissionConfig); if (shouldNotify(permissionConfig, enabledOverride)) notify(permissionConfig);
@@ -119,7 +122,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
return; 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 (action === "test") {
if (ctx.mode !== "tui") { if (ctx.mode !== "tui") {
ctx.ui.notify("System notifications are only emitted in interactive TUI mode", "warning"); ctx.ui.notify("System notifications are only emitted in interactive TUI mode", "warning");
+63
View File
@@ -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,
};
}
+24 -34
View File
@@ -1,28 +1,18 @@
import assert from "node:assert/strict"; 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 type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import { getConfig, shouldNotify } from "../src/config.ts"; import { getConfig, shouldNotify } from "../src/config.ts";
import type { NotifySettings } from "../src/settings.ts";
const envKeys = [ const defaultSettings = (): NotifySettings => ({
"PI_NOTIFY_ENABLED", enabled: true,
"PI_NOTIFY_TITLE", title: "π - {project}",
"PI_NOTIFY_BODY", body: "{message}",
"PI_NOTIFY_MESSAGE_SOURCE", messageSource: "assistant",
"PI_NOTIFY_MESSAGE_MAX", messageMax: 80,
"PI_NOTIFY_MIN_SECONDS", minDurationMs: 3_000,
"PI_NOTIFY_VISIBILITY", visibility: "always",
"PI_NOTIFY_ACTION", action: "focus",
"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 { function context(): ExtensionContext {
@@ -45,6 +35,7 @@ describe("config", () => {
], ],
runtime, runtime,
5_000, 5_000,
defaultSettings(),
); );
assert.deepEqual(config, { assert.deepEqual(config, {
@@ -62,19 +53,20 @@ describe("config", () => {
}); });
it("supports privacy, threshold, focus, and status template settings", () => { 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" }], { const config = getConfig(context(), 1_000, [{ role: "assistant", content: "secret" }], {
notificationId: "pi-session", notificationId: "pi-session",
toolErrorCount: 2, 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.title, "⚠ ready · 2 tool errors 2");
assert.equal(config.body, "Ready for input"); assert.equal(config.body, "Ready for input");
@@ -88,15 +80,13 @@ describe("config", () => {
}); });
it("applies selected-message truncation and a session override", () => { 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( const config = getConfig(
context(), context(),
1_000, 1_000,
[{ role: "user", content: "hello world" }], [{ role: "user", content: "hello world" }],
runtime, runtime,
2_000, 2_000,
{ ...defaultSettings(), messageSource: "user", messageMax: 5, minDurationMs: 0 },
); );
assert.equal(config.body, "hell…"); assert.equal(config.body, "hell…");
+17 -18
View File
@@ -1,23 +1,24 @@
import assert from "node:assert/strict"; 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 type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { createPiNotifyExtension } from "../src/index.ts"; import { createPiNotifyExtension } from "../src/index.ts";
import type { NotifySettings } from "../src/settings.ts";
import type { NotifyConfig } from "../src/types.ts"; import type { NotifyConfig } from "../src/types.ts";
type Handler = (...args: any[]) => any; type Handler = (...args: any[]) => any;
const savedEnv = new Map( const DEFAULT_SETTINGS: NotifySettings = {
["PI_NOTIFY_MIN_SECONDS", "PI_NOTIFY_MESSAGE_SOURCE"].map((key) => [key, process.env[key]]), enabled: true,
); title: "π - {project}",
body: "{message}",
messageSource: "assistant",
messageMax: 80,
minDurationMs: 3_000,
visibility: "always",
action: "focus",
};
afterEach(() => { function harness(mode: ExtensionContext["mode"] = "tui", settings: Partial<NotifySettings> = {}) {
for (const [key, value] of savedEnv) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
function harness(mode: ExtensionContext["mode"] = "tui") {
const handlers = new Map<string, Handler>(); const handlers = new Map<string, Handler>();
const eventHandlers = new Map<string, Handler>(); const eventHandlers = new Map<string, Handler>();
const commands = new Map<string, { handler: Handler }>(); const commands = new Map<string, { handler: Handler }>();
@@ -45,6 +46,7 @@ function harness(mode: ExtensionContext["mode"] = "tui") {
createNotificationId: () => "pi-session-test", createNotificationId: () => "pi-session-test",
notify: (config) => notifications.push(config), notify: (config) => notifications.push(config),
getBackend: () => "kitty", getBackend: () => "kitty",
settings: { ...DEFAULT_SETTINGS, ...settings },
})(pi); })(pi);
const ctx = { const ctx = {
@@ -93,9 +95,8 @@ describe("pi-notify extension", () => {
}); });
it("does not write terminal notifications in RPC or non-interactive modes", async () => { 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) { 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("session_start")?.({}, h.ctx);
await h.handlers.get("agent_start")?.({}, h.ctx); await h.handlers.get("agent_start")?.({}, h.ctx);
await h.handlers.get("agent_settled")?.({}, 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 () => { it("notifies immediately when permission confirmation needs human input", async () => {
process.env.PI_NOTIFY_MIN_SECONDS = "3600"; const h = harness("tui", { minDurationMs: 3_600_000 });
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx); await h.handlers.get("session_start")?.({}, h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.({ 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 () => { it("keeps permission details private and honors the session off override", async () => {
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none"; const h = harness("tui", { messageSource: "none" });
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx); await h.handlers.get("session_start")?.({}, h.ctx);
const prompt = { requestId: "perm-secret", surface: "bash", value: "cat private-file" }; const prompt = { requestId: "perm-secret", surface: "bash", value: "cat private-file" };
+107
View 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",
);
});
});