@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.
Files changed (3) hide show
  1. package/package.json +8 -10
  2. package/src/index.ts +11 -325
  3. 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.1",
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/index.ts"
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
- * 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
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
- async setup(context) {
166
- // Load config at startup
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
+ })