@serkanalgur/opencodev2-notification 1.2.1 → 1.2.3
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/package.json +8 -10
- package/src/index.ts +11 -325
- package/src/tui.ts +339 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serkanalgur/opencodev2-notification",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.3",
|
|
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",
|
|
@@ -24,9 +24,10 @@
|
|
|
24
24
|
"type": "module",
|
|
25
25
|
"exports": {
|
|
26
26
|
".": "./src/index.ts",
|
|
27
|
-
"./tui": "./src/
|
|
27
|
+
"./tui": "./src/tui.ts"
|
|
28
28
|
},
|
|
29
29
|
"main": "src/index.ts",
|
|
30
|
+
"tui": "./src/tui.ts",
|
|
30
31
|
"files": [
|
|
31
32
|
"src/",
|
|
32
33
|
"README.md",
|
|
@@ -38,21 +39,18 @@
|
|
|
38
39
|
"format": "prettier --write .",
|
|
39
40
|
"format:check": "prettier --check ."
|
|
40
41
|
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"solid-js": ">=1.9.0"
|
|
44
|
+
},
|
|
41
45
|
"devDependencies": {
|
|
42
46
|
"@opencode/plugin": "^2.0.0",
|
|
43
47
|
"@types/bun": "latest",
|
|
44
48
|
"prettier": "^3.4.0",
|
|
45
|
-
"solid-js": ">=1.9.0",
|
|
46
49
|
"tsx": "^4.23.15",
|
|
47
50
|
"typescript": "^5.7.0"
|
|
48
51
|
},
|
|
49
52
|
"peerDependencies": {
|
|
50
|
-
"@opencode/plugin": ">=2.0.0"
|
|
51
|
-
"solid-js": ">=1.9.0"
|
|
53
|
+
"@opencode/plugin": ">=2.0.0"
|
|
52
54
|
},
|
|
53
|
-
"peerDependenciesMeta": {
|
|
54
|
-
"solid-js": {
|
|
55
|
-
"optional": false
|
|
56
|
-
}
|
|
57
|
-
}
|
|
55
|
+
"peerDependenciesMeta": {}
|
|
58
56
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,331 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* opencodev2-notification
|
|
3
|
-
* Native OS notifications for OpenCode V2
|
|
2
|
+
* opencodev2-notification — server entry
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
4
|
+
* The notification logic lives in ./tui.ts (a CLI/TUI plugin that requires the
|
|
5
|
+
* client-side `attention` API). This server entry is loaded when the package is
|
|
6
|
+
* listed in opencode.json(c) `plugins`, but it intentionally does nothing:
|
|
7
|
+
* `attention` is only available on the TUI client, so the real plugin must be
|
|
8
|
+
* loaded as a CLI plugin via cli.json → plugins → ./tui.
|
|
14
9
|
*/
|
|
15
|
-
|
|
16
|
-
import * as fs from "node:fs/promises"
|
|
17
|
-
import * as os from "node:os"
|
|
18
|
-
import * as path from "node:path"
|
|
19
|
-
import { Plugin } from "@opencode/plugin/tui"
|
|
20
|
-
|
|
21
|
-
// ==========================================
|
|
22
|
-
// TYPES
|
|
23
|
-
// ==========================================
|
|
24
|
-
|
|
25
|
-
type AttentionSoundName = "default" | "question" | "permission" | "error" | "done" | "subagent_done"
|
|
26
|
-
|
|
27
|
-
interface NotifyConfig {
|
|
28
|
-
/** Notify for child/sub-session events (default: false) */
|
|
29
|
-
notifyChildSessions: boolean
|
|
30
|
-
/** Sound configuration per event type */
|
|
31
|
-
sounds: {
|
|
32
|
-
idle: AttentionSoundName
|
|
33
|
-
error: AttentionSoundName
|
|
34
|
-
permission: AttentionSoundName
|
|
35
|
-
}
|
|
36
|
-
/** Quiet hours configuration */
|
|
37
|
-
quietHours: {
|
|
38
|
-
enabled: boolean
|
|
39
|
-
start: string // "HH:MM" format
|
|
40
|
-
end: string // "HH:MM" format
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ==========================================
|
|
45
|
-
// DEFAULT CONFIGURATION
|
|
46
|
-
// ==========================================
|
|
47
|
-
|
|
48
|
-
const DEFAULT_CONFIG: NotifyConfig = {
|
|
49
|
-
notifyChildSessions: false,
|
|
50
|
-
sounds: {
|
|
51
|
-
idle: "done",
|
|
52
|
-
error: "error",
|
|
53
|
-
permission: "permission",
|
|
54
|
-
},
|
|
55
|
-
quietHours: {
|
|
56
|
-
enabled: false,
|
|
57
|
-
start: "22:00",
|
|
58
|
-
end: "08:00",
|
|
59
|
-
},
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// ==========================================
|
|
63
|
-
// CONFIGURATION LOADING
|
|
64
|
-
// ==========================================
|
|
65
|
-
|
|
66
|
-
async function loadConfig(): Promise<NotifyConfig> {
|
|
67
|
-
const configPath = path.join(
|
|
68
|
-
os.homedir(),
|
|
69
|
-
".config",
|
|
70
|
-
"opencode",
|
|
71
|
-
"opencodev2-notification.json"
|
|
72
|
-
)
|
|
73
|
-
|
|
74
|
-
try {
|
|
75
|
-
const content = await fs.readFile(configPath, "utf8")
|
|
76
|
-
const userConfig = JSON.parse(content) as Partial<NotifyConfig>
|
|
77
|
-
|
|
78
|
-
// Merge with defaults
|
|
79
|
-
return {
|
|
80
|
-
...DEFAULT_CONFIG,
|
|
81
|
-
...userConfig,
|
|
82
|
-
sounds: {
|
|
83
|
-
...DEFAULT_CONFIG.sounds,
|
|
84
|
-
...userConfig.sounds,
|
|
85
|
-
},
|
|
86
|
-
quietHours: {
|
|
87
|
-
...DEFAULT_CONFIG.quietHours,
|
|
88
|
-
...userConfig.quietHours,
|
|
89
|
-
},
|
|
90
|
-
}
|
|
91
|
-
} catch {
|
|
92
|
-
// Config doesn't exist or is invalid, use defaults
|
|
93
|
-
return DEFAULT_CONFIG
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ==========================================
|
|
98
|
-
// QUIET HOURS CHECK
|
|
99
|
-
// ==========================================
|
|
100
|
-
|
|
101
|
-
function isQuietHours(config: NotifyConfig): boolean {
|
|
102
|
-
if (!config.quietHours.enabled) return false
|
|
103
|
-
|
|
104
|
-
const now = new Date()
|
|
105
|
-
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
|
106
|
-
|
|
107
|
-
const [startHour, startMin] = config.quietHours.start.split(":").map(Number)
|
|
108
|
-
const [endHour, endMin] = config.quietHours.end.split(":").map(Number)
|
|
109
|
-
|
|
110
|
-
const startMinutes = startHour * 60 + startMin
|
|
111
|
-
const endMinutes = endHour * 60 + endMin
|
|
112
|
-
|
|
113
|
-
// Handle overnight quiet hours (e.g., 22:00 - 08:00)
|
|
114
|
-
if (startMinutes > endMinutes) {
|
|
115
|
-
return currentMinutes >= startMinutes || currentMinutes < endMinutes
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return currentMinutes >= startMinutes && currentMinutes < endMinutes
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// ==========================================
|
|
122
|
-
// DEDUPLICATION
|
|
123
|
-
// ==========================================
|
|
124
|
-
|
|
125
|
-
type RecentNotifications = Map<string, number>
|
|
126
|
-
|
|
127
|
-
const READY_DEDUPE_WINDOW_MS = 1500
|
|
128
|
-
const PERMISSION_DEDUPE_WINDOW_MS = 1500
|
|
129
|
-
|
|
130
|
-
function shouldSendDedupedNotification(
|
|
131
|
-
recentNotifications: RecentNotifications,
|
|
132
|
-
dedupeKey: string,
|
|
133
|
-
windowMs: number,
|
|
134
|
-
nowMs = Date.now()
|
|
135
|
-
): boolean {
|
|
136
|
-
// Prune old entries
|
|
137
|
-
for (const [key, timestamp] of recentNotifications) {
|
|
138
|
-
if (nowMs - timestamp >= windowMs) {
|
|
139
|
-
recentNotifications.delete(key)
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
const lastSentAt = recentNotifications.get(dedupeKey)
|
|
144
|
-
if (lastSentAt !== undefined && nowMs - lastSentAt < windowMs) {
|
|
145
|
-
return false
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
recentNotifications.set(dedupeKey, nowMs)
|
|
149
|
-
return true
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function toNonEmptyString(value: unknown): string | null {
|
|
153
|
-
if (typeof value !== "string") return null
|
|
154
|
-
const normalized = value.trim()
|
|
155
|
-
if (!normalized) return null
|
|
156
|
-
return normalized
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// ==========================================
|
|
160
|
-
// CLI PLUGIN EXPORT
|
|
161
|
-
// ==========================================
|
|
10
|
+
import { Plugin } from "@opencode/plugin"
|
|
162
11
|
|
|
163
12
|
export default Plugin.define({
|
|
164
|
-
id: "opencodev2-notification",
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
const config = await loadConfig()
|
|
168
|
-
|
|
169
|
-
// Deduplication maps
|
|
170
|
-
const recentReadyNotifications: RecentNotifications = new Map()
|
|
171
|
-
const recentPermissionNotifications: RecentNotifications = new Map()
|
|
172
|
-
|
|
173
|
-
// Helper: get session info
|
|
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)
|
|
178
|
-
}
|
|
179
|
-
return "Task"
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Helper: check if parent session
|
|
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> => {
|
|
195
|
-
try {
|
|
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)
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
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
|
|
219
|
-
}
|
|
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
|
|
231
|
-
)
|
|
232
|
-
) {
|
|
233
|
-
return
|
|
234
|
-
}
|
|
235
|
-
|
|
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
|
|
254
|
-
}
|
|
255
|
-
|
|
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
|
|
285
|
-
)
|
|
286
|
-
) {
|
|
287
|
-
return
|
|
288
|
-
}
|
|
289
|
-
|
|
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
|
|
313
|
-
)
|
|
314
|
-
) {
|
|
315
|
-
return
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
await sendNotification(
|
|
319
|
-
"Permission Updated",
|
|
320
|
-
"Your input has been recorded",
|
|
321
|
-
config.sounds.permission
|
|
322
|
-
)
|
|
323
|
-
})
|
|
324
|
-
)
|
|
325
|
-
|
|
326
|
-
// Return cleanup function
|
|
327
|
-
return () => {
|
|
328
|
-
unsubscribers.forEach((unsub) => unsub())
|
|
329
|
-
}
|
|
13
|
+
id: "opencodev2-notification.server",
|
|
14
|
+
setup() {
|
|
15
|
+
// No-op: the client-side plugin in ./tui does the work.
|
|
330
16
|
},
|
|
331
|
-
})
|
|
17
|
+
})
|
package/src/tui.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
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
|
+
* - Uses OpenCode's built-in attention API for native notifications
|
|
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
|
+
|
|
16
|
+
import * as fs from "node:fs/promises"
|
|
17
|
+
import * as os from "node:os"
|
|
18
|
+
import * as path from "node:path"
|
|
19
|
+
import { Plugin } from "@opencode/plugin/tui"
|
|
20
|
+
|
|
21
|
+
// ==========================================
|
|
22
|
+
// TYPES
|
|
23
|
+
// ==========================================
|
|
24
|
+
|
|
25
|
+
type AttentionSoundName = "default" | "question" | "permission" | "error" | "done" | "subagent_done"
|
|
26
|
+
|
|
27
|
+
interface NotifyConfig {
|
|
28
|
+
/** Notify for child/sub-session events (default: false) */
|
|
29
|
+
notifyChildSessions: boolean
|
|
30
|
+
/** Sound configuration per event type */
|
|
31
|
+
sounds: {
|
|
32
|
+
idle: AttentionSoundName
|
|
33
|
+
error: AttentionSoundName
|
|
34
|
+
permission: AttentionSoundName
|
|
35
|
+
}
|
|
36
|
+
/** Quiet hours configuration */
|
|
37
|
+
quietHours: {
|
|
38
|
+
enabled: boolean
|
|
39
|
+
start: string // "HH:MM" format
|
|
40
|
+
end: string // "HH:MM" format
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ==========================================
|
|
45
|
+
// DEFAULT CONFIGURATION
|
|
46
|
+
// ==========================================
|
|
47
|
+
|
|
48
|
+
const DEFAULT_CONFIG: NotifyConfig = {
|
|
49
|
+
notifyChildSessions: false,
|
|
50
|
+
sounds: {
|
|
51
|
+
idle: "done",
|
|
52
|
+
error: "error",
|
|
53
|
+
permission: "permission",
|
|
54
|
+
},
|
|
55
|
+
quietHours: {
|
|
56
|
+
enabled: false,
|
|
57
|
+
start: "22:00",
|
|
58
|
+
end: "08:00",
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ==========================================
|
|
63
|
+
// CONFIGURATION LOADING
|
|
64
|
+
// ==========================================
|
|
65
|
+
|
|
66
|
+
async function loadConfig(): Promise<NotifyConfig> {
|
|
67
|
+
const configPath = path.join(
|
|
68
|
+
os.homedir(),
|
|
69
|
+
".config",
|
|
70
|
+
"opencode",
|
|
71
|
+
"opencodev2-notification.json"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const content = await fs.readFile(configPath, "utf8")
|
|
76
|
+
const userConfig = JSON.parse(content) as Partial<NotifyConfig>
|
|
77
|
+
|
|
78
|
+
// Merge with defaults
|
|
79
|
+
return {
|
|
80
|
+
...DEFAULT_CONFIG,
|
|
81
|
+
...userConfig,
|
|
82
|
+
sounds: {
|
|
83
|
+
...DEFAULT_CONFIG.sounds,
|
|
84
|
+
...userConfig.sounds,
|
|
85
|
+
},
|
|
86
|
+
quietHours: {
|
|
87
|
+
...DEFAULT_CONFIG.quietHours,
|
|
88
|
+
...userConfig.quietHours,
|
|
89
|
+
},
|
|
90
|
+
}
|
|
91
|
+
} catch {
|
|
92
|
+
// Config doesn't exist or is invalid, use defaults
|
|
93
|
+
return DEFAULT_CONFIG
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ==========================================
|
|
98
|
+
// QUIET HOURS CHECK
|
|
99
|
+
// ==========================================
|
|
100
|
+
|
|
101
|
+
function isQuietHours(config: NotifyConfig): boolean {
|
|
102
|
+
if (!config.quietHours.enabled) return false
|
|
103
|
+
|
|
104
|
+
const now = new Date()
|
|
105
|
+
const currentMinutes = now.getHours() * 60 + now.getMinutes()
|
|
106
|
+
|
|
107
|
+
const [startHour, startMin] = config.quietHours.start.split(":").map(Number)
|
|
108
|
+
const [endHour, endMin] = config.quietHours.end.split(":").map(Number)
|
|
109
|
+
|
|
110
|
+
const startMinutes = startHour * 60 + startMin
|
|
111
|
+
const endMinutes = endHour * 60 + endMin
|
|
112
|
+
|
|
113
|
+
// Handle overnight quiet hours (e.g., 22:00 - 08:00)
|
|
114
|
+
if (startMinutes > endMinutes) {
|
|
115
|
+
return currentMinutes >= startMinutes || currentMinutes < endMinutes
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return currentMinutes >= startMinutes && currentMinutes < endMinutes
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ==========================================
|
|
122
|
+
// DEDUPLICATION
|
|
123
|
+
// ==========================================
|
|
124
|
+
|
|
125
|
+
type RecentNotifications = Map<string, number>
|
|
126
|
+
|
|
127
|
+
const READY_DEDUPE_WINDOW_MS = 1500
|
|
128
|
+
const PERMISSION_DEDUPE_WINDOW_MS = 1500
|
|
129
|
+
|
|
130
|
+
function shouldSendDedupedNotification(
|
|
131
|
+
recentNotifications: RecentNotifications,
|
|
132
|
+
dedupeKey: string,
|
|
133
|
+
windowMs: number,
|
|
134
|
+
nowMs = Date.now()
|
|
135
|
+
): boolean {
|
|
136
|
+
// Prune old entries
|
|
137
|
+
for (const [key, timestamp] of recentNotifications) {
|
|
138
|
+
if (nowMs - timestamp >= windowMs) {
|
|
139
|
+
recentNotifications.delete(key)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lastSentAt = recentNotifications.get(dedupeKey)
|
|
144
|
+
if (lastSentAt !== undefined && nowMs - lastSentAt < windowMs) {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
recentNotifications.set(dedupeKey, nowMs)
|
|
149
|
+
return true
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function toNonEmptyString(value: unknown): string | null {
|
|
153
|
+
if (typeof value !== "string") return null
|
|
154
|
+
const normalized = value.trim()
|
|
155
|
+
if (!normalized) return null
|
|
156
|
+
return normalized
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ==========================================
|
|
160
|
+
// CLI PLUGIN EXPORT
|
|
161
|
+
// ==========================================
|
|
162
|
+
|
|
163
|
+
export default Plugin.define({
|
|
164
|
+
id: "opencodev2-notification",
|
|
165
|
+
async setup(context) {
|
|
166
|
+
// This plugin requires the TUI context (data + attention APIs).
|
|
167
|
+
// OpenCode loads plugins on the server process first where these are unavailable.
|
|
168
|
+
// Bail out gracefully — the TUI will load a separate instance with full context.
|
|
169
|
+
const ctx = context as any
|
|
170
|
+
if (!ctx.data || !ctx.attention) {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Load config at startup
|
|
175
|
+
const config = await loadConfig()
|
|
176
|
+
|
|
177
|
+
// Deduplication maps
|
|
178
|
+
const recentReadyNotifications: RecentNotifications = new Map()
|
|
179
|
+
const recentPermissionNotifications: RecentNotifications = new Map()
|
|
180
|
+
|
|
181
|
+
// Helper: get session info
|
|
182
|
+
const getSessionTitle = (sessionID: string): string => {
|
|
183
|
+
const session = context.data.session.get(sessionID)
|
|
184
|
+
if (session?.title) {
|
|
185
|
+
return session.title.slice(0, 50)
|
|
186
|
+
}
|
|
187
|
+
return "Task"
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Helper: check if parent session
|
|
191
|
+
const isParentSession = (sessionID: string): boolean => {
|
|
192
|
+
const session = context.data.session.get(sessionID)
|
|
193
|
+
// No parentID means this IS the parent/root session
|
|
194
|
+
return !(session as any)?.parentID
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Helper: send notification using OpenCode's built-in attention API
|
|
198
|
+
const sendNotification = async (
|
|
199
|
+
title: string,
|
|
200
|
+
message: string,
|
|
201
|
+
soundName: AttentionSoundName
|
|
202
|
+
): Promise<void> => {
|
|
203
|
+
try {
|
|
204
|
+
await context.attention.notify({
|
|
205
|
+
title,
|
|
206
|
+
message,
|
|
207
|
+
notification: { when: "blurred" },
|
|
208
|
+
sound: { name: soundName, volume: 1, when: "always" },
|
|
209
|
+
})
|
|
210
|
+
} catch (error) {
|
|
211
|
+
console.error("opencodev2-notification: failed to send notification:", error)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Subscribe to events using context.data.on
|
|
216
|
+
const unsubscribers: Array<() => void> = []
|
|
217
|
+
|
|
218
|
+
// Session idle - task completed
|
|
219
|
+
unsubscribers.push(
|
|
220
|
+
context.data.on("session.idle", async (event) => {
|
|
221
|
+
const sessionID = toNonEmptyString(event.data.sessionID)
|
|
222
|
+
if (!sessionID) return
|
|
223
|
+
|
|
224
|
+
// Check parent session
|
|
225
|
+
if (!config.notifyChildSessions) {
|
|
226
|
+
if (!isParentSession(sessionID)) return
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Check quiet hours
|
|
230
|
+
if (isQuietHours(config)) return
|
|
231
|
+
|
|
232
|
+
// Deduplication
|
|
233
|
+
const dedupeKey = `session-ready:${sessionID}`
|
|
234
|
+
if (
|
|
235
|
+
!shouldSendDedupedNotification(
|
|
236
|
+
recentReadyNotifications,
|
|
237
|
+
dedupeKey,
|
|
238
|
+
READY_DEDUPE_WINDOW_MS
|
|
239
|
+
)
|
|
240
|
+
) {
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const sessionTitle = getSessionTitle(sessionID)
|
|
245
|
+
await sendNotification(
|
|
246
|
+
"Ready for review",
|
|
247
|
+
sessionTitle,
|
|
248
|
+
config.sounds.idle
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
// Session execution failed
|
|
254
|
+
unsubscribers.push(
|
|
255
|
+
context.data.on("session.execution.failed", async (event) => {
|
|
256
|
+
const sessionID = toNonEmptyString(event.data.sessionID)
|
|
257
|
+
if (!sessionID) return
|
|
258
|
+
|
|
259
|
+
// Check parent session
|
|
260
|
+
if (!config.notifyChildSessions) {
|
|
261
|
+
if (!isParentSession(sessionID)) return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Check quiet hours
|
|
265
|
+
if (isQuietHours(config)) return
|
|
266
|
+
|
|
267
|
+
const error = event.data.error
|
|
268
|
+
const errorMessage = (error.message ?? "Something went wrong").slice(0, 100)
|
|
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
|
|
293
|
+
)
|
|
294
|
+
) {
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
await sendNotification(
|
|
299
|
+
"Waiting for you",
|
|
300
|
+
"OpenCode needs your input",
|
|
301
|
+
config.sounds.permission
|
|
302
|
+
)
|
|
303
|
+
})
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
// Permission replied (when user responds)
|
|
307
|
+
unsubscribers.push(
|
|
308
|
+
context.data.on("permission.replied", async (event) => {
|
|
309
|
+
// Check quiet hours
|
|
310
|
+
if (isQuietHours(config)) return
|
|
311
|
+
|
|
312
|
+
// Deduplication
|
|
313
|
+
const permissionKey = toNonEmptyString(event.data.requestID)
|
|
314
|
+
? `permission:replied:${event.data.requestID}`
|
|
315
|
+
: `permission-reply:${Date.now()}`
|
|
316
|
+
if (
|
|
317
|
+
!shouldSendDedupedNotification(
|
|
318
|
+
recentPermissionNotifications,
|
|
319
|
+
permissionKey,
|
|
320
|
+
PERMISSION_DEDUPE_WINDOW_MS
|
|
321
|
+
)
|
|
322
|
+
) {
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
await sendNotification(
|
|
327
|
+
"Permission Updated",
|
|
328
|
+
"Your input has been recorded",
|
|
329
|
+
config.sounds.permission
|
|
330
|
+
)
|
|
331
|
+
})
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
// Return cleanup function
|
|
335
|
+
return () => {
|
|
336
|
+
unsubscribers.forEach((unsub) => unsub())
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
})
|