@pi-unipi/notify 2.0.2 → 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 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
- results.push(
274
- `✗ Native: ${err instanceof Error ? err.message : "failed"}`
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 allSuccess = results.length > 0 && results.every((r) => r.success);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/notify",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "Cross-platform notification extension for Pi — native OS, Gotify, and Telegram notifications for agent lifecycle events",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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
+ }
@@ -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
- * Resolves when notification is shown, rejects on error.
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
@@ -30,6 +30,7 @@ export const DEFAULT_CONFIG: NotifyConfig = {
30
30
  },
31
31
  native: {
32
32
  enabled: true,
33
+ suppressWhenFocused: false,
33
34
  },
34
35
  gotify: {
35
36
  enabled: false,
@@ -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 4; // native, gotify, telegram, ntfy
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
- const key = platforms[this.selectedIndex];
102
- if (key === "ntfy") {
103
- // ntfy toggle updates the resolved ntfy config
104
- this.ntfyConfig.enabled = !this.ntfyConfig.enabled;
105
- } else if (key) {
106
- this.config[key].enabled = !this.config[key].enabled;
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
  }