@mohak34/opencode-notifier 0.1.21-beta.0 → 0.1.22-beta.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.
Files changed (3) hide show
  1. package/README.md +29 -2
  2. package/dist/index.js +115 -28
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -52,7 +52,7 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
52
52
  "notification": true,
53
53
  "timeout": 5,
54
54
  "showProjectName": true,
55
- "showSessionTitle": true,
55
+ "showSessionTitle": false,
56
56
  "showIcon": true,
57
57
  "notificationSystem": "osascript",
58
58
  "command": {
@@ -81,6 +81,13 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
81
81
  "subagent_complete": null,
82
82
  "error": null,
83
83
  "question": null
84
+ },
85
+ "volumes": {
86
+ "permission": 1,
87
+ "complete": 1,
88
+ "subagent_complete": 1,
89
+ "error": 1,
90
+ "question": 1
84
91
  }
85
92
  }
86
93
  ```
@@ -95,7 +102,7 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
95
102
  "notification": true,
96
103
  "timeout": 5,
97
104
  "showProjectName": true,
98
- "showSessionTitle": true,
105
+ "showSessionTitle": false,
99
106
  "showIcon": true,
100
107
  "notificationSystem": "osascript"
101
108
  }
@@ -181,6 +188,26 @@ Platform notes:
181
188
  - Windows: Only .wav files work
182
189
  - If file doesn't exist, falls back to bundled sound
183
190
 
191
+ ### Volumes
192
+
193
+ Set per-event volume from `0` to `1`:
194
+
195
+ ```json
196
+ {
197
+ "volumes": {
198
+ "permission": 0.6,
199
+ "complete": 0.3,
200
+ "subagent_complete": 0.15,
201
+ "error": 1,
202
+ "question": 0.7
203
+ }
204
+ }
205
+ ```
206
+
207
+ - `0` = mute, `1` = full volume
208
+ - Values outside `0..1` are clamped automatically
209
+ - On Windows, playback still works but custom volume may not be honored by the default player
210
+
184
211
  ### Custom commands
185
212
 
186
213
  Run your own script when something happens. Use `{event}`, `{message}`, and `{sessionTitle}` as placeholders:
package/dist/index.js CHANGED
@@ -3743,7 +3743,7 @@ var DEFAULT_CONFIG = {
3743
3743
  notification: true,
3744
3744
  timeout: 5,
3745
3745
  showProjectName: true,
3746
- showSessionTitle: true,
3746
+ showSessionTitle: false,
3747
3747
  showIcon: true,
3748
3748
  notificationSystem: "osascript",
3749
3749
  command: {
@@ -3756,21 +3756,32 @@ var DEFAULT_CONFIG = {
3756
3756
  complete: { ...DEFAULT_EVENT_CONFIG },
3757
3757
  subagent_complete: { sound: false, notification: false },
3758
3758
  error: { ...DEFAULT_EVENT_CONFIG },
3759
- question: { ...DEFAULT_EVENT_CONFIG }
3759
+ question: { ...DEFAULT_EVENT_CONFIG },
3760
+ interrupted: { ...DEFAULT_EVENT_CONFIG }
3760
3761
  },
3761
3762
  messages: {
3762
3763
  permission: "Session needs permission: {sessionTitle}",
3763
3764
  complete: "Session has finished: {sessionTitle}",
3764
3765
  subagent_complete: "Subagent task completed: {sessionTitle}",
3765
3766
  error: "Session encountered an error: {sessionTitle}",
3766
- question: "Session has a question: {sessionTitle}"
3767
+ question: "Session has a question: {sessionTitle}",
3768
+ interrupted: "Session was interrupted: {sessionTitle}"
3767
3769
  },
3768
3770
  sounds: {
3769
3771
  permission: null,
3770
3772
  complete: null,
3771
3773
  subagent_complete: null,
3772
3774
  error: null,
3773
- question: null
3775
+ question: null,
3776
+ interrupted: null
3777
+ },
3778
+ volumes: {
3779
+ permission: 1,
3780
+ complete: 1,
3781
+ subagent_complete: 1,
3782
+ error: 1,
3783
+ question: 1,
3784
+ interrupted: 1
3774
3785
  }
3775
3786
  };
3776
3787
  function getConfigPath() {
@@ -3791,6 +3802,18 @@ function parseEventConfig(userEvent, defaultConfig) {
3791
3802
  notification: userEvent.notification ?? defaultConfig.notification
3792
3803
  };
3793
3804
  }
3805
+ function parseVolume(value, defaultVolume) {
3806
+ if (typeof value !== "number" || !Number.isFinite(value)) {
3807
+ return defaultVolume;
3808
+ }
3809
+ if (value < 0) {
3810
+ return 0;
3811
+ }
3812
+ if (value > 1) {
3813
+ return 1;
3814
+ }
3815
+ return value;
3816
+ }
3794
3817
  function loadConfig() {
3795
3818
  const configPath = getConfigPath();
3796
3819
  if (!existsSync(configPath)) {
@@ -3827,21 +3850,32 @@ function loadConfig() {
3827
3850
  complete: parseEventConfig(userConfig.events?.complete ?? userConfig.complete, defaultWithGlobal),
3828
3851
  subagent_complete: parseEventConfig(userConfig.events?.subagent_complete ?? userConfig.subagent_complete, { sound: false, notification: false }),
3829
3852
  error: parseEventConfig(userConfig.events?.error ?? userConfig.error, defaultWithGlobal),
3830
- question: parseEventConfig(userConfig.events?.question ?? userConfig.question, defaultWithGlobal)
3853
+ question: parseEventConfig(userConfig.events?.question ?? userConfig.question, defaultWithGlobal),
3854
+ interrupted: parseEventConfig(userConfig.events?.interrupted ?? userConfig.interrupted, defaultWithGlobal)
3831
3855
  },
3832
3856
  messages: {
3833
3857
  permission: userConfig.messages?.permission ?? DEFAULT_CONFIG.messages.permission,
3834
3858
  complete: userConfig.messages?.complete ?? DEFAULT_CONFIG.messages.complete,
3835
3859
  subagent_complete: userConfig.messages?.subagent_complete ?? DEFAULT_CONFIG.messages.subagent_complete,
3836
3860
  error: userConfig.messages?.error ?? DEFAULT_CONFIG.messages.error,
3837
- question: userConfig.messages?.question ?? DEFAULT_CONFIG.messages.question
3861
+ question: userConfig.messages?.question ?? DEFAULT_CONFIG.messages.question,
3862
+ interrupted: userConfig.messages?.interrupted ?? DEFAULT_CONFIG.messages.interrupted
3838
3863
  },
3839
3864
  sounds: {
3840
3865
  permission: userConfig.sounds?.permission ?? DEFAULT_CONFIG.sounds.permission,
3841
3866
  complete: userConfig.sounds?.complete ?? DEFAULT_CONFIG.sounds.complete,
3842
3867
  subagent_complete: userConfig.sounds?.subagent_complete ?? DEFAULT_CONFIG.sounds.subagent_complete,
3843
3868
  error: userConfig.sounds?.error ?? DEFAULT_CONFIG.sounds.error,
3844
- question: userConfig.sounds?.question ?? DEFAULT_CONFIG.sounds.question
3869
+ question: userConfig.sounds?.question ?? DEFAULT_CONFIG.sounds.question,
3870
+ interrupted: userConfig.sounds?.interrupted ?? DEFAULT_CONFIG.sounds.interrupted
3871
+ },
3872
+ volumes: {
3873
+ permission: parseVolume(userConfig.volumes?.permission, DEFAULT_CONFIG.volumes.permission),
3874
+ complete: parseVolume(userConfig.volumes?.complete, DEFAULT_CONFIG.volumes.complete),
3875
+ subagent_complete: parseVolume(userConfig.volumes?.subagent_complete, DEFAULT_CONFIG.volumes.subagent_complete),
3876
+ error: parseVolume(userConfig.volumes?.error, DEFAULT_CONFIG.volumes.error),
3877
+ question: parseVolume(userConfig.volumes?.question, DEFAULT_CONFIG.volumes.question),
3878
+ interrupted: parseVolume(userConfig.volumes?.interrupted, DEFAULT_CONFIG.volumes.interrupted)
3845
3879
  }
3846
3880
  };
3847
3881
  } catch {
@@ -3860,6 +3894,9 @@ function getMessage(config, event) {
3860
3894
  function getSoundPath(config, event) {
3861
3895
  return config.sounds[event];
3862
3896
  }
3897
+ function getSoundVolume(config, event) {
3898
+ return config.volumes[event];
3899
+ }
3863
3900
  function getIconPath(config) {
3864
3901
  if (!config.showIcon) {
3865
3902
  return;
@@ -3951,6 +3988,8 @@ import { existsSync as existsSync2 } from "fs";
3951
3988
  import { spawn } from "child_process";
3952
3989
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
3953
3990
  var DEBOUNCE_MS2 = 1000;
3991
+ var FULL_VOLUME_PERCENT = 100;
3992
+ var FULL_VOLUME_PULSE = 65536;
3954
3993
  var lastSoundTime = {};
3955
3994
  function getBundledSoundPath(event) {
3956
3995
  const soundFilename = `${event}.wav`;
@@ -3993,12 +4032,32 @@ async function runCommand(command, args) {
3993
4032
  });
3994
4033
  });
3995
4034
  }
3996
- async function playOnLinux(soundPath) {
4035
+ function normalizeVolume(volume) {
4036
+ if (!Number.isFinite(volume)) {
4037
+ return 1;
4038
+ }
4039
+ if (volume < 0) {
4040
+ return 0;
4041
+ }
4042
+ if (volume > 1) {
4043
+ return 1;
4044
+ }
4045
+ return volume;
4046
+ }
4047
+ function toPercentVolume(volume) {
4048
+ return Math.round(volume * FULL_VOLUME_PERCENT);
4049
+ }
4050
+ function toPulseVolume(volume) {
4051
+ return Math.round(volume * FULL_VOLUME_PULSE);
4052
+ }
4053
+ async function playOnLinux(soundPath, volume) {
4054
+ const percentVolume = toPercentVolume(volume);
4055
+ const pulseVolume = toPulseVolume(volume);
3997
4056
  const players = [
3998
- { command: "paplay", args: [soundPath] },
4057
+ { command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
3999
4058
  { command: "aplay", args: [soundPath] },
4000
- { command: "mpv", args: ["--no-video", "--no-terminal", soundPath] },
4001
- { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", soundPath] }
4059
+ { command: "mpv", args: ["--no-video", "--no-terminal", `--volume=${percentVolume}`, soundPath] },
4060
+ { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
4002
4061
  ];
4003
4062
  for (const player of players) {
4004
4063
  try {
@@ -4009,20 +4068,21 @@ async function playOnLinux(soundPath) {
4009
4068
  }
4010
4069
  }
4011
4070
  }
4012
- async function playOnMac(soundPath) {
4013
- await runCommand("afplay", [soundPath]);
4071
+ async function playOnMac(soundPath, volume) {
4072
+ await runCommand("afplay", ["-v", `${volume}`, soundPath]);
4014
4073
  }
4015
4074
  async function playOnWindows(soundPath) {
4016
4075
  const script = `& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }`;
4017
4076
  await runCommand("powershell", ["-c", script, soundPath]);
4018
4077
  }
4019
- async function playSound(event, customPath) {
4078
+ async function playSound(event, customPath, volume) {
4020
4079
  const now = Date.now();
4021
4080
  if (lastSoundTime[event] && now - lastSoundTime[event] < DEBOUNCE_MS2) {
4022
4081
  return;
4023
4082
  }
4024
4083
  lastSoundTime[event] = now;
4025
4084
  const soundPath = getSoundFilePath(event, customPath);
4085
+ const normalizedVolume = normalizeVolume(volume);
4026
4086
  if (!soundPath) {
4027
4087
  return;
4028
4088
  }
@@ -4030,10 +4090,10 @@ async function playSound(event, customPath) {
4030
4090
  try {
4031
4091
  switch (os2) {
4032
4092
  case "darwin":
4033
- await playOnMac(soundPath);
4093
+ await playOnMac(soundPath, normalizedVolume);
4034
4094
  break;
4035
4095
  case "linux":
4036
- await playOnLinux(soundPath);
4096
+ await playOnLinux(soundPath, normalizedVolume);
4037
4097
  break;
4038
4098
  case "win32":
4039
4099
  await playOnWindows(soundPath);
@@ -4067,6 +4127,15 @@ function runCommand2(config, event, message, sessionTitle, projectName) {
4067
4127
  }
4068
4128
 
4069
4129
  // src/index.ts
4130
+ var recentErrors = new Map;
4131
+ setInterval(() => {
4132
+ const cutoff = Date.now() - 5 * 60 * 1000;
4133
+ for (const [sessionID, timestamp] of recentErrors) {
4134
+ if (timestamp < cutoff) {
4135
+ recentErrors.delete(sessionID);
4136
+ }
4137
+ }
4138
+ }, 5 * 60 * 1000);
4070
4139
  function getNotificationTitle(config, projectName) {
4071
4140
  if (config.showProjectName && projectName) {
4072
4141
  return `OpenCode (${projectName})`;
@@ -4087,7 +4156,8 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
4087
4156
  }
4088
4157
  if (isEventSoundEnabled(config, eventType)) {
4089
4158
  const customSoundPath = getSoundPath(config, eventType);
4090
- promises.push(playSound(eventType, customSoundPath));
4159
+ const soundVolume = getSoundVolume(config, eventType);
4160
+ promises.push(playSound(eventType, customSoundPath, soundVolume));
4091
4161
  }
4092
4162
  const minDuration = config.command?.minDuration;
4093
4163
  const shouldSkipCommand = typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
@@ -4103,7 +4173,7 @@ function getSessionIDFromEvent(event) {
4103
4173
  }
4104
4174
  return null;
4105
4175
  }
4106
- async function getElapsedSinceLastPrompt(client, sessionID) {
4176
+ async function getElapsedSinceLastPrompt(client, sessionID, nowMs = Date.now()) {
4107
4177
  try {
4108
4178
  const response = await client.session.messages({ path: { id: sessionID } });
4109
4179
  const messages = response.data ?? [];
@@ -4117,7 +4187,7 @@ async function getElapsedSinceLastPrompt(client, sessionID) {
4117
4187
  }
4118
4188
  }
4119
4189
  if (lastUserMessageTime !== null) {
4120
- return (Date.now() - lastUserMessageTime) / 1000;
4190
+ return (nowMs - lastUserMessageTime) / 1000;
4121
4191
  }
4122
4192
  } catch {}
4123
4193
  return null;
@@ -4164,11 +4234,18 @@ var NotifierPlugin = async ({ client, directory }) => {
4164
4234
  if (event.type === "session.idle") {
4165
4235
  const sessionID = getSessionIDFromEvent(event);
4166
4236
  if (sessionID) {
4167
- const sessionInfo = await getSessionInfo(client, sessionID);
4168
- if (!sessionInfo.isChild) {
4169
- await handleEventWithElapsedTime(client, config, "complete", projectName, event, sessionInfo.title);
4237
+ const errorTime = recentErrors.get(sessionID);
4238
+ if (errorTime && Date.now() - errorTime < 500) {
4239
+ recentErrors.delete(sessionID);
4240
+ const sessionInfo = await getSessionInfo(client, sessionID);
4241
+ await handleEventWithElapsedTime(client, config, "interrupted", projectName, event, sessionInfo.title);
4170
4242
  } else {
4171
- await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, event, sessionInfo.title);
4243
+ const sessionInfo = await getSessionInfo(client, sessionID);
4244
+ if (!sessionInfo.isChild) {
4245
+ await handleEventWithElapsedTime(client, config, "complete", projectName, event, sessionInfo.title);
4246
+ } else {
4247
+ await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, event, sessionInfo.title);
4248
+ }
4172
4249
  }
4173
4250
  } else {
4174
4251
  await handleEventWithElapsedTime(client, config, "complete", projectName, event);
@@ -4176,12 +4253,22 @@ var NotifierPlugin = async ({ client, directory }) => {
4176
4253
  }
4177
4254
  if (event.type === "session.error") {
4178
4255
  const sessionID = getSessionIDFromEvent(event);
4179
- let sessionTitle = null;
4180
- if (sessionID && config.showSessionTitle) {
4181
- const info = await getSessionInfo(client, sessionID);
4182
- sessionTitle = info.title;
4256
+ if (sessionID) {
4257
+ recentErrors.set(sessionID, Date.now());
4258
+ let sessionTitle = null;
4259
+ if (config.showSessionTitle) {
4260
+ const info = await getSessionInfo(client, sessionID);
4261
+ sessionTitle = info.title;
4262
+ }
4263
+ setTimeout(() => {
4264
+ if (recentErrors.has(sessionID)) {
4265
+ recentErrors.delete(sessionID);
4266
+ handleEventWithElapsedTime(client, config, "error", projectName, event, sessionTitle);
4267
+ }
4268
+ }, 100);
4269
+ } else {
4270
+ await handleEventWithElapsedTime(client, config, "error", projectName, event);
4183
4271
  }
4184
- await handleEventWithElapsedTime(client, config, "error", projectName, event, sessionTitle);
4185
4272
  }
4186
4273
  },
4187
4274
  "permission.ask": async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.21-beta.0",
3
+ "version": "0.1.22-beta.0",
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",