@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.
- package/README.md +88 -14
- package/dist/index.js +629 -35
- 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 (`: `,
|
|
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
|
|
371
|
-
|
|
372
|
-
| macOS
|
|
373
|
-
| Linux X11
|
|
374
|
-
| Linux Wayland (Hyprland)
|
|
375
|
-
| Linux Wayland (Niri)
|
|
376
|
-
| Linux Wayland (Sway)
|
|
377
|
-
| Linux Wayland (KDE)
|
|
378
|
-
| Linux Wayland (GNOME)
|
|
379
|
-
| Linux Wayland (river, dwl, Cosmic, etc.) | Not supported
|
|
380
|
-
| Windows
|
|
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
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
|
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 =
|
|
367
|
+
var platform = os2.type();
|
|
284
368
|
var platformNotifier;
|
|
285
|
-
if (platform === "
|
|
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
|
|
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 =
|
|
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
|
|
701
|
+
const os3 = platform2();
|
|
520
702
|
try {
|
|
521
|
-
switch (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|