@mohak34/opencode-notifier 0.1.20-beta.0 → 0.1.21-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 +24 -39
  2. package/dist/index.js +71 -92
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -52,6 +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
56
  "showIcon": true,
56
57
  "notificationSystem": "osascript",
57
58
  "command": {
@@ -68,11 +69,11 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
68
69
  "question": { "sound": true, "notification": true }
69
70
  },
70
71
  "messages": {
71
- "permission": "Session needs permission",
72
- "complete": "Session has finished",
73
- "subagent_complete": "Subagent task completed",
74
- "error": "Session encountered an error",
75
- "question": "Session has a question"
72
+ "permission": "Session needs permission: {sessionTitle}",
73
+ "complete": "Session has finished: {sessionTitle}",
74
+ "subagent_complete": "Subagent task completed: {sessionTitle}",
75
+ "error": "Session encountered an error: {sessionTitle}",
76
+ "question": "Session has a question: {sessionTitle}"
76
77
  },
77
78
  "sounds": {
78
79
  "permission": null,
@@ -80,13 +81,6 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
80
81
  "subagent_complete": null,
81
82
  "error": null,
82
83
  "question": null
83
- },
84
- "volumes": {
85
- "permission": 1,
86
- "complete": 1,
87
- "subagent_complete": 1,
88
- "error": 1,
89
- "question": 1
90
84
  }
91
85
  }
92
86
  ```
@@ -101,6 +95,7 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
101
95
  "notification": true,
102
96
  "timeout": 5,
103
97
  "showProjectName": true,
98
+ "showSessionTitle": true,
104
99
  "showIcon": true,
105
100
  "notificationSystem": "osascript"
106
101
  }
@@ -110,6 +105,7 @@ Create `~/.config/opencode/opencode-notifier.json` with the defaults:
110
105
  - `notification` - Turn notifications on/off (default: true)
111
106
  - `timeout` - How long notifications show in seconds, Linux only (default: 5)
112
107
  - `showProjectName` - Show folder name in notification title (default: true)
108
+ - `showSessionTitle` - Include the session title in notification messages via `{sessionTitle}` placeholder (default: true)
113
109
  - `showIcon` - Show OpenCode icon, Windows/Linux only (default: true)
114
110
  - `notificationSystem` - macOS only: `"osascript"` or `"node-notifier"` (default: "osascript")
115
111
 
@@ -146,15 +142,24 @@ Customize the notification text:
146
142
  ```json
147
143
  {
148
144
  "messages": {
149
- "permission": "Session needs permission",
150
- "complete": "Session has finished",
151
- "subagent_complete": "Subagent task completed",
152
- "error": "Session encountered an error",
153
- "question": "Session has a question"
145
+ "permission": "Session needs permission: {sessionTitle}",
146
+ "complete": "Session has finished: {sessionTitle}",
147
+ "subagent_complete": "Subagent task completed: {sessionTitle}",
148
+ "error": "Session encountered an error: {sessionTitle}",
149
+ "question": "Session has a question: {sessionTitle}"
154
150
  }
155
151
  }
156
152
  ```
157
153
 
154
+ Messages support placeholder tokens that get replaced with actual values:
155
+
156
+ - `{sessionTitle}` - The title/summary of the current session (e.g. "Fix login bug")
157
+ - `{projectName}` - The project folder name
158
+
159
+ When `showSessionTitle` is `false`, `{sessionTitle}` is replaced with an empty string. Any trailing separators (`: `, ` - `, ` | `) are automatically cleaned up when a placeholder resolves to empty.
160
+
161
+ To disable session titles in messages without changing `showSessionTitle`, just remove the `{sessionTitle}` placeholder from your custom messages.
162
+
158
163
  ### Sounds
159
164
 
160
165
  Use your own sound files:
@@ -176,29 +181,9 @@ Platform notes:
176
181
  - Windows: Only .wav files work
177
182
  - If file doesn't exist, falls back to bundled sound
178
183
 
179
- ### Volumes
180
-
181
- Set per-event volume from `0` to `1`:
182
-
183
- ```json
184
- {
185
- "volumes": {
186
- "permission": 0.6,
187
- "complete": 0.3,
188
- "subagent_complete": 0.15,
189
- "error": 1,
190
- "question": 0.7
191
- }
192
- }
193
- ```
194
-
195
- - `0` = mute, `1` = full volume
196
- - Values outside `0..1` are clamped automatically
197
- - On Windows, playback still works but custom volume may not be honored by the default player
198
-
199
184
  ### Custom commands
200
185
 
201
- Run your own script when something happens. Use `{event}` and `{message}` as placeholders:
186
+ Run your own script when something happens. Use `{event}`, `{message}`, and `{sessionTitle}` as placeholders:
202
187
 
203
188
  ```json
204
189
  {
@@ -213,7 +198,7 @@ Run your own script when something happens. Use `{event}` and `{message}` as pla
213
198
 
214
199
  - `enabled` - Turn command on/off
215
200
  - `path` - Path to your script/executable
216
- - `args` - Arguments to pass, can use `{event}` and `{message}` tokens
201
+ - `args` - Arguments to pass, can use `{event}`, `{message}`, and `{sessionTitle}` tokens
217
202
  - `minDuration` - Skip if response was quick, avoids spam (seconds)
218
203
 
219
204
  #### Example: Log events to a file
package/dist/index.js CHANGED
@@ -3743,6 +3743,7 @@ var DEFAULT_CONFIG = {
3743
3743
  notification: true,
3744
3744
  timeout: 5,
3745
3745
  showProjectName: true,
3746
+ showSessionTitle: true,
3746
3747
  showIcon: true,
3747
3748
  notificationSystem: "osascript",
3748
3749
  command: {
@@ -3758,11 +3759,11 @@ var DEFAULT_CONFIG = {
3758
3759
  question: { ...DEFAULT_EVENT_CONFIG }
3759
3760
  },
3760
3761
  messages: {
3761
- permission: "Session needs permission",
3762
- complete: "Session has finished",
3763
- subagent_complete: "Subagent task completed",
3764
- error: "Session encountered an error",
3765
- question: "Session has a question"
3762
+ permission: "Session needs permission: {sessionTitle}",
3763
+ complete: "Session has finished: {sessionTitle}",
3764
+ subagent_complete: "Subagent task completed: {sessionTitle}",
3765
+ error: "Session encountered an error: {sessionTitle}",
3766
+ question: "Session has a question: {sessionTitle}"
3766
3767
  },
3767
3768
  sounds: {
3768
3769
  permission: null,
@@ -3770,13 +3771,6 @@ var DEFAULT_CONFIG = {
3770
3771
  subagent_complete: null,
3771
3772
  error: null,
3772
3773
  question: null
3773
- },
3774
- volumes: {
3775
- permission: 1,
3776
- complete: 1,
3777
- subagent_complete: 1,
3778
- error: 1,
3779
- question: 1
3780
3774
  }
3781
3775
  };
3782
3776
  function getConfigPath() {
@@ -3797,18 +3791,6 @@ function parseEventConfig(userEvent, defaultConfig) {
3797
3791
  notification: userEvent.notification ?? defaultConfig.notification
3798
3792
  };
3799
3793
  }
3800
- function parseVolume(value, defaultVolume) {
3801
- if (typeof value !== "number" || !Number.isFinite(value)) {
3802
- return defaultVolume;
3803
- }
3804
- if (value < 0) {
3805
- return 0;
3806
- }
3807
- if (value > 1) {
3808
- return 1;
3809
- }
3810
- return value;
3811
- }
3812
3794
  function loadConfig() {
3813
3795
  const configPath = getConfigPath();
3814
3796
  if (!existsSync(configPath)) {
@@ -3831,6 +3813,7 @@ function loadConfig() {
3831
3813
  notification: globalNotification,
3832
3814
  timeout: typeof userConfig.timeout === "number" && userConfig.timeout > 0 ? userConfig.timeout : DEFAULT_CONFIG.timeout,
3833
3815
  showProjectName: userConfig.showProjectName ?? DEFAULT_CONFIG.showProjectName,
3816
+ showSessionTitle: userConfig.showSessionTitle ?? DEFAULT_CONFIG.showSessionTitle,
3834
3817
  showIcon: userConfig.showIcon ?? DEFAULT_CONFIG.showIcon,
3835
3818
  notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : "osascript",
3836
3819
  command: {
@@ -3859,13 +3842,6 @@ function loadConfig() {
3859
3842
  subagent_complete: userConfig.sounds?.subagent_complete ?? DEFAULT_CONFIG.sounds.subagent_complete,
3860
3843
  error: userConfig.sounds?.error ?? DEFAULT_CONFIG.sounds.error,
3861
3844
  question: userConfig.sounds?.question ?? DEFAULT_CONFIG.sounds.question
3862
- },
3863
- volumes: {
3864
- permission: parseVolume(userConfig.volumes?.permission, DEFAULT_CONFIG.volumes.permission),
3865
- complete: parseVolume(userConfig.volumes?.complete, DEFAULT_CONFIG.volumes.complete),
3866
- subagent_complete: parseVolume(userConfig.volumes?.subagent_complete, DEFAULT_CONFIG.volumes.subagent_complete),
3867
- error: parseVolume(userConfig.volumes?.error, DEFAULT_CONFIG.volumes.error),
3868
- question: parseVolume(userConfig.volumes?.question, DEFAULT_CONFIG.volumes.question)
3869
3845
  }
3870
3846
  };
3871
3847
  } catch {
@@ -3884,9 +3860,6 @@ function getMessage(config, event) {
3884
3860
  function getSoundPath(config, event) {
3885
3861
  return config.sounds[event];
3886
3862
  }
3887
- function getSoundVolume(config, event) {
3888
- return config.volumes[event];
3889
- }
3890
3863
  function getIconPath(config) {
3891
3864
  if (!config.showIcon) {
3892
3865
  return;
@@ -3901,6 +3874,16 @@ function getIconPath(config) {
3901
3874
  } catch {}
3902
3875
  return;
3903
3876
  }
3877
+ function interpolateMessage(message, context) {
3878
+ let result = message;
3879
+ const sessionTitle = context.sessionTitle || "";
3880
+ result = result.replaceAll("{sessionTitle}", sessionTitle);
3881
+ const projectName = context.projectName || "";
3882
+ result = result.replaceAll("{projectName}", projectName);
3883
+ result = result.replace(/\s*[:\-|]\s*$/, "").trim();
3884
+ result = result.replace(/\s{2,}/g, " ");
3885
+ return result;
3886
+ }
3904
3887
 
3905
3888
  // src/notify.ts
3906
3889
  var import_node_notifier = __toESM(require_node_notifier(), 1);
@@ -3968,8 +3951,6 @@ import { existsSync as existsSync2 } from "fs";
3968
3951
  import { spawn } from "child_process";
3969
3952
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
3970
3953
  var DEBOUNCE_MS2 = 1000;
3971
- var FULL_VOLUME_PERCENT = 100;
3972
- var FULL_VOLUME_PULSE = 65536;
3973
3954
  var lastSoundTime = {};
3974
3955
  function getBundledSoundPath(event) {
3975
3956
  const soundFilename = `${event}.wav`;
@@ -4012,32 +3993,12 @@ async function runCommand(command, args) {
4012
3993
  });
4013
3994
  });
4014
3995
  }
4015
- function normalizeVolume(volume) {
4016
- if (!Number.isFinite(volume)) {
4017
- return 1;
4018
- }
4019
- if (volume < 0) {
4020
- return 0;
4021
- }
4022
- if (volume > 1) {
4023
- return 1;
4024
- }
4025
- return volume;
4026
- }
4027
- function toPercentVolume(volume) {
4028
- return Math.round(volume * FULL_VOLUME_PERCENT);
4029
- }
4030
- function toPulseVolume(volume) {
4031
- return Math.round(volume * FULL_VOLUME_PULSE);
4032
- }
4033
- async function playOnLinux(soundPath, volume) {
4034
- const percentVolume = toPercentVolume(volume);
4035
- const pulseVolume = toPulseVolume(volume);
3996
+ async function playOnLinux(soundPath) {
4036
3997
  const players = [
4037
- { command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
3998
+ { command: "paplay", args: [soundPath] },
4038
3999
  { command: "aplay", args: [soundPath] },
4039
- { command: "mpv", args: ["--no-video", "--no-terminal", `--volume=${percentVolume}`, soundPath] },
4040
- { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
4000
+ { command: "mpv", args: ["--no-video", "--no-terminal", soundPath] },
4001
+ { command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", soundPath] }
4041
4002
  ];
4042
4003
  for (const player of players) {
4043
4004
  try {
@@ -4048,21 +4009,20 @@ async function playOnLinux(soundPath, volume) {
4048
4009
  }
4049
4010
  }
4050
4011
  }
4051
- async function playOnMac(soundPath, volume) {
4052
- await runCommand("afplay", ["-v", `${volume}`, soundPath]);
4012
+ async function playOnMac(soundPath) {
4013
+ await runCommand("afplay", [soundPath]);
4053
4014
  }
4054
4015
  async function playOnWindows(soundPath) {
4055
4016
  const script = `& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }`;
4056
4017
  await runCommand("powershell", ["-c", script, soundPath]);
4057
4018
  }
4058
- async function playSound(event, customPath, volume) {
4019
+ async function playSound(event, customPath) {
4059
4020
  const now = Date.now();
4060
4021
  if (lastSoundTime[event] && now - lastSoundTime[event] < DEBOUNCE_MS2) {
4061
4022
  return;
4062
4023
  }
4063
4024
  lastSoundTime[event] = now;
4064
4025
  const soundPath = getSoundFilePath(event, customPath);
4065
- const normalizedVolume = normalizeVolume(volume);
4066
4026
  if (!soundPath) {
4067
4027
  return;
4068
4028
  }
@@ -4070,10 +4030,10 @@ async function playSound(event, customPath, volume) {
4070
4030
  try {
4071
4031
  switch (os2) {
4072
4032
  case "darwin":
4073
- await playOnMac(soundPath, normalizedVolume);
4033
+ await playOnMac(soundPath);
4074
4034
  break;
4075
4035
  case "linux":
4076
- await playOnLinux(soundPath, normalizedVolume);
4036
+ await playOnLinux(soundPath);
4077
4037
  break;
4078
4038
  case "win32":
4079
4039
  await playOnWindows(soundPath);
@@ -4086,15 +4046,18 @@ async function playSound(event, customPath, volume) {
4086
4046
 
4087
4047
  // src/command.ts
4088
4048
  import { spawn as spawn2 } from "child_process";
4089
- function substituteTokens(value, event, message) {
4090
- return value.replaceAll("{event}", event).replaceAll("{message}", message);
4049
+ function substituteTokens(value, event, message, sessionTitle, projectName) {
4050
+ let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
4051
+ result = result.replaceAll("{sessionTitle}", sessionTitle || "");
4052
+ result = result.replaceAll("{projectName}", projectName || "");
4053
+ return result;
4091
4054
  }
4092
- function runCommand2(config, event, message) {
4055
+ function runCommand2(config, event, message, sessionTitle, projectName) {
4093
4056
  if (!config.command.enabled || !config.command.path) {
4094
4057
  return;
4095
4058
  }
4096
- const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message));
4097
- const command = substituteTokens(config.command.path, event, message);
4059
+ const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, projectName));
4060
+ const command = substituteTokens(config.command.path, event, message, sessionTitle, projectName);
4098
4061
  const proc = spawn2(command, args, {
4099
4062
  stdio: "ignore",
4100
4063
  detached: true
@@ -4110,9 +4073,13 @@ function getNotificationTitle(config, projectName) {
4110
4073
  }
4111
4074
  return "OpenCode";
4112
4075
  }
4113
- async function handleEvent(config, eventType, projectName, elapsedSeconds) {
4076
+ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle) {
4114
4077
  const promises = [];
4115
- const message = getMessage(config, eventType);
4078
+ const rawMessage = getMessage(config, eventType);
4079
+ const message = interpolateMessage(rawMessage, {
4080
+ sessionTitle: config.showSessionTitle ? sessionTitle : null,
4081
+ projectName
4082
+ });
4116
4083
  if (isEventNotificationEnabled(config, eventType)) {
4117
4084
  const title = getNotificationTitle(config, projectName);
4118
4085
  const iconPath = getIconPath(config);
@@ -4120,13 +4087,12 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds) {
4120
4087
  }
4121
4088
  if (isEventSoundEnabled(config, eventType)) {
4122
4089
  const customSoundPath = getSoundPath(config, eventType);
4123
- const soundVolume = getSoundVolume(config, eventType);
4124
- promises.push(playSound(eventType, customSoundPath, soundVolume));
4090
+ promises.push(playSound(eventType, customSoundPath));
4125
4091
  }
4126
4092
  const minDuration = config.command?.minDuration;
4127
4093
  const shouldSkipCommand = typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
4128
4094
  if (!shouldSkipCommand) {
4129
- runCommand2(config, eventType, message);
4095
+ runCommand2(config, eventType, message, sessionTitle, projectName);
4130
4096
  }
4131
4097
  await Promise.allSettled(promises);
4132
4098
  }
@@ -4156,26 +4122,33 @@ async function getElapsedSinceLastPrompt(client, sessionID) {
4156
4122
  } catch {}
4157
4123
  return null;
4158
4124
  }
4159
- async function isChildSession(client, sessionID) {
4125
+ async function getSessionInfo(client, sessionID) {
4160
4126
  try {
4161
4127
  const response = await client.session.get({ path: { id: sessionID } });
4162
- const parentID = response.data?.parentID;
4163
- return !!parentID;
4128
+ return {
4129
+ isChild: !!response.data?.parentID,
4130
+ title: response.data?.title ?? null
4131
+ };
4164
4132
  } catch {
4165
- return false;
4133
+ return { isChild: false, title: null };
4166
4134
  }
4167
4135
  }
4168
- async function handleEventWithElapsedTime(client, config, eventType, projectName, event) {
4169
- const minDuration = config.command?.minDuration;
4170
- const shouldLookupElapsed = !!config.command?.enabled && typeof config.command?.path === "string" && config.command.path.length > 0 && typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0;
4136
+ async function handleEventWithElapsedTime(client, config, eventType, projectName, event, preloadedSessionTitle) {
4137
+ const sessionID = getSessionIDFromEvent(event);
4171
4138
  let elapsedSeconds = null;
4172
- if (shouldLookupElapsed) {
4173
- const sessionID = getSessionIDFromEvent(event);
4174
- if (sessionID) {
4139
+ let sessionTitle = preloadedSessionTitle ?? null;
4140
+ if (sessionID) {
4141
+ const minDuration = config.command?.minDuration;
4142
+ const shouldLookupElapsed = !!config.command?.enabled && typeof config.command?.path === "string" && config.command.path.length > 0 && typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0;
4143
+ if (shouldLookupElapsed) {
4175
4144
  elapsedSeconds = await getElapsedSinceLastPrompt(client, sessionID);
4176
4145
  }
4146
+ if (!sessionTitle && config.showSessionTitle) {
4147
+ const info = await getSessionInfo(client, sessionID);
4148
+ sessionTitle = info.title;
4149
+ }
4177
4150
  }
4178
- await handleEvent(config, eventType, projectName, elapsedSeconds);
4151
+ await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle);
4179
4152
  }
4180
4153
  var NotifierPlugin = async ({ client, directory }) => {
4181
4154
  const config = loadConfig();
@@ -4191,18 +4164,24 @@ var NotifierPlugin = async ({ client, directory }) => {
4191
4164
  if (event.type === "session.idle") {
4192
4165
  const sessionID = getSessionIDFromEvent(event);
4193
4166
  if (sessionID) {
4194
- const isChild = await isChildSession(client, sessionID);
4195
- if (!isChild) {
4196
- await handleEventWithElapsedTime(client, config, "complete", projectName, event);
4167
+ const sessionInfo = await getSessionInfo(client, sessionID);
4168
+ if (!sessionInfo.isChild) {
4169
+ await handleEventWithElapsedTime(client, config, "complete", projectName, event, sessionInfo.title);
4197
4170
  } else {
4198
- await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, event);
4171
+ await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, event, sessionInfo.title);
4199
4172
  }
4200
4173
  } else {
4201
4174
  await handleEventWithElapsedTime(client, config, "complete", projectName, event);
4202
4175
  }
4203
4176
  }
4204
4177
  if (event.type === "session.error") {
4205
- await handleEventWithElapsedTime(client, config, "error", projectName, event);
4178
+ const sessionID = getSessionIDFromEvent(event);
4179
+ let sessionTitle = null;
4180
+ if (sessionID && config.showSessionTitle) {
4181
+ const info = await getSessionInfo(client, sessionID);
4182
+ sessionTitle = info.title;
4183
+ }
4184
+ await handleEventWithElapsedTime(client, config, "error", projectName, event, sessionTitle);
4206
4185
  }
4207
4186
  },
4208
4187
  "permission.ask": async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.20-beta.0",
3
+ "version": "0.1.21-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",