token-stats-timer 1.0.11 → 1.0.12
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 +21 -9
- package/notify.ts +157 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,10 +9,9 @@
|
|
|
9
9
|
Footer 上行(左对齐):
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
|
|
12
|
+
↑12k ↓3.4k CH87% ⚡77.7 t/s 5.3%/1.0M | 5h: 87% W: 92% ⏱ 2h 15m
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
- `● run 01:23` —— 当前 run 已耗时(从空闲后的第一个 `agent_start` 到 `agent_settled`,含重试/压缩恢复/排队提示)
|
|
16
15
|
- `prev` / `max` —— 上一次 run 耗时 / 本会话分支内最长 run 耗时(带 15 字 prompt 预览)
|
|
17
16
|
- `↑ ↓ Σ CH` —— 累计输入 / 输出 / 总量 / 缓存命中率
|
|
18
17
|
- `⚡` —— 实时速率(2s rolling window,无流时回落到平均速率)
|
|
@@ -28,7 +27,20 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
28
27
|
- ✅ pi 执行完成 —— 正常结束(含耗时)
|
|
29
28
|
- ⏹ pi 已中止 —— 用户 Esc 中止
|
|
30
29
|
|
|
31
|
-
会话关闭时另有 👋
|
|
30
|
+
会话关闭时另有 👋 提示(可关)。
|
|
31
|
+
|
|
32
|
+
### 投递通道(自动降级,优先用能送达的)
|
|
33
|
+
|
|
34
|
+
1. **OSC 终端协议** —— 向终端写转义序列,由**终端应用自己**弹系统通知,归属明确,无需 osascript:
|
|
35
|
+
- **iTerm2 → OSC 9**(`ESC]9;内容`,iTerm2 官方文档的通知序列,**实测有效**)
|
|
36
|
+
- Ghostty / WezTerm / Hyper / rxvt-unicode → OSC 777(`ESC]777;notify;标题;内容`)
|
|
37
|
+
> ⚠️ 注意:OSC 777 在 iTerm2 3.5.x 上会静默忽略(3.6.9 才支持),官方 pi 示例(notify.ts)只发 OSC 777,在 iTerm2 上不生效——本插件已按终端选择序列。
|
|
38
|
+
2. **terminal-notifier** —— 装了 `brew install terminal-notifier` 后自动使用,系统级最可靠(`-sender` 指定归属、`-group` 去重)。
|
|
39
|
+
3. **osascript** —— 保底(无法用终端协议时的最后手段)。
|
|
40
|
+
|
|
41
|
+
> 为什么不用纯 osascript:macOS Sequoia 起通知按“调用进程”归属,pi 是 node 进程、无法在系统设置里授权,通知会被静默丢弃(脚本 exit 0 但没弹窗)。OSC 序列把归属改到终端 App,授权后即可正常弹。
|
|
42
|
+
|
|
43
|
+
每次投递结果追加到 `~/.pi/agent/extensions/token-stats-logs/notify.log`,没弹通知时先看它判断是触发问题还是投递问题。
|
|
32
44
|
|
|
33
45
|
### 配置
|
|
34
46
|
|
|
@@ -48,10 +60,10 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
48
60
|
|
|
49
61
|
- `enabled` —— 总开关(也可用 `/notify on|off` 切换)
|
|
50
62
|
- `minDurationSec` —— 耗时低于该秒数的 run 不通知(设为 30 可避免秒回打扰)
|
|
51
|
-
- `sound` —— 通知声音(macOS 内置:Glass/Ping/Sosumi/Hero/Funk 等,`""`
|
|
63
|
+
- `sound` —— 通知声音(macOS 内置:Glass/Ping/Sosumi/Hero/Funk 等,`""` 静音;注意仅 osascript/terminal-notifier 通道有声,OSC 777 无声音参数)
|
|
52
64
|
- `onSuccess/onFailure/onAbort/onSessionEnd` —— 各类通知开关
|
|
53
65
|
|
|
54
|
-
`/notify` —— 无参查看当前状态;`on`/`off`
|
|
66
|
+
`/notify` —— 无参查看当前状态;`on`/`off` 开关;`test` 立即发一条测试通知验证通道是否可达(先看系统通知栏有没有,没有再看 notify.log)。
|
|
55
67
|
|
|
56
68
|
## 任务计时(step-timer)
|
|
57
69
|
|
|
@@ -60,16 +72,16 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
60
72
|
|
|
61
73
|
`总耗时 01:23`
|
|
62
74
|
|
|
63
|
-
经 `appendEntry` 持久化、不进入 LLM 上下文,`/resume`
|
|
75
|
+
经 `appendEntry` 持久化、不进入 LLM 上下文,`/resume` 后仍在。
|
|
64
76
|
|
|
65
|
-
无独立开关,随包启用;计时口径与 run-timer 一致(一次 run = 首个 agent_start → agent_settled
|
|
77
|
+
无独立开关,随包启用;计时口径与 run-timer 一致(一次 run = 首个 agent_start → agent_settled,含重试/压缩/排队提示)。
|
|
66
78
|
|
|
67
79
|
## 命令
|
|
68
80
|
|
|
69
81
|
- `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐)
|
|
70
82
|
- `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` —— 统计查询
|
|
71
83
|
- `/stats config` —— 显示样式 / 显示内容 / 配额刷新时间
|
|
72
|
-
- `/notify [on|off]` ——
|
|
84
|
+
- `/notify [on|off|test]` —— 通知开关 / 测试(无参查看状态)
|
|
73
85
|
|
|
74
86
|
## 与两个原包的兼容性
|
|
75
87
|
|
|
@@ -77,7 +89,7 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
77
89
|
- 统计日志沿用 `~/.pi/agent/extensions/token-stats-logs/`(历史数据 `/stats` 可直接查询)
|
|
78
90
|
- run 计时状态沿用 session 内 `run-timer-state` 自定义条目(`/reload` 后自动恢复,兼容旧 `turn-timer-state`)
|
|
79
91
|
|
|
80
|
-
##
|
|
92
|
+
## 安装(替换原包)
|
|
81
93
|
|
|
82
94
|
```bash
|
|
83
95
|
pi remove npm:@liziy/token-stats
|
package/notify.ts
CHANGED
|
@@ -11,16 +11,49 @@
|
|
|
11
11
|
// sound 通知声音(默认 "Glass","" 表示静音)
|
|
12
12
|
// onSuccess / onAbort / onSessionEnd 各类通知开关
|
|
13
13
|
//
|
|
14
|
-
// 命令:/notify on | off | status
|
|
14
|
+
// 命令:/notify on | off | status | test
|
|
15
|
+
//
|
|
16
|
+
// ── 投递通道(按优先级自动降级)──────────────────────────────
|
|
17
|
+
// 1. OSC 终端协议:向 stdout 写转义序列,由终端自己弹系统通知,归属终端应用。
|
|
18
|
+
// ⚠️ 重要:iTerm2 官方文档的通知序列是 OSC 9(`ESC]9;msg`)!
|
|
19
|
+
// OSC 777 是 rxvt 系/Ghostty/WezTerm 用的,iTerm2 3.6.9 才支持,
|
|
20
|
+
// iTerm2 3.5.x 收到 OSC 777 会静默忽略(实测确认)。
|
|
21
|
+
// - iTerm2 → OSC 9(无标题字段,格式为 `标题\n内容`)
|
|
22
|
+
// - Ghostty / WezTerm / Hyper / rxvt-unicode → OSC 777(title;body)
|
|
23
|
+
// 2. terminal-notifier:brew install terminal-notifier 装了之后用它,
|
|
24
|
+
// 系统级最可靠,-sender 指定归属 bundle id,-group 去重。
|
|
25
|
+
// 3. osascript:保底(无法用终端协议时的最后手段)。
|
|
26
|
+
//
|
|
27
|
+
// 每次投递结果追加到 ~/.pi/agent/extensions/token-stats-logs/notify.log,
|
|
28
|
+
// 便于排查"没弹通知"是触发问题还是投递问题。
|
|
15
29
|
|
|
16
30
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
-
import { spawn } from "node:child_process";
|
|
18
|
-
import {
|
|
31
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
32
|
+
import {
|
|
33
|
+
appendFileSync,
|
|
34
|
+
existsSync,
|
|
35
|
+
mkdirSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
writeFileSync,
|
|
38
|
+
} from "node:fs";
|
|
19
39
|
import { homedir } from "node:os";
|
|
20
40
|
import { join } from "node:path";
|
|
21
41
|
|
|
22
42
|
const CONFIG_DIR = join(homedir(), ".pi/agent/extensions/token-stats");
|
|
23
43
|
const CONFIG_FILE = join(CONFIG_DIR, "notify-config.json");
|
|
44
|
+
const LOG_DIR = join(homedir(), ".pi/agent/extensions/token-stats-logs");
|
|
45
|
+
const NOTIFY_LOG = join(LOG_DIR, "notify.log");
|
|
46
|
+
|
|
47
|
+
/** 支持 OSC 777 终端协议的应用(按 TERM_PROGRAM 匹配) */
|
|
48
|
+
const OSC777_TERMINALS = new Set([
|
|
49
|
+
"iTerm.app",
|
|
50
|
+
"iTerm2",
|
|
51
|
+
"WezTerm",
|
|
52
|
+
"WezTerm.app",
|
|
53
|
+
"ghostty",
|
|
54
|
+
"Ghostty",
|
|
55
|
+
"Hyper",
|
|
56
|
+
]);
|
|
24
57
|
|
|
25
58
|
export interface NotifyConfig {
|
|
26
59
|
enabled: boolean;
|
|
@@ -58,6 +91,19 @@ function saveConfig(cfg: NotifyConfig): void {
|
|
|
58
91
|
/** 连续两次通知的最短间隔(毫秒),防止并行 agent 结束时刷屏 */
|
|
59
92
|
const DEBOUNCE_MS = 3000;
|
|
60
93
|
|
|
94
|
+
function logLine(channel: string, title: string, ok: boolean, detail = ""): void {
|
|
95
|
+
try {
|
|
96
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
97
|
+
appendFileSync(
|
|
98
|
+
NOTIFY_LOG,
|
|
99
|
+
`${new Date().toISOString()} [${channel}] ${ok ? "OK" : "FAIL"} ${JSON.stringify(title)}${detail ? ` ${detail}` : ""}\n`,
|
|
100
|
+
"utf-8",
|
|
101
|
+
);
|
|
102
|
+
} catch {
|
|
103
|
+
// 日志失败不影响通知本身
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
61
107
|
function formatDuration(ms: number): string {
|
|
62
108
|
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
63
109
|
const hours = Math.floor(totalSeconds / 3600);
|
|
@@ -73,6 +119,79 @@ function truncate(s: string, max = 80): string {
|
|
|
73
119
|
return s.length > max ? s.slice(0, max) + "…" : s;
|
|
74
120
|
}
|
|
75
121
|
|
|
122
|
+
/** 终端是否支持 OSC 777 通知协议(非 iTerm2 的现代终端:Ghostty/WezTerm/Hyper/rxvt) */
|
|
123
|
+
function supportsOSC777(): boolean {
|
|
124
|
+
const tp = process.env.TERM_PROGRAM || "";
|
|
125
|
+
if (OSC777_TERMINALS.has(tp)) return true;
|
|
126
|
+
// rxvt-unicode 系列仅能从 TERM 判断
|
|
127
|
+
return /rxvt|urxvt/i.test(process.env.TERM || "");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 是否 iTerm2(iTerm2 官方文档的通知序列是 OSC 9,不是 OSC 777) */
|
|
131
|
+
function isITerm2(): boolean {
|
|
132
|
+
const tp = process.env.TERM_PROGRAM || "";
|
|
133
|
+
return tp === "iTerm.app" || tp === "iTerm2";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 通道1a:OSC 9 终端通知(iTerm2 官方文档序列,无标题字段,格式 `标题\n内容`) */
|
|
137
|
+
function notifyOSC9(title: string, message: string): void {
|
|
138
|
+
const safe = (s: string) => s.replace(/[\x07\x1b]/g, "").replace(/;/g, "·");
|
|
139
|
+
process.stdout.write(`\x1b]9;${safe(title)}\n${safe(message)}\x07`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 通道1b:OSC 777 终端通知(Ghostty / WezTerm / Hyper / rxvt,title;body 结构) */
|
|
143
|
+
function notifyOSC777(title: string, message: string): void {
|
|
144
|
+
// 去掉 BEL/ESC 等可能截断/注入的字符;OSC 参数以 ; 分隔,替换为 · 避免歧义
|
|
145
|
+
const safe = (s: string) => s.replace(/[\x07\x1b]/g, "").replace(/;/g, "·");
|
|
146
|
+
process.stdout.write(`\x1b]777;notify;${safe(title)};${safe(message)}\x07`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 通道2:terminal-notifier(brew 安装后自动使用),-sender 归属终端保证权限生效 */
|
|
150
|
+
function notifyTerminalNotifier(title: string, message: string, sound: string): boolean {
|
|
151
|
+
const candidates = [
|
|
152
|
+
"/opt/homebrew/bin/terminal-notifier",
|
|
153
|
+
"/usr/local/bin/terminal-notifier",
|
|
154
|
+
];
|
|
155
|
+
const exe = candidates.find((p) => existsSync(p));
|
|
156
|
+
if (!exe) return false;
|
|
157
|
+
|
|
158
|
+
const sender =
|
|
159
|
+
process.env.TERM_PROGRAM === "iTerm.app"
|
|
160
|
+
? "com.googlecode.iterm2"
|
|
161
|
+
: process.env.TERM_PROGRAM === "WezTerm"
|
|
162
|
+
? "org.wezfurlong.wezterm"
|
|
163
|
+
: undefined;
|
|
164
|
+
const args = ["-title", title, "-message", message, "-group", "pi-agent"];
|
|
165
|
+
if (sound) args.push("-sound", sound);
|
|
166
|
+
if (sender) args.push("-sender", sender);
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const r = spawnSync(exe, args, { timeout: 5000 });
|
|
170
|
+
return !r.error && r.status === 0;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 通道3:osascript 保底(不支持 OSC 777 的终端);失败信息写日志供排查 */
|
|
177
|
+
function notifyOSAScript(title: string, message: string, sound: string): void {
|
|
178
|
+
const soundPart = sound ? ` sound name ${JSON.stringify(sound)}` : "";
|
|
179
|
+
const script = `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}${soundPart}`;
|
|
180
|
+
const child = spawn("osascript", ["-e", script], {
|
|
181
|
+
detached: true,
|
|
182
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
183
|
+
});
|
|
184
|
+
let err = "";
|
|
185
|
+
child.stderr?.on("data", (d) => {
|
|
186
|
+
err += d.toString();
|
|
187
|
+
});
|
|
188
|
+
child.on("error", (e) => logLine("osascript", title, false, e.message));
|
|
189
|
+
child.on("exit", (code) => {
|
|
190
|
+
if (code !== 0) logLine("osascript", title, false, err.trim() || `exit ${code}`);
|
|
191
|
+
});
|
|
192
|
+
child.unref();
|
|
193
|
+
}
|
|
194
|
+
|
|
76
195
|
export function createNotifier(pi: ExtensionAPI): void {
|
|
77
196
|
let config: NotifyConfig = loadConfig();
|
|
78
197
|
|
|
@@ -83,6 +202,7 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
83
202
|
// 进程内去抖计时
|
|
84
203
|
let lastNotifyAt = 0;
|
|
85
204
|
|
|
205
|
+
/** 多通道投递:iTerm2 用 OSC 9 → 其他终端 OSC 777 → terminal-notifier → osascript */
|
|
86
206
|
function notify(title: string, message: string, sound: string): void {
|
|
87
207
|
if (process.platform !== "darwin") return; // 非 macOS 直接跳过
|
|
88
208
|
|
|
@@ -90,11 +210,26 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
90
210
|
if (now - lastNotifyAt < DEBOUNCE_MS) return;
|
|
91
211
|
lastNotifyAt = now;
|
|
92
212
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
213
|
+
if (isITerm2()) {
|
|
214
|
+
// iTerm2:官方文档通知序列 OSC 9(实测有效)
|
|
215
|
+
notifyOSC9(title, message);
|
|
216
|
+
logLine("osc9", title, true);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (supportsOSC777()) {
|
|
221
|
+
// Ghostty / WezTerm / Hyper / rxvt:OSC 777
|
|
222
|
+
notifyOSC777(title, message);
|
|
223
|
+
logLine("osc777", title, true);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (notifyTerminalNotifier(title, message, sound)) {
|
|
228
|
+
logLine("terminal-notifier", title, true);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
notifyOSAScript(title, message, sound);
|
|
98
233
|
}
|
|
99
234
|
|
|
100
235
|
pi.on("agent_start", () => {
|
|
@@ -155,7 +290,7 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
155
290
|
|
|
156
291
|
// ── /notify 命令 ─────────────────────────────────────
|
|
157
292
|
pi.registerCommand("notify", {
|
|
158
|
-
description: "macOS 完成通知: on | off | status(详细配置见 notify-config.json)",
|
|
293
|
+
description: "macOS 完成通知: on | off | status | test(详细配置见 notify-config.json)",
|
|
159
294
|
handler: async (args, ctx) => {
|
|
160
295
|
const arg = args.trim();
|
|
161
296
|
if (arg === "on") {
|
|
@@ -166,6 +301,19 @@ export function createNotifier(pi: ExtensionAPI): void {
|
|
|
166
301
|
config = { ...config, enabled: false };
|
|
167
302
|
saveConfig(config);
|
|
168
303
|
ctx.ui.notify("完成通知已关闭", "info");
|
|
304
|
+
} else if (arg === "test") {
|
|
305
|
+
// 立即发一条测试通知,验证当前投递通道是否可达
|
|
306
|
+
const title = "🔔 pi 通知测试";
|
|
307
|
+
const channel = isITerm2()
|
|
308
|
+
? "osc9 终端协议"
|
|
309
|
+
: supportsOSC777()
|
|
310
|
+
? "osc777 终端协议"
|
|
311
|
+
: existsSync("/opt/homebrew/bin/terminal-notifier") ||
|
|
312
|
+
existsSync("/usr/local/bin/terminal-notifier")
|
|
313
|
+
? "terminal-notifier"
|
|
314
|
+
: "osascript";
|
|
315
|
+
notify(title, `投递通道:${channel}\n时间:${new Date().toLocaleTimeString()}`, config.sound);
|
|
316
|
+
ctx.ui.notify("测试通知已发送,请查看系统通知栏", "info");
|
|
169
317
|
} else {
|
|
170
318
|
const sound = config.sound ? `, 声音=${config.sound}` : ", 静音";
|
|
171
319
|
ctx.ui.notify(
|