@mohak34/opencode-notifier 0.1.36-beta.0 → 0.1.37-beta.0

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.
Files changed (3) hide show
  1. package/README.md +3 -2
  2. package/dist/index.js +72 -11
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -320,15 +320,16 @@ To disable this and always get notified:
320
320
  | macOS | AppleScript (`System Events`) | None | Untested |
321
321
  | Linux X11 | `xdotool` | `xdotool` installed | Untested |
322
322
  | Linux Wayland (Hyprland) | `hyprctl activewindow` | None | Tested |
323
+ | Linux Wayland (Niri) | `niri msg --json focused-window` | None | Tested |
323
324
  | Linux Wayland (Sway) | `swaymsg -t get_tree` | None | Untested |
324
325
  | Linux Wayland (KDE) | `kdotool` | `kdotool` installed | Untested |
325
326
  | Linux Wayland (GNOME) | Not supported | - | Falls back to always notifying |
326
- | Linux Wayland (Niri, river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
327
+ | Linux Wayland (river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
327
328
  | Windows | `GetForegroundWindow()` via PowerShell | None | Untested |
328
329
 
329
330
  **Unsupported compositors**: Wayland has no standard protocol for querying the focused window. Each compositor has its own IPC, and GNOME intentionally doesn't expose focus information. Unsupported compositors fall back to always notifying.
330
331
 
331
- **tmux/screen**: When running inside tmux, the tmux server daemonizes and detaches from the terminal's process tree. The plugin handles this by querying the tmux client PID and walking from there. GNU Screen has the same issue but is not currently handled (falls back to always notifying).
332
+ **tmux/screen**: When running inside tmux, focus detection uses tmux pane state (`session_attached`, `window_active`, `pane_active`) via `tmux display-message`. This keeps suppression accurate when switching panes/windows/sessions. GNU Screen is not currently handled (falls back to always notifying).
332
333
 
333
334
  **Fail-open design**: If detection fails for any reason (missing tools, unknown compositor, permissions), it falls back to always notifying. It never silently eats your notifications.
334
335
 
package/dist/index.js CHANGED
@@ -250,6 +250,18 @@ if (platform === "Linux" || platform.match(/BSD$/)) {
250
250
  var lastNotificationTime = {};
251
251
  var lastLinuxNotificationId = null;
252
252
  var linuxNotifySendSupportsReplace = null;
253
+ function sanitizeGhosttyField(value) {
254
+ return value.replace(/[;\x07\x1b\n\r]/g, "");
255
+ }
256
+ function formatGhosttyNotificationSequence(title, message, env = process.env) {
257
+ const escapedTitle = sanitizeGhosttyField(title);
258
+ const escapedMessage = sanitizeGhosttyField(message);
259
+ const payload = `\x1B]9;${escapedTitle}: ${escapedMessage}\x07`;
260
+ if (env.TMUX) {
261
+ return `\x1BPtmux;\x1B${payload}\x1B\\`;
262
+ }
263
+ return payload;
264
+ }
253
265
  function detectNotifySendCapabilities() {
254
266
  return new Promise((resolve) => {
255
267
  execFile("notify-send", ["--version"], (error, stdout) => {
@@ -302,9 +314,8 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
302
314
  lastNotificationTime[message] = now;
303
315
  if (notificationSystem === "ghostty") {
304
316
  return new Promise((resolve) => {
305
- const escapedTitle = title.replace(/[;\x07\x1b\n\r]/g, "");
306
- const escapedMessage = message.replace(/[;\x07\x1b\n\r]/g, "");
307
- process.stdout.write(`\x1B]9;${escapedTitle}: ${escapedMessage}\x07`, () => {
317
+ const sequence = formatGhosttyNotificationSequence(title, message);
318
+ process.stdout.write(sequence, () => {
308
319
  resolve();
309
320
  });
310
321
  });
@@ -578,10 +589,23 @@ function getSwayActiveWindowId() {
578
589
  return null;
579
590
  }
580
591
  }
592
+ function getNiriActiveWindowId() {
593
+ const output = execWithTimeout("niri msg --json focused-window", 1000);
594
+ if (!output)
595
+ return null;
596
+ try {
597
+ const data = JSON.parse(output);
598
+ return typeof data?.id === "number" ? String(data.id) : null;
599
+ } catch {
600
+ return null;
601
+ }
602
+ }
581
603
  function getLinuxWaylandActiveWindowId() {
582
604
  const env = process.env;
583
605
  if (env.HYPRLAND_INSTANCE_SIGNATURE)
584
606
  return getHyprlandActiveWindowId();
607
+ if (env.NIRI_SOCKET)
608
+ return getNiriActiveWindowId();
585
609
  if (env.SWAYSOCK)
586
610
  return getSwayActiveWindowId();
587
611
  if (env.KDE_SESSION_VERSION)
@@ -652,16 +676,19 @@ function getActiveWindowId() {
652
676
  return null;
653
677
  }
654
678
  var cachedWindowId = getActiveWindowId();
655
- var tmuxPane = process.env.TMUX_PANE ?? null;
656
- function isTmuxPaneActive() {
679
+ function isTmuxPaneFocused(tmuxPane, probeResult) {
657
680
  if (!tmuxPane)
658
- return true;
659
- const result = execWithTimeout(`tmux display-message -t ${tmuxPane} -p '#{session_attached} #{window_active} #{pane_active}'`);
660
- if (!result)
661
681
  return false;
662
- const [sessionAttached, windowActive, paneActive] = result.split(" ");
682
+ if (!probeResult)
683
+ return false;
684
+ const [sessionAttached, windowActive, paneActive] = probeResult.split(" ");
663
685
  return sessionAttached === "1" && windowActive === "1" && paneActive === "1";
664
686
  }
687
+ function isTmuxPaneActive() {
688
+ const tmuxPane = process.env.TMUX_PANE ?? null;
689
+ const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
690
+ return isTmuxPaneFocused(tmuxPane, result);
691
+ }
665
692
  function isTerminalFocused() {
666
693
  try {
667
694
  if (process.platform === "darwin") {
@@ -687,6 +714,34 @@ function isTerminalFocused() {
687
714
  }
688
715
  }
689
716
 
717
+ // src/permission-dedupe.ts
718
+ var PERMISSION_DEDUPE_WINDOW_MS = 1000;
719
+ var sessionLastPermissionAt = new Map;
720
+ var globalLastPermissionAt = 0;
721
+ function shouldSuppressPermissionAlert(sessionID, now = Date.now()) {
722
+ const sessionLastAt = sessionID ? sessionLastPermissionAt.get(sessionID) : undefined;
723
+ const latestSeen = Math.max(globalLastPermissionAt, sessionLastAt ?? 0);
724
+ const isDuplicate = latestSeen > 0 && now - latestSeen < PERMISSION_DEDUPE_WINDOW_MS;
725
+ if (isDuplicate) {
726
+ return true;
727
+ }
728
+ globalLastPermissionAt = now;
729
+ if (sessionID) {
730
+ sessionLastPermissionAt.set(sessionID, now);
731
+ }
732
+ return false;
733
+ }
734
+ function prunePermissionAlertState(cutoffMs) {
735
+ for (const [sessionID, timestamp] of sessionLastPermissionAt) {
736
+ if (timestamp < cutoffMs) {
737
+ sessionLastPermissionAt.delete(sessionID);
738
+ }
739
+ }
740
+ if (globalLastPermissionAt < cutoffMs) {
741
+ globalLastPermissionAt = 0;
742
+ }
743
+ }
744
+
690
745
  // src/index.ts
691
746
  var IDLE_COMPLETE_DELAY_MS = 350;
692
747
  var pendingIdleTimers = new Map;
@@ -734,6 +789,7 @@ setInterval(() => {
734
789
  sessionLastBusyAt.delete(sessionID);
735
790
  }
736
791
  }
792
+ prunePermissionAlertState(cutoff);
737
793
  }, 5 * 60 * 1000);
738
794
  function getNotificationTitle(config, projectName) {
739
795
  if (config.showProjectName && projectName) {
@@ -924,7 +980,10 @@ var NotifierPlugin = async ({ client, directory }) => {
924
980
  event: async ({ event }) => {
925
981
  const config = getConfig();
926
982
  if (event.type === "permission.asked") {
927
- await handleEventWithElapsedTime(client, config, "permission", projectName, event);
983
+ const sessionID = getSessionIDFromEvent(event);
984
+ if (!shouldSuppressPermissionAlert(sessionID)) {
985
+ await handleEventWithElapsedTime(client, config, "permission", projectName, event);
986
+ }
928
987
  }
929
988
  if (event.type === "session.idle") {
930
989
  const sessionID = getSessionIDFromEvent(event);
@@ -951,7 +1010,9 @@ var NotifierPlugin = async ({ client, directory }) => {
951
1010
  },
952
1011
  "permission.ask": async () => {
953
1012
  const config = getConfig();
954
- await handleEvent(config, "permission", projectName, null);
1013
+ if (!shouldSuppressPermissionAlert(null)) {
1014
+ await handleEvent(config, "permission", projectName, null);
1015
+ }
955
1016
  },
956
1017
  "tool.execute.before": async (input) => {
957
1018
  const config = getConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.36-beta.0",
3
+ "version": "0.1.37-beta.0",
4
4
  "description": "OpenCode plugin that sends system notifications and plays sounds when permission is needed, generation completes, or errors occur",
5
5
  "author": "mohak34",
6
6
  "license": "MIT",