@serkanalgur/opencodev2-notification 1.0.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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -0
  3. package/package.json +49 -0
  4. package/src/index.ts +701 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 serkanalgur
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,160 @@
1
+ # @serkanalgur/opencodev2-notification
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@serkanalgur/opencodev2-notification.svg)](https://www.npmjs.com/package/@serkanalgur/opencodev2-notification)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![OpenCode Plugin](https://img.shields.io/badge/OpenCode-Plugin-blue.svg)](https://opencode.ai)
6
+ [![GitHub stars](https://img.shields.io/github/stars/serkanalgur/opencodev2-notification)](https://github.com/serkanalgur/opencodev2-notification/stargazers)
7
+ [![GitHub issues](https://img.shields.io/github/issues/serkanalgur/opencodev2-notification)](https://github.com/serkanalgur/opencodev2-notification/issues)
8
+
9
+ > Native OS notifications for OpenCode V2
10
+
11
+ A plugin for [OpenCode V2](https://opencode.ai) that delivers native OS notifications when tasks complete, errors occur, or the AI needs your input.
12
+
13
+ ## Why This Exists
14
+
15
+ You delegate a task and switch to another window. Now you're checking back every 30 seconds. Did it finish? Did it error? Is it waiting for permission?
16
+
17
+ This plugin solves that:
18
+
19
+ - **Stay focused** - Work in other apps. A notification arrives when the AI needs you.
20
+ - **Zero dependencies** - Uses only built-in OS APIs (osascript, PowerShell, notify-send)
21
+ - **Native OS notifications** - macOS Notification Center, Windows Toast, Linux Desktop Notifications
22
+ - **Smart defaults** - Won't spam you. Only notifies for meaningful events with parent-session filtering and quiet-hours support.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install @serkanalgur/opencodev2-notification
28
+ ```
29
+
30
+ ### Via opencode.json
31
+
32
+ Add to your `opencode.json` or `opencode.jsonc`:
33
+
34
+ ```jsonc
35
+ {
36
+ "plugins": ["@serkanalgur/opencodev2-notification"]
37
+ }
38
+ ```
39
+
40
+ ### As a local plugin
41
+
42
+ Or copy the plugin files to your `.opencode/plugins/` directory:
43
+
44
+ ```
45
+ .opencode/plugins/notification/index.ts
46
+ ```
47
+
48
+ ## How It Works
49
+
50
+ > "Notify the human when the AI needs them back, not for every micro-event."
51
+
52
+ | Event | Notifies? | Sound | Why |
53
+ |-------|-----------|-------|-----|
54
+ | Session complete | Yes | Glass | Main task done - time to review |
55
+ | Session error | Yes | Basso | Something broke - needs attention |
56
+ | Permission needed | Yes | Submarine | AI is blocked, waiting for you |
57
+ | Question asked | Yes | Submarine (default) | Questions should always reach you promptly |
58
+ | Sub-task complete/error | No (default) | - | Set `notifyChildSessions: true` to include child sessions |
59
+
60
+ The plugin automatically:
61
+
62
+ 1. Detects your terminal emulator
63
+ 2. Suppresses notifications when your terminal is focused on macOS
64
+ 3. Enables click-to-focus on macOS (click notification → terminal foregrounds)
65
+
66
+ Question notifications bypass macOS focus suppression so direct prompts are not missed.
67
+
68
+ ## Zero Dependencies - Pure Native!
69
+
70
+ This plugin uses **built-in OS APIs** only. No external packages to install!
71
+
72
+ | Platform | Method | Requirements |
73
+ |----------|--------|--------------|
74
+ | **macOS** | `osascript` (AppleScript) | Built into macOS since 10.0 |
75
+ | **Windows** | PowerShell Toast/BalloonTip | Built into Windows 7+ |
76
+ | **Linux** | `notify-send` / `dbus-send` | Pre-installed on most desktop distros |
77
+
78
+ ### How It Works
79
+
80
+ - **macOS:** Uses `osascript -e 'display notification ...'` - native Notification Center
81
+ - **Windows:** Uses PowerShell with .NET Toast notifications (or BalloonTip fallback)
82
+ - **Linux:** Uses `notify-send` (or `dbus-send` as fallback for minimal systems)
83
+
84
+ ## Platform Support
85
+
86
+ | Feature | macOS | Windows | Linux |
87
+ |---------|-------|---------|-------|
88
+ | Native OS notifications | Yes | Yes | Yes |
89
+ | Custom sounds | Yes | No | No |
90
+ | Focus detection | Yes | No | No |
91
+ | Click-to-focus | Yes | No | No |
92
+
93
+ ## Configuration (Optional)
94
+
95
+ Works out of the box. To customize, create `~/.config/opencode/opencodev2-notification.json`:
96
+
97
+ ```json
98
+ {
99
+ "notifyChildSessions": false,
100
+ "timeout": 0,
101
+ "terminal": "ghostty",
102
+ "sounds": {
103
+ "idle": "Glass",
104
+ "error": "Basso",
105
+ "permission": "Submarine",
106
+ "question": "Submarine"
107
+ },
108
+ "quietHours": {
109
+ "enabled": false,
110
+ "start": "22:00",
111
+ "end": "08:00"
112
+ }
113
+ }
114
+ ```
115
+
116
+ ### Configuration Keys
117
+
118
+ | Key | Type | Default | Description |
119
+ |-----|------|---------|-------------|
120
+ | `notifyChildSessions` | boolean | `false` | Include child/sub-session notifications |
121
+ | `timeout` | number | `0` | Seconds before notification disappears (0 = no timeout) |
122
+ | `terminal` | string | auto-detect | Override terminal auto-detection |
123
+ | `sounds.idle` | string | `"Glass"` | Sound for session complete |
124
+ | `sounds.error` | string | `"Basso"` | Sound for errors |
125
+ | `sounds.permission` | string | `"Submarine"` | Sound for permission requests |
126
+ | `sounds.question` | string | `"Submarine"` | Sound for questions |
127
+ | `quietHours.enabled` | boolean | `false` | Enable quiet hours |
128
+ | `quietHours.start` | string | `"22:00"` | Quiet hours start (HH:MM) |
129
+ | `quietHours.end` | string | `"08:00"` | Quiet hours end (HH:MM) |
130
+
131
+ ### Available macOS Sounds
132
+
133
+ Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
134
+
135
+ ## FAQ
136
+
137
+ ### Does this add bloat to my context?
138
+
139
+ Minimal footprint. The plugin is event-driven - it listens for session events and fires notifications. No tools are added to your conversation.
140
+
141
+ ### Will I get spammed with notifications?
142
+
143
+ No. Smart defaults prevent noise:
144
+
145
+ - Only notifies for parent sessions (not every sub-task)
146
+ - Supports quiet-hours suppression
147
+ - Suppresses when your terminal is the active window on macOS
148
+ - Deduplication prevents rapid-fire notifications
149
+
150
+ ### Can I disable it temporarily?
151
+
152
+ Remove the plugin from your `opencode.json` or delete the plugin files.
153
+
154
+ ## Credits
155
+
156
+ Inspired by [opencode-notify](https://github.com/kdcokenny/opencode-notify) by [kdcokenny](https://github.com/kdcokenny).
157
+
158
+ ## License
159
+
160
+ MIT
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@serkanalgur/opencodev2-notification",
3
+ "version": "1.0.0",
4
+ "description": "Native OS notifications for OpenCode V2 - know when tasks complete, errors occur, or the AI needs your input",
5
+ "keywords": [
6
+ "opencode",
7
+ "opencode-plugin",
8
+ "notifications",
9
+ "macos",
10
+ "windows",
11
+ "linux",
12
+ "typescript"
13
+ ],
14
+ "homepage": "https://github.com/serkanalgur/opencodev2-notification#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/serkanalgur/opencodev2-notification/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/serkanalgur/opencodev2-notification.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "serkanalgur",
24
+ "type": "module",
25
+ "exports": {
26
+ ".": "./src/index.ts"
27
+ },
28
+ "main": "src/index.ts",
29
+ "files": [
30
+ "src/",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "scripts": {
35
+ "typecheck": "tsc --noEmit",
36
+ "test": "bun run test-notification.ts",
37
+ "format": "prettier --write .",
38
+ "format:check": "prettier --check ."
39
+ },
40
+ "devDependencies": {
41
+ "@opencode/plugin": "^2.0.0",
42
+ "@types/bun": "latest",
43
+ "prettier": "^3.4.0",
44
+ "typescript": "^5.7.0"
45
+ },
46
+ "peerDependencies": {
47
+ "@opencode/plugin": ">=2.0.0"
48
+ }
49
+ }
package/src/index.ts ADDED
@@ -0,0 +1,701 @@
1
+ /**
2
+ * opencodev2-notification
3
+ * Native OS notifications for OpenCode V2
4
+ *
5
+ * Philosophy: "Notify the human when the AI needs them back, not for every micro-event."
6
+ *
7
+ * Features:
8
+ * - Native OS notifications on macOS, Windows, and Linux
9
+ * - Auto-detects terminal emulator for click-to-focus (macOS)
10
+ * - Suppresses notifications when terminal is focused (macOS)
11
+ * - Parent session only by default (no spam from sub-tasks)
12
+ * - Quiet hours support
13
+ * - Configurable sounds per event type
14
+ *
15
+ * Notification paths:
16
+ * - macOS: alerter (native Notification Center)
17
+ * - Windows: node-notifier (toast notifications)
18
+ * - Linux: node-notifier (notify-send)
19
+ */
20
+
21
+ import * as fs from "node:fs/promises"
22
+ import * as os from "node:os"
23
+ import * as path from "node:path"
24
+ import { Plugin } from "@opencode/plugin"
25
+ import type { Event } from "@opencode/plugin"
26
+
27
+ // ==========================================
28
+ // TYPES
29
+ // ==========================================
30
+
31
+ interface NotifyConfig {
32
+ /** Notify for child/sub-session events (default: false) */
33
+ notifyChildSessions: boolean
34
+ /** Seconds before a desktop notification disappears (default: 0, no timeout) */
35
+ timeout: number
36
+ /** Sound configuration per event type */
37
+ sounds: {
38
+ idle: string
39
+ error: string
40
+ permission: string
41
+ question?: string
42
+ }
43
+ /** Quiet hours configuration */
44
+ quietHours: {
45
+ enabled: boolean
46
+ start: string // "HH:MM" format
47
+ end: string // "HH:MM" format
48
+ }
49
+ /** Override terminal detection (optional) */
50
+ terminal?: string
51
+ }
52
+
53
+ interface TerminalInfo {
54
+ name: string | null
55
+ bundleId: string | null
56
+ processName: string | null
57
+ }
58
+
59
+ // ==========================================
60
+ // DEFAULT CONFIGURATION
61
+ // ==========================================
62
+
63
+ const DEFAULT_CONFIG: NotifyConfig = {
64
+ notifyChildSessions: false,
65
+ timeout: 0,
66
+ sounds: {
67
+ idle: "Glass",
68
+ error: "Basso",
69
+ permission: "Submarine",
70
+ },
71
+ quietHours: {
72
+ enabled: false,
73
+ start: "22:00",
74
+ end: "08:00",
75
+ },
76
+ }
77
+
78
+ // Terminal name to macOS process name mapping (for focus detection)
79
+ const TERMINAL_PROCESS_NAMES: Record<string, string> = {
80
+ ghostty: "Ghostty",
81
+ kitty: "kitty",
82
+ iterm: "iTerm2",
83
+ iterm2: "iTerm2",
84
+ wezterm: "WezTerm",
85
+ alacritty: "Alacritty",
86
+ terminal: "Terminal",
87
+ apple_terminal: "Terminal",
88
+ hyper: "Hyper",
89
+ warp: "Warp",
90
+ vscode: "Code",
91
+ "vscode-insiders": "Code - Insiders",
92
+ }
93
+
94
+ // ==========================================
95
+ // CONFIGURATION LOADING
96
+ // ==========================================
97
+
98
+ async function loadConfig(): Promise<NotifyConfig> {
99
+ const configPath = path.join(
100
+ os.homedir(),
101
+ ".config",
102
+ "opencode",
103
+ "opencodev2-notification.json"
104
+ )
105
+
106
+ try {
107
+ const content = await fs.readFile(configPath, "utf8")
108
+ const userConfig = JSON.parse(content) as Partial<NotifyConfig>
109
+
110
+ // Validate timeout
111
+ const configuredTimeout = userConfig.timeout
112
+ let timeout = DEFAULT_CONFIG.timeout
113
+ if (
114
+ typeof configuredTimeout === "number" &&
115
+ Number.isFinite(configuredTimeout) &&
116
+ configuredTimeout >= 0
117
+ ) {
118
+ timeout = configuredTimeout
119
+ }
120
+
121
+ // Merge with defaults
122
+ return {
123
+ ...DEFAULT_CONFIG,
124
+ ...userConfig,
125
+ timeout,
126
+ sounds: {
127
+ ...DEFAULT_CONFIG.sounds,
128
+ ...userConfig.sounds,
129
+ },
130
+ quietHours: {
131
+ ...DEFAULT_CONFIG.quietHours,
132
+ ...userConfig.quietHours,
133
+ },
134
+ }
135
+ } catch {
136
+ // Config doesn't exist or is invalid, use defaults
137
+ return DEFAULT_CONFIG
138
+ }
139
+ }
140
+
141
+ // ==========================================
142
+ // TERMINAL DETECTION (macOS)
143
+ // ==========================================
144
+
145
+ async function runOsascript(script: string): Promise<string | null> {
146
+ if (process.platform !== "darwin") return null
147
+
148
+ try {
149
+ const proc = Bun.spawn(["osascript", "-e", script], {
150
+ stdout: "pipe",
151
+ stderr: "pipe",
152
+ })
153
+ const output = await new Response(proc.stdout).text()
154
+ return output.trim()
155
+ } catch {
156
+ return null
157
+ }
158
+ }
159
+
160
+ async function getBundleId(appName: string): Promise<string | null> {
161
+ return runOsascript(`id of application "${appName}"`)
162
+ }
163
+
164
+ async function getFrontmostApp(): Promise<string | null> {
165
+ return runOsascript(
166
+ 'tell application "System Events" to get name of first application process whose frontmost is true'
167
+ )
168
+ }
169
+
170
+ /**
171
+ * Detect terminal using environment variables (no external packages!)
172
+ * Most terminals set these variables automatically
173
+ */
174
+ async function detectTerminalInfo(
175
+ config: NotifyConfig
176
+ ): Promise<TerminalInfo> {
177
+ // Try to detect terminal using built-in environment variables
178
+ // These are set by most terminals automatically
179
+ const terminalName =
180
+ config.terminal || // User override
181
+ process.env.TERM_PROGRAM?.toLowerCase() || // iTerm2, Apple_Terminal, etc.
182
+ process.env.TERM?.toLowerCase() || // Generic terminal type
183
+ process.env.COLORTERM?.toLowerCase() || // Some terminals set this
184
+ null
185
+
186
+ if (!terminalName) {
187
+ return { name: null, bundleId: null, processName: null }
188
+ }
189
+
190
+ // Map common terminal names to process names for focus detection
191
+ const processName =
192
+ TERMINAL_PROCESS_NAMES[terminalName.toLowerCase()] || terminalName
193
+
194
+ // On macOS, get bundle ID dynamically
195
+ const bundleId = process.platform === "darwin" ? await getBundleId(processName) : null
196
+
197
+ return {
198
+ name: terminalName,
199
+ bundleId,
200
+ processName,
201
+ }
202
+ }
203
+
204
+ async function isTerminalFocused(terminalInfo: TerminalInfo): Promise<boolean> {
205
+ if (!terminalInfo.processName) return false
206
+ if (process.platform !== "darwin") return false
207
+
208
+ const frontmost = await getFrontmostApp()
209
+ if (!frontmost) return false
210
+
211
+ // Case-insensitive comparison
212
+ return frontmost.toLowerCase() === terminalInfo.processName.toLowerCase()
213
+ }
214
+
215
+ // ==========================================
216
+ // QUIET HOURS CHECK
217
+ // ==========================================
218
+
219
+ function isQuietHours(config: NotifyConfig): boolean {
220
+ if (!config.quietHours.enabled) return false
221
+
222
+ const now = new Date()
223
+ const currentMinutes = now.getHours() * 60 + now.getMinutes()
224
+
225
+ const [startHour, startMin] = config.quietHours.start.split(":").map(Number)
226
+ const [endHour, endMin] = config.quietHours.end.split(":").map(Number)
227
+
228
+ const startMinutes = startHour * 60 + startMin
229
+ const endMinutes = endHour * 60 + endMin
230
+
231
+ // Handle overnight quiet hours (e.g., 22:00 - 08:00)
232
+ if (startMinutes > endMinutes) {
233
+ return currentMinutes >= startMinutes || currentMinutes < endMinutes
234
+ }
235
+
236
+ return currentMinutes >= startMinutes && currentMinutes < endMinutes
237
+ }
238
+
239
+ // ==========================================
240
+ // NOTIFICATION BACKENDS (Native - No Dependencies!)
241
+ // ==========================================
242
+
243
+ /**
244
+ * macOS: Use built-in osascript (AppleScript) for notifications
245
+ * No external packages needed - works on any macOS installation
246
+ */
247
+ async function sendMacOSNotification(
248
+ title: string,
249
+ message: string,
250
+ subtitle: string | undefined,
251
+ sound: string,
252
+ timeout: number
253
+ ): Promise<void> {
254
+ try {
255
+ // Build AppleScript for native macOS notification
256
+ // display notification is built into macOS since 10.0
257
+ let script = `display notification "${message.replace(/"/g, '\\"')}" with title "${title.replace(/"/g, '\\"')}"`
258
+
259
+ if (subtitle) {
260
+ script += ` subtitle "${subtitle.replace(/"/g, '\\"')}"`
261
+ }
262
+
263
+ if (sound) {
264
+ script += ` sound name "${sound}"`
265
+ }
266
+
267
+ const proc = Bun.spawn(["osascript", "-e", script], {
268
+ stdout: "ignore",
269
+ stderr: "pipe",
270
+ })
271
+
272
+ // Don't block on notification
273
+ void proc.exited.then((exitCode) => {
274
+ if (exitCode !== 0) {
275
+ console.warn(`opencodev2-notification: osascript exited with code ${exitCode}`)
276
+ }
277
+ })
278
+ } catch (error) {
279
+ const msg = error instanceof Error ? error.message : String(error)
280
+ console.warn(`opencodev2-notification: macOS notification failed (${msg})`)
281
+ }
282
+ }
283
+
284
+ /**
285
+ * Windows: Use built-in PowerShell for toast notifications
286
+ * No external packages needed - PowerShell is built into Windows 7+
287
+ */
288
+ async function sendWindowsNotification(
289
+ title: string,
290
+ message: string,
291
+ sound: string
292
+ ): Promise<void> {
293
+ try {
294
+ // PowerShell with BurntToast module or fallback to basic toast
295
+ // Using .NET NotifyIcon as ultimate fallback (always available)
296
+ const psScript = `
297
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
298
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType = WindowsRuntime] | Out-Null
299
+
300
+ $template = @"
301
+ <toast>
302
+ <visual>
303
+ <binding template="ToastGeneric">
304
+ <text>${title.replace(/"/g, '""')}</text>
305
+ <text>${message.replace(/"/g, '""')}</text>
306
+ </binding>
307
+ </visual>
308
+ <audio src="ms-winsoundevent:Notification.Default"/>
309
+ </toast>
310
+ "@
311
+
312
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
313
+ $xml.LoadXml($template)
314
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
315
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("OpenCode").Show($toast)
316
+ `
317
+
318
+ const proc = Bun.spawn(
319
+ ["powershell", "-NoProfile", "-NonInteractive", "-Command", psScript],
320
+ { stdout: "ignore", stderr: "pipe" }
321
+ )
322
+
323
+ void proc.exited.then((exitCode) => {
324
+ if (exitCode !== 0) {
325
+ // Fallback to BalloonTip if toast fails (older Windows)
326
+ sendWindowsBalloonFallback(title, message)
327
+ }
328
+ })
329
+ } catch {
330
+ // Ultimate fallback: BalloonTip (works on all Windows versions)
331
+ sendWindowsBalloonFallback(title, message)
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Windows fallback: Use .NET NotifyIcon BalloonTip (works on all Windows)
337
+ */
338
+ async function sendWindowsBalloonFallback(title: string, message: string): Promise<void> {
339
+ try {
340
+ const psScript = `
341
+ Add-Type -AssemblyName System.Windows.Forms
342
+ $notify = New-Object System.Windows.Forms.NotifyIcon
343
+ $notify.Icon = [System.Drawing.SystemIcons]::Information
344
+ $notify.BalloonTipTitle = "${title.replace(/"/g, '""')}"
345
+ $notify.BalloonTipText = "${message.replace(/"/g, '""')}"
346
+ $notify.BalloonTipIcon = 'Info'
347
+ $notify.Visible = $true
348
+ $notify.ShowBalloonTip(5000)
349
+ Start-Sleep -Seconds 6
350
+ $notify.Dispose()
351
+ `
352
+ await Bun.spawn(["powershell", "-NoProfile", "-NonInteractive", "-Command", psScript], {
353
+ stdout: "ignore",
354
+ stderr: "ignore",
355
+ })
356
+ } catch {
357
+ // Silent fail
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Linux: Use built-in notify-send or dbus-send
363
+ * notify-send is pre-installed on most desktop Linux distributions
364
+ * dbus-send is a fallback that works on any D-Bus enabled system
365
+ */
366
+ async function sendLinuxNotification(
367
+ title: string,
368
+ message: string,
369
+ sound: string
370
+ ): Promise<void> {
371
+ // Try notify-send first (most common)
372
+ const notifySendPath = Bun.which("notify-send")
373
+
374
+ if (notifySendPath) {
375
+ try {
376
+ const proc = Bun.spawn(
377
+ [notifySendPath, "-a", "OpenCode", "-u", "normal", title, message],
378
+ { stdout: "ignore", stderr: "pipe" }
379
+ )
380
+
381
+ void proc.exited.then((exitCode) => {
382
+ if (exitCode !== 0) {
383
+ // Fallback to dbus-send
384
+ sendLinuxDBusFallback(title, message)
385
+ }
386
+ })
387
+ return
388
+ } catch {
389
+ // Fall through to dbus-send
390
+ }
391
+ }
392
+
393
+ // Fallback: dbus-send (works on any D-Bus system)
394
+ await sendLinuxDBusFallback(title, message)
395
+ }
396
+
397
+ /**
398
+ * Linux fallback: Use dbus-send to call notification service directly
399
+ */
400
+ async function sendLinuxDBusFallback(title: string, message: string): Promise<void> {
401
+ try {
402
+ const proc = Bun.spawn(
403
+ [
404
+ "dbus-send",
405
+ "--session",
406
+ "--type=method_call",
407
+ "--dest=org.freedesktop.Notifications",
408
+ "/org/freedesktop/Notifications",
409
+ "org.freedesktop.Notifications.Notify",
410
+ "string:opencode", // app_name
411
+ "uint32:0", // replaces_id
412
+ "string:", // app_icon
413
+ "string:" + title, // summary
414
+ "string:" + message, // body
415
+ "array:string:", // actions
416
+ "dict:string:variant:", // hints
417
+ "int32:5000", // expire_timeout
418
+ ],
419
+ { stdout: "ignore", stderr: "ignore" }
420
+ )
421
+
422
+ void proc.exited // Just fire and forget
423
+ } catch {
424
+ // Silent fail - notification best effort
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Main notification dispatcher - uses native OS APIs only
430
+ */
431
+ async function sendNotification(
432
+ title: string,
433
+ message: string,
434
+ subtitle: string | undefined,
435
+ sound: string,
436
+ terminalInfo: TerminalInfo,
437
+ timeout: number
438
+ ): Promise<void> {
439
+ switch (process.platform) {
440
+ case "darwin":
441
+ await sendMacOSNotification(title, message, subtitle, sound, timeout)
442
+ break
443
+ case "win32":
444
+ await sendWindowsNotification(title, message, sound)
445
+ break
446
+ case "linux":
447
+ await sendLinuxNotification(title, message, sound)
448
+ break
449
+ default:
450
+ console.warn(`opencodev2-notification: unsupported platform ${process.platform}`)
451
+ }
452
+ }
453
+
454
+ // ==========================================
455
+ // DEDUPLICATION
456
+ // ==========================================
457
+
458
+ type RecentNotifications = Map<string, number>
459
+
460
+ const QUESTION_DEDUPE_WINDOW_MS = 1500
461
+ const READY_DEDUPE_WINDOW_MS = 1500
462
+ const PERMISSION_DEDUPE_WINDOW_MS = 1500
463
+
464
+ function shouldSendDedupedNotification(
465
+ recentNotifications: RecentNotifications,
466
+ dedupeKey: string,
467
+ windowMs: number,
468
+ nowMs = Date.now()
469
+ ): boolean {
470
+ // Prune old entries
471
+ for (const [key, timestamp] of recentNotifications) {
472
+ if (nowMs - timestamp >= windowMs) {
473
+ recentNotifications.delete(key)
474
+ }
475
+ }
476
+
477
+ const lastSentAt = recentNotifications.get(dedupeKey)
478
+ if (lastSentAt !== undefined && nowMs - lastSentAt < windowMs) {
479
+ return false
480
+ }
481
+
482
+ recentNotifications.set(dedupeKey, nowMs)
483
+ return true
484
+ }
485
+
486
+ function toNonEmptyString(value: unknown): string | null {
487
+ if (typeof value !== "string") return null
488
+ const normalized = value.trim()
489
+ if (!normalized) return null
490
+ return normalized
491
+ }
492
+
493
+ // ==========================================
494
+ // PLUGIN EXPORT
495
+ // ==========================================
496
+
497
+ export default Plugin.define({
498
+ id: "opencodev2-notification",
499
+ async setup(ctx) {
500
+ // Load config at startup
501
+ const config = await loadConfig()
502
+
503
+ // Detect terminal at startup (cached for performance)
504
+ const terminalInfo = await detectTerminalInfo(config)
505
+
506
+ // Deduplication maps
507
+ const recentQuestionNotifications: RecentNotifications = new Map()
508
+ const recentReadyNotifications: RecentNotifications = new Map()
509
+ const recentPermissionNotifications: RecentNotifications = new Map()
510
+
511
+ // Helper: get session info
512
+ const getSessionTitle = async (sessionID: string): Promise<string> => {
513
+ try {
514
+ const session = await ctx.session.get({ sessionID })
515
+ if (session?.title) {
516
+ return session.title.slice(0, 50)
517
+ }
518
+ } catch {
519
+ // Use default
520
+ }
521
+ return "Task"
522
+ }
523
+
524
+ // Helper: check if parent session
525
+ const isParentSession = async (sessionID: string): Promise<boolean> => {
526
+ try {
527
+ const session = await ctx.session.get({ sessionID })
528
+ // In V2, check if session has a parent
529
+ return !(session as any)?.parentID
530
+ } catch {
531
+ // If we can't fetch, assume it's a parent to be safe
532
+ return true
533
+ }
534
+ }
535
+
536
+ // Subscribe to events
537
+ const controller = new AbortController()
538
+ void (async () => {
539
+ for await (const event of ctx.event.subscribe({
540
+ signal: controller.signal,
541
+ })) {
542
+ try {
543
+ await handleEvent(event)
544
+ } catch (error) {
545
+ console.error("opencodev2-notification: event handler error:", error)
546
+ }
547
+ }
548
+ })()
549
+
550
+ // Event handler
551
+ async function handleEvent(event: any): Promise<void> {
552
+ const eventType = event.type
553
+ const properties = event.properties || {}
554
+
555
+ switch (eventType) {
556
+ case "session.idle": {
557
+ const sessionID = toNonEmptyString(properties.sessionID)
558
+ if (!sessionID) break
559
+
560
+ // Check parent session
561
+ if (!config.notifyChildSessions) {
562
+ const isParent = await isParentSession(sessionID)
563
+ if (!isParent) break
564
+ }
565
+
566
+ // Check quiet hours
567
+ if (isQuietHours(config)) break
568
+
569
+ // Check terminal focus
570
+ if (await isTerminalFocused(terminalInfo)) break
571
+
572
+ // Deduplication
573
+ const dedupeKey = `session-ready:${sessionID}`
574
+ if (
575
+ !shouldSendDedupedNotification(
576
+ recentReadyNotifications,
577
+ dedupeKey,
578
+ READY_DEDUPE_WINDOW_MS
579
+ )
580
+ ) {
581
+ break
582
+ }
583
+
584
+ const sessionTitle = await getSessionTitle(sessionID)
585
+ await sendNotification(
586
+ "Ready for review",
587
+ sessionTitle,
588
+ sessionTitle,
589
+ config.sounds.idle,
590
+ terminalInfo,
591
+ config.timeout
592
+ )
593
+ break
594
+ }
595
+
596
+ case "session.error": {
597
+ const sessionID = toNonEmptyString(properties.sessionID)
598
+ if (!sessionID) break
599
+
600
+ // Check parent session
601
+ if (!config.notifyChildSessions) {
602
+ const isParent = await isParentSession(sessionID)
603
+ if (!isParent) break
604
+ }
605
+
606
+ // Check quiet hours
607
+ if (isQuietHours(config)) break
608
+
609
+ // Check terminal focus
610
+ if (await isTerminalFocused(terminalInfo)) break
611
+
612
+ const error = properties.error
613
+ const errorMessage =
614
+ typeof error === "string"
615
+ ? error.slice(0, 100)
616
+ : error
617
+ ? String(error).slice(0, 100)
618
+ : "Something went wrong"
619
+
620
+ await sendNotification(
621
+ "Something went wrong",
622
+ errorMessage,
623
+ undefined,
624
+ config.sounds.error,
625
+ terminalInfo,
626
+ config.timeout
627
+ )
628
+ break
629
+ }
630
+
631
+ case "permission.updated":
632
+ case "permission.asked": {
633
+ // Check quiet hours
634
+ if (isQuietHours(config)) break
635
+
636
+ // Check terminal focus
637
+ if (await isTerminalFocused(terminalInfo)) break
638
+
639
+ // Deduplication
640
+ const permissionKey = toNonEmptyString(properties.id)
641
+ ? `permission:request:${properties.id}`
642
+ : `permission:${Date.now()}`
643
+ if (
644
+ !shouldSendDedupedNotification(
645
+ recentPermissionNotifications,
646
+ permissionKey,
647
+ PERMISSION_DEDUPE_WINDOW_MS
648
+ )
649
+ ) {
650
+ break
651
+ }
652
+
653
+ await sendNotification(
654
+ "Waiting for you",
655
+ "OpenCode needs your input",
656
+ undefined,
657
+ config.sounds.permission,
658
+ terminalInfo,
659
+ config.timeout
660
+ )
661
+ break
662
+ }
663
+
664
+ case "question.asked": {
665
+ // Check quiet hours
666
+ if (isQuietHours(config)) break
667
+
668
+ // Deduplication
669
+ const questionKey = toNonEmptyString(properties.id)
670
+ ? `question:request:${properties.id}`
671
+ : `question:${Date.now()}`
672
+ if (
673
+ !shouldSendDedupedNotification(
674
+ recentQuestionNotifications,
675
+ questionKey,
676
+ QUESTION_DEDUPE_WINDOW_MS
677
+ )
678
+ ) {
679
+ break
680
+ }
681
+
682
+ const sound = config.sounds.question ?? config.sounds.permission
683
+ await sendNotification(
684
+ "Question for you",
685
+ "OpenCode needs your input",
686
+ undefined,
687
+ sound,
688
+ terminalInfo,
689
+ config.timeout
690
+ )
691
+ break
692
+ }
693
+ }
694
+ }
695
+
696
+ // Return cleanup function
697
+ return () => {
698
+ controller.abort()
699
+ }
700
+ },
701
+ })