token-stats-timer 1.0.0 → 1.0.2
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 +36 -1
- package/index.ts +3 -0
- package/notify.ts +208 -0
- package/package.json +21 -1
package/README.md
CHANGED
|
@@ -21,11 +21,45 @@ Footer 上行(左对齐):
|
|
|
21
21
|
|
|
22
22
|
Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
23
23
|
|
|
24
|
+
## macOS 完成通知
|
|
25
|
+
|
|
26
|
+
每次 run(agent 任务)结束后弹系统通知,区分三种结果:
|
|
27
|
+
|
|
28
|
+
- ✅ pi 执行完成 —— 正常结束(含耗时)
|
|
29
|
+
- ❌ pi 执行失败 —— 模型调用出错 / 工具执行失败(含错误摘要)
|
|
30
|
+
- ⏹ pi 已中止 —— 用户 Esc 中止
|
|
31
|
+
|
|
32
|
+
会话关闭时另有 👋 提示(可关)。通知经 `osascript` 发送,需在系统设置 → 通知 中允许终端 App 通知。
|
|
33
|
+
|
|
34
|
+
### 配置
|
|
35
|
+
|
|
36
|
+
配置文件:`~/.pi/agent/extensions/token-stats/notify-config.json`(不存在时用默认值)
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"minDurationSec": 0,
|
|
42
|
+
"sound": "Glass",
|
|
43
|
+
"onSuccess": true,
|
|
44
|
+
"onFailure": true,
|
|
45
|
+
"onAbort": true,
|
|
46
|
+
"onSessionEnd": true
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- `enabled` —— 总开关(也可用 `/notify on|off` 切换)
|
|
51
|
+
- `minDurationSec` —— 耗时低于该秒数的 run 不通知(设为 30 可避免秒回打扰)
|
|
52
|
+
- `sound` —— 通知声音(macOS 内置:Glass/Ping/Sosumi/Hero/Funk 等,`""` 静音)
|
|
53
|
+
- `onSuccess/onFailure/onAbort/onSessionEnd` —— 各类通知开关
|
|
54
|
+
|
|
55
|
+
`/notify` —— 无参查看当前状态;`on`/`off` 开启/关闭。
|
|
56
|
+
|
|
24
57
|
## 命令
|
|
25
58
|
|
|
26
59
|
- `/stats` —— 无参进入套餐配置(为当前 provider 选择/关闭配额套餐)
|
|
27
60
|
- `/stats day [YYYY-MM-DD]` / `hour` / `week` / `month [YYYY-MM]` —— 统计查询
|
|
28
61
|
- `/stats config` —— 显示样式 / 显示内容(含「计时器」开关)/ 配额刷新时间
|
|
62
|
+
- `/notify [on|off]` —— macOS 完成通知开关(无参查看状态)
|
|
29
63
|
|
|
30
64
|
## 与两个原包的兼容性
|
|
31
65
|
|
|
@@ -38,6 +72,7 @@ Footer 下行:cwd + git 分支 + 其他扩展状态。
|
|
|
38
72
|
```bash
|
|
39
73
|
pi remove npm:@carlosgtrz/pi-run-timer
|
|
40
74
|
pi remove npm:@liziy/token-stats
|
|
75
|
+
pi install npm:token-stats-timer
|
|
41
76
|
```
|
|
42
77
|
|
|
43
|
-
|
|
78
|
+
`/reload` 或重启 pi 生效。
|
package/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
20
20
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
21
21
|
import { createRunTimer } from "./run-timer.ts";
|
|
22
22
|
import { createTokenStats, formatUserPath, type SharedState } from "./token-stats.ts";
|
|
23
|
+
import { createNotifier } from "./notify.ts";
|
|
23
24
|
|
|
24
25
|
const shared: SharedState = {
|
|
25
26
|
sessionActive: false,
|
|
@@ -29,6 +30,8 @@ const shared: SharedState = {
|
|
|
29
30
|
export default function runTokenStatsExtension(pi: ExtensionAPI) {
|
|
30
31
|
const stats = createTokenStats(pi, shared);
|
|
31
32
|
const timer = createRunTimer(pi, shared);
|
|
33
|
+
// macOS 完成通知(成功/失败/中止),配置见 notify-config.json
|
|
34
|
+
createNotifier(pi);
|
|
32
35
|
|
|
33
36
|
pi.on("session_start", (_event, ctx) => {
|
|
34
37
|
shared.sessionActive = true;
|
package/notify.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// run-token-stats / notify 模块
|
|
2
|
+
// =============================================================================
|
|
3
|
+
// macOS 系统通知:每次 run(agent 完成任务)结束后弹系统通知,区分成功/失败/中止。
|
|
4
|
+
//
|
|
5
|
+
// run 语义与 run-timer 一致:从空闲后的第一个 agent_start 到 agent_settled
|
|
6
|
+
// 的连续忙碌期(含重试、压缩恢复、排队提示)。
|
|
7
|
+
//
|
|
8
|
+
// 配置持久化:~/.pi/agent/extensions/token-stats/notify-config.json
|
|
9
|
+
// enabled 总开关(默认 true)
|
|
10
|
+
// minDurationSec 时长低于该秒数的 run 不通知(默认 0 = 每次都通知)
|
|
11
|
+
// sound 通知声音(默认 "Glass","" 表示静音)
|
|
12
|
+
// onSuccess / onFailure / onAbort / onSessionEnd 各类通知开关(默认全开)
|
|
13
|
+
//
|
|
14
|
+
// 命令:/notify on | off | status
|
|
15
|
+
|
|
16
|
+
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";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const CONFIG_DIR = join(homedir(), ".pi/agent/extensions/token-stats");
|
|
23
|
+
const CONFIG_FILE = join(CONFIG_DIR, "notify-config.json");
|
|
24
|
+
|
|
25
|
+
export interface NotifyConfig {
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
minDurationSec: number;
|
|
28
|
+
sound: string;
|
|
29
|
+
onSuccess: boolean;
|
|
30
|
+
onFailure: boolean;
|
|
31
|
+
onAbort: boolean;
|
|
32
|
+
onSessionEnd: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const DEFAULT_CONFIG: NotifyConfig = {
|
|
36
|
+
enabled: true,
|
|
37
|
+
minDurationSec: 0,
|
|
38
|
+
sound: "Glass",
|
|
39
|
+
onSuccess: true,
|
|
40
|
+
onFailure: true,
|
|
41
|
+
onAbort: false,
|
|
42
|
+
onSessionEnd: false,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function loadConfig(): NotifyConfig {
|
|
46
|
+
if (!existsSync(CONFIG_FILE)) return { ...DEFAULT_CONFIG };
|
|
47
|
+
try {
|
|
48
|
+
const saved = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")) as Partial<NotifyConfig>;
|
|
49
|
+
return { ...DEFAULT_CONFIG, ...saved };
|
|
50
|
+
} catch {
|
|
51
|
+
return { ...DEFAULT_CONFIG };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function saveConfig(cfg: NotifyConfig): void {
|
|
56
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
57
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", "utf-8");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 连续两次通知的最短间隔(毫秒),防止并行 agent 结束时刷屏 */
|
|
61
|
+
const DEBOUNCE_MS = 3000;
|
|
62
|
+
|
|
63
|
+
function formatDuration(ms: number): string {
|
|
64
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
65
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
66
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
67
|
+
const seconds = totalSeconds % 60;
|
|
68
|
+
if (hours > 0) {
|
|
69
|
+
return `${hours}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
70
|
+
}
|
|
71
|
+
return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function truncate(s: string, max = 80): string {
|
|
75
|
+
return s.length > max ? s.slice(0, max) + "…" : s;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function cwdName(): string {
|
|
79
|
+
const parts = process.cwd().split("/").filter(Boolean);
|
|
80
|
+
return parts.pop() || process.cwd();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function createNotifier(pi: ExtensionAPI): void {
|
|
84
|
+
let config: NotifyConfig = loadConfig();
|
|
85
|
+
|
|
86
|
+
// 当前 run 的状态(agent_start 重置,agent_end 收集,agent_settled 消费)
|
|
87
|
+
let runStartMs: number | undefined;
|
|
88
|
+
let runFailed = false;
|
|
89
|
+
let runAborted = false;
|
|
90
|
+
let runErrorMsg: string | undefined;
|
|
91
|
+
|
|
92
|
+
// 进程内去抖计时
|
|
93
|
+
let lastNotifyAt = 0;
|
|
94
|
+
|
|
95
|
+
function notify(title: string, message: string, sound: string): void {
|
|
96
|
+
if (process.platform !== "darwin") return; // 非 macOS 直接跳过
|
|
97
|
+
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
if (now - lastNotifyAt < DEBOUNCE_MS) return;
|
|
100
|
+
lastNotifyAt = now;
|
|
101
|
+
|
|
102
|
+
// spawn + 参数数组传参,避免 shell 注入;detached + unref 不阻塞 pi
|
|
103
|
+
const soundPart = sound ? ` sound name ${JSON.stringify(sound)}` : "";
|
|
104
|
+
const script = `display notification ${JSON.stringify(message)} with title ${JSON.stringify(title)}${soundPart}`;
|
|
105
|
+
const child = spawn("osascript", ["-e", script], { detached: true });
|
|
106
|
+
child.unref();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
pi.on("agent_start", () => {
|
|
110
|
+
runFailed = false;
|
|
111
|
+
runAborted = false;
|
|
112
|
+
runErrorMsg = undefined;
|
|
113
|
+
if (runStartMs === undefined) runStartMs = Date.now();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// 一次 run 结束;若 run 已带错误状态,最终结论按错误弹
|
|
117
|
+
pi.on("agent_end", (event) => {
|
|
118
|
+
if (!config.enabled) return;
|
|
119
|
+
for (const m of event.messages) {
|
|
120
|
+
if (m.role === "assistant") {
|
|
121
|
+
if (m.stopReason === "aborted") {
|
|
122
|
+
runAborted = true;
|
|
123
|
+
runErrorMsg = runErrorMsg || "被用户中止";
|
|
124
|
+
} else if (m.stopReason === "error" || m.errorMessage) {
|
|
125
|
+
runFailed = true;
|
|
126
|
+
runErrorMsg = runErrorMsg || m.errorMessage || "模型调用出错";
|
|
127
|
+
}
|
|
128
|
+
} else if (m.role === "toolResult" && m.isError) {
|
|
129
|
+
runFailed = true;
|
|
130
|
+
const toolMsg = `工具 ${m.toolName} 执行失败`;
|
|
131
|
+
runErrorMsg = runErrorMsg && !runErrorMsg.startsWith("工具 ")
|
|
132
|
+
? runErrorMsg
|
|
133
|
+
: toolMsg;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
139
|
+
if (!ctx.isIdle()) return;
|
|
140
|
+
|
|
141
|
+
const startMs = runStartMs;
|
|
142
|
+
runStartMs = undefined;
|
|
143
|
+
if (!config.enabled) return;
|
|
144
|
+
|
|
145
|
+
const durationMs = startMs !== undefined ? Date.now() - startMs : 0;
|
|
146
|
+
if (durationMs < config.minDurationSec * 1000) return;
|
|
147
|
+
|
|
148
|
+
const where = `「${cwdName()}」`;
|
|
149
|
+
const dur = startMs !== undefined ? ` · ${formatDuration(durationMs)}` : "";
|
|
150
|
+
|
|
151
|
+
if (runFailed && config.onFailure) {
|
|
152
|
+
notify(
|
|
153
|
+
"❌ pi 执行失败",
|
|
154
|
+
`${where}${dur}\n${truncate(runErrorMsg || "未知错误")}`,
|
|
155
|
+
config.sound,
|
|
156
|
+
);
|
|
157
|
+
} else if (runAborted && config.onAbort) {
|
|
158
|
+
notify(
|
|
159
|
+
"⏹ pi 已中止",
|
|
160
|
+
`${where}${dur}\n${truncate(runErrorMsg || "执行被中止")}`,
|
|
161
|
+
config.sound,
|
|
162
|
+
);
|
|
163
|
+
} else if (config.onSuccess) {
|
|
164
|
+
notify(
|
|
165
|
+
"✅ pi 执行完成",
|
|
166
|
+
`${where}${dur}\nagent 任务已结束`,
|
|
167
|
+
config.sound,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// 消费本次结果,避免串扰下一次 run
|
|
172
|
+
runFailed = false;
|
|
173
|
+
runAborted = false;
|
|
174
|
+
runErrorMsg = undefined;
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// 会话关闭时提示(可选)
|
|
178
|
+
pi.on("session_shutdown", () => {
|
|
179
|
+
if (config.enabled && config.onSessionEnd) {
|
|
180
|
+
notify("👋 pi 会话结束", "session 已关闭", config.sound);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── /notify 命令 ─────────────────────────────────────
|
|
185
|
+
pi.registerCommand("notify", {
|
|
186
|
+
description: "macOS 完成通知: on | off | status(详细配置见 notify-config.json)",
|
|
187
|
+
handler: async (args, ctx) => {
|
|
188
|
+
const arg = args.trim();
|
|
189
|
+
if (arg === "on") {
|
|
190
|
+
config = { ...config, enabled: true };
|
|
191
|
+
saveConfig(config);
|
|
192
|
+
ctx.ui.notify("完成通知已开启", "info");
|
|
193
|
+
} else if (arg === "off") {
|
|
194
|
+
config = { ...config, enabled: false };
|
|
195
|
+
saveConfig(config);
|
|
196
|
+
ctx.ui.notify("完成通知已关闭", "info");
|
|
197
|
+
} else {
|
|
198
|
+
const sound = config.sound ? `, 声音=${config.sound}` : ", 静音";
|
|
199
|
+
ctx.ui.notify(
|
|
200
|
+
`完成通知: ${config.enabled ? "开" : "关"}, 最短时长=${config.minDurationSec}s, ` +
|
|
201
|
+
`成功=${config.onSuccess ? "开" : "关"}, 失败=${config.onFailure ? "开" : "关"}, ` +
|
|
202
|
+
`中止=${config.onAbort ? "开" : "关"}, 会话结束=${config.onSessionEnd ? "开" : "关"}${sound}`,
|
|
203
|
+
"info",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "token-stats-timer",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "pi extension to display token usage and time spend.",
|
|
5
5
|
"keywords": [
|
|
6
|
+
"pi-package",
|
|
6
7
|
"pi-extension"
|
|
7
8
|
],
|
|
8
9
|
"license": "ISC",
|
|
@@ -10,11 +11,30 @@
|
|
|
10
11
|
"type": "module",
|
|
11
12
|
"main": "index.ts",
|
|
12
13
|
"files": [
|
|
14
|
+
"index.ts",
|
|
13
15
|
"run-timer.ts",
|
|
14
16
|
"token-stats.ts",
|
|
17
|
+
"notify.ts",
|
|
15
18
|
"README.md"
|
|
16
19
|
],
|
|
17
20
|
"scripts": {
|
|
18
21
|
"test": "echo \"Error: no test specified\" && exit 1"
|
|
22
|
+
},
|
|
23
|
+
"pi": {
|
|
24
|
+
"extensions": [
|
|
25
|
+
"./index.ts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@earendil-works/pi-ai": "*",
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*"
|
|
32
|
+
},
|
|
33
|
+
"devEngines": {
|
|
34
|
+
"packageManager": {
|
|
35
|
+
"name": "nub",
|
|
36
|
+
"version": "^0.7.5",
|
|
37
|
+
"onFail": "ignore"
|
|
38
|
+
}
|
|
19
39
|
}
|
|
20
40
|
}
|