pi-notifications 0.1.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 +76 -0
- package/README.zh-CN.md +76 -0
- package/SKILL.md +46 -0
- package/config.example.json +19 -0
- package/index.ts +4 -0
- package/locales/index.json +122 -0
- package/package.json +73 -0
- package/src/adapter.ts +182 -0
- package/src/config.ts +147 -0
- package/src/i18n.ts +3 -0
- package/src/index.ts +386 -0
- package/tsconfig.json +13 -0
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# pi-notifications
|
|
2
|
+
|
|
3
|
+
Configurable external notifications for the [Pi coding agent](https://pi.dev).
|
|
4
|
+
|
|
5
|
+
[中文文档](./README.zh-CN.md)
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Sends a system notification before `ask_user_question` or the legacy `ask_user` tool waits for input.
|
|
10
|
+
- Sends a completion notification after each Pi agent run, including whether a tool reported an error and the number of completed turns.
|
|
11
|
+
- Runs the adapter with Node's argv API, never through a shell, so notification text is not re-parsed as command syntax.
|
|
12
|
+
- Detects the configured command at runtime. A missing command or the first failed invocation produces one in-session warning and then disables external notifications for that runtime.
|
|
13
|
+
- Notification failures never block Pi's agent lifecycle or tool execution.
|
|
14
|
+
- All runtime messages are localized through `pi-extensions-i18n`.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pi install npm:pi-notifications
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Then reload Pi:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
/reload
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Install the shared i18n package automatically through this package's dependency. The default adapter expects `terminal-notifier` to be available in `PATH`.
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
The optional configuration file is:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
<pi-agent-dir>/extensions/pi-notifications/config.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`<pi-agent-dir>` is Pi's configured agent directory (normally `~/.pi/agent`). Start from [`config.example.json`](./config.example.json). Configuration fields are:
|
|
39
|
+
|
|
40
|
+
- `enabled`: set to `false` to disable external notifications.
|
|
41
|
+
- `adapter.command`: executable name or path.
|
|
42
|
+
- `adapter.args`: argv entries. `{title}`, `{subtitle}`, and `{message}` are replaced in each entry.
|
|
43
|
+
- `timeoutMs`: maximum time for one adapter invocation.
|
|
44
|
+
|
|
45
|
+
For example, a custom adapter can use an executable directly:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"enabled": true,
|
|
50
|
+
"adapter": {
|
|
51
|
+
"command": "notify-send",
|
|
52
|
+
"args": ["{title}", "{message}"]
|
|
53
|
+
},
|
|
54
|
+
"timeoutMs": 3000
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The configuration file is read when the extension loads. Use `/config:notifications` to open the normal TUI settings menu, or use `reset` to restore defaults. Run `/reload` after saving. A missing file uses the default `terminal-notifier` configuration. A malformed file is reported in Pi and uses the default configuration.
|
|
59
|
+
|
|
60
|
+
## Default adapter
|
|
61
|
+
|
|
62
|
+
The default argv is equivalent to:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
terminal-notifier -title <title> -subtitle <subtitle> -message <message> -sound default -timeout 5
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Install it using the package manager appropriate for your operating system, or configure another notification executable. The package does not assume a specific operating system daemon.
|
|
69
|
+
|
|
70
|
+
## Localization
|
|
71
|
+
|
|
72
|
+
Runtime messages are provided in `zh-CN` and `en-US` through `pi-extensions-i18n`. Set the shared language with `/config:language` or `PI_EXTENSIONS_LOCALE`.
|
|
73
|
+
|
|
74
|
+
## License
|
|
75
|
+
|
|
76
|
+
MIT
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# pi-notifications
|
|
2
|
+
|
|
3
|
+
面向 [Pi coding agent](https://pi.dev) 的可配置外部通知扩展。
|
|
4
|
+
|
|
5
|
+
[English](./README.md)
|
|
6
|
+
|
|
7
|
+
## 功能
|
|
8
|
+
|
|
9
|
+
- 在 `ask_user_question` 或旧版 `ask_user` 工具等待输入前发送系统通知。
|
|
10
|
+
- 每次 Pi agent 运行结束后发送完成通知,包含工具是否报错和已完成的 turn 数。
|
|
11
|
+
- 使用 Node 的 argv API 执行 adapter,不经过 shell,通知文本不会被重新解析为命令语法。
|
|
12
|
+
- 运行时探测配置的命令。命令缺失或首次执行失败时,在当前会话明确提示一次,随后停用本次运行的外部通知。
|
|
13
|
+
- 通知失败不会阻塞 Pi 的 agent 生命周期或工具执行。
|
|
14
|
+
- 所有运行时文案均通过 `pi-extensions-i18n` 国际化。
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pi install npm:pi-notifications
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
然后重新加载 Pi:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
/reload
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
本包会自动安装共享的 i18n 依赖。默认 adapter 要求 `terminal-notifier` 位于 `PATH` 中。
|
|
29
|
+
|
|
30
|
+
## 配置
|
|
31
|
+
|
|
32
|
+
可选配置文件路径:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
<Pi agent 目录>/extensions/pi-notifications/config.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`<Pi agent 目录>` 是 Pi 当前配置的 agent 目录(通常为 `~/.pi/agent`)。可以从 [`config.example.json`](./config.example.json) 开始。配置字段如下:
|
|
39
|
+
|
|
40
|
+
- `enabled`:设为 `false` 可关闭外部通知。
|
|
41
|
+
- `adapter.command`:可执行文件名或路径。
|
|
42
|
+
- `adapter.args`:argv 参数数组;每个参数中的 `{title}`、`{subtitle}`、`{message}` 会被替换。
|
|
43
|
+
- `timeoutMs`:单次 adapter 执行的最长时间。
|
|
44
|
+
|
|
45
|
+
例如,直接使用另一个通知命令:
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"enabled": true,
|
|
50
|
+
"adapter": {
|
|
51
|
+
"command": "notify-send",
|
|
52
|
+
"args": ["{title}", "{message}"]
|
|
53
|
+
},
|
|
54
|
+
"timeoutMs": 3000
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
配置在扩展加载时读取。使用 `/config:notifications` 打开常规 TUI 配置菜单,输入 `reset` 恢复默认值;保存后执行 `/reload`。配置文件不存在时使用默认的 `terminal-notifier` 配置;配置格式损坏时会在 Pi 中明确提示并使用默认配置。
|
|
59
|
+
|
|
60
|
+
## 默认 adapter
|
|
61
|
+
|
|
62
|
+
默认 argv 等价于:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
terminal-notifier -title <title> -subtitle <subtitle> -message <message> -sound default -timeout 5
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
请使用适合当前操作系统的包管理器安装它,或配置其他通知可执行文件。本包不假定某个特定操作系统 daemon 存在。
|
|
69
|
+
|
|
70
|
+
## 国际化
|
|
71
|
+
|
|
72
|
+
运行时文案通过 `pi-extensions-i18n` 提供 `zh-CN` 和 `en-US`。使用 `/config:language` 或 `PI_EXTENSIONS_LOCALE` 设置共享语言。
|
|
73
|
+
|
|
74
|
+
## 许可证
|
|
75
|
+
|
|
76
|
+
MIT
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: configure-pi-notifications
|
|
3
|
+
description: 配置与排查 pi-notifications 的外部通知命令、argv 参数、超时和一次性失败提示。Use when configuring or diagnosing Pi external notifications.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 配置 pi-notifications
|
|
7
|
+
|
|
8
|
+
## 配置文件
|
|
9
|
+
|
|
10
|
+
读取实际 Pi agent 目录下的 `extensions/pi-notifications/config.json`。不存在时使用内置默认配置;不要猜测未列出的字段。可以从包内 `config.example.json` 开始。
|
|
11
|
+
|
|
12
|
+
配置字段:
|
|
13
|
+
|
|
14
|
+
- `enabled`:布尔值,默认 `true`。
|
|
15
|
+
- `adapter.command`:通知可执行文件名或路径,默认 `terminal-notifier`。
|
|
16
|
+
- `adapter.args`:argv 字符串数组;支持 `{title}`、`{subtitle}`、`{message}` 占位符。
|
|
17
|
+
- `timeoutMs`:正整数,默认 `3000`。
|
|
18
|
+
|
|
19
|
+
修改配置后执行 `/reload`。配置文件损坏会明确提示,并使用默认配置继续启动。
|
|
20
|
+
|
|
21
|
+
使用 `/config:notifications` 在 TUI 中修改完整通知配置,输入 `reset` 恢复默认值。保存后执行 `/reload`。
|
|
22
|
+
|
|
23
|
+
Use `/config:notifications` to edit the complete notification configuration in the TUI, use `reset` for defaults, and run `/reload` after saving.
|
|
24
|
+
|
|
25
|
+
## 运行行为
|
|
26
|
+
|
|
27
|
+
- `ask_user_question` 和 `ask_user` 在真正等待输入前发送通知。
|
|
28
|
+
- `agent_end` 发送完成通知,并根据 tool result 是否报错选择完成或错误标题;消息包含 turn 数和耗时。
|
|
29
|
+
- 命令通过 argv 执行,不经过 shell;不要把整条 shell 命令写入 `adapter.args`。
|
|
30
|
+
- 扩展加载时不依赖外部命令存在;第一次发送通知时探测命令。
|
|
31
|
+
- 命令缺失或第一次执行失败只提示一次,并停用当前运行的外部通知;Pi 任务继续执行,不要重试或阻塞。
|
|
32
|
+
- 运行时文案语言由 `pi-extensions-i18n` 控制。
|
|
33
|
+
|
|
34
|
+
## 排查
|
|
35
|
+
|
|
36
|
+
1. 检查配置文件的 JSON、`enabled`、命令和 argv 参数类型。
|
|
37
|
+
2. 在当前 Pi 进程的 `PATH` 中确认配置的命令可执行。
|
|
38
|
+
3. 如果看到一次“命令不可用”或“执行失败”提示,先修复命令或配置,再执行 `/reload`。
|
|
39
|
+
4. 不要读取凭据、Pi settings 或私有服务来排查通知 adapter。
|
|
40
|
+
|
|
41
|
+
## 验证
|
|
42
|
+
|
|
43
|
+
- 定向测试:`npm test --workspace pi-notifications`。
|
|
44
|
+
- 类型检查:`npm run typecheck --workspace pi-notifications`。
|
|
45
|
+
- 手动 TUI 验证:在 agent 触发一次 `ask_user_question`,再完成一次普通任务;确认系统通知出现且 Pi 不被阻塞。
|
|
46
|
+
- 缺少通知命令、命令退出失败和超时属于预期降级场景;确认每种场景只出现一次明确提示。
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"enabled": true,
|
|
3
|
+
"adapter": {
|
|
4
|
+
"command": "terminal-notifier",
|
|
5
|
+
"args": [
|
|
6
|
+
"-title",
|
|
7
|
+
"{title}",
|
|
8
|
+
"-subtitle",
|
|
9
|
+
"{subtitle}",
|
|
10
|
+
"-message",
|
|
11
|
+
"{message}",
|
|
12
|
+
"-sound",
|
|
13
|
+
"default",
|
|
14
|
+
"-timeout",
|
|
15
|
+
"5"
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"timeoutMs": 3000
|
|
19
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
{
|
|
2
|
+
"inputTitle": {
|
|
3
|
+
"zh-CN": "Pi · {project} 💬",
|
|
4
|
+
"en-US": "Pi · {project} 💬"
|
|
5
|
+
},
|
|
6
|
+
"inputNeeded": {
|
|
7
|
+
"zh-CN": "需要你的输入",
|
|
8
|
+
"en-US": "Input required"
|
|
9
|
+
},
|
|
10
|
+
"multipleQuestions": {
|
|
11
|
+
"zh-CN": "{prefix}{question} …等 {count} 个问题",
|
|
12
|
+
"en-US": "{prefix}{question} …and {count} more question(s)"
|
|
13
|
+
},
|
|
14
|
+
"taskTitle": {
|
|
15
|
+
"zh-CN": "Pi · {project}",
|
|
16
|
+
"en-US": "Pi · {project}"
|
|
17
|
+
},
|
|
18
|
+
"taskError": {
|
|
19
|
+
"zh-CN": "⚠️ 执行完成(有错误)",
|
|
20
|
+
"en-US": "⚠️ Finished with errors"
|
|
21
|
+
},
|
|
22
|
+
"taskDone": {
|
|
23
|
+
"zh-CN": "✅ 执行完成",
|
|
24
|
+
"en-US": "✅ Finished"
|
|
25
|
+
},
|
|
26
|
+
"stepCount": {
|
|
27
|
+
"zh-CN": "共 {count} 步,耗时 {seconds}s",
|
|
28
|
+
"en-US": "{count} steps, took {seconds}s"
|
|
29
|
+
},
|
|
30
|
+
"elapsed": {
|
|
31
|
+
"zh-CN": "耗时 {seconds}s",
|
|
32
|
+
"en-US": "Took {seconds}s"
|
|
33
|
+
},
|
|
34
|
+
"adapterMissing": {
|
|
35
|
+
"zh-CN": "通知 adapter 命令不可用:{command}。本次会话已停用系统通知。",
|
|
36
|
+
"en-US": "Notification adapter command is unavailable: {command}. System notifications are disabled for this session."
|
|
37
|
+
},
|
|
38
|
+
"sendFailed": {
|
|
39
|
+
"zh-CN": "通知 adapter 执行失败:{command}({reason})。本次会话已停用系统通知。",
|
|
40
|
+
"en-US": "Notification adapter failed: {command} ({reason}). System notifications are disabled for this session."
|
|
41
|
+
},
|
|
42
|
+
"configInvalid": {
|
|
43
|
+
"zh-CN": "通知配置读取失败:{path}({reason})。已使用默认配置。",
|
|
44
|
+
"en-US": "Failed to read notification config: {path} ({reason}). Default configuration is being used."
|
|
45
|
+
},
|
|
46
|
+
"configCommandDescription": {
|
|
47
|
+
"zh-CN": "配置系统通知",
|
|
48
|
+
"en-US": "Configure system notifications"
|
|
49
|
+
},
|
|
50
|
+
"configCommandUsage": {
|
|
51
|
+
"zh-CN": "用法:/config:notifications(打开配置菜单)或 /config:notifications reset(恢复默认值)",
|
|
52
|
+
"en-US": "Usage: /config:notifications (open settings) or /config:notifications reset (restore defaults)"
|
|
53
|
+
},
|
|
54
|
+
"configMenuTitle": {
|
|
55
|
+
"zh-CN": "通知配置",
|
|
56
|
+
"en-US": "Notification settings"
|
|
57
|
+
},
|
|
58
|
+
"configEnabled": {
|
|
59
|
+
"zh-CN": "启用通知:{value}",
|
|
60
|
+
"en-US": "Notifications enabled: {value}"
|
|
61
|
+
},
|
|
62
|
+
"configCommand": {
|
|
63
|
+
"zh-CN": "通知命令:{value}",
|
|
64
|
+
"en-US": "Notification command: {value}"
|
|
65
|
+
},
|
|
66
|
+
"configArguments": {
|
|
67
|
+
"zh-CN": "命令参数:{value}",
|
|
68
|
+
"en-US": "Command arguments: {value}"
|
|
69
|
+
},
|
|
70
|
+
"configTimeout": {
|
|
71
|
+
"zh-CN": "超时:{value} ms",
|
|
72
|
+
"en-US": "Timeout: {value} ms"
|
|
73
|
+
},
|
|
74
|
+
"configOn": {
|
|
75
|
+
"zh-CN": "开",
|
|
76
|
+
"en-US": "on"
|
|
77
|
+
},
|
|
78
|
+
"configOff": {
|
|
79
|
+
"zh-CN": "关",
|
|
80
|
+
"en-US": "off"
|
|
81
|
+
},
|
|
82
|
+
"configDone": {
|
|
83
|
+
"zh-CN": "完成",
|
|
84
|
+
"en-US": "Done"
|
|
85
|
+
},
|
|
86
|
+
"configCommandInput": {
|
|
87
|
+
"zh-CN": "通知命令路径",
|
|
88
|
+
"en-US": "Notification command path"
|
|
89
|
+
},
|
|
90
|
+
"configArgumentsInput": {
|
|
91
|
+
"zh-CN": "命令参数(多个参数用逗号分隔)",
|
|
92
|
+
"en-US": "Command arguments (separate arguments with commas)"
|
|
93
|
+
},
|
|
94
|
+
"configTimeoutInput": {
|
|
95
|
+
"zh-CN": "超时毫秒数(正整数)",
|
|
96
|
+
"en-US": "Timeout in milliseconds (positive integer)"
|
|
97
|
+
},
|
|
98
|
+
"configPresetValue": {
|
|
99
|
+
"zh-CN": "常用值:{value}",
|
|
100
|
+
"en-US": "Common value: {value}"
|
|
101
|
+
},
|
|
102
|
+
"configCustom": {
|
|
103
|
+
"zh-CN": "自定义…",
|
|
104
|
+
"en-US": "Custom…"
|
|
105
|
+
},
|
|
106
|
+
"configCancel": {
|
|
107
|
+
"zh-CN": "取消",
|
|
108
|
+
"en-US": "Cancel"
|
|
109
|
+
},
|
|
110
|
+
"configCommandInteractiveOnly": {
|
|
111
|
+
"zh-CN": "通知配置需要在 TUI 中修改;请在交互式 Pi 会话中执行命令。",
|
|
112
|
+
"en-US": "Notification configuration requires the TUI; run this command in an interactive Pi session."
|
|
113
|
+
},
|
|
114
|
+
"configCommandSaved": {
|
|
115
|
+
"zh-CN": "通知配置已保存:{path}。请执行 /reload 使配置生效。",
|
|
116
|
+
"en-US": "Notification configuration saved to {path}. Run /reload to apply it."
|
|
117
|
+
},
|
|
118
|
+
"configCommandInvalid": {
|
|
119
|
+
"zh-CN": "通知配置无效:{error}",
|
|
120
|
+
"en-US": "Invalid notification configuration: {error}"
|
|
121
|
+
}
|
|
122
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-notifications",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Configurable external notifications for Pi agent lifecycle events",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"src",
|
|
13
|
+
"locales",
|
|
14
|
+
"config.example.json",
|
|
15
|
+
"README.md",
|
|
16
|
+
"README.zh-CN.md",
|
|
17
|
+
"SKILL.md",
|
|
18
|
+
"tsconfig.json"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"test": "tsx --test tests/*.test.ts",
|
|
22
|
+
"typecheck": "tsc --noEmit --pretty false",
|
|
23
|
+
"build": "npm run typecheck",
|
|
24
|
+
"check": "npm run typecheck && npm test && npm pack --dry-run --json > /dev/null"
|
|
25
|
+
},
|
|
26
|
+
"pi": {
|
|
27
|
+
"extensions": [
|
|
28
|
+
"./index.ts",
|
|
29
|
+
"../pi-extensions-i18n/index.ts"
|
|
30
|
+
],
|
|
31
|
+
"skills": [
|
|
32
|
+
"./SKILL.md"
|
|
33
|
+
]
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"license": "MIT",
|
|
39
|
+
"author": "maplezzk",
|
|
40
|
+
"homepage": "https://github.com/maplezzk/pi-extensions/tree/main/packages/pi-notifications",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/maplezzk/pi-extensions/issues"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/maplezzk/pi-extensions.git",
|
|
47
|
+
"directory": "packages/pi-notifications"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public",
|
|
51
|
+
"registry": "https://registry.npmjs.org"
|
|
52
|
+
},
|
|
53
|
+
"keywords": [
|
|
54
|
+
"pi-package",
|
|
55
|
+
"pi",
|
|
56
|
+
"pi-extension",
|
|
57
|
+
"coding-agent",
|
|
58
|
+
"notifications",
|
|
59
|
+
"terminal-notifier"
|
|
60
|
+
],
|
|
61
|
+
"peerDependencies": {
|
|
62
|
+
"@earendil-works/pi-coding-agent": ">=0.80.0 <0.81.0"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"pi-extensions-i18n": "^0.4.0"
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@earendil-works/pi-coding-agent": "0.80.10",
|
|
69
|
+
"@types/node": "24.12.4",
|
|
70
|
+
"tsx": "4.23.1",
|
|
71
|
+
"typescript": "5.9.3"
|
|
72
|
+
}
|
|
73
|
+
}
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { accessSync, constants } from "node:fs";
|
|
2
|
+
import { isAbsolute, resolve } from "node:path";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
|
|
5
|
+
export interface NotificationPayload {
|
|
6
|
+
title: string;
|
|
7
|
+
subtitle: string;
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface NotificationAdapterConfig {
|
|
12
|
+
command: string;
|
|
13
|
+
args: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type NotificationFailureKind = "missing" | "failed";
|
|
17
|
+
|
|
18
|
+
export interface NotificationFailure {
|
|
19
|
+
kind: NotificationFailureKind;
|
|
20
|
+
command: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type CommandAvailability = (command: string) => boolean;
|
|
25
|
+
export type RunArgvCommand = (command: string, args: string[], timeoutMs: number) => Promise<void>;
|
|
26
|
+
|
|
27
|
+
export interface NotificationAdapterOptions {
|
|
28
|
+
isCommandAvailable?: CommandAvailability;
|
|
29
|
+
run?: RunArgvCommand;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NotificationAdapter {
|
|
33
|
+
send(payload: NotificationPayload): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const WINDOWS_DEFAULT_EXTENSIONS = [".COM", ".EXE", ".BAT", ".CMD"];
|
|
37
|
+
|
|
38
|
+
/** 把通知内容按 argv 参数模板展开,不经过 shell。 */
|
|
39
|
+
export function renderNotificationArgs(
|
|
40
|
+
args: readonly string[],
|
|
41
|
+
payload: NotificationPayload,
|
|
42
|
+
): string[] {
|
|
43
|
+
const values: Record<string, string> = {
|
|
44
|
+
title: payload.title,
|
|
45
|
+
subtitle: payload.subtitle,
|
|
46
|
+
message: payload.message,
|
|
47
|
+
};
|
|
48
|
+
return args.map((arg) => arg.replace(/\{(title|subtitle|message)\}/g, (_, key: string) => values[key]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 检查命令是否能在当前运行时找到,避免调用 command -v 或其他 shell。 */
|
|
52
|
+
export function isCommandAvailable(command: string): boolean {
|
|
53
|
+
const trimmed = command.trim();
|
|
54
|
+
if (!trimmed) return false;
|
|
55
|
+
|
|
56
|
+
if (isAbsolute(trimmed) || trimmed.includes("/") || trimmed.includes("\\")) {
|
|
57
|
+
return canExecute(trimmed);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const pathValue = process.env.PATH ?? "";
|
|
61
|
+
const candidates = pathValue.split(process.platform === "win32" ? ";" : ":");
|
|
62
|
+
const extensions = process.platform === "win32" ? windowsExtensions(trimmed) : [""];
|
|
63
|
+
return candidates.some((directory) => {
|
|
64
|
+
if (!directory) return false;
|
|
65
|
+
return extensions.some((extension) => canExecute(resolve(directory, `${trimmed}${extension}`)));
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function windowsExtensions(command: string): string[] {
|
|
70
|
+
if (command.includes(".")) return [""];
|
|
71
|
+
const configured = process.env.PATHEXT
|
|
72
|
+
?.split(";")
|
|
73
|
+
.map((value) => value.trim())
|
|
74
|
+
.filter(Boolean);
|
|
75
|
+
return configured && configured.length > 0 ? configured : WINDOWS_DEFAULT_EXTENSIONS;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function canExecute(path: string): boolean {
|
|
79
|
+
try {
|
|
80
|
+
accessSync(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 使用 Node spawn 的 argv 形式执行外部通知命令。 */
|
|
88
|
+
export function runArgvCommand(
|
|
89
|
+
command: string,
|
|
90
|
+
args: string[],
|
|
91
|
+
timeoutMs: number,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
94
|
+
let settled = false;
|
|
95
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
96
|
+
|
|
97
|
+
const finish = (error?: Error): void => {
|
|
98
|
+
if (settled) return;
|
|
99
|
+
settled = true;
|
|
100
|
+
if (timeoutHandle !== undefined) clearTimeout(timeoutHandle);
|
|
101
|
+
if (error) rejectPromise(error);
|
|
102
|
+
else resolvePromise();
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let child;
|
|
106
|
+
try {
|
|
107
|
+
child = spawn(command, args, {
|
|
108
|
+
shell: false,
|
|
109
|
+
stdio: "ignore",
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
} catch (error) {
|
|
113
|
+
finish(toError(error));
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
timeoutHandle = setTimeout(() => {
|
|
118
|
+
child.kill();
|
|
119
|
+
finish(new Error(`command timed out after ${timeoutMs}ms`));
|
|
120
|
+
}, timeoutMs);
|
|
121
|
+
|
|
122
|
+
child.once("error", (error) => finish(toError(error)));
|
|
123
|
+
child.once("close", (code, signal) => {
|
|
124
|
+
if (code === 0) {
|
|
125
|
+
finish();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const status = signal ? `signal ${signal}` : `exit code ${String(code)}`;
|
|
129
|
+
finish(new Error(`command failed with ${status}`));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function toError(error: unknown): Error {
|
|
135
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 创建一次性失效的 argv 通知 adapter。
|
|
140
|
+
* 第一次缺少命令或执行失败后停止后续尝试,由调用方负责展示一次提示。
|
|
141
|
+
*/
|
|
142
|
+
export function createArgvNotificationAdapter(
|
|
143
|
+
config: NotificationAdapterConfig,
|
|
144
|
+
timeoutMs: number,
|
|
145
|
+
options: NotificationAdapterOptions = {},
|
|
146
|
+
): NotificationAdapter {
|
|
147
|
+
const checkCommand = options.isCommandAvailable ?? isCommandAvailable;
|
|
148
|
+
const run = options.run ?? runArgvCommand;
|
|
149
|
+
let disabled = false;
|
|
150
|
+
let commandChecked = false;
|
|
151
|
+
let commandAvailable = false;
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
async send(payload) {
|
|
155
|
+
if (disabled) return;
|
|
156
|
+
|
|
157
|
+
if (!commandChecked) {
|
|
158
|
+
commandChecked = true;
|
|
159
|
+
commandAvailable = checkCommand(config.command);
|
|
160
|
+
}
|
|
161
|
+
if (!commandAvailable) {
|
|
162
|
+
disabled = true;
|
|
163
|
+
throw <NotificationFailure>{
|
|
164
|
+
kind: "missing",
|
|
165
|
+
command: config.command,
|
|
166
|
+
reason: "command not found in PATH",
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await run(config.command, renderNotificationArgs(config.args, payload), timeoutMs);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
disabled = true;
|
|
174
|
+
throw <NotificationFailure>{
|
|
175
|
+
kind: "failed",
|
|
176
|
+
command: config.command,
|
|
177
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { NotificationAdapterConfig } from "./adapter.ts";
|
|
5
|
+
|
|
6
|
+
export interface NotificationConfig {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
adapter: NotificationAdapterConfig;
|
|
9
|
+
timeoutMs: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ConfigDiagnostic {
|
|
13
|
+
path: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface LoadedNotificationConfig {
|
|
18
|
+
config: NotificationConfig;
|
|
19
|
+
diagnostic?: ConfigDiagnostic;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_NOTIFICATION_CONFIG: NotificationConfig = {
|
|
23
|
+
enabled: true,
|
|
24
|
+
adapter: {
|
|
25
|
+
command: "terminal-notifier",
|
|
26
|
+
args: [
|
|
27
|
+
"-title",
|
|
28
|
+
"{title}",
|
|
29
|
+
"-subtitle",
|
|
30
|
+
"{subtitle}",
|
|
31
|
+
"-message",
|
|
32
|
+
"{message}",
|
|
33
|
+
"-sound",
|
|
34
|
+
"default",
|
|
35
|
+
"-timeout",
|
|
36
|
+
"5",
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
timeoutMs: 3000,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
43
|
+
const CONFIG_DIRECTORY_NAME = "pi-notifications";
|
|
44
|
+
|
|
45
|
+
/** 返回当前 Pi agent 目录下的通知配置路径。 */
|
|
46
|
+
export function configPath(): string {
|
|
47
|
+
return join(getAgentDir(), "extensions", CONFIG_DIRECTORY_NAME, CONFIG_FILE_NAME);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 将经过校验的通知配置写入配置文件。 */
|
|
51
|
+
export function saveConfig(config: NotificationConfig, path = configPath()): string {
|
|
52
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
53
|
+
writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
54
|
+
return path;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 读取通知配置;文件缺失时使用默认配置。 */
|
|
58
|
+
export function loadConfig(): NotificationConfig {
|
|
59
|
+
return loadConfigWithDiagnostics().config;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 读取通知配置,并保留损坏配置的诊断信息供 UI 一次提示。 */
|
|
63
|
+
export function loadConfigWithDiagnostics(): LoadedNotificationConfig {
|
|
64
|
+
const path = configPath();
|
|
65
|
+
let raw: string;
|
|
66
|
+
try {
|
|
67
|
+
raw = readFileSync(path, "utf8");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (isMissingFile(error)) return { config: cloneDefaultConfig() };
|
|
70
|
+
return {
|
|
71
|
+
config: cloneDefaultConfig(),
|
|
72
|
+
diagnostic: { path, reason: errorMessage(error) },
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
return { config: parseConfig(JSON.parse(raw)) };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return {
|
|
80
|
+
config: cloneDefaultConfig(),
|
|
81
|
+
diagnostic: { path, reason: errorMessage(error) },
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 解析并校验通知配置,供文件加载和配置命令共同使用。 */
|
|
87
|
+
export function parseConfig(value: unknown): NotificationConfig {
|
|
88
|
+
if (!isRecord(value)) throw new Error("configuration must be a JSON object");
|
|
89
|
+
|
|
90
|
+
const enabled = value.enabled === undefined ? true : value.enabled;
|
|
91
|
+
if (typeof enabled !== "boolean") throw new Error("enabled must be a boolean");
|
|
92
|
+
|
|
93
|
+
const adapterValue = value.adapter === undefined
|
|
94
|
+
? DEFAULT_NOTIFICATION_CONFIG.adapter
|
|
95
|
+
: value.adapter;
|
|
96
|
+
if (!isRecord(adapterValue)) throw new Error("adapter must be an object");
|
|
97
|
+
|
|
98
|
+
const command = adapterValue.command;
|
|
99
|
+
if (typeof command !== "string" || command.trim() === "") {
|
|
100
|
+
throw new Error("adapter.command must be a non-empty string");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const args = adapterValue.args;
|
|
104
|
+
if (!Array.isArray(args) || args.some((arg) => typeof arg !== "string")) {
|
|
105
|
+
throw new Error("adapter.args must be an array of strings");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const timeoutMs = value.timeoutMs === undefined
|
|
109
|
+
? DEFAULT_NOTIFICATION_CONFIG.timeoutMs
|
|
110
|
+
: value.timeoutMs;
|
|
111
|
+
if (
|
|
112
|
+
typeof timeoutMs !== "number" ||
|
|
113
|
+
!Number.isInteger(timeoutMs) ||
|
|
114
|
+
timeoutMs <= 0
|
|
115
|
+
) {
|
|
116
|
+
throw new Error("timeoutMs must be a positive integer");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
enabled,
|
|
121
|
+
adapter: { command: command.trim(), args: [...args] },
|
|
122
|
+
timeoutMs,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function cloneDefaultConfig(): NotificationConfig {
|
|
127
|
+
return {
|
|
128
|
+
enabled: DEFAULT_NOTIFICATION_CONFIG.enabled,
|
|
129
|
+
adapter: {
|
|
130
|
+
command: DEFAULT_NOTIFICATION_CONFIG.adapter.command,
|
|
131
|
+
args: [...DEFAULT_NOTIFICATION_CONFIG.adapter.args],
|
|
132
|
+
},
|
|
133
|
+
timeoutMs: DEFAULT_NOTIFICATION_CONFIG.timeoutMs,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isRecord(value: unknown): value is Record<string, any> {
|
|
138
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isMissingFile(error: unknown): boolean {
|
|
142
|
+
return (error as NodeJS.ErrnoException).code === "ENOENT";
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function errorMessage(error: unknown): string {
|
|
146
|
+
return error instanceof Error ? error.message : String(error);
|
|
147
|
+
}
|
package/src/i18n.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { basename } from "node:path";
|
|
7
|
+
import {
|
|
8
|
+
createArgvNotificationAdapter,
|
|
9
|
+
type NotificationAdapter,
|
|
10
|
+
type NotificationFailure,
|
|
11
|
+
type NotificationPayload,
|
|
12
|
+
} from "./adapter.ts";
|
|
13
|
+
import { loadConfigWithDiagnostics, parseConfig, saveConfig, type NotificationConfig } from "./config.ts";
|
|
14
|
+
import { i18n } from "./i18n.ts";
|
|
15
|
+
|
|
16
|
+
export type { NotificationConfig } from "./config.ts";
|
|
17
|
+
export type {
|
|
18
|
+
CommandAvailability,
|
|
19
|
+
NotificationAdapter,
|
|
20
|
+
NotificationAdapterConfig,
|
|
21
|
+
NotificationAdapterOptions,
|
|
22
|
+
NotificationFailure,
|
|
23
|
+
NotificationFailureKind,
|
|
24
|
+
NotificationPayload,
|
|
25
|
+
RunArgvCommand,
|
|
26
|
+
} from "./adapter.ts";
|
|
27
|
+
export {
|
|
28
|
+
createArgvNotificationAdapter,
|
|
29
|
+
isCommandAvailable,
|
|
30
|
+
renderNotificationArgs,
|
|
31
|
+
runArgvCommand,
|
|
32
|
+
} from "./adapter.ts";
|
|
33
|
+
export { configPath, loadConfig, loadConfigWithDiagnostics, parseConfig, saveConfig } from "./config.ts";
|
|
34
|
+
|
|
35
|
+
interface Notice {
|
|
36
|
+
level: "warning";
|
|
37
|
+
message: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface NotificationRuntime {
|
|
41
|
+
config: NotificationConfig;
|
|
42
|
+
adapter: NotificationAdapter | null;
|
|
43
|
+
failureReported: boolean;
|
|
44
|
+
context?: ExtensionContext;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const CONFIG_COMMAND_ALIASES = ["config:notifications", "notifications-config", "pi-notifications-config"] as const;
|
|
48
|
+
const CONFIG_RESET_COMMAND = "reset";
|
|
49
|
+
const CONFIG_OPTION = { enabled: 0, command: 1, args: 2, timeout: 3 } as const;
|
|
50
|
+
const CONFIG_ARG_SEPARATOR = ",";
|
|
51
|
+
const CONFIG_ARG_DISPLAY_SEPARATOR = ", ";
|
|
52
|
+
const COMMAND_PRESETS = ["terminal-notifier", "notify-send"] as const;
|
|
53
|
+
const TIMEOUT_PRESETS = ["1000", "3000", "5000"] as const;
|
|
54
|
+
const NOTICE_WARNING = "warning" as const;
|
|
55
|
+
const NOTICE_INFO = "info" as const;
|
|
56
|
+
const NOTICE_ERROR = "error" as const;
|
|
57
|
+
|
|
58
|
+
const pendingNotices: Notice[] = [];
|
|
59
|
+
let activeRuntime: NotificationRuntime | undefined;
|
|
60
|
+
let noticeKeys = new Set<string>();
|
|
61
|
+
|
|
62
|
+
/** 注册配置命令,通过 TUI 菜单和输入框修改通知配置。 */
|
|
63
|
+
function registerConfigCommand(pi: ExtensionAPI): void {
|
|
64
|
+
const command = {
|
|
65
|
+
description: i18n.t("configCommandDescription"),
|
|
66
|
+
getArgumentCompletions: () => [{ value: CONFIG_RESET_COMMAND, label: CONFIG_RESET_COMMAND }],
|
|
67
|
+
handler: async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
68
|
+
const argument = args.trim();
|
|
69
|
+
if (argument && argument !== CONFIG_RESET_COMMAND) {
|
|
70
|
+
ctx.ui.notify(i18n.t("configCommandUsage"), NOTICE_WARNING);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (argument === CONFIG_RESET_COMMAND) {
|
|
74
|
+
try {
|
|
75
|
+
const path = saveConfig(parseConfig({}));
|
|
76
|
+
ctx.ui.notify(i18n.t("configCommandSaved", { path }), NOTICE_INFO);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
ctx.ui.notify(i18n.t("configCommandInvalid", {
|
|
79
|
+
error: error instanceof Error ? error.message : String(error),
|
|
80
|
+
}), NOTICE_ERROR);
|
|
81
|
+
}
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!ctx.hasUI) {
|
|
85
|
+
ctx.ui.notify(i18n.t("configCommandInteractiveOnly"), NOTICE_WARNING);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let config: NotificationConfig;
|
|
90
|
+
try {
|
|
91
|
+
config = loadConfigWithDiagnostics().config;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
ctx.ui.notify(i18n.t("configCommandInvalid", {
|
|
94
|
+
error: error instanceof Error ? error.message : String(error),
|
|
95
|
+
}), NOTICE_ERROR);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const save = (next: NotificationConfig): void => {
|
|
99
|
+
try {
|
|
100
|
+
const path = saveConfig(next);
|
|
101
|
+
config = next;
|
|
102
|
+
ctx.ui.notify(i18n.t("configCommandSaved", { path }), NOTICE_INFO);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
ctx.ui.notify(i18n.t("configCommandInvalid", {
|
|
105
|
+
error: error instanceof Error ? error.message : String(error),
|
|
106
|
+
}), NOTICE_ERROR);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
const status = (enabled: boolean): string => enabled ? i18n.t("configOn") : i18n.t("configOff");
|
|
110
|
+
|
|
111
|
+
/** Selects a common scalar value and only opens text input for custom values. */
|
|
112
|
+
const chooseSettingValue = async (
|
|
113
|
+
title: string,
|
|
114
|
+
current: string,
|
|
115
|
+
options: readonly string[],
|
|
116
|
+
): Promise<string | undefined> => {
|
|
117
|
+
const customChoice = i18n.t("configCustom");
|
|
118
|
+
const cancelChoice = i18n.t("configCancel");
|
|
119
|
+
const choices = [
|
|
120
|
+
...options.map((value) => i18n.t("configPresetValue", { value })),
|
|
121
|
+
customChoice,
|
|
122
|
+
cancelChoice,
|
|
123
|
+
];
|
|
124
|
+
const selected = await ctx.ui.select(title, choices);
|
|
125
|
+
if (selected === undefined || selected === cancelChoice) return undefined;
|
|
126
|
+
if (selected === customChoice) return ctx.ui.input(title, current);
|
|
127
|
+
const index = choices.indexOf(selected);
|
|
128
|
+
return index >= 0 && index < options.length ? options[index] : undefined;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
while (true) {
|
|
132
|
+
const doneChoice = i18n.t("configDone");
|
|
133
|
+
const choices = [
|
|
134
|
+
i18n.t("configEnabled", { value: status(config.enabled) }),
|
|
135
|
+
i18n.t("configCommand", { value: config.adapter.command }),
|
|
136
|
+
i18n.t("configArguments", { value: config.adapter.args.join(CONFIG_ARG_DISPLAY_SEPARATOR) }),
|
|
137
|
+
i18n.t("configTimeout", { value: config.timeoutMs }),
|
|
138
|
+
doneChoice,
|
|
139
|
+
];
|
|
140
|
+
const selected = await ctx.ui.select(i18n.t("configMenuTitle"), choices);
|
|
141
|
+
if (selected === undefined || selected === doneChoice) return;
|
|
142
|
+
const selectedIndex = choices.indexOf(selected);
|
|
143
|
+
let next: NotificationConfig | undefined;
|
|
144
|
+
if (selectedIndex === CONFIG_OPTION.enabled) {
|
|
145
|
+
next = parseConfig({ ...config, enabled: !config.enabled });
|
|
146
|
+
} else {
|
|
147
|
+
let title = i18n.t("configTimeoutInput");
|
|
148
|
+
let current = String(config.timeoutMs);
|
|
149
|
+
let options: readonly string[] = TIMEOUT_PRESETS;
|
|
150
|
+
if (selectedIndex === CONFIG_OPTION.command) {
|
|
151
|
+
title = i18n.t("configCommandInput");
|
|
152
|
+
current = config.adapter.command;
|
|
153
|
+
options = COMMAND_PRESETS;
|
|
154
|
+
} else if (selectedIndex === CONFIG_OPTION.args) {
|
|
155
|
+
title = i18n.t("configArgumentsInput");
|
|
156
|
+
current = config.adapter.args.join(CONFIG_ARG_DISPLAY_SEPARATOR);
|
|
157
|
+
options = [config.adapter.args.join(CONFIG_ARG_DISPLAY_SEPARATOR)];
|
|
158
|
+
}
|
|
159
|
+
const input = await chooseSettingValue(title, current, options);
|
|
160
|
+
if (input === undefined) continue;
|
|
161
|
+
const adapter = { ...config.adapter };
|
|
162
|
+
if (selectedIndex === CONFIG_OPTION.command) adapter.command = input.trim();
|
|
163
|
+
else if (selectedIndex === CONFIG_OPTION.args) {
|
|
164
|
+
adapter.args = input.split(CONFIG_ARG_SEPARATOR).map((arg) => arg.trim()).filter(Boolean);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
const timeoutMs = Number(input.trim());
|
|
168
|
+
try { next = parseConfig({ ...config, timeoutMs }); }
|
|
169
|
+
catch (error) {
|
|
170
|
+
ctx.ui.notify(i18n.t("configCommandInvalid", {
|
|
171
|
+
error: error instanceof Error ? error.message : String(error),
|
|
172
|
+
}), NOTICE_ERROR);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (selectedIndex === CONFIG_OPTION.command || selectedIndex === CONFIG_OPTION.args) {
|
|
176
|
+
try { next = parseConfig({ ...config, adapter }); }
|
|
177
|
+
catch (error) {
|
|
178
|
+
ctx.ui.notify(i18n.t("configCommandInvalid", {
|
|
179
|
+
error: error instanceof Error ? error.message : String(error),
|
|
180
|
+
}), NOTICE_ERROR);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (next) save(next);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
for (const name of CONFIG_COMMAND_ALIASES) pi.registerCommand(name, command);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** 导出给其他扩展使用;通知发送在后台执行,不阻塞当前 Pi 事件。 */
|
|
192
|
+
export function notify(title: string, subtitle: string, message: string): void {
|
|
193
|
+
const runtime = activeRuntime ?? createRuntime();
|
|
194
|
+
activeRuntime = runtime;
|
|
195
|
+
dispatchNotification(runtime, { title, subtitle, message });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export default function piNotifications(pi: ExtensionAPI): void {
|
|
199
|
+
registerConfigCommand(pi);
|
|
200
|
+
const runtime = createRuntime();
|
|
201
|
+
let turnCount = 0;
|
|
202
|
+
let taskStartTime = 0;
|
|
203
|
+
activeRuntime = runtime;
|
|
204
|
+
|
|
205
|
+
pi.on("session_start", (_event, ctx) => {
|
|
206
|
+
runtime.context = ctx;
|
|
207
|
+
flushPendingNotices(ctx);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
pi.on("session_shutdown", () => {
|
|
211
|
+
runtime.context = undefined;
|
|
212
|
+
if (activeRuntime === runtime) activeRuntime = undefined;
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
216
|
+
runtime.context = ctx;
|
|
217
|
+
if (event.toolName !== "ask_user_question" && event.toolName !== "ask_user") return;
|
|
218
|
+
|
|
219
|
+
const project = basename(ctx.cwd);
|
|
220
|
+
const input = isRecord(event.input) ? event.input : {};
|
|
221
|
+
let prompt = i18n.t("inputNeeded");
|
|
222
|
+
|
|
223
|
+
if (event.toolName === "ask_user_question" && Array.isArray(input.questions)) {
|
|
224
|
+
const firstQuestion = isRecord(input.questions[0]) ? input.questions[0] : {};
|
|
225
|
+
const header = asString(firstQuestion.header);
|
|
226
|
+
const question = asString(firstQuestion.question);
|
|
227
|
+
const count = input.questions.length;
|
|
228
|
+
prompt = count > 1
|
|
229
|
+
? i18n.t("multipleQuestions", {
|
|
230
|
+
prefix: header ? `[${header}] ` : "",
|
|
231
|
+
question,
|
|
232
|
+
count,
|
|
233
|
+
})
|
|
234
|
+
: `${header ? `[${header}] ` : ""}${question}`;
|
|
235
|
+
} else if (event.toolName === "ask_user") {
|
|
236
|
+
prompt = asString(input.question) || i18n.t("inputNeeded");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
dispatchNotification(runtime, {
|
|
240
|
+
title: i18n.t("inputTitle", { project }),
|
|
241
|
+
subtitle: i18n.t("inputNeeded"),
|
|
242
|
+
message: prompt,
|
|
243
|
+
}, ctx);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
247
|
+
runtime.context = ctx;
|
|
248
|
+
turnCount = 0;
|
|
249
|
+
taskStartTime = Date.now();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
253
|
+
runtime.context = ctx;
|
|
254
|
+
turnCount++;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
pi.on("agent_end", async (event, ctx) => {
|
|
258
|
+
runtime.context = ctx;
|
|
259
|
+
const elapsed = taskStartTime
|
|
260
|
+
? ((Date.now() - taskStartTime) / 1000).toFixed(1)
|
|
261
|
+
: "?";
|
|
262
|
+
const hasError = event.messages?.some(
|
|
263
|
+
(message: any) => message.role === "tool" && message.isError,
|
|
264
|
+
);
|
|
265
|
+
const project = basename(ctx.cwd);
|
|
266
|
+
const message = turnCount > 0
|
|
267
|
+
? i18n.t("stepCount", {
|
|
268
|
+
count: turnCount,
|
|
269
|
+
seconds: elapsed,
|
|
270
|
+
})
|
|
271
|
+
: i18n.t("elapsed", { seconds: elapsed });
|
|
272
|
+
|
|
273
|
+
dispatchNotification(runtime, {
|
|
274
|
+
title: i18n.t("taskTitle", { project }),
|
|
275
|
+
subtitle: hasError ? i18n.t("taskError") : i18n.t("taskDone"),
|
|
276
|
+
message,
|
|
277
|
+
}, ctx);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function createRuntime(): NotificationRuntime {
|
|
282
|
+
const loaded = loadConfigWithDiagnostics();
|
|
283
|
+
const runtime: NotificationRuntime = {
|
|
284
|
+
config: loaded.config,
|
|
285
|
+
adapter: loaded.config.enabled
|
|
286
|
+
? createArgvNotificationAdapter(
|
|
287
|
+
loaded.config.adapter,
|
|
288
|
+
loaded.config.timeoutMs,
|
|
289
|
+
)
|
|
290
|
+
: null,
|
|
291
|
+
failureReported: false,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (loaded.diagnostic) {
|
|
295
|
+
queueNotice(
|
|
296
|
+
"config",
|
|
297
|
+
i18n.t("configInvalid", {
|
|
298
|
+
path: loaded.diagnostic.path,
|
|
299
|
+
reason: loaded.diagnostic.reason,
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return runtime;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function dispatchNotification(
|
|
307
|
+
runtime: NotificationRuntime,
|
|
308
|
+
payload: NotificationPayload,
|
|
309
|
+
context?: ExtensionContext,
|
|
310
|
+
): void {
|
|
311
|
+
if (!runtime.adapter) return;
|
|
312
|
+
|
|
313
|
+
void runtime.adapter.send(payload).catch((error: unknown) => {
|
|
314
|
+
reportAdapterFailure(runtime, error, context ?? runtime.context);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function reportAdapterFailure(
|
|
319
|
+
runtime: NotificationRuntime,
|
|
320
|
+
error: unknown,
|
|
321
|
+
context?: ExtensionContext,
|
|
322
|
+
): void {
|
|
323
|
+
if (runtime.failureReported) return;
|
|
324
|
+
runtime.failureReported = true;
|
|
325
|
+
|
|
326
|
+
const failure = asNotificationFailure(error);
|
|
327
|
+
const message = failure?.kind === "missing"
|
|
328
|
+
? i18n.t("adapterMissing", { command: failure.command })
|
|
329
|
+
: i18n.t("sendFailed", {
|
|
330
|
+
command: failure?.command ?? runtime.config.adapter.command,
|
|
331
|
+
reason: failure?.reason ?? errorMessage(error),
|
|
332
|
+
});
|
|
333
|
+
showNotice({ level: "warning", message }, context);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function asNotificationFailure(error: unknown): NotificationFailure | undefined {
|
|
337
|
+
if (!isRecord(error)) return undefined;
|
|
338
|
+
if (
|
|
339
|
+
(error.kind === "missing" || error.kind === "failed") &&
|
|
340
|
+
typeof error.command === "string" &&
|
|
341
|
+
typeof error.reason === "string"
|
|
342
|
+
) {
|
|
343
|
+
return {
|
|
344
|
+
kind: error.kind,
|
|
345
|
+
command: error.command,
|
|
346
|
+
reason: error.reason,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
return undefined;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function showNotice(notice: Notice, context?: ExtensionContext): void {
|
|
353
|
+
const key = `${notice.level}:${notice.message}`;
|
|
354
|
+
if (noticeKeys.has(key)) return;
|
|
355
|
+
noticeKeys.add(key);
|
|
356
|
+
|
|
357
|
+
if (context?.ui) {
|
|
358
|
+
context.ui.notify(notice.message, notice.level);
|
|
359
|
+
} else {
|
|
360
|
+
pendingNotices.push(notice);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function queueNotice(key: string, message: string): void {
|
|
365
|
+
if (noticeKeys.has(key)) return;
|
|
366
|
+
noticeKeys.add(key);
|
|
367
|
+
pendingNotices.push({ level: "warning", message });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function flushPendingNotices(context: ExtensionContext): void {
|
|
371
|
+
for (const notice of pendingNotices.splice(0)) {
|
|
372
|
+
context.ui.notify(notice.message, notice.level);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function isRecord(value: unknown): value is Record<string, any> {
|
|
377
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function asString(value: unknown): string {
|
|
381
|
+
return typeof value === "string" ? value : "";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function errorMessage(error: unknown): string {
|
|
385
|
+
return error instanceof Error ? error.message : String(error);
|
|
386
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"noEmit": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"strict": false,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["index.ts", "src/**/*.ts"]
|
|
13
|
+
}
|