@mohak34/opencode-notifier 0.2.2-beta.0 → 0.2.3-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 +88 -14
  2. package/dist/index.js +629 -35
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -17,9 +17,10 @@ Restart OpenCode. Done.
17
17
  ## What it does
18
18
 
19
19
  You'll get notified when:
20
+
20
21
  - OpenCode needs permission to run something
21
22
  - Your session finishes
22
- - An error happens
23
+ - An error happens
23
24
  - The question tool pops up
24
25
 
25
26
  There's also `subagent_complete` for when subagents finish, and `user_cancelled` for when you press ESC to abort -- both are silent by default so you don't get spammed.
@@ -39,9 +40,22 @@ sudo pacman -S libnotify # Arch
39
40
  For sounds, you need one of: `paplay`, `aplay`, `mpv`, or `ffplay`
40
41
 
41
42
  **Windows**: Works out of the box. But heads up:
43
+
42
44
  - Only `.wav` files work (not mp3)
43
45
  - Use full paths like `C:/Users/You/sounds/alert.wav` not `~/`
44
46
 
47
+ **WSL**: It's recommeneded to set `customIconPath` pointing to a file on Windows filesystem
48
+ due to issues with path translation (can be copied from `logos` folder from this repository).
49
+ This path will be passed down to `snoretoast-*.exe`
50
+
51
+ In `opencode-notifier.json` config:
52
+ ```json
53
+ "showIcon": true,
54
+ "customIconPath": "C:\\Users\\jhon\\Documents\\opencode-logo-dark.png",
55
+ ```
56
+
57
+ - If notifications are not showing up, check out: (missing WSL notification)[https://github.com/mikaelbr/node-notifier?tab=readme-ov-file#windows-and-wsl2]
58
+
45
59
  ## Config file
46
60
 
47
61
  Create `~/.config/opencode/opencode-notifier.json` with the defaults:
@@ -55,12 +69,14 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
55
69
  "showProjectName": true,
56
70
  "showSessionTitle": false,
57
71
  "showIcon": true,
72
+ "customIconPath": null,
58
73
  "suppressWhenFocused": true,
59
74
  "enableOnDesktop": false,
60
75
  "notificationSystem": "osascript",
61
76
  "linux": {
62
77
  "grouping": false
63
78
  },
79
+ "minDuration": 0,
64
80
  "command": {
65
81
  "enabled": false,
66
82
  "path": "/path/to/command",
@@ -217,7 +233,7 @@ Messages support placeholder tokens that get replaced with actual values:
217
233
  - `{timestamp}` - Current time in `HH:MM:SS` format (e.g. "14:30:05")
218
234
  - `{turn}` - Global notification counter that persists across restarts (e.g. 1, 2, 3). Stored in `~/.config/opencode/opencode-notifier-state.json`
219
235
 
220
- When `showSessionTitle` is `false`, `{sessionTitle}` is replaced with an empty string. Any trailing separators (`: `, ` - `, ` | `) are automatically cleaned up when a placeholder resolves to empty.
236
+ When `showSessionTitle` is `false`, `{sessionTitle}` is replaced with an empty string. Any trailing separators (`: `, `-`, `|`) are automatically cleaned up when a placeholder resolves to empty.
221
237
 
222
238
  To disable session titles in messages without changing `showSessionTitle`, just remove the `{sessionTitle}` placeholder from your custom messages.
223
239
 
@@ -245,6 +261,7 @@ Use your own sound files:
245
261
  ```
246
262
 
247
263
  Platform notes:
264
+
248
265
  - macOS/Linux: .wav or .mp3 files work
249
266
  - Windows: Only .wav files work
250
267
  - If file doesn't exist, falls back to bundled sound
@@ -365,23 +382,37 @@ To disable this and always get notified:
365
382
  }
366
383
  ```
367
384
 
385
+ ## Minimum duration threshold
386
+
387
+ You can suppress `complete` and `subagent_complete` notifications for short-lived sessions. Set `minDuration` to the number of seconds a session must exceed to trigger a done notification:
388
+
389
+ ```json
390
+ {
391
+ "minDuration": 10
392
+ }
393
+ ```
394
+
395
+ With the above, if OpenCode finishes in under 10 seconds, no notification, sound, bell, or command is fired. Default is `0` (no threshold).
396
+
397
+ This is independent of `command.minDuration`, which only controls whether the custom command runs.
398
+
368
399
  ### Platform support
369
400
 
370
- | Platform | Method | Requirements | Status |
371
- |----------|--------|--------------|--------|
372
- | macOS | AppleScript (`System Events`) | None | Untested |
373
- | Linux X11 | `xdotool` | `xdotool` installed | Untested |
374
- | Linux Wayland (Hyprland) | `hyprctl activewindow` | None | Tested |
375
- | Linux Wayland (Niri) | `niri msg --json focused-window` | None | Tested |
376
- | Linux Wayland (Sway) | `swaymsg -t get_tree` | None | Untested |
377
- | Linux Wayland (KDE) | `kdotool` | `kdotool` installed | Untested |
378
- | Linux Wayland (GNOME) | Not supported | - | Falls back to always notifying |
379
- | Linux Wayland (river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
380
- | Windows | `GetForegroundWindow()` via PowerShell | None | Untested |
401
+ | Platform | Method | Requirements | Status |
402
+ | ---------------------------------------- | ---------------------------------------- | --------------------- | ------------------------------ |
403
+ | macOS | AppleScript (`System Events`) | None | Untested |
404
+ | Linux X11 | `xdotool` | `xdotool` installed | Untested |
405
+ | Linux Wayland (Hyprland) | `hyprctl activewindow` | None | Tested |
406
+ | Linux Wayland (Niri) | `niri msg --json focused-window` | None | Tested |
407
+ | Linux Wayland (Sway) | `swaymsg -t get_tree` | None | Untested |
408
+ | Linux Wayland (KDE) | `kdotool` | `kdotool` installed | Tested |
409
+ | Linux Wayland (GNOME) | Not supported | - | Falls back to always notifying |
410
+ | Linux Wayland (river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
411
+ | Windows | `GetForegroundWindow()` via PowerShell | None | Untested |
381
412
 
382
413
  **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.
383
414
 
384
- **tmux/screen**: When running inside tmux, focus detection uses tmux pane state (`session_attached`, `window_active`, `pane_active`) via `tmux display-message`. This keeps suppression accurate when switching panes/windows/sessions. GNU Screen is not currently handled (falls back to always notifying).
415
+ **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).
385
416
 
386
417
  **WezTerm panes**: When running in WezTerm with `WEZTERM_PANE` set, focus suppression is pane-aware via `wezterm cli list-clients --format json`. This means notifications are shown when you switch to a different WezTerm pane/tab.
387
418
 
@@ -407,6 +438,15 @@ With grouping enabled, each new notification replaces the previous one so you on
407
438
 
408
439
  Works with all major notification daemons (GNOME, dunst, mako, swaync, etc.) on both X11 and Wayland.
409
440
 
441
+ ## KDE Plasma: Jump back to terminal from notification
442
+
443
+ On KDE Plasma/Wayland, clicking the popup body is not consistently delivered as a notification activation event.This plugin uses an explicit notification action button instead:
444
+
445
+ - **Jump to terminal** (action button on the popup card, or in notification history)
446
+
447
+ When clicked, the plugin runs its terminal-focus path. On KDE with `kdotool` installed, it auto-captures the startup terminal window ID and jumps back to that pinned window.
448
+ The action button is only enabled on Linux KDE sessions where `kdotool` is available.
449
+
410
450
  ## Updating
411
451
 
412
452
  If Opencode does not update the plugin or there is an issue with the cache version:
@@ -431,6 +471,7 @@ Switch back to osascript. Some users report node-notifier works for sounds but n
431
471
 
432
472
  **Linux: No notifications?**
433
473
  Install libnotify-bin:
474
+
434
475
  ```bash
435
476
  sudo apt install libnotify-bin # Debian/Ubuntu
436
477
  sudo dnf install libnotify # Fedora
@@ -442,7 +483,39 @@ Test with: `notify-send "Test" "Hello"`
442
483
  **Linux: No sounds?**
443
484
  Install one of: `paplay`, `aplay`, `mpv`, or `ffplay`
444
485
 
486
+ **KDE Plasma: jumps to wrong terminal window or doesn't jump?**
487
+
488
+ Jump back feature tested on:
489
+
490
+ - KWin with default floating windows
491
+ - KWin + Krohnkite
492
+ - Ghostty and Konsole
493
+ - Warp
494
+ - OpenCode in tmux inside VS Code terminal
495
+
496
+ Most terminal emulators should work fine, but there can be exceptions.
497
+
498
+ Known limitations:
499
+
500
+ - Kitty is currently unsupported for this jump-back path (unstable focus targeting)
501
+ - Yakuake sessions are not supported for activity-specific jump-back behavior
502
+
503
+ You can still override manually (if needed) by pinning an explicit window ID:
504
+
505
+ ```bash
506
+ export OPENCODE_NOTIFIER_WINDOW_ID="$(kdotool getactivewindow)"
507
+ opencode
508
+ ```
509
+
510
+ Manual pinning bypasses heuristic window matching and should activate that exact window on notification action click.
511
+
512
+ **X11 deterministic jump-back**
513
+
514
+ - `xdotool` support is possible for the same startup pin behavior
515
+ - not implemented yet
516
+
445
517
  **Windows: Custom sounds not working?**
518
+
446
519
  - Must be .wav format (not .mp3)
447
520
  - Use full Windows paths: `C:/Users/YourName/sounds/alert.wav` (not `~/`)
448
521
  - Make sure the file actually plays in Windows Media Player
@@ -485,6 +558,7 @@ This is a known Bun issue on Windows. Disable native notifications and use Power
485
558
  ```
486
559
 
487
560
  **Plugin not loading?**
561
+
488
562
  - Check your opencode.json syntax
489
563
  - Clear the cache (see Updating section)
490
564
  - Restart OpenCode
package/dist/index.js CHANGED
@@ -1,12 +1,87 @@
1
1
  // src/index.ts
2
2
  import { basename } from "path";
3
- import { readFileSync as readFileSync2, writeFileSync } from "fs";
3
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
4
4
 
5
5
  // src/config.ts
6
6
  import { readFileSync, existsSync } from "fs";
7
7
  import { join, dirname } from "path";
8
8
  import { homedir } from "os";
9
9
  import { fileURLToPath } from "url";
10
+
11
+ // node_modules/is-wsl/index.js
12
+ import process2 from "node:process";
13
+ import os from "node:os";
14
+ import fs3 from "node:fs";
15
+
16
+ // node_modules/is-inside-container/index.js
17
+ import fs2 from "node:fs";
18
+
19
+ // node_modules/is-docker/index.js
20
+ import fs from "node:fs";
21
+ var isDockerCached;
22
+ function hasDockerEnv() {
23
+ try {
24
+ fs.statSync("/.dockerenv");
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+ function hasDockerCGroup() {
31
+ try {
32
+ return fs.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+ function isDocker() {
38
+ if (isDockerCached === undefined) {
39
+ isDockerCached = hasDockerEnv() || hasDockerCGroup();
40
+ }
41
+ return isDockerCached;
42
+ }
43
+
44
+ // node_modules/is-inside-container/index.js
45
+ var cachedResult;
46
+ var hasContainerEnv = () => {
47
+ try {
48
+ fs2.statSync("/run/.containerenv");
49
+ return true;
50
+ } catch {
51
+ return false;
52
+ }
53
+ };
54
+ function isInsideContainer() {
55
+ if (cachedResult === undefined) {
56
+ cachedResult = hasContainerEnv() || isDocker();
57
+ }
58
+ return cachedResult;
59
+ }
60
+
61
+ // node_modules/is-wsl/index.js
62
+ var isWsl = () => {
63
+ if (process2.platform !== "linux") {
64
+ return false;
65
+ }
66
+ if (os.release().toLowerCase().includes("microsoft")) {
67
+ if (isInsideContainer()) {
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+ try {
73
+ if (fs3.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
74
+ return !isInsideContainer();
75
+ }
76
+ } catch {}
77
+ if (fs3.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs3.existsSync("/run/WSL")) {
78
+ return !isInsideContainer();
79
+ }
80
+ return false;
81
+ };
82
+ var is_wsl_default = process2.env.__IS_WSL_TEST__ ? isWsl : isWsl();
83
+
84
+ // src/config.ts
10
85
  var DEFAULT_EVENT_CONFIG = {
11
86
  sound: true,
12
87
  notification: true,
@@ -21,12 +96,14 @@ var DEFAULT_CONFIG = {
21
96
  showProjectName: true,
22
97
  showSessionTitle: false,
23
98
  showIcon: true,
99
+ customIconPath: null,
24
100
  suppressWhenFocused: true,
25
101
  enableOnDesktop: false,
26
102
  notificationSystem: "osascript",
27
103
  linux: {
28
104
  grouping: false
29
105
  },
106
+ minDuration: 0,
30
107
  command: {
31
108
  enabled: false,
32
109
  path: "",
@@ -154,12 +231,14 @@ function loadConfig() {
154
231
  showProjectName: userConfig.showProjectName ?? DEFAULT_CONFIG.showProjectName,
155
232
  showSessionTitle: userConfig.showSessionTitle ?? DEFAULT_CONFIG.showSessionTitle,
156
233
  showIcon: userConfig.showIcon ?? DEFAULT_CONFIG.showIcon,
234
+ customIconPath: userConfig.customIconPath ?? DEFAULT_CONFIG.customIconPath,
157
235
  suppressWhenFocused: userConfig.suppressWhenFocused ?? DEFAULT_CONFIG.suppressWhenFocused,
158
236
  enableOnDesktop: typeof userConfig.enableOnDesktop === "boolean" ? userConfig.enableOnDesktop : DEFAULT_CONFIG.enableOnDesktop,
159
237
  notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : userConfig.notificationSystem === "ghostty" ? "ghostty" : "osascript",
160
238
  linux: {
161
239
  grouping: typeof userConfig.linux?.grouping === "boolean" ? userConfig.linux.grouping : DEFAULT_CONFIG.linux.grouping
162
240
  },
241
+ minDuration: typeof userConfig.minDuration === "number" && Number.isFinite(userConfig.minDuration) && userConfig.minDuration >= 0 ? userConfig.minDuration : DEFAULT_CONFIG.minDuration,
163
242
  command: {
164
243
  enabled: typeof userCommand.enabled === "boolean" ? userCommand.enabled : DEFAULT_CONFIG.command.enabled,
165
244
  path: typeof userCommand.path === "string" ? userCommand.path : DEFAULT_CONFIG.command.path,
@@ -249,10 +328,15 @@ function getIconPath(config) {
249
328
  return;
250
329
  }
251
330
  try {
252
- const __filename2 = fileURLToPath(import.meta.url);
253
- const __dirname2 = dirname(__filename2);
254
- const iconPath = join(__dirname2, "..", "logos", "opencode-logo-dark.png");
255
- if (existsSync(iconPath)) {
331
+ let iconPath;
332
+ if (config.customIconPath) {
333
+ iconPath = config.customIconPath;
334
+ } else {
335
+ const __filename2 = fileURLToPath(import.meta.url);
336
+ const __dirname2 = dirname(__filename2);
337
+ iconPath = join(__dirname2, "..", "logos", "opencode-logo-dark.png");
338
+ }
339
+ if (is_wsl_default || existsSync(iconPath)) {
256
340
  return iconPath;
257
341
  }
258
342
  } catch {}
@@ -276,21 +360,23 @@ function interpolateMessage(message, context) {
276
360
  }
277
361
 
278
362
  // src/notify.ts
279
- import os from "os";
280
- import { exec, execFile } from "child_process";
363
+ import os2 from "os";
364
+ import { exec, execFile, spawn } from "child_process";
281
365
  import notifier from "node-notifier";
282
366
  var DEBOUNCE_MS = 1000;
283
- var platform = os.type();
367
+ var platform = os2.type();
284
368
  var platformNotifier;
285
- if (platform === "Linux" || platform.match(/BSD$/)) {
286
- const { NotifySend } = notifier;
287
- platformNotifier = new NotifySend({ withFallback: false });
288
- } else if (platform === "Windows_NT") {
369
+ if (platform === "Windows_NT" || is_wsl_default) {
289
370
  const { WindowsToaster } = notifier;
290
371
  platformNotifier = new WindowsToaster({ withFallback: false });
372
+ } else if (platform === "Linux" || platform.match(/BSD$/)) {
373
+ const { NotifySend } = notifier;
374
+ platformNotifier = new NotifySend({ withFallback: false });
291
375
  } else if (platform !== "Darwin") {
292
376
  platformNotifier = notifier;
293
377
  }
378
+ var LINUX_FOCUS_ACTION_KEY = "focus-terminal";
379
+ var LINUX_FOCUS_ACTION_LABEL = "Jump to terminal";
294
380
  var lastNotificationTime = {};
295
381
  var lastLinuxNotificationId = null;
296
382
  var linuxNotifySendSupportsReplace = null;
@@ -324,8 +410,12 @@ function detectNotifySendCapabilities() {
324
410
  });
325
411
  });
326
412
  }
327
- function sendLinuxNotificationDirect(title, message, timeout, iconPath, grouping = true) {
413
+ function sendLinuxNotificationDirect(title, message, timeout, iconPath, grouping = true, onAction) {
328
414
  return new Promise((resolve) => {
415
+ if (onAction) {
416
+ sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping, onAction).then(() => resolve()).catch(() => resolve());
417
+ return;
418
+ }
329
419
  const args = [];
330
420
  args.push("--app-name", "opencode");
331
421
  if (iconPath) {
@@ -350,7 +440,85 @@ function sendLinuxNotificationDirect(title, message, timeout, iconPath, grouping
350
440
  });
351
441
  });
352
442
  }
353
- async function sendNotification(title, message, timeout, iconPath, notificationSystem = "osascript", linuxGrouping = true) {
443
+ async function sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping = true, onAction) {
444
+ const args = ["--app-name", "opencode"];
445
+ if (iconPath) {
446
+ args.push("--icon", iconPath);
447
+ }
448
+ args.push("--expire-time", String(timeout * 1000));
449
+ if (grouping && lastLinuxNotificationId !== null) {
450
+ args.push("--replace-id", String(lastLinuxNotificationId));
451
+ }
452
+ args.push("--print-id");
453
+ args.push("--action", `${LINUX_FOCUS_ACTION_KEY}=${LINUX_FOCUS_ACTION_LABEL}`);
454
+ args.push("--", title, message);
455
+ return new Promise((resolve) => {
456
+ const child = spawn("notify-send", args, { stdio: ["ignore", "pipe", "pipe"] });
457
+ let stdout = "";
458
+ const consumeStdout = () => {
459
+ const lines = stdout.split(/\r?\n/);
460
+ stdout = lines.pop() ?? "";
461
+ for (const rawLine of lines) {
462
+ const line = rawLine.trim();
463
+ if (!line) {
464
+ continue;
465
+ }
466
+ const parsed = parseNotifySendOutputLine(line);
467
+ if (!parsed) {
468
+ continue;
469
+ }
470
+ if (parsed.type === "id") {
471
+ if (grouping) {
472
+ lastLinuxNotificationId = parsed.id;
473
+ }
474
+ continue;
475
+ }
476
+ if (onAction) {
477
+ if (parsed.action === "focus") {
478
+ onAction("focus");
479
+ } else if (parsed.action === "close") {
480
+ onAction("close");
481
+ }
482
+ }
483
+ }
484
+ };
485
+ child.stdout?.on("data", (data) => {
486
+ stdout += data.toString();
487
+ consumeStdout();
488
+ });
489
+ child.on("close", () => {
490
+ if (stdout.trim().length > 0) {
491
+ stdout += `
492
+ `;
493
+ consumeStdout();
494
+ }
495
+ resolve();
496
+ });
497
+ child.on("error", () => {
498
+ resolve();
499
+ });
500
+ });
501
+ }
502
+ function parseNotifySendOutputLine(line) {
503
+ const trimmed = line.trim();
504
+ if (!trimmed) {
505
+ return null;
506
+ }
507
+ if (/^\d+$/.test(trimmed)) {
508
+ const id = parseInt(trimmed, 10);
509
+ if (!isNaN(id)) {
510
+ return { type: "id", id };
511
+ }
512
+ }
513
+ if (trimmed === LINUX_FOCUS_ACTION_KEY) {
514
+ return { type: "action", action: "focus" };
515
+ }
516
+ if (trimmed === "close") {
517
+ return { type: "action", action: "close" };
518
+ }
519
+ return null;
520
+ }
521
+ async function sendNotification(title, message, timeout, iconPath, notificationSystem = "osascript", linuxGrouping = true, onClick) {
354
522
  const now = Date.now();
355
523
  if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
356
524
  return;
@@ -386,7 +554,18 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
386
554
  });
387
555
  });
388
556
  }
389
- if (platform === "Linux" || platform.match(/BSD$/)) {
557
+ if ((platform === "Linux" || platform.match(/BSD$/)) && !is_wsl_default) {
558
+ if (onClick) {
559
+ if (linuxGrouping) {
560
+ if (linuxNotifySendSupportsReplace === null) {
561
+ linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
562
+ }
563
+ if (linuxNotifySendSupportsReplace) {
564
+ return sendLinuxNotificationDirect(title, message, timeout, iconPath, true, () => onClick());
565
+ }
566
+ }
567
+ return sendLinuxNotificationDirect(title, message, timeout, iconPath, false, () => onClick());
568
+ }
390
569
  if (linuxGrouping) {
391
570
  if (linuxNotifySendSupportsReplace === null) {
392
571
  linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
@@ -404,7 +583,10 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
404
583
  icon: iconPath,
405
584
  "app-name": "opencode"
406
585
  };
407
- platformNotifier.notify(notificationOptions, () => {
586
+ platformNotifier.notify(notificationOptions, (err, response, metadata) => {
587
+ if (onClick && metadata?.activationType === "default") {
588
+ onClick();
589
+ }
408
590
  resolve();
409
591
  });
410
592
  });
@@ -415,7 +597,7 @@ import { platform as platform2 } from "os";
415
597
  import { join as join2, dirname as dirname2 } from "path";
416
598
  import { fileURLToPath as fileURLToPath2 } from "url";
417
599
  import { existsSync as existsSync2 } from "fs";
418
- import { spawn } from "child_process";
600
+ import { spawn as spawn2 } from "child_process";
419
601
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
420
602
  var DEBOUNCE_MS2 = 1000;
421
603
  var FULL_VOLUME_PERCENT = 100;
@@ -446,7 +628,7 @@ function getSoundFilePath(event, customPath) {
446
628
  }
447
629
  async function runCommand(command, args) {
448
630
  return new Promise((resolve, reject) => {
449
- const proc = spawn(command, args, {
631
+ const proc = spawn2(command, args, {
450
632
  stdio: "ignore",
451
633
  detached: false
452
634
  });
@@ -516,9 +698,9 @@ async function playSound(event, customPath, volume) {
516
698
  if (!soundPath) {
517
699
  return;
518
700
  }
519
- const os2 = platform2();
701
+ const os3 = platform2();
520
702
  try {
521
- switch (os2) {
703
+ switch (os3) {
522
704
  case "darwin":
523
705
  await playOnMac(soundPath, normalizedVolume);
524
706
  break;
@@ -553,7 +735,7 @@ function ringBell(now = Date.now()) {
553
735
  }
554
736
 
555
737
  // src/command.ts
556
- import { spawn as spawn2 } from "child_process";
738
+ import { spawn as spawn3 } from "child_process";
557
739
  function substituteTokens(value, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
558
740
  let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
559
741
  result = result.replaceAll("{sessionTitle}", sessionTitle || "");
@@ -569,7 +751,7 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
569
751
  }
570
752
  const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
571
753
  const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
572
- const proc = spawn2(command, args, {
754
+ const proc = spawn3(command, args, {
573
755
  stdio: "ignore",
574
756
  detached: true
575
757
  });
@@ -579,6 +761,28 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
579
761
 
580
762
  // src/focus.ts
581
763
  import { execFileSync, execSync } from "child_process";
764
+ import { readFileSync as readFileSync2, unlinkSync, writeFileSync } from "fs";
765
+ import { tmpdir } from "os";
766
+ import { join as join3 } from "path";
767
+ var LINUX_TERMINAL_APPS = new Set([
768
+ "ghostty",
769
+ "konsole",
770
+ "gnome-terminal",
771
+ "xterm",
772
+ "urxvt",
773
+ "alacritty",
774
+ "kitty",
775
+ "wezterm",
776
+ "wezterm-gui",
777
+ "tilix",
778
+ "terminator",
779
+ "xfce4-terminal",
780
+ "lxterminal",
781
+ "mate-terminal",
782
+ "deepin-terminal",
783
+ "foot",
784
+ "footclient"
785
+ ]);
582
786
  var MAC_TERMINAL_APP_NAMES = new Set([
583
787
  "terminal",
584
788
  "iterm2",
@@ -759,6 +963,14 @@ function getActiveWindowId() {
759
963
  return null;
760
964
  }
761
965
  var cachedWindowId = getActiveWindowId();
966
+ var cachedWindowTitleValue;
967
+ function getCachedWindowTitle() {
968
+ if (cachedWindowTitleValue !== undefined) {
969
+ return cachedWindowTitleValue;
970
+ }
971
+ cachedWindowTitleValue = process.platform === "linux" && !!process.env.KDE_SESSION_VERSION && cachedWindowId ? getWindowTitleFromKdotool(cachedWindowId) : null;
972
+ return cachedWindowTitleValue;
973
+ }
762
974
  function isTmuxPaneFocused(tmuxPane, probeResult) {
763
975
  if (!tmuxPane)
764
976
  return false;
@@ -767,6 +979,23 @@ function isTmuxPaneFocused(tmuxPane, probeResult) {
767
979
  const [sessionAttached, windowActive, paneActive] = probeResult.split(" ");
768
980
  return sessionAttached === "1" && windowActive === "1" && paneActive === "1";
769
981
  }
982
+ function isLinuxTerminalFocused(params) {
983
+ const { cachedWindowId: cachedWindowId2, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
984
+ if (!cachedWindowId2) {
985
+ if (!wezTermPaneActive)
986
+ return false;
987
+ if (tmuxPaneActive !== null)
988
+ return tmuxPaneActive;
989
+ return false;
990
+ }
991
+ if (currentWindowId !== cachedWindowId2)
992
+ return false;
993
+ if (!wezTermPaneActive)
994
+ return false;
995
+ if (tmuxPaneActive !== null)
996
+ return tmuxPaneActive;
997
+ return true;
998
+ }
770
999
  function isTmuxPaneActive() {
771
1000
  const tmuxPane = process.env.TMUX_PANE ?? null;
772
1001
  const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
@@ -799,18 +1028,378 @@ function isTerminalFocused() {
799
1028
  }
800
1029
  return true;
801
1030
  }
802
- if (!cachedWindowId)
803
- return false;
804
- const currentId = getActiveWindowId();
805
- if (currentId !== cachedWindowId)
806
- return false;
807
- if (!isWezTermPaneActive())
808
- return false;
809
- if (process.env.TMUX)
810
- return isTmuxPaneActive();
811
- return true;
1031
+ const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
1032
+ return isLinuxTerminalFocused({
1033
+ cachedWindowId,
1034
+ currentWindowId: getActiveWindowId(),
1035
+ wezTermPaneActive: isWezTermPaneActive(),
1036
+ tmuxPaneActive
1037
+ });
1038
+ } catch {
1039
+ return false;
1040
+ }
1041
+ }
1042
+ function getWindowIdFromXdotool(searchTerm) {
1043
+ return execWithTimeout(`xdotool search --classname "${searchTerm}" | head -1`);
1044
+ }
1045
+ function getWindowIdFromKdotool(searchTerm) {
1046
+ return execWithTimeout(`kdotool search --classname "${searchTerm}" | head -1`);
1047
+ }
1048
+ function getWindowTitleFromKdotool(windowId) {
1049
+ return execWithTimeout(`kdotool getwindowname ${windowId}`);
1050
+ }
1051
+ var cachedKDEJumpBackSupport = null;
1052
+ function isKDEJumpBackSupported() {
1053
+ if (process.platform !== "linux" || !process.env.KDE_SESSION_VERSION) {
1054
+ return false;
1055
+ }
1056
+ if (cachedKDEJumpBackSupport !== null) {
1057
+ return cachedKDEJumpBackSupport;
1058
+ }
1059
+ cachedKDEJumpBackSupport = execFileWithTimeout("kdotool", ["--help"], 1000) !== null;
1060
+ return cachedKDEJumpBackSupport;
1061
+ }
1062
+ function getWindowClassX11(windowId) {
1063
+ return execWithTimeout(`xprop -id ${windowId} WM_CLASS 2>/dev/null | awk -F '"' '{print $4}'`);
1064
+ }
1065
+ function getWaylandAppId(windowId) {
1066
+ if (process.env.HYPRLAND_INSTANCE_SIGNATURE) {
1067
+ const output = execWithTimeout(`hyprctl clients -j`);
1068
+ if (!output)
1069
+ return null;
1070
+ try {
1071
+ const clients = JSON.parse(output);
1072
+ for (const client of clients) {
1073
+ if (String(client.address) === windowId) {
1074
+ return client.class?.toLowerCase() || client.initialClass?.toLowerCase() || null;
1075
+ }
1076
+ }
1077
+ } catch {
1078
+ return null;
1079
+ }
1080
+ }
1081
+ if (process.env.SWAYSOCK) {
1082
+ const output = execWithTimeout(`swaymsg -t get_tree`, 1000);
1083
+ if (!output)
1084
+ return null;
1085
+ try {
1086
+ const tree = JSON.parse(output);
1087
+ const findWindow = (node) => {
1088
+ if (String(node.id) === windowId) {
1089
+ return node.app_id?.toLowerCase() || node.window_properties?.class?.toLowerCase() || null;
1090
+ }
1091
+ if (Array.isArray(node.nodes)) {
1092
+ for (const child of node.nodes) {
1093
+ const result = findWindow(child);
1094
+ if (result)
1095
+ return result;
1096
+ }
1097
+ }
1098
+ if (Array.isArray(node.floating_nodes)) {
1099
+ for (const child of node.floating_nodes) {
1100
+ const result = findWindow(child);
1101
+ if (result)
1102
+ return result;
1103
+ }
1104
+ }
1105
+ return null;
1106
+ };
1107
+ return findWindow(tree);
1108
+ } catch {
1109
+ return null;
1110
+ }
1111
+ }
1112
+ if (process.env.NIRI_SOCKET) {
1113
+ const output = execWithTimeout(`niri msg --json windows`);
1114
+ if (!output)
1115
+ return null;
1116
+ try {
1117
+ const windows = JSON.parse(output);
1118
+ for (const window of windows) {
1119
+ if (String(window.id) === windowId) {
1120
+ return window.app_id?.toLowerCase() || null;
1121
+ }
1122
+ }
1123
+ } catch {
1124
+ return null;
1125
+ }
1126
+ }
1127
+ return null;
1128
+ }
1129
+ function getTerminalWindowId() {
1130
+ if (process.platform !== "linux")
1131
+ return null;
1132
+ const term = process.env.TERM_PROGRAM?.toLowerCase() || "";
1133
+ const desktopSession = process.env.DESKTOP_SESSION?.toLowerCase() || "";
1134
+ const isKDE = process.env.KDE_SESSION_VERSION || desktopSession.includes("plasma");
1135
+ if (process.env.WAYLAND_DISPLAY) {
1136
+ const cachedId = cachedWindowId;
1137
+ if (cachedId) {
1138
+ const appId = getWaylandAppId(cachedId);
1139
+ if (appId && LINUX_TERMINAL_APPS.has(appId)) {
1140
+ return cachedId;
1141
+ }
1142
+ }
1143
+ if (isKDE) {
1144
+ return cachedId || "kde-wayland";
1145
+ }
1146
+ return cachedId;
1147
+ }
1148
+ if (process.env.DISPLAY) {
1149
+ const cachedId = cachedWindowId;
1150
+ if (cachedId) {
1151
+ const windowClass = getWindowClassX11(cachedId);
1152
+ if (windowClass && LINUX_TERMINAL_APPS.has(windowClass.toLowerCase())) {
1153
+ return cachedId;
1154
+ }
1155
+ }
1156
+ for (const app of LINUX_TERMINAL_APPS) {
1157
+ const id = isKDE ? getWindowIdFromKdotool(app) : getWindowIdFromXdotool(app);
1158
+ if (id)
1159
+ return id;
1160
+ }
1161
+ }
1162
+ return null;
1163
+ }
1164
+ function focusLinuxWindowX11(windowId) {
1165
+ try {
1166
+ execSync(`xdotool windowactivate ${windowId} 2>/dev/null`, { timeout: 1000 });
1167
+ } catch {}
1168
+ }
1169
+ function findTerminalPid() {
1170
+ try {
1171
+ let currentPid = process.pid;
1172
+ while (currentPid > 1) {
1173
+ try {
1174
+ const statContent = readFileSync2(`/proc/${currentPid}/stat`, "utf-8");
1175
+ const match = statContent.match(/^\d+\s+\([^)]+\)\s+\S\s+(\d+)/);
1176
+ if (!match)
1177
+ break;
1178
+ const ppid = parseInt(match[1], 10);
1179
+ const cmdline = readFileSync2(`/proc/${ppid}/comm`, "utf-8").trim();
1180
+ if (cmdline.match(/ghostty|konsole|gnome-terminal|xterm|alacritty|kitty|wezterm|terminator|tilix|foot/i)) {
1181
+ return ppid;
1182
+ }
1183
+ currentPid = ppid;
1184
+ } catch {
1185
+ break;
1186
+ }
1187
+ }
1188
+ return process.ppid;
812
1189
  } catch {
1190
+ return process.ppid;
1191
+ }
1192
+ }
1193
+ function focusKDEWithKWinScript() {
1194
+ try {
1195
+ const pinnedWindowId = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim() || null;
1196
+ if (pinnedWindowId) {
1197
+ try {
1198
+ execSync(`kdotool windowactivate ${pinnedWindowId} 2>/dev/null`, { timeout: 1500 });
1199
+ return;
1200
+ } catch {}
1201
+ }
1202
+ if (cachedWindowId) {
1203
+ try {
1204
+ execSync(`kdotool windowactivate ${cachedWindowId} 2>/dev/null`, { timeout: 1500 });
1205
+ return;
1206
+ } catch {}
1207
+ }
1208
+ const terminalPid = findTerminalPid();
1209
+ const currentPid = process.pid;
1210
+ const termProgram = (process.env.TERM_PROGRAM || "terminal").toLowerCase();
1211
+ const cwd = process.cwd().toLowerCase();
1212
+ const cwdBase = cwd.split("/").filter(Boolean).pop() || "";
1213
+ const cachedTitle = (getCachedWindowTitle() || "").toLowerCase();
1214
+ const scriptContent = `
1215
+ function activateTargetWindow(window) {
1216
+ // Jump to the window's desktop/activity first, then activate.
1217
+ // This works more reliably on Plasma than moving windows between desktops.
1218
+ try {
1219
+ if (window.desktops && window.desktops.length > 0) {
1220
+ workspace.currentDesktop = window.desktops[0];
1221
+ } else if (typeof window.desktop === "number" && window.desktop > 0) {
1222
+ workspace.currentDesktop = window.desktop;
1223
+ }
1224
+ } catch (e) {}
1225
+
1226
+ try {
1227
+ if (window.activities && window.activities.length > 0 && typeof workspace.currentActivity !== "undefined") {
1228
+ workspace.currentActivity = window.activities[0];
1229
+ }
1230
+ } catch (e) {}
1231
+
1232
+ try { window.minimized = false; } catch (e) {}
1233
+
1234
+ try { workspace.activeWindow = window; } catch (e) {}
1235
+ try {
1236
+ if (typeof workspace.activateWindow === "function") {
1237
+ workspace.activateWindow(window);
1238
+ }
1239
+ } catch (e) {}
1240
+ try { window.active = true; } catch (e) {}
1241
+
1242
+ // Nudge stacking so KWin treats this like an explicit user jump.
1243
+ try {
1244
+ window.keepAbove = true;
1245
+ window.keepAbove = false;
1246
+ } catch (e) {}
1247
+ }
1248
+
1249
+ function isLikelyTerminal(window) {
1250
+ var resourceClass = (window.resourceClass || "").toLowerCase();
1251
+ var resourceName = (window.resourceName || "").toLowerCase();
1252
+ var caption = (window.caption || "").toLowerCase();
1253
+
1254
+ return resourceClass.indexOf("ghostty") !== -1 ||
1255
+ resourceName.indexOf("ghostty") !== -1 ||
1256
+ caption.indexOf("ghostty") !== -1 ||
1257
+ resourceClass.indexOf("konsole") !== -1 ||
1258
+ resourceName.indexOf("konsole") !== -1 ||
1259
+ caption.indexOf("konsole") !== -1 ||
1260
+ resourceClass.indexOf("terminal") !== -1 ||
1261
+ resourceName.indexOf("terminal") !== -1;
1262
+ }
1263
+
1264
+ function findAndActivateTerminal() {
1265
+ var allWindows = workspace.windowList();
1266
+ var terminalPid = ${terminalPid};
1267
+ var termProgramHint = ${JSON.stringify(termProgram)};
1268
+ var cwdHint = ${JSON.stringify(cwd)};
1269
+ var cwdBaseHint = ${JSON.stringify(cwdBase)};
1270
+ var cachedTitleHint = ${JSON.stringify(cachedTitle)};
1271
+
1272
+ function contains(haystack, needle) {
1273
+ return !!needle && needle.length > 0 && haystack.indexOf(needle) !== -1;
1274
+ }
1275
+
1276
+ function windowScore(window) {
1277
+ var resourceClass = (window.resourceClass || "").toLowerCase();
1278
+ var resourceName = (window.resourceName || "").toLowerCase();
1279
+ var caption = (window.caption || "").toLowerCase();
1280
+ var score = 0;
1281
+
1282
+ if (window.pid === terminalPid) score += 30;
1283
+ if (contains(caption, "opencode")) score += 60;
1284
+ if (contains(caption, cachedTitleHint)) score += 50;
1285
+ if (contains(caption, cwdBaseHint)) score += 35;
1286
+ if (contains(caption, cwdHint)) score += 20;
1287
+ if (contains(resourceClass, termProgramHint) || contains(resourceName, termProgramHint) || contains(caption, termProgramHint)) score += 20;
1288
+ if (isLikelyTerminal(window)) score += 10;
1289
+ if (window.minimized === true) score -= 5;
1290
+
1291
+ return score;
1292
+ }
1293
+
1294
+ var bestWindow = null;
1295
+ var bestScore = -1;
1296
+
1297
+ for (var i = 0; i < allWindows.length; i++) {
1298
+ var candidate = allWindows[i];
1299
+ var score = windowScore(candidate);
1300
+ if (score > bestScore) {
1301
+ bestWindow = candidate;
1302
+ bestScore = score;
1303
+ }
1304
+ }
1305
+
1306
+ // Require enough confidence to avoid jumping to unrelated terminals.
1307
+ if (bestWindow && bestScore >= 30) {
1308
+ activateTargetWindow(bestWindow);
1309
+ return true;
1310
+ }
1311
+
813
1312
  return false;
1313
+ }
1314
+
1315
+ findAndActivateTerminal();
1316
+ `;
1317
+ const scriptPath = join3(tmpdir(), `opencode-focus-${currentPid}.kwinscript`);
1318
+ const pluginName = `opencode-focus-${currentPid}`;
1319
+ writeFileSync(scriptPath, scriptContent);
1320
+ execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript "${scriptPath}" "${pluginName}"`, { encoding: "utf-8", timeout: 2000 });
1321
+ execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.start`, { timeout: 2000 });
1322
+ try {
1323
+ unlinkSync(scriptPath);
1324
+ } catch {}
1325
+ setTimeout(() => {
1326
+ try {
1327
+ execSync(`qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript "${pluginName}"`, { timeout: 500 });
1328
+ } catch {}
1329
+ }, 1000);
1330
+ } catch {
1331
+ try {
1332
+ const cachedId = cachedWindowId;
1333
+ if (cachedId) {
1334
+ execSync(`xdotool windowactivate ${cachedId} 2>/dev/null`, { timeout: 1000 });
1335
+ }
1336
+ } catch {}
1337
+ }
1338
+ }
1339
+ function focusLinuxWindowHyprland(windowId) {
1340
+ try {
1341
+ execSync(`hyprctl dispatch focuswindow address:${windowId} 2>/dev/null`, { timeout: 1000 });
1342
+ } catch {}
1343
+ }
1344
+ function focusLinuxWindowSway(windowId) {
1345
+ try {
1346
+ execSync(`swaymsg "[con_id=${windowId}] focus" 2>/dev/null`, { timeout: 1000 });
1347
+ } catch {}
1348
+ }
1349
+ function focusLinuxWindowNiri(windowId) {
1350
+ try {
1351
+ execSync(`niri msg action focus-window --id ${windowId} 2>/dev/null`, { timeout: 1000 });
1352
+ } catch {}
1353
+ }
1354
+ function captureStartupWindowId() {
1355
+ if (!isKDEJumpBackSupported()) {
1356
+ return;
1357
+ }
1358
+ const existing = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim();
1359
+ if (existing) {
1360
+ return;
1361
+ }
1362
+ const detected = execWithTimeout("kdotool getactivewindow", 1000);
1363
+ if (detected && /^\d+$/.test(detected)) {
1364
+ process.env.OPENCODE_NOTIFIER_WINDOW_ID = detected;
1365
+ }
1366
+ }
1367
+ async function focusTerminal() {
1368
+ if (process.platform === "darwin") {
1369
+ try {
1370
+ const frontmostAppName = getMacOSFrontmostAppName();
1371
+ if (frontmostAppName && isMacTerminalAppFocused(frontmostAppName, process.env)) {
1372
+ return;
1373
+ }
1374
+ const expectedApps = getExpectedMacTerminalAppNames(process.env);
1375
+ for (const app of expectedApps) {
1376
+ try {
1377
+ execSync(`osascript -e 'tell application "${app}" to activate' 2>/dev/null`, { timeout: 1000 });
1378
+ return;
1379
+ } catch {}
1380
+ }
1381
+ execSync(`osascript -e 'tell application "Terminal" to activate' 2>/dev/null`, { timeout: 1000 });
1382
+ } catch {}
1383
+ return;
1384
+ }
1385
+ if (process.platform === "linux") {
1386
+ const env = process.env;
1387
+ if (env.KDE_SESSION_VERSION) {
1388
+ focusKDEWithKWinScript();
1389
+ return;
1390
+ }
1391
+ const windowId = getTerminalWindowId();
1392
+ if (!windowId)
1393
+ return;
1394
+ if (env.HYPRLAND_INSTANCE_SIGNATURE) {
1395
+ focusLinuxWindowHyprland(windowId);
1396
+ } else if (env.SWAYSOCK) {
1397
+ focusLinuxWindowSway(windowId);
1398
+ } else if (env.NIRI_SOCKET) {
1399
+ focusLinuxWindowNiri(windowId);
1400
+ } else if (env.DISPLAY) {
1401
+ focusLinuxWindowX11(windowId);
1402
+ }
814
1403
  }
815
1404
  }
816
1405
 
@@ -873,7 +1462,7 @@ function getStringField(record, key) {
873
1462
  var globalTurnCount = null;
874
1463
  function loadTurnCount() {
875
1464
  try {
876
- const content = readFileSync2(getStatePath(), "utf-8");
1465
+ const content = readFileSync3(getStatePath(), "utf-8");
877
1466
  const state = JSON.parse(content);
878
1467
  if (typeof state.turn === "number" && Number.isFinite(state.turn) && state.turn >= 0) {
879
1468
  return state.turn;
@@ -883,7 +1472,7 @@ function loadTurnCount() {
883
1472
  }
884
1473
  function saveTurnCount(count) {
885
1474
  try {
886
- writeFileSync(getStatePath(), JSON.stringify({ turn: count }));
1475
+ writeFileSync2(getStatePath(), JSON.stringify({ turn: count }));
887
1476
  } catch {}
888
1477
  }
889
1478
  function incrementTurnCount() {
@@ -950,6 +1539,9 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
950
1539
  if (config.suppressWhenFocused && isTerminalFocused()) {
951
1540
  return;
952
1541
  }
1542
+ if ((eventType === "complete" || eventType === "subagent_complete") && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < config.minDuration) {
1543
+ return;
1544
+ }
953
1545
  const promises = [];
954
1546
  const timestamp = formatTimestamp();
955
1547
  const turn = incrementTurnCount();
@@ -964,7 +1556,8 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
964
1556
  if (isEventNotificationEnabled(config, eventType)) {
965
1557
  const title = getNotificationTitle(config, projectName);
966
1558
  const iconPath = getIconPath(config);
967
- promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping));
1559
+ const onNotificationClick = isKDEJumpBackSupported() ? () => void focusTerminal() : undefined;
1560
+ promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick));
968
1561
  }
969
1562
  if (isEventSoundEnabled(config, eventType)) {
970
1563
  const customSoundPath = getSoundPath(config, eventType);
@@ -1134,6 +1727,7 @@ async function handleEventWithElapsedTime(client, config, eventType, projectName
1134
1727
  await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName);
1135
1728
  }
1136
1729
  var NotifierPlugin = async ({ client, directory }) => {
1730
+ captureStartupWindowId();
1137
1731
  const clientEnv = process.env.OPENCODE_CLIENT;
1138
1732
  if (clientEnv && clientEnv !== "cli") {
1139
1733
  const config = loadConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.2.2-beta.0",
3
+ "version": "0.2.3-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",
@@ -34,6 +34,7 @@
34
34
  "alerts"
35
35
  ],
36
36
  "dependencies": {
37
+ "is-wsl": "^3.1.0",
37
38
  "node-notifier": "^10.0.1"
38
39
  },
39
40
  "devDependencies": {