@nm156/opencode-notifier 0.3.1 → 0.4.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/dist/index.js CHANGED
@@ -1,7 +1,3 @@
1
- // src/index.ts
2
- import { basename } from "path";
3
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
4
-
5
1
  // src/config.ts
6
2
  import { readFileSync, existsSync } from "fs";
7
3
  import { join, dirname } from "path";
@@ -369,1453 +365,1420 @@ function interpolateMessage(message, context) {
369
365
  return result;
370
366
  }
371
367
 
372
- // src/notify.ts
373
- import os2 from "os";
374
- import { execFile, spawn } from "child_process";
375
- import notifier from "node-notifier";
376
- var DEBOUNCE_MS = 1000;
377
- var platform = os2.type();
378
- var platformNotifier;
379
- if (platform === "Windows_NT" || is_wsl_default) {
380
- const { WindowsToaster } = notifier;
381
- platformNotifier = new WindowsToaster({ withFallback: false });
382
- } else if (platform === "Linux" || platform.match(/BSD$/)) {
383
- const { NotifySend } = notifier;
384
- platformNotifier = new NotifySend({ withFallback: false });
385
- } else if (platform !== "Darwin") {
386
- platformNotifier = notifier;
387
- }
388
- var LINUX_FOCUS_ACTION_KEY = "focus-terminal";
389
- var LINUX_FOCUS_ACTION_LABEL = "Jump to terminal";
390
- var lastNotificationTime = {};
391
- var lastLinuxNotificationId = null;
392
- var linuxNotifySendSupportsReplace = null;
393
- function sanitizeGhosttyField(value) {
394
- return value.replace(/[;\u0000-\u001f\u007f-\u009f]/g, "");
368
+ // src/focus.ts
369
+ import { execFileSync, execSync } from "child_process";
370
+ import { accessSync, constants, mkdtempSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
371
+ import { tmpdir } from "os";
372
+ import { delimiter, join as join2 } from "path";
373
+ var LINUX_TERMINAL_APPS = new Set([
374
+ "ghostty",
375
+ "konsole",
376
+ "gnome-terminal",
377
+ "xterm",
378
+ "urxvt",
379
+ "alacritty",
380
+ "kitty",
381
+ "wezterm",
382
+ "wezterm-gui",
383
+ "tilix",
384
+ "terminator",
385
+ "xfce4-terminal",
386
+ "lxterminal",
387
+ "mate-terminal",
388
+ "deepin-terminal",
389
+ "foot",
390
+ "footclient"
391
+ ]);
392
+ var MAC_TERMINAL_APP_NAMES = new Set([
393
+ "terminal",
394
+ "iterm2",
395
+ "ghostty",
396
+ "wezterm-gui",
397
+ "alacritty",
398
+ "kitty",
399
+ "hyper",
400
+ "warp",
401
+ "tabby",
402
+ "cursor",
403
+ "visual studio code",
404
+ "code",
405
+ "code insiders",
406
+ "zed",
407
+ "rio"
408
+ ]);
409
+ function execWithTimeout(command, timeoutMs = 500) {
410
+ try {
411
+ return execSync(command, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
412
+ } catch {
413
+ return null;
414
+ }
395
415
  }
396
- function formatGhosttyNotificationSequence(title, message, env = process.env) {
397
- const escapedTitle = sanitizeGhosttyField(title);
398
- const escapedMessage = sanitizeGhosttyField(message);
399
- const payload = `\x1B]9;${escapedTitle}: ${escapedMessage}\x07`;
400
- if (env.TMUX) {
401
- return `\x1BPtmux;\x1B${payload}\x1B\\`;
416
+ function execFileWithTimeout(command, args, timeoutMs = 500) {
417
+ try {
418
+ return execFileSync(command, args, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim();
419
+ } catch {
420
+ return null;
402
421
  }
403
- return payload;
404
422
  }
405
- function detectNotifySendCapabilities() {
406
- return new Promise((resolve) => {
407
- execFile("notify-send", ["--version"], (error, stdout) => {
408
- if (error) {
409
- resolve(false);
410
- return;
411
- }
412
- const match = stdout.match(/(\d+)\.(\d+)/);
413
- if (match) {
414
- const major = parseInt(match[1], 10);
415
- const minor = parseInt(match[2], 10);
416
- resolve(major > 0 || major === 0 && minor >= 8);
417
- return;
418
- }
419
- resolve(false);
420
- });
421
- });
423
+ function isNumericWindowId(value) {
424
+ return /^\d+$/.test(value);
422
425
  }
423
- function sendLinuxNotificationDirect(title, message, timeout, iconPath, grouping = true, onAction) {
424
- return new Promise((resolve) => {
425
- if (onAction) {
426
- sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping, onAction).then(() => resolve()).catch(() => resolve());
427
- return;
428
- }
429
- const args = [];
430
- args.push("--app-name", "opencode");
431
- if (iconPath) {
432
- args.push("--icon", iconPath);
433
- }
434
- args.push("--expire-time", String(timeout * 1000));
435
- if (grouping && lastLinuxNotificationId !== null) {
436
- args.push("--replace-id", String(lastLinuxNotificationId));
437
- }
438
- if (grouping) {
439
- args.push("--print-id");
440
- }
441
- args.push("--", title, message);
442
- execFile("notify-send", args, (error, stdout) => {
443
- if (!error && grouping && stdout) {
444
- const id = parseInt(stdout.trim(), 10);
445
- if (!isNaN(id)) {
446
- lastLinuxNotificationId = id;
447
- }
448
- }
449
- resolve();
450
- });
451
- });
426
+ function isSafeCompositorWindowId(value) {
427
+ return /^[A-Za-z0-9._:-]+$/.test(value);
452
428
  }
453
- async function sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping = true, onAction) {
454
- const args = ["--app-name", "opencode"];
455
- if (iconPath) {
456
- args.push("--icon", iconPath);
457
- }
458
- args.push("--expire-time", String(timeout * 1000));
459
- if (grouping && lastLinuxNotificationId !== null) {
460
- args.push("--replace-id", String(lastLinuxNotificationId));
461
- }
462
- args.push("--print-id");
463
- args.push("--action", `${LINUX_FOCUS_ACTION_KEY}=${LINUX_FOCUS_ACTION_LABEL}`);
464
- args.push("--", title, message);
465
- return new Promise((resolve) => {
466
- const child = spawn("notify-send", args, { stdio: ["ignore", "pipe", "pipe"] });
467
- let stdout = "";
468
- const consumeStdout = () => {
469
- const lines = stdout.split(/\r?\n/);
470
- stdout = lines.pop() ?? "";
471
- for (const rawLine of lines) {
472
- const line = rawLine.trim();
473
- if (!line) {
474
- continue;
475
- }
476
- const parsed = parseNotifySendOutputLine(line);
477
- if (!parsed) {
478
- continue;
479
- }
480
- if (parsed.type === "id") {
481
- if (grouping) {
482
- lastLinuxNotificationId = parsed.id;
483
- }
484
- continue;
485
- }
486
- if (onAction) {
487
- if (parsed.action === "focus") {
488
- onAction("focus");
489
- } else if (parsed.action === "close") {
490
- onAction("close");
491
- }
492
- }
493
- }
494
- };
495
- child.stdout?.on("data", (data) => {
496
- stdout += data.toString();
497
- consumeStdout();
498
- });
499
- child.on("close", () => {
500
- if (stdout.trim().length > 0) {
501
- stdout += `
502
- `;
503
- consumeStdout();
504
- }
505
- resolve();
506
- });
507
- child.on("error", () => {
508
- resolve();
509
- });
510
- });
429
+ function firstNumericWindowId(output) {
430
+ if (!output)
431
+ return null;
432
+ const value = output.split(/\s+/).find((part) => isNumericWindowId(part));
433
+ return value ?? null;
511
434
  }
512
- function parseNotifySendOutputLine(line) {
513
- const trimmed = line.trim();
514
- if (!trimmed) {
435
+ function getHyprlandActiveWindowId() {
436
+ const output = execWithTimeout("hyprctl activewindow -j");
437
+ if (!output)
438
+ return null;
439
+ try {
440
+ const data = JSON.parse(output);
441
+ return typeof data?.address === "string" && isSafeCompositorWindowId(data.address) ? data.address : null;
442
+ } catch {
515
443
  return null;
516
444
  }
517
- if (/^\d+$/.test(trimmed)) {
518
- const id = parseInt(trimmed, 10);
519
- if (!isNaN(id)) {
520
- return { type: "id", id };
521
- }
445
+ }
446
+ function findFocusedWindowId(node) {
447
+ if (node.focused === true && typeof node.id === "number") {
448
+ return String(node.id);
522
449
  }
523
- if (trimmed === LINUX_FOCUS_ACTION_KEY) {
524
- return { type: "action", action: "focus" };
450
+ if (Array.isArray(node.nodes)) {
451
+ for (const child of node.nodes) {
452
+ const id = findFocusedWindowId(child);
453
+ if (id !== null)
454
+ return id;
455
+ }
525
456
  }
526
- if (trimmed === "close") {
527
- return { type: "action", action: "close" };
457
+ if (Array.isArray(node.floating_nodes)) {
458
+ for (const child of node.floating_nodes) {
459
+ const id = findFocusedWindowId(child);
460
+ if (id !== null)
461
+ return id;
462
+ }
528
463
  }
529
464
  return null;
530
465
  }
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) {
542
- const now = Date.now();
543
- if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
544
- return;
466
+ function getSwayActiveWindowId() {
467
+ const output = execWithTimeout("swaymsg -t get_tree", 1000);
468
+ if (!output)
469
+ return null;
470
+ try {
471
+ const tree = JSON.parse(output);
472
+ return findFocusedWindowId(tree);
473
+ } catch {
474
+ return null;
545
475
  }
546
- lastNotificationTime[message] = now;
547
- if (notificationSystem === "ghostty") {
548
- return new Promise((resolve) => {
549
- const sequence = formatGhosttyNotificationSequence(title, message);
550
- process.stdout.write(sequence, () => {
551
- resolve();
552
- });
553
- });
554
- }
555
- if (platform === "Darwin") {
556
- if (notificationSystem === "node-notifier") {
557
- return new Promise((resolve) => {
558
- const notificationOptions = {
559
- title,
560
- message,
561
- timeout,
562
- icon: iconPath
563
- };
564
- notifier.notify(notificationOptions, () => {
565
- resolve();
566
- });
567
- });
568
- }
569
- return new Promise((resolve) => {
570
- execFile("osascript", buildOsascriptNotificationArgs(title, message), () => {
571
- resolve();
572
- });
573
- });
476
+ }
477
+ function getNiriActiveWindowId() {
478
+ const output = execWithTimeout("niri msg --json focused-window", 1000);
479
+ if (!output)
480
+ return null;
481
+ try {
482
+ const data = JSON.parse(output);
483
+ return typeof data?.id === "number" ? String(data.id) : null;
484
+ } catch {
485
+ return null;
574
486
  }
575
- if ((platform === "Linux" || platform.match(/BSD$/)) && !is_wsl_default) {
576
- if (!process.env.DBUS_SESSION_BUS_ADDRESS)
577
- return;
578
- if (onClick) {
579
- if (linuxGrouping) {
580
- if (linuxNotifySendSupportsReplace === null) {
581
- linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
582
- }
583
- if (linuxNotifySendSupportsReplace) {
584
- return sendLinuxNotificationDirect(title, message, timeout, iconPath, true, () => onClick());
585
- }
586
- }
587
- return sendLinuxNotificationDirect(title, message, timeout, iconPath, false, () => onClick());
588
- }
589
- if (linuxGrouping) {
590
- if (linuxNotifySendSupportsReplace === null) {
591
- linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
592
- }
593
- if (linuxNotifySendSupportsReplace) {
594
- return sendLinuxNotificationDirect(title, message, timeout, iconPath, true);
487
+ }
488
+ function parseWezTermFocusedPaneId(output) {
489
+ try {
490
+ const data = JSON.parse(output);
491
+ if (!Array.isArray(data))
492
+ return null;
493
+ for (const client of data) {
494
+ if (typeof client?.focused_pane_id === "number") {
495
+ return String(client.focused_pane_id);
595
496
  }
596
497
  }
498
+ return null;
499
+ } catch {
500
+ return null;
597
501
  }
598
- return new Promise((resolve) => {
599
- const notificationOptions = {
600
- title,
601
- message,
602
- timeout,
603
- icon: iconPath,
604
- appName: windowsAppID ?? "opencode"
605
- };
606
- platformNotifier.notify(notificationOptions, (err, response, metadata) => {
607
- if (onClick && metadata?.activationType === "default") {
608
- onClick();
609
- }
610
- resolve();
611
- });
612
- });
613
502
  }
614
-
615
- // src/sound.ts
616
- import { platform as platform2 } from "os";
617
- import { join as join2, dirname as dirname2 } from "path";
618
- import { fileURLToPath as fileURLToPath2 } from "url";
619
- import { existsSync as existsSync2 } from "fs";
620
- import { spawn as spawn2 } from "child_process";
621
- var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
622
- var DEBOUNCE_MS2 = 1000;
623
- var FULL_VOLUME_PERCENT = 100;
624
- var FULL_VOLUME_PULSE = 65536;
625
- var lastSoundTime = {};
626
- function getBundledSoundPath(event) {
627
- const soundFilename = `${event}.wav`;
628
- const possiblePaths = [
629
- join2(__dirname2, "..", "sounds", soundFilename),
630
- join2(__dirname2, "sounds", soundFilename)
631
- ];
632
- for (const path of possiblePaths) {
633
- if (existsSync2(path)) {
634
- return path;
635
- }
636
- }
637
- return join2(__dirname2, "..", "sounds", soundFilename);
503
+ function getLinuxWaylandActiveWindowId() {
504
+ const env = process.env;
505
+ if (env.HYPRLAND_INSTANCE_SIGNATURE)
506
+ return getHyprlandActiveWindowId();
507
+ if (env.NIRI_SOCKET)
508
+ return getNiriActiveWindowId();
509
+ if (env.SWAYSOCK)
510
+ return getSwayActiveWindowId();
511
+ if (env.KDE_SESSION_VERSION)
512
+ return firstNumericWindowId(execWithTimeout("kdotool getactivewindow"));
513
+ if (isGnomeLikeSession(env))
514
+ return getGnomeAtspiActiveWindowKey();
515
+ return null;
638
516
  }
639
- function getSoundFilePath(event, customPath) {
640
- if (customPath && existsSync2(customPath)) {
641
- return customPath;
517
+ var ATSPI_ACTIVE_BIT_INDEX = 1;
518
+ var ATSPI_TERMINAL_PATH_MARKERS = ["mitchellh/ghostty"];
519
+ var ATSPI_TERMINAL_APP_ALIASES = new Set([
520
+ "gnome-terminal-server"
521
+ ]);
522
+ var GNOME_LIKE_DESKTOPS = new Set(["gnome", "ubuntu", "pop"]);
523
+ function isGnomeLikeSession(env = process.env) {
524
+ const desktop = `${env.XDG_CURRENT_DESKTOP ?? ""} ${env.DESKTOP_SESSION ?? ""}`.toLowerCase();
525
+ return desktop.split(/[:;\s]+/).some((token) => GNOME_LIKE_DESKTOPS.has(token));
526
+ }
527
+ function getLinuxFocusBackendName(env = process.env) {
528
+ if (env.HYPRLAND_INSTANCE_SIGNATURE)
529
+ return "hyprland";
530
+ if (env.NIRI_SOCKET)
531
+ return "niri";
532
+ if (env.SWAYSOCK)
533
+ return "sway";
534
+ if (env.KDE_SESSION_VERSION)
535
+ return "kde";
536
+ if (isGnomeLikeSession(env))
537
+ return "gnome-atspi";
538
+ if (env.DISPLAY)
539
+ return "x11";
540
+ if (env.WAYLAND_DISPLAY)
541
+ return "wayland-unsupported";
542
+ return "none";
543
+ }
544
+ function parseAtspiString(output) {
545
+ if (!output)
546
+ return null;
547
+ const match = output.match(/'((?:[^'\\]|\\.)*)'/);
548
+ if (!match)
549
+ return null;
550
+ try {
551
+ return JSON.parse(`"${match[1].replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`);
552
+ } catch {
553
+ return match[1];
642
554
  }
643
- const bundledPath = getBundledSoundPath(event);
644
- if (existsSync2(bundledPath)) {
645
- return bundledPath;
555
+ }
556
+ function parseAtspiObjectRefs(output) {
557
+ if (!output)
558
+ return [];
559
+ const refs = [];
560
+ const re = /\('([^']+)',\s*(?:objectpath\s+)?'([^']+)'\)/g;
561
+ let match;
562
+ while ((match = re.exec(output)) !== null) {
563
+ refs.push({ bus: match[1], path: match[2] });
646
564
  }
647
- return null;
565
+ return refs;
648
566
  }
649
- async function runCommand(command, args) {
650
- return new Promise((resolve, reject) => {
651
- const proc = spawn2(command, args, {
652
- stdio: "ignore",
653
- detached: false,
654
- windowsHide: true
655
- });
656
- proc.on("error", (err) => {
657
- reject(err);
658
- });
659
- proc.on("close", (code) => {
660
- if (code === 0) {
661
- resolve();
662
- } else {
663
- reject(new Error(`Command exited with code ${code}`));
664
- }
665
- });
666
- });
567
+ function parseAtspiStateActive(output) {
568
+ if (!output)
569
+ return null;
570
+ const match = output.match(/uint32\s+(\d+)/);
571
+ if (!match)
572
+ return null;
573
+ const firstWord = Number(match[1]) >>> 0;
574
+ return (firstWord >>> ATSPI_ACTIVE_BIT_INDEX & 1) === 1;
667
575
  }
668
- function normalizeVolume(volume) {
669
- if (!Number.isFinite(volume)) {
670
- return 1;
576
+ function isAtspiTerminalWindow(appName, windowPath) {
577
+ if (ATSPI_TERMINAL_PATH_MARKERS.some((marker) => windowPath.toLowerCase().includes(marker))) {
578
+ return true;
671
579
  }
672
- if (volume < 0) {
673
- return 0;
580
+ if (!appName)
581
+ return false;
582
+ const normalized = appName.trim().toLowerCase();
583
+ return LINUX_TERMINAL_APPS.has(normalized) || ATSPI_TERMINAL_APP_ALIASES.has(normalized);
584
+ }
585
+ var ATSPI_ROLES = new Set(["window", "frame", "dialog"]);
586
+ function isAtspiWindowRoleAccepted(role) {
587
+ return role !== null && ATSPI_ROLES.has(role);
588
+ }
589
+ function callAtspi(address, dest, path, method, args = [], timeoutMs = 500) {
590
+ return execFileWithTimeout("gdbus", [
591
+ "call",
592
+ "--address",
593
+ address,
594
+ "--dest",
595
+ dest,
596
+ "--object-path",
597
+ path,
598
+ "--method",
599
+ method,
600
+ ...args
601
+ ], timeoutMs);
602
+ }
603
+ var cachedAtspiAddress = null;
604
+ var ATSPI_ADDRESS_CACHE_TTL_MS = 1e4;
605
+ function getAtspiBusAddress() {
606
+ const now = Date.now();
607
+ if (cachedAtspiAddress && now - cachedAtspiAddress.at < ATSPI_ADDRESS_CACHE_TTL_MS) {
608
+ return cachedAtspiAddress.address;
674
609
  }
675
- if (volume > 1) {
676
- return 1;
610
+ cachedAtspiAddress = null;
611
+ const output = execFileWithTimeout("gdbus", [
612
+ "call",
613
+ "--session",
614
+ "--dest",
615
+ "org.a11y.Bus",
616
+ "--object-path",
617
+ "/org/a11y/bus",
618
+ "--method",
619
+ "org.a11y.Bus.GetAddress"
620
+ ], 1000);
621
+ const address = parseAtspiString(output);
622
+ if (address) {
623
+ cachedAtspiAddress = { address, at: now };
677
624
  }
678
- return volume;
625
+ return address;
679
626
  }
680
- function toPercentVolume(volume) {
681
- return Math.round(volume * FULL_VOLUME_PERCENT);
627
+ function getAtspiWindowRole(address, ref) {
628
+ const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetRoleName");
629
+ return parseAtspiString(output);
682
630
  }
683
- function toPulseVolume(volume) {
684
- return Math.round(volume * FULL_VOLUME_PULSE);
631
+ function isAtspiWindowActive(address, ref) {
632
+ const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetState");
633
+ return parseAtspiStateActive(output);
685
634
  }
686
- async function playOnLinux(soundPath, volume) {
687
- const percentVolume = toPercentVolume(volume);
688
- const pulseVolume = toPulseVolume(volume);
689
- const players = [
690
- { command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
691
- { command: "aplay", args: [soundPath] },
692
- {
693
- command: "mpv",
694
- args: [
695
- "--no-video",
696
- "--no-terminal",
697
- "--script-opts=autoload-disabled=yes",
698
- "--keep-open=no",
699
- "--idle=no",
700
- `--volume=${percentVolume}`,
701
- soundPath
702
- ]
703
- },
704
- { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
705
- ];
706
- for (const player of players) {
707
- try {
708
- await runCommand(player.command, player.args);
709
- return;
710
- } catch {
711
- continue;
635
+ function getAtspiTerminalWindowRefs(address) {
636
+ const refs = [];
637
+ const rootOutput = callAtspi(address, "org.a11y.atspi.Registry", "/org/a11y/atspi/accessible/root", "org.a11y.atspi.Accessible.GetChildren", [], 1000);
638
+ for (const app of parseAtspiObjectRefs(rootOutput)) {
639
+ const appName = parseAtspiString(callAtspi(address, app.bus, "/org/a11y/atspi/accessible/root", "org.freedesktop.DBus.Properties.Get", ["org.a11y.atspi.Accessible", "Name"]));
640
+ const childrenOutput = callAtspi(address, app.bus, app.path, "org.a11y.atspi.Accessible.GetChildren");
641
+ for (const child of parseAtspiObjectRefs(childrenOutput)) {
642
+ if (!isAtspiTerminalWindow(appName, child.path))
643
+ continue;
644
+ const role = getAtspiWindowRole(address, child)?.toLowerCase() ?? null;
645
+ if (isAtspiWindowRoleAccepted(role)) {
646
+ refs.push(child);
647
+ }
712
648
  }
713
649
  }
650
+ return refs;
714
651
  }
715
- async function playOnMac(soundPath, volume) {
716
- await runCommand("afplay", ["-v", `${volume}`, soundPath]);
717
- }
718
- async function playOnWindows(soundPath) {
719
- const script = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`;
720
- const encoded = Buffer.from(script, "utf16le").toString("base64");
721
- await runCommand("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
722
- }
723
- async function playSound(event, customPath, volume) {
724
- const now = Date.now();
725
- if (lastSoundTime[event] && now - lastSoundTime[event] < DEBOUNCE_MS2) {
726
- return;
727
- }
728
- lastSoundTime[event] = now;
729
- const soundPath = getSoundFilePath(event, customPath);
730
- const normalizedVolume = normalizeVolume(volume);
731
- if (!soundPath) {
732
- return;
733
- }
734
- const os = platform2();
652
+ function getGnomeAtspiActiveWindowKey() {
735
653
  try {
736
- switch (os) {
737
- case "darwin":
738
- await playOnMac(soundPath, normalizedVolume);
739
- break;
740
- case "linux":
741
- await playOnLinux(soundPath, normalizedVolume);
742
- break;
743
- case "win32":
744
- await playOnWindows(soundPath);
745
- break;
746
- default:
747
- break;
654
+ const address = getAtspiBusAddress();
655
+ if (!address)
656
+ return null;
657
+ for (const ref of getAtspiTerminalWindowRefs(address)) {
658
+ if (isAtspiWindowActive(address, ref) === true) {
659
+ return `atspi:${ref.bus}@${ref.path}`;
660
+ }
748
661
  }
749
- } catch {}
750
- }
751
-
752
- // src/bell.ts
753
- var lastBellTime = 0;
754
- var BELL_DEBOUNCE_MS = 500;
755
- function ringBell(now = Date.now()) {
756
- if (!process.stdout.isTTY) {
757
- return Promise.resolve();
758
- }
759
- if (now - lastBellTime < BELL_DEBOUNCE_MS) {
760
- return Promise.resolve();
662
+ return null;
663
+ } catch {
664
+ return null;
761
665
  }
762
- lastBellTime = now;
763
- return new Promise((resolve) => {
764
- process.stdout.write("\x07", () => {
765
- resolve();
766
- });
767
- });
768
- }
769
-
770
- // src/command.ts
771
- import { spawn as spawn3 } from "child_process";
772
- function substituteTokens(value, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
773
- let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
774
- result = result.replaceAll("{sessionTitle}", sessionTitle || "");
775
- result = result.replaceAll("{agentName}", agentName || "");
776
- result = result.replaceAll("{projectName}", projectName || "");
777
- result = result.replaceAll("{timestamp}", timestamp || "");
778
- result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
779
- return result;
780
666
  }
781
- function runCommand2(config, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
782
- if (!config.command.enabled || !config.command.path) {
783
- return;
667
+ function debugFocusState(message) {
668
+ if (process.env.OPENCODE_NOTIFIER_DEBUG) {
669
+ console.error(`[opencode-notifier] ${message}`);
784
670
  }
785
- const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
786
- const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
787
- const proc = spawn3(command, args, {
788
- shell: false,
789
- stdio: "ignore",
790
- detached: true,
791
- windowsHide: true
792
- });
793
- proc.on("error", () => {});
794
- proc.unref();
795
671
  }
796
-
797
- // src/focus.ts
798
- import { execFileSync, execSync } from "child_process";
799
- import { accessSync, constants, mkdtempSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
800
- import { tmpdir } from "os";
801
- import { delimiter, join as join3 } from "path";
802
- var LINUX_TERMINAL_APPS = new Set([
803
- "ghostty",
804
- "konsole",
805
- "gnome-terminal",
806
- "xterm",
807
- "urxvt",
672
+ var WINDOWS_TERMINAL_WINDOW_CLASSES = new Set([
673
+ "cascadia_hosting_window_class",
674
+ "consolewindowclass",
675
+ "windowsterminalwindowclass"
676
+ ]);
677
+ var WINDOWS_TERMINAL_PROCESS_NAMES = new Set([
678
+ "windowsterminal",
679
+ "windowsterminalpreview",
680
+ "conhost",
808
681
  "alacritty",
809
- "kitty",
810
682
  "wezterm",
811
683
  "wezterm-gui",
812
- "tilix",
813
- "terminator",
814
- "xfce4-terminal",
815
- "lxterminal",
816
- "mate-terminal",
817
- "deepin-terminal",
818
- "foot",
819
- "footclient"
820
- ]);
821
- var MAC_TERMINAL_APP_NAMES = new Set([
822
- "terminal",
823
- "iterm2",
824
- "ghostty",
825
- "wezterm-gui",
826
- "alacritty",
827
684
  "kitty",
828
685
  "hyper",
829
- "warp",
830
- "tabby",
831
686
  "cursor",
832
- "visual studio code",
833
687
  "code",
834
- "code insiders",
835
- "zed",
836
- "rio"
688
+ "code - insiders"
837
689
  ]);
838
- function execWithTimeout(command, timeoutMs = 500) {
839
- try {
840
- return execSync(command, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
841
- } catch {
690
+ function getWindowsActiveWindowInfo() {
691
+ const script = `
692
+ $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;
693
+ $h=$p::GetForegroundWindow();
694
+ if(!$h){return}
695
+ $sb=New-Object System.Text.StringBuilder 256;
696
+ $p::GetClassName($h,$sb,256)|Out-Null;
697
+ $c=$sb.ToString();
698
+ $procId=0;
699
+ $p::GetWindowThreadProcessId($h,[ref]$procId)|Out-Null;
700
+ $pn='';
701
+ try{$pn=(Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName}catch{}
702
+ Write-Output "$c|$pn"
703
+ `.trim().replace(/\n/g, "; ");
704
+ let output = execFileWithTimeout("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 5000);
705
+ if (!output)
706
+ output = execFileWithTimeout("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
707
+ if (!output)
842
708
  return null;
843
- }
844
- }
845
- function execFileWithTimeout(command, args, timeoutMs = 500) {
846
- try {
847
- return execFileSync(command, args, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim();
848
- } catch {
709
+ const line = output.split(`
710
+ `).map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith("#<"));
711
+ if (!line)
849
712
  return null;
850
- }
713
+ const sep = line.indexOf("|");
714
+ if (sep === -1)
715
+ return null;
716
+ const className = line.substring(0, sep) || null;
717
+ const processName = line.substring(sep + 1) || null;
718
+ return { className, processName };
851
719
  }
852
- function isNumericWindowId(value) {
853
- return /^\d+$/.test(value);
720
+ function getMacOSActiveWindowId() {
721
+ return execWithTimeout(`osascript -e 'tell application "System Events" to return id of window 1 of (first application process whose frontmost is true)'`);
854
722
  }
855
- function isSafeCompositorWindowId(value) {
856
- return /^[A-Za-z0-9._:-]+$/.test(value);
723
+ function getMacOSFrontmostAppName() {
724
+ const lsappinfo = execWithTimeout(`lsappinfo info -only name \`lsappinfo front\` | sed 's/.*="\\([^"]*\\)".*/\\1/'`);
725
+ if (lsappinfo)
726
+ return lsappinfo;
727
+ return execWithTimeout(`osascript -e 'tell application "System Events" to return name of first application process whose frontmost is true'`);
857
728
  }
858
- function firstNumericWindowId(output) {
859
- if (!output)
860
- return null;
861
- const value = output.split(/\s+/).find((part) => isNumericWindowId(part));
862
- return value ?? null;
729
+ function normalizeMacAppName(value) {
730
+ return value.trim().toLowerCase().replace(/\.app$/i, "").replace(/\s+/g, " ");
863
731
  }
864
- function getHyprlandActiveWindowId() {
865
- const output = execWithTimeout("hyprctl activewindow -j");
866
- if (!output)
867
- return null;
868
- try {
869
- const data = JSON.parse(output);
870
- return typeof data?.address === "string" && isSafeCompositorWindowId(data.address) ? data.address : null;
871
- } catch {
872
- return null;
732
+ function getExpectedMacTerminalAppNames(env) {
733
+ const expected = new Set;
734
+ const termProgram = typeof env.TERM_PROGRAM === "string" ? normalizeMacAppName(env.TERM_PROGRAM) : "";
735
+ if (env.TMUX && (termProgram === "tmux" || termProgram === "screen" || termProgram.length === 0)) {
736
+ return new Set(MAC_TERMINAL_APP_NAMES);
873
737
  }
874
- }
875
- function findFocusedWindowId(node) {
876
- if (node.focused === true && typeof node.id === "number") {
877
- return String(node.id);
738
+ if (termProgram === "apple_terminal") {
739
+ expected.add("terminal");
740
+ } else if (termProgram === "iterm" || termProgram === "iterm2") {
741
+ expected.add("iterm2");
742
+ } else if (termProgram === "vscode") {
743
+ expected.add("visual studio code");
744
+ expected.add("code");
745
+ expected.add("code insiders");
746
+ } else if (termProgram === "warpterminal") {
747
+ expected.add("warp");
748
+ } else if (termProgram === "wezterm") {
749
+ expected.add("wezterm-gui");
750
+ } else if (termProgram.length > 0) {
751
+ expected.add(termProgram);
878
752
  }
879
- if (Array.isArray(node.nodes)) {
880
- for (const child of node.nodes) {
881
- const id = findFocusedWindowId(child);
882
- if (id !== null)
883
- return id;
884
- }
753
+ if (expected.size > 0) {
754
+ return expected;
885
755
  }
886
- if (Array.isArray(node.floating_nodes)) {
887
- for (const child of node.floating_nodes) {
888
- const id = findFocusedWindowId(child);
889
- if (id !== null)
890
- return id;
891
- }
756
+ return new Set(MAC_TERMINAL_APP_NAMES);
757
+ }
758
+ function buildOsascriptActivateAppArgs(appName) {
759
+ const escapedAppName = appName.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replaceAll("\\", "\\\\").replaceAll('"', "\\\"");
760
+ return [
761
+ "-e",
762
+ `tell application "${escapedAppName}" to activate`
763
+ ];
764
+ }
765
+ function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
766
+ if (!frontmostAppName) {
767
+ return false;
892
768
  }
893
- return null;
769
+ const normalizedFrontmost = normalizeMacAppName(frontmostAppName);
770
+ if (!normalizedFrontmost) {
771
+ return false;
772
+ }
773
+ const expectedApps = getExpectedMacTerminalAppNames(env);
774
+ return expectedApps.has(normalizedFrontmost);
894
775
  }
895
- function getSwayActiveWindowId() {
896
- const output = execWithTimeout("swaymsg -t get_tree", 1000);
897
- if (!output)
776
+ function getActiveWindowId() {
777
+ const platform = process.platform;
778
+ if (platform === "darwin")
779
+ return getMacOSActiveWindowId();
780
+ if (platform === "linux") {
781
+ if (process.env.WAYLAND_DISPLAY)
782
+ return getLinuxWaylandActiveWindowId();
783
+ if (process.env.DISPLAY)
784
+ return firstNumericWindowId(execWithTimeout("xdotool getactivewindow"));
898
785
  return null;
899
- try {
900
- const tree = JSON.parse(output);
901
- return findFocusedWindowId(tree);
902
- } catch {
786
+ }
787
+ if (platform === "win32")
903
788
  return null;
789
+ return null;
790
+ }
791
+ var cachedWindowId = getActiveWindowId();
792
+ var cachedWindowTitleValue;
793
+ function getCachedWindowTitle() {
794
+ if (cachedWindowTitleValue !== undefined) {
795
+ return cachedWindowTitleValue;
904
796
  }
797
+ cachedWindowTitleValue = process.platform === "linux" && !!process.env.KDE_SESSION_VERSION && cachedWindowId ? getWindowTitleFromKdotool(cachedWindowId) : null;
798
+ return cachedWindowTitleValue;
905
799
  }
906
- function getNiriActiveWindowId() {
907
- const output = execWithTimeout("niri msg --json focused-window", 1000);
908
- if (!output)
909
- return null;
910
- try {
911
- const data = JSON.parse(output);
912
- return typeof data?.id === "number" ? String(data.id) : null;
913
- } catch {
914
- return null;
800
+ function isTmuxPaneFocused(tmuxPane, probeResult) {
801
+ if (!tmuxPane)
802
+ return false;
803
+ if (!probeResult)
804
+ return false;
805
+ const [sessionAttached, windowActive, paneActive] = probeResult.split(" ");
806
+ return Number(sessionAttached) > 0 && windowActive === "1" && paneActive === "1";
807
+ }
808
+ function isLinuxTerminalFocused(params) {
809
+ const { cachedWindowId: cachedWindowId2, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
810
+ if (!cachedWindowId2) {
811
+ if (!wezTermPaneActive)
812
+ return false;
813
+ if (tmuxPaneActive !== null)
814
+ return tmuxPaneActive;
815
+ return false;
915
816
  }
817
+ if (currentWindowId !== cachedWindowId2)
818
+ return false;
819
+ if (!wezTermPaneActive)
820
+ return false;
821
+ if (tmuxPaneActive !== null)
822
+ return tmuxPaneActive;
823
+ return true;
916
824
  }
917
- function parseWezTermFocusedPaneId(output) {
825
+ function isWindowsTerminalFocused(params) {
826
+ const { className, processName } = params;
827
+ const classLower = className?.toLowerCase() ?? "";
828
+ const processLower = processName?.toLowerCase() ?? "";
829
+ return WINDOWS_TERMINAL_WINDOW_CLASSES.has(classLower) || WINDOWS_TERMINAL_PROCESS_NAMES.has(processLower);
830
+ }
831
+ function isTmuxPaneActive() {
832
+ const tmuxPane = process.env.TMUX_PANE ?? null;
833
+ const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
834
+ return isTmuxPaneFocused(tmuxPane, result);
835
+ }
836
+ function isWezTermPaneActive() {
837
+ const weztermPane = process.env.WEZTERM_PANE ?? null;
838
+ if (!weztermPane)
839
+ return true;
840
+ const output = execFileWithTimeout("wezterm", ["cli", "list-clients", "--format", "json"], 1000);
841
+ if (!output)
842
+ return false;
843
+ const focusedPaneId = parseWezTermFocusedPaneId(output);
844
+ if (!focusedPaneId)
845
+ return false;
846
+ return focusedPaneId === weztermPane;
847
+ }
848
+ function isTerminalFocused() {
918
849
  try {
919
- const data = JSON.parse(output);
920
- if (!Array.isArray(data))
921
- return null;
922
- for (const client of data) {
923
- if (typeof client?.focused_pane_id === "number") {
924
- return String(client.focused_pane_id);
850
+ if (process.platform === "darwin") {
851
+ const frontmostAppName = getMacOSFrontmostAppName();
852
+ if (!isMacTerminalAppFocused(frontmostAppName, process.env)) {
853
+ return false;
854
+ }
855
+ if (!isWezTermPaneActive()) {
856
+ return false;
857
+ }
858
+ if (process.env.TMUX) {
859
+ return isTmuxPaneActive();
925
860
  }
861
+ return true;
926
862
  }
927
- return null;
863
+ if (process.platform === "win32") {
864
+ const info = getWindowsActiveWindowInfo();
865
+ return isWindowsTerminalFocused({
866
+ className: info?.className ?? null,
867
+ processName: info?.processName ?? null
868
+ });
869
+ }
870
+ const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
871
+ const currentWindowId = getActiveWindowId();
872
+ const focused = isLinuxTerminalFocused({
873
+ cachedWindowId,
874
+ currentWindowId,
875
+ wezTermPaneActive: isWezTermPaneActive(),
876
+ tmuxPaneActive
877
+ });
878
+ 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}`);
879
+ return focused;
928
880
  } catch {
929
- return null;
881
+ return false;
930
882
  }
931
883
  }
932
- function getLinuxWaylandActiveWindowId() {
933
- const env = process.env;
934
- if (env.HYPRLAND_INSTANCE_SIGNATURE)
935
- return getHyprlandActiveWindowId();
936
- if (env.NIRI_SOCKET)
937
- return getNiriActiveWindowId();
938
- if (env.SWAYSOCK)
939
- return getSwayActiveWindowId();
940
- if (env.KDE_SESSION_VERSION)
941
- return firstNumericWindowId(execWithTimeout("kdotool getactivewindow"));
942
- if (isGnomeLikeSession(env))
943
- return getGnomeAtspiActiveWindowKey();
944
- return null;
945
- }
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));
884
+ function getWindowIdFromXdotool(searchTerm) {
885
+ return firstNumericWindowId(execFileWithTimeout("xdotool", ["search", "--classname", searchTerm]));
955
886
  }
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";
887
+ function getWindowIdFromKdotool(searchTerm) {
888
+ return firstNumericWindowId(execFileWithTimeout("kdotool", ["search", "--classname", searchTerm]));
972
889
  }
973
- function parseAtspiString(output) {
974
- if (!output)
975
- return null;
976
- const match = output.match(/'((?:[^'\\]|\\.)*)'/);
977
- if (!match)
890
+ function getWindowTitleFromKdotool(windowId) {
891
+ if (!isNumericWindowId(windowId))
978
892
  return null;
979
- try {
980
- return JSON.parse(`"${match[1].replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`);
981
- } catch {
982
- return match[1];
983
- }
893
+ return execFileWithTimeout("kdotool", ["getwindowname", windowId]);
984
894
  }
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] });
895
+ var cachedKDEJumpBackSupport = null;
896
+ function isKDEJumpBackSupported() {
897
+ if (process.platform !== "linux" || !process.env.KDE_SESSION_VERSION) {
898
+ return false;
993
899
  }
994
- return refs;
900
+ if (cachedKDEJumpBackSupport !== null) {
901
+ return cachedKDEJumpBackSupport;
902
+ }
903
+ cachedKDEJumpBackSupport = execFileWithTimeout("kdotool", ["--help"], 1000) !== null;
904
+ return cachedKDEJumpBackSupport;
995
905
  }
996
- function parseAtspiStateActive(output) {
997
- if (!output)
906
+ function getWindowClassX11(windowId) {
907
+ if (!isNumericWindowId(windowId))
998
908
  return null;
999
- const match = output.match(/uint32\s+(\d+)/);
1000
- if (!match)
909
+ const output = execFileWithTimeout("xprop", ["-id", windowId, "WM_CLASS"]);
910
+ if (!output)
1001
911
  return null;
1002
- const firstWord = Number(match[1]) >>> 0;
1003
- return (firstWord >>> ATSPI_ACTIVE_BIT_INDEX & 1) === 1;
912
+ const matches = [...output.matchAll(/"([^"]*)"/g)].map((match) => match[1]);
913
+ return matches[1] ?? matches[0] ?? null;
1004
914
  }
1005
- function isAtspiTerminalWindow(appName, windowPath) {
1006
- if (ATSPI_TERMINAL_PATH_MARKERS.some((marker) => windowPath.toLowerCase().includes(marker))) {
1007
- return true;
915
+ function getWaylandAppId(windowId) {
916
+ if (process.env.HYPRLAND_INSTANCE_SIGNATURE) {
917
+ const output = execWithTimeout(`hyprctl clients -j`);
918
+ if (!output)
919
+ return null;
920
+ try {
921
+ const clients = JSON.parse(output);
922
+ for (const client of clients) {
923
+ if (String(client.address) === windowId) {
924
+ return client.class?.toLowerCase() || client.initialClass?.toLowerCase() || null;
925
+ }
926
+ }
927
+ } catch {
928
+ return null;
929
+ }
1008
930
  }
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;
931
+ if (process.env.SWAYSOCK) {
932
+ const output = execWithTimeout(`swaymsg -t get_tree`, 1000);
933
+ if (!output)
934
+ return null;
935
+ try {
936
+ const tree = JSON.parse(output);
937
+ const findWindow = (node) => {
938
+ if (String(node.id) === windowId) {
939
+ return node.app_id?.toLowerCase() || node.window_properties?.class?.toLowerCase() || null;
940
+ }
941
+ if (Array.isArray(node.nodes)) {
942
+ for (const child of node.nodes) {
943
+ const result = findWindow(child);
944
+ if (result)
945
+ return result;
946
+ }
947
+ }
948
+ if (Array.isArray(node.floating_nodes)) {
949
+ for (const child of node.floating_nodes) {
950
+ const result = findWindow(child);
951
+ if (result)
952
+ return result;
953
+ }
954
+ }
955
+ return null;
956
+ };
957
+ return findWindow(tree);
958
+ } catch {
959
+ return null;
960
+ }
1038
961
  }
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 };
962
+ if (process.env.NIRI_SOCKET) {
963
+ const output = execWithTimeout(`niri msg --json windows`);
964
+ if (!output)
965
+ return null;
966
+ try {
967
+ const windows = JSON.parse(output);
968
+ for (const window of windows) {
969
+ if (String(window.id) === windowId) {
970
+ return window.app_id?.toLowerCase() || null;
971
+ }
972
+ }
973
+ } catch {
974
+ return null;
975
+ }
1053
976
  }
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);
977
+ return null;
1063
978
  }
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);
979
+ function getTerminalWindowId() {
980
+ if (process.platform !== "linux")
981
+ return null;
982
+ const term = process.env.TERM_PROGRAM?.toLowerCase() || "";
983
+ const desktopSession = process.env.DESKTOP_SESSION?.toLowerCase() || "";
984
+ const isKDE = process.env.KDE_SESSION_VERSION || desktopSession.includes("plasma");
985
+ if (process.env.WAYLAND_DISPLAY) {
986
+ const cachedId = cachedWindowId;
987
+ if (cachedId) {
988
+ const appId = getWaylandAppId(cachedId);
989
+ if (appId && LINUX_TERMINAL_APPS.has(appId)) {
990
+ return cachedId;
1076
991
  }
1077
992
  }
993
+ if (isKDE) {
994
+ return cachedId || "kde-wayland";
995
+ }
996
+ return cachedId;
1078
997
  }
1079
- return refs;
998
+ if (process.env.DISPLAY) {
999
+ const cachedId = cachedWindowId;
1000
+ if (cachedId) {
1001
+ const windowClass = getWindowClassX11(cachedId);
1002
+ if (windowClass && LINUX_TERMINAL_APPS.has(windowClass.toLowerCase())) {
1003
+ return cachedId;
1004
+ }
1005
+ }
1006
+ for (const app of LINUX_TERMINAL_APPS) {
1007
+ const id = isKDE ? getWindowIdFromKdotool(app) : getWindowIdFromXdotool(app);
1008
+ if (id)
1009
+ return id;
1010
+ }
1011
+ }
1012
+ return null;
1080
1013
  }
1081
- function getGnomeAtspiActiveWindowKey() {
1014
+ function focusLinuxWindowX11(windowId) {
1015
+ if (!isNumericWindowId(windowId))
1016
+ return;
1082
1017
  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}`;
1018
+ execFileSync("xdotool", ["windowactivate", windowId], { timeout: 1000, stdio: "ignore" });
1019
+ } catch {}
1020
+ }
1021
+ function findTerminalPid() {
1022
+ try {
1023
+ let currentPid = process.pid;
1024
+ while (currentPid > 1) {
1025
+ try {
1026
+ const statContent = readFileSync2(`/proc/${currentPid}/stat`, "utf-8");
1027
+ const match = statContent.match(/^\d+\s+\([^)]+\)\s+\S\s+(\d+)/);
1028
+ if (!match)
1029
+ break;
1030
+ const ppid = parseInt(match[1], 10);
1031
+ const cmdline = readFileSync2(`/proc/${ppid}/comm`, "utf-8").trim();
1032
+ if (cmdline.match(/ghostty|konsole|gnome-terminal|xterm|alacritty|kitty|wezterm|terminator|tilix|foot/i)) {
1033
+ return ppid;
1034
+ }
1035
+ currentPid = ppid;
1036
+ } catch {
1037
+ break;
1089
1038
  }
1090
1039
  }
1091
- return null;
1040
+ return process.ppid;
1092
1041
  } catch {
1093
- return null;
1042
+ return process.ppid;
1094
1043
  }
1095
1044
  }
1096
- function debugFocusState(message) {
1097
- if (process.env.OPENCODE_NOTIFIER_DEBUG) {
1098
- console.error(`[opencode-notifier] ${message}`);
1045
+ var cachedQdbusBinary;
1046
+ var QDBUS_CANDIDATES = ["qdbus-qt6", "qdbus6", "qdbus-qt5", "qdbus5", "qdbus"];
1047
+ function isExecutableOnPath(name) {
1048
+ const pathEnv = process.env.PATH ?? "";
1049
+ for (const dir of pathEnv.split(delimiter)) {
1050
+ if (!dir)
1051
+ continue;
1052
+ const full = join2(dir, name);
1053
+ try {
1054
+ accessSync(full, constants.X_OK);
1055
+ if (statSync(full).isFile())
1056
+ return true;
1057
+ } catch {}
1099
1058
  }
1059
+ return false;
1100
1060
  }
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 };
1148
- }
1149
- function getMacOSActiveWindowId() {
1150
- return execWithTimeout(`osascript -e 'tell application "System Events" to return id of window 1 of (first application process whose frontmost is true)'`);
1151
- }
1152
- function getMacOSFrontmostAppName() {
1153
- const lsappinfo = execWithTimeout(`lsappinfo info -only name \`lsappinfo front\` | sed 's/.*="\\([^"]*\\)".*/\\1/'`);
1154
- if (lsappinfo)
1155
- return lsappinfo;
1156
- return execWithTimeout(`osascript -e 'tell application "System Events" to return name of first application process whose frontmost is true'`);
1157
- }
1158
- function normalizeMacAppName(value) {
1159
- return value.trim().toLowerCase().replace(/\.app$/i, "").replace(/\s+/g, " ");
1061
+ function findQdbusBinary(candidates = QDBUS_CANDIDATES, isExecutable = isExecutableOnPath) {
1062
+ for (const candidate of candidates) {
1063
+ if (isExecutable(candidate)) {
1064
+ return candidate;
1065
+ }
1066
+ }
1067
+ return null;
1160
1068
  }
1161
- function getExpectedMacTerminalAppNames(env) {
1162
- const expected = new Set;
1163
- const termProgram = typeof env.TERM_PROGRAM === "string" ? normalizeMacAppName(env.TERM_PROGRAM) : "";
1164
- if (env.TMUX && (termProgram === "tmux" || termProgram === "screen" || termProgram.length === 0)) {
1165
- return new Set(MAC_TERMINAL_APP_NAMES);
1069
+ function resolveQdbusBinary() {
1070
+ if (cachedQdbusBinary !== undefined) {
1071
+ return cachedQdbusBinary;
1166
1072
  }
1167
- if (termProgram === "apple_terminal") {
1168
- expected.add("terminal");
1169
- } else if (termProgram === "iterm" || termProgram === "iterm2") {
1170
- expected.add("iterm2");
1171
- } else if (termProgram === "vscode") {
1172
- expected.add("visual studio code");
1173
- expected.add("code");
1174
- expected.add("code insiders");
1175
- } else if (termProgram === "warpterminal") {
1176
- expected.add("warp");
1177
- } else if (termProgram === "wezterm") {
1178
- expected.add("wezterm-gui");
1179
- } else if (termProgram.length > 0) {
1180
- expected.add(termProgram);
1181
- }
1182
- if (expected.size > 0) {
1183
- return expected;
1184
- }
1185
- return new Set(MAC_TERMINAL_APP_NAMES);
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
- }
1194
- function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
1195
- if (!frontmostAppName) {
1196
- return false;
1197
- }
1198
- const normalizedFrontmost = normalizeMacAppName(frontmostAppName);
1199
- if (!normalizedFrontmost) {
1200
- return false;
1201
- }
1202
- const expectedApps = getExpectedMacTerminalAppNames(env);
1203
- return expectedApps.has(normalizedFrontmost);
1073
+ cachedQdbusBinary = findQdbusBinary();
1074
+ return cachedQdbusBinary;
1204
1075
  }
1205
- function getActiveWindowId() {
1206
- const platform = process.platform;
1207
- if (platform === "darwin")
1208
- return getMacOSActiveWindowId();
1209
- if (platform === "linux") {
1210
- if (process.env.WAYLAND_DISPLAY)
1211
- return getLinuxWaylandActiveWindowId();
1212
- if (process.env.DISPLAY)
1213
- return firstNumericWindowId(execWithTimeout("xdotool getactivewindow"));
1214
- return null;
1215
- }
1216
- if (platform === "win32")
1217
- return null;
1218
- return null;
1076
+ function focusKDEWithKWinScript() {
1077
+ try {
1078
+ const pinnedWindowId = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim() || null;
1079
+ if (pinnedWindowId && isNumericWindowId(pinnedWindowId)) {
1080
+ try {
1081
+ execFileSync("kdotool", ["windowactivate", pinnedWindowId], { timeout: 1500, stdio: "ignore" });
1082
+ return;
1083
+ } catch {}
1084
+ }
1085
+ if (cachedWindowId && isNumericWindowId(cachedWindowId)) {
1086
+ try {
1087
+ execFileSync("kdotool", ["windowactivate", cachedWindowId], { timeout: 1500, stdio: "ignore" });
1088
+ return;
1089
+ } catch {}
1090
+ }
1091
+ const terminalPid = findTerminalPid();
1092
+ const currentPid = process.pid;
1093
+ const termProgram = (process.env.TERM_PROGRAM || "terminal").toLowerCase();
1094
+ const cwd = process.cwd().toLowerCase();
1095
+ const cwdBase = cwd.split("/").filter(Boolean).pop() || "";
1096
+ const cachedTitle = (getCachedWindowTitle() || "").toLowerCase();
1097
+ const scriptContent = `
1098
+ function activateTargetWindow(window) {
1099
+ // Jump to the window's desktop/activity first, then activate.
1100
+ // This works more reliably on Plasma than moving windows between desktops.
1101
+ try {
1102
+ if (window.desktops && window.desktops.length > 0) {
1103
+ workspace.currentDesktop = window.desktops[0];
1104
+ } else if (typeof window.desktop === "number" && window.desktop > 0) {
1105
+ workspace.currentDesktop = window.desktop;
1106
+ }
1107
+ } catch (e) {}
1108
+
1109
+ try {
1110
+ if (window.activities && window.activities.length > 0 && typeof workspace.currentActivity !== "undefined") {
1111
+ workspace.currentActivity = window.activities[0];
1112
+ }
1113
+ } catch (e) {}
1114
+
1115
+ try { window.minimized = false; } catch (e) {}
1116
+
1117
+ try { workspace.activeWindow = window; } catch (e) {}
1118
+ try {
1119
+ if (typeof workspace.activateWindow === "function") {
1120
+ workspace.activateWindow(window);
1121
+ }
1122
+ } catch (e) {}
1123
+ try { window.active = true; } catch (e) {}
1124
+
1125
+ // Nudge stacking so KWin treats this like an explicit user jump.
1126
+ try {
1127
+ window.keepAbove = true;
1128
+ window.keepAbove = false;
1129
+ } catch (e) {}
1219
1130
  }
1220
- var cachedWindowId = getActiveWindowId();
1221
- var cachedWindowTitleValue;
1222
- function getCachedWindowTitle() {
1223
- if (cachedWindowTitleValue !== undefined) {
1224
- return cachedWindowTitleValue;
1225
- }
1226
- cachedWindowTitleValue = process.platform === "linux" && !!process.env.KDE_SESSION_VERSION && cachedWindowId ? getWindowTitleFromKdotool(cachedWindowId) : null;
1227
- return cachedWindowTitleValue;
1131
+
1132
+ function isLikelyTerminal(window) {
1133
+ var resourceClass = (window.resourceClass || "").toLowerCase();
1134
+ var resourceName = (window.resourceName || "").toLowerCase();
1135
+ var caption = (window.caption || "").toLowerCase();
1136
+
1137
+ return resourceClass.indexOf("ghostty") !== -1 ||
1138
+ resourceName.indexOf("ghostty") !== -1 ||
1139
+ caption.indexOf("ghostty") !== -1 ||
1140
+ resourceClass.indexOf("konsole") !== -1 ||
1141
+ resourceName.indexOf("konsole") !== -1 ||
1142
+ caption.indexOf("konsole") !== -1 ||
1143
+ resourceClass.indexOf("terminal") !== -1 ||
1144
+ resourceName.indexOf("terminal") !== -1;
1228
1145
  }
1229
- function isTmuxPaneFocused(tmuxPane, probeResult) {
1230
- if (!tmuxPane)
1231
- return false;
1232
- if (!probeResult)
1146
+
1147
+ function findAndActivateTerminal() {
1148
+ var allWindows = workspace.windowList();
1149
+ var terminalPid = ${terminalPid};
1150
+ var termProgramHint = ${JSON.stringify(termProgram)};
1151
+ var cwdHint = ${JSON.stringify(cwd)};
1152
+ var cwdBaseHint = ${JSON.stringify(cwdBase)};
1153
+ var cachedTitleHint = ${JSON.stringify(cachedTitle)};
1154
+
1155
+ function contains(haystack, needle) {
1156
+ return !!needle && needle.length > 0 && haystack.indexOf(needle) !== -1;
1157
+ }
1158
+
1159
+ function windowScore(window) {
1160
+ var resourceClass = (window.resourceClass || "").toLowerCase();
1161
+ var resourceName = (window.resourceName || "").toLowerCase();
1162
+ var caption = (window.caption || "").toLowerCase();
1163
+ var score = 0;
1164
+
1165
+ if (window.pid === terminalPid) score += 30;
1166
+ if (contains(caption, "opencode")) score += 60;
1167
+ if (contains(caption, cachedTitleHint)) score += 50;
1168
+ if (contains(caption, cwdBaseHint)) score += 35;
1169
+ if (contains(caption, cwdHint)) score += 20;
1170
+ if (contains(resourceClass, termProgramHint) || contains(resourceName, termProgramHint) || contains(caption, termProgramHint)) score += 20;
1171
+ if (isLikelyTerminal(window)) score += 10;
1172
+ if (window.minimized === true) score -= 5;
1173
+
1174
+ return score;
1175
+ }
1176
+
1177
+ var bestWindow = null;
1178
+ var bestScore = -1;
1179
+
1180
+ for (var i = 0; i < allWindows.length; i++) {
1181
+ var candidate = allWindows[i];
1182
+ var score = windowScore(candidate);
1183
+ if (score > bestScore) {
1184
+ bestWindow = candidate;
1185
+ bestScore = score;
1186
+ }
1187
+ }
1188
+
1189
+ // Require enough confidence to avoid jumping to unrelated terminals.
1190
+ if (bestWindow && bestScore >= 30) {
1191
+ activateTargetWindow(bestWindow);
1192
+ return true;
1193
+ }
1194
+
1233
1195
  return false;
1234
- const [sessionAttached, windowActive, paneActive] = probeResult.split(" ");
1235
- return Number(sessionAttached) > 0 && windowActive === "1" && paneActive === "1";
1236
1196
  }
1237
- function isLinuxTerminalFocused(params) {
1238
- const { cachedWindowId, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
1239
- if (!cachedWindowId) {
1240
- if (!wezTermPaneActive)
1241
- return false;
1242
- if (tmuxPaneActive !== null)
1243
- return tmuxPaneActive;
1244
- return false;
1197
+
1198
+ findAndActivateTerminal();
1199
+ `;
1200
+ const pluginName = `opencode-focus-${currentPid}`;
1201
+ let scriptDirectory = null;
1202
+ let scriptPath = null;
1203
+ let scriptLoaded = false;
1204
+ const cleanupScript = () => {
1205
+ if (!scriptPath || !scriptDirectory)
1206
+ return;
1207
+ try {
1208
+ rmSync(scriptPath, { force: true });
1209
+ } catch {}
1210
+ try {
1211
+ rmSync(scriptDirectory, { recursive: true, force: true });
1212
+ } catch {}
1213
+ };
1214
+ const qdbus = resolveQdbusBinary();
1215
+ if (!qdbus) {
1216
+ throw new Error("qdbus not found");
1217
+ }
1218
+ try {
1219
+ scriptDirectory = mkdtempSync(join2(tmpdir(), "opencode-focus-"));
1220
+ scriptPath = join2(scriptDirectory, "script.kwinscript");
1221
+ writeFileSync(scriptPath, scriptContent, { encoding: "utf-8", mode: 384, flag: "wx" });
1222
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.loadScript", scriptPath, pluginName], { encoding: "utf-8", timeout: 2000, stdio: "ignore" });
1223
+ scriptLoaded = true;
1224
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.start"], {
1225
+ timeout: 2000,
1226
+ stdio: "ignore"
1227
+ });
1228
+ cleanupScript();
1229
+ setTimeout(() => {
1230
+ try {
1231
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
1232
+ timeout: 500,
1233
+ stdio: "ignore"
1234
+ });
1235
+ } catch {}
1236
+ }, 1000);
1237
+ } catch {
1238
+ if (scriptLoaded) {
1239
+ try {
1240
+ execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
1241
+ timeout: 500,
1242
+ stdio: "ignore"
1243
+ });
1244
+ } catch {}
1245
+ }
1246
+ cleanupScript();
1247
+ throw new Error("Unable to activate the terminal through KWin");
1248
+ }
1249
+ } catch {
1250
+ try {
1251
+ const cachedId = cachedWindowId;
1252
+ if (cachedId && isNumericWindowId(cachedId)) {
1253
+ execFileSync("xdotool", ["windowactivate", cachedId], { timeout: 1000, stdio: "ignore" });
1254
+ }
1255
+ } catch {}
1245
1256
  }
1246
- if (currentWindowId !== cachedWindowId)
1247
- return false;
1248
- if (!wezTermPaneActive)
1249
- return false;
1250
- if (tmuxPaneActive !== null)
1251
- return tmuxPaneActive;
1252
- return true;
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
- }
1260
- function isTmuxPaneActive() {
1261
- const tmuxPane = process.env.TMUX_PANE ?? null;
1262
- const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
1263
- return isTmuxPaneFocused(tmuxPane, result);
1264
1257
  }
1265
- function isWezTermPaneActive() {
1266
- const weztermPane = process.env.WEZTERM_PANE ?? null;
1267
- if (!weztermPane)
1268
- return true;
1269
- const output = execFileWithTimeout("wezterm", ["cli", "list-clients", "--format", "json"], 1000);
1270
- if (!output)
1271
- return false;
1272
- const focusedPaneId = parseWezTermFocusedPaneId(output);
1273
- if (!focusedPaneId)
1274
- return false;
1275
- return focusedPaneId === weztermPane;
1258
+ function focusLinuxWindowHyprland(windowId) {
1259
+ if (!isSafeCompositorWindowId(windowId))
1260
+ return;
1261
+ try {
1262
+ execFileSync("hyprctl", ["dispatch", "focuswindow", `address:${windowId}`], { timeout: 1000, stdio: "ignore" });
1263
+ } catch {}
1276
1264
  }
1277
- function isTerminalFocused() {
1265
+ function focusLinuxWindowSway(windowId) {
1266
+ if (!isNumericWindowId(windowId))
1267
+ return;
1278
1268
  try {
1279
- if (process.platform === "darwin") {
1269
+ execFileSync("swaymsg", [`[con_id=${windowId}] focus`], { timeout: 1000, stdio: "ignore" });
1270
+ } catch {}
1271
+ }
1272
+ function focusLinuxWindowNiri(windowId) {
1273
+ if (!isNumericWindowId(windowId))
1274
+ return;
1275
+ try {
1276
+ execFileSync("niri", ["msg", "action", "focus-window", "--id", windowId], { timeout: 1000, stdio: "ignore" });
1277
+ } catch {}
1278
+ }
1279
+ function captureStartupWindowId() {
1280
+ if (!isKDEJumpBackSupported()) {
1281
+ return;
1282
+ }
1283
+ const existing = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim();
1284
+ if (existing) {
1285
+ return;
1286
+ }
1287
+ const detected = execWithTimeout("kdotool getactivewindow", 1000);
1288
+ if (detected && /^\d+$/.test(detected)) {
1289
+ process.env.OPENCODE_NOTIFIER_WINDOW_ID = detected;
1290
+ }
1291
+ }
1292
+ async function focusTerminal() {
1293
+ if (process.platform === "darwin") {
1294
+ try {
1280
1295
  const frontmostAppName = getMacOSFrontmostAppName();
1281
- if (!isMacTerminalAppFocused(frontmostAppName, process.env)) {
1282
- return false;
1283
- }
1284
- if (!isWezTermPaneActive()) {
1285
- return false;
1296
+ if (frontmostAppName && isMacTerminalAppFocused(frontmostAppName, process.env)) {
1297
+ return;
1286
1298
  }
1287
- if (process.env.TMUX) {
1288
- return isTmuxPaneActive();
1299
+ const expectedApps = getExpectedMacTerminalAppNames(process.env);
1300
+ for (const app of expectedApps) {
1301
+ try {
1302
+ execFileSync("osascript", buildOsascriptActivateAppArgs(app), { timeout: 1000, stdio: "ignore" });
1303
+ return;
1304
+ } catch {}
1289
1305
  }
1290
- return true;
1306
+ execFileSync("osascript", buildOsascriptActivateAppArgs("Terminal"), { timeout: 1000, stdio: "ignore" });
1307
+ } catch {}
1308
+ return;
1309
+ }
1310
+ if (process.platform === "linux") {
1311
+ const env = process.env;
1312
+ if (env.KDE_SESSION_VERSION) {
1313
+ focusKDEWithKWinScript();
1314
+ return;
1291
1315
  }
1292
- if (process.platform === "win32") {
1293
- const info = getWindowsActiveWindowInfo();
1294
- return isWindowsTerminalFocused({
1295
- className: info?.className ?? null,
1296
- processName: info?.processName ?? null
1297
- });
1316
+ const windowId = getTerminalWindowId();
1317
+ if (!windowId)
1318
+ return;
1319
+ if (env.HYPRLAND_INSTANCE_SIGNATURE) {
1320
+ focusLinuxWindowHyprland(windowId);
1321
+ } else if (env.SWAYSOCK) {
1322
+ focusLinuxWindowSway(windowId);
1323
+ } else if (env.NIRI_SOCKET) {
1324
+ focusLinuxWindowNiri(windowId);
1325
+ } else if (env.DISPLAY) {
1326
+ focusLinuxWindowX11(windowId);
1298
1327
  }
1299
- const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
1300
- const currentWindowId = getActiveWindowId();
1301
- const focused = isLinuxTerminalFocused({
1302
- cachedWindowId,
1303
- currentWindowId,
1304
- wezTermPaneActive: isWezTermPaneActive(),
1305
- tmuxPaneActive
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;
1309
- } catch {
1310
- return false;
1311
1328
  }
1312
1329
  }
1313
- function getWindowIdFromXdotool(searchTerm) {
1314
- return firstNumericWindowId(execFileWithTimeout("xdotool", ["search", "--classname", searchTerm]));
1315
- }
1316
- function getWindowIdFromKdotool(searchTerm) {
1317
- return firstNumericWindowId(execFileWithTimeout("kdotool", ["search", "--classname", searchTerm]));
1330
+
1331
+ // src/notifier.ts
1332
+ import { basename } from "path";
1333
+
1334
+ // src/delivery.ts
1335
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
1336
+
1337
+ // src/notify.ts
1338
+ import os2 from "os";
1339
+ import { execFile as execFile2, spawn } from "child_process";
1340
+ import notifier from "node-notifier";
1341
+ var DEBOUNCE_MS = 1000;
1342
+ var platform = os2.type();
1343
+ var platformNotifier;
1344
+ if (platform === "Windows_NT" || is_wsl_default) {
1345
+ const { WindowsToaster } = notifier;
1346
+ platformNotifier = new WindowsToaster({ withFallback: false });
1347
+ } else if (platform === "Linux" || platform.match(/BSD$/)) {
1348
+ const { NotifySend } = notifier;
1349
+ platformNotifier = new NotifySend({ withFallback: false });
1350
+ } else if (platform !== "Darwin") {
1351
+ platformNotifier = notifier;
1318
1352
  }
1319
- function getWindowTitleFromKdotool(windowId) {
1320
- if (!isNumericWindowId(windowId))
1321
- return null;
1322
- return execFileWithTimeout("kdotool", ["getwindowname", windowId]);
1353
+ var LINUX_FOCUS_ACTION_KEY = "focus-terminal";
1354
+ var LINUX_FOCUS_ACTION_LABEL = "Jump to terminal";
1355
+ var lastNotificationTime = {};
1356
+ var lastLinuxNotificationId = null;
1357
+ var linuxNotifySendSupportsReplace = null;
1358
+ function sanitizeGhosttyField(value) {
1359
+ return value.replace(/[;\u0000-\u001f\u007f-\u009f]/g, "");
1323
1360
  }
1324
- var cachedKDEJumpBackSupport = null;
1325
- function isKDEJumpBackSupported() {
1326
- if (process.platform !== "linux" || !process.env.KDE_SESSION_VERSION) {
1327
- return false;
1328
- }
1329
- if (cachedKDEJumpBackSupport !== null) {
1330
- return cachedKDEJumpBackSupport;
1361
+ function formatGhosttyNotificationSequence(title, message, env = process.env) {
1362
+ const escapedTitle = sanitizeGhosttyField(title);
1363
+ const escapedMessage = sanitizeGhosttyField(message);
1364
+ const payload = `\x1B]9;${escapedTitle}: ${escapedMessage}\x07`;
1365
+ if (env.TMUX) {
1366
+ return `\x1BPtmux;\x1B${payload}\x1B\\`;
1331
1367
  }
1332
- cachedKDEJumpBackSupport = execFileWithTimeout("kdotool", ["--help"], 1000) !== null;
1333
- return cachedKDEJumpBackSupport;
1368
+ return payload;
1334
1369
  }
1335
- function getWindowClassX11(windowId) {
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;
1370
+ function detectNotifySendCapabilities() {
1371
+ return new Promise((resolve) => {
1372
+ execFile2("notify-send", ["--version"], (error, stdout) => {
1373
+ if (error) {
1374
+ resolve(false);
1375
+ return;
1376
+ }
1377
+ const match = stdout.match(/(\d+)\.(\d+)/);
1378
+ if (match) {
1379
+ const major = parseInt(match[1], 10);
1380
+ const minor = parseInt(match[2], 10);
1381
+ resolve(major > 0 || major === 0 && minor >= 8);
1382
+ return;
1383
+ }
1384
+ resolve(false);
1385
+ });
1386
+ });
1343
1387
  }
1344
- function getWaylandAppId(windowId) {
1345
- if (process.env.HYPRLAND_INSTANCE_SIGNATURE) {
1346
- const output = execWithTimeout(`hyprctl clients -j`);
1347
- if (!output)
1348
- return null;
1349
- try {
1350
- const clients = JSON.parse(output);
1351
- for (const client of clients) {
1352
- if (String(client.address) === windowId) {
1353
- return client.class?.toLowerCase() || client.initialClass?.toLowerCase() || null;
1388
+ function sendLinuxNotificationDirect(title, message, timeout, iconPath, grouping = true, onAction) {
1389
+ return new Promise((resolve) => {
1390
+ if (onAction) {
1391
+ sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping, onAction).then(() => resolve()).catch(() => resolve());
1392
+ return;
1393
+ }
1394
+ const args = [];
1395
+ args.push("--app-name", "opencode");
1396
+ if (iconPath) {
1397
+ args.push("--icon", iconPath);
1398
+ }
1399
+ args.push("--expire-time", String(timeout * 1000));
1400
+ if (grouping && lastLinuxNotificationId !== null) {
1401
+ args.push("--replace-id", String(lastLinuxNotificationId));
1402
+ }
1403
+ if (grouping) {
1404
+ args.push("--print-id");
1405
+ }
1406
+ args.push("--", title, message);
1407
+ execFile2("notify-send", args, (error, stdout) => {
1408
+ if (!error && grouping && stdout) {
1409
+ const id = parseInt(stdout.trim(), 10);
1410
+ if (!isNaN(id)) {
1411
+ lastLinuxNotificationId = id;
1354
1412
  }
1355
1413
  }
1356
- } catch {
1357
- return null;
1358
- }
1414
+ resolve();
1415
+ });
1416
+ });
1417
+ }
1418
+ async function sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping = true, onAction) {
1419
+ const args = ["--app-name", "opencode"];
1420
+ if (iconPath) {
1421
+ args.push("--icon", iconPath);
1359
1422
  }
1360
- if (process.env.SWAYSOCK) {
1361
- const output = execWithTimeout(`swaymsg -t get_tree`, 1000);
1362
- if (!output)
1363
- return null;
1364
- try {
1365
- const tree = JSON.parse(output);
1366
- const findWindow = (node) => {
1367
- if (String(node.id) === windowId) {
1368
- return node.app_id?.toLowerCase() || node.window_properties?.class?.toLowerCase() || null;
1423
+ args.push("--expire-time", String(timeout * 1000));
1424
+ if (grouping && lastLinuxNotificationId !== null) {
1425
+ args.push("--replace-id", String(lastLinuxNotificationId));
1426
+ }
1427
+ args.push("--print-id");
1428
+ args.push("--action", `${LINUX_FOCUS_ACTION_KEY}=${LINUX_FOCUS_ACTION_LABEL}`);
1429
+ args.push("--", title, message);
1430
+ return new Promise((resolve) => {
1431
+ const child = spawn("notify-send", args, { stdio: ["ignore", "pipe", "pipe"] });
1432
+ let stdout = "";
1433
+ const consumeStdout = () => {
1434
+ const lines = stdout.split(/\r?\n/);
1435
+ stdout = lines.pop() ?? "";
1436
+ for (const rawLine of lines) {
1437
+ const line = rawLine.trim();
1438
+ if (!line) {
1439
+ continue;
1369
1440
  }
1370
- if (Array.isArray(node.nodes)) {
1371
- for (const child of node.nodes) {
1372
- const result = findWindow(child);
1373
- if (result)
1374
- return result;
1441
+ const parsed = parseNotifySendOutputLine(line);
1442
+ if (!parsed) {
1443
+ continue;
1444
+ }
1445
+ if (parsed.type === "id") {
1446
+ if (grouping) {
1447
+ lastLinuxNotificationId = parsed.id;
1375
1448
  }
1449
+ continue;
1376
1450
  }
1377
- if (Array.isArray(node.floating_nodes)) {
1378
- for (const child of node.floating_nodes) {
1379
- const result = findWindow(child);
1380
- if (result)
1381
- return result;
1451
+ if (onAction) {
1452
+ if (parsed.action === "focus") {
1453
+ onAction("focus");
1454
+ } else if (parsed.action === "close") {
1455
+ onAction("close");
1382
1456
  }
1383
1457
  }
1384
- return null;
1385
- };
1386
- return findWindow(tree);
1387
- } catch {
1388
- return null;
1389
- }
1390
- }
1391
- if (process.env.NIRI_SOCKET) {
1392
- const output = execWithTimeout(`niri msg --json windows`);
1393
- if (!output)
1394
- return null;
1395
- try {
1396
- const windows = JSON.parse(output);
1397
- for (const window of windows) {
1398
- if (String(window.id) === windowId) {
1399
- return window.app_id?.toLowerCase() || null;
1400
- }
1401
1458
  }
1402
- } catch {
1403
- return null;
1404
- }
1405
- }
1406
- return null;
1459
+ };
1460
+ child.stdout?.on("data", (data) => {
1461
+ stdout += data.toString();
1462
+ consumeStdout();
1463
+ });
1464
+ child.on("close", () => {
1465
+ if (stdout.trim().length > 0) {
1466
+ stdout += `
1467
+ `;
1468
+ consumeStdout();
1469
+ }
1470
+ resolve();
1471
+ });
1472
+ child.on("error", () => {
1473
+ resolve();
1474
+ });
1475
+ });
1407
1476
  }
1408
- function getTerminalWindowId() {
1409
- if (process.platform !== "linux")
1477
+ function parseNotifySendOutputLine(line) {
1478
+ const trimmed = line.trim();
1479
+ if (!trimmed) {
1410
1480
  return null;
1411
- const term = process.env.TERM_PROGRAM?.toLowerCase() || "";
1412
- const desktopSession = process.env.DESKTOP_SESSION?.toLowerCase() || "";
1413
- const isKDE = process.env.KDE_SESSION_VERSION || desktopSession.includes("plasma");
1414
- if (process.env.WAYLAND_DISPLAY) {
1415
- const cachedId = cachedWindowId;
1416
- if (cachedId) {
1417
- const appId = getWaylandAppId(cachedId);
1418
- if (appId && LINUX_TERMINAL_APPS.has(appId)) {
1419
- return cachedId;
1420
- }
1421
- }
1422
- if (isKDE) {
1423
- return cachedId || "kde-wayland";
1424
- }
1425
- return cachedId;
1426
- }
1427
- if (process.env.DISPLAY) {
1428
- const cachedId = cachedWindowId;
1429
- if (cachedId) {
1430
- const windowClass = getWindowClassX11(cachedId);
1431
- if (windowClass && LINUX_TERMINAL_APPS.has(windowClass.toLowerCase())) {
1432
- return cachedId;
1433
- }
1434
- }
1435
- for (const app of LINUX_TERMINAL_APPS) {
1436
- const id = isKDE ? getWindowIdFromKdotool(app) : getWindowIdFromXdotool(app);
1437
- if (id)
1438
- return id;
1439
- }
1440
1481
  }
1441
- return null;
1442
- }
1443
- function focusLinuxWindowX11(windowId) {
1444
- if (!isNumericWindowId(windowId))
1445
- return;
1446
- try {
1447
- execFileSync("xdotool", ["windowactivate", windowId], { timeout: 1000, stdio: "ignore" });
1448
- } catch {}
1449
- }
1450
- function findTerminalPid() {
1451
- try {
1452
- let currentPid = process.pid;
1453
- while (currentPid > 1) {
1454
- try {
1455
- const statContent = readFileSync2(`/proc/${currentPid}/stat`, "utf-8");
1456
- const match = statContent.match(/^\d+\s+\([^)]+\)\s+\S\s+(\d+)/);
1457
- if (!match)
1458
- break;
1459
- const ppid = parseInt(match[1], 10);
1460
- const cmdline = readFileSync2(`/proc/${ppid}/comm`, "utf-8").trim();
1461
- if (cmdline.match(/ghostty|konsole|gnome-terminal|xterm|alacritty|kitty|wezterm|terminator|tilix|foot/i)) {
1462
- return ppid;
1463
- }
1464
- currentPid = ppid;
1465
- } catch {
1466
- break;
1467
- }
1482
+ if (/^\d+$/.test(trimmed)) {
1483
+ const id = parseInt(trimmed, 10);
1484
+ if (!isNaN(id)) {
1485
+ return { type: "id", id };
1468
1486
  }
1469
- return process.ppid;
1470
- } catch {
1471
- return process.ppid;
1472
1487
  }
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 {}
1488
+ if (trimmed === LINUX_FOCUS_ACTION_KEY) {
1489
+ return { type: "action", action: "focus" };
1487
1490
  }
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
- }
1491
+ if (trimmed === "close") {
1492
+ return { type: "action", action: "close" };
1495
1493
  }
1496
1494
  return null;
1497
1495
  }
1498
- function resolveQdbusBinary() {
1499
- if (cachedQdbusBinary !== undefined) {
1500
- return cachedQdbusBinary;
1501
- }
1502
- cachedQdbusBinary = findQdbusBinary();
1503
- return cachedQdbusBinary;
1504
- }
1505
- function focusKDEWithKWinScript() {
1506
- try {
1507
- const pinnedWindowId = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim() || null;
1508
- if (pinnedWindowId && isNumericWindowId(pinnedWindowId)) {
1509
- try {
1510
- execFileSync("kdotool", ["windowactivate", pinnedWindowId], { timeout: 1500, stdio: "ignore" });
1511
- return;
1512
- } catch {}
1513
- }
1514
- if (cachedWindowId && isNumericWindowId(cachedWindowId)) {
1515
- try {
1516
- execFileSync("kdotool", ["windowactivate", cachedWindowId], { timeout: 1500, stdio: "ignore" });
1517
- return;
1518
- } catch {}
1519
- }
1520
- const terminalPid = findTerminalPid();
1521
- const currentPid = process.pid;
1522
- const termProgram = (process.env.TERM_PROGRAM || "terminal").toLowerCase();
1523
- const cwd = process.cwd().toLowerCase();
1524
- const cwdBase = cwd.split("/").filter(Boolean).pop() || "";
1525
- const cachedTitle = (getCachedWindowTitle() || "").toLowerCase();
1526
- const scriptContent = `
1527
- function activateTargetWindow(window) {
1528
- // Jump to the window's desktop/activity first, then activate.
1529
- // This works more reliably on Plasma than moving windows between desktops.
1530
- try {
1531
- if (window.desktops && window.desktops.length > 0) {
1532
- workspace.currentDesktop = window.desktops[0];
1533
- } else if (typeof window.desktop === "number" && window.desktop > 0) {
1534
- workspace.currentDesktop = window.desktop;
1535
- }
1536
- } catch (e) {}
1537
-
1538
- try {
1539
- if (window.activities && window.activities.length > 0 && typeof workspace.currentActivity !== "undefined") {
1540
- workspace.currentActivity = window.activities[0];
1541
- }
1542
- } catch (e) {}
1543
-
1544
- try { window.minimized = false; } catch (e) {}
1545
-
1546
- try { workspace.activeWindow = window; } catch (e) {}
1547
- try {
1548
- if (typeof workspace.activateWindow === "function") {
1549
- workspace.activateWindow(window);
1550
- }
1551
- } catch (e) {}
1552
- try { window.active = true; } catch (e) {}
1553
-
1554
- // Nudge stacking so KWin treats this like an explicit user jump.
1555
- try {
1556
- window.keepAbove = true;
1557
- window.keepAbove = false;
1558
- } catch (e) {}
1496
+ function buildWindowsNotificationOptions(title, message, timeout, iconPath, windowsAppID) {
1497
+ return {
1498
+ title,
1499
+ message,
1500
+ timeout,
1501
+ icon: iconPath,
1502
+ appName: windowsAppID ?? "opencode"
1503
+ };
1559
1504
  }
1560
-
1561
- function isLikelyTerminal(window) {
1562
- var resourceClass = (window.resourceClass || "").toLowerCase();
1563
- var resourceName = (window.resourceName || "").toLowerCase();
1564
- var caption = (window.caption || "").toLowerCase();
1565
-
1566
- return resourceClass.indexOf("ghostty") !== -1 ||
1567
- resourceName.indexOf("ghostty") !== -1 ||
1568
- caption.indexOf("ghostty") !== -1 ||
1569
- resourceClass.indexOf("konsole") !== -1 ||
1570
- resourceName.indexOf("konsole") !== -1 ||
1571
- caption.indexOf("konsole") !== -1 ||
1572
- resourceClass.indexOf("terminal") !== -1 ||
1573
- resourceName.indexOf("terminal") !== -1;
1505
+ function buildOsascriptNotificationArgs(title, message) {
1506
+ return [
1507
+ "-e",
1508
+ `on run argv
1509
+ display notification (item 1 of argv) with title (item 2 of argv)
1510
+ end run`,
1511
+ message,
1512
+ title
1513
+ ];
1574
1514
  }
1575
-
1576
- function findAndActivateTerminal() {
1577
- var allWindows = workspace.windowList();
1578
- var terminalPid = ${terminalPid};
1579
- var termProgramHint = ${JSON.stringify(termProgram)};
1580
- var cwdHint = ${JSON.stringify(cwd)};
1581
- var cwdBaseHint = ${JSON.stringify(cwdBase)};
1582
- var cachedTitleHint = ${JSON.stringify(cachedTitle)};
1583
-
1584
- function contains(haystack, needle) {
1585
- return !!needle && needle.length > 0 && haystack.indexOf(needle) !== -1;
1586
- }
1587
-
1588
- function windowScore(window) {
1589
- var resourceClass = (window.resourceClass || "").toLowerCase();
1590
- var resourceName = (window.resourceName || "").toLowerCase();
1591
- var caption = (window.caption || "").toLowerCase();
1592
- var score = 0;
1593
-
1594
- if (window.pid === terminalPid) score += 30;
1595
- if (contains(caption, "opencode")) score += 60;
1596
- if (contains(caption, cachedTitleHint)) score += 50;
1597
- if (contains(caption, cwdBaseHint)) score += 35;
1598
- if (contains(caption, cwdHint)) score += 20;
1599
- if (contains(resourceClass, termProgramHint) || contains(resourceName, termProgramHint) || contains(caption, termProgramHint)) score += 20;
1600
- if (isLikelyTerminal(window)) score += 10;
1601
- if (window.minimized === true) score -= 5;
1602
-
1603
- return score;
1515
+ async function sendNotification(title, message, timeout, iconPath, notificationSystem = "osascript", linuxGrouping = true, onClick, windowsAppID) {
1516
+ const now = Date.now();
1517
+ if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
1518
+ return;
1519
+ }
1520
+ lastNotificationTime[message] = now;
1521
+ if (notificationSystem === "ghostty") {
1522
+ return new Promise((resolve) => {
1523
+ const sequence = formatGhosttyNotificationSequence(title, message);
1524
+ process.stdout.write(sequence, () => {
1525
+ resolve();
1526
+ });
1527
+ });
1528
+ }
1529
+ if (platform === "Darwin") {
1530
+ if (notificationSystem === "node-notifier") {
1531
+ return new Promise((resolve) => {
1532
+ const notificationOptions = {
1533
+ title,
1534
+ message,
1535
+ timeout,
1536
+ icon: iconPath
1537
+ };
1538
+ notifier.notify(notificationOptions, () => {
1539
+ resolve();
1540
+ });
1541
+ });
1604
1542
  }
1605
-
1606
- var bestWindow = null;
1607
- var bestScore = -1;
1608
-
1609
- for (var i = 0; i < allWindows.length; i++) {
1610
- var candidate = allWindows[i];
1611
- var score = windowScore(candidate);
1612
- if (score > bestScore) {
1613
- bestWindow = candidate;
1614
- bestScore = score;
1543
+ return new Promise((resolve) => {
1544
+ execFile2("osascript", buildOsascriptNotificationArgs(title, message), () => {
1545
+ resolve();
1546
+ });
1547
+ });
1548
+ }
1549
+ if ((platform === "Linux" || platform.match(/BSD$/)) && !is_wsl_default) {
1550
+ if (!process.env.DBUS_SESSION_BUS_ADDRESS)
1551
+ return;
1552
+ if (onClick) {
1553
+ if (linuxGrouping) {
1554
+ if (linuxNotifySendSupportsReplace === null) {
1555
+ linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
1615
1556
  }
1557
+ if (linuxNotifySendSupportsReplace) {
1558
+ return sendLinuxNotificationDirect(title, message, timeout, iconPath, true, () => onClick());
1559
+ }
1560
+ }
1561
+ return sendLinuxNotificationDirect(title, message, timeout, iconPath, false, () => onClick());
1616
1562
  }
1617
-
1618
- // Require enough confidence to avoid jumping to unrelated terminals.
1619
- if (bestWindow && bestScore >= 30) {
1620
- activateTargetWindow(bestWindow);
1621
- return true;
1563
+ if (linuxGrouping) {
1564
+ if (linuxNotifySendSupportsReplace === null) {
1565
+ linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
1566
+ }
1567
+ if (linuxNotifySendSupportsReplace) {
1568
+ return sendLinuxNotificationDirect(title, message, timeout, iconPath, true);
1569
+ }
1622
1570
  }
1571
+ }
1572
+ return new Promise((resolve) => {
1573
+ const notificationOptions = buildWindowsNotificationOptions(title, message, timeout, iconPath, windowsAppID);
1574
+ platformNotifier.notify(notificationOptions, (err, response, metadata) => {
1575
+ if (onClick && metadata?.activationType === "default") {
1576
+ onClick();
1577
+ }
1578
+ resolve();
1579
+ });
1580
+ });
1581
+ }
1623
1582
 
1583
+ // src/sound.ts
1584
+ import { platform as platform2 } from "os";
1585
+ import { join as join3, dirname as dirname2 } from "path";
1586
+ import { fileURLToPath as fileURLToPath2 } from "url";
1587
+ import { existsSync as existsSync2 } from "fs";
1588
+ import { spawn as spawn2 } from "child_process";
1589
+ var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
1590
+ var DEBOUNCE_MS2 = 1000;
1591
+ var FULL_VOLUME_PERCENT = 100;
1592
+ var FULL_VOLUME_PULSE = 65536;
1593
+ var lastSoundTime = {};
1594
+ var lastAnySoundTime = 0;
1595
+ function claimSoundSlot(event, now = Date.now()) {
1596
+ if (now - lastAnySoundTime < DEBOUNCE_MS2) {
1597
+ return false;
1598
+ }
1599
+ if (lastSoundTime[event] && now - lastSoundTime[event] < DEBOUNCE_MS2) {
1624
1600
  return false;
1601
+ }
1602
+ lastAnySoundTime = now;
1603
+ lastSoundTime[event] = now;
1604
+ return true;
1625
1605
  }
1626
-
1627
- findAndActivateTerminal();
1628
- `;
1629
- const pluginName = `opencode-focus-${currentPid}`;
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 {}
1639
- try {
1640
- rmSync(scriptDirectory, { recursive: true, force: true });
1641
- } catch {}
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");
1606
+ function getBundledSoundPath(event) {
1607
+ const soundFilename = `${event}.wav`;
1608
+ const possiblePaths = [
1609
+ join3(__dirname2, "..", "sounds", soundFilename),
1610
+ join3(__dirname2, "sounds", soundFilename)
1611
+ ];
1612
+ for (const path of possiblePaths) {
1613
+ if (existsSync2(path)) {
1614
+ return path;
1677
1615
  }
1678
- } catch {
1679
- try {
1680
- const cachedId = cachedWindowId;
1681
- if (cachedId && isNumericWindowId(cachedId)) {
1682
- execFileSync("xdotool", ["windowactivate", cachedId], { timeout: 1000, stdio: "ignore" });
1683
- }
1684
- } catch {}
1685
1616
  }
1617
+ return join3(__dirname2, "..", "sounds", soundFilename);
1686
1618
  }
1687
- function focusLinuxWindowHyprland(windowId) {
1688
- if (!isSafeCompositorWindowId(windowId))
1689
- return;
1690
- try {
1691
- execFileSync("hyprctl", ["dispatch", "focuswindow", `address:${windowId}`], { timeout: 1000, stdio: "ignore" });
1692
- } catch {}
1693
- }
1694
- function focusLinuxWindowSway(windowId) {
1695
- if (!isNumericWindowId(windowId))
1696
- return;
1697
- try {
1698
- execFileSync("swaymsg", [`[con_id=${windowId}] focus`], { timeout: 1000, stdio: "ignore" });
1699
- } catch {}
1619
+ function getSoundFilePath(event, customPath) {
1620
+ if (customPath && existsSync2(customPath)) {
1621
+ return customPath;
1622
+ }
1623
+ const bundledPath = getBundledSoundPath(event);
1624
+ if (existsSync2(bundledPath)) {
1625
+ return bundledPath;
1626
+ }
1627
+ return null;
1700
1628
  }
1701
- function focusLinuxWindowNiri(windowId) {
1702
- if (!isNumericWindowId(windowId))
1703
- return;
1704
- try {
1705
- execFileSync("niri", ["msg", "action", "focus-window", "--id", windowId], { timeout: 1000, stdio: "ignore" });
1706
- } catch {}
1629
+ async function runCommand(command, args) {
1630
+ return new Promise((resolve, reject) => {
1631
+ const proc = spawn2(command, args, {
1632
+ stdio: "ignore",
1633
+ detached: false,
1634
+ windowsHide: true
1635
+ });
1636
+ proc.on("error", (err) => {
1637
+ reject(err);
1638
+ });
1639
+ proc.on("close", (code) => {
1640
+ if (code === 0) {
1641
+ resolve();
1642
+ } else {
1643
+ reject(new Error(`Command exited with code ${code}`));
1644
+ }
1645
+ });
1646
+ });
1707
1647
  }
1708
- function captureStartupWindowId() {
1709
- if (!isKDEJumpBackSupported()) {
1710
- return;
1648
+ function normalizeVolume(volume) {
1649
+ if (!Number.isFinite(volume)) {
1650
+ return 1;
1711
1651
  }
1712
- const existing = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim();
1713
- if (existing) {
1714
- return;
1652
+ if (volume < 0) {
1653
+ return 0;
1715
1654
  }
1716
- const detected = execWithTimeout("kdotool getactivewindow", 1000);
1717
- if (detected && /^\d+$/.test(detected)) {
1718
- process.env.OPENCODE_NOTIFIER_WINDOW_ID = detected;
1655
+ if (volume > 1) {
1656
+ return 1;
1719
1657
  }
1658
+ return volume;
1720
1659
  }
1721
- async function focusTerminal() {
1722
- if (process.platform === "darwin") {
1723
- try {
1724
- const frontmostAppName = getMacOSFrontmostAppName();
1725
- if (frontmostAppName && isMacTerminalAppFocused(frontmostAppName, process.env)) {
1726
- return;
1727
- }
1728
- const expectedApps = getExpectedMacTerminalAppNames(process.env);
1729
- for (const app of expectedApps) {
1730
- try {
1731
- execFileSync("osascript", buildOsascriptActivateAppArgs(app), { timeout: 1000, stdio: "ignore" });
1732
- return;
1733
- } catch {}
1734
- }
1735
- execFileSync("osascript", buildOsascriptActivateAppArgs("Terminal"), { timeout: 1000, stdio: "ignore" });
1736
- } catch {}
1737
- return;
1738
- }
1739
- if (process.platform === "linux") {
1740
- const env = process.env;
1741
- if (env.KDE_SESSION_VERSION) {
1742
- focusKDEWithKWinScript();
1743
- return;
1744
- }
1745
- const windowId = getTerminalWindowId();
1746
- if (!windowId)
1660
+ function toPercentVolume(volume) {
1661
+ return Math.round(volume * FULL_VOLUME_PERCENT);
1662
+ }
1663
+ function toPulseVolume(volume) {
1664
+ return Math.round(volume * FULL_VOLUME_PULSE);
1665
+ }
1666
+ async function playOnLinux(soundPath, volume) {
1667
+ const percentVolume = toPercentVolume(volume);
1668
+ const pulseVolume = toPulseVolume(volume);
1669
+ const players = [
1670
+ { command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
1671
+ { command: "aplay", args: [soundPath] },
1672
+ {
1673
+ command: "mpv",
1674
+ args: [
1675
+ "--no-video",
1676
+ "--no-terminal",
1677
+ "--script-opts=autoload-disabled=yes",
1678
+ "--keep-open=no",
1679
+ "--idle=no",
1680
+ `--volume=${percentVolume}`,
1681
+ soundPath
1682
+ ]
1683
+ },
1684
+ { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
1685
+ ];
1686
+ for (const player of players) {
1687
+ try {
1688
+ await runCommand(player.command, player.args);
1747
1689
  return;
1748
- if (env.HYPRLAND_INSTANCE_SIGNATURE) {
1749
- focusLinuxWindowHyprland(windowId);
1750
- } else if (env.SWAYSOCK) {
1751
- focusLinuxWindowSway(windowId);
1752
- } else if (env.NIRI_SOCKET) {
1753
- focusLinuxWindowNiri(windowId);
1754
- } else if (env.DISPLAY) {
1755
- focusLinuxWindowX11(windowId);
1690
+ } catch {
1691
+ continue;
1756
1692
  }
1757
1693
  }
1758
1694
  }
1759
-
1760
- // src/permission-dedupe.ts
1761
- var PERMISSION_DEDUPE_WINDOW_MS = 1000;
1762
- var sessionLastPermissionAt = new Map;
1763
- var globalLastPermissionAt = 0;
1764
- function shouldSuppressPermissionAlert(sessionID, now = Date.now()) {
1765
- const sessionLastAt = sessionID ? sessionLastPermissionAt.get(sessionID) : undefined;
1766
- const latestSeen = Math.max(globalLastPermissionAt, sessionLastAt ?? 0);
1767
- const isDuplicate = latestSeen > 0 && now - latestSeen < PERMISSION_DEDUPE_WINDOW_MS;
1768
- if (isDuplicate) {
1769
- return true;
1695
+ async function playOnMac(soundPath, volume) {
1696
+ await runCommand("afplay", ["-v", `${volume}`, soundPath]);
1697
+ }
1698
+ async function playOnWindows(soundPath) {
1699
+ const script = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`;
1700
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
1701
+ await runCommand("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
1702
+ }
1703
+ async function playSound(event, customPath, volume) {
1704
+ const soundPath = getSoundFilePath(event, customPath);
1705
+ if (!soundPath) {
1706
+ return;
1770
1707
  }
1771
- globalLastPermissionAt = now;
1772
- if (sessionID) {
1773
- sessionLastPermissionAt.set(sessionID, now);
1708
+ if (!claimSoundSlot(event)) {
1709
+ return;
1774
1710
  }
1775
- return false;
1776
- }
1777
- function prunePermissionAlertState(cutoffMs) {
1778
- for (const [sessionID, timestamp] of sessionLastPermissionAt) {
1779
- if (timestamp < cutoffMs) {
1780
- sessionLastPermissionAt.delete(sessionID);
1711
+ const normalizedVolume = normalizeVolume(volume);
1712
+ const os3 = platform2();
1713
+ try {
1714
+ switch (os3) {
1715
+ case "darwin":
1716
+ await playOnMac(soundPath, normalizedVolume);
1717
+ break;
1718
+ case "linux":
1719
+ await playOnLinux(soundPath, normalizedVolume);
1720
+ break;
1721
+ case "win32":
1722
+ await playOnWindows(soundPath);
1723
+ break;
1724
+ default:
1725
+ break;
1781
1726
  }
1727
+ } catch {}
1728
+ }
1729
+
1730
+ // src/bell.ts
1731
+ var lastBellTime = 0;
1732
+ var BELL_DEBOUNCE_MS = 500;
1733
+ function ringBell(now = Date.now()) {
1734
+ if (!process.stdout.isTTY) {
1735
+ return Promise.resolve();
1782
1736
  }
1783
- if (globalLastPermissionAt < cutoffMs) {
1784
- globalLastPermissionAt = 0;
1737
+ if (now - lastBellTime < BELL_DEBOUNCE_MS) {
1738
+ return Promise.resolve();
1785
1739
  }
1740
+ lastBellTime = now;
1741
+ return new Promise((resolve) => {
1742
+ process.stdout.write("\x07", () => {
1743
+ resolve();
1744
+ });
1745
+ });
1786
1746
  }
1787
1747
 
1788
- // src/index.ts
1789
- var IDLE_COMPLETE_DELAY_MS = 350;
1790
- function isCLIClient(clientEnv) {
1791
- return !clientEnv || clientEnv === "cli";
1792
- }
1793
- var pendingIdleTimers = new Map;
1794
- var sessionIdleSequence = new Map;
1795
- var sessionErrorSuppressionAt = new Map;
1796
- var sessionLastBusyAt = new Map;
1797
- var subagentSessionIds = new Set;
1798
- function asRecord(value) {
1799
- return value !== null && typeof value === "object" ? value : null;
1748
+ // src/command.ts
1749
+ import { spawn as spawn3 } from "child_process";
1750
+ function substituteTokens(value, event, message, sessionTitle, agentName, projectName, timestamp, turn, sessionID) {
1751
+ let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
1752
+ result = result.replaceAll("{sessionTitle}", sessionTitle || "");
1753
+ result = result.replaceAll("{sessionID}", sessionID || "");
1754
+ result = result.replaceAll("{agentName}", agentName || "");
1755
+ result = result.replaceAll("{projectName}", projectName || "");
1756
+ result = result.replaceAll("{timestamp}", timestamp || "");
1757
+ result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
1758
+ return result;
1800
1759
  }
1801
- function getNestedRecord(root, ...path) {
1802
- let current = root;
1803
- for (const key of path) {
1804
- const record = asRecord(current);
1805
- if (!record || !(key in record)) {
1806
- return null;
1807
- }
1808
- current = record[key];
1760
+ function runCommand2(config, event, message, sessionTitle, agentName, projectName, timestamp, turn, sessionID) {
1761
+ if (!config.command.enabled || !config.command.path) {
1762
+ return;
1809
1763
  }
1810
- return asRecord(current);
1811
- }
1812
- function getStringField(record, key) {
1813
- if (!record) {
1814
- return null;
1764
+ const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn, sessionID));
1765
+ const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn, sessionID);
1766
+ let proc;
1767
+ try {
1768
+ proc = spawn3(command, args, {
1769
+ shell: false,
1770
+ stdio: "ignore",
1771
+ detached: true,
1772
+ windowsHide: true
1773
+ });
1774
+ } catch {
1775
+ return;
1815
1776
  }
1816
- const value = record[key];
1817
- return typeof value === "string" && value.length > 0 ? value : null;
1777
+ proc.on("error", () => {});
1778
+ proc.unref();
1818
1779
  }
1780
+
1781
+ // src/delivery.ts
1819
1782
  var globalTurnCount = null;
1820
1783
  function loadTurnCount() {
1821
1784
  try {
@@ -1840,27 +1803,6 @@ function incrementTurnCount() {
1840
1803
  saveTurnCount(globalTurnCount);
1841
1804
  return globalTurnCount;
1842
1805
  }
1843
- var cleanupInterval = setInterval(() => {
1844
- const cutoff = Date.now() - 5 * 60 * 1000;
1845
- for (const [sessionID] of sessionIdleSequence) {
1846
- if (!pendingIdleTimers.has(sessionID)) {
1847
- sessionIdleSequence.delete(sessionID);
1848
- subagentSessionIds.delete(sessionID);
1849
- }
1850
- }
1851
- for (const [sessionID, timestamp] of sessionErrorSuppressionAt) {
1852
- if (timestamp < cutoff) {
1853
- sessionErrorSuppressionAt.delete(sessionID);
1854
- }
1855
- }
1856
- for (const [sessionID, timestamp] of sessionLastBusyAt) {
1857
- if (timestamp < cutoff) {
1858
- sessionLastBusyAt.delete(sessionID);
1859
- }
1860
- }
1861
- prunePermissionAlertState(cutoff);
1862
- }, 5 * 60 * 1000);
1863
- cleanupInterval.unref();
1864
1806
  function getNotificationTitle(config, projectName) {
1865
1807
  if (config.showProjectName && projectName) {
1866
1808
  return `OpenCode (${projectName})`;
@@ -1891,50 +1833,390 @@ function shouldResolveAgentNameForEvent(config, eventType) {
1891
1833
  if (config.command.path.includes("{agentName}")) {
1892
1834
  return true;
1893
1835
  }
1894
- return (config.command.args ?? []).some((arg) => arg.includes("{agentName}"));
1836
+ return (config.command.args ?? []).some((arg) => arg.includes("{agentName}"));
1837
+ }
1838
+ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName) {
1839
+ if (config.suppressWhenFocused && isTerminalFocused()) {
1840
+ return;
1841
+ }
1842
+ if ((eventType === "complete" || eventType === "subagent_complete") && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < config.minDuration) {
1843
+ return;
1844
+ }
1845
+ const promises = [];
1846
+ const timestamp = formatTimestamp();
1847
+ const turn = incrementTurnCount();
1848
+ const rawMessage = getMessage(config, eventType);
1849
+ const message = interpolateMessage(rawMessage, {
1850
+ sessionTitle: config.showSessionTitle ? sessionTitle : null,
1851
+ agentName,
1852
+ projectName,
1853
+ timestamp,
1854
+ turn
1855
+ });
1856
+ const notificationEnabled = isEventNotificationEnabled(config, eventType);
1857
+ if (notificationEnabled) {
1858
+ const title = getNotificationTitle(config, projectName);
1859
+ const iconPath = getIconPath(config);
1860
+ const onNotificationClick = isKDEJumpBackSupported() ? () => void focusTerminal() : undefined;
1861
+ promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick, config.windows.appID));
1862
+ }
1863
+ if (isEventSoundEnabled(config, eventType)) {
1864
+ const customSoundPath = getSoundPath(config, eventType);
1865
+ const ghosttyOnMac = process.platform === "darwin" && config.notificationSystem === "ghostty" && notificationEnabled && config.suppressGhosttySound;
1866
+ if (!ghosttyOnMac) {
1867
+ const soundVolume = getSoundVolume(config, eventType);
1868
+ promises.push(playSound(eventType, customSoundPath, soundVolume));
1869
+ }
1870
+ }
1871
+ if (isEventBellEnabled(config, eventType)) {
1872
+ promises.push(ringBell());
1873
+ }
1874
+ const minDuration = config.command?.minDuration;
1875
+ const shouldSkipCommand = !isEventCommandEnabled(config, eventType) || typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
1876
+ if (!shouldSkipCommand) {
1877
+ runCommand2(config, eventType, message, sessionTitle, agentName, projectName, timestamp, turn, sessionID);
1878
+ }
1879
+ await Promise.allSettled(promises);
1880
+ }
1881
+
1882
+ // src/permission-dedupe.ts
1883
+ var PERMISSION_DEDUPE_WINDOW_MS = 1000;
1884
+ var sessionLastPermissionAt = new Map;
1885
+ var globalLastPermissionAt = 0;
1886
+ function shouldSuppressPermissionAlert(sessionID, now = Date.now()) {
1887
+ const sessionLastAt = sessionID ? sessionLastPermissionAt.get(sessionID) : undefined;
1888
+ const latestSeen = Math.max(globalLastPermissionAt, sessionLastAt ?? 0);
1889
+ const isDuplicate = latestSeen > 0 && now - latestSeen < PERMISSION_DEDUPE_WINDOW_MS;
1890
+ if (isDuplicate) {
1891
+ return true;
1892
+ }
1893
+ globalLastPermissionAt = now;
1894
+ if (sessionID) {
1895
+ sessionLastPermissionAt.set(sessionID, now);
1896
+ }
1897
+ return false;
1898
+ }
1899
+ function prunePermissionAlertState(cutoffMs) {
1900
+ for (const [sessionID, timestamp] of sessionLastPermissionAt) {
1901
+ if (timestamp < cutoffMs) {
1902
+ sessionLastPermissionAt.delete(sessionID);
1903
+ }
1904
+ }
1905
+ if (globalLastPermissionAt < cutoffMs) {
1906
+ globalLastPermissionAt = 0;
1907
+ }
1908
+ }
1909
+
1910
+ // src/notifier.ts
1911
+ var PERMISSION_PENDING_GRACE_MS = 300;
1912
+ var IDLE_COMPLETE_DELAY_MS = 350;
1913
+ function createNotifier(access, directory, delivery = "all") {
1914
+ const children = new Set;
1915
+ const sequences = new Map;
1916
+ const errors = new Set;
1917
+ const touched = new Map;
1918
+ const idleTimers = new Map;
1919
+ let disposed = false;
1920
+ function config() {
1921
+ const value = loadConfig();
1922
+ if (delivery === "all")
1923
+ return value;
1924
+ const local = structuredClone(value);
1925
+ if (delivery === "command") {
1926
+ local.suppressWhenFocused = false;
1927
+ for (const event of Object.values(local.events)) {
1928
+ event.notification = false;
1929
+ event.sound = false;
1930
+ event.bell = false;
1931
+ }
1932
+ } else {
1933
+ local.command.enabled = false;
1934
+ }
1935
+ return local;
1936
+ }
1937
+ function invalidate(sessionID) {
1938
+ touched.set(sessionID, Date.now());
1939
+ const sequence = (sequences.get(sessionID) ?? 0) + 1;
1940
+ sequences.set(sessionID, sequence);
1941
+ clearTimeout(idleTimers.get(sessionID));
1942
+ idleTimers.delete(sessionID);
1943
+ return sequence;
1944
+ }
1945
+ async function notify(event, sessionID = null, title, now = Date.now()) {
1946
+ if (disposed)
1947
+ return;
1948
+ const current = config();
1949
+ if (delivery === "command" && (!current.command.enabled || !current.command.path || !current.events[event].command))
1950
+ return;
1951
+ const lifecycleEvent = event === "session_started" || event === "user_message";
1952
+ let elapsed = null;
1953
+ if (!lifecycleEvent && sessionID && (current.minDuration > 0 || current.command.enabled && (current.command.minDuration ?? 0) > 0)) {
1954
+ elapsed = await access.elapsed(sessionID, now);
1955
+ }
1956
+ let sessionTitle = title ?? null;
1957
+ let agentName = null;
1958
+ if (!lifecycleEvent && sessionID && (!sessionTitle && current.showSessionTitle || shouldResolveAgentNameForEvent(current, event))) {
1959
+ const info = await access.info(sessionID);
1960
+ sessionTitle ??= info.title;
1961
+ agentName = info.agentName ?? null;
1962
+ }
1963
+ if (disposed)
1964
+ return;
1965
+ const project = directory ? current.showFullPath ? directory : basename(directory) : null;
1966
+ await handleEvent(current, event, project, elapsed, sessionTitle, sessionID, agentName ?? extractAgentNameFromSessionTitle(sessionTitle));
1967
+ }
1968
+ function track(sessionID, parentID) {
1969
+ touched.set(sessionID, Date.now());
1970
+ if (parentID)
1971
+ children.add(sessionID);
1972
+ }
1973
+ async function complete(sessionID, sequence, now) {
1974
+ if (disposed || sequences.get(sessionID) !== sequence)
1975
+ return;
1976
+ if (errors.delete(sessionID))
1977
+ return;
1978
+ if (children.has(sessionID)) {
1979
+ await notify("subagent_complete", sessionID, null, now);
1980
+ return;
1981
+ }
1982
+ const info = await access.info(sessionID);
1983
+ if (disposed || sequences.get(sessionID) !== sequence || errors.delete(sessionID))
1984
+ return;
1985
+ if (info.isChild === null)
1986
+ return;
1987
+ if (info.isChild)
1988
+ children.add(sessionID);
1989
+ await notify(info.isChild ? "subagent_complete" : "complete", sessionID, info.title, now);
1990
+ }
1991
+ const cleanup = setInterval(() => {
1992
+ const cutoff = Date.now() - 5 * 60000;
1993
+ prunePermissionAlertState(cutoff);
1994
+ for (const [id, lastSeen] of touched) {
1995
+ if (lastSeen >= cutoff || idleTimers.has(id))
1996
+ continue;
1997
+ touched.delete(id);
1998
+ children.delete(id);
1999
+ sequences.delete(id);
2000
+ errors.delete(id);
2001
+ }
2002
+ }, 5 * 60000);
2003
+ cleanup.unref();
2004
+ return {
2005
+ notify,
2006
+ track,
2007
+ async created(id, parentID, title) {
2008
+ if (id)
2009
+ track(id, parentID);
2010
+ if (!parentID)
2011
+ await notify("session_started", id, title);
2012
+ },
2013
+ busy(id) {
2014
+ invalidate(id);
2015
+ errors.delete(id);
2016
+ },
2017
+ async idle(id, immediate = true) {
2018
+ if (disposed)
2019
+ return;
2020
+ if (!id)
2021
+ return notify("complete");
2022
+ const sequence = invalidate(id);
2023
+ const now = Date.now();
2024
+ if (immediate)
2025
+ return complete(id, sequence, now);
2026
+ idleTimers.set(id, setTimeout(() => {
2027
+ idleTimers.delete(id);
2028
+ complete(id, sequence, now).catch(() => {
2029
+ return;
2030
+ });
2031
+ }, IDLE_COMPLETE_DELAY_MS));
2032
+ },
2033
+ async failed(id, event) {
2034
+ if (id) {
2035
+ invalidate(id);
2036
+ errors.add(id);
2037
+ }
2038
+ await notify(event, id);
2039
+ },
2040
+ async permission(id, requestID, legacyHook = false) {
2041
+ if (requestID) {
2042
+ await new Promise((resolve) => setTimeout(resolve, PERMISSION_PENDING_GRACE_MS));
2043
+ if (disposed || !await access.permissionPending(id, requestID))
2044
+ return;
2045
+ }
2046
+ if (disposed)
2047
+ return;
2048
+ if (legacyHook && shouldSuppressPermissionAlert(id))
2049
+ return;
2050
+ await notify("permission", id);
2051
+ },
2052
+ async userMessage(id, verifyParent = false) {
2053
+ if (id && children.has(id))
2054
+ return;
2055
+ if (id && verifyParent) {
2056
+ const info = await access.info(id);
2057
+ if (info.isChild !== false)
2058
+ return;
2059
+ }
2060
+ await notify("user_message", id);
2061
+ },
2062
+ dispose() {
2063
+ disposed = true;
2064
+ clearInterval(cleanup);
2065
+ for (const timer of idleTimers.values())
2066
+ clearTimeout(timer);
2067
+ idleTimers.clear();
2068
+ children.clear();
2069
+ sequences.clear();
2070
+ errors.clear();
2071
+ touched.clear();
2072
+ }
2073
+ };
2074
+ }
2075
+
2076
+ // src/v2.ts
2077
+ function createV2Notifier(client, location, delivery) {
2078
+ const notifier2 = createNotifier({
2079
+ async info(sessionID) {
2080
+ try {
2081
+ const session = await client.session.get({ sessionID });
2082
+ return { isChild: !!session.parentID, title: session.title ?? null, agentName: session.parentID ? session.agent ?? null : null };
2083
+ } catch {
2084
+ return { isChild: null, title: null };
2085
+ }
2086
+ },
2087
+ async elapsed(sessionID, now) {
2088
+ try {
2089
+ const messages = await client.session.context({ sessionID });
2090
+ const times = messages.flatMap((message) => message.type === "user" ? [message.time.created] : []);
2091
+ return times.length ? (now - Math.max(...times)) / 1000 : null;
2092
+ } catch {
2093
+ return null;
2094
+ }
2095
+ },
2096
+ async permissionPending(sessionID, permissionID) {
2097
+ if (!sessionID)
2098
+ return true;
2099
+ try {
2100
+ return (await client.permission.list({ sessionID })).some((request) => request.id === permissionID);
2101
+ } catch {
2102
+ return true;
2103
+ }
2104
+ }
2105
+ }, location.directory, delivery);
2106
+ const seen = new Set;
2107
+ let disposed = false;
2108
+ function first(key) {
2109
+ if (seen.has(key))
2110
+ return false;
2111
+ seen.add(key);
2112
+ if (seen.size > 2048) {
2113
+ const oldest = seen.values().next().value;
2114
+ if (oldest !== undefined)
2115
+ seen.delete(oldest);
2116
+ }
2117
+ return true;
2118
+ }
2119
+ return {
2120
+ connected: () => notifier2.notify("client_connected"),
2121
+ async event(event) {
2122
+ try {
2123
+ if (disposed)
2124
+ return;
2125
+ if (event.location) {
2126
+ const workspaceID = "workspaceID" in event.location ? event.location.workspaceID : undefined;
2127
+ if (event.location.directory !== location.directory || workspaceID !== location.workspaceID)
2128
+ return;
2129
+ }
2130
+ switch (event.type) {
2131
+ case "session.created":
2132
+ if (first(`created:${event.data.sessionID}`)) {
2133
+ await notifier2.created(event.data.sessionID, event.data.parentID ?? null, event.data.title ?? null);
2134
+ }
2135
+ break;
2136
+ case "session.execution.started":
2137
+ notifier2.busy(event.data.sessionID);
2138
+ break;
2139
+ case "session.execution.succeeded":
2140
+ if (first(`complete:${event.id}`))
2141
+ await notifier2.idle(event.data.sessionID);
2142
+ break;
2143
+ case "session.execution.failed":
2144
+ if (first(`failed:${event.id}`))
2145
+ await notifier2.failed(event.data.sessionID, "error");
2146
+ break;
2147
+ case "session.execution.interrupted":
2148
+ if (event.data.reason === "user" && first(`cancelled:${event.id}`)) {
2149
+ await notifier2.failed(event.data.sessionID, "user_cancelled");
2150
+ }
2151
+ break;
2152
+ case "permission.asked":
2153
+ if (first(`permission:${event.data.id}`))
2154
+ await notifier2.permission(event.data.sessionID, event.data.id);
2155
+ break;
2156
+ case "session.inbox.enqueued":
2157
+ if (event.data.item.type === "user" && first(`message:${event.data.inboxID}`)) {
2158
+ await notifier2.userMessage(event.data.sessionID, true);
2159
+ }
2160
+ break;
2161
+ case "form.created":
2162
+ if (event.data.form.metadata?.kind === "question" && first(`form:${event.data.form.id}`)) {
2163
+ await notifier2.notify("question", event.data.form.sessionID);
2164
+ }
2165
+ break;
2166
+ }
2167
+ } catch {}
2168
+ },
2169
+ dispose() {
2170
+ disposed = true;
2171
+ notifier2.dispose();
2172
+ seen.clear();
2173
+ }
2174
+ };
1895
2175
  }
1896
- async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName) {
1897
- if (config.suppressWhenFocused && isTerminalFocused()) {
2176
+ var setupV2 = (ctx) => {
2177
+ const notifier2 = createV2Notifier(ctx, ctx.location, "command");
2178
+ notifier2.connected().catch(() => {
1898
2179
  return;
1899
- }
1900
- if ((eventType === "complete" || eventType === "subagent_complete") && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < config.minDuration) {
1901
- return;
1902
- }
1903
- const promises = [];
1904
- const timestamp = formatTimestamp();
1905
- const turn = incrementTurnCount();
1906
- const rawMessage = getMessage(config, eventType);
1907
- const message = interpolateMessage(rawMessage, {
1908
- sessionTitle: config.showSessionTitle ? sessionTitle : null,
1909
- agentName,
1910
- projectName,
1911
- timestamp,
1912
- turn
1913
2180
  });
1914
- const notificationEnabled = isEventNotificationEnabled(config, eventType);
1915
- if (notificationEnabled) {
1916
- const title = getNotificationTitle(config, projectName);
1917
- const iconPath = getIconPath(config);
1918
- const onNotificationClick = isKDEJumpBackSupported() ? () => void focusTerminal() : undefined;
1919
- promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick, config.windows.appID));
1920
- }
1921
- if (isEventSoundEnabled(config, eventType)) {
1922
- const customSoundPath = getSoundPath(config, eventType);
1923
- const ghosttyOnMac = process.platform === "darwin" && config.notificationSystem === "ghostty" && notificationEnabled && config.suppressGhosttySound;
1924
- if (!ghosttyOnMac) {
1925
- const soundVolume = getSoundVolume(config, eventType);
1926
- promises.push(playSound(eventType, customSoundPath, soundVolume));
2181
+ const controller = new AbortController;
2182
+ const task = (async () => {
2183
+ try {
2184
+ for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
2185
+ await notifier2.event(event);
2186
+ }
2187
+ } catch {}
2188
+ })();
2189
+ return async () => {
2190
+ notifier2.dispose();
2191
+ controller.abort();
2192
+ await task;
2193
+ };
2194
+ };
2195
+
2196
+ // src/index.ts
2197
+ function isCLIClient(clientEnv) {
2198
+ return !clientEnv || clientEnv === "cli";
2199
+ }
2200
+ function asRecord(value) {
2201
+ return value !== null && typeof value === "object" ? value : null;
2202
+ }
2203
+ function getNestedRecord(root, ...path) {
2204
+ let current = root;
2205
+ for (const key of path) {
2206
+ const record = asRecord(current);
2207
+ if (!record || !(key in record)) {
2208
+ return null;
1927
2209
  }
2210
+ current = record[key];
1928
2211
  }
1929
- if (isEventBellEnabled(config, eventType)) {
1930
- promises.push(ringBell());
1931
- }
1932
- const minDuration = config.command?.minDuration;
1933
- const shouldSkipCommand = !isEventCommandEnabled(config, eventType) || typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
1934
- if (!shouldSkipCommand) {
1935
- runCommand2(config, eventType, message, sessionTitle, agentName, projectName, timestamp, turn);
2212
+ return asRecord(current);
2213
+ }
2214
+ function getStringField(record, key) {
2215
+ if (!record) {
2216
+ return null;
1936
2217
  }
1937
- await Promise.allSettled(promises);
2218
+ const value = record[key];
2219
+ return typeof value === "string" && value.length > 0 ? value : null;
1938
2220
  }
1939
2221
  function getSessionIDFromEvent(event) {
1940
2222
  const properties = getNestedRecord(event, "properties");
@@ -1949,20 +2231,20 @@ function getPermissionIDFromEvent(event) {
1949
2231
  const request = getNestedRecord(event, "properties", "request");
1950
2232
  return getStringField(request, "id");
1951
2233
  }
1952
- var PERMISSION_PENDING_GRACE_MS = 300;
1953
2234
  async function isPermissionStillPending(client, permissionID) {
1954
2235
  try {
1955
- const inner = client?._client || client?.session?._client;
2236
+ const record = asRecord(client);
2237
+ const inner = asRecord(record?._client) ?? getNestedRecord(client, "session", "_client");
1956
2238
  if (!inner || typeof inner.get !== "function") {
1957
2239
  return true;
1958
2240
  }
1959
2241
  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) {
2242
+ const body = asRecord(listResponse)?.data ?? listResponse;
2243
+ const pendingList = Array.isArray(body) ? body : asRecord(body)?.data;
2244
+ if (!Array.isArray(pendingList)) {
1963
2245
  return true;
1964
2246
  }
1965
- return pendingList.some((p) => p?.id === permissionID);
2247
+ return pendingList.some((p) => asRecord(p)?.id === permissionID);
1966
2248
  } catch {
1967
2249
  return true;
1968
2250
  }
@@ -1982,459 +2264,99 @@ function getMessageUpdatedInfo(event) {
1982
2264
  sessionID: getStringField(info, "sessionID")
1983
2265
  };
1984
2266
  }
1985
- function clearPendingIdleTimer(sessionID) {
1986
- const timer = pendingIdleTimers.get(sessionID);
1987
- if (!timer) {
1988
- return;
1989
- }
1990
- clearTimeout(timer);
1991
- pendingIdleTimers.delete(sessionID);
1992
- }
1993
- function bumpSessionIdleSequence(sessionID) {
1994
- const nextSequence = (sessionIdleSequence.get(sessionID) ?? 0) + 1;
1995
- sessionIdleSequence.set(sessionID, nextSequence);
1996
- return nextSequence;
1997
- }
1998
- function hasCurrentSessionIdleSequence(sessionID, sequence) {
1999
- return sessionIdleSequence.get(sessionID) === sequence;
2000
- }
2001
- function markSessionError(sessionID) {
2002
- if (!sessionID) {
2003
- return;
2004
- }
2005
- sessionErrorSuppressionAt.set(sessionID, Date.now());
2006
- bumpSessionIdleSequence(sessionID);
2007
- clearPendingIdleTimer(sessionID);
2008
- }
2009
- function markSessionBusy(sessionID) {
2010
- const now = Date.now();
2011
- sessionLastBusyAt.set(sessionID, now);
2012
- sessionErrorSuppressionAt.delete(sessionID);
2013
- bumpSessionIdleSequence(sessionID);
2014
- clearPendingIdleTimer(sessionID);
2015
- }
2016
- function shouldSuppressSessionIdle(sessionID, consume = true) {
2017
- const errorAt = sessionErrorSuppressionAt.get(sessionID);
2018
- if (errorAt === undefined) {
2019
- return false;
2020
- }
2021
- const busyAt = sessionLastBusyAt.get(sessionID);
2022
- if (typeof busyAt === "number" && busyAt > errorAt) {
2023
- sessionErrorSuppressionAt.delete(sessionID);
2024
- return false;
2025
- }
2026
- if (consume) {
2027
- sessionErrorSuppressionAt.delete(sessionID);
2028
- }
2029
- return true;
2030
- }
2031
- async function getElapsedSinceLastPrompt(client, sessionID, nowMs = Date.now()) {
2032
- try {
2033
- const lastUserMessageTime = await client.lastUserMessageAt(sessionID);
2034
- if (lastUserMessageTime !== null) {
2035
- return (nowMs - lastUserMessageTime) / 1000;
2036
- }
2037
- } catch {}
2038
- return null;
2039
- }
2040
- async function getSessionInfo(client, sessionID) {
2041
- try {
2042
- return await client.sessionInfo(sessionID);
2043
- } catch {
2044
- return { isChild: false, title: null };
2045
- }
2046
- }
2047
- async function processSessionIdle(client, config, projectName, sessionID, sequence, idleReceivedAtMs) {
2048
- if (!hasCurrentSessionIdleSequence(sessionID, sequence)) {
2049
- return;
2050
- }
2051
- if (shouldSuppressSessionIdle(sessionID)) {
2052
- return;
2053
- }
2054
- if (subagentSessionIds.has(sessionID)) {
2055
- await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, sessionID, idleReceivedAtMs, null);
2056
- return;
2057
- }
2058
- const sessionInfo = await getSessionInfo(client, sessionID);
2059
- if (!hasCurrentSessionIdleSequence(sessionID, sequence)) {
2060
- return;
2061
- }
2062
- if (shouldSuppressSessionIdle(sessionID)) {
2063
- return;
2064
- }
2065
- if (!sessionInfo.isChild) {
2066
- await handleEventWithElapsedTime(client, config, "complete", projectName, sessionID, idleReceivedAtMs, sessionInfo.title);
2067
- return;
2068
- }
2069
- subagentSessionIds.add(sessionID);
2070
- await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, sessionID, idleReceivedAtMs, sessionInfo.title);
2071
- }
2072
- function scheduleSessionIdle(client, config, projectName, sessionID) {
2073
- clearPendingIdleTimer(sessionID);
2074
- const sequence = bumpSessionIdleSequence(sessionID);
2075
- const idleReceivedAtMs = Date.now();
2076
- const timer = setTimeout(() => {
2077
- pendingIdleTimers.delete(sessionID);
2078
- processSessionIdle(client, config, projectName, sessionID, sequence, idleReceivedAtMs).catch(() => {
2079
- return;
2080
- });
2081
- }, IDLE_COMPLETE_DELAY_MS);
2082
- pendingIdleTimers.set(sessionID, timer);
2083
- }
2084
- async function handleEventWithElapsedTime(client, config, eventType, projectName, sessionID, elapsedReferenceNowMs, preloadedSessionTitle) {
2085
- const commandMinDuration = config.command?.minDuration;
2086
- const shouldLookupElapsedForCommand = !!config.command?.enabled && typeof config.command?.path === "string" && config.command.path.length > 0 && typeof commandMinDuration === "number" && Number.isFinite(commandMinDuration) && commandMinDuration > 0;
2087
- const shouldLookupElapsedForNotification = typeof config.minDuration === "number" && Number.isFinite(config.minDuration) && config.minDuration > 0;
2088
- const shouldLookupElapsed = shouldLookupElapsedForCommand || shouldLookupElapsedForNotification;
2089
- let elapsedSeconds = null;
2090
- if (shouldLookupElapsed) {
2091
- if (sessionID) {
2092
- elapsedSeconds = await getElapsedSinceLastPrompt(client, sessionID, elapsedReferenceNowMs);
2093
- }
2094
- }
2095
- let sessionTitle = preloadedSessionTitle ?? null;
2096
- const shouldLookupSessionInfo = sessionID && !sessionTitle && (config.showSessionTitle || shouldResolveAgentNameForEvent(config, eventType));
2097
- if (shouldLookupSessionInfo) {
2098
- const info = await getSessionInfo(client, sessionID);
2099
- sessionTitle = info.title;
2100
- }
2101
- const agentName = extractAgentNameFromSessionTitle(sessionTitle);
2102
- await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName);
2103
- }
2104
- function createNotifierRuntime(client, directory, clientEnv) {
2267
+ var NotifierPlugin = async ({ client, directory }) => {
2105
2268
  captureStartupWindowId();
2106
- if (clientEnv && clientEnv !== "cli") {
2107
- const config = loadConfig();
2108
- if (!config.enableOnDesktop)
2109
- return null;
2110
- }
2111
- const getConfig = () => loadConfig();
2112
- const projectName = directory ? getConfig().showFullPath ? directory : basename(directory) : null;
2113
- const isCLI = isCLIClient(clientEnv);
2114
- if (isCLI) {
2115
- handleEvent(getConfig(), "client_connected", projectName, null);
2116
- } else {
2117
- setTimeout(() => {
2118
- handleEvent(getConfig(), "client_connected", projectName, null);
2119
- }, 100);
2120
- }
2121
- const dispatch = async (event) => {
2122
- const config = getConfig();
2123
- switch (event.type) {
2124
- case "session.created": {
2125
- if (event.parentID && event.sessionID) {
2126
- subagentSessionIds.add(event.sessionID);
2127
- } else {
2128
- await handleEvent(config, "session_started", projectName, null, event.title, event.sessionID, null);
2129
- }
2130
- return;
2131
- }
2132
- case "session.updated": {
2133
- if (event.parentID && event.sessionID) {
2134
- subagentSessionIds.add(event.sessionID);
2135
- }
2136
- return;
2137
- }
2138
- case "session.deleted": {
2139
- if (event.sessionID) {
2140
- subagentSessionIds.delete(event.sessionID);
2141
- }
2142
- return;
2143
- }
2144
- case "permission.asked": {
2145
- const { sessionID, permissionID } = event;
2146
- let stillPending = true;
2147
- if (permissionID) {
2148
- await new Promise((resolve) => setTimeout(resolve, PERMISSION_PENDING_GRACE_MS));
2149
- stillPending = await client.permissionPending(sessionID, permissionID);
2150
- }
2151
- if (stillPending && !shouldSuppressPermissionAlert(sessionID)) {
2152
- await handleEventWithElapsedTime(client, config, "permission", projectName, sessionID);
2153
- }
2154
- return;
2155
- }
2156
- case "permission.ask": {
2157
- if (!shouldSuppressPermissionAlert(null)) {
2158
- await handleEvent(config, "permission", projectName, null);
2159
- }
2160
- return;
2161
- }
2162
- case "session.idle": {
2163
- const sessionID = event.sessionID;
2164
- if (sessionID) {
2165
- if (isCLI) {
2166
- const idleReceivedAtMs = Date.now();
2167
- const sequence = bumpSessionIdleSequence(sessionID);
2168
- await processSessionIdle(client, config, projectName, sessionID, sequence, idleReceivedAtMs);
2169
- } else {
2170
- scheduleSessionIdle(client, config, projectName, sessionID);
2171
- }
2172
- } else {
2173
- await handleEventWithElapsedTime(client, config, "complete", projectName, null);
2174
- }
2175
- return;
2176
- }
2177
- case "session.busy": {
2178
- markSessionBusy(event.sessionID);
2179
- return;
2269
+ const clientEnv = process.env.OPENCODE_CLIENT;
2270
+ if (clientEnv && clientEnv !== "cli" && !loadConfig().enableOnDesktop)
2271
+ return {};
2272
+ const notifier2 = createNotifier({
2273
+ async info(id) {
2274
+ try {
2275
+ const { data } = await client.session.get({ path: { id } });
2276
+ if (!data)
2277
+ return { isChild: null, title: null };
2278
+ return { isChild: !!data.parentID, title: data.title ?? null };
2279
+ } catch {
2280
+ return { isChild: null, title: null };
2180
2281
  }
2181
- case "session.error": {
2182
- markSessionError(event.sessionID);
2183
- const eventType = event.userCancelled ? "user_cancelled" : "error";
2184
- let sessionTitle = null;
2185
- if (event.sessionID && config.showSessionTitle) {
2186
- const info = await getSessionInfo(client, event.sessionID);
2187
- sessionTitle = info.title;
2188
- }
2189
- await handleEventWithElapsedTime(client, config, eventType, projectName, event.sessionID, undefined, sessionTitle);
2190
- return;
2282
+ },
2283
+ async elapsed(id, now) {
2284
+ try {
2285
+ const { data } = await client.session.messages({ path: { id } });
2286
+ const times = (data ?? []).flatMap(({ info }) => info.role === "user" && typeof info.time?.created === "number" ? [info.time.created] : []);
2287
+ return times.length ? (now - Math.max(...times)) / 1000 : null;
2288
+ } catch {
2289
+ return null;
2191
2290
  }
2192
- case "message.user": {
2193
- const sessionID = event.sessionID;
2194
- if (!sessionID || !subagentSessionIds.has(sessionID)) {
2195
- await handleEvent(config, "user_message", projectName, null, null, sessionID, null);
2291
+ },
2292
+ permissionPending: (_id, requestID) => isPermissionStillPending(client, requestID)
2293
+ }, directory);
2294
+ const isCLI = isCLIClient(clientEnv);
2295
+ const connected = () => void notifier2.notify("client_connected").catch(() => {
2296
+ return;
2297
+ });
2298
+ const connectedTimer = isCLI ? undefined : setTimeout(connected, 100);
2299
+ if (isCLI)
2300
+ connected();
2301
+ return {
2302
+ async dispose() {
2303
+ clearTimeout(connectedTimer);
2304
+ notifier2.dispose();
2305
+ },
2306
+ async event({ event }) {
2307
+ try {
2308
+ const type = asRecord(event)?.type;
2309
+ const sessionID = getSessionIDFromEvent(event);
2310
+ if (type === "session.created" || type === "session.updated") {
2311
+ const info = getSessionLifecycleInfo(event);
2312
+ if (type === "session.created")
2313
+ await notifier2.created(info.id, info.parentID, info.title);
2314
+ else if (info.id)
2315
+ notifier2.track(info.id, info.parentID);
2196
2316
  }
2197
- return;
2198
- }
2199
- case "tool.before": {
2200
- if (event.tool === "question") {
2201
- await handleEvent(config, "question", projectName, null);
2317
+ if (type === "permission.asked") {
2318
+ await notifier2.permission(sessionID, getPermissionIDFromEvent(event), true);
2202
2319
  }
2203
- if (event.tool === "plan_exit") {
2204
- await handleEvent(config, "plan_exit", projectName, null);
2320
+ if (type === "session.idle")
2321
+ await notifier2.idle(sessionID, isCLI);
2322
+ if (type === "session.status" && getNestedRecord(event, "properties", "status")?.type === "busy" && sessionID) {
2323
+ notifier2.busy(sessionID);
2205
2324
  }
2206
- return;
2207
- }
2208
- }
2209
- };
2210
- return { dispatch };
2211
- }
2212
- function normalizeV1Event(event) {
2213
- const events = [];
2214
- const type = getStringField(asRecord(event), "type");
2215
- if (type === "session.created") {
2216
- const info = getSessionLifecycleInfo(event);
2217
- events.push({ type: "session.created", sessionID: info.id, parentID: info.parentID, title: info.title });
2218
- }
2219
- if (type === "session.updated") {
2220
- const info = getSessionLifecycleInfo(event);
2221
- events.push({ type: "session.updated", sessionID: info.id, parentID: info.parentID });
2222
- }
2223
- if (type === "session.deleted") {
2224
- const info = getSessionLifecycleInfo(event);
2225
- events.push({ type: "session.deleted", sessionID: info.id });
2226
- }
2227
- if (type === "permission.asked") {
2228
- events.push({
2229
- type: "permission.asked",
2230
- sessionID: getSessionIDFromEvent(event),
2231
- permissionID: getPermissionIDFromEvent(event)
2232
- });
2233
- }
2234
- if (type === "session.idle") {
2235
- events.push({ type: "session.idle", sessionID: getSessionIDFromEvent(event) });
2236
- }
2237
- if (type === "session.status") {
2238
- const status = getNestedRecord(event, "properties", "status");
2239
- const sessionID = getStringField(getNestedRecord(event, "properties"), "sessionID");
2240
- if (getStringField(status, "type") === "busy" && sessionID) {
2241
- events.push({ type: "session.busy", sessionID });
2242
- }
2243
- }
2244
- if (type === "session.error") {
2245
- const error = getNestedRecord(event, "properties", "error");
2246
- events.push({
2247
- type: "session.error",
2248
- sessionID: getSessionIDFromEvent(event),
2249
- userCancelled: getStringField(error, "name") === "MessageAbortedError"
2250
- });
2251
- }
2252
- if (type === "message.updated") {
2253
- const info = getMessageUpdatedInfo(event);
2254
- if (info.role === "user") {
2255
- events.push({ type: "message.user", sessionID: info.sessionID });
2256
- }
2257
- }
2258
- return events;
2259
- }
2260
- function normalizeV2Event(event) {
2261
- const events = [];
2262
- const record = asRecord(event);
2263
- const type = getStringField(record, "type");
2264
- const data = getNestedRecord(event, "data") ?? {};
2265
- if (type === "session.created") {
2266
- events.push({
2267
- type: "session.created",
2268
- sessionID: getStringField(data, "sessionID"),
2269
- parentID: getStringField(data, "parentID"),
2270
- title: getStringField(data, "title")
2271
- });
2272
- }
2273
- if (type === "session.deleted") {
2274
- events.push({ type: "session.deleted", sessionID: getStringField(data, "sessionID") });
2275
- }
2276
- if (type === "permission.asked") {
2277
- events.push({
2278
- type: "permission.asked",
2279
- sessionID: getStringField(data, "sessionID"),
2280
- permissionID: getStringField(data, "id")
2281
- });
2282
- }
2283
- if (type === "session.execution.started") {
2284
- const sessionID = getStringField(data, "sessionID");
2285
- if (sessionID) {
2286
- events.push({ type: "session.busy", sessionID });
2287
- }
2288
- }
2289
- if (type === "session.execution.succeeded") {
2290
- events.push({ type: "session.idle", sessionID: getStringField(data, "sessionID") });
2291
- }
2292
- if (type === "session.execution.failed") {
2293
- events.push({ type: "session.error", sessionID: getStringField(data, "sessionID"), userCancelled: false });
2294
- }
2295
- if (type === "session.execution.interrupted") {
2296
- const reason = getStringField(data, "reason");
2297
- if (reason === "user" || reason === "inactivity") {
2298
- events.push({
2299
- type: "session.error",
2300
- sessionID: getStringField(data, "sessionID"),
2301
- userCancelled: reason === "user"
2302
- });
2303
- }
2304
- }
2305
- if (type === "session.inbox.enqueued") {
2306
- const item = getNestedRecord(event, "data", "item");
2307
- if (getStringField(item, "type") === "user") {
2308
- events.push({ type: "message.user", sessionID: getStringField(data, "sessionID") });
2309
- }
2310
- }
2311
- return events;
2312
- }
2313
- function createV1SessionClient(client) {
2314
- return {
2315
- async lastUserMessageAt(sessionID) {
2316
- const response = await client.session.messages({ path: { id: sessionID } });
2317
- const messages = response.data ?? [];
2318
- let lastUserMessageTime = null;
2319
- for (const msg of messages) {
2320
- const info = msg.info;
2321
- if (info.role === "user" && typeof info.time?.created === "number") {
2322
- if (lastUserMessageTime === null || info.time.created > lastUserMessageTime) {
2323
- lastUserMessageTime = info.time.created;
2324
- }
2325
+ if (type === "session.error") {
2326
+ const cancelled = getNestedRecord(event, "properties", "error")?.name === "MessageAbortedError";
2327
+ await notifier2.failed(sessionID, cancelled ? "user_cancelled" : "error");
2325
2328
  }
2326
- }
2327
- return lastUserMessageTime;
2328
- },
2329
- async sessionInfo(sessionID) {
2330
- const response = await client.session.get({ path: { id: sessionID } });
2331
- return {
2332
- isChild: !!response.data?.parentID,
2333
- title: typeof response.data?.title === "string" ? response.data.title : null
2334
- };
2335
- },
2336
- permissionPending(_sessionID, permissionID) {
2337
- return isPermissionStillPending(client, permissionID);
2338
- }
2339
- };
2340
- }
2341
- function createV2SessionClient(ctx) {
2342
- return {
2343
- async lastUserMessageAt(sessionID) {
2344
- const messages = await ctx.session.context({ sessionID });
2345
- let lastUserMessageTime = null;
2346
- for (const msg of messages ?? []) {
2347
- if (msg.type === "user" && typeof msg.time?.created === "number") {
2348
- if (lastUserMessageTime === null || msg.time.created > lastUserMessageTime) {
2349
- lastUserMessageTime = msg.time.created;
2350
- }
2329
+ if (type === "message.updated") {
2330
+ const info = getMessageUpdatedInfo(event);
2331
+ if (info.role === "user")
2332
+ await notifier2.userMessage(info.sessionID);
2351
2333
  }
2352
- }
2353
- return lastUserMessageTime;
2354
- },
2355
- async sessionInfo(sessionID) {
2356
- const info = await ctx.session.get({ sessionID });
2357
- return {
2358
- isChild: !!info?.parentID,
2359
- title: typeof info?.title === "string" ? info.title : null
2360
- };
2361
- },
2362
- async permissionPending(sessionID, permissionID) {
2363
- if (!sessionID)
2364
- return true;
2365
- try {
2366
- const pending = await ctx.permission.list({ sessionID });
2367
- if (!Array.isArray(pending))
2368
- return true;
2369
- return pending.some((p) => p?.id === permissionID);
2370
- } catch {
2371
- return true;
2372
- }
2373
- }
2374
- };
2375
- }
2376
- var NotifierPlugin = async ({ client, directory }) => {
2377
- const runtime = createNotifierRuntime(createV1SessionClient(client), directory ?? null, process.env.OPENCODE_CLIENT);
2378
- if (!runtime)
2379
- return {};
2380
- return {
2381
- event: async ({ event }) => {
2382
- for (const normalized of normalizeV1Event(event)) {
2383
- await runtime.dispatch(normalized);
2384
- }
2334
+ } catch {}
2385
2335
  },
2386
2336
  "permission.ask": async () => {
2387
- await runtime.dispatch({ type: "permission.ask" });
2337
+ try {
2338
+ await notifier2.permission(null, null, true);
2339
+ } catch {}
2388
2340
  },
2389
2341
  "tool.execute.before": async (input) => {
2390
- await runtime.dispatch({ type: "tool.before", tool: input.tool });
2391
- }
2392
- };
2393
- };
2394
- function isV2Context(ctx) {
2395
- const record = asRecord(ctx);
2396
- return typeof asRecord(record?.event)?.subscribe === "function" && typeof asRecord(record?.tool)?.hook === "function" && typeof asRecord(record?.session)?.get === "function";
2397
- }
2398
- var NotifierPluginV2 = {
2399
- id: "opencode-notifier",
2400
- async setup(ctx) {
2401
- if (!isV2Context(ctx))
2402
- return;
2403
- const runtime = createNotifierRuntime(createV2SessionClient(ctx), ctx.location?.directory ?? null, process.env.OPENCODE_CLIENT);
2404
- if (!runtime)
2405
- return;
2406
- const controller = new AbortController;
2407
- (async () => {
2408
2342
  try {
2409
- for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
2410
- for (const normalized of normalizeV2Event(event)) {
2411
- await runtime.dispatch(normalized);
2412
- }
2413
- }
2343
+ if (input.tool === "question" || input.tool === "plan_exit")
2344
+ await notifier2.notify(input.tool);
2414
2345
  } catch {}
2415
- })();
2416
- await ctx.tool.hook("execute.before", (event) => {
2417
- runtime.dispatch({ type: "tool.before", tool: event.tool });
2418
- });
2419
- return () => controller.abort();
2420
- }
2346
+ }
2347
+ };
2421
2348
  };
2422
- var pluginModule = {
2349
+ var src_default = {
2423
2350
  id: "opencode-notifier",
2424
- setup: NotifierPluginV2.setup,
2425
- server: NotifierPlugin
2351
+ server: NotifierPlugin,
2352
+ setup: setupV2
2426
2353
  };
2427
- var src_default = pluginModule;
2428
2354
  export {
2429
- NotifierPlugin,
2430
- NotifierPluginV2,
2431
- PERMISSION_PENDING_GRACE_MS,
2432
- src_default as default,
2433
- extractAgentNameFromSessionTitle,
2434
- getPermissionIDFromEvent,
2435
- isCLIClient,
2436
2355
  isPermissionStillPending,
2437
- isV2Context,
2438
- normalizeV1Event,
2439
- normalizeV2Event
2356
+ isCLIClient,
2357
+ getPermissionIDFromEvent,
2358
+ extractAgentNameFromSessionTitle,
2359
+ src_default as default,
2360
+ PERMISSION_PENDING_GRACE_MS,
2361
+ NotifierPlugin
2440
2362
  };