token-stats-timer 1.0.10 → 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 CHANGED
@@ -9,10 +9,9 @@
9
9
  Footer 上行(左对齐):
10
10
 
11
11
  ```
12
- ● run 01:23 · prev 00:40 · max 03:12 (Review README…) | ↑12k ↓3.4k CH87% ⚡77.7 t/s 5.3%/1.0M | 5h: 87% W: 92% ⏱ 2h 15m
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
- 会话关闭时另有 👋 提示(可关)。通知经 `osascript` 发送,需在系统设置 → 通知 中允许终端 App 通知。
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,含重试/压缩/排队提示)。早期版本的每 turn 摘要(timing-turn)、“⏱ 思考”每条消息内联标注与 Thinking 实时标签均已移除,历史条目自动隐藏。
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]` —— macOS 完成通知开关(无参查看状态)
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 { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
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
- // spawn + 参数数组传参,避免 shell 注入;detached + unref 不阻塞 pi
94
- const soundPart = sound ? ` sound name ${JSON.stringify(sound)}` : "";
95
- const script = `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}${soundPart}`;
96
- const child = spawn("osascript", ["-e", script], { detached: true });
97
- child.unref();
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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "token-stats-timer",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "pi extension to display token usage and time spend.",
5
5
  "keywords": [
6
6
  "pi-package",
package/step-timer.ts CHANGED
@@ -92,11 +92,12 @@ export function createStepTimer(pi: ExtensionAPI): void {
92
92
  lastCtx = undefined;
93
93
  });
94
94
 
95
- pi.registerEntryRenderer<FinalTimingData>(FINAL_TYPE, (entry, _opts, _theme) => {
95
+ pi.registerEntryRenderer<FinalTimingData>(FINAL_TYPE, (entry, _opts, theme) => {
96
96
  const d = entry.data;
97
97
  if (!d) return undefined;
98
- const box = new Box(1, 1);
99
- box.addChild(new Text(`总耗时 ${formatDuration(d.totalMs)}`, 0, 0));
98
+ const title = theme.fg("accent", "总耗时");
99
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
100
+ box.addChild(new Text(`${title} ${formatDuration(d.totalMs)}`, 0, 0));
100
101
  return box;
101
102
  });
102
103
  }