@mohak34/opencode-notifier 0.1.29-beta.0 → 0.1.30-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 +47 -9
  2. package/dist/index.js +227 -0
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,7 +22,7 @@ You'll get notified when:
22
22
  - An error happens
23
23
  - The question tool pops up
24
24
 
25
- There's also `subagent_complete` for when subagents finish, but that's silent by default so you don't get spammed.
25
+ There's also `subagent_complete` for when subagents finish, and `user_cancelled` for when you press ESC to abort -- both are silent by default so you don't get spammed.
26
26
 
27
27
  ## Setup by platform
28
28
 
@@ -54,6 +54,7 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
54
54
  "showProjectName": true,
55
55
  "showSessionTitle": false,
56
56
  "showIcon": true,
57
+ "suppressWhenFocused": true,
57
58
  "notificationSystem": "osascript",
58
59
  "linux": {
59
60
  "grouping": false
@@ -69,28 +70,32 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
69
70
  "complete": { "sound": true, "notification": true },
70
71
  "subagent_complete": { "sound": false, "notification": false },
71
72
  "error": { "sound": true, "notification": true },
72
- "question": { "sound": true, "notification": true }
73
+ "question": { "sound": true, "notification": true },
74
+ "user_cancelled": { "sound": false, "notification": false }
73
75
  },
74
76
  "messages": {
75
77
  "permission": "Session needs permission: {sessionTitle}",
76
78
  "complete": "Session has finished: {sessionTitle}",
77
79
  "subagent_complete": "Subagent task completed: {sessionTitle}",
78
80
  "error": "Session encountered an error: {sessionTitle}",
79
- "question": "Session has a question: {sessionTitle}"
81
+ "question": "Session has a question: {sessionTitle}",
82
+ "user_cancelled": "Session was cancelled by user: {sessionTitle}"
80
83
  },
81
84
  "sounds": {
82
85
  "permission": null,
83
86
  "complete": null,
84
87
  "subagent_complete": null,
85
88
  "error": null,
86
- "question": null
89
+ "question": null,
90
+ "user_cancelled": null
87
91
  },
88
92
  "volumes": {
89
93
  "permission": 1,
90
94
  "complete": 1,
91
95
  "subagent_complete": 1,
92
96
  "error": 1,
93
- "question": 1
97
+ "question": 1,
98
+ "user_cancelled": 1
94
99
  }
95
100
  }
96
101
  ```
@@ -117,6 +122,7 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
117
122
  - `showProjectName` - Show folder name in notification title (default: true)
118
123
  - `showSessionTitle` - Include the session title in notification messages via `{sessionTitle}` placeholder (default: true)
119
124
  - `showIcon` - Show OpenCode icon, Windows/Linux only (default: true)
125
+ - `suppressWhenFocused` - Skip notifications and sounds when the terminal is the active window (default: true). See [Focus detection](#focus-detection) for platform details
120
126
  - `notificationSystem` - macOS only: `"osascript"` or `"node-notifier"` (default: "osascript")
121
127
  - `linux.grouping` - Linux only: replace notifications in-place instead of stacking (default: false). Requires `notify-send` 0.8+
122
128
 
@@ -131,11 +137,14 @@ Control each event separately:
131
137
  "complete": { "sound": true, "notification": true },
132
138
  "subagent_complete": { "sound": false, "notification": false },
133
139
  "error": { "sound": true, "notification": true },
134
- "question": { "sound": true, "notification": true }
140
+ "question": { "sound": true, "notification": true },
141
+ "user_cancelled": { "sound": false, "notification": false }
135
142
  }
136
143
  }
137
144
  ```
138
145
 
146
+ `user_cancelled` fires when you press ESC to abort a session. It's silent by default so intentional cancellations don't trigger error alerts. Set `sound` or `notification` to `true` if you want confirmation when cancelling.
147
+
139
148
  Or use true/false for both:
140
149
 
141
150
  ```json
@@ -157,7 +166,8 @@ Customize the notification text:
157
166
  "complete": "Session has finished: {sessionTitle}",
158
167
  "subagent_complete": "Subagent task completed: {sessionTitle}",
159
168
  "error": "Session encountered an error: {sessionTitle}",
160
- "question": "Session has a question: {sessionTitle}"
169
+ "question": "Session has a question: {sessionTitle}",
170
+ "user_cancelled": "Session was cancelled by user: {sessionTitle}"
161
171
  }
162
172
  }
163
173
  ```
@@ -182,7 +192,8 @@ Use your own sound files:
182
192
  "complete": "/path/to/done.wav",
183
193
  "subagent_complete": "/path/to/subagent-done.wav",
184
194
  "error": "/path/to/error.wav",
185
- "question": "/path/to/question.wav"
195
+ "question": "/path/to/question.wav",
196
+ "user_cancelled": "/path/to/cancelled.wav"
186
197
  }
187
198
  }
188
199
  ```
@@ -203,7 +214,8 @@ Set per-event volume from `0` to `1`:
203
214
  "complete": 0.3,
204
215
  "subagent_complete": 0.15,
205
216
  "error": 1,
206
- "question": 0.7
217
+ "question": 0.7,
218
+ "user_cancelled": 0.5
207
219
  }
208
220
  }
209
221
  ```
@@ -267,6 +279,32 @@ Run your own script when something happens. Use `{event}`, `{message}`, and `{se
267
279
 
268
280
  **NOTE:** If you go with node-notifier and start missing notifications, just switch back or remove the option from the config. Users have reported issues with using node-notifier for receiving only sounds and no notification popups.
269
281
 
282
+ ## Focus detection
283
+
284
+ 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.
285
+
286
+ To disable this and always get notified:
287
+
288
+ ```json
289
+ {
290
+ "suppressWhenFocused": false
291
+ }
292
+ ```
293
+
294
+ ### Platform support
295
+
296
+ | Platform | Method | Requirements |
297
+ |----------|--------|--------------|
298
+ | macOS | AppleScript (`System Events`) | None |
299
+ | Linux X11 | `xdotool` | `xdotool` installed |
300
+ | Linux Wayland (Hyprland) | `hyprctl activewindow` | None |
301
+ | Linux Wayland (Sway) | `swaymsg -t get_tree` | None |
302
+ | Linux Wayland (KDE) | `kdotool` | `kdotool` installed |
303
+ | Linux Wayland (GNOME) | Not supported | Falls back to always notifying |
304
+ | Windows | `GetForegroundWindow()` via PowerShell | None |
305
+
306
+ If detection fails for any reason (missing tools, unknown compositor, permissions), it falls back to always notifying. It never silently eats your notifications.
307
+
270
308
  ## Linux: Notification Grouping
271
309
 
272
310
  By default, each notification appears as a separate entry. During active sessions this can create noise when multiple events fire quickly (e.g. permission + complete + question).
package/dist/index.js CHANGED
@@ -3745,6 +3745,7 @@ var DEFAULT_CONFIG = {
3745
3745
  showProjectName: true,
3746
3746
  showSessionTitle: false,
3747
3747
  showIcon: true,
3748
+ suppressWhenFocused: true,
3748
3749
  notificationSystem: "osascript",
3749
3750
  linux: {
3750
3751
  grouping: false
@@ -3848,6 +3849,7 @@ function loadConfig() {
3848
3849
  showProjectName: userConfig.showProjectName ?? DEFAULT_CONFIG.showProjectName,
3849
3850
  showSessionTitle: userConfig.showSessionTitle ?? DEFAULT_CONFIG.showSessionTitle,
3850
3851
  showIcon: userConfig.showIcon ?? DEFAULT_CONFIG.showIcon,
3852
+ suppressWhenFocused: userConfig.suppressWhenFocused ?? DEFAULT_CONFIG.suppressWhenFocused,
3851
3853
  notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : "osascript",
3852
3854
  linux: {
3853
3855
  grouping: typeof userConfig.linux?.grouping === "boolean" ? userConfig.linux.grouping : DEFAULT_CONFIG.linux.grouping
@@ -4198,6 +4200,228 @@ function runCommand2(config, event, message, sessionTitle, projectName) {
4198
4200
  proc.unref();
4199
4201
  }
4200
4202
 
4203
+ // src/focus.ts
4204
+ import { execSync } from "child_process";
4205
+ import { readFileSync as readFileSync2 } from "fs";
4206
+ var TERMINAL_MAP = {
4207
+ ghostty: { name: "Ghostty", macProcessNames: ["Ghostty", "ghostty"] },
4208
+ kitty: { name: "kitty", macProcessNames: ["kitty"] },
4209
+ alacritty: { name: "Alacritty", macProcessNames: ["Alacritty", "alacritty"] },
4210
+ wezterm: { name: "WezTerm", macProcessNames: ["WezTerm", "wezterm-gui"] },
4211
+ apple_terminal: { name: "Terminal", macProcessNames: ["Terminal"] },
4212
+ iterm: { name: "iTerm2", macProcessNames: ["iTerm2", "iTerm"] },
4213
+ warp: { name: "Warp", macProcessNames: ["Warp"] },
4214
+ vscode: { name: "VS Code", macProcessNames: ["Code", "Code - Insiders", "Cursor", "cursor"] },
4215
+ windows_terminal: { name: "Windows Terminal", macProcessNames: [] },
4216
+ tmux: { name: "tmux", macProcessNames: [] }
4217
+ };
4218
+ var cachedTerminal = undefined;
4219
+ function detectTerminal() {
4220
+ if (cachedTerminal !== undefined)
4221
+ return cachedTerminal;
4222
+ const env = process.env;
4223
+ let result = null;
4224
+ if (env.GHOSTTY_RESOURCES_DIR) {
4225
+ result = TERMINAL_MAP.ghostty;
4226
+ } else if (env.KITTY_PID) {
4227
+ result = TERMINAL_MAP.kitty;
4228
+ } else if (env.ALACRITTY_SOCKET || env.ALACRITTY_LOG) {
4229
+ result = TERMINAL_MAP.alacritty;
4230
+ } else if (env.WEZTERM_EXECUTABLE) {
4231
+ result = TERMINAL_MAP.wezterm;
4232
+ } else if (env.WT_SESSION) {
4233
+ result = TERMINAL_MAP.windows_terminal;
4234
+ } else if (env.VSCODE_PID || env.TERM_PROGRAM === "vscode") {
4235
+ result = TERMINAL_MAP.vscode;
4236
+ } else if (env.TERM_PROGRAM === "Apple_Terminal") {
4237
+ result = TERMINAL_MAP.apple_terminal;
4238
+ } else if (env.TERM_PROGRAM === "iTerm.app") {
4239
+ result = TERMINAL_MAP.iterm;
4240
+ } else if (env.TERM_PROGRAM === "WarpTerminal") {
4241
+ result = TERMINAL_MAP.warp;
4242
+ } else if (env.TMUX || env.TERM_PROGRAM === "tmux") {
4243
+ result = TERMINAL_MAP.tmux;
4244
+ }
4245
+ cachedTerminal = result;
4246
+ return result;
4247
+ }
4248
+ function execWithTimeout(command, timeoutMs = 500) {
4249
+ try {
4250
+ return execSync(command, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
4251
+ } catch {
4252
+ return null;
4253
+ }
4254
+ }
4255
+ function isMacOSFocused(terminal) {
4256
+ const frontApp = execWithTimeout(`osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'`);
4257
+ if (!frontApp)
4258
+ return false;
4259
+ return terminal.macProcessNames.some((name) => name.toLowerCase() === frontApp.toLowerCase());
4260
+ }
4261
+ function isPidAncestorOfCurrentProcess(pid) {
4262
+ let currentPid = process.pid;
4263
+ for (let depth = 0;depth < 20; depth++) {
4264
+ if (currentPid === pid)
4265
+ return true;
4266
+ if (currentPid <= 1)
4267
+ return false;
4268
+ try {
4269
+ const stat = readFileSync2(`/proc/${currentPid}/stat`, "utf-8");
4270
+ const match = stat.match(/^\d+\s+\(.*?\)\s+\S+\s+(\d+)/);
4271
+ if (!match)
4272
+ return false;
4273
+ currentPid = parseInt(match[1], 10);
4274
+ } catch {
4275
+ return false;
4276
+ }
4277
+ }
4278
+ return false;
4279
+ }
4280
+ function isLinuxX11Focused() {
4281
+ const pidStr = execWithTimeout("xdotool getactivewindow getwindowpid");
4282
+ if (!pidStr)
4283
+ return false;
4284
+ const pid = parseInt(pidStr, 10);
4285
+ if (!Number.isFinite(pid) || pid <= 0)
4286
+ return false;
4287
+ return isPidAncestorOfCurrentProcess(pid);
4288
+ }
4289
+ function isHyprlandFocused() {
4290
+ const output = execWithTimeout("hyprctl activewindow -j");
4291
+ if (!output)
4292
+ return false;
4293
+ try {
4294
+ const data = JSON.parse(output);
4295
+ const pid = data?.pid;
4296
+ if (typeof pid !== "number" || pid <= 0)
4297
+ return false;
4298
+ return isPidAncestorOfCurrentProcess(pid);
4299
+ } catch {
4300
+ return false;
4301
+ }
4302
+ }
4303
+ function isSwayFocused() {
4304
+ const output = execWithTimeout("swaymsg -t get_tree", 1000);
4305
+ if (!output)
4306
+ return false;
4307
+ try {
4308
+ const tree = JSON.parse(output);
4309
+ const pid = findFocusedPid(tree);
4310
+ if (pid === null)
4311
+ return false;
4312
+ return isPidAncestorOfCurrentProcess(pid);
4313
+ } catch {
4314
+ return false;
4315
+ }
4316
+ }
4317
+ function findFocusedPid(node) {
4318
+ if (node.focused === true && typeof node.pid === "number") {
4319
+ return node.pid;
4320
+ }
4321
+ if (Array.isArray(node.nodes)) {
4322
+ for (const child of node.nodes) {
4323
+ const pid = findFocusedPid(child);
4324
+ if (pid !== null)
4325
+ return pid;
4326
+ }
4327
+ }
4328
+ if (Array.isArray(node.floating_nodes)) {
4329
+ for (const child of node.floating_nodes) {
4330
+ const pid = findFocusedPid(child);
4331
+ if (pid !== null)
4332
+ return pid;
4333
+ }
4334
+ }
4335
+ return null;
4336
+ }
4337
+ function isKDEWaylandFocused() {
4338
+ const windowId = execWithTimeout("kdotool getactivewindow");
4339
+ if (!windowId)
4340
+ return false;
4341
+ const pidStr = execWithTimeout(`kdotool getwindowpid ${windowId}`);
4342
+ if (!pidStr)
4343
+ return false;
4344
+ const pid = parseInt(pidStr, 10);
4345
+ if (!Number.isFinite(pid) || pid <= 0)
4346
+ return false;
4347
+ return isPidAncestorOfCurrentProcess(pid);
4348
+ }
4349
+ function isLinuxWaylandFocused() {
4350
+ const env = process.env;
4351
+ if (env.HYPRLAND_INSTANCE_SIGNATURE) {
4352
+ return isHyprlandFocused();
4353
+ }
4354
+ if (env.SWAYSOCK) {
4355
+ return isSwayFocused();
4356
+ }
4357
+ if (env.KDE_SESSION_VERSION) {
4358
+ return isKDEWaylandFocused();
4359
+ }
4360
+ return false;
4361
+ }
4362
+ function isWindowsFocused() {
4363
+ const script = `
4364
+ Add-Type @"
4365
+ using System;
4366
+ using System.Runtime.InteropServices;
4367
+ public class FocusHelper {
4368
+ [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
4369
+ [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
4370
+ }
4371
+ "@
4372
+ $hwnd = [FocusHelper]::GetForegroundWindow()
4373
+ $pid = 0
4374
+ [void][FocusHelper]::GetWindowThreadProcessId($hwnd, [ref]$pid)
4375
+ Write-Output $pid
4376
+ `.trim().replace(/\n/g, "; ");
4377
+ const pidStr = execWithTimeout(`powershell -NoProfile -Command "${script}"`, 1000);
4378
+ if (!pidStr)
4379
+ return false;
4380
+ const pid = parseInt(pidStr, 10);
4381
+ if (!Number.isFinite(pid) || pid <= 0)
4382
+ return false;
4383
+ let currentPid = process.pid;
4384
+ for (let depth = 0;depth < 20; depth++) {
4385
+ if (currentPid === pid)
4386
+ return true;
4387
+ if (currentPid <= 1)
4388
+ return false;
4389
+ const parentPidStr = execWithTimeout(`powershell -NoProfile -Command "(Get-Process -Id ${currentPid}).Parent.Id"`, 500);
4390
+ if (!parentPidStr)
4391
+ return false;
4392
+ currentPid = parseInt(parentPidStr, 10);
4393
+ if (!Number.isFinite(currentPid))
4394
+ return false;
4395
+ }
4396
+ return false;
4397
+ }
4398
+ function isTerminalFocused() {
4399
+ try {
4400
+ const platform3 = process.platform;
4401
+ if (platform3 === "darwin") {
4402
+ const terminal = detectTerminal();
4403
+ if (!terminal || terminal.macProcessNames.length === 0)
4404
+ return false;
4405
+ return isMacOSFocused(terminal);
4406
+ }
4407
+ if (platform3 === "linux") {
4408
+ if (process.env.WAYLAND_DISPLAY) {
4409
+ return isLinuxWaylandFocused();
4410
+ }
4411
+ if (process.env.DISPLAY) {
4412
+ return isLinuxX11Focused();
4413
+ }
4414
+ return false;
4415
+ }
4416
+ if (platform3 === "win32") {
4417
+ return isWindowsFocused();
4418
+ }
4419
+ return false;
4420
+ } catch {
4421
+ return false;
4422
+ }
4423
+ }
4424
+
4201
4425
  // src/index.ts
4202
4426
  var IDLE_COMPLETE_DELAY_MS = 350;
4203
4427
  var pendingIdleTimers = new Map;
@@ -4229,6 +4453,9 @@ function getNotificationTitle(config, projectName) {
4229
4453
  return "OpenCode";
4230
4454
  }
4231
4455
  async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle) {
4456
+ if (config.suppressWhenFocused && isTerminalFocused()) {
4457
+ return;
4458
+ }
4232
4459
  const promises = [];
4233
4460
  const rawMessage = getMessage(config, eventType);
4234
4461
  const message = interpolateMessage(rawMessage, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.29-beta.0",
3
+ "version": "0.1.30-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",