@pranjalmandavkar/opencode-notifier 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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@pranjalmandavkar/opencode-notifier",
3
+ "version": "1.0.0",
4
+ "description": "Opencode Plugin that sends system notification when permission is needed, generation is complete or error occurs in session",
5
+ "author": "MandavkarPranjal",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "dist/index.js",
9
+ "types": "dist/index.d.ts",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/mandavkarpranjal/opencode-notifier.git"
13
+ },
14
+ "homepage": "https://github.com/mandavkarpranjal/opencode-notifier#readme",
15
+ "keywords": [
16
+ "opencode",
17
+ "opencode-plugin",
18
+ "notification",
19
+ "alerts"
20
+ ],
21
+ "scripts": {
22
+ "build": "bun build src/index.ts --outdir dist --target node",
23
+ "typecheck": "tsc --noEmit",
24
+ "release:patch": "bun run build && npm version patch && npm publish --access public",
25
+ "release:minor": "bun run build && npm version minor && npm publish --access public",
26
+ "release:major": "bun run build && npm version major && npm publish --access public"
27
+ },
28
+ "devDependencies": {
29
+ "@opencode-ai/plugin": "^1.1.25",
30
+ "@types/bun": "latest",
31
+ "@types/node": "^25.0.9",
32
+ "@types/node-notifier": "^8.0.5"
33
+ },
34
+ "peerDependencies": {
35
+ "@opencode-ai/plugin": "^1.1.25"
36
+ },
37
+ "dependencies": {
38
+ "node-notifier": "^10.0.1"
39
+ }
40
+ }
Binary file
Binary file
Binary file
Binary file
package/src/config.ts ADDED
@@ -0,0 +1,157 @@
1
+ import { readFileSync, existsSync } from "fs"
2
+ import { join } from "path"
3
+ import { homedir } from "os"
4
+
5
+ export type EventType = "permission" | "complete" | "error" | "question"
6
+
7
+ export interface EventConfig {
8
+ sound: boolean
9
+ notification: boolean
10
+ }
11
+
12
+ export interface NotifierConfig {
13
+ sound: boolean
14
+ notification: boolean
15
+ timeout: number
16
+ showProjectName: boolean
17
+ events: {
18
+ permission: EventConfig
19
+ complete: EventConfig
20
+ error: EventConfig
21
+ question: EventConfig
22
+ }
23
+ messages: {
24
+ permission: string
25
+ complete: string
26
+ error: string
27
+ question: string
28
+ }
29
+ sounds: {
30
+ permission: string | null
31
+ complete: string | null
32
+ error: string | null
33
+ question: string | null
34
+ }
35
+ }
36
+
37
+ const DEFAULT_EVENT_CONFIG: EventConfig = {
38
+ sound: true,
39
+ notification: true,
40
+ }
41
+
42
+ const DEFAULT_CONFIG: NotifierConfig = {
43
+ sound: true,
44
+ notification: true,
45
+ timeout: 5,
46
+ showProjectName: true,
47
+ events: {
48
+ permission: { ...DEFAULT_EVENT_CONFIG },
49
+ complete: { ...DEFAULT_EVENT_CONFIG },
50
+ error: { ...DEFAULT_EVENT_CONFIG },
51
+ question: { ...DEFAULT_EVENT_CONFIG },
52
+ },
53
+ messages: {
54
+ permission: "Session needs permission",
55
+ complete: "Session has finished",
56
+ error: "Session encountered an error",
57
+ question: "Session has a question",
58
+ },
59
+ sounds: {
60
+ permission: null,
61
+ complete: null,
62
+ error: null,
63
+ question: null
64
+ },
65
+ }
66
+
67
+ function getConfigPath(): string {
68
+ return join(homedir(), ".config", "opencode", "opencode-notifier.json")
69
+ }
70
+
71
+ function parseEventConfig(
72
+ userEvent: boolean | { sound?: boolean; notification?: boolean } | undefined,
73
+ defaultConfig: EventConfig
74
+ ): EventConfig {
75
+ if (userEvent === undefined) {
76
+ return defaultConfig
77
+ }
78
+
79
+ if (typeof userEvent === "boolean") {
80
+ return {
81
+ sound: userEvent,
82
+ notification: userEvent,
83
+ }
84
+ }
85
+
86
+ return {
87
+ sound: userEvent.sound ?? defaultConfig.sound,
88
+ notification: userEvent.notification ?? defaultConfig.notification,
89
+ }
90
+ }
91
+
92
+ export function loadConfig(): NotifierConfig {
93
+ const configPath = getConfigPath()
94
+
95
+ if (!existsSync(configPath)) {
96
+ return DEFAULT_CONFIG
97
+ }
98
+
99
+ try {
100
+ const fileContent = readFileSync(configPath, "utf-8")
101
+ const userConfig = JSON.parse(fileContent)
102
+
103
+ const globalSound = userConfig.sound ?? DEFAULT_CONFIG.sound
104
+ const globalNotification = userConfig.notification ?? DEFAULT_CONFIG.notification
105
+
106
+ const defaultWithGlobal: EventConfig = {
107
+ sound: globalSound,
108
+ notification: globalNotification,
109
+ }
110
+
111
+ return {
112
+ sound: globalSound,
113
+ notification: globalNotification,
114
+ timeout:
115
+ typeof userConfig.timeout === "number" && userConfig.timeout > 0
116
+ ? userConfig.timeout
117
+ : DEFAULT_CONFIG.timeout,
118
+ showProjectName: userConfig.showProjectName ?? DEFAULT_CONFIG.showProjectName,
119
+ events: {
120
+ permission: parseEventConfig(userConfig.events?.permission ?? userConfig.permission, defaultWithGlobal),
121
+ complete: parseEventConfig(userConfig.events?.complete ?? userConfig.complete, defaultWithGlobal),
122
+ error: parseEventConfig(userConfig.events?.error ?? userConfig.error, defaultWithGlobal),
123
+ question: parseEventConfig(userConfig.events?.question ?? userConfig.question, defaultWithGlobal),
124
+ },
125
+ messages: {
126
+ permission: userConfig.messages?.permission ?? DEFAULT_CONFIG.messages.permission,
127
+ complete: userConfig.messages?.complete ?? DEFAULT_CONFIG.messages.complete,
128
+ error: userConfig.messages?.error ?? DEFAULT_CONFIG.messages.error,
129
+ question: userConfig.messages?.question ?? DEFAULT_CONFIG.messages.question,
130
+ },
131
+ sounds: {
132
+ permission: userConfig.sounds?.permission ?? DEFAULT_CONFIG.sounds.permission,
133
+ complete: userConfig.sounds?.complete ?? DEFAULT_CONFIG.sounds.complete,
134
+ error: userConfig.sounds?.error ?? DEFAULT_CONFIG.sounds.error,
135
+ question: userConfig.sounds?.question ?? DEFAULT_CONFIG.sounds.question,
136
+ },
137
+ }
138
+ } catch {
139
+ return DEFAULT_CONFIG
140
+ }
141
+ }
142
+
143
+ export function isEventNotificationEnabled(config: NotifierConfig, event: EventType): boolean {
144
+ return config.events[event].notification
145
+ }
146
+
147
+ export function getMessage(config: NotifierConfig, event: EventType): string {
148
+ return config.messages[event]
149
+ }
150
+
151
+ export function isEventSoundEnabled(config: NotifierConfig, event: EventType): boolean {
152
+ return config.events[event].sound
153
+ }
154
+
155
+ export function getSound(config: NotifierConfig, event: EventType): string | null {
156
+ return config.sounds[event]
157
+ }
package/src/index.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { basename } from "path"
3
+ import { loadConfig, isEventNotificationEnabled, getMessage, isEventSoundEnabled, getSound } from "./config"
4
+ import type { EventType, NotifierConfig } from "./config"
5
+ import { sendNotification } from "./notify"
6
+ import { playSound } from "./sound"
7
+
8
+ function getNotificationTitle(config: NotifierConfig, projectName: string | null): string {
9
+ if (config.showProjectName && projectName) {
10
+ return `OpenCode (${projectName})`
11
+ }
12
+ return "OpenCode"
13
+ }
14
+
15
+ async function handleEvent(
16
+ config: NotifierConfig,
17
+ eventType: EventType,
18
+ projectName: string | null
19
+ ): Promise<void> {
20
+ const promises: Promise<void>[] = []
21
+
22
+ if (isEventNotificationEnabled(config, eventType)) {
23
+ const title = getNotificationTitle(config, projectName)
24
+ const message = getMessage(config, eventType)
25
+ promises.push(sendNotification(title, message, config.timeout))
26
+ }
27
+
28
+ if (isEventSoundEnabled(config, eventType)) {
29
+ const customSound = getSound(config, eventType)
30
+ promises.push(playSound(eventType, customSound))
31
+ }
32
+
33
+ await Promise.allSettled(promises)
34
+ }
35
+
36
+ export const NotifierPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
37
+ const config = loadConfig()
38
+ const projectName = directory ? basename(directory) : null
39
+
40
+ return {
41
+ event: async ({ event }) => {
42
+ if (event.type === "permission.updated") {
43
+ await handleEvent(config, "permission", projectName)
44
+ }
45
+
46
+ if ((event as any).type === "permission.asked") {
47
+ await handleEvent(config, "permission", projectName)
48
+ }
49
+
50
+ if (event.type === "session.idle") {
51
+ await handleEvent(config, "complete", projectName)
52
+ }
53
+
54
+ if (event.type === "session.error") {
55
+ await handleEvent(config, "error", projectName)
56
+ }
57
+ },
58
+ "permission.ask": async () => {
59
+ await handleEvent(config, "permission", projectName)
60
+ },
61
+ "tool.execute.before": async (input, output) => {
62
+ if (input.tool === "question") {
63
+ await handleEvent(config, "question", projectName)
64
+ }
65
+ },
66
+ }
67
+ }
68
+
69
+ export default NotifierPlugin
package/src/notify.ts ADDED
@@ -0,0 +1,62 @@
1
+ import os from "os"
2
+ import { exec } from "child_process"
3
+ import notifier from "node-notifier"
4
+
5
+ const DEBOUNCE_MS = 1000
6
+
7
+ const platform = os.type()
8
+
9
+ let platformNotifier: any
10
+
11
+ if (platform === "Linux" || platform.match(/BSD$/)) {
12
+ const { NotifySend } = notifier
13
+ platformNotifier = new NotifySend({ withFallback: false })
14
+ } else if (platform === "Windows_NT") {
15
+ const { WindowsToaster } = notifier
16
+ platformNotifier = new WindowsToaster({ withFallback: false })
17
+ } else if (platform !== "Darwin") {
18
+ platformNotifier = notifier
19
+ }
20
+
21
+ const lastNotificationTime: Record<string, number> = {}
22
+
23
+ export async function sendNotification(
24
+ title: string,
25
+ message: string,
26
+ timeout: number
27
+ ): Promise<void> {
28
+ const now = Date.now()
29
+ if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
30
+ return
31
+ }
32
+ lastNotificationTime[message] = now
33
+
34
+ if (platform === "Darwin") {
35
+ return new Promise((resolve) => {
36
+ const escapedMessage = message.replace(/"/g, '\\"')
37
+ const escapedTitle = title.replace(/"/g, '\\"')
38
+ exec(
39
+ `osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`,
40
+ () => {
41
+ resolve()
42
+ }
43
+ )
44
+ })
45
+ }
46
+
47
+ return new Promise((resolve) => {
48
+ const notificationOptions: any = {
49
+ title: title,
50
+ message: message,
51
+ timeout: timeout,
52
+ icon: undefined,
53
+ }
54
+
55
+ platformNotifier.notify(
56
+ notificationOptions,
57
+ () => {
58
+ resolve()
59
+ }
60
+ )
61
+ })
62
+ }
package/src/sound.ts ADDED
@@ -0,0 +1,126 @@
1
+ import { platform } from "os"
2
+ import { join, dirname } from "path"
3
+ import { fileURLToPath } from "url"
4
+ import { existsSync } from "fs"
5
+ import { spawn } from "child_process"
6
+ import type { EventType } from "./config"
7
+
8
+ const __dirname = dirname(fileURLToPath(import.meta.url))
9
+ const DEBOUNCE_MS = 1000
10
+
11
+ const lastSoundTime: Record<string, number> = {}
12
+
13
+ function getBundledSoundPath(event: EventType): string {
14
+ const soundFilename = `${event}.wav`
15
+
16
+ const possiblePaths = [
17
+ join(__dirname, "..", "sounds", soundFilename),
18
+ join(__dirname, "sounds", soundFilename),
19
+ ]
20
+
21
+ for (const path of possiblePaths) {
22
+ if (existsSync(path)) {
23
+ return path
24
+ }
25
+ }
26
+
27
+ return join(__dirname, "..", "sounds", soundFilename)
28
+ }
29
+
30
+ function getSoundFilePath(event: EventType, customPath: string | null): string | null {
31
+ if (customPath && existsSync(customPath)) {
32
+ return customPath
33
+ }
34
+
35
+ const bundledPath = getBundledSoundPath(event)
36
+ if (existsSync(bundledPath)) {
37
+ return bundledPath
38
+ }
39
+
40
+ return null
41
+ }
42
+
43
+ async function runCommand(command: string, args: string[]): Promise<void> {
44
+ return new Promise((resolve, reject) => {
45
+ const proc = spawn(command, args, {
46
+ stdio: "ignore",
47
+ detached: false,
48
+ })
49
+
50
+ proc.on("error", (err) => {
51
+ reject(err)
52
+ })
53
+
54
+ proc.on("close", (code) => {
55
+ if (code === 0) {
56
+ resolve()
57
+ } else {
58
+ reject(new Error(`Command exited with code ${code}`))
59
+ }
60
+ })
61
+ })
62
+ }
63
+
64
+ async function playOnLinux(soundPath: string): Promise<void> {
65
+ const players = [
66
+ { command: "paplay", args: [soundPath] },
67
+ { command: "aplay", args: [soundPath] },
68
+ { command: "mpv", args: ["--no-video", "--no-terminal", soundPath] },
69
+ { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", soundPath] },
70
+ ]
71
+
72
+ for (const player of players) {
73
+ try {
74
+ await runCommand(player.command, player.args)
75
+ return
76
+ } catch {
77
+ continue
78
+ }
79
+ }
80
+ }
81
+
82
+ async function playOnMac(soundPath: string): Promise<void> {
83
+ await runCommand("afplay", [soundPath])
84
+ }
85
+
86
+ async function playOnWindows(soundPath: string): Promise<void> {
87
+ const script = `& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }`
88
+ await runCommand("powershell", ["-c", script, soundPath])
89
+ }
90
+
91
+ export async function playSound(
92
+ event: EventType,
93
+ customPath: string | null
94
+ ): Promise<void> {
95
+ const now = Date.now()
96
+ if (lastSoundTime[event] && now - lastSoundTime[event] < DEBOUNCE_MS) {
97
+ return
98
+ }
99
+ lastSoundTime[event] = now
100
+
101
+ const soundPath = getSoundFilePath(event, customPath)
102
+
103
+ if (!soundPath) {
104
+ return
105
+ }
106
+
107
+ const os = platform()
108
+
109
+ try {
110
+ switch (os) {
111
+ case "darwin":
112
+ await playOnMac(soundPath)
113
+ break
114
+ case "linux":
115
+ await playOnLinux(soundPath)
116
+ break
117
+ case "win32":
118
+ await playOnWindows(soundPath)
119
+ break
120
+ default:
121
+ break
122
+ }
123
+ } catch {
124
+ // Silent fail - notification will still work
125
+ }
126
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }