@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/README.md +49 -22
- package/dist/bell.d.ts +2 -0
- package/dist/command.d.ts +2 -0
- package/dist/config.d.ts +109 -0
- package/dist/delivery.d.ts +4 -0
- package/dist/focus.d.ts +34 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +1705 -1783
- package/dist/notifier.d.ts +24 -0
- package/dist/notify.d.ts +19 -0
- package/dist/permission-dedupe.d.ts +3 -0
- package/dist/sound.d.ts +4 -0
- package/dist/tui.d.ts +7 -0
- package/dist/tui.js +2229 -0
- package/dist/v2.d.ts +14 -0
- package/package.json +23 -7
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/
|
|
373
|
-
import
|
|
374
|
-
import {
|
|
375
|
-
import
|
|
376
|
-
|
|
377
|
-
var
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
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
|
|
406
|
-
return
|
|
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
|
|
424
|
-
return
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
|
513
|
-
const
|
|
514
|
-
if (!
|
|
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
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
}
|
|
445
|
+
}
|
|
446
|
+
function findFocusedWindowId(node) {
|
|
447
|
+
if (node.focused === true && typeof node.id === "number") {
|
|
448
|
+
return String(node.id);
|
|
522
449
|
}
|
|
523
|
-
if (
|
|
524
|
-
|
|
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 (
|
|
527
|
-
|
|
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
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
|
565
|
+
return refs;
|
|
648
566
|
}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
|
669
|
-
if (
|
|
670
|
-
return
|
|
576
|
+
function isAtspiTerminalWindow(appName, windowPath) {
|
|
577
|
+
if (ATSPI_TERMINAL_PATH_MARKERS.some((marker) => windowPath.toLowerCase().includes(marker))) {
|
|
578
|
+
return true;
|
|
671
579
|
}
|
|
672
|
-
if (
|
|
673
|
-
return
|
|
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
|
-
|
|
676
|
-
|
|
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
|
|
625
|
+
return address;
|
|
679
626
|
}
|
|
680
|
-
function
|
|
681
|
-
|
|
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
|
|
684
|
-
|
|
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
|
-
|
|
687
|
-
const
|
|
688
|
-
const
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
{
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
|
782
|
-
if (
|
|
783
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
"
|
|
804
|
-
"
|
|
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
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
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
|
-
|
|
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
|
|
853
|
-
return
|
|
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
|
|
856
|
-
|
|
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
|
|
859
|
-
|
|
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
|
|
865
|
-
const
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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
|
-
|
|
876
|
-
if (
|
|
877
|
-
|
|
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 (
|
|
880
|
-
|
|
881
|
-
const id = findFocusedWindowId(child);
|
|
882
|
-
if (id !== null)
|
|
883
|
-
return id;
|
|
884
|
-
}
|
|
753
|
+
if (expected.size > 0) {
|
|
754
|
+
return expected;
|
|
885
755
|
}
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
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
|
-
|
|
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
|
|
896
|
-
const
|
|
897
|
-
if (
|
|
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
|
-
|
|
900
|
-
|
|
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
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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
|
|
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
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
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
|
-
|
|
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
|
|
881
|
+
return false;
|
|
930
882
|
}
|
|
931
883
|
}
|
|
932
|
-
function
|
|
933
|
-
|
|
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
|
|
957
|
-
|
|
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
|
|
974
|
-
if (!
|
|
975
|
-
return null;
|
|
976
|
-
const match = output.match(/'((?:[^'\\]|\\.)*)'/);
|
|
977
|
-
if (!match)
|
|
890
|
+
function getWindowTitleFromKdotool(windowId) {
|
|
891
|
+
if (!isNumericWindowId(windowId))
|
|
978
892
|
return null;
|
|
979
|
-
|
|
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
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
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
|
-
|
|
900
|
+
if (cachedKDEJumpBackSupport !== null) {
|
|
901
|
+
return cachedKDEJumpBackSupport;
|
|
902
|
+
}
|
|
903
|
+
cachedKDEJumpBackSupport = execFileWithTimeout("kdotool", ["--help"], 1000) !== null;
|
|
904
|
+
return cachedKDEJumpBackSupport;
|
|
995
905
|
}
|
|
996
|
-
function
|
|
997
|
-
if (!
|
|
906
|
+
function getWindowClassX11(windowId) {
|
|
907
|
+
if (!isNumericWindowId(windowId))
|
|
998
908
|
return null;
|
|
999
|
-
const
|
|
1000
|
-
if (!
|
|
909
|
+
const output = execFileWithTimeout("xprop", ["-id", windowId, "WM_CLASS"]);
|
|
910
|
+
if (!output)
|
|
1001
911
|
return null;
|
|
1002
|
-
const
|
|
1003
|
-
return
|
|
912
|
+
const matches = [...output.matchAll(/"([^"]*)"/g)].map((match) => match[1]);
|
|
913
|
+
return matches[1] ?? matches[0] ?? null;
|
|
1004
914
|
}
|
|
1005
|
-
function
|
|
1006
|
-
if (
|
|
1007
|
-
|
|
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 (
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
}
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
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
|
|
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
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
const
|
|
1074
|
-
if (
|
|
1075
|
-
|
|
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
|
-
|
|
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
|
|
1014
|
+
function focusLinuxWindowX11(windowId) {
|
|
1015
|
+
if (!isNumericWindowId(windowId))
|
|
1016
|
+
return;
|
|
1082
1017
|
try {
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
|
1040
|
+
return process.ppid;
|
|
1092
1041
|
} catch {
|
|
1093
|
-
return
|
|
1042
|
+
return process.ppid;
|
|
1094
1043
|
}
|
|
1095
1044
|
}
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
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
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
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
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
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
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
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
|
|
1265
|
+
function focusLinuxWindowSway(windowId) {
|
|
1266
|
+
if (!isNumericWindowId(windowId))
|
|
1267
|
+
return;
|
|
1278
1268
|
try {
|
|
1279
|
-
|
|
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 (
|
|
1282
|
-
return
|
|
1283
|
-
}
|
|
1284
|
-
if (!isWezTermPaneActive()) {
|
|
1285
|
-
return false;
|
|
1296
|
+
if (frontmostAppName && isMacTerminalAppFocused(frontmostAppName, process.env)) {
|
|
1297
|
+
return;
|
|
1286
1298
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1293
|
-
|
|
1294
|
-
return
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
1314
|
-
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
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
|
-
|
|
1333
|
-
return cachedKDEJumpBackSupport;
|
|
1368
|
+
return payload;
|
|
1334
1369
|
}
|
|
1335
|
-
function
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
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
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
return
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
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 (
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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
|
-
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
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
|
|
1409
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
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
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
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
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
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
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
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
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
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
|
|
1688
|
-
if (
|
|
1689
|
-
return;
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
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
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
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
|
|
1709
|
-
if (!
|
|
1710
|
-
return;
|
|
1648
|
+
function normalizeVolume(volume) {
|
|
1649
|
+
if (!Number.isFinite(volume)) {
|
|
1650
|
+
return 1;
|
|
1711
1651
|
}
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
return;
|
|
1652
|
+
if (volume < 0) {
|
|
1653
|
+
return 0;
|
|
1715
1654
|
}
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
process.env.OPENCODE_NOTIFIER_WINDOW_ID = detected;
|
|
1655
|
+
if (volume > 1) {
|
|
1656
|
+
return 1;
|
|
1719
1657
|
}
|
|
1658
|
+
return volume;
|
|
1720
1659
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
}
|
|
1745
|
-
|
|
1746
|
-
|
|
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
|
-
|
|
1749
|
-
|
|
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
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
1773
|
-
sessionLastPermissionAt.set(sessionID, now);
|
|
1708
|
+
if (!claimSoundSlot(event)) {
|
|
1709
|
+
return;
|
|
1774
1710
|
}
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
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 (
|
|
1784
|
-
|
|
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/
|
|
1789
|
-
|
|
1790
|
-
function
|
|
1791
|
-
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
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
|
|
1802
|
-
|
|
1803
|
-
|
|
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
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
-
|
|
1817
|
-
|
|
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
|
-
|
|
1897
|
-
|
|
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
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
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
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
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
|
-
|
|
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
|
|
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 :
|
|
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
|
-
|
|
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
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
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
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
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
|
-
|
|
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 (
|
|
2204
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2410
|
-
|
|
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
|
-
|
|
2417
|
-
runtime.dispatch({ type: "tool.before", tool: event.tool });
|
|
2418
|
-
});
|
|
2419
|
-
return () => controller.abort();
|
|
2420
|
-
}
|
|
2346
|
+
}
|
|
2347
|
+
};
|
|
2421
2348
|
};
|
|
2422
|
-
var
|
|
2349
|
+
var src_default = {
|
|
2423
2350
|
id: "opencode-notifier",
|
|
2424
|
-
|
|
2425
|
-
|
|
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
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2356
|
+
isCLIClient,
|
|
2357
|
+
getPermissionIDFromEvent,
|
|
2358
|
+
extractAgentNameFromSessionTitle,
|
|
2359
|
+
src_default as default,
|
|
2360
|
+
PERMISSION_PENDING_GRACE_MS,
|
|
2361
|
+
NotifierPlugin
|
|
2440
2362
|
};
|