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