@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.
- package/README.md +13 -36
- package/package.json +3 -2
- package/src/index.ts +169 -502
package/README.md
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@serkanalgur/opencodev2-notification)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://opencode.ai)
|
|
6
6
|
[](https://github.com/serkanalgur/opencodev2-notification/stargazers)
|
|
7
7
|
[](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
|
-
- **
|
|
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.
|
|
63
|
-
2.
|
|
64
|
-
3.
|
|
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
|
-
|
|
|
87
|
-
|
|
88
|
-
|
|
|
89
|
-
|
|
|
90
|
-
|
|
|
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
|
-
-
|
|
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
|
|
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
|
-
* -
|
|
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(
|
|
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 =
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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 =
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
294
|
+
) {
|
|
295
|
+
return
|
|
629
296
|
}
|
|
630
297
|
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
-
|
|
322
|
+
) {
|
|
323
|
+
return
|
|
662
324
|
}
|
|
663
325
|
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
+
unsubscribers.forEach((unsub) => unsub())
|
|
699
366
|
}
|
|
700
367
|
},
|
|
701
368
|
})
|