pi-win-notify 1.0.5 → 1.0.7

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.
@@ -1,562 +1,562 @@
1
- /**
2
- * Pi Desktop Notification — 桌面通知扩展
3
- *
4
- * pi 完成输出时右下角弹出 WPF 暗色通知窗口。切到其他程序时不会错过。
5
- *
6
- * ## 使用
7
- *
8
- * /notify 切换通知开关 (on/off)
9
- * /notify on|off 直接设置
10
- * 弹窗按钮: 知道了 / 继续(切回终端并聚焦)
11
- * 全局快捷键: Alt+[ = 知道了 Alt+] = 继续
12
- *
13
- * ## 行为
14
- *
15
- * - 右下角置顶、不抢焦点、80% 半透明、15 秒自动消失
16
- * - 通知标题 = 当前轮用户消息前 10 字
17
- * - LLM 超时重试时自动抑制(2 秒冷却期,只在主动权交还用户时弹)
18
- * - 多 pi 窗口安全(各自独立的句柄缓存)
19
- * - Footer 状态指示: 🔔 开启 / 🔕 关闭
20
- * ## 文件
21
- *
22
- * 本文件放在 ~/.pi/agent/extensions/ 下自动生效。
23
- * 调试日志: %TEMP%/pi-notify-debug.log
24
- *
25
- * ## 跨平台
26
- *
27
- * Windows: WPF 暗色窗口 (本扩展主要针对)
28
- * macOS: osascript Notification Center
29
- * Linux: notify-send
30
- */
31
-
32
- import koffi from "koffi";
33
- import { spawn, type ChildProcess } from "node:child_process";
34
- import { platform, tmpdir } from "node:os";
35
- import { dirname, join } from "node:path";
36
- import { fileURLToPath } from "node:url";
37
- import { appendFileSync, readFileSync, existsSync } from "node:fs";
38
- import { getAgentDir } from "@earendil-works/pi-coding-agent";
39
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
40
-
41
- const __filename = fileURLToPath(import.meta.url);
42
- const __dirname = dirname(__filename);
43
-
44
- // ── Win32 API via koffi(零 temp 文件,零 PowerShell)─────────────────
45
- let GetForegroundWindow: () => number;
46
- let GetWindowTextW: (hwnd: number, buf: unknown, maxCount: number) => number;
47
- let GetWindowTextLengthW: (hwnd: number) => number;
48
-
49
- function initWin32(): void {
50
- if (platform() !== "win32") return;
51
- try {
52
- const user32 = koffi.load("user32.dll");
53
- GetForegroundWindow = user32.func("intptr_t GetForegroundWindow()");
54
- GetWindowTextW = user32.func("int GetWindowTextW(intptr_t hWnd, char16_t* lpString, int nMaxCount)");
55
- GetWindowTextLengthW = user32.func("int GetWindowTextLengthW(intptr_t hWnd)");
56
- log("koffi: Win32 API initialized");
57
- } catch (e: unknown) {
58
- log(`koffi: init failed — ${e}`);
59
- }
60
- }
61
-
62
- // ── 状态 ─────────────────────────────────────────────────────────────────────
63
- let enabled = true;
64
- let uniqueWindowId = "";
65
- let notifyTimer: ReturnType<typeof setTimeout> | null = null;
66
- let taskStartTime = 0;
67
- let psHost: ChildProcess | null = null;
68
- let psHostReady = false;
69
- let psHostCrashCount = 0;
70
- let psHostLastError = "";
71
- let piApi: ExtensionAPI | null = null;
72
-
73
- // ── 可配置项 ────────────────────────────────────────────────────────────────
74
- const CONFIG_PATH = join(getAgentDir(), "notify.json");
75
- type Config = { timeout: number; opacity: number; messageMode: "fixed" | "response"; lang: "zh" | "en" | "ja" | "ko"; muteUntil?: number };
76
-
77
- function loadConfig(): Config {
78
- try {
79
- if (existsSync(CONFIG_PATH)) {
80
- const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
81
- return { timeout: saved.timeout ?? 15, opacity: saved.opacity ?? 1.0, messageMode: saved.messageMode ?? "response", lang: saved.lang ?? "en", muteUntil: saved.muteUntil };
82
- }
83
- } catch { /* */ }
84
- return { timeout: 15, opacity: 1.0, messageMode: "response", lang: "en" };
85
- }
86
-
87
- function saveConfig(c: Config): void {
88
- try {
89
- const { writeFileSync } = require("node:fs");
90
- writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2), "utf-8");
91
- } catch { /* */ }
92
- }
93
-
94
- function saveMuteUntil(ts: number | undefined): void {
95
- config.muteUntil = ts;
96
- saveConfig(config);
97
- }
98
-
99
- const config = loadConfig();
100
-
101
- // ── i18n ────────────────────────────────────────────────────────────────────
102
- const i18n: Record<string, Record<string, string>> = {
103
- zh: { dismissBtn: " 知 道 了 ", continueBtn: " 继 续 ", completion: "任务完成", switchBack: "可以切回来了", enabled: "通知已开启 🔔", disabled: "通知已关闭 🔕", configTitle: "通知配置", timeoutLabel: "超时", opacityLabel: "不透明度", modeLabel: "模式", langLabel: "语言", statusLabel: "状态", on: "开", off: "关", mute3: "3 分钟", mute30: "30 分钟", mute60: "1 小时", muteOff: "关闭勿扰" },
104
- en: { dismissBtn: " Dismiss ", continueBtn: "Continue", completion: "Task complete", switchBack: "Switch back", enabled: "Notify ON 🔔", disabled: "Notify OFF 🔕", configTitle: "Notify Config", timeoutLabel: "Timeout", opacityLabel: "Opacity", modeLabel: "Mode", langLabel: "Language", statusLabel: "Status", on: "ON", off: "OFF", mute3: "3 min", mute30: "30 min", mute60: "1 hour", muteOff: "Turn off" },
105
- ja: { dismissBtn: " 閉じる ", continueBtn: " 続 行 ", completion: "完了", switchBack: "戻れます", enabled: "通知ON 🔔", disabled: "通知OFF 🔕", configTitle: "通知設定", timeoutLabel: "タイムアウト", opacityLabel: "不透明度", modeLabel: "モード", langLabel: "言語", statusLabel: "状態", on: "ON", off: "OFF", mute3: "3 分", mute30: "30 分", mute60: "1 時間", muteOff: "オフ" },
106
- ko: { dismissBtn: " 닫 기 ", continueBtn: " 계 속 ", completion: "완료", switchBack: "돌아가기", enabled: "알림 ON 🔔", disabled: "알림 OFF 🔕", configTitle: "알림 설정", timeoutLabel: "시간제한", opacityLabel: "불투명도", modeLabel: "모드", langLabel: "언어", statusLabel: "상태", on: "ON", off: "OFF", mute3: "3 분", mute30: "30 분", mute60: "1 시간", muteOff: "끄기" },
107
- };
108
- function t(key: string): string { return i18n[config.lang]?.[key] ?? i18n.zh[key] ?? key; }
109
- function completionMsg(): string { return `${t("completion")},${t("switchBack")} 🎉`; }
110
-
111
- function formatElapsed(ms: number): string {
112
- if (ms < 1000) return `${ms}ms`;
113
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
114
- const m = Math.floor(ms / 60000);
115
- const s = Math.round((ms % 60000) / 1000);
116
- return `${m}m ${s}s`;
117
- }
118
-
119
- function extractSummary(event: { messages?: unknown[] }): string | null {
120
- try {
121
- const msgs = event.messages as Array<{ role?: string; content?: unknown }>;
122
- if (!msgs) return null;
123
- for (let i = msgs.length - 1; i >= 0; i--) {
124
- if (msgs[i].role === "assistant") {
125
- const text = extractText(msgs[i].content);
126
- if (text) {
127
- const cleaned = text.replace(/\s+/g, " ").trim();
128
- return cleaned.length > 50 ? cleaned.slice(0, 50) + "…" : cleaned;
129
- }
130
- }
131
- }
132
- } catch { /* */ }
133
- return null;
134
- }
135
-
136
- // ── 窗口句柄缓存(koffi 直调 user32.dll)────────────────────────────
137
- let terminalHwnd = "";
138
- let terminalTitle = "";
139
-
140
- function cacheTerminalHwnd(): void {
141
- if (platform() !== "win32" || !GetForegroundWindow) return;
142
- try {
143
- const hwnd = GetForegroundWindow();
144
- if (!hwnd) return;
145
- const len = GetWindowTextLengthW(hwnd);
146
- if (len > 0) {
147
- const buf = Buffer.alloc((len + 1) * 2);
148
- GetWindowTextW(hwnd, buf, len + 1);
149
- terminalTitle = buf.toString("utf16le").replace(/\0+$/, "");
150
- }
151
- terminalHwnd = String(hwnd);
152
- log(`cached terminal: hwnd=${terminalHwnd} title="${terminalTitle}"`);
153
- } catch (e: unknown) {
154
- log(`FAIL cache hwnd: ${e}`);
155
- }
156
- }
157
-
158
- // ── 窗口标识 ─────────────────────────────────────────────────────────────────
159
- function generateWindowId(): string {
160
- return `pi@${process.pid.toString(36)}`;
161
- }
162
-
163
- function setWindowTitle(title: string): void {
164
- process.stdout.write(`\x1b]0;${title}\x07`);
165
- }
166
-
167
- // ── 调试日志 ─────────────────────────────────────────────────────────────────
168
- const LOG = join(tmpdir(), "pi-notify-debug.log");
169
- function log(msg: string): void {
170
- const ts = new Date().toISOString();
171
- try { appendFileSync(LOG, `[${ts}] ${msg}\n`, "utf-8"); } catch { /* ignore */ }
172
- }
173
-
174
- // ── 常驻 PowerShell 通知宿主(host.ps1 独立文件,一次编译,后续 stdin 一行即弹)
175
-
176
- function spawnHost(): void {
177
- if (platform() !== "win32") return;
178
- if (psHost) {
179
- try { psHost.kill(); } catch { /* */ }
180
- psHost = null;
181
- }
182
- psHostReady = false;
183
-
184
- log("spawning PS host...");
185
- const psPath = join(__dirname, "host.ps1");
186
-
187
- const child = spawn("powershell.exe", [
188
- "-NoProfile",
189
- "-ExecutionPolicy", "Bypass",
190
- "-WindowStyle", "Hidden",
191
- "-File", psPath,
192
- ], {
193
- windowsHide: true,
194
- stdio: ["pipe", "pipe", "pipe"],
195
- });
196
-
197
- let stdoutBuf = "";
198
- child.stdout.on("data", (chunk: Buffer) => {
199
- stdoutBuf += chunk.toString("utf-8");
200
- const lines = stdoutBuf.split("\n");
201
- stdoutBuf = lines.pop() || "";
202
- for (const line of lines) {
203
- const trimmed = line.trim();
204
- if (!trimmed) continue;
205
- if (trimmed === "READY") {
206
- psHostReady = true;
207
- psHostCrashCount = 0;
208
- psHostLastError = "";
209
- log("PS host ready");
210
- } else if (trimmed === "OK") {
211
- log("PS host: notification dismissed");
212
- } else if (trimmed.startsWith("ERROR:")) {
213
- log(`PS host error: ${trimmed}`);
214
- } else if (trimmed.startsWith("MUTE:")) {
215
- const mins = parseInt(trimmed.slice(5));
216
- if (mins > 0) {
217
- enabled = false;
218
- saveMuteUntil(Date.now() + mins * 60000);
219
- log(`mute: notifications off for ${mins}m`);
220
- } else {
221
- enabled = true;
222
- saveMuteUntil(undefined);
223
- log("mute off");
224
- }
225
- }
226
- }
227
- });
228
-
229
- child.stderr.on("data", (chunk: Buffer) => {
230
- log(`PS host stderr: ${chunk.toString("utf-8").trim()}`);
231
- });
232
-
233
- child.on("close", (code) => {
234
- log(`PS host exited (code=${code})`);
235
- if (code !== 0 && code !== null) {
236
- psHostCrashCount++;
237
- psHostLastError = `exit code ${code}`;
238
- if (piApi) {
239
- piApi.ui.notify(`桌面通知服务异常 (${psHostLastError}),下次弹窗时自动恢复`, "warning");
240
- }
241
- }
242
- psHost = null;
243
- psHostReady = false;
244
- });
245
-
246
- child.on("error", (err) => {
247
- log(`PS host spawn error: ${err.message}`);
248
- psHost = null;
249
- psHostReady = false;
250
- });
251
-
252
- psHost = child;
253
- }
254
-
255
- // ── 桌面通知 ────────────────────────────────────────────────────────────────
256
-
257
- function notifyWindows(title: string, body: string, hwnd: string, winTitle: string): void {
258
- log(`notifyWindows: title="${title}" hwnd=${hwnd} win="${winTitle}"`);
259
-
260
- if (!hwnd || hwnd === "0") {
261
- log("no valid hwnd, skipping");
262
- return;
263
- }
264
-
265
- if (!psHost || !psHostReady) {
266
- log(`PS host not ready (host=${!!psHost}, ready=${psHostReady}), respawning...`);
267
- spawnHost();
268
- return;
269
- }
270
-
271
- const elapsedLabel = taskStartTime > 0 ? `⏱ ${formatElapsed(Date.now() - taskStartTime)}` : "";
272
-
273
- const payload = JSON.stringify({
274
- title,
275
- body,
276
- hwnd,
277
- winTitle,
278
- dismissLabel: t("dismissBtn"),
279
- continueLabel: t("continueBtn"),
280
- mute3Label: t("mute3"),
281
- mute30Label: t("mute30"),
282
- mute60Label: t("mute60"),
283
- muteOffLabel: t("muteOff"),
284
- timeoutSec: config.timeout,
285
- opacityVal: config.opacity.toFixed(2),
286
- elapsedLabel,
287
- });
288
-
289
- try {
290
- psHost.stdin!.write(payload + "\n");
291
- log("notify payload sent to PS host");
292
- } catch (e: unknown) {
293
- log(`notify stdin write failed: ${e}`);
294
- spawnHost();
295
- }
296
- }
297
-
298
- function notifyMacOS(title: string, body: string): void {
299
- spawn("osascript", [
300
- "-e",
301
- `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`,
302
- ], { detached: true, stdio: "ignore" }).unref();
303
- }
304
-
305
- function notifyLinux(title: string, body: string): void {
306
- spawn("notify-send", [title, body], { detached: true, stdio: "ignore" }).unref();
307
- }
308
-
309
- function showNotification(title: string, body: string): void {
310
- if (process.env.KITTY_WINDOW_ID) {
311
- process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
312
- process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
313
- } else if (process.env.GHOSTTY_RESOURCES_DIR ||
314
- process.env.ITERM_SESSION_ID ||
315
- process.env.WEZTERM_PANE) {
316
- process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
317
- } else if (platform() === "win32") {
318
- notifyWindows(title, body, terminalHwnd, uniqueWindowId);
319
- } else if (platform() === "darwin") {
320
- notifyMacOS(title, body);
321
- } else {
322
- notifyLinux(title, body);
323
- }
324
- }
325
-
326
- // ── 提取通知标题 ──────────────────────────────────────────────────────────
327
-
328
- function extractPromptTitle(ctx: ExtensionContext): string {
329
- try {
330
- const entries = ctx.sessionManager.getEntries();
331
- for (let i = entries.length - 1; i >= 0; i--) {
332
- const entry = entries[i] as Record<string, unknown>;
333
- const msg = entry.message as Record<string, unknown> | undefined;
334
- const role = (entry.role ?? msg?.role) as string | undefined;
335
- if (role === "user") {
336
- const content = (entry.content ?? msg?.content);
337
- const text = extractText(content);
338
- if (text) {
339
- const cleaned = text.replace(/\s+/g, " ").trim();
340
- return cleaned.length > 10
341
- ? cleaned.slice(0, 25) + "…"
342
- : cleaned;
343
- }
344
- break;
345
- }
346
- }
347
- } catch { /* fallback */ }
348
- return "pi";
349
- }
350
-
351
- function extractText(content: unknown): string {
352
- if (typeof content === "string") return content;
353
- if (Array.isArray(content)) {
354
- for (const block of content) {
355
- if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
356
- const t = (block as { text?: string }).text;
357
- if (t) return t;
358
- }
359
- }
360
- }
361
- return "";
362
- }
363
-
364
- // ── 前台检测(koffi 直调 GetForegroundWindow)─────────────────────
365
-
366
- function isTerminalForeground(): boolean {
367
- if (platform() !== "win32" || !terminalHwnd || terminalHwnd === "0" || !GetForegroundWindow) {
368
- return false;
369
- }
370
- try {
371
- const fgHwnd = String(GetForegroundWindow());
372
- const match = fgHwnd === terminalHwnd;
373
- log(`fg-check: foreground=${fgHwnd} match=${match}`);
374
- return match;
375
- } catch {
376
- return false;
377
- }
378
- }
379
-
380
- // ── 异常结束检测 ──────────────────────────────────────────────────────────
381
-
382
- const RETRY_PATTERN = /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
383
-
384
- function getLastAssistantMessage(event: { messages?: unknown[] }): { stopReason?: string; errorMessage?: string } | null {
385
- try {
386
- const msgs = event.messages as Array<{ role?: string; stopReason?: string; errorMessage?: string }>;
387
- if (!msgs) return null;
388
- for (let i = msgs.length - 1; i >= 0; i--) {
389
- if (msgs[i].role === "assistant") return msgs[i];
390
- }
391
- } catch { /* */ }
392
- return null;
393
- }
394
-
395
- function willRetry(msg: { stopReason?: string; errorMessage?: string }): boolean {
396
- if (msg.stopReason !== "error" || !msg.errorMessage) return false;
397
- return RETRY_PATTERN.test(msg.errorMessage);
398
- }
399
-
400
- // ── 扩展入口 ────────────────────────────────────────────────────────────────
401
-
402
- export default function (pi: ExtensionAPI) {
403
- log("extension loaded");
404
- piApi = pi;
405
- initWin32();
406
- uniqueWindowId = `pi@${process.pid.toString(36)}`;
407
- process.stdout.write(`\x1b]0;${uniqueWindowId}\x07`);
408
- log(`windowId = ${uniqueWindowId}`);
409
- cacheTerminalHwnd();
410
-
411
- // 恢复上次未过期的勿扰(仅设置状态,到期检查由 agent_end 处理)
412
- if (config.muteUntil && config.muteUntil > Date.now()) {
413
- enabled = false;
414
- log(`mute restored: ${Math.round((config.muteUntil - Date.now()) / 60000)}m remaining`);
415
- }
416
-
417
- spawnHost();
418
-
419
- // ── /notify 命令 ──────────────────────────────────────────────────────────
420
- pi.registerCommand("notify", {
421
- description: "开关/配置桌面通知",
422
- getArgumentCompletions: (prefix) => {
423
- const parts = prefix.trim().split(/\s+/).filter(Boolean);
424
- const wantsNextLevel = prefix.endsWith(" ");
425
-
426
- if (parts.length === 0 || (parts.length === 1 && !wantsNextLevel)) {
427
- const subs = ["on", "off", "timeout", "opacity", "message", "lang", "status"];
428
- const filtered = subs.filter((s) => s.startsWith(parts[0] ?? ""));
429
- return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
430
- }
431
- const sub = parts[0];
432
- const val = parts[1] ?? "";
433
- if (sub === "timeout") {
434
- return ["5", "10", "15", "20", "30", "60"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: `${s}s` }));
435
- }
436
- if (sub === "opacity") {
437
- return ["0.5", "0.6", "0.7", "0.8", "0.9", "1.0"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s }));
438
- }
439
- if (sub === "message") {
440
- return ["fixed", "response"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s === "response" ? "AI reply (20 chars)" : "Fixed text" }));
441
- }
442
- if (sub === "lang") {
443
- return ["zh", "en", "ja", "ko"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s }));
444
- }
445
- return null;
446
- },
447
- handler: async (args, ctx) => {
448
- const raw = args?.trim() ?? "";
449
- const parts = raw.split(/\s+/);
450
- const sub = parts[0]?.toLowerCase();
451
- const val = parts.slice(1).join(" ").toLowerCase();
452
-
453
- if (!sub) {
454
- enabled = !enabled;
455
- ctx.ui.notify(enabled ? t("enabled") : t("disabled"), "info");
456
- return;
457
- }
458
-
459
- if (sub === "on" || sub === "1" || sub === "true") { enabled = true; ctx.ui.notify(t("enabled"), "info"); return; }
460
- if (sub === "off" || sub === "0" || sub === "false") { enabled = false; ctx.ui.notify(t("disabled"), "info"); return; }
461
-
462
- if (sub === "timeout") {
463
- const n = parseInt(val);
464
- if (n >= 5 && n <= 60) { config.timeout = n; saveConfig(config); ctx.ui.notify(`Timeout=${n}s`, "info"); }
465
- else { ctx.ui.notify("timeout: 5~60", "warning"); }
466
- return;
467
- }
468
-
469
- if (sub === "opacity") {
470
- const n = parseFloat(val);
471
- if (n >= 0.3 && n <= 1.0) { config.opacity = n; saveConfig(config); ctx.ui.notify(`Opacity=${n}`, "info"); }
472
- else { ctx.ui.notify("opacity: 0.3~1.0", "warning"); }
473
- return;
474
- }
475
-
476
- if (sub === "message") {
477
- if (val === "fixed" || val === "response") {
478
- config.messageMode = val; saveConfig(config);
479
- ctx.ui.notify(`Message=${val}`, "info");
480
- } else { ctx.ui.notify("Usage: /notify message fixed|response", "warning"); }
481
- return;
482
- }
483
-
484
- if (sub === "lang") {
485
- if (i18n[val]) { config.lang = val as typeof config.lang; saveConfig(config); ctx.ui.notify(`Language=${val}`, "info"); }
486
- else { ctx.ui.notify("Available: zh en ja ko", "warning"); }
487
- return;
488
- }
489
-
490
- // /notify status
491
- if (sub === "status") {
492
- const hostStatus = psHostReady ? "🟢" : psHost ? "🟡" : "🔴";
493
- const crashInfo = psHostCrashCount > 0 ? ` 重启${psHostCrashCount}次` : "";
494
- const lastErr = psHostLastError ? ` (${psHostLastError})` : "";
495
- ctx.ui.notify(
496
- `状态${hostStatus}${crashInfo}${lastErr} | ${t("timeoutLabel")}=${config.timeout}s ${t("opacityLabel")}=${config.opacity} ${t("modeLabel")}=${config.messageMode} ${t("langLabel")}=${config.lang} ${enabled ? t("on") : t("off")}`,
497
- psHostReady ? "info" : "warning",
498
- );
499
- return;
500
- }
501
-
502
- ctx.ui.notify(`Unknown: ${raw} — try /notify status`, "warning");
503
- },
504
- });
505
-
506
- // ── 事件 ──────────────────────────────────────────────────────────────────
507
- pi.on("session_start", async () => {
508
- log("session_start");
509
- });
510
-
511
- let isCompacting = false;
512
- pi.on("session_before_compact", () => { isCompacting = true; log("compaction started"); });
513
- pi.on("session_compact", () => { isCompacting = false; log("compaction ended"); });
514
-
515
- pi.on("agent_start", () => {
516
- taskStartTime = Date.now();
517
- if (notifyTimer) {
518
- clearTimeout(notifyTimer);
519
- notifyTimer = null;
520
- log(`agent_start: cancelled pending notification`);
521
- }
522
- });
523
-
524
- pi.on("agent_end", (_event, ctx) => {
525
- // 实时检查勿扰过期(每次 agent_end 读文件,跨实例自动同步)
526
- if (config.muteUntil && Date.now() > config.muteUntil) {
527
- enabled = true;
528
- saveMuteUntil(undefined);
529
- log("mute expired (checked on agent_end)");
530
- if (piApi) piApi.ui.notify("通知已恢复 🔔", "info");
531
- }
532
-
533
- log(`agent_end: enabled=${enabled} compacting=${isCompacting}`);
534
- if (!enabled || isCompacting) return;
535
-
536
- const msg = getLastAssistantMessage(_event);
537
- if (msg) log(`agent_end: stopReason=${msg.stopReason} error=${!!msg.errorMessage}`);
538
-
539
- if (msg) {
540
- if (msg.stopReason === "aborted") { log("aborted, skip"); return; }
541
- if (msg.stopReason === "error" && !willRetry(msg)) { log("non-retryable error, skip"); return; }
542
- }
543
-
544
- const title = extractPromptTitle(ctx);
545
-
546
- if (notifyTimer) clearTimeout(notifyTimer);
547
- const delay = (msg && willRetry(msg)) ? 10000 : 2000;
548
- log(`agent_end: delay=${delay}ms`);
549
- notifyTimer = setTimeout(() => {
550
- notifyTimer = null;
551
- if (isCompacting) { log("compaction in progress, skip"); return; }
552
- const inForeground = isTerminalForeground();
553
- log(`foreground: ${inForeground}`);
554
- if (inForeground) return;
555
- const body = config.messageMode === "response"
556
- ? extractSummary(_event) ?? completionMsg()
557
- : completionMsg();
558
- log(`notification: "${title}" body="${body}"`);
559
- showNotification(title, body);
560
- }, delay);
561
- });
562
- }
1
+ /**
2
+ * Pi Desktop Notification — 桌面通知扩展
3
+ *
4
+ * pi 完成输出时右下角弹出 WPF 暗色通知窗口。切到其他程序时不会错过。
5
+ *
6
+ * ## 使用
7
+ *
8
+ * /notify 切换通知开关 (on/off)
9
+ * /notify on|off 直接设置
10
+ * 弹窗按钮: 知道了 / 继续(切回终端并聚焦)
11
+ * 全局快捷键: Alt+[ = 知道了 Alt+] = 继续
12
+ *
13
+ * ## 行为
14
+ *
15
+ * - 右下角置顶、不抢焦点、80% 半透明、15 秒自动消失
16
+ * - 通知标题 = 当前轮用户消息前 10 字
17
+ * - LLM 超时重试时自动抑制(2 秒冷却期,只在主动权交还用户时弹)
18
+ * - 多 pi 窗口安全(各自独立的句柄缓存)
19
+ * - Footer 状态指示: 🔔 开启 / 🔕 关闭
20
+ * ## 文件
21
+ *
22
+ * 本文件放在 ~/.pi/agent/extensions/ 下自动生效。
23
+ * 调试日志: %TEMP%/pi-notify-debug.log
24
+ *
25
+ * ## 跨平台
26
+ *
27
+ * Windows: WPF 暗色窗口 (本扩展主要针对)
28
+ * macOS: osascript Notification Center
29
+ * Linux: notify-send
30
+ */
31
+
32
+ import koffi from "koffi";
33
+ import { spawn, type ChildProcess } from "node:child_process";
34
+ import { platform, tmpdir } from "node:os";
35
+ import { dirname, join } from "node:path";
36
+ import { fileURLToPath } from "node:url";
37
+ import { appendFileSync, readFileSync, existsSync } from "node:fs";
38
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
39
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
40
+
41
+ const __filename = fileURLToPath(import.meta.url);
42
+ const __dirname = dirname(__filename);
43
+
44
+ // ── Win32 API via koffi(零 temp 文件,零 PowerShell)─────────────────
45
+ let GetForegroundWindow: () => number;
46
+ let GetWindowTextW: (hwnd: number, buf: unknown, maxCount: number) => number;
47
+ let GetWindowTextLengthW: (hwnd: number) => number;
48
+
49
+ function initWin32(): void {
50
+ if (platform() !== "win32") return;
51
+ try {
52
+ const user32 = koffi.load("user32.dll");
53
+ GetForegroundWindow = user32.func("intptr_t GetForegroundWindow()");
54
+ GetWindowTextW = user32.func("int GetWindowTextW(intptr_t hWnd, char16_t* lpString, int nMaxCount)");
55
+ GetWindowTextLengthW = user32.func("int GetWindowTextLengthW(intptr_t hWnd)");
56
+ log("koffi: Win32 API initialized");
57
+ } catch (e: unknown) {
58
+ log(`koffi: init failed — ${e}`);
59
+ }
60
+ }
61
+
62
+ // ── 状态 ─────────────────────────────────────────────────────────────────────
63
+ let enabled = true;
64
+ let uniqueWindowId = "";
65
+ let notifyTimer: ReturnType<typeof setTimeout> | null = null;
66
+ let taskStartTime = 0;
67
+ let psHost: ChildProcess | null = null;
68
+ let psHostReady = false;
69
+ let psHostCrashCount = 0;
70
+ let psHostLastError = "";
71
+ let piApi: ExtensionAPI | null = null;
72
+
73
+ // ── 可配置项 ────────────────────────────────────────────────────────────────
74
+ const CONFIG_PATH = join(getAgentDir(), "notify.json");
75
+ type Config = { timeout: number; opacity: number; messageMode: "fixed" | "response"; lang: "zh" | "en" | "ja" | "ko"; muteUntil?: number };
76
+
77
+ function loadConfig(): Config {
78
+ try {
79
+ if (existsSync(CONFIG_PATH)) {
80
+ const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
81
+ return { timeout: saved.timeout ?? 15, opacity: saved.opacity ?? 1.0, messageMode: saved.messageMode ?? "response", lang: saved.lang ?? "en", muteUntil: saved.muteUntil };
82
+ }
83
+ } catch { /* */ }
84
+ return { timeout: 15, opacity: 1.0, messageMode: "response", lang: "en" };
85
+ }
86
+
87
+ function saveConfig(c: Config): void {
88
+ try {
89
+ const { writeFileSync } = require("node:fs");
90
+ writeFileSync(CONFIG_PATH, JSON.stringify(c, null, 2), "utf-8");
91
+ } catch { /* */ }
92
+ }
93
+
94
+ function saveMuteUntil(ts: number | undefined): void {
95
+ config.muteUntil = ts;
96
+ saveConfig(config);
97
+ }
98
+
99
+ const config = loadConfig();
100
+
101
+ // ── i18n ────────────────────────────────────────────────────────────────────
102
+ const i18n: Record<string, Record<string, string>> = {
103
+ zh: { dismissBtn: " 知 道 了 ", continueBtn: " 继 续 ", completion: "任务完成", switchBack: "可以切回来了", enabled: "通知已开启 🔔", disabled: "通知已关闭 🔕", configTitle: "通知配置", timeoutLabel: "超时", opacityLabel: "不透明度", modeLabel: "模式", langLabel: "语言", statusLabel: "状态", on: "开", off: "关", mute3: "3 分钟", mute30: "30 分钟", mute60: "1 小时", muteOff: "关闭勿扰" },
104
+ en: { dismissBtn: " Dismiss ", continueBtn: "Continue", completion: "Task complete", switchBack: "Switch back", enabled: "Notify ON 🔔", disabled: "Notify OFF 🔕", configTitle: "Notify Config", timeoutLabel: "Timeout", opacityLabel: "Opacity", modeLabel: "Mode", langLabel: "Language", statusLabel: "Status", on: "ON", off: "OFF", mute3: "3 min", mute30: "30 min", mute60: "1 hour", muteOff: "Turn off" },
105
+ ja: { dismissBtn: " 閉じる ", continueBtn: " 続 行 ", completion: "完了", switchBack: "戻れます", enabled: "通知ON 🔔", disabled: "通知OFF 🔕", configTitle: "通知設定", timeoutLabel: "タイムアウト", opacityLabel: "不透明度", modeLabel: "モード", langLabel: "言語", statusLabel: "状態", on: "ON", off: "OFF", mute3: "3 分", mute30: "30 分", mute60: "1 時間", muteOff: "オフ" },
106
+ ko: { dismissBtn: " 닫 기 ", continueBtn: " 계 속 ", completion: "완료", switchBack: "돌아가기", enabled: "알림 ON 🔔", disabled: "알림 OFF 🔕", configTitle: "알림 설정", timeoutLabel: "시간제한", opacityLabel: "불투명도", modeLabel: "모드", langLabel: "언어", statusLabel: "상태", on: "ON", off: "OFF", mute3: "3 분", mute30: "30 분", mute60: "1 시간", muteOff: "끄기" },
107
+ };
108
+ function t(key: string): string { return i18n[config.lang]?.[key] ?? i18n.zh[key] ?? key; }
109
+ function completionMsg(): string { return `${t("completion")},${t("switchBack")} 🎉`; }
110
+
111
+ function formatElapsed(ms: number): string {
112
+ if (ms < 1000) return `${ms}ms`;
113
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
114
+ const m = Math.floor(ms / 60000);
115
+ const s = Math.round((ms % 60000) / 1000);
116
+ return `${m}m ${s}s`;
117
+ }
118
+
119
+ function extractSummary(event: { messages?: unknown[] }): string | null {
120
+ try {
121
+ const msgs = event.messages as Array<{ role?: string; content?: unknown }>;
122
+ if (!msgs) return null;
123
+ for (let i = msgs.length - 1; i >= 0; i--) {
124
+ if (msgs[i].role === "assistant") {
125
+ const text = extractText(msgs[i].content);
126
+ if (text) {
127
+ const cleaned = text.replace(/\s+/g, " ").trim();
128
+ return cleaned.length > 50 ? cleaned.slice(0, 50) + "…" : cleaned;
129
+ }
130
+ }
131
+ }
132
+ } catch { /* */ }
133
+ return null;
134
+ }
135
+
136
+ // ── 窗口句柄缓存(koffi 直调 user32.dll)────────────────────────────
137
+ let terminalHwnd = "";
138
+ let terminalTitle = "";
139
+
140
+ function cacheTerminalHwnd(): void {
141
+ if (platform() !== "win32" || !GetForegroundWindow) return;
142
+ try {
143
+ const hwnd = GetForegroundWindow();
144
+ if (!hwnd) return;
145
+ const len = GetWindowTextLengthW(hwnd);
146
+ if (len > 0) {
147
+ const buf = Buffer.alloc((len + 1) * 2);
148
+ GetWindowTextW(hwnd, buf, len + 1);
149
+ terminalTitle = buf.toString("utf16le").replace(/\0+$/, "");
150
+ }
151
+ terminalHwnd = String(hwnd);
152
+ log(`cached terminal: hwnd=${terminalHwnd} title="${terminalTitle}"`);
153
+ } catch (e: unknown) {
154
+ log(`FAIL cache hwnd: ${e}`);
155
+ }
156
+ }
157
+
158
+ // ── 窗口标识 ─────────────────────────────────────────────────────────────────
159
+ function generateWindowId(): string {
160
+ return `pi@${process.pid.toString(36)}`;
161
+ }
162
+
163
+ function setWindowTitle(title: string): void {
164
+ process.stdout.write(`\x1b]0;${title}\x07`);
165
+ }
166
+
167
+ // ── 调试日志 ─────────────────────────────────────────────────────────────────
168
+ const LOG = join(tmpdir(), "pi-notify-debug.log");
169
+ function log(msg: string): void {
170
+ const ts = new Date().toISOString();
171
+ try { appendFileSync(LOG, `[${ts}] ${msg}\n`, "utf-8"); } catch { /* ignore */ }
172
+ }
173
+
174
+ // ── 常驻 PowerShell 通知宿主(host.ps1 独立文件,一次编译,后续 stdin 一行即弹)
175
+
176
+ function spawnHost(): void {
177
+ if (platform() !== "win32") return;
178
+ if (psHost) {
179
+ try { psHost.kill(); } catch { /* */ }
180
+ psHost = null;
181
+ }
182
+ psHostReady = false;
183
+
184
+ log("spawning PS host...");
185
+ const psPath = join(__dirname, "host.ps1");
186
+
187
+ const child = spawn("powershell.exe", [
188
+ "-NoProfile",
189
+ "-ExecutionPolicy", "Bypass",
190
+ "-WindowStyle", "Hidden",
191
+ "-File", psPath,
192
+ ], {
193
+ windowsHide: true,
194
+ stdio: ["pipe", "pipe", "pipe"],
195
+ });
196
+
197
+ let stdoutBuf = "";
198
+ child.stdout.on("data", (chunk: Buffer) => {
199
+ stdoutBuf += chunk.toString("utf-8");
200
+ const lines = stdoutBuf.split("\n");
201
+ stdoutBuf = lines.pop() || "";
202
+ for (const line of lines) {
203
+ const trimmed = line.trim();
204
+ if (!trimmed) continue;
205
+ if (trimmed === "READY") {
206
+ psHostReady = true;
207
+ psHostCrashCount = 0;
208
+ psHostLastError = "";
209
+ log("PS host ready");
210
+ } else if (trimmed === "OK") {
211
+ log("PS host: notification dismissed");
212
+ } else if (trimmed.startsWith("ERROR:")) {
213
+ log(`PS host error: ${trimmed}`);
214
+ } else if (trimmed.startsWith("MUTE:")) {
215
+ const mins = parseInt(trimmed.slice(5));
216
+ if (mins > 0) {
217
+ enabled = false;
218
+ saveMuteUntil(Date.now() + mins * 60000);
219
+ log(`mute: notifications off for ${mins}m`);
220
+ } else {
221
+ enabled = true;
222
+ saveMuteUntil(undefined);
223
+ log("mute off");
224
+ }
225
+ }
226
+ }
227
+ });
228
+
229
+ child.stderr.on("data", (chunk: Buffer) => {
230
+ log(`PS host stderr: ${chunk.toString("utf-8").trim()}`);
231
+ });
232
+
233
+ child.on("close", (code) => {
234
+ log(`PS host exited (code=${code})`);
235
+ if (code !== 0 && code !== null) {
236
+ psHostCrashCount++;
237
+ psHostLastError = `exit code ${code}`;
238
+ if (piApi) {
239
+ piApi.ui.notify(`桌面通知服务异常 (${psHostLastError}),下次弹窗时自动恢复`, "warning");
240
+ }
241
+ }
242
+ psHost = null;
243
+ psHostReady = false;
244
+ });
245
+
246
+ child.on("error", (err) => {
247
+ log(`PS host spawn error: ${err.message}`);
248
+ psHost = null;
249
+ psHostReady = false;
250
+ });
251
+
252
+ psHost = child;
253
+ }
254
+
255
+ // ── 桌面通知 ────────────────────────────────────────────────────────────────
256
+
257
+ function notifyWindows(title: string, body: string, hwnd: string, winTitle: string): void {
258
+ log(`notifyWindows: title="${title}" hwnd=${hwnd} win="${winTitle}"`);
259
+
260
+ if (!hwnd || hwnd === "0") {
261
+ log("no valid hwnd, skipping");
262
+ return;
263
+ }
264
+
265
+ if (!psHost || !psHostReady) {
266
+ log(`PS host not ready (host=${!!psHost}, ready=${psHostReady}), respawning...`);
267
+ spawnHost();
268
+ return;
269
+ }
270
+
271
+ const elapsedLabel = taskStartTime > 0 ? `⏱ ${formatElapsed(Date.now() - taskStartTime)}` : "";
272
+
273
+ const payload = JSON.stringify({
274
+ title,
275
+ body,
276
+ hwnd,
277
+ winTitle,
278
+ dismissLabel: t("dismissBtn"),
279
+ continueLabel: t("continueBtn"),
280
+ mute3Label: t("mute3"),
281
+ mute30Label: t("mute30"),
282
+ mute60Label: t("mute60"),
283
+ muteOffLabel: t("muteOff"),
284
+ timeoutSec: config.timeout,
285
+ opacityVal: config.opacity.toFixed(2),
286
+ elapsedLabel,
287
+ });
288
+
289
+ try {
290
+ psHost.stdin!.write(payload + "\n");
291
+ log("notify payload sent to PS host");
292
+ } catch (e: unknown) {
293
+ log(`notify stdin write failed: ${e}`);
294
+ spawnHost();
295
+ }
296
+ }
297
+
298
+ function notifyMacOS(title: string, body: string): void {
299
+ spawn("osascript", [
300
+ "-e",
301
+ `display notification "${body.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`,
302
+ ], { detached: true, stdio: "ignore" }).unref();
303
+ }
304
+
305
+ function notifyLinux(title: string, body: string): void {
306
+ spawn("notify-send", [title, body], { detached: true, stdio: "ignore" }).unref();
307
+ }
308
+
309
+ function showNotification(title: string, body: string): void {
310
+ if (process.env.KITTY_WINDOW_ID) {
311
+ process.stdout.write(`\x1b]99;i=1:d=0;${title}\x1b\\`);
312
+ process.stdout.write(`\x1b]99;i=1:p=body;${body}\x1b\\`);
313
+ } else if (process.env.GHOSTTY_RESOURCES_DIR ||
314
+ process.env.ITERM_SESSION_ID ||
315
+ process.env.WEZTERM_PANE) {
316
+ process.stdout.write(`\x1b]777;notify;${title};${body}\x07`);
317
+ } else if (platform() === "win32") {
318
+ notifyWindows(title, body, terminalHwnd, uniqueWindowId);
319
+ } else if (platform() === "darwin") {
320
+ notifyMacOS(title, body);
321
+ } else {
322
+ notifyLinux(title, body);
323
+ }
324
+ }
325
+
326
+ // ── 提取通知标题 ──────────────────────────────────────────────────────────
327
+
328
+ function extractPromptTitle(ctx: ExtensionContext): string {
329
+ try {
330
+ const entries = ctx.sessionManager.getEntries();
331
+ for (let i = entries.length - 1; i >= 0; i--) {
332
+ const entry = entries[i] as Record<string, unknown>;
333
+ const msg = entry.message as Record<string, unknown> | undefined;
334
+ const role = (entry.role ?? msg?.role) as string | undefined;
335
+ if (role === "user") {
336
+ const content = (entry.content ?? msg?.content);
337
+ const text = extractText(content);
338
+ if (text) {
339
+ const cleaned = text.replace(/\s+/g, " ").trim();
340
+ return cleaned.length > 10
341
+ ? cleaned.slice(0, 25) + "…"
342
+ : cleaned;
343
+ }
344
+ break;
345
+ }
346
+ }
347
+ } catch { /* fallback */ }
348
+ return "pi";
349
+ }
350
+
351
+ function extractText(content: unknown): string {
352
+ if (typeof content === "string") return content;
353
+ if (Array.isArray(content)) {
354
+ for (const block of content) {
355
+ if (block && typeof block === "object" && (block as { type?: string }).type === "text") {
356
+ const t = (block as { text?: string }).text;
357
+ if (t) return t;
358
+ }
359
+ }
360
+ }
361
+ return "";
362
+ }
363
+
364
+ // ── 前台检测(koffi 直调 GetForegroundWindow)─────────────────────
365
+
366
+ function isTerminalForeground(): boolean {
367
+ if (platform() !== "win32" || !terminalHwnd || terminalHwnd === "0" || !GetForegroundWindow) {
368
+ return false;
369
+ }
370
+ try {
371
+ const fgHwnd = String(GetForegroundWindow());
372
+ const match = fgHwnd === terminalHwnd;
373
+ log(`fg-check: foreground=${fgHwnd} match=${match}`);
374
+ return match;
375
+ } catch {
376
+ return false;
377
+ }
378
+ }
379
+
380
+ // ── 异常结束检测 ──────────────────────────────────────────────────────────
381
+
382
+ const RETRY_PATTERN = /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|stream ended before message_stop|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
383
+
384
+ function getLastAssistantMessage(event: { messages?: unknown[] }): { stopReason?: string; errorMessage?: string } | null {
385
+ try {
386
+ const msgs = event.messages as Array<{ role?: string; stopReason?: string; errorMessage?: string }>;
387
+ if (!msgs) return null;
388
+ for (let i = msgs.length - 1; i >= 0; i--) {
389
+ if (msgs[i].role === "assistant") return msgs[i];
390
+ }
391
+ } catch { /* */ }
392
+ return null;
393
+ }
394
+
395
+ function willRetry(msg: { stopReason?: string; errorMessage?: string }): boolean {
396
+ if (msg.stopReason !== "error" || !msg.errorMessage) return false;
397
+ return RETRY_PATTERN.test(msg.errorMessage);
398
+ }
399
+
400
+ // ── 扩展入口 ────────────────────────────────────────────────────────────────
401
+
402
+ export default function (pi: ExtensionAPI) {
403
+ log("extension loaded");
404
+ piApi = pi;
405
+ initWin32();
406
+ uniqueWindowId = `pi@${process.pid.toString(36)}`;
407
+ process.stdout.write(`\x1b]0;${uniqueWindowId}\x07`);
408
+ log(`windowId = ${uniqueWindowId}`);
409
+ cacheTerminalHwnd();
410
+
411
+ // 恢复上次未过期的勿扰(仅设置状态,到期检查由 agent_end 处理)
412
+ if (config.muteUntil && config.muteUntil > Date.now()) {
413
+ enabled = false;
414
+ log(`mute restored: ${Math.round((config.muteUntil - Date.now()) / 60000)}m remaining`);
415
+ }
416
+
417
+ spawnHost();
418
+
419
+ // ── /notify 命令 ──────────────────────────────────────────────────────────
420
+ pi.registerCommand("notify", {
421
+ description: "开关/配置桌面通知",
422
+ getArgumentCompletions: (prefix) => {
423
+ const parts = prefix.trim().split(/\s+/).filter(Boolean);
424
+ const wantsNextLevel = prefix.endsWith(" ");
425
+
426
+ if (parts.length === 0 || (parts.length === 1 && !wantsNextLevel)) {
427
+ const subs = ["on", "off", "timeout", "opacity", "message", "lang", "status"];
428
+ const filtered = subs.filter((s) => s.startsWith(parts[0] ?? ""));
429
+ return filtered.length > 0 ? filtered.map((s) => ({ value: s, label: s })) : null;
430
+ }
431
+ const sub = parts[0];
432
+ const val = parts[1] ?? "";
433
+ if (sub === "timeout") {
434
+ return ["5", "10", "15", "20", "30", "60"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: `${s}s` }));
435
+ }
436
+ if (sub === "opacity") {
437
+ return ["0.5", "0.6", "0.7", "0.8", "0.9", "1.0"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s }));
438
+ }
439
+ if (sub === "message") {
440
+ return ["fixed", "response"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s === "response" ? "AI reply (20 chars)" : "Fixed text" }));
441
+ }
442
+ if (sub === "lang") {
443
+ return ["zh", "en", "ja", "ko"].filter((s) => s.startsWith(val)).map((s) => ({ value: `${sub} ${s}`, label: s }));
444
+ }
445
+ return null;
446
+ },
447
+ handler: async (args, ctx) => {
448
+ const raw = args?.trim() ?? "";
449
+ const parts = raw.split(/\s+/);
450
+ const sub = parts[0]?.toLowerCase();
451
+ const val = parts.slice(1).join(" ").toLowerCase();
452
+
453
+ if (!sub) {
454
+ enabled = !enabled;
455
+ ctx.ui.notify(enabled ? t("enabled") : t("disabled"), "info");
456
+ return;
457
+ }
458
+
459
+ if (sub === "on" || sub === "1" || sub === "true") { enabled = true; ctx.ui.notify(t("enabled"), "info"); return; }
460
+ if (sub === "off" || sub === "0" || sub === "false") { enabled = false; ctx.ui.notify(t("disabled"), "info"); return; }
461
+
462
+ if (sub === "timeout") {
463
+ const n = parseInt(val);
464
+ if (n >= 5 && n <= 60) { config.timeout = n; saveConfig(config); ctx.ui.notify(`Timeout=${n}s`, "info"); }
465
+ else { ctx.ui.notify("timeout: 5~60", "warning"); }
466
+ return;
467
+ }
468
+
469
+ if (sub === "opacity") {
470
+ const n = parseFloat(val);
471
+ if (n >= 0.3 && n <= 1.0) { config.opacity = n; saveConfig(config); ctx.ui.notify(`Opacity=${n}`, "info"); }
472
+ else { ctx.ui.notify("opacity: 0.3~1.0", "warning"); }
473
+ return;
474
+ }
475
+
476
+ if (sub === "message") {
477
+ if (val === "fixed" || val === "response") {
478
+ config.messageMode = val; saveConfig(config);
479
+ ctx.ui.notify(`Message=${val}`, "info");
480
+ } else { ctx.ui.notify("Usage: /notify message fixed|response", "warning"); }
481
+ return;
482
+ }
483
+
484
+ if (sub === "lang") {
485
+ if (i18n[val]) { config.lang = val as typeof config.lang; saveConfig(config); ctx.ui.notify(`Language=${val}`, "info"); }
486
+ else { ctx.ui.notify("Available: zh en ja ko", "warning"); }
487
+ return;
488
+ }
489
+
490
+ // /notify status
491
+ if (sub === "status") {
492
+ const hostStatus = psHostReady ? "🟢" : psHost ? "🟡" : "🔴";
493
+ const crashInfo = psHostCrashCount > 0 ? ` 重启${psHostCrashCount}次` : "";
494
+ const lastErr = psHostLastError ? ` (${psHostLastError})` : "";
495
+ ctx.ui.notify(
496
+ `状态${hostStatus}${crashInfo}${lastErr} | ${t("timeoutLabel")}=${config.timeout}s ${t("opacityLabel")}=${config.opacity} ${t("modeLabel")}=${config.messageMode} ${t("langLabel")}=${config.lang} ${enabled ? t("on") : t("off")}`,
497
+ psHostReady ? "info" : "warning",
498
+ );
499
+ return;
500
+ }
501
+
502
+ ctx.ui.notify(`Unknown: ${raw} — try /notify status`, "warning");
503
+ },
504
+ });
505
+
506
+ // ── 事件 ──────────────────────────────────────────────────────────────────
507
+ pi.on("session_start", async () => {
508
+ log("session_start");
509
+ });
510
+
511
+ let isCompacting = false;
512
+ pi.on("session_before_compact", () => { isCompacting = true; log("compaction started"); });
513
+ pi.on("session_compact", () => { isCompacting = false; log("compaction ended"); });
514
+
515
+ pi.on("agent_start", () => {
516
+ taskStartTime = Date.now();
517
+ if (notifyTimer) {
518
+ clearTimeout(notifyTimer);
519
+ notifyTimer = null;
520
+ log(`agent_start: cancelled pending notification`);
521
+ }
522
+ });
523
+
524
+ pi.on("agent_end", (_event, ctx) => {
525
+ // 实时检查勿扰过期(每次 agent_end 读文件,跨实例自动同步)
526
+ if (config.muteUntil && Date.now() > config.muteUntil) {
527
+ enabled = true;
528
+ saveMuteUntil(undefined);
529
+ log("mute expired (checked on agent_end)");
530
+ if (piApi) piApi.ui.notify("通知已恢复 🔔", "info");
531
+ }
532
+
533
+ log(`agent_end: enabled=${enabled} compacting=${isCompacting}`);
534
+ if (!enabled || isCompacting) return;
535
+
536
+ const msg = getLastAssistantMessage(_event);
537
+ if (msg) log(`agent_end: stopReason=${msg.stopReason} error=${!!msg.errorMessage}`);
538
+
539
+ if (msg) {
540
+ if (msg.stopReason === "aborted") { log("aborted, skip"); return; }
541
+ if (msg.stopReason === "error" && !willRetry(msg)) { log("non-retryable error, skip"); return; }
542
+ }
543
+
544
+ const title = extractPromptTitle(ctx);
545
+
546
+ if (notifyTimer) clearTimeout(notifyTimer);
547
+ const delay = (msg && willRetry(msg)) ? 10000 : 2000;
548
+ log(`agent_end: delay=${delay}ms`);
549
+ notifyTimer = setTimeout(() => {
550
+ notifyTimer = null;
551
+ if (isCompacting) { log("compaction in progress, skip"); return; }
552
+ const inForeground = isTerminalForeground();
553
+ log(`foreground: ${inForeground}`);
554
+ if (inForeground) return;
555
+ const body = config.messageMode === "response"
556
+ ? extractSummary(_event) ?? completionMsg()
557
+ : completionMsg();
558
+ log(`notification: "${title}" body="${body}"`);
559
+ showNotification(title, body);
560
+ }, delay);
561
+ });
562
+ }