@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.
- package/README.md +17 -2
- package/dist/index.js +69 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|