mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat(pi-notify): notify on permission prompts
This commit is contained in:
@@ -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 cwd,AutoReview 只可依据用户直接消息中的明确目标授权连接。`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 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` 的请求。
|
- `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,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
@@ -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,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
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user