@mohak34/opencode-notifier 0.1.37-beta.0 → 0.2.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 +17 -2
  2. package/dist/index.js +69 -8
  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.
@@ -331,6 +344,8 @@ To disable this and always get notified:
331
344
 
332
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).
333
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.
348
+
334
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.
335
350
 
336
351
  If you test on a platform marked "Untested" and it works (or doesn't), please open an issue and let us know.
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 || "";
@@ -492,20 +494,21 @@ async function playSound(event, customPath, volume) {
492
494
 
493
495
  // src/command.ts
494
496
  import { spawn as spawn2 } from "child_process";
495
- function substituteTokens(value, event, message, sessionTitle, projectName, timestamp, turn) {
497
+ function substituteTokens(value, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
496
498
  let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
497
499
  result = result.replaceAll("{sessionTitle}", sessionTitle || "");
500
+ result = result.replaceAll("{agentName}", agentName || "");
498
501
  result = result.replaceAll("{projectName}", projectName || "");
499
502
  result = result.replaceAll("{timestamp}", timestamp || "");
500
503
  result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
501
504
  return result;
502
505
  }
503
- function runCommand2(config, event, message, sessionTitle, projectName, timestamp, turn) {
506
+ function runCommand2(config, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
504
507
  if (!config.command.enabled || !config.command.path) {
505
508
  return;
506
509
  }
507
- const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, projectName, timestamp, turn));
508
- 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);
509
512
  const proc = spawn2(command, args, {
510
513
  stdio: "ignore",
511
514
  detached: true
@@ -600,6 +603,21 @@ function getNiriActiveWindowId() {
600
603
  return null;
601
604
  }
602
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
+ }
603
621
  function getLinuxWaylandActiveWindowId() {
604
622
  const env = process.env;
605
623
  if (env.HYPRLAND_INSTANCE_SIGNATURE)
@@ -631,6 +649,9 @@ function normalizeMacAppName(value) {
631
649
  function getExpectedMacTerminalAppNames(env) {
632
650
  const expected = new Set;
633
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
+ }
634
655
  if (termProgram === "apple_terminal") {
635
656
  expected.add("terminal");
636
657
  } else if (termProgram === "iterm" || termProgram === "iterm2") {
@@ -689,6 +710,18 @@ function isTmuxPaneActive() {
689
710
  const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
690
711
  return isTmuxPaneFocused(tmuxPane, result);
691
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
+ }
692
725
  function isTerminalFocused() {
693
726
  try {
694
727
  if (process.platform === "darwin") {
@@ -696,6 +729,9 @@ function isTerminalFocused() {
696
729
  if (!isMacTerminalAppFocused(frontmostAppName, process.env)) {
697
730
  return false;
698
731
  }
732
+ if (!isWezTermPaneActive()) {
733
+ return false;
734
+ }
699
735
  if (process.env.TMUX) {
700
736
  return isTmuxPaneActive();
701
737
  }
@@ -706,6 +742,8 @@ function isTerminalFocused() {
706
742
  const currentId = getActiveWindowId();
707
743
  if (currentId !== cachedWindowId)
708
744
  return false;
745
+ if (!isWezTermPaneActive())
746
+ return false;
709
747
  if (process.env.TMUX)
710
748
  return isTmuxPaneActive();
711
749
  return true;
@@ -804,7 +842,26 @@ function formatTimestamp() {
804
842
  const s = String(now.getSeconds()).padStart(2, "0");
805
843
  return `${h}:${m}:${s}`;
806
844
  }
807
- 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) {
808
865
  if (config.suppressWhenFocused && isTerminalFocused()) {
809
866
  return;
810
867
  }
@@ -814,6 +871,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
814
871
  const rawMessage = getMessage(config, eventType);
815
872
  const message = interpolateMessage(rawMessage, {
816
873
  sessionTitle: config.showSessionTitle ? sessionTitle : null,
874
+ agentName,
817
875
  projectName,
818
876
  timestamp,
819
877
  turn
@@ -831,7 +889,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
831
889
  const minDuration = config.command?.minDuration;
832
890
  const shouldSkipCommand = !isEventCommandEnabled(config, eventType) || typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
833
891
  if (!shouldSkipCommand) {
834
- runCommand2(config, eventType, message, sessionTitle, projectName, timestamp, turn);
892
+ runCommand2(config, eventType, message, sessionTitle, agentName, projectName, timestamp, turn);
835
893
  }
836
894
  await Promise.allSettled(promises);
837
895
  }
@@ -961,11 +1019,13 @@ async function handleEventWithElapsedTime(client, config, eventType, projectName
961
1019
  }
962
1020
  }
963
1021
  let sessionTitle = preloadedSessionTitle ?? null;
964
- if (sessionID && !sessionTitle && config.showSessionTitle) {
1022
+ const shouldLookupSessionInfo = sessionID && !sessionTitle && (config.showSessionTitle || shouldResolveAgentNameForEvent(config, eventType));
1023
+ if (shouldLookupSessionInfo) {
965
1024
  const info = await getSessionInfo(client, sessionID);
966
1025
  sessionTitle = info.title;
967
1026
  }
968
- await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID);
1027
+ const agentName = extractAgentNameFromSessionTitle(sessionTitle);
1028
+ await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName);
969
1029
  }
970
1030
  var NotifierPlugin = async ({ client, directory }) => {
971
1031
  const clientEnv = process.env.OPENCODE_CLIENT;
@@ -1024,6 +1084,7 @@ var NotifierPlugin = async ({ client, directory }) => {
1024
1084
  };
1025
1085
  var src_default = NotifierPlugin;
1026
1086
  export {
1087
+ extractAgentNameFromSessionTitle,
1027
1088
  src_default as default,
1028
1089
  NotifierPlugin
1029
1090
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.37-beta.0",
3
+ "version": "0.2.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",