@mohak34/opencode-notifier 0.2.8 → 0.2.9-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -24
- package/dist/index.js +471 -70
- 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") {
|
|
@@ -636,7 +650,8 @@ async function runCommand(command, args) {
|
|
|
636
650
|
return new Promise((resolve, reject) => {
|
|
637
651
|
const proc = spawn2(command, args, {
|
|
638
652
|
stdio: "ignore",
|
|
639
|
-
detached: false
|
|
653
|
+
detached: false,
|
|
654
|
+
windowsHide: true
|
|
640
655
|
});
|
|
641
656
|
proc.on("error", (err) => {
|
|
642
657
|
reject(err);
|
|
@@ -674,7 +689,18 @@ async function playOnLinux(soundPath, volume) {
|
|
|
674
689
|
const players = [
|
|
675
690
|
{ command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
|
|
676
691
|
{ command: "aplay", args: [soundPath] },
|
|
677
|
-
{
|
|
692
|
+
{
|
|
693
|
+
command: "mpv",
|
|
694
|
+
args: [
|
|
695
|
+
"--no-video",
|
|
696
|
+
"--no-terminal",
|
|
697
|
+
"--script-opts=autoload-disabled=yes",
|
|
698
|
+
"--keep-open=no",
|
|
699
|
+
"--idle=no",
|
|
700
|
+
`--volume=${percentVolume}`,
|
|
701
|
+
soundPath
|
|
702
|
+
]
|
|
703
|
+
},
|
|
678
704
|
{ command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
|
|
679
705
|
];
|
|
680
706
|
for (const player of players) {
|
|
@@ -690,8 +716,9 @@ async function playOnMac(soundPath, volume) {
|
|
|
690
716
|
await runCommand("afplay", ["-v", `${volume}`, soundPath]);
|
|
691
717
|
}
|
|
692
718
|
async function playOnWindows(soundPath) {
|
|
693
|
-
const script =
|
|
694
|
-
|
|
719
|
+
const script = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`;
|
|
720
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
721
|
+
await runCommand("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
|
695
722
|
}
|
|
696
723
|
async function playSound(event, customPath, volume) {
|
|
697
724
|
const now = Date.now();
|
|
@@ -704,9 +731,9 @@ async function playSound(event, customPath, volume) {
|
|
|
704
731
|
if (!soundPath) {
|
|
705
732
|
return;
|
|
706
733
|
}
|
|
707
|
-
const
|
|
734
|
+
const os = platform2();
|
|
708
735
|
try {
|
|
709
|
-
switch (
|
|
736
|
+
switch (os) {
|
|
710
737
|
case "darwin":
|
|
711
738
|
await playOnMac(soundPath, normalizedVolume);
|
|
712
739
|
break;
|
|
@@ -758,8 +785,10 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
|
|
|
758
785
|
const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
|
|
759
786
|
const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
|
|
760
787
|
const proc = spawn3(command, args, {
|
|
788
|
+
shell: false,
|
|
761
789
|
stdio: "ignore",
|
|
762
|
-
detached: true
|
|
790
|
+
detached: true,
|
|
791
|
+
windowsHide: true
|
|
763
792
|
});
|
|
764
793
|
proc.on("error", () => {});
|
|
765
794
|
proc.unref();
|
|
@@ -767,9 +796,9 @@ function runCommand2(config, event, message, sessionTitle, agentName, projectNam
|
|
|
767
796
|
|
|
768
797
|
// src/focus.ts
|
|
769
798
|
import { execFileSync, execSync } from "child_process";
|
|
770
|
-
import { readFileSync as readFileSync2,
|
|
799
|
+
import { accessSync, constants, mkdtempSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
|
|
771
800
|
import { tmpdir } from "os";
|
|
772
|
-
import { join as join3 } from "path";
|
|
801
|
+
import { delimiter, join as join3 } from "path";
|
|
773
802
|
var LINUX_TERMINAL_APPS = new Set([
|
|
774
803
|
"ghostty",
|
|
775
804
|
"konsole",
|
|
@@ -820,13 +849,25 @@ function execFileWithTimeout(command, args, timeoutMs = 500) {
|
|
|
820
849
|
return null;
|
|
821
850
|
}
|
|
822
851
|
}
|
|
852
|
+
function isNumericWindowId(value) {
|
|
853
|
+
return /^\d+$/.test(value);
|
|
854
|
+
}
|
|
855
|
+
function isSafeCompositorWindowId(value) {
|
|
856
|
+
return /^[A-Za-z0-9._:-]+$/.test(value);
|
|
857
|
+
}
|
|
858
|
+
function firstNumericWindowId(output) {
|
|
859
|
+
if (!output)
|
|
860
|
+
return null;
|
|
861
|
+
const value = output.split(/\s+/).find((part) => isNumericWindowId(part));
|
|
862
|
+
return value ?? null;
|
|
863
|
+
}
|
|
823
864
|
function getHyprlandActiveWindowId() {
|
|
824
865
|
const output = execWithTimeout("hyprctl activewindow -j");
|
|
825
866
|
if (!output)
|
|
826
867
|
return null;
|
|
827
868
|
try {
|
|
828
869
|
const data = JSON.parse(output);
|
|
829
|
-
return typeof data?.address === "string" ? data.address : null;
|
|
870
|
+
return typeof data?.address === "string" && isSafeCompositorWindowId(data.address) ? data.address : null;
|
|
830
871
|
} catch {
|
|
831
872
|
return null;
|
|
832
873
|
}
|
|
@@ -897,15 +938,213 @@ function getLinuxWaylandActiveWindowId() {
|
|
|
897
938
|
if (env.SWAYSOCK)
|
|
898
939
|
return getSwayActiveWindowId();
|
|
899
940
|
if (env.KDE_SESSION_VERSION)
|
|
900
|
-
return execWithTimeout("kdotool getactivewindow");
|
|
941
|
+
return firstNumericWindowId(execWithTimeout("kdotool getactivewindow"));
|
|
942
|
+
if (isGnomeLikeSession(env))
|
|
943
|
+
return getGnomeAtspiActiveWindowKey();
|
|
901
944
|
return null;
|
|
902
945
|
}
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
946
|
+
var ATSPI_ACTIVE_BIT_INDEX = 1;
|
|
947
|
+
var ATSPI_TERMINAL_PATH_MARKERS = ["mitchellh/ghostty"];
|
|
948
|
+
var ATSPI_TERMINAL_APP_ALIASES = new Set([
|
|
949
|
+
"gnome-terminal-server"
|
|
950
|
+
]);
|
|
951
|
+
var GNOME_LIKE_DESKTOPS = new Set(["gnome", "ubuntu", "pop"]);
|
|
952
|
+
function isGnomeLikeSession(env = process.env) {
|
|
953
|
+
const desktop = `${env.XDG_CURRENT_DESKTOP ?? ""} ${env.DESKTOP_SESSION ?? ""}`.toLowerCase();
|
|
954
|
+
return desktop.split(/[:;\s]+/).some((token) => GNOME_LIKE_DESKTOPS.has(token));
|
|
955
|
+
}
|
|
956
|
+
function getLinuxFocusBackendName(env = process.env) {
|
|
957
|
+
if (env.HYPRLAND_INSTANCE_SIGNATURE)
|
|
958
|
+
return "hyprland";
|
|
959
|
+
if (env.NIRI_SOCKET)
|
|
960
|
+
return "niri";
|
|
961
|
+
if (env.SWAYSOCK)
|
|
962
|
+
return "sway";
|
|
963
|
+
if (env.KDE_SESSION_VERSION)
|
|
964
|
+
return "kde";
|
|
965
|
+
if (isGnomeLikeSession(env))
|
|
966
|
+
return "gnome-atspi";
|
|
967
|
+
if (env.DISPLAY)
|
|
968
|
+
return "x11";
|
|
969
|
+
if (env.WAYLAND_DISPLAY)
|
|
970
|
+
return "wayland-unsupported";
|
|
971
|
+
return "none";
|
|
972
|
+
}
|
|
973
|
+
function parseAtspiString(output) {
|
|
974
|
+
if (!output)
|
|
975
|
+
return null;
|
|
976
|
+
const match = output.match(/'((?:[^'\\]|\\.)*)'/);
|
|
977
|
+
if (!match)
|
|
978
|
+
return null;
|
|
979
|
+
try {
|
|
980
|
+
return JSON.parse(`"${match[1].replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`);
|
|
981
|
+
} catch {
|
|
982
|
+
return match[1];
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function parseAtspiObjectRefs(output) {
|
|
986
|
+
if (!output)
|
|
987
|
+
return [];
|
|
988
|
+
const refs = [];
|
|
989
|
+
const re = /\('([^']+)',\s*(?:objectpath\s+)?'([^']+)'\)/g;
|
|
990
|
+
let match;
|
|
991
|
+
while ((match = re.exec(output)) !== null) {
|
|
992
|
+
refs.push({ bus: match[1], path: match[2] });
|
|
993
|
+
}
|
|
994
|
+
return refs;
|
|
995
|
+
}
|
|
996
|
+
function parseAtspiStateActive(output) {
|
|
997
|
+
if (!output)
|
|
998
|
+
return null;
|
|
999
|
+
const match = output.match(/uint32\s+(\d+)/);
|
|
1000
|
+
if (!match)
|
|
1001
|
+
return null;
|
|
1002
|
+
const firstWord = Number(match[1]) >>> 0;
|
|
1003
|
+
return (firstWord >>> ATSPI_ACTIVE_BIT_INDEX & 1) === 1;
|
|
1004
|
+
}
|
|
1005
|
+
function isAtspiTerminalWindow(appName, windowPath) {
|
|
1006
|
+
if (ATSPI_TERMINAL_PATH_MARKERS.some((marker) => windowPath.toLowerCase().includes(marker))) {
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
if (!appName)
|
|
1010
|
+
return false;
|
|
1011
|
+
const normalized = appName.trim().toLowerCase();
|
|
1012
|
+
return LINUX_TERMINAL_APPS.has(normalized) || ATSPI_TERMINAL_APP_ALIASES.has(normalized);
|
|
1013
|
+
}
|
|
1014
|
+
var ATSPI_ROLES = new Set(["window", "frame", "dialog"]);
|
|
1015
|
+
function isAtspiWindowRoleAccepted(role) {
|
|
1016
|
+
return role !== null && ATSPI_ROLES.has(role);
|
|
1017
|
+
}
|
|
1018
|
+
function callAtspi(address, dest, path, method, args = [], timeoutMs = 500) {
|
|
1019
|
+
return execFileWithTimeout("gdbus", [
|
|
1020
|
+
"call",
|
|
1021
|
+
"--address",
|
|
1022
|
+
address,
|
|
1023
|
+
"--dest",
|
|
1024
|
+
dest,
|
|
1025
|
+
"--object-path",
|
|
1026
|
+
path,
|
|
1027
|
+
"--method",
|
|
1028
|
+
method,
|
|
1029
|
+
...args
|
|
1030
|
+
], timeoutMs);
|
|
1031
|
+
}
|
|
1032
|
+
var cachedAtspiAddress = null;
|
|
1033
|
+
var ATSPI_ADDRESS_CACHE_TTL_MS = 1e4;
|
|
1034
|
+
function getAtspiBusAddress() {
|
|
1035
|
+
const now = Date.now();
|
|
1036
|
+
if (cachedAtspiAddress && now - cachedAtspiAddress.at < ATSPI_ADDRESS_CACHE_TTL_MS) {
|
|
1037
|
+
return cachedAtspiAddress.address;
|
|
1038
|
+
}
|
|
1039
|
+
cachedAtspiAddress = null;
|
|
1040
|
+
const output = execFileWithTimeout("gdbus", [
|
|
1041
|
+
"call",
|
|
1042
|
+
"--session",
|
|
1043
|
+
"--dest",
|
|
1044
|
+
"org.a11y.Bus",
|
|
1045
|
+
"--object-path",
|
|
1046
|
+
"/org/a11y/bus",
|
|
1047
|
+
"--method",
|
|
1048
|
+
"org.a11y.Bus.GetAddress"
|
|
1049
|
+
], 1000);
|
|
1050
|
+
const address = parseAtspiString(output);
|
|
1051
|
+
if (address) {
|
|
1052
|
+
cachedAtspiAddress = { address, at: now };
|
|
1053
|
+
}
|
|
1054
|
+
return address;
|
|
1055
|
+
}
|
|
1056
|
+
function getAtspiWindowRole(address, ref) {
|
|
1057
|
+
const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetRoleName");
|
|
1058
|
+
return parseAtspiString(output);
|
|
1059
|
+
}
|
|
1060
|
+
function isAtspiWindowActive(address, ref) {
|
|
1061
|
+
const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetState");
|
|
1062
|
+
return parseAtspiStateActive(output);
|
|
1063
|
+
}
|
|
1064
|
+
function getAtspiTerminalWindowRefs(address) {
|
|
1065
|
+
const refs = [];
|
|
1066
|
+
const rootOutput = callAtspi(address, "org.a11y.atspi.Registry", "/org/a11y/atspi/accessible/root", "org.a11y.atspi.Accessible.GetChildren", [], 1000);
|
|
1067
|
+
for (const app of parseAtspiObjectRefs(rootOutput)) {
|
|
1068
|
+
const appName = parseAtspiString(callAtspi(address, app.bus, "/org/a11y/atspi/accessible/root", "org.freedesktop.DBus.Properties.Get", ["org.a11y.atspi.Accessible", "Name"]));
|
|
1069
|
+
const childrenOutput = callAtspi(address, app.bus, app.path, "org.a11y.atspi.Accessible.GetChildren");
|
|
1070
|
+
for (const child of parseAtspiObjectRefs(childrenOutput)) {
|
|
1071
|
+
if (!isAtspiTerminalWindow(appName, child.path))
|
|
1072
|
+
continue;
|
|
1073
|
+
const role = getAtspiWindowRole(address, child)?.toLowerCase() ?? null;
|
|
1074
|
+
if (isAtspiWindowRoleAccepted(role)) {
|
|
1075
|
+
refs.push(child);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return refs;
|
|
1080
|
+
}
|
|
1081
|
+
function getGnomeAtspiActiveWindowKey() {
|
|
1082
|
+
try {
|
|
1083
|
+
const address = getAtspiBusAddress();
|
|
1084
|
+
if (!address)
|
|
1085
|
+
return null;
|
|
1086
|
+
for (const ref of getAtspiTerminalWindowRefs(address)) {
|
|
1087
|
+
if (isAtspiWindowActive(address, ref) === true) {
|
|
1088
|
+
return `atspi:${ref.bus}@${ref.path}`;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
} catch {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function debugFocusState(message) {
|
|
1097
|
+
if (process.env.OPENCODE_NOTIFIER_DEBUG) {
|
|
1098
|
+
console.error(`[opencode-notifier] ${message}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
var WINDOWS_TERMINAL_WINDOW_CLASSES = new Set([
|
|
1102
|
+
"cascadia_hosting_window_class",
|
|
1103
|
+
"consolewindowclass",
|
|
1104
|
+
"windowsterminalwindowclass"
|
|
1105
|
+
]);
|
|
1106
|
+
var WINDOWS_TERMINAL_PROCESS_NAMES = new Set([
|
|
1107
|
+
"windowsterminal",
|
|
1108
|
+
"windowsterminalpreview",
|
|
1109
|
+
"conhost",
|
|
1110
|
+
"alacritty",
|
|
1111
|
+
"wezterm",
|
|
1112
|
+
"wezterm-gui",
|
|
1113
|
+
"kitty",
|
|
1114
|
+
"hyper",
|
|
1115
|
+
"cursor",
|
|
1116
|
+
"code",
|
|
1117
|
+
"code - insiders"
|
|
1118
|
+
]);
|
|
1119
|
+
function getWindowsActiveWindowInfo() {
|
|
1120
|
+
const script = `
|
|
1121
|
+
$p=Add-Type -Name NFI -Namespace OpenCodeNotifier -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();[DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetClassName(IntPtr h,System.Text.StringBuilder b,int n);[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p);' -PassThru;
|
|
1122
|
+
$h=$p::GetForegroundWindow();
|
|
1123
|
+
if(!$h){return}
|
|
1124
|
+
$sb=New-Object System.Text.StringBuilder 256;
|
|
1125
|
+
$p::GetClassName($h,$sb,256)|Out-Null;
|
|
1126
|
+
$c=$sb.ToString();
|
|
1127
|
+
$procId=0;
|
|
1128
|
+
$p::GetWindowThreadProcessId($h,[ref]$procId)|Out-Null;
|
|
1129
|
+
$pn='';
|
|
1130
|
+
try{$pn=(Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName}catch{}
|
|
1131
|
+
Write-Output "$c|$pn"
|
|
1132
|
+
`.trim().replace(/\n/g, "; ");
|
|
1133
|
+
let output = execFileWithTimeout("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 5000);
|
|
1134
|
+
if (!output)
|
|
1135
|
+
output = execFileWithTimeout("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
|
|
1136
|
+
if (!output)
|
|
1137
|
+
return null;
|
|
1138
|
+
const line = output.split(`
|
|
1139
|
+
`).map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith("#<"));
|
|
1140
|
+
if (!line)
|
|
1141
|
+
return null;
|
|
1142
|
+
const sep = line.indexOf("|");
|
|
1143
|
+
if (sep === -1)
|
|
1144
|
+
return null;
|
|
1145
|
+
const className = line.substring(0, sep) || null;
|
|
1146
|
+
const processName = line.substring(sep + 1) || null;
|
|
1147
|
+
return { className, processName };
|
|
909
1148
|
}
|
|
910
1149
|
function getMacOSActiveWindowId() {
|
|
911
1150
|
return execWithTimeout(`osascript -e 'tell application "System Events" to return id of window 1 of (first application process whose frontmost is true)'`);
|
|
@@ -945,6 +1184,13 @@ function getExpectedMacTerminalAppNames(env) {
|
|
|
945
1184
|
}
|
|
946
1185
|
return new Set(MAC_TERMINAL_APP_NAMES);
|
|
947
1186
|
}
|
|
1187
|
+
function buildOsascriptActivateAppArgs(appName) {
|
|
1188
|
+
const escapedAppName = appName.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replaceAll("\\", "\\\\").replaceAll('"', "\\\"");
|
|
1189
|
+
return [
|
|
1190
|
+
"-e",
|
|
1191
|
+
`tell application "${escapedAppName}" to activate`
|
|
1192
|
+
];
|
|
1193
|
+
}
|
|
948
1194
|
function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
|
|
949
1195
|
if (!frontmostAppName) {
|
|
950
1196
|
return false;
|
|
@@ -957,18 +1203,18 @@ function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
|
|
|
957
1203
|
return expectedApps.has(normalizedFrontmost);
|
|
958
1204
|
}
|
|
959
1205
|
function getActiveWindowId() {
|
|
960
|
-
const
|
|
961
|
-
if (
|
|
1206
|
+
const platform = process.platform;
|
|
1207
|
+
if (platform === "darwin")
|
|
962
1208
|
return getMacOSActiveWindowId();
|
|
963
|
-
if (
|
|
1209
|
+
if (platform === "linux") {
|
|
964
1210
|
if (process.env.WAYLAND_DISPLAY)
|
|
965
1211
|
return getLinuxWaylandActiveWindowId();
|
|
966
1212
|
if (process.env.DISPLAY)
|
|
967
|
-
return execWithTimeout("xdotool getactivewindow");
|
|
1213
|
+
return firstNumericWindowId(execWithTimeout("xdotool getactivewindow"));
|
|
968
1214
|
return null;
|
|
969
1215
|
}
|
|
970
|
-
if (
|
|
971
|
-
return
|
|
1216
|
+
if (platform === "win32")
|
|
1217
|
+
return null;
|
|
972
1218
|
return null;
|
|
973
1219
|
}
|
|
974
1220
|
var cachedWindowId = getActiveWindowId();
|
|
@@ -989,15 +1235,15 @@ function isTmuxPaneFocused(tmuxPane, probeResult) {
|
|
|
989
1235
|
return Number(sessionAttached) > 0 && windowActive === "1" && paneActive === "1";
|
|
990
1236
|
}
|
|
991
1237
|
function isLinuxTerminalFocused(params) {
|
|
992
|
-
const { cachedWindowId
|
|
993
|
-
if (!
|
|
1238
|
+
const { cachedWindowId, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
|
|
1239
|
+
if (!cachedWindowId) {
|
|
994
1240
|
if (!wezTermPaneActive)
|
|
995
1241
|
return false;
|
|
996
1242
|
if (tmuxPaneActive !== null)
|
|
997
1243
|
return tmuxPaneActive;
|
|
998
1244
|
return false;
|
|
999
1245
|
}
|
|
1000
|
-
if (currentWindowId !==
|
|
1246
|
+
if (currentWindowId !== cachedWindowId)
|
|
1001
1247
|
return false;
|
|
1002
1248
|
if (!wezTermPaneActive)
|
|
1003
1249
|
return false;
|
|
@@ -1005,6 +1251,12 @@ function isLinuxTerminalFocused(params) {
|
|
|
1005
1251
|
return tmuxPaneActive;
|
|
1006
1252
|
return true;
|
|
1007
1253
|
}
|
|
1254
|
+
function isWindowsTerminalFocused(params) {
|
|
1255
|
+
const { className, processName } = params;
|
|
1256
|
+
const classLower = className?.toLowerCase() ?? "";
|
|
1257
|
+
const processLower = processName?.toLowerCase() ?? "";
|
|
1258
|
+
return WINDOWS_TERMINAL_WINDOW_CLASSES.has(classLower) || WINDOWS_TERMINAL_PROCESS_NAMES.has(processLower);
|
|
1259
|
+
}
|
|
1008
1260
|
function isTmuxPaneActive() {
|
|
1009
1261
|
const tmuxPane = process.env.TMUX_PANE ?? null;
|
|
1010
1262
|
const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
|
|
@@ -1037,25 +1289,37 @@ function isTerminalFocused() {
|
|
|
1037
1289
|
}
|
|
1038
1290
|
return true;
|
|
1039
1291
|
}
|
|
1292
|
+
if (process.platform === "win32") {
|
|
1293
|
+
const info = getWindowsActiveWindowInfo();
|
|
1294
|
+
return isWindowsTerminalFocused({
|
|
1295
|
+
className: info?.className ?? null,
|
|
1296
|
+
processName: info?.processName ?? null
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1040
1299
|
const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
|
|
1041
|
-
|
|
1300
|
+
const currentWindowId = getActiveWindowId();
|
|
1301
|
+
const focused = isLinuxTerminalFocused({
|
|
1042
1302
|
cachedWindowId,
|
|
1043
|
-
currentWindowId
|
|
1303
|
+
currentWindowId,
|
|
1044
1304
|
wezTermPaneActive: isWezTermPaneActive(),
|
|
1045
1305
|
tmuxPaneActive
|
|
1046
1306
|
});
|
|
1307
|
+
debugFocusState(`linux focus: backend=${getLinuxFocusBackendName()} session=${process.env.XDG_SESSION_TYPE ?? "?"} desktop=${process.env.XDG_CURRENT_DESKTOP ?? process.env.DESKTOP_SESSION ?? "?"} cached=${cachedWindowId ?? "null"} current=${currentWindowId ?? "null"} tmux=${String(tmuxPaneActive)} focused=${focused}`);
|
|
1308
|
+
return focused;
|
|
1047
1309
|
} catch {
|
|
1048
1310
|
return false;
|
|
1049
1311
|
}
|
|
1050
1312
|
}
|
|
1051
1313
|
function getWindowIdFromXdotool(searchTerm) {
|
|
1052
|
-
return
|
|
1314
|
+
return firstNumericWindowId(execFileWithTimeout("xdotool", ["search", "--classname", searchTerm]));
|
|
1053
1315
|
}
|
|
1054
1316
|
function getWindowIdFromKdotool(searchTerm) {
|
|
1055
|
-
return
|
|
1317
|
+
return firstNumericWindowId(execFileWithTimeout("kdotool", ["search", "--classname", searchTerm]));
|
|
1056
1318
|
}
|
|
1057
1319
|
function getWindowTitleFromKdotool(windowId) {
|
|
1058
|
-
|
|
1320
|
+
if (!isNumericWindowId(windowId))
|
|
1321
|
+
return null;
|
|
1322
|
+
return execFileWithTimeout("kdotool", ["getwindowname", windowId]);
|
|
1059
1323
|
}
|
|
1060
1324
|
var cachedKDEJumpBackSupport = null;
|
|
1061
1325
|
function isKDEJumpBackSupported() {
|
|
@@ -1069,7 +1333,13 @@ function isKDEJumpBackSupported() {
|
|
|
1069
1333
|
return cachedKDEJumpBackSupport;
|
|
1070
1334
|
}
|
|
1071
1335
|
function getWindowClassX11(windowId) {
|
|
1072
|
-
|
|
1336
|
+
if (!isNumericWindowId(windowId))
|
|
1337
|
+
return null;
|
|
1338
|
+
const output = execFileWithTimeout("xprop", ["-id", windowId, "WM_CLASS"]);
|
|
1339
|
+
if (!output)
|
|
1340
|
+
return null;
|
|
1341
|
+
const matches = [...output.matchAll(/"([^"]*)"/g)].map((match) => match[1]);
|
|
1342
|
+
return matches[1] ?? matches[0] ?? null;
|
|
1073
1343
|
}
|
|
1074
1344
|
function getWaylandAppId(windowId) {
|
|
1075
1345
|
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) {
|
|
@@ -1171,8 +1441,10 @@ function getTerminalWindowId() {
|
|
|
1171
1441
|
return null;
|
|
1172
1442
|
}
|
|
1173
1443
|
function focusLinuxWindowX11(windowId) {
|
|
1444
|
+
if (!isNumericWindowId(windowId))
|
|
1445
|
+
return;
|
|
1174
1446
|
try {
|
|
1175
|
-
|
|
1447
|
+
execFileSync("xdotool", ["windowactivate", windowId], { timeout: 1000, stdio: "ignore" });
|
|
1176
1448
|
} catch {}
|
|
1177
1449
|
}
|
|
1178
1450
|
function findTerminalPid() {
|
|
@@ -1199,18 +1471,49 @@ function findTerminalPid() {
|
|
|
1199
1471
|
return process.ppid;
|
|
1200
1472
|
}
|
|
1201
1473
|
}
|
|
1474
|
+
var cachedQdbusBinary;
|
|
1475
|
+
var QDBUS_CANDIDATES = ["qdbus-qt6", "qdbus6", "qdbus-qt5", "qdbus5", "qdbus"];
|
|
1476
|
+
function isExecutableOnPath(name) {
|
|
1477
|
+
const pathEnv = process.env.PATH ?? "";
|
|
1478
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
1479
|
+
if (!dir)
|
|
1480
|
+
continue;
|
|
1481
|
+
const full = join3(dir, name);
|
|
1482
|
+
try {
|
|
1483
|
+
accessSync(full, constants.X_OK);
|
|
1484
|
+
if (statSync(full).isFile())
|
|
1485
|
+
return true;
|
|
1486
|
+
} catch {}
|
|
1487
|
+
}
|
|
1488
|
+
return false;
|
|
1489
|
+
}
|
|
1490
|
+
function findQdbusBinary(candidates = QDBUS_CANDIDATES, isExecutable = isExecutableOnPath) {
|
|
1491
|
+
for (const candidate of candidates) {
|
|
1492
|
+
if (isExecutable(candidate)) {
|
|
1493
|
+
return candidate;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
function resolveQdbusBinary() {
|
|
1499
|
+
if (cachedQdbusBinary !== undefined) {
|
|
1500
|
+
return cachedQdbusBinary;
|
|
1501
|
+
}
|
|
1502
|
+
cachedQdbusBinary = findQdbusBinary();
|
|
1503
|
+
return cachedQdbusBinary;
|
|
1504
|
+
}
|
|
1202
1505
|
function focusKDEWithKWinScript() {
|
|
1203
1506
|
try {
|
|
1204
1507
|
const pinnedWindowId = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim() || null;
|
|
1205
|
-
if (pinnedWindowId) {
|
|
1508
|
+
if (pinnedWindowId && isNumericWindowId(pinnedWindowId)) {
|
|
1206
1509
|
try {
|
|
1207
|
-
|
|
1510
|
+
execFileSync("kdotool", ["windowactivate", pinnedWindowId], { timeout: 1500, stdio: "ignore" });
|
|
1208
1511
|
return;
|
|
1209
1512
|
} catch {}
|
|
1210
1513
|
}
|
|
1211
|
-
if (cachedWindowId) {
|
|
1514
|
+
if (cachedWindowId && isNumericWindowId(cachedWindowId)) {
|
|
1212
1515
|
try {
|
|
1213
|
-
|
|
1516
|
+
execFileSync("kdotool", ["windowactivate", cachedWindowId], { timeout: 1500, stdio: "ignore" });
|
|
1214
1517
|
return;
|
|
1215
1518
|
} catch {}
|
|
1216
1519
|
}
|
|
@@ -1323,41 +1626,83 @@ function findAndActivateTerminal() {
|
|
|
1323
1626
|
|
|
1324
1627
|
findAndActivateTerminal();
|
|
1325
1628
|
`;
|
|
1326
|
-
const scriptPath = join3(tmpdir(), `opencode-focus-${currentPid}.kwinscript`);
|
|
1327
1629
|
const pluginName = `opencode-focus-${currentPid}`;
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1630
|
+
let scriptDirectory = null;
|
|
1631
|
+
let scriptPath = null;
|
|
1632
|
+
let scriptLoaded = false;
|
|
1633
|
+
const cleanupScript = () => {
|
|
1634
|
+
if (!scriptPath || !scriptDirectory)
|
|
1635
|
+
return;
|
|
1636
|
+
try {
|
|
1637
|
+
rmSync(scriptPath, { force: true });
|
|
1638
|
+
} catch {}
|
|
1335
1639
|
try {
|
|
1336
|
-
|
|
1640
|
+
rmSync(scriptDirectory, { recursive: true, force: true });
|
|
1337
1641
|
} catch {}
|
|
1338
|
-
}
|
|
1642
|
+
};
|
|
1643
|
+
const qdbus = resolveQdbusBinary();
|
|
1644
|
+
if (!qdbus) {
|
|
1645
|
+
throw new Error("qdbus not found");
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
scriptDirectory = mkdtempSync(join3(tmpdir(), "opencode-focus-"));
|
|
1649
|
+
scriptPath = join3(scriptDirectory, "script.kwinscript");
|
|
1650
|
+
writeFileSync(scriptPath, scriptContent, { encoding: "utf-8", mode: 384, flag: "wx" });
|
|
1651
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.loadScript", scriptPath, pluginName], { encoding: "utf-8", timeout: 2000, stdio: "ignore" });
|
|
1652
|
+
scriptLoaded = true;
|
|
1653
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.start"], {
|
|
1654
|
+
timeout: 2000,
|
|
1655
|
+
stdio: "ignore"
|
|
1656
|
+
});
|
|
1657
|
+
cleanupScript();
|
|
1658
|
+
setTimeout(() => {
|
|
1659
|
+
try {
|
|
1660
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
|
|
1661
|
+
timeout: 500,
|
|
1662
|
+
stdio: "ignore"
|
|
1663
|
+
});
|
|
1664
|
+
} catch {}
|
|
1665
|
+
}, 1000);
|
|
1666
|
+
} catch {
|
|
1667
|
+
if (scriptLoaded) {
|
|
1668
|
+
try {
|
|
1669
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
|
|
1670
|
+
timeout: 500,
|
|
1671
|
+
stdio: "ignore"
|
|
1672
|
+
});
|
|
1673
|
+
} catch {}
|
|
1674
|
+
}
|
|
1675
|
+
cleanupScript();
|
|
1676
|
+
throw new Error("Unable to activate the terminal through KWin");
|
|
1677
|
+
}
|
|
1339
1678
|
} catch {
|
|
1340
1679
|
try {
|
|
1341
1680
|
const cachedId = cachedWindowId;
|
|
1342
|
-
if (cachedId) {
|
|
1343
|
-
|
|
1681
|
+
if (cachedId && isNumericWindowId(cachedId)) {
|
|
1682
|
+
execFileSync("xdotool", ["windowactivate", cachedId], { timeout: 1000, stdio: "ignore" });
|
|
1344
1683
|
}
|
|
1345
1684
|
} catch {}
|
|
1346
1685
|
}
|
|
1347
1686
|
}
|
|
1348
1687
|
function focusLinuxWindowHyprland(windowId) {
|
|
1688
|
+
if (!isSafeCompositorWindowId(windowId))
|
|
1689
|
+
return;
|
|
1349
1690
|
try {
|
|
1350
|
-
|
|
1691
|
+
execFileSync("hyprctl", ["dispatch", "focuswindow", `address:${windowId}`], { timeout: 1000, stdio: "ignore" });
|
|
1351
1692
|
} catch {}
|
|
1352
1693
|
}
|
|
1353
1694
|
function focusLinuxWindowSway(windowId) {
|
|
1695
|
+
if (!isNumericWindowId(windowId))
|
|
1696
|
+
return;
|
|
1354
1697
|
try {
|
|
1355
|
-
|
|
1698
|
+
execFileSync("swaymsg", [`[con_id=${windowId}] focus`], { timeout: 1000, stdio: "ignore" });
|
|
1356
1699
|
} catch {}
|
|
1357
1700
|
}
|
|
1358
1701
|
function focusLinuxWindowNiri(windowId) {
|
|
1702
|
+
if (!isNumericWindowId(windowId))
|
|
1703
|
+
return;
|
|
1359
1704
|
try {
|
|
1360
|
-
|
|
1705
|
+
execFileSync("niri", ["msg", "action", "focus-window", "--id", windowId], { timeout: 1000, stdio: "ignore" });
|
|
1361
1706
|
} catch {}
|
|
1362
1707
|
}
|
|
1363
1708
|
function captureStartupWindowId() {
|
|
@@ -1383,11 +1728,11 @@ async function focusTerminal() {
|
|
|
1383
1728
|
const expectedApps = getExpectedMacTerminalAppNames(process.env);
|
|
1384
1729
|
for (const app of expectedApps) {
|
|
1385
1730
|
try {
|
|
1386
|
-
|
|
1731
|
+
execFileSync("osascript", buildOsascriptActivateAppArgs(app), { timeout: 1000, stdio: "ignore" });
|
|
1387
1732
|
return;
|
|
1388
1733
|
} catch {}
|
|
1389
1734
|
}
|
|
1390
|
-
|
|
1735
|
+
execFileSync("osascript", buildOsascriptActivateAppArgs("Terminal"), { timeout: 1000, stdio: "ignore" });
|
|
1391
1736
|
} catch {}
|
|
1392
1737
|
return;
|
|
1393
1738
|
}
|
|
@@ -1442,6 +1787,9 @@ function prunePermissionAlertState(cutoffMs) {
|
|
|
1442
1787
|
|
|
1443
1788
|
// src/index.ts
|
|
1444
1789
|
var IDLE_COMPLETE_DELAY_MS = 350;
|
|
1790
|
+
function isCLIClient(clientEnv) {
|
|
1791
|
+
return !clientEnv || clientEnv === "cli";
|
|
1792
|
+
}
|
|
1445
1793
|
var pendingIdleTimers = new Map;
|
|
1446
1794
|
var sessionIdleSequence = new Map;
|
|
1447
1795
|
var sessionErrorSuppressionAt = new Map;
|
|
@@ -1492,7 +1840,7 @@ function incrementTurnCount() {
|
|
|
1492
1840
|
saveTurnCount(globalTurnCount);
|
|
1493
1841
|
return globalTurnCount;
|
|
1494
1842
|
}
|
|
1495
|
-
setInterval(() => {
|
|
1843
|
+
var cleanupInterval = setInterval(() => {
|
|
1496
1844
|
const cutoff = Date.now() - 5 * 60 * 1000;
|
|
1497
1845
|
for (const [sessionID] of sessionIdleSequence) {
|
|
1498
1846
|
if (!pendingIdleTimers.has(sessionID)) {
|
|
@@ -1512,6 +1860,7 @@ setInterval(() => {
|
|
|
1512
1860
|
}
|
|
1513
1861
|
prunePermissionAlertState(cutoff);
|
|
1514
1862
|
}, 5 * 60 * 1000);
|
|
1863
|
+
cleanupInterval.unref();
|
|
1515
1864
|
function getNotificationTitle(config, projectName) {
|
|
1516
1865
|
if (config.showProjectName && projectName) {
|
|
1517
1866
|
return `OpenCode (${projectName})`;
|
|
@@ -1567,7 +1916,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
|
|
|
1567
1916
|
const title = getNotificationTitle(config, projectName);
|
|
1568
1917
|
const iconPath = getIconPath(config);
|
|
1569
1918
|
const onNotificationClick = isKDEJumpBackSupported() ? () => void focusTerminal() : undefined;
|
|
1570
|
-
promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick));
|
|
1919
|
+
promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick, config.windows.appID));
|
|
1571
1920
|
}
|
|
1572
1921
|
if (isEventSoundEnabled(config, eventType)) {
|
|
1573
1922
|
const customSoundPath = getSoundPath(config, eventType);
|
|
@@ -1591,6 +1940,33 @@ function getSessionIDFromEvent(event) {
|
|
|
1591
1940
|
const properties = getNestedRecord(event, "properties");
|
|
1592
1941
|
return getStringField(properties, "sessionID");
|
|
1593
1942
|
}
|
|
1943
|
+
function getPermissionIDFromEvent(event) {
|
|
1944
|
+
const properties = getNestedRecord(event, "properties");
|
|
1945
|
+
const id = getStringField(properties, "id");
|
|
1946
|
+
if (id) {
|
|
1947
|
+
return id;
|
|
1948
|
+
}
|
|
1949
|
+
const request = getNestedRecord(event, "properties", "request");
|
|
1950
|
+
return getStringField(request, "id");
|
|
1951
|
+
}
|
|
1952
|
+
var PERMISSION_PENDING_GRACE_MS = 300;
|
|
1953
|
+
async function isPermissionStillPending(client, permissionID) {
|
|
1954
|
+
try {
|
|
1955
|
+
const inner = client?._client || client?.session?._client;
|
|
1956
|
+
if (!inner || typeof inner.get !== "function") {
|
|
1957
|
+
return true;
|
|
1958
|
+
}
|
|
1959
|
+
const listResponse = await inner.get({ url: "/permission" });
|
|
1960
|
+
const body = listResponse?.data ?? listResponse;
|
|
1961
|
+
const pendingList = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : null;
|
|
1962
|
+
if (!pendingList) {
|
|
1963
|
+
return true;
|
|
1964
|
+
}
|
|
1965
|
+
return pendingList.some((p) => p?.id === permissionID);
|
|
1966
|
+
} catch {
|
|
1967
|
+
return true;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1594
1970
|
function getSessionLifecycleInfo(event) {
|
|
1595
1971
|
const info = getNestedRecord(event, "properties", "info");
|
|
1596
1972
|
return {
|
|
@@ -1751,9 +2127,14 @@ var NotifierPlugin = async ({ client, directory }) => {
|
|
|
1751
2127
|
}
|
|
1752
2128
|
const getConfig = () => loadConfig();
|
|
1753
2129
|
const projectName = directory ? getConfig().showFullPath ? directory : basename(directory) : null;
|
|
1754
|
-
|
|
2130
|
+
const isCLI = isCLIClient(clientEnv);
|
|
2131
|
+
if (isCLI) {
|
|
1755
2132
|
handleEvent(getConfig(), "client_connected", projectName, null);
|
|
1756
|
-
}
|
|
2133
|
+
} else {
|
|
2134
|
+
setTimeout(() => {
|
|
2135
|
+
handleEvent(getConfig(), "client_connected", projectName, null);
|
|
2136
|
+
}, 100);
|
|
2137
|
+
}
|
|
1757
2138
|
return {
|
|
1758
2139
|
event: async ({ event }) => {
|
|
1759
2140
|
const config = getConfig();
|
|
@@ -1779,14 +2160,26 @@ var NotifierPlugin = async ({ client, directory }) => {
|
|
|
1779
2160
|
}
|
|
1780
2161
|
if (event.type === "permission.asked") {
|
|
1781
2162
|
const sessionID = getSessionIDFromEvent(event);
|
|
1782
|
-
|
|
2163
|
+
const permissionID = getPermissionIDFromEvent(event);
|
|
2164
|
+
let stillPending = true;
|
|
2165
|
+
if (permissionID) {
|
|
2166
|
+
await new Promise((resolve) => setTimeout(resolve, PERMISSION_PENDING_GRACE_MS));
|
|
2167
|
+
stillPending = await isPermissionStillPending(client, permissionID);
|
|
2168
|
+
}
|
|
2169
|
+
if (stillPending && !shouldSuppressPermissionAlert(sessionID)) {
|
|
1783
2170
|
await handleEventWithElapsedTime(client, config, "permission", projectName, event);
|
|
1784
2171
|
}
|
|
1785
2172
|
}
|
|
1786
2173
|
if (event.type === "session.idle") {
|
|
1787
2174
|
const sessionID = getSessionIDFromEvent(event);
|
|
1788
2175
|
if (sessionID) {
|
|
1789
|
-
|
|
2176
|
+
if (isCLI) {
|
|
2177
|
+
const idleReceivedAtMs = Date.now();
|
|
2178
|
+
const sequence = bumpSessionIdleSequence(sessionID);
|
|
2179
|
+
await processSessionIdle(client, config, projectName, event, sessionID, sequence, idleReceivedAtMs);
|
|
2180
|
+
} else {
|
|
2181
|
+
scheduleSessionIdle(client, config, projectName, event, sessionID);
|
|
2182
|
+
}
|
|
1790
2183
|
} else {
|
|
1791
2184
|
await handleEventWithElapsedTime(client, config, "complete", projectName, event);
|
|
1792
2185
|
}
|
|
@@ -1832,9 +2225,17 @@ var NotifierPlugin = async ({ client, directory }) => {
|
|
|
1832
2225
|
}
|
|
1833
2226
|
};
|
|
1834
2227
|
};
|
|
1835
|
-
var
|
|
2228
|
+
var pluginModule = {
|
|
2229
|
+
id: "opencode-notifier",
|
|
2230
|
+
server: NotifierPlugin
|
|
2231
|
+
};
|
|
2232
|
+
var src_default = pluginModule;
|
|
1836
2233
|
export {
|
|
1837
|
-
|
|
2234
|
+
NotifierPlugin,
|
|
2235
|
+
PERMISSION_PENDING_GRACE_MS,
|
|
1838
2236
|
src_default as default,
|
|
1839
|
-
|
|
2237
|
+
extractAgentNameFromSessionTitle,
|
|
2238
|
+
getPermissionIDFromEvent,
|
|
2239
|
+
isCLIClient,
|
|
2240
|
+
isPermissionStillPending
|
|
1840
2241
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mohak34/opencode-notifier",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.9-beta.1",
|
|
4
4
|
"description": "OpenCode plugin that sends system notifications and plays sounds when permission is needed, generation completes, or errors occur",
|
|
5
5
|
"author": "mohak34",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"node-notifier": "^10.0.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
|
-
"@opencode-ai/plugin": "^1.
|
|
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
|
}
|