@mohak34/opencode-notifier 0.2.8 → 0.2.9-beta.1

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 +81 -24
  2. package/dist/index.js +471 -70
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -4,13 +4,15 @@ OpenCode plugin that plays sounds and sends system notifications when permission
4
4
 
5
5
  ## Quick Start
6
6
 
7
- Add this to your `opencode.json`:
7
+ Install the plugin via the CLI: `opencode plug -g @mohak34/opencode-notifier`.
8
8
 
9
- ```json
10
- {
11
- "plugin": ["@mohak34/opencode-notifier@latest"]
12
- }
13
- ```
9
+ Or add manually to your `opencode.json`:
10
+
11
+ ```json
12
+ {
13
+ "plugin": ["@mohak34/opencode-notifier@latest"]
14
+ }
15
+ ```
14
16
 
15
17
  Restart OpenCode. Done.
16
18
 
@@ -319,6 +321,13 @@ Run your own script when something happens. Use `{event}`, `{message}`, `{sessio
319
321
  - `args` - Arguments to pass, can use `{event}`, `{message}`, `{sessionTitle}`, `{agentName}`, `{projectName}`, `{timestamp}`, and `{turn}` tokens
320
322
  - `minDuration` - Skip if response was quick, avoids spam (seconds)
321
323
 
324
+ Token values are passed as argv values and are not shell-escaped for use inside
325
+ script source. Do not put `{message}`, `{sessionTitle}`, or other dynamic tokens
326
+ inside a `sh -c`, `bash -c`, `powershell -Command`, or similar script string.
327
+ Use a wrapper script and pass the tokens as separate arguments instead.
328
+ Custom commands run with the same user permissions as OpenCode, so only enable
329
+ scripts you trust.
330
+
322
331
  #### Example: Log events to a file
323
332
 
324
333
  ```json
@@ -328,7 +337,10 @@ Run your own script when something happens. Use `{event}`, `{message}`, `{sessio
328
337
  "path": "/bin/bash",
329
338
  "args": [
330
339
  "-c",
331
- "echo '[{event}] {message}' >> /tmp/opencode.log"
340
+ "printf '[%s] %s\\n' \"$1\" \"$2\" >> /tmp/opencode.log",
341
+ "opencode-notifier",
342
+ "{event}",
343
+ "{message}"
332
344
  ]
333
345
  }
334
346
  }
@@ -425,11 +437,13 @@ This is independent of `command.minDuration`, which only controls whether the cu
425
437
  | Linux Wayland (Niri) | `niri msg --json focused-window` | None | Tested |
426
438
  | Linux Wayland (Sway) | `swaymsg -t get_tree` | None | Untested |
427
439
  | Linux Wayland (KDE) | `kdotool` | `kdotool` installed | Tested |
428
- | Linux Wayland (GNOME) | Not supported | - | Falls back to always notifying |
440
+ | Linux Wayland (GNOME) | AT-SPI (`gdbus` on the `org.a11y.Bus`) | `gdbus` installed | Tested (Ubuntu 26.04.1 LTS + GNOME Shell 50.1 + Ghostty 1.3.0) |
429
441
  | Linux Wayland (river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
430
442
  | Windows | `GetForegroundWindow()` via PowerShell | None | Untested |
431
443
 
432
- **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.
444
+ **GNOME Wayland**: GNOME exposes no compositor API for the focused window (`Introspect.GetWindows` and `Eval` are access-denied) and XWayland tools like `xdotool` cannot see native Wayland windows, so focus is read from the accessibility bus instead: the active terminal window is the one whose AT-SPI `ACTIVE` state bit is set. Ghostty is matched by its `/com/mitchellh/ghostty` AT-SPI path, other terminals by app name (including the `gnome-terminal-server` AT-SPI alias). Window identity is `bus@path` since AT-SPI paths repeat across processes. Implemented and verified on Ubuntu 26.04.1 LTS + GNOME Shell 50.1 + Ghostty 1.3.0. With several terminal windows open, suppression compares against the window that was active at startup. Set `OPENCODE_NOTIFIER_DEBUG=1` to log the focus backend decision.
445
+
446
+ **Unsupported compositors**: Wayland has no standard protocol for querying the focused window. Each compositor has its own IPC. Compositors without a backend (river, dwl, Cosmic, etc.) fall back to always notifying.
433
447
 
434
448
  **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. On Linux setups where window focus cannot be detected at all, tmux pane state is also used as a best-effort fallback. GNU Screen is not currently handled (falls back to always notifying).
435
449
 
@@ -468,25 +482,49 @@ The action button is only enabled on Linux KDE sessions where `kdotool` is avail
468
482
 
469
483
  ## Updating
470
484
 
471
- If Opencode does not update the plugin or there is an issue with the cache version:
485
+ OpenCode caches plugin packages under `~/.cache/opencode`. If you switch between `latest`, `beta`, or a pinned version and OpenCode still uses the old plugin, close OpenCode and remove the cached package.
486
+
487
+ Linux/macOS:
472
488
 
473
489
  ```bash
474
- # Linux/macOS
475
- rm -rf ~/.cache/opencode/packages/@mohak34/opencode-notifier@beta
490
+ rm -rf ~/.cache/opencode/packages/@mohak34/opencode-notifier*
476
491
  rm -rf ~/.cache/opencode/node_modules/@mohak34/opencode-notifier
492
+ rm -f ~/.cache/opencode/bun.lock
493
+ ```
494
+
495
+ Windows PowerShell:
496
+
497
+ ```powershell
498
+ Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\opencode\packages\@mohak34\opencode-notifier*" -ErrorAction SilentlyContinue
499
+ Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\opencode\node_modules\@mohak34\opencode-notifier" -ErrorAction SilentlyContinue
500
+ Remove-Item -Force "$env:USERPROFILE\.cache\opencode\bun.lock" -ErrorAction SilentlyContinue
501
+ ```
477
502
 
478
- # Windows
479
- Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\opencode\packages\@mohak34\opencode-notifier@beta"
480
- Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\opencode\node_modules\@mohak34\opencode-notifier"
503
+ Then reopen OpenCode. It will download the plugin again.
504
+
505
+ To avoid cache confusion while testing, pin the exact version in `opencode.json` instead of using a moving tag:
506
+
507
+ ```json
508
+ {
509
+ "plugin": ["@mohak34/opencode-notifier@x.y.z"]
510
+ }
511
+ ```
512
+
513
+ Check the version published under a tag:
514
+
515
+ ```bash
516
+ npm view @mohak34/opencode-notifier@latest version
517
+ npm view @mohak34/opencode-notifier@beta version
481
518
  ```
482
519
 
483
- Then restart OpenCode.
520
+ Check the version OpenCode cached:
484
521
 
485
- Verify installation:
486
522
  ```bash
487
- cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@beta/node_modules/@mohak34/opencode-notifier/package.json | grep version
523
+ cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@latest/node_modules/@mohak34/opencode-notifier/package.json | grep version
488
524
  ```
489
525
 
526
+ If you use `@beta` or a pinned version, replace `latest` in the path with `beta` or the exact version, for example `0.2.9-beta.0`.
527
+
490
528
  ## Troubleshooting
491
529
 
492
530
  **macOS: Not seeing notifications?**
@@ -550,6 +588,18 @@ Manual pinning bypasses heuristic window matching and should activate that exact
550
588
  **Windows WSL notifications not working?**
551
589
  WSL doesn't have a native notification daemon. Use PowerShell commands instead:
552
590
 
591
+ Save this wrapper as `C:\Users\YourName\bin\opencode-notifier-popup.ps1`:
592
+
593
+ ```powershell
594
+ param(
595
+ [string]$Message,
596
+ [string]$Event
597
+ )
598
+
599
+ $wshell = New-Object -ComObject Wscript.Shell
600
+ $wshell.Popup($Message, 5, ("OpenCode - {0}" -f $Event), 0+64)
601
+ ```
602
+
553
603
  ```json
554
604
  {
555
605
  "notification": false,
@@ -558,8 +608,11 @@ WSL doesn't have a native notification daemon. Use PowerShell commands instead:
558
608
  "enabled": true,
559
609
  "path": "powershell.exe",
560
610
  "args": [
561
- "-Command",
562
- "$wshell = New-Object -ComObject Wscript.Shell; $wshell.Popup('{message}', 5, 'OpenCode - {event}', 0+64)"
611
+ "-NoProfile",
612
+ "-File",
613
+ "C:\\Users\\YourName\\bin\\opencode-notifier-popup.ps1",
614
+ "{message}",
615
+ "{event}"
563
616
  ]
564
617
  }
565
618
  }
@@ -576,8 +629,11 @@ This is a known Bun issue on Windows. Disable native notifications and use Power
576
629
  "enabled": true,
577
630
  "path": "powershell.exe",
578
631
  "args": [
579
- "-Command",
580
- "$wshell = New-Object -ComObject Wscript.Shell; $wshell.Popup('{message}', 5, 'OpenCode - {event}', 0+64)"
632
+ "-NoProfile",
633
+ "-File",
634
+ "C:\\Users\\YourName\\bin\\opencode-notifier-popup.ps1",
635
+ "{message}",
636
+ "{event}"
581
637
  ]
582
638
  }
583
639
  }
@@ -593,10 +649,11 @@ This is a known Bun issue on Windows. Disable native notifications and use Power
593
649
 
594
650
  - Check `suppressWhenFocused`: when `true` (default), notifications are skipped while OpenCode terminal is focused. Set to `false` to always notify.
595
651
  - Check `enableOnDesktop`: defaults to `false`, so the plugin won't run on Desktop/Web clients. Set to `true` if you need it there.
596
- - Verify the package is actually cached:
652
+ - Verify the package version OpenCode cached:
597
653
  ```bash
598
- cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@beta/node_modules/@mohak34/opencode-notifier/package.json | grep version
654
+ cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@latest/node_modules/@mohak34/opencode-notifier/package.json | grep version
599
655
  ```
656
+ If you use `@beta` or a pinned version, replace `latest` in the path with `beta` or the exact version.
600
657
 
601
658
  ## Changelog
602
659
 
package/dist/index.js CHANGED
@@ -105,6 +105,9 @@ var DEFAULT_CONFIG = {
105
105
  linux: {
106
106
  grouping: false
107
107
  },
108
+ windows: {
109
+ appID: "opencode"
110
+ },
108
111
  minDuration: 0,
109
112
  command: {
110
113
  enabled: false,
@@ -242,6 +245,9 @@ function loadConfig() {
242
245
  linux: {
243
246
  grouping: typeof userConfig.linux?.grouping === "boolean" ? userConfig.linux.grouping : DEFAULT_CONFIG.linux.grouping
244
247
  },
248
+ windows: {
249
+ appID: typeof userConfig.windows?.appID === "string" && userConfig.windows.appID.length > 0 ? userConfig.windows.appID : DEFAULT_CONFIG.windows.appID
250
+ },
245
251
  minDuration: typeof userConfig.minDuration === "number" && Number.isFinite(userConfig.minDuration) && userConfig.minDuration >= 0 ? userConfig.minDuration : DEFAULT_CONFIG.minDuration,
246
252
  command: {
247
253
  enabled: typeof userCommand.enabled === "boolean" ? userCommand.enabled : DEFAULT_CONFIG.command.enabled,
@@ -365,7 +371,7 @@ function interpolateMessage(message, context) {
365
371
 
366
372
  // src/notify.ts
367
373
  import os2 from "os";
368
- import { exec, execFile, spawn } from "child_process";
374
+ import { execFile, spawn } from "child_process";
369
375
  import notifier from "node-notifier";
370
376
  var DEBOUNCE_MS = 1000;
371
377
  var platform = os2.type();
@@ -385,7 +391,7 @@ var lastNotificationTime = {};
385
391
  var lastLinuxNotificationId = null;
386
392
  var linuxNotifySendSupportsReplace = null;
387
393
  function sanitizeGhosttyField(value) {
388
- return value.replace(/[;\x07\x1b\n\r]/g, "");
394
+ return value.replace(/[;\u0000-\u001f\u007f-\u009f]/g, "");
389
395
  }
390
396
  function formatGhosttyNotificationSequence(title, message, env = process.env) {
391
397
  const escapedTitle = sanitizeGhosttyField(title);
@@ -522,7 +528,17 @@ function parseNotifySendOutputLine(line) {
522
528
  }
523
529
  return null;
524
530
  }
525
- async function sendNotification(title, message, timeout, iconPath, notificationSystem = "osascript", linuxGrouping = true, onClick) {
531
+ function buildOsascriptNotificationArgs(title, message) {
532
+ return [
533
+ "-e",
534
+ `on run argv
535
+ display notification (item 1 of argv) with title (item 2 of argv)
536
+ end run`,
537
+ message,
538
+ title
539
+ ];
540
+ }
541
+ async function sendNotification(title, message, timeout, iconPath, notificationSystem = "osascript", linuxGrouping = true, onClick, windowsAppID) {
526
542
  const now = Date.now();
527
543
  if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
528
544
  return;
@@ -551,9 +567,7 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
551
567
  });
552
568
  }
553
569
  return new Promise((resolve) => {
554
- const escapedMessage = message.replace(/"/g, "\\\"");
555
- const escapedTitle = title.replace(/"/g, "\\\"");
556
- exec(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`, () => {
570
+ execFile("osascript", buildOsascriptNotificationArgs(title, message), () => {
557
571
  resolve();
558
572
  });
559
573
  });
@@ -587,7 +601,7 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
587
601
  message,
588
602
  timeout,
589
603
  icon: iconPath,
590
- "app-name": "opencode"
604
+ appName: windowsAppID ?? "opencode"
591
605
  };
592
606
  platformNotifier.notify(notificationOptions, (err, response, metadata) => {
593
607
  if (onClick && metadata?.activationType === "default") {
@@ -636,7 +650,8 @@ async function runCommand(command, args) {
636
650
  return new Promise((resolve, reject) => {
637
651
  const proc = spawn2(command, args, {
638
652
  stdio: "ignore",
639
- detached: false
653
+ detached: false,
654
+ windowsHide: true
640
655
  });
641
656
  proc.on("error", (err) => {
642
657
  reject(err);
@@ -674,7 +689,18 @@ async function playOnLinux(soundPath, volume) {
674
689
  const players = [
675
690
  { command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
676
691
  { command: "aplay", args: [soundPath] },
677
- { command: "mpv", args: ["--no-video", "--no-terminal", "--script-opts=autoload-disabled=yes", `--volume=${percentVolume}`, soundPath] },
692
+ {
693
+ command: "mpv",
694
+ args: [
695
+ "--no-video",
696
+ "--no-terminal",
697
+ "--script-opts=autoload-disabled=yes",
698
+ "--keep-open=no",
699
+ "--idle=no",
700
+ `--volume=${percentVolume}`,
701
+ soundPath
702
+ ]
703
+ },
678
704
  { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
679
705
  ];
680
706
  for (const player of players) {
@@ -690,8 +716,9 @@ async function playOnMac(soundPath, volume) {
690
716
  await runCommand("afplay", ["-v", `${volume}`, soundPath]);
691
717
  }
692
718
  async function playOnWindows(soundPath) {
693
- const script = `& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }`;
694
- await runCommand("powershell", ["-c", script, soundPath]);
719
+ const script = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`;
720
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
721
+ await runCommand("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
695
722
  }
696
723
  async function playSound(event, customPath, volume) {
697
724
  const now = Date.now();
@@ -704,9 +731,9 @@ async function playSound(event, customPath, volume) {
704
731
  if (!soundPath) {
705
732
  return;
706
733
  }
707
- const os3 = platform2();
734
+ const os = platform2();
708
735
  try {
709
- switch (os3) {
736
+ switch (os) {
710
737
  case "darwin":
711
738
  await playOnMac(soundPath, normalizedVolume);
712
739
  break;
@@ -758,8 +785,10 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
758
785
  const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
759
786
  const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
760
787
  const proc = spawn3(command, args, {
788
+ shell: false,
761
789
  stdio: "ignore",
762
- detached: true
790
+ detached: true,
791
+ windowsHide: true
763
792
  });
764
793
  proc.on("error", () => {});
765
794
  proc.unref();
@@ -767,9 +796,9 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
767
796
 
768
797
  // src/focus.ts
769
798
  import { execFileSync, execSync } from "child_process";
770
- import { readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
799
+ import { accessSync, constants, mkdtempSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
771
800
  import { tmpdir } from "os";
772
- import { join as join3 } from "path";
801
+ import { delimiter, join as join3 } from "path";
773
802
  var LINUX_TERMINAL_APPS = new Set([
774
803
  "ghostty",
775
804
  "konsole",
@@ -820,13 +849,25 @@ function execFileWithTimeout(command, args, timeoutMs = 500) {
820
849
  return null;
821
850
  }
822
851
  }
852
+ function isNumericWindowId(value) {
853
+ return /^\d+$/.test(value);
854
+ }
855
+ function isSafeCompositorWindowId(value) {
856
+ return /^[A-Za-z0-9._:-]+$/.test(value);
857
+ }
858
+ function firstNumericWindowId(output) {
859
+ if (!output)
860
+ return null;
861
+ const value = output.split(/\s+/).find((part) => isNumericWindowId(part));
862
+ return value ?? null;
863
+ }
823
864
  function getHyprlandActiveWindowId() {
824
865
  const output = execWithTimeout("hyprctl activewindow -j");
825
866
  if (!output)
826
867
  return null;
827
868
  try {
828
869
  const data = JSON.parse(output);
829
- return typeof data?.address === "string" ? data.address : null;
870
+ return typeof data?.address === "string" && isSafeCompositorWindowId(data.address) ? data.address : null;
830
871
  } catch {
831
872
  return null;
832
873
  }
@@ -897,15 +938,213 @@ function getLinuxWaylandActiveWindowId() {
897
938
  if (env.SWAYSOCK)
898
939
  return getSwayActiveWindowId();
899
940
  if (env.KDE_SESSION_VERSION)
900
- return execWithTimeout("kdotool getactivewindow");
941
+ return firstNumericWindowId(execWithTimeout("kdotool getactivewindow"));
942
+ if (isGnomeLikeSession(env))
943
+ return getGnomeAtspiActiveWindowKey();
901
944
  return null;
902
945
  }
903
- function getWindowsActiveWindowId() {
904
- const script = `$type=Add-Type -Name FocusHelper -Namespace OpenCodeNotifier -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();' -PassThru; $type::GetForegroundWindow()`;
905
- let windowId = execFileWithTimeout("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
906
- if (!windowId)
907
- windowId = execFileWithTimeout("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
908
- return windowId;
946
+ var ATSPI_ACTIVE_BIT_INDEX = 1;
947
+ var ATSPI_TERMINAL_PATH_MARKERS = ["mitchellh/ghostty"];
948
+ var ATSPI_TERMINAL_APP_ALIASES = new Set([
949
+ "gnome-terminal-server"
950
+ ]);
951
+ var GNOME_LIKE_DESKTOPS = new Set(["gnome", "ubuntu", "pop"]);
952
+ function isGnomeLikeSession(env = process.env) {
953
+ const desktop = `${env.XDG_CURRENT_DESKTOP ?? ""} ${env.DESKTOP_SESSION ?? ""}`.toLowerCase();
954
+ return desktop.split(/[:;\s]+/).some((token) => GNOME_LIKE_DESKTOPS.has(token));
955
+ }
956
+ function getLinuxFocusBackendName(env = process.env) {
957
+ if (env.HYPRLAND_INSTANCE_SIGNATURE)
958
+ return "hyprland";
959
+ if (env.NIRI_SOCKET)
960
+ return "niri";
961
+ if (env.SWAYSOCK)
962
+ return "sway";
963
+ if (env.KDE_SESSION_VERSION)
964
+ return "kde";
965
+ if (isGnomeLikeSession(env))
966
+ return "gnome-atspi";
967
+ if (env.DISPLAY)
968
+ return "x11";
969
+ if (env.WAYLAND_DISPLAY)
970
+ return "wayland-unsupported";
971
+ return "none";
972
+ }
973
+ function parseAtspiString(output) {
974
+ if (!output)
975
+ return null;
976
+ const match = output.match(/'((?:[^'\\]|\\.)*)'/);
977
+ if (!match)
978
+ return null;
979
+ try {
980
+ return JSON.parse(`"${match[1].replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`);
981
+ } catch {
982
+ return match[1];
983
+ }
984
+ }
985
+ function parseAtspiObjectRefs(output) {
986
+ if (!output)
987
+ return [];
988
+ const refs = [];
989
+ const re = /\('([^']+)',\s*(?:objectpath\s+)?'([^']+)'\)/g;
990
+ let match;
991
+ while ((match = re.exec(output)) !== null) {
992
+ refs.push({ bus: match[1], path: match[2] });
993
+ }
994
+ return refs;
995
+ }
996
+ function parseAtspiStateActive(output) {
997
+ if (!output)
998
+ return null;
999
+ const match = output.match(/uint32\s+(\d+)/);
1000
+ if (!match)
1001
+ return null;
1002
+ const firstWord = Number(match[1]) >>> 0;
1003
+ return (firstWord >>> ATSPI_ACTIVE_BIT_INDEX & 1) === 1;
1004
+ }
1005
+ function isAtspiTerminalWindow(appName, windowPath) {
1006
+ if (ATSPI_TERMINAL_PATH_MARKERS.some((marker) => windowPath.toLowerCase().includes(marker))) {
1007
+ return true;
1008
+ }
1009
+ if (!appName)
1010
+ return false;
1011
+ const normalized = appName.trim().toLowerCase();
1012
+ return LINUX_TERMINAL_APPS.has(normalized) || ATSPI_TERMINAL_APP_ALIASES.has(normalized);
1013
+ }
1014
+ var ATSPI_ROLES = new Set(["window", "frame", "dialog"]);
1015
+ function isAtspiWindowRoleAccepted(role) {
1016
+ return role !== null && ATSPI_ROLES.has(role);
1017
+ }
1018
+ function callAtspi(address, dest, path, method, args = [], timeoutMs = 500) {
1019
+ return execFileWithTimeout("gdbus", [
1020
+ "call",
1021
+ "--address",
1022
+ address,
1023
+ "--dest",
1024
+ dest,
1025
+ "--object-path",
1026
+ path,
1027
+ "--method",
1028
+ method,
1029
+ ...args
1030
+ ], timeoutMs);
1031
+ }
1032
+ var cachedAtspiAddress = null;
1033
+ var ATSPI_ADDRESS_CACHE_TTL_MS = 1e4;
1034
+ function getAtspiBusAddress() {
1035
+ const now = Date.now();
1036
+ if (cachedAtspiAddress && now - cachedAtspiAddress.at < ATSPI_ADDRESS_CACHE_TTL_MS) {
1037
+ return cachedAtspiAddress.address;
1038
+ }
1039
+ cachedAtspiAddress = null;
1040
+ const output = execFileWithTimeout("gdbus", [
1041
+ "call",
1042
+ "--session",
1043
+ "--dest",
1044
+ "org.a11y.Bus",
1045
+ "--object-path",
1046
+ "/org/a11y/bus",
1047
+ "--method",
1048
+ "org.a11y.Bus.GetAddress"
1049
+ ], 1000);
1050
+ const address = parseAtspiString(output);
1051
+ if (address) {
1052
+ cachedAtspiAddress = { address, at: now };
1053
+ }
1054
+ return address;
1055
+ }
1056
+ function getAtspiWindowRole(address, ref) {
1057
+ const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetRoleName");
1058
+ return parseAtspiString(output);
1059
+ }
1060
+ function isAtspiWindowActive(address, ref) {
1061
+ const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetState");
1062
+ return parseAtspiStateActive(output);
1063
+ }
1064
+ function getAtspiTerminalWindowRefs(address) {
1065
+ const refs = [];
1066
+ const rootOutput = callAtspi(address, "org.a11y.atspi.Registry", "/org/a11y/atspi/accessible/root", "org.a11y.atspi.Accessible.GetChildren", [], 1000);
1067
+ for (const app of parseAtspiObjectRefs(rootOutput)) {
1068
+ const appName = parseAtspiString(callAtspi(address, app.bus, "/org/a11y/atspi/accessible/root", "org.freedesktop.DBus.Properties.Get", ["org.a11y.atspi.Accessible", "Name"]));
1069
+ const childrenOutput = callAtspi(address, app.bus, app.path, "org.a11y.atspi.Accessible.GetChildren");
1070
+ for (const child of parseAtspiObjectRefs(childrenOutput)) {
1071
+ if (!isAtspiTerminalWindow(appName, child.path))
1072
+ continue;
1073
+ const role = getAtspiWindowRole(address, child)?.toLowerCase() ?? null;
1074
+ if (isAtspiWindowRoleAccepted(role)) {
1075
+ refs.push(child);
1076
+ }
1077
+ }
1078
+ }
1079
+ return refs;
1080
+ }
1081
+ function getGnomeAtspiActiveWindowKey() {
1082
+ try {
1083
+ const address = getAtspiBusAddress();
1084
+ if (!address)
1085
+ return null;
1086
+ for (const ref of getAtspiTerminalWindowRefs(address)) {
1087
+ if (isAtspiWindowActive(address, ref) === true) {
1088
+ return `atspi:${ref.bus}@${ref.path}`;
1089
+ }
1090
+ }
1091
+ return null;
1092
+ } catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function debugFocusState(message) {
1097
+ if (process.env.OPENCODE_NOTIFIER_DEBUG) {
1098
+ console.error(`[opencode-notifier] ${message}`);
1099
+ }
1100
+ }
1101
+ var WINDOWS_TERMINAL_WINDOW_CLASSES = new Set([
1102
+ "cascadia_hosting_window_class",
1103
+ "consolewindowclass",
1104
+ "windowsterminalwindowclass"
1105
+ ]);
1106
+ var WINDOWS_TERMINAL_PROCESS_NAMES = new Set([
1107
+ "windowsterminal",
1108
+ "windowsterminalpreview",
1109
+ "conhost",
1110
+ "alacritty",
1111
+ "wezterm",
1112
+ "wezterm-gui",
1113
+ "kitty",
1114
+ "hyper",
1115
+ "cursor",
1116
+ "code",
1117
+ "code - insiders"
1118
+ ]);
1119
+ function getWindowsActiveWindowInfo() {
1120
+ const script = `
1121
+ $p=Add-Type -Name NFI -Namespace OpenCodeNotifier -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();[DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetClassName(IntPtr h,System.Text.StringBuilder b,int n);[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p);' -PassThru;
1122
+ $h=$p::GetForegroundWindow();
1123
+ if(!$h){return}
1124
+ $sb=New-Object System.Text.StringBuilder 256;
1125
+ $p::GetClassName($h,$sb,256)|Out-Null;
1126
+ $c=$sb.ToString();
1127
+ $procId=0;
1128
+ $p::GetWindowThreadProcessId($h,[ref]$procId)|Out-Null;
1129
+ $pn='';
1130
+ try{$pn=(Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName}catch{}
1131
+ Write-Output "$c|$pn"
1132
+ `.trim().replace(/\n/g, "; ");
1133
+ let output = execFileWithTimeout("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 5000);
1134
+ if (!output)
1135
+ output = execFileWithTimeout("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
1136
+ if (!output)
1137
+ return null;
1138
+ const line = output.split(`
1139
+ `).map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith("#<"));
1140
+ if (!line)
1141
+ return null;
1142
+ const sep = line.indexOf("|");
1143
+ if (sep === -1)
1144
+ return null;
1145
+ const className = line.substring(0, sep) || null;
1146
+ const processName = line.substring(sep + 1) || null;
1147
+ return { className, processName };
909
1148
  }
910
1149
  function getMacOSActiveWindowId() {
911
1150
  return execWithTimeout(`osascript -e 'tell application "System Events" to return id of window 1 of (first application process whose frontmost is true)'`);
@@ -945,6 +1184,13 @@ function getExpectedMacTerminalAppNames(env) {
945
1184
  }
946
1185
  return new Set(MAC_TERMINAL_APP_NAMES);
947
1186
  }
1187
+ function buildOsascriptActivateAppArgs(appName) {
1188
+ const escapedAppName = appName.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replaceAll("\\", "\\\\").replaceAll('"', "\\\"");
1189
+ return [
1190
+ "-e",
1191
+ `tell application "${escapedAppName}" to activate`
1192
+ ];
1193
+ }
948
1194
  function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
949
1195
  if (!frontmostAppName) {
950
1196
  return false;
@@ -957,18 +1203,18 @@ function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
957
1203
  return expectedApps.has(normalizedFrontmost);
958
1204
  }
959
1205
  function getActiveWindowId() {
960
- const platform3 = process.platform;
961
- if (platform3 === "darwin")
1206
+ const platform = process.platform;
1207
+ if (platform === "darwin")
962
1208
  return getMacOSActiveWindowId();
963
- if (platform3 === "linux") {
1209
+ if (platform === "linux") {
964
1210
  if (process.env.WAYLAND_DISPLAY)
965
1211
  return getLinuxWaylandActiveWindowId();
966
1212
  if (process.env.DISPLAY)
967
- return execWithTimeout("xdotool getactivewindow");
1213
+ return firstNumericWindowId(execWithTimeout("xdotool getactivewindow"));
968
1214
  return null;
969
1215
  }
970
- if (platform3 === "win32")
971
- return getWindowsActiveWindowId();
1216
+ if (platform === "win32")
1217
+ return null;
972
1218
  return null;
973
1219
  }
974
1220
  var cachedWindowId = getActiveWindowId();
@@ -989,15 +1235,15 @@ function isTmuxPaneFocused(tmuxPane, probeResult) {
989
1235
  return Number(sessionAttached) > 0 && windowActive === "1" && paneActive === "1";
990
1236
  }
991
1237
  function isLinuxTerminalFocused(params) {
992
- const { cachedWindowId: cachedWindowId2, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
993
- if (!cachedWindowId2) {
1238
+ const { cachedWindowId, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
1239
+ if (!cachedWindowId) {
994
1240
  if (!wezTermPaneActive)
995
1241
  return false;
996
1242
  if (tmuxPaneActive !== null)
997
1243
  return tmuxPaneActive;
998
1244
  return false;
999
1245
  }
1000
- if (currentWindowId !== cachedWindowId2)
1246
+ if (currentWindowId !== cachedWindowId)
1001
1247
  return false;
1002
1248
  if (!wezTermPaneActive)
1003
1249
  return false;
@@ -1005,6 +1251,12 @@ function isLinuxTerminalFocused(params) {
1005
1251
  return tmuxPaneActive;
1006
1252
  return true;
1007
1253
  }
1254
+ function isWindowsTerminalFocused(params) {
1255
+ const { className, processName } = params;
1256
+ const classLower = className?.toLowerCase() ?? "";
1257
+ const processLower = processName?.toLowerCase() ?? "";
1258
+ return WINDOWS_TERMINAL_WINDOW_CLASSES.has(classLower) || WINDOWS_TERMINAL_PROCESS_NAMES.has(processLower);
1259
+ }
1008
1260
  function isTmuxPaneActive() {
1009
1261
  const tmuxPane = process.env.TMUX_PANE ?? null;
1010
1262
  const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
@@ -1037,25 +1289,37 @@ function isTerminalFocused() {
1037
1289
  }
1038
1290
  return true;
1039
1291
  }
1292
+ if (process.platform === "win32") {
1293
+ const info = getWindowsActiveWindowInfo();
1294
+ return isWindowsTerminalFocused({
1295
+ className: info?.className ?? null,
1296
+ processName: info?.processName ?? null
1297
+ });
1298
+ }
1040
1299
  const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
1041
- return isLinuxTerminalFocused({
1300
+ const currentWindowId = getActiveWindowId();
1301
+ const focused = isLinuxTerminalFocused({
1042
1302
  cachedWindowId,
1043
- currentWindowId: getActiveWindowId(),
1303
+ currentWindowId,
1044
1304
  wezTermPaneActive: isWezTermPaneActive(),
1045
1305
  tmuxPaneActive
1046
1306
  });
1307
+ debugFocusState(`linux focus: backend=${getLinuxFocusBackendName()} session=${process.env.XDG_SESSION_TYPE ?? "?"} desktop=${process.env.XDG_CURRENT_DESKTOP ?? process.env.DESKTOP_SESSION ?? "?"} cached=${cachedWindowId ?? "null"} current=${currentWindowId ?? "null"} tmux=${String(tmuxPaneActive)} focused=${focused}`);
1308
+ return focused;
1047
1309
  } catch {
1048
1310
  return false;
1049
1311
  }
1050
1312
  }
1051
1313
  function getWindowIdFromXdotool(searchTerm) {
1052
- return execWithTimeout(`xdotool search --classname "${searchTerm}" | head -1`);
1314
+ return firstNumericWindowId(execFileWithTimeout("xdotool", ["search", "--classname", searchTerm]));
1053
1315
  }
1054
1316
  function getWindowIdFromKdotool(searchTerm) {
1055
- return execWithTimeout(`kdotool search --classname "${searchTerm}" | head -1`);
1317
+ return firstNumericWindowId(execFileWithTimeout("kdotool", ["search", "--classname", searchTerm]));
1056
1318
  }
1057
1319
  function getWindowTitleFromKdotool(windowId) {
1058
- return execWithTimeout(`kdotool getwindowname ${windowId}`);
1320
+ if (!isNumericWindowId(windowId))
1321
+ return null;
1322
+ return execFileWithTimeout("kdotool", ["getwindowname", windowId]);
1059
1323
  }
1060
1324
  var cachedKDEJumpBackSupport = null;
1061
1325
  function isKDEJumpBackSupported() {
@@ -1069,7 +1333,13 @@ function isKDEJumpBackSupported() {
1069
1333
  return cachedKDEJumpBackSupport;
1070
1334
  }
1071
1335
  function getWindowClassX11(windowId) {
1072
- return execWithTimeout(`xprop -id ${windowId} WM_CLASS 2>/dev/null | awk -F '"' '{print $4}'`);
1336
+ if (!isNumericWindowId(windowId))
1337
+ return null;
1338
+ const output = execFileWithTimeout("xprop", ["-id", windowId, "WM_CLASS"]);
1339
+ if (!output)
1340
+ return null;
1341
+ const matches = [...output.matchAll(/"([^"]*)"/g)].map((match) => match[1]);
1342
+ return matches[1] ?? matches[0] ?? null;
1073
1343
  }
1074
1344
  function getWaylandAppId(windowId) {
1075
1345
  if (process.env.HYPRLAND_INSTANCE_SIGNATURE) {
@@ -1171,8 +1441,10 @@ function getTerminalWindowId() {
1171
1441
  return null;
1172
1442
  }
1173
1443
  function focusLinuxWindowX11(windowId) {
1444
+ if (!isNumericWindowId(windowId))
1445
+ return;
1174
1446
  try {
1175
- execSync(`xdotool windowactivate ${windowId} 2>/dev/null`, { timeout: 1000 });
1447
+ execFileSync("xdotool", ["windowactivate", windowId], { timeout: 1000, stdio: "ignore" });
1176
1448
  } catch {}
1177
1449
  }
1178
1450
  function findTerminalPid() {
@@ -1199,18 +1471,49 @@ function findTerminalPid() {
1199
1471
  return process.ppid;
1200
1472
  }
1201
1473
  }
1474
+ var cachedQdbusBinary;
1475
+ var QDBUS_CANDIDATES = ["qdbus-qt6", "qdbus6", "qdbus-qt5", "qdbus5", "qdbus"];
1476
+ function isExecutableOnPath(name) {
1477
+ const pathEnv = process.env.PATH ?? "";
1478
+ for (const dir of pathEnv.split(delimiter)) {
1479
+ if (!dir)
1480
+ continue;
1481
+ const full = join3(dir, name);
1482
+ try {
1483
+ accessSync(full, constants.X_OK);
1484
+ if (statSync(full).isFile())
1485
+ return true;
1486
+ } catch {}
1487
+ }
1488
+ return false;
1489
+ }
1490
+ function findQdbusBinary(candidates = QDBUS_CANDIDATES, isExecutable = isExecutableOnPath) {
1491
+ for (const candidate of candidates) {
1492
+ if (isExecutable(candidate)) {
1493
+ return candidate;
1494
+ }
1495
+ }
1496
+ return null;
1497
+ }
1498
+ function resolveQdbusBinary() {
1499
+ if (cachedQdbusBinary !== undefined) {
1500
+ return cachedQdbusBinary;
1501
+ }
1502
+ cachedQdbusBinary = findQdbusBinary();
1503
+ return cachedQdbusBinary;
1504
+ }
1202
1505
  function focusKDEWithKWinScript() {
1203
1506
  try {
1204
1507
  const pinnedWindowId = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim() || null;
1205
- if (pinnedWindowId) {
1508
+ if (pinnedWindowId && isNumericWindowId(pinnedWindowId)) {
1206
1509
  try {
1207
- execSync(`kdotool windowactivate ${pinnedWindowId} 2>/dev/null`, { timeout: 1500 });
1510
+ execFileSync("kdotool", ["windowactivate", pinnedWindowId], { timeout: 1500, stdio: "ignore" });
1208
1511
  return;
1209
1512
  } catch {}
1210
1513
  }
1211
- if (cachedWindowId) {
1514
+ if (cachedWindowId && isNumericWindowId(cachedWindowId)) {
1212
1515
  try {
1213
- execSync(`kdotool windowactivate ${cachedWindowId} 2>/dev/null`, { timeout: 1500 });
1516
+ execFileSync("kdotool", ["windowactivate", cachedWindowId], { timeout: 1500, stdio: "ignore" });
1214
1517
  return;
1215
1518
  } catch {}
1216
1519
  }
@@ -1323,41 +1626,83 @@ function findAndActivateTerminal() {
1323
1626
 
1324
1627
  findAndActivateTerminal();
1325
1628
  `;
1326
- const scriptPath = join3(tmpdir(), `opencode-focus-${currentPid}.kwinscript`);
1327
1629
  const pluginName = `opencode-focus-${currentPid}`;
1328
- writeFileSync(scriptPath, scriptContent);
1329
- execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript "${scriptPath}" "${pluginName}"`, { encoding: "utf-8", timeout: 2000 });
1330
- execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.start`, { timeout: 2000 });
1331
- try {
1332
- unlinkSync(scriptPath);
1333
- } catch {}
1334
- setTimeout(() => {
1630
+ let scriptDirectory = null;
1631
+ let scriptPath = null;
1632
+ let scriptLoaded = false;
1633
+ const cleanupScript = () => {
1634
+ if (!scriptPath || !scriptDirectory)
1635
+ return;
1636
+ try {
1637
+ rmSync(scriptPath, { force: true });
1638
+ } catch {}
1335
1639
  try {
1336
- execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript "${pluginName}"`, { timeout: 500 });
1640
+ rmSync(scriptDirectory, { recursive: true, force: true });
1337
1641
  } catch {}
1338
- }, 1000);
1642
+ };
1643
+ const qdbus = resolveQdbusBinary();
1644
+ if (!qdbus) {
1645
+ throw new Error("qdbus not found");
1646
+ }
1647
+ try {
1648
+ scriptDirectory = mkdtempSync(join3(tmpdir(), "opencode-focus-"));
1649
+ scriptPath = join3(scriptDirectory, "script.kwinscript");
1650
+ writeFileSync(scriptPath, scriptContent, { encoding: "utf-8", mode: 384, flag: "wx" });
1651
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.loadScript", scriptPath, pluginName], { encoding: "utf-8", timeout: 2000, stdio: "ignore" });
1652
+ scriptLoaded = true;
1653
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.start"], {
1654
+ timeout: 2000,
1655
+ stdio: "ignore"
1656
+ });
1657
+ cleanupScript();
1658
+ setTimeout(() => {
1659
+ try {
1660
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
1661
+ timeout: 500,
1662
+ stdio: "ignore"
1663
+ });
1664
+ } catch {}
1665
+ }, 1000);
1666
+ } catch {
1667
+ if (scriptLoaded) {
1668
+ try {
1669
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
1670
+ timeout: 500,
1671
+ stdio: "ignore"
1672
+ });
1673
+ } catch {}
1674
+ }
1675
+ cleanupScript();
1676
+ throw new Error("Unable to activate the terminal through KWin");
1677
+ }
1339
1678
  } catch {
1340
1679
  try {
1341
1680
  const cachedId = cachedWindowId;
1342
- if (cachedId) {
1343
- execSync(`xdotool windowactivate ${cachedId} 2>/dev/null`, { timeout: 1000 });
1681
+ if (cachedId && isNumericWindowId(cachedId)) {
1682
+ execFileSync("xdotool", ["windowactivate", cachedId], { timeout: 1000, stdio: "ignore" });
1344
1683
  }
1345
1684
  } catch {}
1346
1685
  }
1347
1686
  }
1348
1687
  function focusLinuxWindowHyprland(windowId) {
1688
+ if (!isSafeCompositorWindowId(windowId))
1689
+ return;
1349
1690
  try {
1350
- execSync(`hyprctl dispatch focuswindow address:${windowId} 2>/dev/null`, { timeout: 1000 });
1691
+ execFileSync("hyprctl", ["dispatch", "focuswindow", `address:${windowId}`], { timeout: 1000, stdio: "ignore" });
1351
1692
  } catch {}
1352
1693
  }
1353
1694
  function focusLinuxWindowSway(windowId) {
1695
+ if (!isNumericWindowId(windowId))
1696
+ return;
1354
1697
  try {
1355
- execSync(`swaymsg "[con_id=${windowId}] focus" 2>/dev/null`, { timeout: 1000 });
1698
+ execFileSync("swaymsg", [`[con_id=${windowId}] focus`], { timeout: 1000, stdio: "ignore" });
1356
1699
  } catch {}
1357
1700
  }
1358
1701
  function focusLinuxWindowNiri(windowId) {
1702
+ if (!isNumericWindowId(windowId))
1703
+ return;
1359
1704
  try {
1360
- execSync(`niri msg action focus-window --id ${windowId} 2>/dev/null`, { timeout: 1000 });
1705
+ execFileSync("niri", ["msg", "action", "focus-window", "--id", windowId], { timeout: 1000, stdio: "ignore" });
1361
1706
  } catch {}
1362
1707
  }
1363
1708
  function captureStartupWindowId() {
@@ -1383,11 +1728,11 @@ async function focusTerminal() {
1383
1728
  const expectedApps = getExpectedMacTerminalAppNames(process.env);
1384
1729
  for (const app of expectedApps) {
1385
1730
  try {
1386
- execSync(`osascript -e 'tell application "${app}" to activate' 2>/dev/null`, { timeout: 1000 });
1731
+ execFileSync("osascript", buildOsascriptActivateAppArgs(app), { timeout: 1000, stdio: "ignore" });
1387
1732
  return;
1388
1733
  } catch {}
1389
1734
  }
1390
- execSync(`osascript -e 'tell application "Terminal" to activate' 2>/dev/null`, { timeout: 1000 });
1735
+ execFileSync("osascript", buildOsascriptActivateAppArgs("Terminal"), { timeout: 1000, stdio: "ignore" });
1391
1736
  } catch {}
1392
1737
  return;
1393
1738
  }
@@ -1442,6 +1787,9 @@ function prunePermissionAlertState(cutoffMs) {
1442
1787
 
1443
1788
  // src/index.ts
1444
1789
  var IDLE_COMPLETE_DELAY_MS = 350;
1790
+ function isCLIClient(clientEnv) {
1791
+ return !clientEnv || clientEnv === "cli";
1792
+ }
1445
1793
  var pendingIdleTimers = new Map;
1446
1794
  var sessionIdleSequence = new Map;
1447
1795
  var sessionErrorSuppressionAt = new Map;
@@ -1492,7 +1840,7 @@ function incrementTurnCount() {
1492
1840
  saveTurnCount(globalTurnCount);
1493
1841
  return globalTurnCount;
1494
1842
  }
1495
- setInterval(() => {
1843
+ var cleanupInterval = setInterval(() => {
1496
1844
  const cutoff = Date.now() - 5 * 60 * 1000;
1497
1845
  for (const [sessionID] of sessionIdleSequence) {
1498
1846
  if (!pendingIdleTimers.has(sessionID)) {
@@ -1512,6 +1860,7 @@ setInterval(() => {
1512
1860
  }
1513
1861
  prunePermissionAlertState(cutoff);
1514
1862
  }, 5 * 60 * 1000);
1863
+ cleanupInterval.unref();
1515
1864
  function getNotificationTitle(config, projectName) {
1516
1865
  if (config.showProjectName && projectName) {
1517
1866
  return `OpenCode (${projectName})`;
@@ -1567,7 +1916,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
1567
1916
  const title = getNotificationTitle(config, projectName);
1568
1917
  const iconPath = getIconPath(config);
1569
1918
  const onNotificationClick = isKDEJumpBackSupported() ? () => void focusTerminal() : undefined;
1570
- promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick));
1919
+ promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick, config.windows.appID));
1571
1920
  }
1572
1921
  if (isEventSoundEnabled(config, eventType)) {
1573
1922
  const customSoundPath = getSoundPath(config, eventType);
@@ -1591,6 +1940,33 @@ function getSessionIDFromEvent(event) {
1591
1940
  const properties = getNestedRecord(event, "properties");
1592
1941
  return getStringField(properties, "sessionID");
1593
1942
  }
1943
+ function getPermissionIDFromEvent(event) {
1944
+ const properties = getNestedRecord(event, "properties");
1945
+ const id = getStringField(properties, "id");
1946
+ if (id) {
1947
+ return id;
1948
+ }
1949
+ const request = getNestedRecord(event, "properties", "request");
1950
+ return getStringField(request, "id");
1951
+ }
1952
+ var PERMISSION_PENDING_GRACE_MS = 300;
1953
+ async function isPermissionStillPending(client, permissionID) {
1954
+ try {
1955
+ const inner = client?._client || client?.session?._client;
1956
+ if (!inner || typeof inner.get !== "function") {
1957
+ return true;
1958
+ }
1959
+ const listResponse = await inner.get({ url: "/permission" });
1960
+ const body = listResponse?.data ?? listResponse;
1961
+ const pendingList = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : null;
1962
+ if (!pendingList) {
1963
+ return true;
1964
+ }
1965
+ return pendingList.some((p) => p?.id === permissionID);
1966
+ } catch {
1967
+ return true;
1968
+ }
1969
+ }
1594
1970
  function getSessionLifecycleInfo(event) {
1595
1971
  const info = getNestedRecord(event, "properties", "info");
1596
1972
  return {
@@ -1751,9 +2127,14 @@ var NotifierPlugin = async ({ client, directory }) => {
1751
2127
  }
1752
2128
  const getConfig = () => loadConfig();
1753
2129
  const projectName = directory ? getConfig().showFullPath ? directory : basename(directory) : null;
1754
- setTimeout(() => {
2130
+ const isCLI = isCLIClient(clientEnv);
2131
+ if (isCLI) {
1755
2132
  handleEvent(getConfig(), "client_connected", projectName, null);
1756
- }, 100);
2133
+ } else {
2134
+ setTimeout(() => {
2135
+ handleEvent(getConfig(), "client_connected", projectName, null);
2136
+ }, 100);
2137
+ }
1757
2138
  return {
1758
2139
  event: async ({ event }) => {
1759
2140
  const config = getConfig();
@@ -1779,14 +2160,26 @@ var NotifierPlugin = async ({ client, directory }) => {
1779
2160
  }
1780
2161
  if (event.type === "permission.asked") {
1781
2162
  const sessionID = getSessionIDFromEvent(event);
1782
- if (!shouldSuppressPermissionAlert(sessionID)) {
2163
+ const permissionID = getPermissionIDFromEvent(event);
2164
+ let stillPending = true;
2165
+ if (permissionID) {
2166
+ await new Promise((resolve) => setTimeout(resolve, PERMISSION_PENDING_GRACE_MS));
2167
+ stillPending = await isPermissionStillPending(client, permissionID);
2168
+ }
2169
+ if (stillPending && !shouldSuppressPermissionAlert(sessionID)) {
1783
2170
  await handleEventWithElapsedTime(client, config, "permission", projectName, event);
1784
2171
  }
1785
2172
  }
1786
2173
  if (event.type === "session.idle") {
1787
2174
  const sessionID = getSessionIDFromEvent(event);
1788
2175
  if (sessionID) {
1789
- scheduleSessionIdle(client, config, projectName, event, sessionID);
2176
+ if (isCLI) {
2177
+ const idleReceivedAtMs = Date.now();
2178
+ const sequence = bumpSessionIdleSequence(sessionID);
2179
+ await processSessionIdle(client, config, projectName, event, sessionID, sequence, idleReceivedAtMs);
2180
+ } else {
2181
+ scheduleSessionIdle(client, config, projectName, event, sessionID);
2182
+ }
1790
2183
  } else {
1791
2184
  await handleEventWithElapsedTime(client, config, "complete", projectName, event);
1792
2185
  }
@@ -1832,9 +2225,17 @@ var NotifierPlugin = async ({ client, directory }) => {
1832
2225
  }
1833
2226
  };
1834
2227
  };
1835
- var src_default = NotifierPlugin;
2228
+ var pluginModule = {
2229
+ id: "opencode-notifier",
2230
+ server: NotifierPlugin
2231
+ };
2232
+ var src_default = pluginModule;
1836
2233
  export {
1837
- extractAgentNameFromSessionTitle,
2234
+ NotifierPlugin,
2235
+ PERMISSION_PENDING_GRACE_MS,
1838
2236
  src_default as default,
1839
- NotifierPlugin
2237
+ extractAgentNameFromSessionTitle,
2238
+ getPermissionIDFromEvent,
2239
+ isCLIClient,
2240
+ isPermissionStillPending
1840
2241
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.2.8",
3
+ "version": "0.2.9-beta.1",
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",
@@ -38,12 +38,12 @@
38
38
  "node-notifier": "^10.0.1"
39
39
  },
40
40
  "devDependencies": {
41
- "@opencode-ai/plugin": "^1.0.224",
41
+ "@opencode-ai/plugin": "^1.18.25",
42
42
  "@types/node": "^22.0.0",
43
43
  "@types/node-notifier": "^8.0.5",
44
44
  "typescript": "^5.0.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "@opencode-ai/plugin": ">=1.0.0"
47
+ "@opencode-ai/plugin": ">=1.17.0"
48
48
  }
49
49
  }