@serkanalgur/opencodev2-notification 1.0.1 → 1.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 +24 -50
  2. package/package.json +3 -2
  3. package/src/index.ts +144 -514
package/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@serkanalgur/opencodev2-notification.svg)](https://www.npmjs.com/package/@serkanalgur/opencodev2-notification)
4
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)
5
+ [![OpenCode Plugin](https://img.shields.io/badge/OpenCode-V2%20Plugin-blue.svg)](https://opencode.ai)
6
6
  [![GitHub stars](https://img.shields.io/github/stars/serkanalgur/opencodev2-notification)](https://github.com/serkanalgur/opencodev2-notification/stargazers)
7
7
  [![GitHub issues](https://img.shields.io/github/issues/serkanalgur/opencodev2-notification)](https://github.com/serkanalgur/opencodev2-notification/issues)
8
8
 
9
9
  > Native OS notifications for OpenCode V2
10
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.
11
+ A CLI plugin for [OpenCode V2](https://opencode.ai) that delivers native OS notifications when tasks complete, errors occur, or the AI needs your input.
12
12
 
13
13
  ## Why This Exists
14
14
 
@@ -17,9 +17,9 @@ You delegate a task and switch to another window. Now you're checking back every
17
17
  This plugin solves that:
18
18
 
19
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
20
+ - **Uses OpenCode's built-in attention API** - Native notifications on all platforms
22
21
  - **Smart defaults** - Won't spam you. Only notifies for meaningful events with parent-session filtering and quiet-hours support.
22
+ - **Lightweight** - Event-driven, no tools added to your conversation
23
23
 
24
24
  ## Installation
25
25
 
@@ -51,44 +51,24 @@ Or copy the plugin files to your `.opencode/plugins/` directory:
51
51
 
52
52
  | Event | Notifies? | Sound | Why |
53
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 |
54
+ | Session complete | Yes | done | Main task done - time to review |
55
+ | Session error | Yes | error | Something broke - needs attention |
56
+ | Permission needed | Yes | permission | AI is blocked, waiting for you |
58
57
  | Sub-task complete/error | No (default) | - | Set `notifyChildSessions: true` to include child sessions |
59
58
 
60
59
  The plugin automatically:
61
60
 
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)
61
+ 1. Uses OpenCode's built-in `attention.notify()` API for native notifications
62
+ 2. Only notifies when terminal is not focused (no spam while you're working)
63
+ 3. Deduplicates rapid-fire notifications
83
64
 
84
65
  ## Platform Support
85
66
 
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 |
67
+ | Platform | Method | Status |
68
+ |----------|--------|--------|
69
+ | **macOS** | Notification Center | Full support |
70
+ | **Windows** | Toast notifications | Full support |
71
+ | **Linux** | Desktop notifications | Full support |
92
72
 
93
73
  ## Configuration (Optional)
94
74
 
@@ -97,13 +77,10 @@ Works out of the box. To customize, create `~/.config/opencode/opencodev2-notifi
97
77
  ```json
98
78
  {
99
79
  "notifyChildSessions": false,
100
- "timeout": 0,
101
- "terminal": "ghostty",
102
80
  "sounds": {
103
- "idle": "Glass",
104
- "error": "Basso",
105
- "permission": "Submarine",
106
- "question": "Submarine"
81
+ "idle": "done",
82
+ "error": "error",
83
+ "permission": "permission"
107
84
  },
108
85
  "quietHours": {
109
86
  "enabled": false,
@@ -118,19 +95,16 @@ Works out of the box. To customize, create `~/.config/opencode/opencodev2-notifi
118
95
  | Key | Type | Default | Description |
119
96
  |-----|------|---------|-------------|
120
97
  | `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 |
98
+ | `sounds.idle` | string | `"done"` | Sound for session complete |
99
+ | `sounds.error` | string | `"error"` | Sound for errors |
100
+ | `sounds.permission` | string | `"permission"` | Sound for permission requests |
127
101
  | `quietHours.enabled` | boolean | `false` | Enable quiet hours |
128
102
  | `quietHours.start` | string | `"22:00"` | Quiet hours start (HH:MM) |
129
103
  | `quietHours.end` | string | `"08:00"` | Quiet hours end (HH:MM) |
130
104
 
131
- ### Available macOS Sounds
105
+ ### Available Sound Names
132
106
 
133
- Basso, Blow, Bottle, Frog, Funk, Glass, Hero, Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
107
+ `default`, `question`, `permission`, `error`, `done`, `subagent_done`
134
108
 
135
109
  ## FAQ
136
110
 
@@ -144,7 +118,7 @@ No. Smart defaults prevent noise:
144
118
 
145
119
  - Only notifies for parent sessions (not every sub-task)
146
120
  - Supports quiet-hours suppression
147
- - Suppresses when your terminal is the active window on macOS
121
+ - Only notifies when terminal is not focused
148
122
  - Deduplication prevents rapid-fire notifications
149
123
 
150
124
  ### Can I disable it temporarily?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serkanalgur/opencodev2-notification",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "description": "Native OS notifications for OpenCode V2 - know when tasks complete, errors occur, or the AI needs your input",
5
5
  "keywords": [
6
6
  "opencode",
@@ -23,7 +23,8 @@
23
23
  "author": "serkanalgur",
24
24
  "type": "module",
25
25
  "exports": {
26
- ".": "./src/index.ts"
26
+ ".": "./src/index.ts",
27
+ "./tui": "./src/index.ts"
27
28
  },
28
29
  "main": "src/index.ts",
29
30
  "files": [
package/src/index.ts CHANGED
@@ -5,40 +5,33 @@
5
5
  * Philosophy: "Notify the human when the AI needs them back, not for every micro-event."
6
6
  *
7
7
  * Features:
8
- * - Native OS notifications on macOS, Windows, and Linux
8
+ * - Uses OpenCode's built-in attention API for native notifications
9
9
  * - Auto-detects terminal emulator for click-to-focus (macOS)
10
10
  * - Suppresses notifications when terminal is focused (macOS)
11
11
  * - Parent session only by default (no spam from sub-tasks)
12
12
  * - Quiet hours support
13
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
14
  */
20
15
 
21
16
  import * as fs from "node:fs/promises"
22
17
  import * as os from "node:os"
23
18
  import * as path from "node:path"
24
- import { Plugin } from "@opencode/plugin"
25
- import type { Event } from "@opencode/plugin"
19
+ import { Plugin } from "@opencode/plugin/tui"
26
20
 
27
21
  // ==========================================
28
22
  // TYPES
29
23
  // ==========================================
30
24
 
25
+ type AttentionSoundName = "default" | "question" | "permission" | "error" | "done" | "subagent_done"
26
+
31
27
  interface NotifyConfig {
32
28
  /** Notify for child/sub-session events (default: false) */
33
29
  notifyChildSessions: boolean
34
- /** Seconds before a desktop notification disappears (default: 0, no timeout) */
35
- timeout: number
36
30
  /** Sound configuration per event type */
37
31
  sounds: {
38
- idle: string
39
- error: string
40
- permission: string
41
- question?: string
32
+ idle: AttentionSoundName
33
+ error: AttentionSoundName
34
+ permission: AttentionSoundName
42
35
  }
43
36
  /** Quiet hours configuration */
44
37
  quietHours: {
@@ -46,14 +39,6 @@ interface NotifyConfig {
46
39
  start: string // "HH:MM" format
47
40
  end: string // "HH:MM" format
48
41
  }
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
42
  }
58
43
 
59
44
  // ==========================================
@@ -62,11 +47,10 @@ interface TerminalInfo {
62
47
 
63
48
  const DEFAULT_CONFIG: NotifyConfig = {
64
49
  notifyChildSessions: false,
65
- timeout: 0,
66
50
  sounds: {
67
- idle: "Glass",
68
- error: "Basso",
69
- permission: "Submarine",
51
+ idle: "done",
52
+ error: "error",
53
+ permission: "permission",
70
54
  },
71
55
  quietHours: {
72
56
  enabled: false,
@@ -75,22 +59,6 @@ const DEFAULT_CONFIG: NotifyConfig = {
75
59
  },
76
60
  }
77
61
 
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
62
  // ==========================================
95
63
  // CONFIGURATION LOADING
96
64
  // ==========================================
@@ -107,22 +75,10 @@ async function loadConfig(): Promise<NotifyConfig> {
107
75
  const content = await fs.readFile(configPath, "utf8")
108
76
  const userConfig = JSON.parse(content) as Partial<NotifyConfig>
109
77
 
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
78
  // Merge with defaults
122
79
  return {
123
80
  ...DEFAULT_CONFIG,
124
81
  ...userConfig,
125
- timeout,
126
82
  sounds: {
127
83
  ...DEFAULT_CONFIG.sounds,
128
84
  ...userConfig.sounds,
@@ -138,80 +94,6 @@ async function loadConfig(): Promise<NotifyConfig> {
138
94
  }
139
95
  }
140
96
 
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
97
  // ==========================================
216
98
  // QUIET HOURS CHECK
217
99
  // ==========================================
@@ -236,228 +118,12 @@ function isQuietHours(config: NotifyConfig): boolean {
236
118
  return currentMinutes >= startMinutes && currentMinutes < endMinutes
237
119
  }
238
120
 
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
121
  // ==========================================
455
122
  // DEDUPLICATION
456
123
  // ==========================================
457
124
 
458
125
  type RecentNotifications = Map<string, number>
459
126
 
460
- const QUESTION_DEDUPE_WINDOW_MS = 1500
461
127
  const READY_DEDUPE_WINDOW_MS = 1500
462
128
  const PERMISSION_DEDUPE_WINDOW_MS = 1500
463
129
 
@@ -491,211 +157,175 @@ function toNonEmptyString(value: unknown): string | null {
491
157
  }
492
158
 
493
159
  // ==========================================
494
- // PLUGIN EXPORT
160
+ // CLI PLUGIN EXPORT
495
161
  // ==========================================
496
162
 
497
163
  export default Plugin.define({
498
164
  id: "opencodev2-notification",
499
- async setup(ctx) {
165
+ async setup(context) {
500
166
  // Load config at startup
501
167
  const config = await loadConfig()
502
168
 
503
- // Detect terminal at startup (cached for performance)
504
- const terminalInfo = await detectTerminalInfo(config)
505
-
506
169
  // Deduplication maps
507
- const recentQuestionNotifications: RecentNotifications = new Map()
508
170
  const recentReadyNotifications: RecentNotifications = new Map()
509
171
  const recentPermissionNotifications: RecentNotifications = new Map()
510
172
 
511
173
  // 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
174
+ const getSessionTitle = (sessionID: string): string => {
175
+ const session = context.data.session.get(sessionID)
176
+ if (session?.title) {
177
+ return session.title.slice(0, 50)
520
178
  }
521
179
  return "Task"
522
180
  }
523
181
 
524
182
  // Helper: check if parent session
525
- const isParentSession = async (sessionID: string): Promise<boolean> => {
183
+ const isParentSession = (sessionID: string): boolean => {
184
+ const session = context.data.session.get(sessionID)
185
+ // No parentID means this IS the parent/root session
186
+ return !(session as any)?.parentID
187
+ }
188
+
189
+ // Helper: send notification using OpenCode's built-in attention API
190
+ const sendNotification = async (
191
+ title: string,
192
+ message: string,
193
+ soundName: AttentionSoundName
194
+ ): Promise<void> => {
526
195
  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
196
+ await context.attention.notify({
197
+ title,
198
+ message,
199
+ notification: { when: "blurred" },
200
+ sound: { name: soundName, volume: 1, when: "always" },
201
+ })
202
+ } catch (error) {
203
+ console.error("opencodev2-notification: failed to send notification:", error)
533
204
  }
534
205
  }
535
206
 
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)
207
+ // Subscribe to events using context.data.on
208
+ const unsubscribers: Array<() => void> = []
209
+
210
+ // Session idle - task completed
211
+ unsubscribers.push(
212
+ context.data.on("session.idle", async (event) => {
213
+ const sessionID = toNonEmptyString(event.data.sessionID)
214
+ if (!sessionID) return
215
+
216
+ // Check parent session
217
+ if (!config.notifyChildSessions) {
218
+ if (!isParentSession(sessionID)) return
546
219
  }
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
220
+
221
+ // Check quiet hours
222
+ if (isQuietHours(config)) return
223
+
224
+ // Deduplication
225
+ const dedupeKey = `session-ready:${sessionID}`
226
+ if (
227
+ !shouldSendDedupedNotification(
228
+ recentReadyNotifications,
229
+ dedupeKey,
230
+ READY_DEDUPE_WINDOW_MS
592
231
  )
593
- break
232
+ ) {
233
+ return
594
234
  }
595
235
 
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
236
+ const sessionTitle = getSessionTitle(sessionID)
237
+ await sendNotification(
238
+ "Ready for review",
239
+ sessionTitle,
240
+ config.sounds.idle
241
+ )
242
+ })
243
+ )
244
+
245
+ // Session execution failed
246
+ unsubscribers.push(
247
+ context.data.on("session.execution.failed", async (event) => {
248
+ const sessionID = toNonEmptyString(event.data.sessionID)
249
+ if (!sessionID) return
250
+
251
+ // Check parent session
252
+ if (!config.notifyChildSessions) {
253
+ if (!isParentSession(sessionID)) return
629
254
  }
630
255
 
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
256
+ // Check quiet hours
257
+ if (isQuietHours(config)) return
258
+
259
+ const error = event.data.error
260
+ const errorMessage = (error.message ?? "Something went wrong").slice(0, 100)
261
+
262
+ await sendNotification(
263
+ "Something went wrong",
264
+ errorMessage,
265
+ config.sounds.error
266
+ )
267
+ })
268
+ )
269
+
270
+ // Permission asked
271
+ unsubscribers.push(
272
+ context.data.on("permission.asked", async (event) => {
273
+ // Check quiet hours
274
+ if (isQuietHours(config)) return
275
+
276
+ // Deduplication
277
+ const permissionKey = toNonEmptyString(event.data.id)
278
+ ? `permission:request:${event.data.id}`
279
+ : `permission:${Date.now()}`
280
+ if (
281
+ !shouldSendDedupedNotification(
282
+ recentPermissionNotifications,
283
+ permissionKey,
284
+ PERMISSION_DEDUPE_WINDOW_MS
660
285
  )
661
- break
286
+ ) {
287
+ return
662
288
  }
663
289
 
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
290
+ await sendNotification(
291
+ "Waiting for you",
292
+ "OpenCode needs your input",
293
+ config.sounds.permission
294
+ )
295
+ })
296
+ )
297
+
298
+ // Permission replied (when user responds)
299
+ unsubscribers.push(
300
+ context.data.on("permission.replied", async (event) => {
301
+ // Check quiet hours
302
+ if (isQuietHours(config)) return
303
+
304
+ // Deduplication
305
+ const permissionKey = toNonEmptyString(event.data.requestID)
306
+ ? `permission:replied:${event.data.requestID}`
307
+ : `permission-reply:${Date.now()}`
308
+ if (
309
+ !shouldSendDedupedNotification(
310
+ recentPermissionNotifications,
311
+ permissionKey,
312
+ PERMISSION_DEDUPE_WINDOW_MS
690
313
  )
691
- break
314
+ ) {
315
+ return
692
316
  }
693
- }
694
- }
317
+
318
+ await sendNotification(
319
+ "Permission Updated",
320
+ "Your input has been recorded",
321
+ config.sounds.permission
322
+ )
323
+ })
324
+ )
695
325
 
696
326
  // Return cleanup function
697
327
  return () => {
698
- controller.abort()
328
+ unsubscribers.forEach((unsub) => unsub())
699
329
  }
700
330
  },
701
331
  })