From 8002d7c2130a9c4fbd663baee2ff3ade6bd93e53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=91=E6=9C=8D=E5=8A=A1=E9=83=A8-=E5=8F=B6=E6=9E=97?= =?UTF-8?q?=E7=AB=8B?= <1361666059@qq.com> Date: Fri, 28 Aug 2026 16:35:33 +0800 Subject: [PATCH] feat(pi-notify): notify on permission prompts --- AGENTS.md | 8 ++-- README.md | 25 +++++++------ pi-notify/README.md | 19 +++++----- pi-notify/package.json | 2 +- pi-notify/src/index.ts | 67 +++++++++++++++++++++++++++++++--- pi-notify/tests/index.test.ts | 69 +++++++++++++++++++++++++++++++++-- 6 files changed, 157 insertions(+), 33 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 268cb9d..2ec5241 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ - 上游来源: - 初始导入快照:`77bd1e175003cd08e6d05d9e7fed695f86ae87b7`(`@ogulcancelik/pi-minimal-footer@0.1.10`)。 - 该目录从明确 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` 表示即将请求人工权限确认时立即通知。 - 上游来源: - 初始导入快照:`3a3691ab690b4bc37a4412ab0dcbd35ef14adcbf`(`@smoose/pi-notify@0.1.1`)。 - 该目录从 npm 对应明确 commit 的源码导入并由本仓库直接维护,不使用 npm 预编译产物,不是 submodule,也不保留嵌套 `.git`。 @@ -96,8 +96,8 @@ - `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 路径保护。 - 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 编码。 -- `/notify on|off` 只覆盖当前 Session;持久默认来自 `PI_NOTIFY_*` 环境变量。`PI_NOTIFY_MESSAGE_SOURCE=none` 必须继续提供不泄露回复正文的隐私模式,非 TUI 模式不得写入 OSC 序列。 +- 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 序列。 - 权限策略默认允许常规工具,允许 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 cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`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` 的请求。 @@ -201,7 +201,7 @@ - 扩展入口是 `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` 或构建产物。 - 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` 实现。 ## `pi-chrome` 开发约定 diff --git a/README.md b/README.md index 4d1097e..240083e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - `hippo-memory-pi/`:从官方 `hippo-memory v1.33.0` 源码导入的 Pi Extension,提供项目记忆注入、错误捕获和 sleep consolidation。 - `@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-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-ask-user`:提供模型主动调用的 `ask_user_question`,用一个顺序 TUI 同时支持单题、多题、选择题、文本题、自定义回答与提交前复核。 - 本仓库维护的 `pi-tool-search`:从完整工具定义生成并缓存经过校验的工作流分组,默认以最多 5 个动态组、28 个动态工具的 LRU 策略加载原始完整 schema,并支持原子批量激活。 @@ -344,18 +344,21 @@ Hippo CLI:从升级后的组合包读取固定版本,先通过 npm 确认版 `uninstall.sh` 移除组合包后询问是否一并执行 `npm uninstall -g hippo-memory`,默认保留;无论选择什么, `.hippo/` 和用户记忆数据都不会被脚本删除。 -### Kitty 完成通知 +### Kitty 完成与权限确认通知 -`pi-notify/` 直接加载本地维护源码。它只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动 -compaction 和排队 follow-up 完成前不会提前通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session -独立的稳定通知 ID、显式 `a=focus`,并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收 -键盘输入也会显示通知,点击通知由 Kitty 返回原始来源;仍可通过环境变量改用 `unfocused` 或 `invisible`。 +`pi-notify/` 直接加载本地维护源码。完成通知只在交互式 TUI 中监听 `agent_settled`,因此自动重试、自动 +compaction 和排队 follow-up 完成前不会提前通知;同时监听 `pi-permission-system` 的 +`permissions:ui_prompt`,仅在权限系统即将显示人工确认界面时立即通知,策略或 AutoReview 已直接完成的 +决定不会通知。Kitty 后端使用 Base64 OSC 99、每个 Pi Session 独立的稳定通知 ID、显式 `a=focus`, +并默认设置 `o=always`:即使来源 Kitty window/pane 正在接收键盘输入也会显示通知,点击通知由 Kitty +返回原始来源;仍可通过环境变量改用 `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` 可避免把回复摘要发送到 -macOS 通知中心。Kitty 位于 tmux 内时需要 `set -g allow-passthrough all` 并重启 tmux server。 -完整配置和上游差异见 [`pi-notify/README.md`](pi-notify/README.md)。 +默认忽略不足 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 内时需要 +`set -g allow-passthrough all` 并重启 tmux server。完整配置和上游差异见 +[`pi-notify/README.md`](pi-notify/README.md)。 ### Codex 原生远程压缩 diff --git a/pi-notify/README.md b/pi-notify/README.md index a01d079..c16126b 100644 --- a/pi-notify/README.md +++ b/pi-notify/README.md @@ -1,14 +1,15 @@ # pi-notify -Kitty-first completion notifications for Pi. This repository maintains a source import of -`@smoose/pi-notify@0.1.1` with local reliability and multi-window improvements. +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, permission-bus integration, and multi-window improvements. ## 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`. - 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. - 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 - `PI_NOTIFY_ENABLED`: `1`/`true` or `0`/`false`; default enabled. -- `PI_NOTIFY_TITLE`: title template; default `π - {project}`. -- `PI_NOTIFY_BODY`: body template; default `{message}`. -- `PI_NOTIFY_MESSAGE_SOURCE`: `assistant`, `user`, or privacy mode `none`; default `assistant`. -- `PI_NOTIFY_MESSAGE_MAX`: maximum message characters before ellipsis; default `80`, maximum `500`. -- `PI_NOTIFY_MIN_SECONDS`: minimum complete run duration; default `3`, range `0`–`3600`. +- `PI_NOTIFY_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. diff --git a/pi-notify/package.json b/pi-notify/package.json index c384bba..079886e 100644 --- a/pi-notify/package.json +++ b/pi-notify/package.json @@ -1,7 +1,7 @@ { "name": "@smoose/pi-notify", "version": "0.1.1-my-pi.1", - "description": "Kitty-aware completion notifications for Pi with exact-window focus and settled-agent delivery.", + "description": "Kitty-aware completion and permission-input notifications for Pi with exact-window focus.", "type": "module", "keywords": [ "pi-package", diff --git a/pi-notify/src/index.ts b/pi-notify/src/index.ts index 0c0d3c7..87f7cce 100644 --- a/pi-notify/src/index.ts +++ b/pi-notify/src/index.ts @@ -1,6 +1,8 @@ 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 { truncateText } from "./messages.ts"; +import { parseMessageMax, parseMessageSource } from "./parsers.ts"; import { getNotificationBackend, sendNotification } from "./terminal.ts"; import type { NotifyConfig } from "./types.ts"; @@ -11,6 +13,35 @@ type ExtensionDependencies = { 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 { + return typeof value === "object" && value !== null; +} + export function createPiNotifyExtension(dependencies: Partial = {}) { const now = dependencies.now ?? Date.now; const createNotificationId = dependencies.createNotificationId ?? (() => `pi-${process.pid}-${randomUUID()}`); @@ -23,6 +54,7 @@ export function createPiNotifyExtension(dependencies: Partial { startedAt = undefined; @@ -30,7 +62,12 @@ export function createPiNotifyExtension(dependencies: Partial { + pi.on("session_start", (_event, ctx) => { + currentCtx = ctx; + }); + + pi.on("agent_start", (_event, ctx) => { + currentCtx = ctx; startedAt ??= now(); }); @@ -51,14 +88,34 @@ export function createPiNotifyExtension(dependencies: Partial { + 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", { - 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) => { const action = args.trim().toLowerCase() || "status"; if (action === "on" || action === "off") { enabledOverride = action === "on"; - ctx.ui.notify(`Completion notifications ${action}`, "info"); + ctx.ui.notify(`Notifications ${action}`, "info"); return; } @@ -76,7 +133,7 @@ export function createPiNotifyExtension(dependencies: Partial 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(() => { - if (savedMinSeconds === undefined) delete process.env.PI_NOTIFY_MIN_SECONDS; - else process.env.PI_NOTIFY_MIN_SECONDS = savedMinSeconds; + 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(); + const eventHandlers = new Map(); const commands = new Map(); const notifications: NotifyConfig[] = []; const uiMessages: string[] = []; @@ -24,6 +29,12 @@ function harness(mode: ExtensionContext["mode"] = "tui") { on(name: string, handler: 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 }) { commands.set(name, command); }, @@ -50,6 +61,7 @@ function harness(mode: ExtensionContext["mode"] = "tui") { return { handlers, + eventHandlers, commands, notifications, uiMessages, @@ -84,8 +96,14 @@ describe("pi-notify extension", () => { process.env.PI_NOTIFY_MIN_SECONDS = "0"; for (const mode of ["rpc", "json", "print"] as const) { 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_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); } }); @@ -103,4 +121,49 @@ describe("pi-notify extension", () => { await command.handler("status", h.ctx); 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); + }); });