@mohak34/opencode-notifier 0.1.12 → 0.1.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ To pin a specific version:
18
18
 
19
19
  ```json
20
20
  {
21
- "plugin": ["@mohak34/opencode-notifier@0.1.10"]
21
+ "plugin": ["@mohak34/opencode-notifier@0.1.14"]
22
22
  }
23
23
  ```
24
24
 
@@ -46,13 +46,13 @@ Remove-Item -Recurse -Force "$env:USERPROFILE\.cache\opencode\node_modules\@moha
46
46
 
47
47
  Then restart OpenCode - it will download the latest version automatically.
48
48
 
49
- ### If you use a pinned version (e.g., `@0.1.10`)
49
+ ### If you use a pinned version (e.g., `@0.1.14`)
50
50
 
51
51
  1. Update the version in your `opencode.json`:
52
52
 
53
53
  ```json
54
54
  {
55
- "plugin": ["@mohak34/opencode-notifier@0.1.10"]
55
+ "plugin": ["@mohak34/opencode-notifier@0.1.14"]
56
56
  }
57
57
  ```
58
58
 
@@ -91,21 +91,31 @@ To customize the plugin, create `~/.config/opencode/opencode-notifier.json`:
91
91
  "sound": true,
92
92
  "notification": true,
93
93
  "timeout": 5,
94
+ "showProjectName": true,
95
+ "command": {
96
+ "enabled": false,
97
+ "path": "/path/to/command",
98
+ "args": ["--event", "{event}", "--message", "{message}"],
99
+ "minDuration": 0
100
+ },
94
101
  "events": {
95
102
  "permission": { "sound": true, "notification": true },
96
103
  "complete": { "sound": true, "notification": true },
104
+ "subagent_complete": { "sound": false, "notification": false },
97
105
  "error": { "sound": true, "notification": true },
98
106
  "question": { "sound": true, "notification": true }
99
107
  },
100
108
  "messages": {
101
- "permission": "OpenCode needs permission",
102
- "complete": "OpenCode has finished",
103
- "error": "OpenCode encountered an error",
104
- "question": "OpenCode has a question"
109
+ "permission": "Session needs permission",
110
+ "complete": "Session has finished",
111
+ "subagent_complete": "Subagent has finished",
112
+ "error": "Session encountered an error",
113
+ "question": "Session has a question"
105
114
  },
106
115
  "sounds": {
107
116
  "permission": "/path/to/custom/sound.wav",
108
117
  "complete": "/path/to/custom/sound.wav",
118
+ "subagent_complete": "/path/to/custom/sound.wav",
109
119
  "error": "/path/to/custom/sound.wav",
110
120
  "question": "/path/to/custom/sound.wav"
111
121
  }
@@ -119,6 +129,8 @@ To customize the plugin, create `~/.config/opencode/opencode-notifier.json`:
119
129
  | `sound` | boolean | `true` | Global toggle for all sounds |
120
130
  | `notification` | boolean | `true` | Global toggle for all notifications |
121
131
  | `timeout` | number | `5` | Notification duration in seconds (Linux only) |
132
+ | `showProjectName` | boolean | `true` | Show project folder name in notification title |
133
+ | `command` | object | — | Command execution settings (enabled/path/args/minDuration) |
122
134
 
123
135
  ### Events
124
136
 
@@ -129,6 +141,7 @@ Control sound and notification separately for each event:
129
141
  "events": {
130
142
  "permission": { "sound": true, "notification": true },
131
143
  "complete": { "sound": false, "notification": true },
144
+ "subagent_complete": { "sound": true, "notification": false },
132
145
  "error": { "sound": true, "notification": false },
133
146
  "question": { "sound": true, "notification": true }
134
147
  }
@@ -142,12 +155,15 @@ Or use a boolean to toggle both:
142
155
  "events": {
143
156
  "permission": true,
144
157
  "complete": false,
158
+ "subagent_complete": true,
145
159
  "error": true,
146
160
  "question": true
147
161
  }
148
162
  }
149
163
  ```
150
164
 
165
+ Note: `complete` fires for primary (main) session completion, while `subagent_complete` fires for subagent completion. `subagent_complete` defaults to disabled (both sound and notification are false).
166
+
151
167
  ### Messages
152
168
 
153
169
  Customize notification text:
@@ -157,12 +173,33 @@ Customize notification text:
157
173
  "messages": {
158
174
  "permission": "Action required",
159
175
  "complete": "Done!",
176
+ "subagent_complete": "Subagent finished",
160
177
  "error": "Something went wrong",
161
178
  "question": "Input needed"
162
179
  }
163
180
  }
164
181
  ```
165
182
 
183
+ ### Command
184
+
185
+ Run a custom command when events fire. Use `{event}` and `{message}` tokens in `path` or `args` to inject the event name and message.
186
+
187
+ `command.minDuration` (optional, seconds) gates command execution based on the time elapsed since the last user prompt.
188
+ - Applies to all events for the custom command.
189
+ - If elapsed time is known and is below `minDuration`, the command is skipped.
190
+ - If elapsed time cannot be determined for an event, the command still runs.
191
+
192
+ ```json
193
+ {
194
+ "command": {
195
+ "enabled": true,
196
+ "path": "/path/to/command",
197
+ "args": ["--event", "{event}", "--message", "{message}"],
198
+ "minDuration": 10
199
+ }
200
+ }
201
+ ```
202
+
166
203
  ### Custom Sounds
167
204
 
168
205
  Use your own sound files:
@@ -172,6 +209,7 @@ Use your own sound files:
172
209
  "sounds": {
173
210
  "permission": "/home/user/sounds/alert.wav",
174
211
  "complete": "/home/user/sounds/done.wav",
212
+ "subagent_complete": "/home/user/sounds/subagent-done.wav",
175
213
  "error": "/home/user/sounds/error.wav",
176
214
  "question": "/home/user/sounds/question.wav"
177
215
  }
@@ -180,6 +218,30 @@ Use your own sound files:
180
218
 
181
219
  If a custom sound file path is provided but the file doesn't exist, the plugin will fall back to the bundled sound.
182
220
 
221
+ ### Example: Different behaviors for main and subagent completion
222
+
223
+ You may want different notification behaviors for primary sessions versus subagent sessions. For example:
224
+
225
+ - **Main session completion**: Play a sound and show a system notification
226
+ - **Subagent completion**: Play a different sound, but no system notification
227
+
228
+ ```json
229
+ {
230
+ "events": {
231
+ "complete": { "sound": true, "notification": true },
232
+ "subagent_complete": { "sound": true, "notification": false }
233
+ },
234
+ "messages": {
235
+ "complete": "Session has finished",
236
+ "subagent_complete": "Subagent task completed"
237
+ },
238
+ "sounds": {
239
+ "complete": "/home/user/sounds/main-done.wav",
240
+ "subagent_complete": "/home/user/sounds/subagent-chime.wav"
241
+ }
242
+ }
243
+ ```
244
+
183
245
  ## Troubleshooting
184
246
 
185
247
  ### macOS: Notifications not showing (only sound works)
@@ -188,17 +250,10 @@ If a custom sound file path is provided but the file doesn't exist, the plugin w
188
250
 
189
251
  If notifications still don't work after updating:
190
252
 
191
- 1. **Install terminal-notifier via Homebrew:**
192
-
193
- ```bash
194
- brew install terminal-notifier
195
- ```
196
-
197
- 2. **Check notification permissions:**
253
+ 1. **Check notification permissions:**
198
254
  - Open **System Settings > Notifications**
199
- - Find your terminal app (e.g., Ghostty, iTerm2, Terminal)
255
+ - Find **Script Editor** in the list
200
256
  - Make sure notifications are set to **Banners** or **Alerts**
201
- - Also enable notifications for **terminal-notifier** if it appears in the list
202
257
 
203
258
  ### Linux: Notifications not showing
204
259
 
package/dist/index.js CHANGED
@@ -3726,6 +3726,9 @@ var require_node_notifier = __commonJS((exports, module) => {
3726
3726
  module.exports.Growl = Growl;
3727
3727
  });
3728
3728
 
3729
+ // src/index.ts
3730
+ import { basename } from "path";
3731
+
3729
3732
  // src/config.ts
3730
3733
  import { readFileSync, existsSync } from "fs";
3731
3734
  import { join } from "path";
@@ -3738,21 +3741,30 @@ var DEFAULT_CONFIG = {
3738
3741
  sound: true,
3739
3742
  notification: true,
3740
3743
  timeout: 5,
3744
+ showProjectName: true,
3745
+ command: {
3746
+ enabled: false,
3747
+ path: "",
3748
+ minDuration: 0
3749
+ },
3741
3750
  events: {
3742
3751
  permission: { ...DEFAULT_EVENT_CONFIG },
3743
3752
  complete: { ...DEFAULT_EVENT_CONFIG },
3753
+ subagent_complete: { sound: false, notification: false },
3744
3754
  error: { ...DEFAULT_EVENT_CONFIG },
3745
3755
  question: { ...DEFAULT_EVENT_CONFIG }
3746
3756
  },
3747
3757
  messages: {
3748
- permission: "OpenCode needs permission",
3749
- complete: "OpenCode has finished",
3750
- error: "OpenCode encountered an error",
3751
- question: "OpenCode has a question"
3758
+ permission: "Session needs permission",
3759
+ complete: "Session has finished",
3760
+ subagent_complete: "Subagent task completed",
3761
+ error: "Session encountered an error",
3762
+ question: "Session has a question"
3752
3763
  },
3753
3764
  sounds: {
3754
3765
  permission: null,
3755
3766
  complete: null,
3767
+ subagent_complete: null,
3756
3768
  error: null,
3757
3769
  question: null
3758
3770
  }
@@ -3789,25 +3801,38 @@ function loadConfig() {
3789
3801
  sound: globalSound,
3790
3802
  notification: globalNotification
3791
3803
  };
3804
+ const userCommand = userConfig.command ?? {};
3805
+ const commandArgs = Array.isArray(userCommand.args) ? userCommand.args.filter((arg) => typeof arg === "string") : undefined;
3806
+ const commandMinDuration = typeof userCommand.minDuration === "number" && Number.isFinite(userCommand.minDuration) && userCommand.minDuration > 0 ? userCommand.minDuration : 0;
3792
3807
  return {
3793
3808
  sound: globalSound,
3794
3809
  notification: globalNotification,
3795
3810
  timeout: typeof userConfig.timeout === "number" && userConfig.timeout > 0 ? userConfig.timeout : DEFAULT_CONFIG.timeout,
3811
+ showProjectName: userConfig.showProjectName ?? DEFAULT_CONFIG.showProjectName,
3812
+ command: {
3813
+ enabled: typeof userCommand.enabled === "boolean" ? userCommand.enabled : DEFAULT_CONFIG.command.enabled,
3814
+ path: typeof userCommand.path === "string" ? userCommand.path : DEFAULT_CONFIG.command.path,
3815
+ args: commandArgs,
3816
+ minDuration: commandMinDuration
3817
+ },
3796
3818
  events: {
3797
3819
  permission: parseEventConfig(userConfig.events?.permission ?? userConfig.permission, defaultWithGlobal),
3798
3820
  complete: parseEventConfig(userConfig.events?.complete ?? userConfig.complete, defaultWithGlobal),
3821
+ subagent_complete: parseEventConfig(userConfig.events?.subagent_complete ?? userConfig.subagent_complete, { sound: false, notification: false }),
3799
3822
  error: parseEventConfig(userConfig.events?.error ?? userConfig.error, defaultWithGlobal),
3800
- question: parseEventConfig(userConfig.events?.question, defaultWithGlobal)
3823
+ question: parseEventConfig(userConfig.events?.question ?? userConfig.question, defaultWithGlobal)
3801
3824
  },
3802
3825
  messages: {
3803
3826
  permission: userConfig.messages?.permission ?? DEFAULT_CONFIG.messages.permission,
3804
3827
  complete: userConfig.messages?.complete ?? DEFAULT_CONFIG.messages.complete,
3828
+ subagent_complete: userConfig.messages?.subagent_complete ?? DEFAULT_CONFIG.messages.subagent_complete,
3805
3829
  error: userConfig.messages?.error ?? DEFAULT_CONFIG.messages.error,
3806
3830
  question: userConfig.messages?.question ?? DEFAULT_CONFIG.messages.question
3807
3831
  },
3808
3832
  sounds: {
3809
3833
  permission: userConfig.sounds?.permission ?? DEFAULT_CONFIG.sounds.permission,
3810
3834
  complete: userConfig.sounds?.complete ?? DEFAULT_CONFIG.sounds.complete,
3835
+ subagent_complete: userConfig.sounds?.subagent_complete ?? DEFAULT_CONFIG.sounds.subagent_complete,
3811
3836
  error: userConfig.sounds?.error ?? DEFAULT_CONFIG.sounds.error,
3812
3837
  question: userConfig.sounds?.question ?? DEFAULT_CONFIG.sounds.question
3813
3838
  }
@@ -3833,7 +3858,6 @@ function getSoundPath(config, event) {
3833
3858
  var import_node_notifier = __toESM(require_node_notifier(), 1);
3834
3859
  import os from "os";
3835
3860
  import { exec } from "child_process";
3836
- var NOTIFICATION_TITLE = "OpenCode";
3837
3861
  var DEBOUNCE_MS = 1000;
3838
3862
  var platform = os.type();
3839
3863
  var platformNotifier;
@@ -3847,7 +3871,7 @@ if (platform === "Linux" || platform.match(/BSD$/)) {
3847
3871
  platformNotifier = import_node_notifier.default;
3848
3872
  }
3849
3873
  var lastNotificationTime = {};
3850
- async function sendNotification(message, timeout) {
3874
+ async function sendNotification(title, message, timeout) {
3851
3875
  const now = Date.now();
3852
3876
  if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
3853
3877
  return;
@@ -3856,7 +3880,7 @@ async function sendNotification(message, timeout) {
3856
3880
  if (platform === "Darwin") {
3857
3881
  return new Promise((resolve) => {
3858
3882
  const escapedMessage = message.replace(/"/g, "\\\"");
3859
- const escapedTitle = NOTIFICATION_TITLE.replace(/"/g, "\\\"");
3883
+ const escapedTitle = title.replace(/"/g, "\\\"");
3860
3884
  exec(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`, () => {
3861
3885
  resolve();
3862
3886
  });
@@ -3864,7 +3888,7 @@ async function sendNotification(message, timeout) {
3864
3888
  }
3865
3889
  return new Promise((resolve) => {
3866
3890
  const notificationOptions = {
3867
- title: NOTIFICATION_TITLE,
3891
+ title,
3868
3892
  message,
3869
3893
  timeout,
3870
3894
  icon: undefined
@@ -3976,42 +4000,131 @@ async function playSound(event, customPath) {
3976
4000
  } catch {}
3977
4001
  }
3978
4002
 
4003
+ // src/command.ts
4004
+ import { spawn as spawn2 } from "child_process";
4005
+ function substituteTokens(value, event, message) {
4006
+ return value.replaceAll("{event}", event).replaceAll("{message}", message);
4007
+ }
4008
+ function runCommand2(config, event, message) {
4009
+ if (!config.command.enabled || !config.command.path) {
4010
+ return;
4011
+ }
4012
+ const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message));
4013
+ const command = substituteTokens(config.command.path, event, message);
4014
+ const proc = spawn2(command, args, {
4015
+ stdio: "ignore",
4016
+ detached: true
4017
+ });
4018
+ proc.on("error", () => {});
4019
+ proc.unref();
4020
+ }
4021
+
3979
4022
  // src/index.ts
3980
- async function handleEvent(config, eventType) {
4023
+ function getNotificationTitle(config, projectName) {
4024
+ if (config.showProjectName && projectName) {
4025
+ return `OpenCode (${projectName})`;
4026
+ }
4027
+ return "OpenCode";
4028
+ }
4029
+ async function handleEvent(config, eventType, projectName, elapsedSeconds) {
3981
4030
  const promises = [];
4031
+ const message = getMessage(config, eventType);
3982
4032
  if (isEventNotificationEnabled(config, eventType)) {
3983
- const message = getMessage(config, eventType);
3984
- promises.push(sendNotification(message, config.timeout));
4033
+ const title = getNotificationTitle(config, projectName);
4034
+ promises.push(sendNotification(title, message, config.timeout));
3985
4035
  }
3986
4036
  if (isEventSoundEnabled(config, eventType)) {
3987
4037
  const customSoundPath = getSoundPath(config, eventType);
3988
4038
  promises.push(playSound(eventType, customSoundPath));
3989
4039
  }
4040
+ const minDuration = config.command?.minDuration;
4041
+ const shouldSkipCommand = typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
4042
+ if (!shouldSkipCommand) {
4043
+ runCommand2(config, eventType, message);
4044
+ }
3990
4045
  await Promise.allSettled(promises);
3991
4046
  }
3992
- var NotifierPlugin = async ({ project, client, $, directory, worktree }) => {
4047
+ function getSessionIDFromEvent(event) {
4048
+ const sessionID = event?.properties?.sessionID;
4049
+ if (typeof sessionID === "string" && sessionID.length > 0) {
4050
+ return sessionID;
4051
+ }
4052
+ return null;
4053
+ }
4054
+ async function getElapsedSinceLastPrompt(client, sessionID) {
4055
+ try {
4056
+ const response = await client.session.messages({ path: { id: sessionID } });
4057
+ const messages = response.data ?? [];
4058
+ let lastUserMessageTime = null;
4059
+ for (const msg of messages) {
4060
+ const info = msg.info;
4061
+ if (info.role === "user" && typeof info.time?.created === "number") {
4062
+ if (lastUserMessageTime === null || info.time.created > lastUserMessageTime) {
4063
+ lastUserMessageTime = info.time.created;
4064
+ }
4065
+ }
4066
+ }
4067
+ if (lastUserMessageTime !== null) {
4068
+ return (Date.now() - lastUserMessageTime) / 1000;
4069
+ }
4070
+ } catch {}
4071
+ return null;
4072
+ }
4073
+ async function isChildSession(client, sessionID) {
4074
+ try {
4075
+ const response = await client.session.get({ path: { id: sessionID } });
4076
+ const parentID = response.data?.parentID;
4077
+ return !!parentID;
4078
+ } catch {
4079
+ return false;
4080
+ }
4081
+ }
4082
+ async function handleEventWithElapsedTime(client, config, eventType, projectName, event) {
4083
+ const minDuration = config.command?.minDuration;
4084
+ const shouldLookupElapsed = !!config.command?.enabled && typeof config.command?.path === "string" && config.command.path.length > 0 && typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0;
4085
+ let elapsedSeconds = null;
4086
+ if (shouldLookupElapsed) {
4087
+ const sessionID = getSessionIDFromEvent(event);
4088
+ if (sessionID) {
4089
+ elapsedSeconds = await getElapsedSinceLastPrompt(client, sessionID);
4090
+ }
4091
+ }
4092
+ await handleEvent(config, eventType, projectName, elapsedSeconds);
4093
+ }
4094
+ var NotifierPlugin = async ({ client, directory }) => {
3993
4095
  const config = loadConfig();
4096
+ const projectName = directory ? basename(directory) : null;
3994
4097
  return {
3995
4098
  event: async ({ event }) => {
3996
4099
  if (event.type === "permission.updated") {
3997
- await handleEvent(config, "permission");
4100
+ await handleEventWithElapsedTime(client, config, "permission", projectName, event);
3998
4101
  }
3999
4102
  if (event.type === "permission.asked") {
4000
- await handleEvent(config, "permission");
4103
+ await handleEventWithElapsedTime(client, config, "permission", projectName, event);
4001
4104
  }
4002
4105
  if (event.type === "session.idle") {
4003
- await handleEvent(config, "complete");
4106
+ const sessionID = getSessionIDFromEvent(event);
4107
+ if (sessionID) {
4108
+ const isChild = await isChildSession(client, sessionID);
4109
+ if (!isChild) {
4110
+ await handleEventWithElapsedTime(client, config, "complete", projectName, event);
4111
+ } else {
4112
+ await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, event);
4113
+ }
4114
+ } else {
4115
+ await handleEventWithElapsedTime(client, config, "complete", projectName, event);
4116
+ }
4004
4117
  }
4005
4118
  if (event.type === "session.error") {
4006
- await handleEvent(config, "error");
4119
+ await handleEventWithElapsedTime(client, config, "error", projectName, event);
4007
4120
  }
4008
4121
  },
4009
4122
  "permission.ask": async () => {
4010
- await handleEvent(config, "permission");
4123
+ await handleEvent(config, "permission", projectName, null);
4011
4124
  },
4012
- "tool.execute.before": async (input, output) => {
4125
+ "tool.execute.before": async (input) => {
4013
4126
  if (input.tool === "question") {
4014
- await handleEvent(config, "question");
4127
+ await handleEvent(config, "question", projectName, null);
4015
4128
  }
4016
4129
  }
4017
4130
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "OpenCode plugin that sends system notifications and plays sounds when permission is needed, generation completes, or errors occur",
5
5
  "author": "mohak34",
6
6
  "license": "MIT",
Binary file