@mohak34/opencode-notifier 0.2.9-beta.0 → 0.3.0-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 +81 -24
  2. package/dist/index.js +461 -72
  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") {
@@ -675,7 +689,18 @@ async function playOnLinux(soundPath, volume) {
675
689
  const players = [
676
690
  { command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
677
691
  { command: "aplay", args: [soundPath] },
678
- { 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
+ },
679
704
  { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
680
705
  ];
681
706
  for (const player of players) {
@@ -691,8 +716,9 @@ async function playOnMac(soundPath, volume) {
691
716
  await runCommand("afplay", ["-v", `${volume}`, soundPath]);
692
717
  }
693
718
  async function playOnWindows(soundPath) {
694
- const script = `& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }`;
695
- await runCommand("powershell", ["-NoProfile", "-NonInteractive", "-Command", 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]);
696
722
  }
697
723
  async function playSound(event, customPath, volume) {
698
724
  const now = Date.now();
@@ -705,9 +731,9 @@ async function playSound(event, customPath, volume) {
705
731
  if (!soundPath) {
706
732
  return;
707
733
  }
708
- const os3 = platform2();
734
+ const os = platform2();
709
735
  try {
710
- switch (os3) {
736
+ switch (os) {
711
737
  case "darwin":
712
738
  await playOnMac(soundPath, normalizedVolume);
713
739
  break;
@@ -759,6 +785,7 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
759
785
  const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
760
786
  const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
761
787
  const proc = spawn3(command, args, {
788
+ shell: false,
762
789
  stdio: "ignore",
763
790
  detached: true,
764
791
  windowsHide: true
@@ -769,9 +796,9 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
769
796
 
770
797
  // src/focus.ts
771
798
  import { execFileSync, execSync } from "child_process";
772
- import { readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
799
+ import { accessSync, constants, mkdtempSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
773
800
  import { tmpdir } from "os";
774
- import { join as join3 } from "path";
801
+ import { delimiter, join as join3 } from "path";
775
802
  var LINUX_TERMINAL_APPS = new Set([
776
803
  "ghostty",
777
804
  "konsole",
@@ -822,13 +849,25 @@ function execFileWithTimeout(command, args, timeoutMs = 500) {
822
849
  return null;
823
850
  }
824
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
+ }
825
864
  function getHyprlandActiveWindowId() {
826
865
  const output = execWithTimeout("hyprctl activewindow -j");
827
866
  if (!output)
828
867
  return null;
829
868
  try {
830
869
  const data = JSON.parse(output);
831
- return typeof data?.address === "string" ? data.address : null;
870
+ return typeof data?.address === "string" && isSafeCompositorWindowId(data.address) ? data.address : null;
832
871
  } catch {
833
872
  return null;
834
873
  }
@@ -899,15 +938,213 @@ function getLinuxWaylandActiveWindowId() {
899
938
  if (env.SWAYSOCK)
900
939
  return getSwayActiveWindowId();
901
940
  if (env.KDE_SESSION_VERSION)
902
- return execWithTimeout("kdotool getactivewindow");
941
+ return firstNumericWindowId(execWithTimeout("kdotool getactivewindow"));
942
+ if (isGnomeLikeSession(env))
943
+ return getGnomeAtspiActiveWindowKey();
903
944
  return null;
904
945
  }
905
- function getWindowsActiveWindowId() {
906
- const script = `$type=Add-Type -Name FocusHelper -Namespace OpenCodeNotifier -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();' -PassThru; $type::GetForegroundWindow()`;
907
- let windowId = execFileWithTimeout("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
908
- if (!windowId)
909
- windowId = execFileWithTimeout("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
910
- 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 };
911
1148
  }
912
1149
  function getMacOSActiveWindowId() {
913
1150
  return execWithTimeout(`osascript -e 'tell application "System Events" to return id of window 1 of (first application process whose frontmost is true)'`);
@@ -947,6 +1184,13 @@ function getExpectedMacTerminalAppNames(env) {
947
1184
  }
948
1185
  return new Set(MAC_TERMINAL_APP_NAMES);
949
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
+ }
950
1194
  function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
951
1195
  if (!frontmostAppName) {
952
1196
  return false;
@@ -959,18 +1203,18 @@ function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
959
1203
  return expectedApps.has(normalizedFrontmost);
960
1204
  }
961
1205
  function getActiveWindowId() {
962
- const platform3 = process.platform;
963
- if (platform3 === "darwin")
1206
+ const platform = process.platform;
1207
+ if (platform === "darwin")
964
1208
  return getMacOSActiveWindowId();
965
- if (platform3 === "linux") {
1209
+ if (platform === "linux") {
966
1210
  if (process.env.WAYLAND_DISPLAY)
967
1211
  return getLinuxWaylandActiveWindowId();
968
1212
  if (process.env.DISPLAY)
969
- return execWithTimeout("xdotool getactivewindow");
1213
+ return firstNumericWindowId(execWithTimeout("xdotool getactivewindow"));
970
1214
  return null;
971
1215
  }
972
- if (platform3 === "win32")
973
- return getWindowsActiveWindowId();
1216
+ if (platform === "win32")
1217
+ return null;
974
1218
  return null;
975
1219
  }
976
1220
  var cachedWindowId = getActiveWindowId();
@@ -991,15 +1235,15 @@ function isTmuxPaneFocused(tmuxPane, probeResult) {
991
1235
  return Number(sessionAttached) > 0 && windowActive === "1" && paneActive === "1";
992
1236
  }
993
1237
  function isLinuxTerminalFocused(params) {
994
- const { cachedWindowId: cachedWindowId2, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
995
- if (!cachedWindowId2) {
1238
+ const { cachedWindowId, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
1239
+ if (!cachedWindowId) {
996
1240
  if (!wezTermPaneActive)
997
1241
  return false;
998
1242
  if (tmuxPaneActive !== null)
999
1243
  return tmuxPaneActive;
1000
1244
  return false;
1001
1245
  }
1002
- if (currentWindowId !== cachedWindowId2)
1246
+ if (currentWindowId !== cachedWindowId)
1003
1247
  return false;
1004
1248
  if (!wezTermPaneActive)
1005
1249
  return false;
@@ -1008,8 +1252,10 @@ function isLinuxTerminalFocused(params) {
1008
1252
  return true;
1009
1253
  }
1010
1254
  function isWindowsTerminalFocused(params) {
1011
- const { cachedWindowId: cachedWindowId2, currentWindowId } = params;
1012
- return !!cachedWindowId2 && currentWindowId === cachedWindowId2;
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);
1013
1259
  }
1014
1260
  function isTmuxPaneActive() {
1015
1261
  const tmuxPane = process.env.TMUX_PANE ?? null;
@@ -1044,30 +1290,36 @@ function isTerminalFocused() {
1044
1290
  return true;
1045
1291
  }
1046
1292
  if (process.platform === "win32") {
1293
+ const info = getWindowsActiveWindowInfo();
1047
1294
  return isWindowsTerminalFocused({
1048
- cachedWindowId,
1049
- currentWindowId: getWindowsActiveWindowId()
1295
+ className: info?.className ?? null,
1296
+ processName: info?.processName ?? null
1050
1297
  });
1051
1298
  }
1052
1299
  const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
1053
- return isLinuxTerminalFocused({
1300
+ const currentWindowId = getActiveWindowId();
1301
+ const focused = isLinuxTerminalFocused({
1054
1302
  cachedWindowId,
1055
- currentWindowId: getActiveWindowId(),
1303
+ currentWindowId,
1056
1304
  wezTermPaneActive: isWezTermPaneActive(),
1057
1305
  tmuxPaneActive
1058
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;
1059
1309
  } catch {
1060
1310
  return false;
1061
1311
  }
1062
1312
  }
1063
1313
  function getWindowIdFromXdotool(searchTerm) {
1064
- return execWithTimeout(`xdotool search --classname "${searchTerm}" | head -1`);
1314
+ return firstNumericWindowId(execFileWithTimeout("xdotool", ["search", "--classname", searchTerm]));
1065
1315
  }
1066
1316
  function getWindowIdFromKdotool(searchTerm) {
1067
- return execWithTimeout(`kdotool search --classname "${searchTerm}" | head -1`);
1317
+ return firstNumericWindowId(execFileWithTimeout("kdotool", ["search", "--classname", searchTerm]));
1068
1318
  }
1069
1319
  function getWindowTitleFromKdotool(windowId) {
1070
- return execWithTimeout(`kdotool getwindowname ${windowId}`);
1320
+ if (!isNumericWindowId(windowId))
1321
+ return null;
1322
+ return execFileWithTimeout("kdotool", ["getwindowname", windowId]);
1071
1323
  }
1072
1324
  var cachedKDEJumpBackSupport = null;
1073
1325
  function isKDEJumpBackSupported() {
@@ -1081,7 +1333,13 @@ function isKDEJumpBackSupported() {
1081
1333
  return cachedKDEJumpBackSupport;
1082
1334
  }
1083
1335
  function getWindowClassX11(windowId) {
1084
- 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;
1085
1343
  }
1086
1344
  function getWaylandAppId(windowId) {
1087
1345
  if (process.env.HYPRLAND_INSTANCE_SIGNATURE) {
@@ -1183,8 +1441,10 @@ function getTerminalWindowId() {
1183
1441
  return null;
1184
1442
  }
1185
1443
  function focusLinuxWindowX11(windowId) {
1444
+ if (!isNumericWindowId(windowId))
1445
+ return;
1186
1446
  try {
1187
- execSync(`xdotool windowactivate ${windowId} 2>/dev/null`, { timeout: 1000 });
1447
+ execFileSync("xdotool", ["windowactivate", windowId], { timeout: 1000, stdio: "ignore" });
1188
1448
  } catch {}
1189
1449
  }
1190
1450
  function findTerminalPid() {
@@ -1211,18 +1471,49 @@ function findTerminalPid() {
1211
1471
  return process.ppid;
1212
1472
  }
1213
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
+ }
1214
1505
  function focusKDEWithKWinScript() {
1215
1506
  try {
1216
1507
  const pinnedWindowId = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim() || null;
1217
- if (pinnedWindowId) {
1508
+ if (pinnedWindowId && isNumericWindowId(pinnedWindowId)) {
1218
1509
  try {
1219
- execSync(`kdotool windowactivate ${pinnedWindowId} 2>/dev/null`, { timeout: 1500 });
1510
+ execFileSync("kdotool", ["windowactivate", pinnedWindowId], { timeout: 1500, stdio: "ignore" });
1220
1511
  return;
1221
1512
  } catch {}
1222
1513
  }
1223
- if (cachedWindowId) {
1514
+ if (cachedWindowId && isNumericWindowId(cachedWindowId)) {
1224
1515
  try {
1225
- execSync(`kdotool windowactivate ${cachedWindowId} 2>/dev/null`, { timeout: 1500 });
1516
+ execFileSync("kdotool", ["windowactivate", cachedWindowId], { timeout: 1500, stdio: "ignore" });
1226
1517
  return;
1227
1518
  } catch {}
1228
1519
  }
@@ -1335,41 +1626,83 @@ function findAndActivateTerminal() {
1335
1626
 
1336
1627
  findAndActivateTerminal();
1337
1628
  `;
1338
- const scriptPath = join3(tmpdir(), `opencode-focus-${currentPid}.kwinscript`);
1339
1629
  const pluginName = `opencode-focus-${currentPid}`;
1340
- writeFileSync(scriptPath, scriptContent);
1341
- execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript "${scriptPath}" "${pluginName}"`, { encoding: "utf-8", timeout: 2000 });
1342
- execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.start`, { timeout: 2000 });
1343
- try {
1344
- unlinkSync(scriptPath);
1345
- } catch {}
1346
- 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 {}
1347
1639
  try {
1348
- execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript "${pluginName}"`, { timeout: 500 });
1640
+ rmSync(scriptDirectory, { recursive: true, force: true });
1349
1641
  } catch {}
1350
- }, 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
+ }
1351
1678
  } catch {
1352
1679
  try {
1353
1680
  const cachedId = cachedWindowId;
1354
- if (cachedId) {
1355
- execSync(`xdotool windowactivate ${cachedId} 2>/dev/null`, { timeout: 1000 });
1681
+ if (cachedId && isNumericWindowId(cachedId)) {
1682
+ execFileSync("xdotool", ["windowactivate", cachedId], { timeout: 1000, stdio: "ignore" });
1356
1683
  }
1357
1684
  } catch {}
1358
1685
  }
1359
1686
  }
1360
1687
  function focusLinuxWindowHyprland(windowId) {
1688
+ if (!isSafeCompositorWindowId(windowId))
1689
+ return;
1361
1690
  try {
1362
- execSync(`hyprctl dispatch focuswindow address:${windowId} 2>/dev/null`, { timeout: 1000 });
1691
+ execFileSync("hyprctl", ["dispatch", "focuswindow", `address:${windowId}`], { timeout: 1000, stdio: "ignore" });
1363
1692
  } catch {}
1364
1693
  }
1365
1694
  function focusLinuxWindowSway(windowId) {
1695
+ if (!isNumericWindowId(windowId))
1696
+ return;
1366
1697
  try {
1367
- execSync(`swaymsg "[con_id=${windowId}] focus" 2>/dev/null`, { timeout: 1000 });
1698
+ execFileSync("swaymsg", [`[con_id=${windowId}] focus`], { timeout: 1000, stdio: "ignore" });
1368
1699
  } catch {}
1369
1700
  }
1370
1701
  function focusLinuxWindowNiri(windowId) {
1702
+ if (!isNumericWindowId(windowId))
1703
+ return;
1371
1704
  try {
1372
- 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" });
1373
1706
  } catch {}
1374
1707
  }
1375
1708
  function captureStartupWindowId() {
@@ -1395,11 +1728,11 @@ async function focusTerminal() {
1395
1728
  const expectedApps = getExpectedMacTerminalAppNames(process.env);
1396
1729
  for (const app of expectedApps) {
1397
1730
  try {
1398
- execSync(`osascript -e 'tell application "${app}" to activate' 2>/dev/null`, { timeout: 1000 });
1731
+ execFileSync("osascript", buildOsascriptActivateAppArgs(app), { timeout: 1000, stdio: "ignore" });
1399
1732
  return;
1400
1733
  } catch {}
1401
1734
  }
1402
- execSync(`osascript -e 'tell application "Terminal" to activate' 2>/dev/null`, { timeout: 1000 });
1735
+ execFileSync("osascript", buildOsascriptActivateAppArgs("Terminal"), { timeout: 1000, stdio: "ignore" });
1403
1736
  } catch {}
1404
1737
  return;
1405
1738
  }
@@ -1454,6 +1787,9 @@ function prunePermissionAlertState(cutoffMs) {
1454
1787
 
1455
1788
  // src/index.ts
1456
1789
  var IDLE_COMPLETE_DELAY_MS = 350;
1790
+ function isCLIClient(clientEnv) {
1791
+ return !clientEnv || clientEnv === "cli";
1792
+ }
1457
1793
  var pendingIdleTimers = new Map;
1458
1794
  var sessionIdleSequence = new Map;
1459
1795
  var sessionErrorSuppressionAt = new Map;
@@ -1504,7 +1840,7 @@ function incrementTurnCount() {
1504
1840
  saveTurnCount(globalTurnCount);
1505
1841
  return globalTurnCount;
1506
1842
  }
1507
- setInterval(() => {
1843
+ var cleanupInterval = setInterval(() => {
1508
1844
  const cutoff = Date.now() - 5 * 60 * 1000;
1509
1845
  for (const [sessionID] of sessionIdleSequence) {
1510
1846
  if (!pendingIdleTimers.has(sessionID)) {
@@ -1524,6 +1860,7 @@ setInterval(() => {
1524
1860
  }
1525
1861
  prunePermissionAlertState(cutoff);
1526
1862
  }, 5 * 60 * 1000);
1863
+ cleanupInterval.unref();
1527
1864
  function getNotificationTitle(config, projectName) {
1528
1865
  if (config.showProjectName && projectName) {
1529
1866
  return `OpenCode (${projectName})`;
@@ -1579,7 +1916,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
1579
1916
  const title = getNotificationTitle(config, projectName);
1580
1917
  const iconPath = getIconPath(config);
1581
1918
  const onNotificationClick = isKDEJumpBackSupported() ? () => void focusTerminal() : undefined;
1582
- 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));
1583
1920
  }
1584
1921
  if (isEventSoundEnabled(config, eventType)) {
1585
1922
  const customSoundPath = getSoundPath(config, eventType);
@@ -1603,6 +1940,33 @@ function getSessionIDFromEvent(event) {
1603
1940
  const properties = getNestedRecord(event, "properties");
1604
1941
  return getStringField(properties, "sessionID");
1605
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
+ }
1606
1970
  function getSessionLifecycleInfo(event) {
1607
1971
  const info = getNestedRecord(event, "properties", "info");
1608
1972
  return {
@@ -1763,9 +2127,14 @@ var NotifierPlugin = async ({ client, directory }) => {
1763
2127
  }
1764
2128
  const getConfig = () => loadConfig();
1765
2129
  const projectName = directory ? getConfig().showFullPath ? directory : basename(directory) : null;
1766
- setTimeout(() => {
2130
+ const isCLI = isCLIClient(clientEnv);
2131
+ if (isCLI) {
1767
2132
  handleEvent(getConfig(), "client_connected", projectName, null);
1768
- }, 100);
2133
+ } else {
2134
+ setTimeout(() => {
2135
+ handleEvent(getConfig(), "client_connected", projectName, null);
2136
+ }, 100);
2137
+ }
1769
2138
  return {
1770
2139
  event: async ({ event }) => {
1771
2140
  const config = getConfig();
@@ -1791,14 +2160,26 @@ var NotifierPlugin = async ({ client, directory }) => {
1791
2160
  }
1792
2161
  if (event.type === "permission.asked") {
1793
2162
  const sessionID = getSessionIDFromEvent(event);
1794
- 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)) {
1795
2170
  await handleEventWithElapsedTime(client, config, "permission", projectName, event);
1796
2171
  }
1797
2172
  }
1798
2173
  if (event.type === "session.idle") {
1799
2174
  const sessionID = getSessionIDFromEvent(event);
1800
2175
  if (sessionID) {
1801
- 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
+ }
1802
2183
  } else {
1803
2184
  await handleEventWithElapsedTime(client, config, "complete", projectName, event);
1804
2185
  }
@@ -1844,9 +2225,17 @@ var NotifierPlugin = async ({ client, directory }) => {
1844
2225
  }
1845
2226
  };
1846
2227
  };
1847
- var src_default = NotifierPlugin;
2228
+ var pluginModule = {
2229
+ id: "opencode-notifier",
2230
+ server: NotifierPlugin
2231
+ };
2232
+ var src_default = pluginModule;
1848
2233
  export {
1849
- extractAgentNameFromSessionTitle,
2234
+ NotifierPlugin,
2235
+ PERMISSION_PENDING_GRACE_MS,
1850
2236
  src_default as default,
1851
- NotifierPlugin
2237
+ extractAgentNameFromSessionTitle,
2238
+ getPermissionIDFromEvent,
2239
+ isCLIClient,
2240
+ isPermissionStillPending
1852
2241
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.2.9-beta.0",
3
+ "version": "0.3.0-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",
@@ -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
  }