@serkanalgur/opencodev2-notification 1.0.1 → 1.1.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 +13 -36
  2. package/package.json +3 -2
  3. package/src/index.ts +169 -502
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
 
@@ -59,36 +59,17 @@ Or copy the plugin files to your `.opencode/plugins/` directory:
59
59
 
60
60
  The plugin automatically:
61
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)
62
+ 1. Uses OpenCode's built-in `attention.notify()` API for native notifications
63
+ 2. Only notifies when terminal is not focused (no spam while you're working)
64
+ 3. Deduplicates rapid-fire notifications
83
65
 
84
66
  ## Platform Support
85
67
 
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 |
68
+ | Platform | Method | Status |
69
+ |----------|--------|--------|
70
+ | **macOS** | Notification Center | Full support |
71
+ | **Windows** | Toast notifications | Full support |
72
+ | **Linux** | Desktop notifications | Full support |
92
73
 
93
74
  ## Configuration (Optional)
94
75
 
@@ -97,8 +78,6 @@ Works out of the box. To customize, create `~/.config/opencode/opencodev2-notifi
97
78
  ```json
98
79
  {
99
80
  "notifyChildSessions": false,
100
- "timeout": 0,
101
- "terminal": "ghostty",
102
81
  "sounds": {
103
82
  "idle": "Glass",
104
83
  "error": "Basso",
@@ -118,8 +97,6 @@ Works out of the box. To customize, create `~/.config/opencode/opencodev2-notifi
118
97
  | Key | Type | Default | Description |
119
98
  |-----|------|---------|-------------|
120
99
  | `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
100
  | `sounds.idle` | string | `"Glass"` | Sound for session complete |
124
101
  | `sounds.error` | string | `"Basso"` | Sound for errors |
125
102
  | `sounds.permission` | string | `"Submarine"` | Sound for permission requests |
@@ -144,7 +121,7 @@ No. Smart defaults prevent noise:
144
121
 
145
122
  - Only notifies for parent sessions (not every sub-task)
146
123
  - Supports quiet-hours suppression
147
- - Suppresses when your terminal is the active window on macOS
124
+ - Only notifies when terminal is not focused
148
125
  - Deduplication prevents rapid-fire notifications
149
126
 
150
127
  ### 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.1.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,24 +5,18 @@
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
@@ -31,8 +25,6 @@ import type { Event } from "@opencode/plugin"
31
25
  interface NotifyConfig {
32
26
  /** Notify for child/sub-session events (default: false) */
33
27
  notifyChildSessions: boolean
34
- /** Seconds before a desktop notification disappears (default: 0, no timeout) */
35
- timeout: number
36
28
  /** Sound configuration per event type */
37
29
  sounds: {
38
30
  idle: string
@@ -50,19 +42,12 @@ interface NotifyConfig {
50
42
  terminal?: string
51
43
  }
52
44
 
53
- interface TerminalInfo {
54
- name: string | null
55
- bundleId: string | null
56
- processName: string | null
57
- }
58
-
59
45
  // ==========================================
60
46
  // DEFAULT CONFIGURATION
61
47
  // ==========================================
62
48
 
63
49
  const DEFAULT_CONFIG: NotifyConfig = {
64
50
  notifyChildSessions: false,
65
- timeout: 0,
66
51
  sounds: {
67
52
  idle: "Glass",
68
53
  error: "Basso",
@@ -75,22 +60,6 @@ const DEFAULT_CONFIG: NotifyConfig = {
75
60
  },
76
61
  }
77
62
 
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
63
  // ==========================================
95
64
  // CONFIGURATION LOADING
96
65
  // ==========================================
@@ -107,22 +76,10 @@ async function loadConfig(): Promise<NotifyConfig> {
107
76
  const content = await fs.readFile(configPath, "utf8")
108
77
  const userConfig = JSON.parse(content) as Partial<NotifyConfig>
109
78
 
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
79
  // Merge with defaults
122
80
  return {
123
81
  ...DEFAULT_CONFIG,
124
82
  ...userConfig,
125
- timeout,
126
83
  sounds: {
127
84
  ...DEFAULT_CONFIG.sounds,
128
85
  ...userConfig.sounds,
@@ -138,80 +95,6 @@ async function loadConfig(): Promise<NotifyConfig> {
138
95
  }
139
96
  }
140
97
 
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
98
  // ==========================================
216
99
  // QUIET HOURS CHECK
217
100
  // ==========================================
@@ -236,221 +119,6 @@ function isQuietHours(config: NotifyConfig): boolean {
236
119
  return currentMinutes >= startMinutes && currentMinutes < endMinutes
237
120
  }
238
121
 
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
122
  // ==========================================
455
123
  // DEDUPLICATION
456
124
  // ==========================================
@@ -491,211 +159,210 @@ function toNonEmptyString(value: unknown): string | null {
491
159
  }
492
160
 
493
161
  // ==========================================
494
- // PLUGIN EXPORT
162
+ // CLI PLUGIN EXPORT
495
163
  // ==========================================
496
164
 
497
165
  export default Plugin.define({
498
166
  id: "opencodev2-notification",
499
- async setup(ctx) {
167
+ async setup(context) {
500
168
  // Load config at startup
501
169
  const config = await loadConfig()
502
170
 
503
- // Detect terminal at startup (cached for performance)
504
- const terminalInfo = await detectTerminalInfo(config)
505
-
506
171
  // Deduplication maps
507
172
  const recentQuestionNotifications: RecentNotifications = new Map()
508
173
  const recentReadyNotifications: RecentNotifications = new Map()
509
174
  const recentPermissionNotifications: RecentNotifications = new Map()
510
175
 
511
176
  // 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
177
+ const getSessionTitle = (sessionID: string): string => {
178
+ const session = context.data.session.get(sessionID)
179
+ if (session?.title) {
180
+ return session.title.slice(0, 50)
520
181
  }
521
182
  return "Task"
522
183
  }
523
184
 
524
185
  // Helper: check if parent session
525
- const isParentSession = async (sessionID: string): Promise<boolean> => {
186
+ const isParentSession = (sessionID: string): boolean => {
187
+ const session = context.data.session.get(sessionID)
188
+ // No parentID means this IS the parent/root session
189
+ return !(session as any)?.parentID
190
+ }
191
+
192
+ // Helper: send notification using OpenCode's built-in attention API
193
+ const sendNotification = async (
194
+ title: string,
195
+ message: string,
196
+ soundName: string
197
+ ): Promise<void> => {
526
198
  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
199
+ await context.attention.notify({
200
+ title,
201
+ message,
202
+ notification: { when: "blurred" },
203
+ sound: { name: soundName, volume: 1, when: "always" },
204
+ })
205
+ } catch (error) {
206
+ console.error("opencodev2-notification: failed to send notification:", error)
533
207
  }
534
208
  }
535
209
 
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)
210
+ // Subscribe to events using context.data.on
211
+ const unsubscribers: Array<() => void> = []
212
+
213
+ // Session idle - task completed
214
+ unsubscribers.push(
215
+ context.data.on("session.idle", async (event) => {
216
+ const sessionID = toNonEmptyString(event.data.sessionID)
217
+ if (!sessionID) return
218
+
219
+ // Check parent session
220
+ if (!config.notifyChildSessions) {
221
+ if (!isParentSession(sessionID)) return
546
222
  }
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
223
+
224
+ // Check quiet hours
225
+ if (isQuietHours(config)) return
226
+
227
+ // Deduplication
228
+ const dedupeKey = `session-ready:${sessionID}`
229
+ if (
230
+ !shouldSendDedupedNotification(
231
+ recentReadyNotifications,
232
+ dedupeKey,
233
+ READY_DEDUPE_WINDOW_MS
592
234
  )
593
- break
235
+ ) {
236
+ return
237
+ }
238
+
239
+ const sessionTitle = getSessionTitle(sessionID)
240
+ await sendNotification(
241
+ "Ready for review",
242
+ sessionTitle,
243
+ config.sounds.idle
244
+ )
245
+ })
246
+ )
247
+
248
+ // Session error
249
+ unsubscribers.push(
250
+ context.data.on("session.error", async (event) => {
251
+ const sessionID = toNonEmptyString(event.data.sessionID)
252
+ if (!sessionID) return
253
+
254
+ // Check parent session
255
+ if (!config.notifyChildSessions) {
256
+ if (!isParentSession(sessionID)) return
594
257
  }
595
258
 
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
259
+ // Check quiet hours
260
+ if (isQuietHours(config)) return
261
+
262
+ const error = event.data.error
263
+ const errorMessage =
264
+ typeof error === "string"
265
+ ? error.slice(0, 100)
266
+ : error
267
+ ? String(error).slice(0, 100)
268
+ : "Something went wrong"
269
+
270
+ await sendNotification(
271
+ "Something went wrong",
272
+ errorMessage,
273
+ config.sounds.error
274
+ )
275
+ })
276
+ )
277
+
278
+ // Permission asked
279
+ unsubscribers.push(
280
+ context.data.on("permission.asked", async (event) => {
281
+ // Check quiet hours
282
+ if (isQuietHours(config)) return
283
+
284
+ // Deduplication
285
+ const permissionKey = toNonEmptyString(event.data.id)
286
+ ? `permission:request:${event.data.id}`
287
+ : `permission:${Date.now()}`
288
+ if (
289
+ !shouldSendDedupedNotification(
290
+ recentPermissionNotifications,
291
+ permissionKey,
292
+ PERMISSION_DEDUPE_WINDOW_MS
627
293
  )
628
- break
294
+ ) {
295
+ return
629
296
  }
630
297
 
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
298
+ await sendNotification(
299
+ "Waiting for you",
300
+ "OpenCode needs your input",
301
+ config.sounds.permission
302
+ )
303
+ })
304
+ )
305
+
306
+ // Permission updated (when user responds)
307
+ unsubscribers.push(
308
+ context.data.on("permission.updated", async (event) => {
309
+ // Check quiet hours
310
+ if (isQuietHours(config)) return
311
+
312
+ // Deduplication
313
+ const permissionKey = toNonEmptyString(event.data.id)
314
+ ? `permission:updated:${event.data.id}`
315
+ : `permission-update:${Date.now()}`
316
+ if (
317
+ !shouldSendDedupedNotification(
318
+ recentPermissionNotifications,
319
+ permissionKey,
320
+ PERMISSION_DEDUPE_WINDOW_MS
660
321
  )
661
- break
322
+ ) {
323
+ return
662
324
  }
663
325
 
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
326
+ await sendNotification(
327
+ "Permission Updated",
328
+ "Your input has been recorded",
329
+ config.sounds.permission
330
+ )
331
+ })
332
+ )
333
+
334
+ // Question asked (when AI asks a question)
335
+ unsubscribers.push(
336
+ context.data.on("question.asked", async (event) => {
337
+ // Check quiet hours
338
+ if (isQuietHours(config)) return
339
+
340
+ // Deduplication
341
+ const questionKey = toNonEmptyString(event.data.id)
342
+ ? `question:request:${event.data.id}`
343
+ : `question:${Date.now()}`
344
+ if (
345
+ !shouldSendDedupedNotification(
346
+ recentQuestionNotifications,
347
+ questionKey,
348
+ QUESTION_DEDUPE_WINDOW_MS
690
349
  )
691
- break
350
+ ) {
351
+ return
692
352
  }
693
- }
694
- }
353
+
354
+ const sound = config.sounds.question ?? config.sounds.permission
355
+ await sendNotification(
356
+ "Question for you",
357
+ "OpenCode needs your input",
358
+ sound
359
+ )
360
+ })
361
+ )
695
362
 
696
363
  // Return cleanup function
697
364
  return () => {
698
- controller.abort()
365
+ unsubscribers.forEach((unsub) => unsub())
699
366
  }
700
367
  },
701
368
  })