pi-attention 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +84 -0
- package/docs/orca-notification-mechanism.md +86 -0
- package/extensions/request-title.ts +145 -0
- package/extensions/waiting-notify.ts +294 -0
- package/package.json +19 -0
package/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# pi-attention 🍎
|
|
2
|
+
|
|
3
|
+
Pi 的 **iTerm2 / Orca 注意力集成扩展**:把 iTerm2 标签页标题显示为**会话第一次 pi 提问**,**回答完成时弹 macOS 系统通知**,并在 **pi 等待你选择时提醒**(权限弹窗 / 计划菜单;iTerm2 原生转义序列与 Orca notification 事件,无需第三方工具)。
|
|
4
|
+
|
|
5
|
+
- **源码仓库**: [pi-packages](https://github.com/the-ultra-nexus/pi-packages)(GitHub monorepo 负责源码与文档)
|
|
6
|
+
- **npm 包**: `pi-attention`(独立安装、升级和卸载)
|
|
7
|
+
|
|
8
|
+
## 功能
|
|
9
|
+
|
|
10
|
+
1. **标题恒定显示提问** — 标题显示本会话最开始的一次提问,跨 reload / resume 稳定,退出时还原。
|
|
11
|
+
2. **回答完成系统通知** — 每次回答完成后,在 macOS 通知中心弹出通知,标题显示 assistant 回复摘要。
|
|
12
|
+
3. **等待用户操作提醒** — 权限弹窗(pi-permission-system)、计划菜单(pi-plan-mode)等阻塞等待你选择时:
|
|
13
|
+
- **iTerm2**:弹系统通知 + dock 弹跳,终端标题变为「⏸ 等待选择…」;
|
|
14
|
+
- **Orca**:上报 `notification` 事件,Orca 弹**原生系统通知**(来源为 Orca 应用)——**仅在 Orca 不在前台时弹出**。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# 全局安装
|
|
20
|
+
pi install npm:pi-attention
|
|
21
|
+
|
|
22
|
+
# 项目级安装
|
|
23
|
+
pi install -l npm:pi-attention
|
|
24
|
+
|
|
25
|
+
# 本地开发:在 pi-packages 仓库根目录执行
|
|
26
|
+
pi install ./extensions/pi-attention
|
|
27
|
+
|
|
28
|
+
# 卸载
|
|
29
|
+
pi remove npm:pi-attention
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
安装后**重启 pi,或在 pi 内执行 `/reload`** 即可生效。GitHub monorepo 仅作为源码和文档中心,不再通过根目录合集安装本扩展。
|
|
33
|
+
|
|
34
|
+
## 配置
|
|
35
|
+
|
|
36
|
+
| 环境变量 | 默认 | 说明 |
|
|
37
|
+
|----------|------|------|
|
|
38
|
+
| `PI_ATTENTION_NOTIFY` | 开 | 设为 `0` 关闭「回答完成通知」 |
|
|
39
|
+
| `PI_ATTENTION_NOTIFY_WAITING` | 开 | 设为 `0` 关闭「等待用户操作」的系统通知 |
|
|
40
|
+
|
|
41
|
+
### 通知机制(iTerm2 原生,无需第三方工具)
|
|
42
|
+
|
|
43
|
+
回答完成时,扩展直接向 iTerm2 发送两条**原生转义序列**来触发通知,发送者即 iTerm2:
|
|
44
|
+
|
|
45
|
+
- `OSC 9 ; <消息>` → 弹系统通知
|
|
46
|
+
- `OSC 1337 ; RequestAttention=once` → dock 图标弹跳一次 + 系统提示音
|
|
47
|
+
|
|
48
|
+
无需安装 terminal-notifier 或 osascript。仅当不在 iTerm2 终端(如 tmux)时,才回退到 osascript 方式(并用显式提示音)。
|
|
49
|
+
|
|
50
|
+
### 通知机制(Orca:notification 事件)
|
|
51
|
+
|
|
52
|
+
Orca pane 下等待用户操作时,扩展经 agent-hook 上报 `notification` 事件(`title` / `body` / `message` / `notification_type`),Orca 主进程走原生系统通知:
|
|
53
|
+
|
|
54
|
+
- 来源为 **Orca 应用本体**,标题自动带 `pi: ` 前缀;
|
|
55
|
+
- **仅在 Orca 不在前台时弹出**——你正看着窗口时不打扰,切到别的应用时才提醒;
|
|
56
|
+
- 相关调研见 `docs/orca-notification-mechanism.md`(`ui_prompt_*` 为何不被 Orca 消费、`ask_user_question` 白名单等)。
|
|
57
|
+
|
|
58
|
+
## 标题行为
|
|
59
|
+
|
|
60
|
+
- 标题**恒定显示本会话最开始的一次提问**(会话里第一条 user 消息),跨 reload / resume 稳定
|
|
61
|
+
- 退出 pi → 标题还原为基础标题 `π · <会话名> · <目录名>`
|
|
62
|
+
|
|
63
|
+
## 通知
|
|
64
|
+
|
|
65
|
+
- 回答完成(`agent_settled`,消息已落盘)时触发 iTerm2 原生通知
|
|
66
|
+
- **通知标题显示 assistant 回复摘要**(最后一条回复的前 60 字)
|
|
67
|
+
- 发送者为 iTerm2(OSC 9)+ dock 弹跳 + 系统提示音(`RequestAttention=once`)
|
|
68
|
+
|
|
69
|
+
## 事件/原理
|
|
70
|
+
|
|
71
|
+
- `request-title.ts` — 标题 + 回答完成通知
|
|
72
|
+
- `session_start` — 会话启动/加载/恢复时,设定标题为最开始提问
|
|
73
|
+
- `agent_settled` — 回答完成(消息已落盘)时触发,发系统通知 + 校正标题
|
|
74
|
+
- `session_shutdown` — 会话退出时还原标题
|
|
75
|
+
- `waiting-notify.ts` — 等待用户操作提醒(权限弹窗 / 计划菜单 / 任意 `ctx.ui.*` 阻塞提示)
|
|
76
|
+
- `ui_prompt_start` — 等待开始:标题切「⏸ 等待选择…」;Orca 上报 `notification` 事件 → 原生系统通知(后台才弹);iTerm2 弹 OSC 通知
|
|
77
|
+
- `ui_prompt_end` — 等待结束:恢复基础标题
|
|
78
|
+
- 环境判定:Orca pane(`ORCA_PANE_KEY` / `ORCA_AGENT_HOOK_ENDPOINT`)→ 上报 `notification` 事件;`TERM_PROGRAM === "iTerm.app"` 且非 `PI_WEB_HOSTNAME` → iTerm2 原生通知;否则静默
|
|
79
|
+
|
|
80
|
+
> 为什么 waiting-notify 不放 Orca 的扩展里:Orca 会定期覆盖它自己分发的扩展(文件头标 `@orca-managed-pi-extension` 的那些),补丁会被冲掉;放本包统一维护。
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
MIT
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Orca 原生通知机制(调研记录)
|
|
2
|
+
|
|
3
|
+
> 主题:pi 跑在 Orca pane 里时,「等待用户操作」如何获得类似 Orca 原生的提示。
|
|
4
|
+
> 结论先行:Orca 对 hook 事件有三类处理——**状态指示、blocked 等待卡、系统通知(plugin 通知)**;
|
|
5
|
+
> `ui_prompt_*` 不在 Orca 协议内,需要走下面「可行通道」之一。
|
|
6
|
+
> 本文档的代码位置均来自 Orca.app(2026-09 版,macOS 13.7.8)。
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Orca 对 hook 上报的总入口
|
|
11
|
+
|
|
12
|
+
本地 hook 端点:`http://127.0.0.1:<ORCA_AGENT_HOOK_PORT>/hook/<source>`(source 见 `app.asar.unpacked/out/shared/agent-hook-relay.js` 的 `AGENT_HOOK_SOURCES`,含 claude/pi 等)。
|
|
13
|
+
请求头 `X-Orca-Agent-Hook-Token`。pi 侧上报参考实现:`~/.pi/agent/extensions/orca-agent-status.ts`(用户级、`@orca-managed-pi-extension`,会被 Orca 定期覆盖)。
|
|
14
|
+
|
|
15
|
+
## 2. 三类原生提示
|
|
16
|
+
|
|
17
|
+
### 2.1 状态指示(agent 生命周期事件)
|
|
18
|
+
- 消费端:`app.asar.unpacked/out/shared/agent-hook-listener/providers/pi-family-events.js`(`normalizePiCompatibleEvent`)
|
|
19
|
+
- pi 的 `agent_start / tool_call / tool_execution_start / tool_execution_end / message_end / agent_end / before_agent_start` → state ∈ {working, done} → Orca 界面内 spinner/完成状态/标题变化
|
|
20
|
+
- **→ 用户实测的「回答完有提示」就是这条**:Orca 原生消费 `agent_end`,无需任何扩展。
|
|
21
|
+
|
|
22
|
+
### 2.2 blocked(等待用户)状态
|
|
23
|
+
- 判定(pi-family-events.js + agent-question-answered-intent.js):
|
|
24
|
+
```js
|
|
25
|
+
isPiCompatibleAsk = agentType==='pi' && isAskUserQuestionTool(toolName) &&
|
|
26
|
+
(eventName==='tool_call' || eventName==='tool_execution_start')
|
|
27
|
+
```
|
|
28
|
+
- `isAskUserQuestionTool`:归一化(去非字母数字、小写)后 === `askuserquestion` || === `requestuserinput`
|
|
29
|
+
- pi 若用工具名 `ask_user_question`(归一化后 = askuserquestion)会被识别
|
|
30
|
+
- **`plan_mode_question` / `plan_mode_complete` / 自定义 UI 不在白名单** → Orca 视为普通 working,不显示等待
|
|
31
|
+
- grok 的做法(grok-events.js):hook `notification` 事件 + `message` 字段含 permission/approval/confirm/needs your/requires your/feedback/clarify/question 等 → state=waiting
|
|
32
|
+
|
|
33
|
+
### 2.3 plugin 通知(系统通知,来源 = 发送 app)
|
|
34
|
+
- 主进程(app.asar 打包代码):
|
|
35
|
+
```js
|
|
36
|
+
async dispatchPlugin(e) {
|
|
37
|
+
let t = `${e.pluginId}: ${e.title}`, n = e.body ?? '', r = !1;
|
|
38
|
+
try { r = V2().showNotification({ title: t, body: n }) } catch {}
|
|
39
|
+
return this.dispatch({ type: 'notification', source: 'plugin', title: t, body: n }), { delivered: r }
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
- `showNotification` = Electron `new Notification({title, body}).show()`(打包 `notification:` 段,GNr 函数带 sound/silent 处理)
|
|
43
|
+
- 事件识别:payload 含 `notification_type` / `notificationType` 字段(或 hook 名映射 `notification` → `Notification`,见打包的 hook 事件名映射)
|
|
44
|
+
- **这条通道弹的是真正的 macOS 系统通知,来源是 Orca 应用(或 pluginId 前缀的 app)**——是「像 Orca 原生一样」的提示
|
|
45
|
+
|
|
46
|
+
## 3. pi 的现状与缺口
|
|
47
|
+
|
|
48
|
+
| 场景 | Orca 原生表现 | pi-attention 现在的兜底 |
|
|
49
|
+
|------|--------------|------------------------|
|
|
50
|
+
| 回答完成(agent_end) | ✅ Orca 界面状态提示(2.1) | request-title(仅 iTerm2 OSC 生效;Orca 下无动作) |
|
|
51
|
+
| 等待用户(plan/权限弹窗) | ❌ 无(ui_prompt 不在协议内;plan_mode_question 不在白名单) | osascript 系统通知(来源「脚本编辑器」) |
|
|
52
|
+
|
|
53
|
+
## 4. 让等待获得 Orca 原生提示的可行通道(已定)
|
|
54
|
+
|
|
55
|
+
**已采用(实验验证通过)——上报 `notification` 事件**:`ui_prompt_start` 时 hook 上报
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{ "hook_event_name": "notification", "message": "Pi 正在等待你的操作", "title": "…", "body": "在 pi 窗口里完成操作", "notification_type": "permission_prompt", "level": "info" }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- ✅ Orca 主进程消费(dispatchPlugin → Electron `new Notification`),弹**原生系统通知**,来源为 Orca 应用;
|
|
62
|
+
- ✅ **仅在 Orca 不在前台时弹出**(前台静默)——正符合「人离开窗口才提醒」的诉求;
|
|
63
|
+
- ✅ 不产生状态快照(provider 层无映射),不污染 last-status.json;
|
|
64
|
+
- ⚠️ `ui_prompt_*` 事件 Orca 不消费(白名单只有 `ask_user_question` / `request_user_input` 的 tool_call),不再上报。
|
|
65
|
+
|
|
66
|
+
被否 / 备选通道:
|
|
67
|
+
|
|
68
|
+
1. **伪造 `ask_user_question` 的 tool_call**(副作用大,不推荐):上报 `tool_call` + `tool_name: "ask_user_question"` → Orca blocked 等待卡;风险:Orca 端可能等用户 submit 才清卡、界面出现伪工具记录。
|
|
69
|
+
2. **osascript 系统通知**(Orca pane 下的旧方案):来源「脚本编辑器」,前台也弹,已由 notification 事件替换。
|
|
70
|
+
|
|
71
|
+
## 5. 实验记录(2026-09-06 完成)
|
|
72
|
+
|
|
73
|
+
- [x] 模拟 POST `notification` 事件(/tmp/sim_notif.py,变体 A:`hook_event_name:'notification'` + message/title/body/notification_type/level)→ **HTTP 204,Orca 弹出原生系统通知**
|
|
74
|
+
- [x] 变体 A 复测:**Orca 在前台时不弹,切走后弹**(前台静默行为确认)
|
|
75
|
+
- [x] 变体 B/C/D(无 message/level、普通工具事件+notification_type、仅 title/body)未再逐一验证——变体 A 已达成目标
|
|
76
|
+
- [x] 未采用 osascript 方案
|
|
77
|
+
- [ ] 变体 B/C/D 的差异行为(如需了解 Orca 通知字段的边界)可后续补充
|
|
78
|
+
- 顺带发现:Orca 的 macOS 通知权限记录(ncprefs flags bit0=0)看似禁止,但 notification 事件的 Electron 通知仍能弹出——以实测为准
|
|
79
|
+
|
|
80
|
+
## 6. 关键参考文件
|
|
81
|
+
|
|
82
|
+
- `app.asar.unpacked/out/shared/agent-hook-listener/providers/pi-family-events.js`
|
|
83
|
+
- `app.asar.unpacked/out/shared/agent-question-answered-intent.js`
|
|
84
|
+
- `app.asar.unpacked/out/shared/agent-hook-listener/providers/grok-events.js`、`grok-tool-fields.js`
|
|
85
|
+
- `app.asar.unpacked/out/shared/agent-hook-relay.js`(AGENT_HOOK_SOURCES、转发协议)
|
|
86
|
+
- `~/.pi/agent/extensions/orca-agent-status.ts`(pi 侧上报参考)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-attention
|
|
3
|
+
*
|
|
4
|
+
* iTerm2 集成扩展。
|
|
5
|
+
*
|
|
6
|
+
* 标题: 恒定显示「本会话最开始的一次提问」(会话里第一条 user 消息),
|
|
7
|
+
* 跨 reload / resume 稳定指向同一句;退出时还原为基础标题。
|
|
8
|
+
*
|
|
9
|
+
* 通知: agent_settled(所有消息已落盘,回答真正完成)时,用 iTerm2 原生 OSC
|
|
10
|
+
* 触发系统通知,通知标题为 assistant 回复摘要;
|
|
11
|
+
* PI_ATTENTION_NOTIFY=0 可关闭(默认开启)。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { exec } from "node:child_process";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
|
|
18
|
+
const notifyEnabled = process.env.PI_ATTENTION_NOTIFY !== "0";
|
|
19
|
+
const NOTIFY_DONE = "pi 已完成回答";
|
|
20
|
+
|
|
21
|
+
function oneLine(s: string): string {
|
|
22
|
+
return s.replace(/\s+/g, " ").trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clipped(s: string): string {
|
|
26
|
+
const MAX = 60;
|
|
27
|
+
return s.length > MAX ? s.slice(0, MAX) + "…" : s;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function cwdName(): string {
|
|
31
|
+
return path.basename(process.cwd()) || process.cwd();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function baseTitle(pi: ExtensionAPI): string {
|
|
35
|
+
const session = pi.getSessionName();
|
|
36
|
+
return session ? `π · ${session} · ${cwdName()}` : `π · ${cwdName()}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 从一条 message 的 content 中提取纯文本(content 可以是字符串或文本块数组)。 */
|
|
40
|
+
function extractPrompt(content: unknown): string {
|
|
41
|
+
if (typeof content === "string") return oneLine(content);
|
|
42
|
+
if (Array.isArray(content)) {
|
|
43
|
+
return oneLine(
|
|
44
|
+
content
|
|
45
|
+
.filter(
|
|
46
|
+
(c): c is { type: "text"; text: string } =>
|
|
47
|
+
typeof c === "object" &&
|
|
48
|
+
c !== null &&
|
|
49
|
+
(c as { type?: string }).type === "text" &&
|
|
50
|
+
typeof (c as { text?: unknown }).text === "string",
|
|
51
|
+
)
|
|
52
|
+
.map((c) => c.text)
|
|
53
|
+
.join(" "),
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return "";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type SM = {
|
|
60
|
+
getEntries(): Array<{ type: string; message?: { role?: string; content?: unknown } }>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** 会话里“最开始”的一条 user 提问(第一条)。 */
|
|
64
|
+
function earliestUserPrompt(sm: SM): string {
|
|
65
|
+
for (const entry of sm.getEntries()) {
|
|
66
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
67
|
+
const p = extractPrompt(entry.message.content);
|
|
68
|
+
if (p) return clipped(p);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 会话里“最新”的一条 user 提问(最后一条,即当前这次提问)。 */
|
|
75
|
+
function latestUserPrompt(sm: SM): string {
|
|
76
|
+
let latest = "";
|
|
77
|
+
for (const entry of sm.getEntries()) {
|
|
78
|
+
if (entry.type === "message" && entry.message?.role === "user") {
|
|
79
|
+
const p = extractPrompt(entry.message.content);
|
|
80
|
+
if (p) latest = clipped(p);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return latest;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 会话里最后一条 assistant 回复的文本摘要。 */
|
|
87
|
+
function latestAssistantText(sm: SM): string {
|
|
88
|
+
let latest = "";
|
|
89
|
+
for (const entry of sm.getEntries()) {
|
|
90
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
91
|
+
const p = extractPrompt(entry.message.content);
|
|
92
|
+
if (p) latest = clipped(p);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return latest;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function applyTitle(pi: ExtensionAPI, ctx: { ui: { setTitle(t: string): void } }, sm: SM): void {
|
|
99
|
+
const earliest = earliestUserPrompt(sm);
|
|
100
|
+
ctx.ui.setTitle(earliest ? `π · ${earliest} · ${cwdName()}` : baseTitle(pi));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 回答完成时触发 iTerm2 原生系统通知(无需第三方工具)。
|
|
105
|
+
* 以「assistant 回复摘要」作为通知标题。
|
|
106
|
+
* - OSC 9 : 弹系统通知,发送者即 iTerm2
|
|
107
|
+
* - OSC 1337 ; RequestAttention=once : dock 图标弹跳一次 + 系统提示音
|
|
108
|
+
* 非 iTerm2 终端或写入失败时回退 osascript(显式加提示音)。
|
|
109
|
+
*/
|
|
110
|
+
function notifyCompletion(replyText: string) {
|
|
111
|
+
if (!notifyEnabled || process.platform !== "darwin" || process.env.PI_WEB_HOSTNAME) return;
|
|
112
|
+
const subject = replyText ? clipped(oneLine(replyText)) : "pi";
|
|
113
|
+
try {
|
|
114
|
+
process.stdout.write(`\x1b]9;${subject}\x07`);
|
|
115
|
+
process.stdout.write("\x1b]1337;RequestAttention=once\x07");
|
|
116
|
+
return;
|
|
117
|
+
} catch {
|
|
118
|
+
// 终端不支持该 OSC 时回退到 osascript(来源会显示为“脚本编辑器”)
|
|
119
|
+
}
|
|
120
|
+
const safeSubject = subject.replace(/"/g, '\\"');
|
|
121
|
+
const script = `display notification "${NOTIFY_DONE}" with title "${safeSubject}" sound name "Glass"`;
|
|
122
|
+
exec(`osascript -e '${script}'`, () => {});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default function (pi: ExtensionAPI) {
|
|
126
|
+
// 标题恒定显示「本会话最开始提问」(会话第一条 user 消息),跨 reload/resume 稳定。
|
|
127
|
+
|
|
128
|
+
// 会话启动/加载/恢复时:显示最开始提问(若有历史)
|
|
129
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
130
|
+
applyTitle(pi, ctx, ctx.sessionManager);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// 回答完成(消息已落盘):
|
|
134
|
+
// 1) 通知,标题 = assistant 回复摘要
|
|
135
|
+
// 2) 校正标题为「最开始提问」
|
|
136
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
137
|
+
notifyCompletion(latestAssistantText(ctx.sessionManager));
|
|
138
|
+
applyTitle(pi, ctx, ctx.sessionManager);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// 会话退出时把标题还原为基础标题
|
|
142
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
143
|
+
ctx.ui.setTitle(baseTitle(pi));
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-attention · waiting-notify
|
|
3
|
+
*
|
|
4
|
+
* 监听 pi 的「等待用户输入」生命周期事件(ui_prompt_start / ui_prompt_end)。
|
|
5
|
+
* 这些事件在所有阻塞式用户界面提示弹出时发出:
|
|
6
|
+
* - gotgenes/pi-permission-system 的权限「允许/拒绝」弹窗(ctx.ui.custom)
|
|
7
|
+
* - narumitw/pi-plan-mode 的计划菜单 / plan_mode_question(ctx.ui.custom / select)
|
|
8
|
+
* - 任何 ctx.ui.select / confirm / input / editor / custom 阻塞等用户的操作
|
|
9
|
+
*
|
|
10
|
+
* 行为(按宿主环境分流):
|
|
11
|
+
* - Orca pane(ORCA_PANE_KEY / ORCA_AGENT_HOOK_ENDPOINT):上报 notification
|
|
12
|
+
* 事件(title/body/message/notification_type),Orca 走原生系统通知——来源为
|
|
13
|
+
* Orca 应用,且只在 Orca 不在前台时弹出(前台静默,人离开窗口才提醒)。
|
|
14
|
+
* ui_prompt_* 事件 Orca 不消费(白名单只有 ask_user_question /
|
|
15
|
+
* request_user_input 的 tool_call),不再上报。
|
|
16
|
+
* - iTerm2(TERM_PROGRAM === "iTerm.app" 且非 pi-web):弹出系统通知(OSC 9,
|
|
17
|
+
* 发送者即 iTerm2)+ dock 弹跳一次(OSC 1337 RequestAttention=once),并把终端
|
|
18
|
+
* 标题切换为「⏸ 等待选择…」,选择完成后恢复基础标题。
|
|
19
|
+
* - 其它环境(pi-web / 非 iTerm2 终端):静默,不做通知(标题仍可设置)。
|
|
20
|
+
*
|
|
21
|
+
* 为什么放在这里维护:Orca 会定期覆盖它自己分发的扩展
|
|
22
|
+
* (orca-agent-status.ts、orca-titlebar-spinner.ts 等,文件头标
|
|
23
|
+
* `@orca-managed-pi-extension`),在那些文件上补丁会被冲掉。本扩展随
|
|
24
|
+
* pi-attention 包分发,由自己的仓库统一维护。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { exec } from "node:child_process";
|
|
28
|
+
import fs from "node:fs";
|
|
29
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
30
|
+
|
|
31
|
+
// ⏸ 开关:PI_ATTENTION_NOTIFY_WAITING=0 关闭「等待用户输入」的系统通知(默认开)
|
|
32
|
+
const waitingNotify = process.env.PI_ATTENTION_NOTIFY_WAITING !== "0";
|
|
33
|
+
|
|
34
|
+
// 标题最长显示宽度(macOS 通知标题过长会被截断)
|
|
35
|
+
const SUBJECT_MAX = 60;
|
|
36
|
+
|
|
37
|
+
function oneLine(s: string): string {
|
|
38
|
+
return s.replace(/\s+/g, " ").trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clipped(s: string): string {
|
|
42
|
+
return s.length > SUBJECT_MAX ? s.slice(0, SUBJECT_MAX) + "…" : s;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function baseTitle(pi: ExtensionAPI): string {
|
|
46
|
+
const session = pi.getSessionName();
|
|
47
|
+
const cwd = process.cwd().split(/[\\/]/).filter(Boolean).at(-1) || process.cwd();
|
|
48
|
+
return session ? `π · ${session} · ${cwd}` : `π · ${cwd}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 是否运行在 Orca 托管的 pane 里(其状态走 agent-hook 上报而非终端 OSC)。 */
|
|
52
|
+
function isOrcaPane(): boolean {
|
|
53
|
+
return !!(process.env.ORCA_PANE_KEY || process.env.ORCA_AGENT_HOOK_ENDPOINT);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 是否运行在 iTerm2 终端里(OSC 9 / OSC 1337 只有 iTerm2 原生支持)。 */
|
|
57
|
+
function isITerm2(): boolean {
|
|
58
|
+
return process.env.TERM_PROGRAM === "iTerm.app";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── iTerm2 / 常规终端:通知 + 标题 ──────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 「等待用户操作」时触发 iTerm2 原生系统通知 + dock 弹跳。
|
|
65
|
+
*
|
|
66
|
+
* 环境判定(与 request-title.ts 同款原则):
|
|
67
|
+
* - TERM_PROGRAM === "iTerm.app":只在真 iTerm2 里发 OSC(其它终端 OSC 无效);
|
|
68
|
+
* - PI_WEB_HOSTNAME:排除 pi-web(pi-web 在 iTerm2 里跑时 TERM_PROGRAM 仍是
|
|
69
|
+
* iTerm.app,单查 TERM_PROGRAM 防不住它,参考 request-title.ts 的 b11c57d 提交);
|
|
70
|
+
* - Orca pane 不在这里处理:事件入口处按 isOrcaPane() 分流到 notifyViaSystem
|
|
71
|
+
* (osascript 系统通知——Orca 终端不支持 OSC 9 / OSC 1337,实测会原样回显)。
|
|
72
|
+
*/
|
|
73
|
+
function notifyWaiting(kind: string, title: string | undefined): void {
|
|
74
|
+
if (
|
|
75
|
+
!waitingNotify ||
|
|
76
|
+
process.platform !== "darwin" ||
|
|
77
|
+
process.env.PI_WEB_HOSTNAME ||
|
|
78
|
+
!isITerm2()
|
|
79
|
+
) return;
|
|
80
|
+
// 标题 = 具体请求内容(与 request-title 的完成通知对称:那头是回复摘要);
|
|
81
|
+
// 无具体标题时用默认句。正文 = 固定引导句。
|
|
82
|
+
const subject = clipped(
|
|
83
|
+
oneLine(title ? title : "pi 正在等待你的输入"),
|
|
84
|
+
);
|
|
85
|
+
const body = "在 pi 窗口里完成操作";
|
|
86
|
+
try {
|
|
87
|
+
process.stdout.write(`\x1b]9;${subject}\x07`);
|
|
88
|
+
process.stdout.write("\x1b]1337;RequestAttention=once\x07");
|
|
89
|
+
return;
|
|
90
|
+
} catch {
|
|
91
|
+
// 终端不支持该 OSC 时回退到 osascript(来源会显示为“脚本编辑器”)
|
|
92
|
+
}
|
|
93
|
+
const safeSubject = subject.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
94
|
+
const script = `osascript -e 'display notification "${body}" with title "${safeSubject}" sound name "Glass"'`;
|
|
95
|
+
exec(script, () => {});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Orca pane 环境下的等待提醒:上报 `notification` 事件给 Orca。
|
|
100
|
+
*
|
|
101
|
+
* 实测(2026-09,Orca app):Orca 主进程对 hook 的 notification 事件(含
|
|
102
|
+
* title/body/message/notification_type)走 dispatchPlugin → Electron 原生系统
|
|
103
|
+
* 通知,**仅在 Orca 不在前台时弹出**(前台静默,符合「人离开窗口才提醒」的
|
|
104
|
+
* 预期);来源为 Orca 应用本体,标题自动带 `${pluginId}: ` 前缀。
|
|
105
|
+
* `ui_prompt_*` 事件 Orca 不消费(白名单只有 ask_user_question /
|
|
106
|
+
* request_user_input 的 tool_call),所以这里不再上报它们,只报 notification。
|
|
107
|
+
*/
|
|
108
|
+
function notifyOrcaWaiting(ctx: ExtensionContext, title: string | undefined): void {
|
|
109
|
+
post(
|
|
110
|
+
"notification",
|
|
111
|
+
{
|
|
112
|
+
message: "Pi 正在等待你的操作",
|
|
113
|
+
title: clipped(oneLine(title ? title : "pi 正在等待你的输入")),
|
|
114
|
+
body: "在 pi 窗口里完成操作",
|
|
115
|
+
notification_type: "permission_prompt",
|
|
116
|
+
level: "info",
|
|
117
|
+
},
|
|
118
|
+
sessionMeta(ctx),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── Orca pane:agent-hook 上报 ──────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
// Why: Pi 会 await 扩展处理器。状态上报保持脱离关键路径,且「只保留最新
|
|
125
|
+
// 一条待发」的槽位避免 Orca 接收端在挂起时堆积过期快照。镜像 orca-agent-status。
|
|
126
|
+
const HOOK_POST_TIMEOUT_MS = 1000;
|
|
127
|
+
let activePost = false;
|
|
128
|
+
let pendingPost: { name: string; extra: Record<string, unknown>; meta: Record<string, unknown> } | null = null;
|
|
129
|
+
|
|
130
|
+
// Why: 每次事件都重读 endpoint 文件很便宜(小文件、很少变化),但 stat+mtime
|
|
131
|
+
// 缓存可避免工具流式执行期间每个事件都重复解析。
|
|
132
|
+
let cachedEndpointKey = "";
|
|
133
|
+
let cachedEndpointValues: Record<string, string> | null = null;
|
|
134
|
+
|
|
135
|
+
function readEndpointFile(): Record<string, string> | null {
|
|
136
|
+
const path = process.env.ORCA_AGENT_HOOK_ENDPOINT;
|
|
137
|
+
if (!path) return null;
|
|
138
|
+
try {
|
|
139
|
+
const stat = fs.statSync(path);
|
|
140
|
+
const cacheKey = stat.mtimeMs + ":" + stat.size + ":" + stat.ino;
|
|
141
|
+
if (cacheKey === cachedEndpointKey && cachedEndpointValues) return cachedEndpointValues;
|
|
142
|
+
const contents: string = fs.readFileSync(path, "utf8");
|
|
143
|
+
const out: Record<string, string> = {};
|
|
144
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
145
|
+
const m = line.match(/^(?:set\s+)?([A-Z0-9_]+)=(.*)$/);
|
|
146
|
+
if (m) out[m[1]] = m[2].replace(/\r$/, "");
|
|
147
|
+
}
|
|
148
|
+
cachedEndpointKey = cacheKey;
|
|
149
|
+
cachedEndpointValues = out;
|
|
150
|
+
return out;
|
|
151
|
+
} catch {
|
|
152
|
+
cachedEndpointKey = "";
|
|
153
|
+
cachedEndpointValues = null;
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function resolveHookCoords(): Record<string, string | undefined> {
|
|
159
|
+
const fileEnv = readEndpointFile() || {};
|
|
160
|
+
return {
|
|
161
|
+
port: fileEnv.ORCA_AGENT_HOOK_PORT || process.env.ORCA_AGENT_HOOK_PORT,
|
|
162
|
+
token: fileEnv.ORCA_AGENT_HOOK_TOKEN || process.env.ORCA_AGENT_HOOK_TOKEN,
|
|
163
|
+
env: fileEnv.ORCA_AGENT_HOOK_ENV || process.env.ORCA_AGENT_HOOK_ENV || "",
|
|
164
|
+
version: fileEnv.ORCA_AGENT_HOOK_VERSION || process.env.ORCA_AGENT_HOOK_VERSION || "",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function processName(value: unknown): string {
|
|
169
|
+
return String(value || "").split(/[\\/]/).pop()?.toLowerCase() || "";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const CONFIGURED_HOOK_PATH = "/hook/pi";
|
|
173
|
+
let cachedOmpRuntime: boolean | null = null;
|
|
174
|
+
|
|
175
|
+
function isOmpRuntime(): boolean {
|
|
176
|
+
if (cachedOmpRuntime !== null) return cachedOmpRuntime;
|
|
177
|
+
if (CONFIGURED_HOOK_PATH === "/hook/omp") {
|
|
178
|
+
cachedOmpRuntime = true;
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
const names = [process.title, process.env._, process.argv[1], process.argv[0]].map(processName);
|
|
182
|
+
cachedOmpRuntime = names.some((n) =>
|
|
183
|
+
["omp", "omp.js", "omp.sh", "omp.cmd", "omp.exe", "omp.bat"].includes(n),
|
|
184
|
+
);
|
|
185
|
+
return cachedOmpRuntime;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function resolveHookPath(ompRuntime: boolean): string {
|
|
189
|
+
return ompRuntime ? "/hook/omp" : CONFIGURED_HOOK_PATH;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function post(name: string, extra: Record<string, unknown>, meta: Record<string, unknown>): void {
|
|
193
|
+
pendingPost = { name, extra, meta };
|
|
194
|
+
drainPosts();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function drainPosts(): void {
|
|
198
|
+
if (activePost || !pendingPost) return;
|
|
199
|
+
const next = pendingPost;
|
|
200
|
+
pendingPost = null;
|
|
201
|
+
activePost = true;
|
|
202
|
+
void postOnce(next.name, next.extra, next.meta).catch(() => {}).finally(() => {
|
|
203
|
+
activePost = false;
|
|
204
|
+
drainPosts();
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function postOnce(
|
|
209
|
+
name: string,
|
|
210
|
+
extra: Record<string, unknown>,
|
|
211
|
+
meta: Record<string, unknown>,
|
|
212
|
+
): Promise<void> {
|
|
213
|
+
const coords = resolveHookCoords();
|
|
214
|
+
const paneKey = process.env.ORCA_PANE_KEY;
|
|
215
|
+
if (!coords.port || !coords.token || !paneKey) return;
|
|
216
|
+
const ompRuntime = isOmpRuntime();
|
|
217
|
+
const url = `http://127.0.0.1:${coords.port}${resolveHookPath(ompRuntime)}`;
|
|
218
|
+
const body = JSON.stringify({
|
|
219
|
+
paneKey,
|
|
220
|
+
launchToken: process.env.ORCA_AGENT_LAUNCH_TOKEN || "",
|
|
221
|
+
tabId: process.env.ORCA_TAB_ID || "",
|
|
222
|
+
worktreeId: process.env.ORCA_WORKTREE_ID || "",
|
|
223
|
+
env: coords.env,
|
|
224
|
+
version: coords.version,
|
|
225
|
+
payload: { hook_event_name: name, ...meta, ...extra },
|
|
226
|
+
});
|
|
227
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
228
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
229
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
230
|
+
timeout = setTimeout(() => {
|
|
231
|
+
controller?.abort();
|
|
232
|
+
reject(new Error("Orca hook delivery timed out"));
|
|
233
|
+
}, HOOK_POST_TIMEOUT_MS);
|
|
234
|
+
if (typeof timeout.unref === "function") timeout.unref();
|
|
235
|
+
});
|
|
236
|
+
try {
|
|
237
|
+
await Promise.race([
|
|
238
|
+
fetch(url, {
|
|
239
|
+
method: "POST",
|
|
240
|
+
headers: {
|
|
241
|
+
"Content-Type": "application/json",
|
|
242
|
+
"X-Orca-Agent-Hook-Token": coords.token,
|
|
243
|
+
},
|
|
244
|
+
body,
|
|
245
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
246
|
+
}),
|
|
247
|
+
timeoutPromise,
|
|
248
|
+
]);
|
|
249
|
+
} catch {
|
|
250
|
+
// Why: 上报失败绝不能反过来弄挂 pi 运行(Orca 重启/下线都属正常)。
|
|
251
|
+
} finally {
|
|
252
|
+
if (timeout) clearTimeout(timeout);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 当前会话的 session_id / session_file,与 orca-agent-status 同构。 */
|
|
257
|
+
function sessionMeta(ctx: ExtensionContext): Record<string, unknown> {
|
|
258
|
+
const sm = ctx?.sessionManager;
|
|
259
|
+
const id = sm?.getSessionId?.();
|
|
260
|
+
const file = sm?.getSessionFile?.();
|
|
261
|
+
return {
|
|
262
|
+
...(typeof id === "string" && id ? { session_id: id } : {}),
|
|
263
|
+
...(typeof file === "string" && file ? { session_file: file } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── 注册 ────────────────────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
export default function (pi: ExtensionAPI): void {
|
|
270
|
+
// Why: 子 agent 进程会继承 lead 的 pane env;只允许第一个(最外层)进程
|
|
271
|
+
// 处理等待提醒,避免子进程重复通知/重复上报。与 orca-agent-status 同款 PID 门禁。
|
|
272
|
+
const ownerPid = process.env.ORCA_PI_STATUS_OWNED;
|
|
273
|
+
const selfPid = String(process.pid);
|
|
274
|
+
if (ownerPid && ownerPid !== selfPid) return;
|
|
275
|
+
process.env.ORCA_PI_STATUS_OWNED = selfPid;
|
|
276
|
+
|
|
277
|
+
// 等待用户输入开始:Orca 走系统通知兜底 + hook 上报;iTerm2 走 OSC 通知。
|
|
278
|
+
pi.on("ui_prompt_start", (event, ctx) => {
|
|
279
|
+
const title = event.title;
|
|
280
|
+
if (isOrcaPane()) {
|
|
281
|
+
// Orca 原生:notification 事件 → 系统通知(后台才弹,前台静默)
|
|
282
|
+
notifyOrcaWaiting(ctx, title);
|
|
283
|
+
} else {
|
|
284
|
+
notifyWaiting(event.kind, title);
|
|
285
|
+
}
|
|
286
|
+
ctx.ui?.setTitle(`⏸ 等待选择… · ${baseTitle(pi)}`);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// 等待结束:恢复基础标题
|
|
290
|
+
// (request-title.ts 会在 agent_settled / session_start 再校正为「最早提问」)。
|
|
291
|
+
pi.on("ui_prompt_end", (event, ctx) => {
|
|
292
|
+
ctx.ui?.setTitle(baseTitle(pi));
|
|
293
|
+
});
|
|
294
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-attention",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Pi extension for iTerm2/Orca attention: title shows the first user request, notify on completion, and alert when pi is waiting for your selection (permission dialogs, plan-mode menus).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["pi-package", "iterm2", "attention", "title", "notification", "macos"],
|
|
8
|
+
"files": ["README.md", "docs", "extensions"],
|
|
9
|
+
"pi": {
|
|
10
|
+
"extensions": ["./extensions"]
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"@earendil-works/pi-ai": "*",
|
|
14
|
+
"@earendil-works/pi-agent-core": "*",
|
|
15
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
16
|
+
"@earendil-works/pi-tui": "*",
|
|
17
|
+
"typebox": "*"
|
|
18
|
+
}
|
|
19
|
+
}
|