@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.
- package/README.md +81 -24
- package/dist/index.js +461 -72
- 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
|
-
|
|
7
|
+
Install the plugin via the CLI: `opencode plug -g @mohak34/opencode-notifier`.
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
"
|
|
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) |
|
|
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
|
-
**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
520
|
+
Check the version OpenCode cached:
|
|
484
521
|
|
|
485
|
-
Verify installation:
|
|
486
522
|
```bash
|
|
487
|
-
cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@
|
|
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
|
-
"-
|
|
562
|
-
"
|
|
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
|
-
"-
|
|
580
|
-
"
|
|
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
|
|
652
|
+
- Verify the package version OpenCode cached:
|
|
597
653
|
```bash
|
|
598
|
-
cat ~/.cache/opencode/packages/@mohak34/opencode-notifier@
|
|
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 {
|
|
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(/[;\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 =
|
|
695
|
-
|
|
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
|
|
734
|
+
const os = platform2();
|
|
709
735
|
try {
|
|
710
|
-
switch (
|
|
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,
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
|
963
|
-
if (
|
|
1206
|
+
const platform = process.platform;
|
|
1207
|
+
if (platform === "darwin")
|
|
964
1208
|
return getMacOSActiveWindowId();
|
|
965
|
-
if (
|
|
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 (
|
|
973
|
-
return
|
|
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
|
|
995
|
-
if (!
|
|
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 !==
|
|
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 {
|
|
1012
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
1295
|
+
className: info?.className ?? null,
|
|
1296
|
+
processName: info?.processName ?? null
|
|
1050
1297
|
});
|
|
1051
1298
|
}
|
|
1052
1299
|
const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
|
|
1053
|
-
|
|
1300
|
+
const currentWindowId = getActiveWindowId();
|
|
1301
|
+
const focused = isLinuxTerminalFocused({
|
|
1054
1302
|
cachedWindowId,
|
|
1055
|
-
currentWindowId
|
|
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
|
|
1314
|
+
return firstNumericWindowId(execFileWithTimeout("xdotool", ["search", "--classname", searchTerm]));
|
|
1065
1315
|
}
|
|
1066
1316
|
function getWindowIdFromKdotool(searchTerm) {
|
|
1067
|
-
return
|
|
1317
|
+
return firstNumericWindowId(execFileWithTimeout("kdotool", ["search", "--classname", searchTerm]));
|
|
1068
1318
|
}
|
|
1069
1319
|
function getWindowTitleFromKdotool(windowId) {
|
|
1070
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
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
|
-
|
|
1640
|
+
rmSync(scriptDirectory, { recursive: true, force: true });
|
|
1349
1641
|
} catch {}
|
|
1350
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1731
|
+
execFileSync("osascript", buildOsascriptActivateAppArgs(app), { timeout: 1000, stdio: "ignore" });
|
|
1399
1732
|
return;
|
|
1400
1733
|
} catch {}
|
|
1401
1734
|
}
|
|
1402
|
-
|
|
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
|
-
|
|
2130
|
+
const isCLI = isCLIClient(clientEnv);
|
|
2131
|
+
if (isCLI) {
|
|
1767
2132
|
handleEvent(getConfig(), "client_connected", projectName, null);
|
|
1768
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2228
|
+
var pluginModule = {
|
|
2229
|
+
id: "opencode-notifier",
|
|
2230
|
+
server: NotifierPlugin
|
|
2231
|
+
};
|
|
2232
|
+
var src_default = pluginModule;
|
|
1848
2233
|
export {
|
|
1849
|
-
|
|
2234
|
+
NotifierPlugin,
|
|
2235
|
+
PERMISSION_PENDING_GRACE_MS,
|
|
1850
2236
|
src_default as default,
|
|
1851
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
47
|
+
"@opencode-ai/plugin": ">=1.17.0"
|
|
48
48
|
}
|
|
49
49
|
}
|