@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.
- package/README.md +24 -50
- package/package.json +3 -2
- package/src/index.ts +144 -514
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
|
|
|
@@ -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 |
|
|
55
|
-
| Session error | Yes |
|
|
56
|
-
| Permission needed | Yes |
|
|
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.
|
|
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)
|
|
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
|
-
|
|
|
87
|
-
|
|
88
|
-
|
|
|
89
|
-
|
|
|
90
|
-
|
|
|
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": "
|
|
104
|
-
"error": "
|
|
105
|
-
"permission": "
|
|
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
|
-
| `
|
|
122
|
-
| `
|
|
123
|
-
| `sounds.
|
|
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
|
|
105
|
+
### Available Sound Names
|
|
132
106
|
|
|
133
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
* -
|
|
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:
|
|
39
|
-
error:
|
|
40
|
-
permission:
|
|
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: "
|
|
68
|
-
error: "
|
|
69
|
-
permission: "
|
|
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(
|
|
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 =
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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 =
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
232
|
+
) {
|
|
233
|
+
return
|
|
594
234
|
}
|
|
595
235
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
286
|
+
) {
|
|
287
|
+
return
|
|
662
288
|
}
|
|
663
289
|
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
328
|
+
unsubscribers.forEach((unsub) => unsub())
|
|
699
329
|
}
|
|
700
330
|
},
|
|
701
331
|
})
|