@mohak34/opencode-notifier 0.1.36 → 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -4
  2. package/dist/index.js +141 -19
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -181,6 +181,7 @@ Customize the notification text:
181
181
  Messages support placeholder tokens that get replaced with actual values:
182
182
 
183
183
  - `{sessionTitle}` - The title/summary of the current session (e.g. "Fix login bug")
184
+ - `{agentName}` - Subagent name extracted from session titles with `(@name subagent)` suffix (e.g. `builder`, `codebase-researcher`), empty for non-subagent sessions
184
185
  - `{projectName}` - The project folder name
185
186
  - `{timestamp}` - Current time in `HH:MM:SS` format (e.g. "14:30:05")
186
187
  - `{turn}` - Global notification counter that persists across restarts (e.g. 1, 2, 3). Stored in `~/.config/opencode/opencode-notifier-state.json`
@@ -236,7 +237,7 @@ Set per-event volume from `0` to `1`:
236
237
 
237
238
  ### Custom commands
238
239
 
239
- Run your own script when something happens. Use `{event}`, `{message}`, `{sessionTitle}`, `{projectName}`, `{timestamp}`, and `{turn}` as placeholders:
240
+ Run your own script when something happens. Use `{event}`, `{message}`, `{sessionTitle}`, `{agentName}`, `{projectName}`, `{timestamp}`, and `{turn}` as placeholders:
240
241
 
241
242
  ```json
242
243
  {
@@ -251,7 +252,7 @@ Run your own script when something happens. Use `{event}`, `{message}`, `{sessio
251
252
 
252
253
  - `enabled` - Turn command on/off
253
254
  - `path` - Path to your script/executable
254
- - `args` - Arguments to pass, can use `{event}`, `{message}`, `{sessionTitle}`, `{projectName}`, `{timestamp}`, and `{turn}` tokens
255
+ - `args` - Arguments to pass, can use `{event}`, `{message}`, `{sessionTitle}`, `{agentName}`, `{projectName}`, `{timestamp}`, and `{turn}` tokens
255
256
  - `minDuration` - Skip if response was quick, avoids spam (seconds)
256
257
 
257
258
  #### Example: Log events to a file
@@ -301,6 +302,18 @@ If you're using [Ghostty](https://ghostty.org/) terminal, you can use its native
301
302
 
302
303
  This sends notifications directly through the terminal instead of using system notification tools. Works on any platform where Ghostty is running.
303
304
 
305
+ If you're using Ghostty inside tmux, enable passthrough in your tmux config so OSC 9 notifications can pass through:
306
+
307
+ ```tmux
308
+ set -g allow-passthrough on
309
+ ```
310
+
311
+ Then reload tmux config:
312
+
313
+ ```bash
314
+ tmux source-file ~/.tmux.conf
315
+ ```
316
+
304
317
  ## Focus detection
305
318
 
306
319
  When `suppressWhenFocused` is `true` (the default), notifications and sounds are skipped if the terminal running OpenCode is the active/focused window. The idea is simple: if you're already looking at it, you don't need an alert.
@@ -320,15 +333,18 @@ To disable this and always get notified:
320
333
  | macOS | AppleScript (`System Events`) | None | Untested |
321
334
  | Linux X11 | `xdotool` | `xdotool` installed | Untested |
322
335
  | Linux Wayland (Hyprland) | `hyprctl activewindow` | None | Tested |
336
+ | Linux Wayland (Niri) | `niri msg --json focused-window` | None | Tested |
323
337
  | Linux Wayland (Sway) | `swaymsg -t get_tree` | None | Untested |
324
338
  | Linux Wayland (KDE) | `kdotool` | `kdotool` installed | Untested |
325
339
  | Linux Wayland (GNOME) | Not supported | - | Falls back to always notifying |
326
- | Linux Wayland (Niri, river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
340
+ | Linux Wayland (river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
327
341
  | Windows | `GetForegroundWindow()` via PowerShell | None | Untested |
328
342
 
329
343
  **Unsupported compositors**: Wayland has no standard protocol for querying the focused window. Each compositor has its own IPC, and GNOME intentionally doesn't expose focus information. Unsupported compositors fall back to always notifying.
330
344
 
331
- **tmux/screen**: When running inside tmux, the tmux server daemonizes and detaches from the terminal's process tree. The plugin handles this by querying the tmux client PID and walking from there. GNU Screen has the same issue but is not currently handled (falls back to always notifying).
345
+ **tmux/screen**: When running inside tmux, focus detection uses tmux pane state (`session_attached`, `window_active`, `pane_active`) via `tmux display-message`. This keeps suppression accurate when switching panes/windows/sessions. GNU Screen is not currently handled (falls back to always notifying).
346
+
347
+ **WezTerm panes**: When running in WezTerm with `WEZTERM_PANE` set, focus suppression is pane-aware via `wezterm cli list-clients --format json`. This means notifications are shown when you switch to a different WezTerm pane/tab.
332
348
 
333
349
  **Fail-open design**: If detection fails for any reason (missing tools, unknown compositor, permissions), it falls back to always notifying. It never silently eats your notifications.
334
350
 
package/dist/index.js CHANGED
@@ -220,6 +220,8 @@ function interpolateMessage(message, context) {
220
220
  let result = message;
221
221
  const sessionTitle = context.sessionTitle || "";
222
222
  result = result.replaceAll("{sessionTitle}", sessionTitle);
223
+ const agentName = context.agentName || "";
224
+ result = result.replaceAll("{agentName}", agentName);
223
225
  const projectName = context.projectName || "";
224
226
  result = result.replaceAll("{projectName}", projectName);
225
227
  const timestamp = context.timestamp || "";
@@ -250,6 +252,18 @@ if (platform === "Linux" || platform.match(/BSD$/)) {
250
252
  var lastNotificationTime = {};
251
253
  var lastLinuxNotificationId = null;
252
254
  var linuxNotifySendSupportsReplace = null;
255
+ function sanitizeGhosttyField(value) {
256
+ return value.replace(/[;\x07\x1b\n\r]/g, "");
257
+ }
258
+ function formatGhosttyNotificationSequence(title, message, env = process.env) {
259
+ const escapedTitle = sanitizeGhosttyField(title);
260
+ const escapedMessage = sanitizeGhosttyField(message);
261
+ const payload = `\x1B]9;${escapedTitle}: ${escapedMessage}\x07`;
262
+ if (env.TMUX) {
263
+ return `\x1BPtmux;\x1B${payload}\x1B\\`;
264
+ }
265
+ return payload;
266
+ }
253
267
  function detectNotifySendCapabilities() {
254
268
  return new Promise((resolve) => {
255
269
  execFile("notify-send", ["--version"], (error, stdout) => {
@@ -302,9 +316,8 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
302
316
  lastNotificationTime[message] = now;
303
317
  if (notificationSystem === "ghostty") {
304
318
  return new Promise((resolve) => {
305
- const escapedTitle = title.replace(/[;\x07\x1b\n\r]/g, "");
306
- const escapedMessage = message.replace(/[;\x07\x1b\n\r]/g, "");
307
- process.stdout.write(`\x1B]9;${escapedTitle}: ${escapedMessage}\x07`, () => {
319
+ const sequence = formatGhosttyNotificationSequence(title, message);
320
+ process.stdout.write(sequence, () => {
308
321
  resolve();
309
322
  });
310
323
  });
@@ -481,20 +494,21 @@ async function playSound(event, customPath, volume) {
481
494
 
482
495
  // src/command.ts
483
496
  import { spawn as spawn2 } from "child_process";
484
- function substituteTokens(value, event, message, sessionTitle, projectName, timestamp, turn) {
497
+ function substituteTokens(value, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
485
498
  let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
486
499
  result = result.replaceAll("{sessionTitle}", sessionTitle || "");
500
+ result = result.replaceAll("{agentName}", agentName || "");
487
501
  result = result.replaceAll("{projectName}", projectName || "");
488
502
  result = result.replaceAll("{timestamp}", timestamp || "");
489
503
  result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
490
504
  return result;
491
505
  }
492
- function runCommand2(config, event, message, sessionTitle, projectName, timestamp, turn) {
506
+ function runCommand2(config, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
493
507
  if (!config.command.enabled || !config.command.path) {
494
508
  return;
495
509
  }
496
- const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, projectName, timestamp, turn));
497
- const command = substituteTokens(config.command.path, event, message, sessionTitle, projectName, timestamp, turn);
510
+ const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
511
+ const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
498
512
  const proc = spawn2(command, args, {
499
513
  stdio: "ignore",
500
514
  detached: true
@@ -578,10 +592,38 @@ function getSwayActiveWindowId() {
578
592
  return null;
579
593
  }
580
594
  }
595
+ function getNiriActiveWindowId() {
596
+ const output = execWithTimeout("niri msg --json focused-window", 1000);
597
+ if (!output)
598
+ return null;
599
+ try {
600
+ const data = JSON.parse(output);
601
+ return typeof data?.id === "number" ? String(data.id) : null;
602
+ } catch {
603
+ return null;
604
+ }
605
+ }
606
+ function parseWezTermFocusedPaneId(output) {
607
+ try {
608
+ const data = JSON.parse(output);
609
+ if (!Array.isArray(data))
610
+ return null;
611
+ for (const client of data) {
612
+ if (typeof client?.focused_pane_id === "number") {
613
+ return String(client.focused_pane_id);
614
+ }
615
+ }
616
+ return null;
617
+ } catch {
618
+ return null;
619
+ }
620
+ }
581
621
  function getLinuxWaylandActiveWindowId() {
582
622
  const env = process.env;
583
623
  if (env.HYPRLAND_INSTANCE_SIGNATURE)
584
624
  return getHyprlandActiveWindowId();
625
+ if (env.NIRI_SOCKET)
626
+ return getNiriActiveWindowId();
585
627
  if (env.SWAYSOCK)
586
628
  return getSwayActiveWindowId();
587
629
  if (env.KDE_SESSION_VERSION)
@@ -607,6 +649,9 @@ function normalizeMacAppName(value) {
607
649
  function getExpectedMacTerminalAppNames(env) {
608
650
  const expected = new Set;
609
651
  const termProgram = typeof env.TERM_PROGRAM === "string" ? normalizeMacAppName(env.TERM_PROGRAM) : "";
652
+ if (env.TMUX && (termProgram === "tmux" || termProgram === "screen" || termProgram.length === 0)) {
653
+ return new Set(MAC_TERMINAL_APP_NAMES);
654
+ }
610
655
  if (termProgram === "apple_terminal") {
611
656
  expected.add("terminal");
612
657
  } else if (termProgram === "iterm" || termProgram === "iterm2") {
@@ -652,16 +697,31 @@ function getActiveWindowId() {
652
697
  return null;
653
698
  }
654
699
  var cachedWindowId = getActiveWindowId();
655
- var tmuxPane = process.env.TMUX_PANE ?? null;
656
- function isTmuxPaneActive() {
700
+ function isTmuxPaneFocused(tmuxPane, probeResult) {
657
701
  if (!tmuxPane)
658
- return true;
659
- const result = execWithTimeout(`tmux display-message -t ${tmuxPane} -p '#{session_attached} #{window_active} #{pane_active}'`);
660
- if (!result)
661
702
  return false;
662
- const [sessionAttached, windowActive, paneActive] = result.split(" ");
703
+ if (!probeResult)
704
+ return false;
705
+ const [sessionAttached, windowActive, paneActive] = probeResult.split(" ");
663
706
  return sessionAttached === "1" && windowActive === "1" && paneActive === "1";
664
707
  }
708
+ function isTmuxPaneActive() {
709
+ const tmuxPane = process.env.TMUX_PANE ?? null;
710
+ const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
711
+ return isTmuxPaneFocused(tmuxPane, result);
712
+ }
713
+ function isWezTermPaneActive() {
714
+ const weztermPane = process.env.WEZTERM_PANE ?? null;
715
+ if (!weztermPane)
716
+ return true;
717
+ const output = execFileWithTimeout("wezterm", ["cli", "list-clients", "--format", "json"], 1000);
718
+ if (!output)
719
+ return false;
720
+ const focusedPaneId = parseWezTermFocusedPaneId(output);
721
+ if (!focusedPaneId)
722
+ return false;
723
+ return focusedPaneId === weztermPane;
724
+ }
665
725
  function isTerminalFocused() {
666
726
  try {
667
727
  if (process.platform === "darwin") {
@@ -669,6 +729,9 @@ function isTerminalFocused() {
669
729
  if (!isMacTerminalAppFocused(frontmostAppName, process.env)) {
670
730
  return false;
671
731
  }
732
+ if (!isWezTermPaneActive()) {
733
+ return false;
734
+ }
672
735
  if (process.env.TMUX) {
673
736
  return isTmuxPaneActive();
674
737
  }
@@ -679,6 +742,8 @@ function isTerminalFocused() {
679
742
  const currentId = getActiveWindowId();
680
743
  if (currentId !== cachedWindowId)
681
744
  return false;
745
+ if (!isWezTermPaneActive())
746
+ return false;
682
747
  if (process.env.TMUX)
683
748
  return isTmuxPaneActive();
684
749
  return true;
@@ -687,6 +752,34 @@ function isTerminalFocused() {
687
752
  }
688
753
  }
689
754
 
755
+ // src/permission-dedupe.ts
756
+ var PERMISSION_DEDUPE_WINDOW_MS = 1000;
757
+ var sessionLastPermissionAt = new Map;
758
+ var globalLastPermissionAt = 0;
759
+ function shouldSuppressPermissionAlert(sessionID, now = Date.now()) {
760
+ const sessionLastAt = sessionID ? sessionLastPermissionAt.get(sessionID) : undefined;
761
+ const latestSeen = Math.max(globalLastPermissionAt, sessionLastAt ?? 0);
762
+ const isDuplicate = latestSeen > 0 && now - latestSeen < PERMISSION_DEDUPE_WINDOW_MS;
763
+ if (isDuplicate) {
764
+ return true;
765
+ }
766
+ globalLastPermissionAt = now;
767
+ if (sessionID) {
768
+ sessionLastPermissionAt.set(sessionID, now);
769
+ }
770
+ return false;
771
+ }
772
+ function prunePermissionAlertState(cutoffMs) {
773
+ for (const [sessionID, timestamp] of sessionLastPermissionAt) {
774
+ if (timestamp < cutoffMs) {
775
+ sessionLastPermissionAt.delete(sessionID);
776
+ }
777
+ }
778
+ if (globalLastPermissionAt < cutoffMs) {
779
+ globalLastPermissionAt = 0;
780
+ }
781
+ }
782
+
690
783
  // src/index.ts
691
784
  var IDLE_COMPLETE_DELAY_MS = 350;
692
785
  var pendingIdleTimers = new Map;
@@ -734,6 +827,7 @@ setInterval(() => {
734
827
  sessionLastBusyAt.delete(sessionID);
735
828
  }
736
829
  }
830
+ prunePermissionAlertState(cutoff);
737
831
  }, 5 * 60 * 1000);
738
832
  function getNotificationTitle(config, projectName) {
739
833
  if (config.showProjectName && projectName) {
@@ -748,7 +842,26 @@ function formatTimestamp() {
748
842
  const s = String(now.getSeconds()).padStart(2, "0");
749
843
  return `${h}:${m}:${s}`;
750
844
  }
751
- async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID) {
845
+ function extractAgentNameFromSessionTitle(sessionTitle) {
846
+ if (!sessionTitle) {
847
+ return "";
848
+ }
849
+ const match = sessionTitle.match(/\s*\(@([^\s)]+)\s+subagent\)\s*$/);
850
+ return match ? match[1] : "";
851
+ }
852
+ function shouldResolveAgentNameForEvent(config, eventType) {
853
+ if (getMessage(config, eventType).includes("{agentName}")) {
854
+ return true;
855
+ }
856
+ if (!config.command.enabled || !isEventCommandEnabled(config, eventType)) {
857
+ return false;
858
+ }
859
+ if (config.command.path.includes("{agentName}")) {
860
+ return true;
861
+ }
862
+ return (config.command.args ?? []).some((arg) => arg.includes("{agentName}"));
863
+ }
864
+ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName) {
752
865
  if (config.suppressWhenFocused && isTerminalFocused()) {
753
866
  return;
754
867
  }
@@ -758,6 +871,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
758
871
  const rawMessage = getMessage(config, eventType);
759
872
  const message = interpolateMessage(rawMessage, {
760
873
  sessionTitle: config.showSessionTitle ? sessionTitle : null,
874
+ agentName,
761
875
  projectName,
762
876
  timestamp,
763
877
  turn
@@ -775,7 +889,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
775
889
  const minDuration = config.command?.minDuration;
776
890
  const shouldSkipCommand = !isEventCommandEnabled(config, eventType) || typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
777
891
  if (!shouldSkipCommand) {
778
- runCommand2(config, eventType, message, sessionTitle, projectName, timestamp, turn);
892
+ runCommand2(config, eventType, message, sessionTitle, agentName, projectName, timestamp, turn);
779
893
  }
780
894
  await Promise.allSettled(promises);
781
895
  }
@@ -905,11 +1019,13 @@ async function handleEventWithElapsedTime(client, config, eventType, projectName
905
1019
  }
906
1020
  }
907
1021
  let sessionTitle = preloadedSessionTitle ?? null;
908
- if (sessionID && !sessionTitle && config.showSessionTitle) {
1022
+ const shouldLookupSessionInfo = sessionID && !sessionTitle && (config.showSessionTitle || shouldResolveAgentNameForEvent(config, eventType));
1023
+ if (shouldLookupSessionInfo) {
909
1024
  const info = await getSessionInfo(client, sessionID);
910
1025
  sessionTitle = info.title;
911
1026
  }
912
- await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID);
1027
+ const agentName = extractAgentNameFromSessionTitle(sessionTitle);
1028
+ await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName);
913
1029
  }
914
1030
  var NotifierPlugin = async ({ client, directory }) => {
915
1031
  const clientEnv = process.env.OPENCODE_CLIENT;
@@ -924,7 +1040,10 @@ var NotifierPlugin = async ({ client, directory }) => {
924
1040
  event: async ({ event }) => {
925
1041
  const config = getConfig();
926
1042
  if (event.type === "permission.asked") {
927
- await handleEventWithElapsedTime(client, config, "permission", projectName, event);
1043
+ const sessionID = getSessionIDFromEvent(event);
1044
+ if (!shouldSuppressPermissionAlert(sessionID)) {
1045
+ await handleEventWithElapsedTime(client, config, "permission", projectName, event);
1046
+ }
928
1047
  }
929
1048
  if (event.type === "session.idle") {
930
1049
  const sessionID = getSessionIDFromEvent(event);
@@ -951,7 +1070,9 @@ var NotifierPlugin = async ({ client, directory }) => {
951
1070
  },
952
1071
  "permission.ask": async () => {
953
1072
  const config = getConfig();
954
- await handleEvent(config, "permission", projectName, null);
1073
+ if (!shouldSuppressPermissionAlert(null)) {
1074
+ await handleEvent(config, "permission", projectName, null);
1075
+ }
955
1076
  },
956
1077
  "tool.execute.before": async (input) => {
957
1078
  const config = getConfig();
@@ -963,6 +1084,7 @@ var NotifierPlugin = async ({ client, directory }) => {
963
1084
  };
964
1085
  var src_default = NotifierPlugin;
965
1086
  export {
1087
+ extractAgentNameFromSessionTitle,
966
1088
  src_default as default,
967
1089
  NotifierPlugin
968
1090
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.36",
3
+ "version": "0.2.0-beta.0",
4
4
  "description": "OpenCode plugin that sends system notifications and plays sounds when permission is needed, generation completes, or errors occur",
5
5
  "author": "mohak34",
6
6
  "license": "MIT",