feat(pi-notify): notify on permission prompts

This commit is contained in:
云服务部-叶林立
2026-08-28 16:35:33 +08:00
parent b83413d670
commit 8002d7c213
6 changed files with 157 additions and 33 deletions
+4 -4
View File
@@ -57,7 +57,7 @@
- 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer> - 上游来源:<https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-minimal-footer>
- 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7``@ogulcancelik/pi-minimal-footer@0.1.10`)。 - 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7``@ogulcancelik/pi-minimal-footer@0.1.10`)。
- 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git` - 该目录从明确 commit 的上游源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`
- `pi-notify/`:基于 `@smoose/pi-notify` 的 Kitty 优先完成通知扩展,使用 OSC 99 精确聚焦来源 window/pane在 Pi 完全 settled 后通知。 - `pi-notify/`:基于 `@smoose/pi-notify` 的 Kitty 优先通知扩展,使用 OSC 99 精确聚焦来源 window/pane,在 Pi 完全 settled 后发送完成通知,并在 `permissions:ui_prompt` 表示即将请求人工权限确认时立即通知。
- 上游来源:<https://github.com/smoosex/pi-notify> - 上游来源:<https://github.com/smoosex/pi-notify>
- 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf``@smoose/pi-notify@0.1.1`)。 - 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf``@smoose/pi-notify@0.1.1`)。
- 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git` - 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`
@@ -96,8 +96,8 @@
- `pi-context-view` 只观察上下文占用,不参与压缩策略。 - `pi-context-view` 只观察上下文占用,不参与压缩策略。
- `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只为 `settings.json``contextPrune` 补入缺失的组合包默认字段,必须保留全部用户显式值(包括 `enabled: false`),配置无效时不得覆盖原文件,配置完整时不得重复改写。组合包默认开启,使用 `agent-message` 触发、`turn` 批处理、73.5% 总预算与 4% 单轮增量触发,摘要模型和 thinking 均为 `default`,空闲/总超时为 90 秒/5 分钟,`quietOversizedSkips``false`;未显式部署的参数继续沿用上游默认,包括 skill 路径保护。 - `pi-condense` 负责压缩已经进入会话的历史工具结果,与 Context Mode 的输入隔离职责互补;`extensions/condense.ts` 只为 `settings.json``contextPrune` 补入缺失的组合包默认字段,必须保留全部用户显式值(包括 `enabled: false`),配置无效时不得覆盖原文件,配置完整时不得重复改写。组合包默认开启,使用 `agent-message` 触发、`turn` 批处理、73.5% 总预算与 4% 单轮增量触发,摘要模型和 thinking 均为 `default`,空闲/总超时为 90 秒/5 分钟,`quietOversizedSkips``false`;未显式部署的参数继续沿用上游默认,包括 skill 路径保护。
- Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。 - Codex fast mode 只为符合条件的 `openai-codex-responses` 请求设置 priority service tier。`/fast-mode on|off` 同时更新当前分支记录与 Pi agent 目录中的 owner-only 全局默认值;新会话继承全局值,已有分支记录优先,配置缺失或无效时回退为关闭。
- Kitty 通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。默认 `o=always``a=focus`,每个 Pi Session 使用独立稳定 ID,标题和正文必须 Base64 编码。 - Kitty 完成通知只在 TUI 模式的 `agent_settled` 后发送;自动重试、自动 compaction 和 follow-up 期间不得提前通知或重置总耗时。权限通知只订阅 `permissions:ui_prompt`,在人类确认界面即将出现时立即发送,不得为策略或 AutoReview 已直接决定的请求通知,也不受完成通知时长门槛影响。默认 `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;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文、权限值或请求者细节的隐私模式,非 TUI 模式不得写入 OSC 序列。
- 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask` - 权限策略默认允许常规工具,允许 FFF 工具;拒绝 Bash 直搜和敏感凭据路径;Git 非只读操作、包管理、外部目录、文件/系统/网络高风险操作与普通 MCP 调用先由 `pi-permission-system` 判为 `ask`
- SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain`ssh_connect``ssh_cd``ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwdAutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。 - SSH 权限沿用同一条 `pi-permission-system` gate 与 `auto-review` authorizer chain`ssh_connect``ssh_cd``ssh_read``ssh_write``ssh_edit``ssh_find``ssh_grep` 默认 `ask`;连接前的证据解析已导入 host ID 为非秘密 target、port 与请求/default cwdAutoReview 只可依据用户直接消息中的明确目标授权连接。`ssh_bash` 通过 `shellTools` 映射到完整 Bash 策略并设置 `decisionFloor: "ask"`,把普通 Bash `allow` 提升为复核请求,同时保留原有 `ask` 与硬 `deny`。全部远端路径不得送入基于本机 cwd 的 `path` / `external_directory` 归一化。
- `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。 - `pi-permission-auto-review` 不是独立 `tool_call` gate,而是 `pi-permission-system` authorizer chain 中名为 `auto-review` 的链路;只复核权限基线产生的 `ask`,不会重复处理已 `allow` 或已 `deny` 的请求。
@@ -201,7 +201,7 @@
- 扩展入口是 `pi-notify/src/index.ts`Kitty/macOS 后端位于 `pi-notify/src/terminal.ts`,测试保留在 `pi-notify/tests/`,来源记录是 `pi-notify/UPSTREAM.md` - 扩展入口是 `pi-notify/src/index.ts`Kitty/macOS 后端位于 `pi-notify/src/terminal.ts`,测试保留在 `pi-notify/tests/`,来源记录是 `pi-notify/UPSTREAM.md`
- 同步上游必须从明确 tag/commit 移植,保留作者、MIT 许可声明与来源;不得导入嵌套 `.git``node_modules` 或构建产物。 - 同步上游必须从明确 tag/commit 移植,保留作者、MIT 许可声明与来源;不得导入嵌套 `.git``node_modules` 或构建产物。
- 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`通知,非 TUI 输出完整性、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。 - 完成通知默认只在 TUI `agent_settled`发送;权限通知只在 `permissions:ui_prompt` 后立即发送。非 TUI 输出完整性、策略/AutoReview 静默决定、隐私模式、自动 retry/compaction/follow-up 去重、总耗时、短任务阈值和多窗口点击聚焦都属于回归边界。
- 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。 - 根包必须通过 `file:./pi-notify` 提供依赖并直接加载源码,不得同时加载 npm `@smoose/pi-notify` 实现。
## `pi-chrome` 开发约定 ## `pi-chrome` 开发约定
+14 -11
View File
@@ -14,7 +14,7 @@
- `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。 - `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。
- `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。 - `@ogulcancelik/pi-codex-compaction@0.1.3`:为 `openai-codex` 提供原生远程压缩。
- 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on` - 本仓库维护的 `pi-minimal-footer`:基于 `@ogulcancelik/pi-minimal-footer@0.1.10`,用紧凑的上下文仪表和订阅用量条替换默认 footer,并在 Codex Fast 模式开启时显示 `Fast on`
- 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 通知,并支持点击精确聚焦来源 Kitty 窗口。 - 本仓库维护的 `pi-notify`:基于 `@smoose/pi-notify@0.1.1`,使用 Kitty OSC 99 在 Pi 完全 settled 或权限系统即将请求人工确认时通知,并支持点击精确聚焦来源 Kitty 窗口。
- `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。 - `pi-condense@2.9.1`:把已完成的工具调用批次总结为可恢复的短摘要,并通过 `context_tree_query` 按需取回原始输出;组合包首次加载时默认开启。
- 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。 - 本仓库维护的 `pi-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。
- 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。
@@ -344,18 +344,21 @@ Hippo CLI:从升级后的组合包读取固定版本,先通过 npm 确认版
`uninstall.sh` 移除组合包后询问是否一并执行 `npm uninstall -g hippo-memory`,默认保留;无论选择什么, `uninstall.sh` 移除组合包后询问是否一并执行 `npm uninstall -g hippo-memory`,默认保留;无论选择什么,
`.hippo/` 和用户记忆数据都不会被脚本删除。 `.hippo/` 和用户记忆数据都不会被脚本删除。
### Kitty 完成通知 ### Kitty 完成与权限确认通知
`pi-notify/` 直接加载本地维护源码。只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动 `pi-notify/` 直接加载本地维护源码。完成通知只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动
compaction 和排队 follow-up 完成前不会提前通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-permission-system`
独立的稳定通知 ID、显式 `a=focus`,并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收 `permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的
键盘输入也会显示通知,点击通知由 Kitty 返回原始来源;仍可通过环境变量改用 `unfocused``invisible` 决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`
并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty
返回原始来源;仍可通过环境变量改用 `unfocused``invisible`
默认忽略不足 3 秒的短任务。`/notify on|off|test|status` 可控制当前 Session;持久默认通过 默认忽略不足 3 秒的短任务,但权限确认通知不受该时长门槛影响`/notify on|off|test|status` 可控制
`PI_NOTIFY_ENABLED``PI_NOTIFY_MIN_SECONDS``PI_NOTIFY_VISIBILITY``PI_NOTIFY_ACTION` 当前 Session 的两类通知;持久默认通过 `PI_NOTIFY_ENABLED``PI_NOTIFY_MIN_SECONDS`
`PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。`PI_NOTIFY_MESSAGE_SOURCE=none` 可避免把回复摘要发送到 `PI_NOTIFY_VISIBILITY``PI_NOTIFY_ACTION``PI_NOTIFY_MESSAGE_SOURCE` 等环境变量配置。
macOS 通知中心。Kitty 位于 tmux 内时需要 `set -g allow-passthrough all` 并重启 tmux server。 `PI_NOTIFY_MESSAGE_SOURCE=none` 会同时隐藏回复摘要以及权限值和请求者细节。Kitty 位于 tmux 内时需要
完整配置和上游差异见 [`pi-notify/README.md`](pi-notify/README.md)。 `set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见
[`pi-notify/README.md`](pi-notify/README.md)。
### Codex 原生远程压缩 ### Codex 原生远程压缩
+10 -9
View File
@@ -1,14 +1,15 @@
# pi-notify # pi-notify
Kitty-first completion notifications for Pi. This repository maintains a source import of Kitty-first completion and permission-input notifications for Pi. This repository maintains a source import of
`@smoose/pi-notify@0.1.1` with local reliability and multi-window improvements. `@smoose/pi-notify@0.1.1` with local reliability, permission-bus integration, and multi-window improvements.
## Behavior ## Behavior
- Waits for Pi's `agent_settled` event, so retries, automatic compaction, and queued follow-ups do not notify early. - Waits for Pi's `agent_settled` event for completion notifications, so retries, automatic compaction, and queued follow-ups do not notify early.
- Listens for `permissions:ui_prompt` and notifies immediately only when `pi-permission-system` is about to ask the human; policy and AutoReview decisions that need no user response stay silent.
- Uses Kitty OSC 99 with Base64 payloads, an ID unique to each Pi session, and explicit `a=focus`. - Uses Kitty OSC 99 with Base64 payloads, an ID unique to each Pi session, and explicit `a=focus`.
- Defaults to `o=always`: notifications remain visible while the originating Kitty window/pane has keyboard focus; clicking a notification returns to that exact source window. - Defaults to `o=always`: notifications remain visible while the originating Kitty window/pane has keyboard focus; clicking a notification returns to that exact source window.
- Measures the entire run across automatic retries and ignores runs shorter than three seconds by default. - Measures the entire run across automatic retries and ignores completion runs shorter than three seconds by default; permission prompts bypass this duration threshold.
- Emits terminal sequences only in interactive TUI mode, never into JSON, print, or RPC output. - Emits terminal sequences only in interactive TUI mode, never into JSON, print, or RPC output.
- Uses macOS AppleScript outside Kitty and a sanitized OSC 777 fallback outside macOS. - Uses macOS AppleScript outside Kitty and a sanitized OSC 777 fallback outside macOS.
@@ -32,11 +33,11 @@ Command overrides are session-local. Use environment variables for persistent de
## Configuration ## Configuration
- `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled. - `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled.
- `PI_NOTIFY_TITLE`: title template; default `π - {project}`. - `PI_NOTIFY_TITLE`: title template shared by completion and permission notifications; default `π - {project}`.
- `PI_NOTIFY_BODY`: body template; default `{message}`. - `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`. - `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 message characters before ellipsis; default `80`, maximum `500`. - `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`. - `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_VISIBILITY`: Kitty policy `unfocused`, `invisible`, or `always`; default `always`.
- `PI_NOTIFY_ACTION`: Kitty click action `focus` or `none`; default `focus`. - `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. - `PI_NOTIFY_SOUND_CMD`: optional shell command run after the notification. This is trusted local configuration and executes through a shell.
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@smoose/pi-notify", "name": "@smoose/pi-notify",
"version": "0.1.1-my-pi.1", "version": "0.1.1-my-pi.1",
"description": "Kitty-aware completion notifications for Pi with exact-window focus and settled-agent delivery.", "description": "Kitty-aware completion and permission-input notifications for Pi with exact-window focus.",
"type": "module", "type": "module",
"keywords": [ "keywords": [
"pi-package", "pi-package",
+62 -5
View File
@@ -1,6 +1,8 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { getConfig, shouldNotify } from "./config.ts"; import { getConfig, shouldNotify } from "./config.ts";
import { truncateText } from "./messages.ts";
import { parseMessageMax, parseMessageSource } from "./parsers.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,6 +13,35 @@ type ExtensionDependencies = {
getBackend: typeof getNotificationBackend; getBackend: typeof getNotificationBackend;
}; };
const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
function formatPermissionPromptBody(raw: unknown): string | undefined {
if (!isRecord(raw) || !readNonEmptyString(raw.requestId)) return undefined;
if (parseMessageSource(process.env.PI_NOTIFY_MESSAGE_SOURCE) === "none") {
return "Permission confirmation required";
}
const surface = readNonEmptyString(raw.surface);
const value = readNonEmptyString(raw.value);
const forwarding = isRecord(raw.forwarding) ? raw.forwarding : undefined;
const agentName = readNonEmptyString(forwarding?.requesterAgentName) ?? readNonEmptyString(raw.agentName);
const labels = ["Permission required", agentName, surface].filter((value): value is string => Boolean(value));
if (!value) return labels.join(" · ");
const normalizedValue = value.replace(/\s+/g, " ").trim();
return `${labels.join(" · ")}\n${truncateText(normalizedValue, parseMessageMax(process.env.PI_NOTIFY_MESSAGE_MAX))}`;
}
function readNonEmptyString(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed || undefined;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
export function createPiNotifyExtension(dependencies: Partial<ExtensionDependencies> = {}) { export function createPiNotifyExtension(dependencies: Partial<ExtensionDependencies> = {}) {
const now = dependencies.now ?? Date.now; const now = dependencies.now ?? Date.now;
const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`); const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`);
@@ -23,6 +54,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
let latestMessages: unknown[] | undefined; let latestMessages: unknown[] | undefined;
let toolErrorCount = 0; let toolErrorCount = 0;
let enabledOverride: boolean | undefined; let enabledOverride: boolean | undefined;
let currentCtx: ExtensionContext | undefined;
const resetRun = (): void => { const resetRun = (): void => {
startedAt = undefined; startedAt = undefined;
@@ -30,7 +62,12 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
toolErrorCount = 0; toolErrorCount = 0;
}; };
pi.on("agent_start", () => { pi.on("session_start", (_event, ctx) => {
currentCtx = ctx;
});
pi.on("agent_start", (_event, ctx) => {
currentCtx = ctx;
startedAt ??= now(); startedAt ??= now();
}); });
@@ -51,14 +88,34 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
} }
}); });
const unsubscribePermissionPrompt = pi.events.on(PERMISSIONS_UI_PROMPT_CHANNEL, (raw) => {
const body = formatPermissionPromptBody(raw);
if (!body || currentCtx?.mode !== "tui") return;
const config = getConfig(
currentCtx,
undefined,
undefined,
{ notificationId, toolErrorCount },
now(),
);
const permissionConfig = { ...config, body, minDurationMs: 0 };
if (shouldNotify(permissionConfig, enabledOverride)) notify(permissionConfig);
});
pi.on("session_shutdown", () => {
currentCtx = undefined;
unsubscribePermissionPrompt();
});
pi.registerCommand("notify", { pi.registerCommand("notify", {
description: "Control Kitty/macOS completion notifications: on, off, test, status", description: "Control Kitty/macOS completion and permission notifications: on, off, test, status",
handler: async (args, ctx) => { handler: async (args, ctx) => {
const action = args.trim().toLowerCase() || "status"; const action = args.trim().toLowerCase() || "status";
if (action === "on" || action === "off") { if (action === "on" || action === "off") {
enabledOverride = action === "on"; enabledOverride = action === "on";
ctx.ui.notify(`Completion notifications ${action}`, "info"); ctx.ui.notify(`Notifications ${action}`, "info");
return; return;
} }
@@ -76,7 +133,7 @@ export function createPiNotifyExtension(dependencies: Partial<ExtensionDependenc
if (action === "status") { if (action === "status") {
const enabled = enabledOverride ?? config.enabled; const enabled = enabledOverride ?? config.enabled;
ctx.ui.notify( ctx.ui.notify(
`Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · min ${config.minDurationMs / 1000}s`, `Notifications ${enabled ? "on" : "off"} · ${getBackend()} · ${config.visibility} · completion min ${config.minDurationMs / 1000}s`,
"info", "info",
); );
return; return;
+66 -3
View File
@@ -6,15 +6,20 @@ import type { NotifyConfig } from "../src/types.ts";
type Handler = (...args: any[]) => any; type Handler = (...args: any[]) => any;
const savedMinSeconds = process.env.PI_NOTIFY_MIN_SECONDS; const savedEnv = new Map(
["PI_NOTIFY_MIN_SECONDS", "PI_NOTIFY_MESSAGE_SOURCE"].map((key) => [key, process.env[key]]),
);
afterEach(() => { afterEach(() => {
if (savedMinSeconds === undefined) delete process.env.PI_NOTIFY_MIN_SECONDS; for (const [key, value] of savedEnv) {
else process.env.PI_NOTIFY_MIN_SECONDS = savedMinSeconds; if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}); });
function harness(mode: ExtensionContext["mode"] = "tui") { 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 commands = new Map<string, { handler: Handler }>(); const commands = new Map<string, { handler: Handler }>();
const notifications: NotifyConfig[] = []; const notifications: NotifyConfig[] = [];
const uiMessages: string[] = []; const uiMessages: string[] = [];
@@ -24,6 +29,12 @@ function harness(mode: ExtensionContext["mode"] = "tui") {
on(name: string, handler: Handler) { on(name: string, handler: Handler) {
handlers.set(name, handler); handlers.set(name, handler);
}, },
events: {
on(name: string, handler: Handler) {
eventHandlers.set(name, handler);
return () => eventHandlers.delete(name);
},
},
registerCommand(name: string, command: { handler: Handler }) { registerCommand(name: string, command: { handler: Handler }) {
commands.set(name, command); commands.set(name, command);
}, },
@@ -50,6 +61,7 @@ function harness(mode: ExtensionContext["mode"] = "tui") {
return { return {
handlers, handlers,
eventHandlers,
commands, commands,
notifications, notifications,
uiMessages, uiMessages,
@@ -84,8 +96,14 @@ describe("pi-notify extension", () => {
process.env.PI_NOTIFY_MIN_SECONDS = "0"; 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);
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);
h.eventHandlers.get("permissions:ui_prompt")?.({
requestId: "perm-non-tui",
surface: "bash",
value: "git push",
});
assert.equal(h.notifications.length, 0, mode); assert.equal(h.notifications.length, 0, mode);
} }
}); });
@@ -103,4 +121,49 @@ describe("pi-notify extension", () => {
await command.handler("status", h.ctx); await command.handler("status", h.ctx);
assert.match(h.uiMessages.at(-1) ?? "", /kitty/); assert.match(h.uiMessages.at(-1) ?? "", /kitty/);
}); });
it("notifies immediately when permission confirmation needs human input", async () => {
process.env.PI_NOTIFY_MIN_SECONDS = "3600";
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.({
requestId: "perm-1",
source: "tool_call",
surface: "bash",
value: "git push\norigin main",
agentName: null,
forwarding: { requesterAgentName: "reviewer", requesterSessionId: "child-1" },
});
assert.equal(h.notifications.length, 1);
assert.equal(h.notifications[0]?.body, "Permission required · reviewer · bash\ngit push origin main");
assert.equal(h.notifications[0]?.durationMs, 0);
assert.equal(h.notifications[0]?.minDurationMs, 0, "permission prompts bypass the completion threshold");
assert.equal(h.notifications[0]?.notificationId, "pi-session-test");
});
it("keeps permission details private and honors the session off override", async () => {
process.env.PI_NOTIFY_MESSAGE_SOURCE = "none";
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx);
const prompt = { requestId: "perm-secret", surface: "bash", value: "cat private-file" };
h.eventHandlers.get("permissions:ui_prompt")?.(prompt);
assert.equal(h.notifications[0]?.body, "Permission confirmation required");
await h.commands.get("notify")?.handler("off", h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.(prompt);
assert.equal(h.notifications.length, 1);
});
it("ignores malformed permission broadcasts and unsubscribes on shutdown", async () => {
const h = harness();
await h.handlers.get("session_start")?.({}, h.ctx);
h.eventHandlers.get("permissions:ui_prompt")?.({ surface: "bash", value: "git push" });
assert.equal(h.notifications.length, 0);
await h.handlers.get("session_shutdown")?.({}, h.ctx);
assert.equal(h.eventHandlers.has("permissions:ui_prompt"), false);
});
}); });