@pi-unipi/notify 2.0.3 → 2.0.4
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 +2 -2
- package/commands.ts +9 -4
- package/events.ts +12 -2
- package/package.json +1 -1
- package/platforms/focus-win.ts +123 -0
- package/platforms/focus.ts +33 -0
- package/platforms/native.ts +33 -1
- package/settings.ts +1 -0
- package/tui/settings-overlay.ts +33 -7
- package/types.ts +8 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Push notifications when things happen. Workflow finishes, Ralph loop completes, MCP server errors — notify sends alerts to native OS, Gotify, Telegram, or ntfy.
|
|
4
4
|
|
|
5
|
-
Configure once, get alerts everywhere. Per-event platform routing lets you send critical errors to Telegram and routine completions to Gotify.
|
|
5
|
+
Configure once, get alerts everywhere. Per-event platform routing lets you send critical errors to Telegram and routine completions to Gotify. Native desktop notifications can also be suppressed while the Pi window is focused.
|
|
6
6
|
|
|
7
7
|
## Commands
|
|
8
8
|
|
|
@@ -53,7 +53,7 @@ Desktop notifications via [node-notifier](https://github.com/mikaelbr/node-notif
|
|
|
53
53
|
- **macOS:** terminal-notifier
|
|
54
54
|
- **Linux:** notify-send / libnotify
|
|
55
55
|
|
|
56
|
-
Zero configuration — works out of the box.
|
|
56
|
+
Zero configuration — works out of the box. Set `native.suppressWhenFocused` to `true` to skip native notifications when the active/focused window is already Pi.
|
|
57
57
|
|
|
58
58
|
### Gotify
|
|
59
59
|
|
package/commands.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { NtfySetupOverlay } from "./tui/ntfy-setup.js";
|
|
|
14
14
|
import { RecapModelSelectorOverlay } from "./tui/recap-model-selector.js";
|
|
15
15
|
import { loadConfig } from "./settings.js";
|
|
16
16
|
import { loadNtfyConfig } from "./ntfy-config.js";
|
|
17
|
-
import { sendNativeNotification } from "./platforms/native.js";
|
|
17
|
+
import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
|
|
18
18
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
19
19
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
20
20
|
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
@@ -267,12 +267,17 @@ export function registerNotifyCommands(pi: ExtensionAPI): void {
|
|
|
267
267
|
try {
|
|
268
268
|
await sendNativeNotification(title, message, {
|
|
269
269
|
windowsAppId: config.native.windowsAppId,
|
|
270
|
+
suppressWhenFocused: config.native.suppressWhenFocused,
|
|
270
271
|
});
|
|
271
272
|
results.push("✓ Native: sent");
|
|
272
273
|
} catch (err) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
274
|
+
if (err instanceof SuppressedError) {
|
|
275
|
+
results.push("— Native: suppressed (window focused)");
|
|
276
|
+
} else {
|
|
277
|
+
results.push(
|
|
278
|
+
`✗ Native: ${err instanceof Error ? err.message : "failed"}`
|
|
279
|
+
);
|
|
280
|
+
}
|
|
276
281
|
}
|
|
277
282
|
}
|
|
278
283
|
|
package/events.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
|
|
|
9
9
|
import { UNIPI_EVENTS, emitEvent } from "@pi-unipi/core";
|
|
10
10
|
import type { NotifyConfig, NotifyPlatform, NotifyDispatchResult } from "./types.js";
|
|
11
11
|
import { loadNtfyConfig } from "./ntfy-config.js";
|
|
12
|
-
import { sendNativeNotification } from "./platforms/native.js";
|
|
12
|
+
import { sendNativeNotification, SuppressedError } from "./platforms/native.js";
|
|
13
13
|
import { sendGotifyNotification } from "./platforms/gotify.js";
|
|
14
14
|
import { sendTelegramNotification } from "./platforms/telegram.js";
|
|
15
15
|
import { sendNtfyNotification } from "./platforms/ntfy.js";
|
|
@@ -184,6 +184,10 @@ export async function dispatchNotification(
|
|
|
184
184
|
await sendToPlatform(platform, title, message, config, cwd);
|
|
185
185
|
return { platform, success: true };
|
|
186
186
|
} catch (err) {
|
|
187
|
+
// SuppressedError is intentional, not a failure
|
|
188
|
+
if (err instanceof SuppressedError) {
|
|
189
|
+
return { platform, success: true, suppressed: true };
|
|
190
|
+
}
|
|
187
191
|
// Silently ignore — platform send failure is tracked in results.
|
|
188
192
|
return {
|
|
189
193
|
platform,
|
|
@@ -194,13 +198,18 @@ export async function dispatchNotification(
|
|
|
194
198
|
})
|
|
195
199
|
);
|
|
196
200
|
|
|
197
|
-
const
|
|
201
|
+
const unsuppressed = results.filter((r) => !r.suppressed);
|
|
202
|
+
const allSuccess = results.length > 0 && unsuppressed.every((r) => r.success);
|
|
203
|
+
const suppressedPlatforms = results
|
|
204
|
+
.filter((r) => r.suppressed)
|
|
205
|
+
.map((r) => r.platform);
|
|
198
206
|
|
|
199
207
|
// Emit notification sent event
|
|
200
208
|
emitEvent(pi, UNIPI_EVENTS.NOTIFICATION_SENT, {
|
|
201
209
|
eventType,
|
|
202
210
|
platforms: enabledPlatforms,
|
|
203
211
|
success: allSuccess,
|
|
212
|
+
...(suppressedPlatforms.length > 0 && { suppressedPlatforms }),
|
|
204
213
|
timestamp: new Date().toISOString(),
|
|
205
214
|
});
|
|
206
215
|
|
|
@@ -219,6 +228,7 @@ async function sendToPlatform(
|
|
|
219
228
|
case "native":
|
|
220
229
|
await sendNativeNotification(title, message, {
|
|
221
230
|
windowsAppId: config.native.windowsAppId,
|
|
231
|
+
suppressWhenFocused: config.native.suppressWhenFocused,
|
|
222
232
|
});
|
|
223
233
|
break;
|
|
224
234
|
case "gotify":
|
package/package.json
CHANGED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Windows focus detection
|
|
3
|
+
*
|
|
4
|
+
* Checks whether the terminal window is the foreground (active) window
|
|
5
|
+
* by walking the WMI process tree upward from the current process PID
|
|
6
|
+
* and comparing each ancestor against the foreground window's owner PID.
|
|
7
|
+
*
|
|
8
|
+
* This approach works reliably across cmd, PowerShell, and Windows
|
|
9
|
+
* Terminal, unlike GetConsoleWindow which returns NULL in spawned
|
|
10
|
+
* child processes.
|
|
11
|
+
*
|
|
12
|
+
* Requires PowerShell (built-in on Windows 7+).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from "child_process";
|
|
16
|
+
import { writeFileSync, rmSync, mkdtempSync } from "fs";
|
|
17
|
+
import { join } from "path";
|
|
18
|
+
import { tmpdir } from "os";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// PowerShell script (embedded)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
const POWERCHECK_SCRIPT = `
|
|
25
|
+
param($targetPid)
|
|
26
|
+
Add-Type @'
|
|
27
|
+
using System;
|
|
28
|
+
using System.Runtime.InteropServices;
|
|
29
|
+
public class WinAPI {
|
|
30
|
+
[DllImport("user32.dll", SetLastError = false)]
|
|
31
|
+
public static extern IntPtr GetForegroundWindow();
|
|
32
|
+
[DllImport("user32.dll", SetLastError = false)]
|
|
33
|
+
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
|
34
|
+
}
|
|
35
|
+
'@ | Out-Null
|
|
36
|
+
$fgHwnd = [WinAPI]::GetForegroundWindow()
|
|
37
|
+
[uint32]$fgPid = 0
|
|
38
|
+
[void][WinAPI]::GetWindowThreadProcessId($fgHwnd, [ref]$fgPid)
|
|
39
|
+
$curPid = $targetPid
|
|
40
|
+
$maxDepth = 20
|
|
41
|
+
while ($curPid -gt 0 -and $maxDepth-- -gt 0) {
|
|
42
|
+
if ($curPid -eq $fgPid) { Write-Host -NoNewline 'True'; exit }
|
|
43
|
+
$proc = Get-CimInstance -Class Win32_Process -Filter "ProcessId = $curPid" -ErrorAction SilentlyContinue | Select-Object -Property ParentProcessId
|
|
44
|
+
if (-not $proc) { break }
|
|
45
|
+
$curPid = $proc.ParentProcessId
|
|
46
|
+
}
|
|
47
|
+
Write-Host -NoNewline 'False'
|
|
48
|
+
`;
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Cache — avoid spawning PowerShell on every check
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
let cached: { result: boolean; time: number } | null = null;
|
|
55
|
+
const CACHE_TTL_MS = 500;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Public API
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns true when the terminal window that owns the current process is
|
|
63
|
+
* the foreground (active) window on Windows.
|
|
64
|
+
*
|
|
65
|
+
* Works by:
|
|
66
|
+
* 1. Calling Win32 GetForegroundWindow + GetWindowThreadProcessId to
|
|
67
|
+
* obtain the foreground window's owning PID.
|
|
68
|
+
* 2. Walking the WMI Win32_Process parent chain upward from
|
|
69
|
+
* process.pid.
|
|
70
|
+
* 3. If any ancestor PID matches the foreground PID the terminal is
|
|
71
|
+
* considered focused.
|
|
72
|
+
*
|
|
73
|
+
* The result is cached for 500 ms to avoid spawning PowerShell on rapid
|
|
74
|
+
* consecutive checks (e.g. batch notifications).
|
|
75
|
+
*/
|
|
76
|
+
export async function isWindowFocusedOnWindows(): Promise<boolean> {
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
if (cached && now - cached.time < CACHE_TTL_MS) {
|
|
79
|
+
return cached.result;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let tmpDir: string | null = null;
|
|
83
|
+
try {
|
|
84
|
+
tmpDir = mkdtempSync(join(tmpdir(), "pi-focus-"));
|
|
85
|
+
const scriptPath = join(tmpDir, "check.ps1");
|
|
86
|
+
writeFileSync(scriptPath, POWERCHECK_SCRIPT, "utf-8");
|
|
87
|
+
|
|
88
|
+
const stdout = await new Promise<string>((resolve, reject) => {
|
|
89
|
+
execFile(
|
|
90
|
+
"powershell.exe",
|
|
91
|
+
[
|
|
92
|
+
"-NoProfile",
|
|
93
|
+
"-NonInteractive",
|
|
94
|
+
"-ExecutionPolicy",
|
|
95
|
+
"Bypass",
|
|
96
|
+
"-File",
|
|
97
|
+
scriptPath,
|
|
98
|
+
String(process.pid),
|
|
99
|
+
],
|
|
100
|
+
{ timeout: 5000, encoding: "utf-8" },
|
|
101
|
+
(err, out) => {
|
|
102
|
+
if (err) reject(err);
|
|
103
|
+
else resolve(out);
|
|
104
|
+
}
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
cached = { result: stdout.trim() === "True", time: Date.now() };
|
|
109
|
+
return cached.result;
|
|
110
|
+
} catch {
|
|
111
|
+
// Detection failure → safe default: assume NOT focused (don't suppress)
|
|
112
|
+
cached = { result: false, time: Date.now() };
|
|
113
|
+
return false;
|
|
114
|
+
} finally {
|
|
115
|
+
if (tmpDir) {
|
|
116
|
+
try {
|
|
117
|
+
rmSync(tmpDir, { recursive: true });
|
|
118
|
+
} catch {
|
|
119
|
+
// Temp file cleanup is non-critical
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/notify — Focus detection abstraction
|
|
3
|
+
*
|
|
4
|
+
* Unified interface for checking whether the terminal window is the
|
|
5
|
+
* foreground (active) window. Platform-specific implementations are
|
|
6
|
+
* dispatched based on process.platform.
|
|
7
|
+
*
|
|
8
|
+
* Currently implemented:
|
|
9
|
+
* - Windows (win32): calls focus-win.ts
|
|
10
|
+
*
|
|
11
|
+
* Unimplemented platforms always return false (no suppression).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { isWindowFocusedOnWindows } from "./focus-win.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check whether the current terminal/console window is the foreground
|
|
18
|
+
* (active) window. Used by sendNativeNotification to optionally
|
|
19
|
+
* suppress notifications when the user is already looking at the screen.
|
|
20
|
+
*
|
|
21
|
+
* @returns true if the terminal is the foreground window, false otherwise.
|
|
22
|
+
* On unimplemented platforms, always returns false.
|
|
23
|
+
*/
|
|
24
|
+
export async function isWindowFocused(): Promise<boolean> {
|
|
25
|
+
switch (process.platform) {
|
|
26
|
+
case "win32":
|
|
27
|
+
return await isWindowFocusedOnWindows();
|
|
28
|
+
// TODO: macOS — use osascript to check frontmost application
|
|
29
|
+
// TODO: Linux — use xdotool (X11) or per-compositor tool (Wayland)
|
|
30
|
+
default:
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
}
|
package/platforms/native.ts
CHANGED
|
@@ -8,22 +8,54 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import notifier from "node-notifier";
|
|
11
|
+
import { isWindowFocused } from "./focus.js";
|
|
11
12
|
|
|
12
13
|
/** Options for native notification */
|
|
13
14
|
export interface NativeNotificationOptions {
|
|
14
15
|
/** Windows appID to show instead of "SnoreToast" */
|
|
15
16
|
windowsAppId?: string;
|
|
17
|
+
/**
|
|
18
|
+
* When true, suppresses the notification if the terminal window is
|
|
19
|
+
* the foreground (active) window. Only effective on platforms where
|
|
20
|
+
* `isWindowFocused` is implemented (currently Windows).
|
|
21
|
+
*/
|
|
22
|
+
suppressWhenFocused?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Thrown by sendNativeNotification when the notification was suppressed
|
|
27
|
+
* because suppressWhenFocused is set and the terminal window is focused.
|
|
28
|
+
*
|
|
29
|
+
* Callers should catch this and treat it as intentional suppression,
|
|
30
|
+
* NOT as a send failure.
|
|
31
|
+
*/
|
|
32
|
+
export class SuppressedError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super("Notification suppressed: terminal window is focused");
|
|
35
|
+
this.name = "SuppressedError";
|
|
36
|
+
}
|
|
16
37
|
}
|
|
17
38
|
|
|
18
39
|
/**
|
|
19
40
|
* Send a native OS notification.
|
|
20
|
-
*
|
|
41
|
+
*
|
|
42
|
+
* When `suppressWhenFocused` is true and `isWindowFocused()` returns true
|
|
43
|
+
* (i.e. the terminal is the foreground window), the notification is
|
|
44
|
+
* suppressed and the promise rejects with SuppressedError.
|
|
45
|
+
*
|
|
46
|
+
* Resolves when notification is shown, rejects with SuppressedError on
|
|
47
|
+
* suppression or with a standard Error on failure.
|
|
21
48
|
*/
|
|
22
49
|
export async function sendNativeNotification(
|
|
23
50
|
title: string,
|
|
24
51
|
message: string,
|
|
25
52
|
options?: NativeNotificationOptions
|
|
26
53
|
): Promise<void> {
|
|
54
|
+
// Suppress if the terminal window is currently focused
|
|
55
|
+
if (options?.suppressWhenFocused && await isWindowFocused()) {
|
|
56
|
+
throw new SuppressedError();
|
|
57
|
+
}
|
|
58
|
+
|
|
27
59
|
return new Promise((resolve, reject) => {
|
|
28
60
|
notifier.notify(
|
|
29
61
|
{
|
package/settings.ts
CHANGED
package/tui/settings-overlay.ts
CHANGED
|
@@ -85,7 +85,7 @@ export class NotifySettingsOverlay implements Component {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
private get maxItems(): number {
|
|
88
|
-
if (this.section === "platforms") return
|
|
88
|
+
if (this.section === "platforms") return 5; // native, gotify, telegram, ntfy + suppress option
|
|
89
89
|
if (this.section === "recap") return 1; // toggle
|
|
90
90
|
return Object.keys(this.config.events).length;
|
|
91
91
|
}
|
|
@@ -98,12 +98,17 @@ export class NotifySettingsOverlay implements Component {
|
|
|
98
98
|
"telegram",
|
|
99
99
|
"ntfy",
|
|
100
100
|
];
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
101
|
+
if (this.selectedIndex < platforms.length) {
|
|
102
|
+
const key = platforms[this.selectedIndex];
|
|
103
|
+
if (key === "ntfy") {
|
|
104
|
+
// ntfy toggle updates the resolved ntfy config
|
|
105
|
+
this.ntfyConfig.enabled = !this.ntfyConfig.enabled;
|
|
106
|
+
} else if (key) {
|
|
107
|
+
this.config[key].enabled = !this.config[key].enabled;
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
// suppressWhenFocused toggle (index 4)
|
|
111
|
+
this.config.native.suppressWhenFocused = !this.config.native.suppressWhenFocused;
|
|
107
112
|
}
|
|
108
113
|
} else if (this.section === "recap") {
|
|
109
114
|
this.config.recap.enabled = !this.config.recap.enabled;
|
|
@@ -273,6 +278,27 @@ export class NotifySettingsOverlay implements Component {
|
|
|
273
278
|
)
|
|
274
279
|
);
|
|
275
280
|
}
|
|
281
|
+
|
|
282
|
+
// suppressWhenFocused toggle (index 4)
|
|
283
|
+
{
|
|
284
|
+
const i = platforms.length;
|
|
285
|
+
const isSelected = i === this.selectedIndex;
|
|
286
|
+
const isEnabled = this.config.native.suppressWhenFocused === true;
|
|
287
|
+
const toggleOn = this.fg("success", "●");
|
|
288
|
+
const toggleOff = this.fg("dim", "○");
|
|
289
|
+
const toggle = isEnabled ? toggleOn : toggleOff;
|
|
290
|
+
const label = isSelected
|
|
291
|
+
? this.bold("Suppress when focused")
|
|
292
|
+
: this.fg("dim", "Suppress when focused");
|
|
293
|
+
const detail = this.fg("dim", isEnabled ? "Windows only — terminal in foreground → skip" : "Windows only");
|
|
294
|
+
|
|
295
|
+
lines.push(
|
|
296
|
+
this.frameLine(
|
|
297
|
+
`${isSelected ? this.fg("accent", "▸") : " "} ${toggle} ${label} ${detail}`,
|
|
298
|
+
innerWidth
|
|
299
|
+
)
|
|
300
|
+
);
|
|
301
|
+
}
|
|
276
302
|
}
|
|
277
303
|
|
|
278
304
|
private renderEvents(lines: string[], innerWidth: number): void {
|
package/types.ts
CHANGED
|
@@ -19,6 +19,12 @@ export interface NativeConfig {
|
|
|
19
19
|
enabled: boolean;
|
|
20
20
|
/** Windows appID to show instead of "SnoreToast" */
|
|
21
21
|
windowsAppId?: string;
|
|
22
|
+
/**
|
|
23
|
+
* When true, suppresses the notification if the terminal window is the
|
|
24
|
+
* foreground (active) window. Only effective on supported platforms
|
|
25
|
+
* (currently Windows). Default: false.
|
|
26
|
+
*/
|
|
27
|
+
suppressWhenFocused?: boolean;
|
|
22
28
|
}
|
|
23
29
|
|
|
24
30
|
/** Gotify notification platform config */
|
|
@@ -101,6 +107,8 @@ export interface NotifyResult {
|
|
|
101
107
|
platform: NotifyPlatform;
|
|
102
108
|
/** Whether the send succeeded */
|
|
103
109
|
success: boolean;
|
|
110
|
+
/** True when the notification was intentionally suppressed (e.g. window focused) */
|
|
111
|
+
suppressed?: boolean;
|
|
104
112
|
/** Error message if failed */
|
|
105
113
|
error?: string;
|
|
106
114
|
}
|