@mohak34/opencode-notifier 0.1.30-beta.1 → 0.1.30-beta.2

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 +22 -11
  2. package/dist/index.js +48 -9
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -176,11 +176,15 @@ Messages support placeholder tokens that get replaced with actual values:
176
176
 
177
177
  - `{sessionTitle}` - The title/summary of the current session (e.g. "Fix login bug")
178
178
  - `{projectName}` - The project folder name
179
+ - `{timestamp}` - Current time in `HH:MM:SS` format (e.g. "14:30:05")
180
+ - `{turn}` - Notification counter for the session, increments with each notification (e.g. 1, 2, 3)
179
181
 
180
182
  When `showSessionTitle` is `false`, `{sessionTitle}` is replaced with an empty string. Any trailing separators (`: `, ` - `, ` | `) are automatically cleaned up when a placeholder resolves to empty.
181
183
 
182
184
  To disable session titles in messages without changing `showSessionTitle`, just remove the `{sessionTitle}` placeholder from your custom messages.
183
185
 
186
+ The `{timestamp}` and `{turn}` placeholders also work in custom command args.
187
+
184
188
  ### Sounds
185
189
 
186
190
  Use your own sound files:
@@ -293,17 +297,24 @@ To disable this and always get notified:
293
297
 
294
298
  ### Platform support
295
299
 
296
- | Platform | Method | Requirements |
297
- |----------|--------|--------------|
298
- | macOS | AppleScript (`System Events`) | None |
299
- | Linux X11 | `xdotool` | `xdotool` installed |
300
- | Linux Wayland (Hyprland) | `hyprctl activewindow` | None |
301
- | Linux Wayland (Sway) | `swaymsg -t get_tree` | None |
302
- | Linux Wayland (KDE) | `kdotool` | `kdotool` installed |
303
- | Linux Wayland (GNOME) | Not supported | Falls back to always notifying |
304
- | Windows | `GetForegroundWindow()` via PowerShell | None |
305
-
306
- If detection fails for any reason (missing tools, unknown compositor, permissions), it falls back to always notifying. It never silently eats your notifications.
300
+ | Platform | Method | Requirements | Status |
301
+ |----------|--------|--------------|--------|
302
+ | macOS | AppleScript (`System Events`) | None | Untested |
303
+ | Linux X11 | `xdotool` | `xdotool` installed | Untested |
304
+ | Linux Wayland (Hyprland) | `hyprctl activewindow` | None | Tested |
305
+ | Linux Wayland (Sway) | `swaymsg -t get_tree` | None | Untested |
306
+ | Linux Wayland (KDE) | `kdotool` | `kdotool` installed | Untested |
307
+ | Linux Wayland (GNOME) | Not supported | - | Falls back to always notifying |
308
+ | Linux Wayland (Niri, river, dwl, Cosmic, etc.) | Not supported | - | Falls back to always notifying |
309
+ | Windows | `GetForegroundWindow()` via PowerShell | None | Untested |
310
+
311
+ **Unsupported compositors**: Wayland has no standard protocol for querying the focused window. Each compositor has its own IPC, and GNOME intentionally doesn't expose focus information. Unsupported compositors fall back to always notifying.
312
+
313
+ **tmux/screen**: When running inside tmux, the tmux server daemonizes and detaches from the terminal's process tree. The plugin handles this by querying the tmux client PID and walking from there. GNU Screen has the same issue but is not currently handled (falls back to always notifying).
314
+
315
+ **Fail-open design**: If detection fails for any reason (missing tools, unknown compositor, permissions), it falls back to always notifying. It never silently eats your notifications.
316
+
317
+ If you test on a platform marked "Untested" and it works (or doesn't), please open an issue and let us know.
307
318
 
308
319
  ## Linux: Notification Grouping
309
320
 
package/dist/index.js CHANGED
@@ -3850,7 +3850,7 @@ function loadConfig() {
3850
3850
  showSessionTitle: userConfig.showSessionTitle ?? DEFAULT_CONFIG.showSessionTitle,
3851
3851
  showIcon: userConfig.showIcon ?? DEFAULT_CONFIG.showIcon,
3852
3852
  suppressWhenFocused: userConfig.suppressWhenFocused ?? DEFAULT_CONFIG.suppressWhenFocused,
3853
- notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : "osascript",
3853
+ notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : userConfig.notificationSystem === "ghostty" ? "ghostty" : "osascript",
3854
3854
  linux: {
3855
3855
  grouping: typeof userConfig.linux?.grouping === "boolean" ? userConfig.linux.grouping : DEFAULT_CONFIG.linux.grouping
3856
3856
  },
@@ -3936,6 +3936,10 @@ function interpolateMessage(message, context) {
3936
3936
  result = result.replaceAll("{sessionTitle}", sessionTitle);
3937
3937
  const projectName = context.projectName || "";
3938
3938
  result = result.replaceAll("{projectName}", projectName);
3939
+ const timestamp = context.timestamp || "";
3940
+ result = result.replaceAll("{timestamp}", timestamp);
3941
+ const turn = context.turn != null ? String(context.turn) : "";
3942
+ result = result.replaceAll("{turn}", turn);
3939
3943
  result = result.replace(/\s*[:\-|]\s*$/, "").trim();
3940
3944
  result = result.replace(/\s{2,}/g, " ");
3941
3945
  return result;
@@ -4009,6 +4013,15 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
4009
4013
  return;
4010
4014
  }
4011
4015
  lastNotificationTime[message] = now;
4016
+ if (notificationSystem === "ghostty") {
4017
+ return new Promise((resolve) => {
4018
+ const escapedTitle = title.replace(/[;\x07\x1b\n\r]/g, "");
4019
+ const escapedMessage = message.replace(/[;\x07\x1b\n\r]/g, "");
4020
+ process.stdout.write(`\x1B]777;notify;${escapedTitle};${escapedMessage}\x07`, () => {
4021
+ resolve();
4022
+ });
4023
+ });
4024
+ }
4012
4025
  if (platform === "Darwin") {
4013
4026
  if (notificationSystem === "node-notifier") {
4014
4027
  return new Promise((resolve) => {
@@ -4180,18 +4193,20 @@ async function playSound(event, customPath, volume) {
4180
4193
 
4181
4194
  // src/command.ts
4182
4195
  import { spawn as spawn2 } from "child_process";
4183
- function substituteTokens(value, event, message, sessionTitle, projectName) {
4196
+ function substituteTokens(value, event, message, sessionTitle, projectName, timestamp, turn) {
4184
4197
  let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
4185
4198
  result = result.replaceAll("{sessionTitle}", sessionTitle || "");
4186
4199
  result = result.replaceAll("{projectName}", projectName || "");
4200
+ result = result.replaceAll("{timestamp}", timestamp || "");
4201
+ result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
4187
4202
  return result;
4188
4203
  }
4189
- function runCommand2(config, event, message, sessionTitle, projectName) {
4204
+ function runCommand2(config, event, message, sessionTitle, projectName, timestamp, turn) {
4190
4205
  if (!config.command.enabled || !config.command.path) {
4191
4206
  return;
4192
4207
  }
4193
- const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, projectName));
4194
- const command = substituteTokens(config.command.path, event, message, sessionTitle, projectName);
4208
+ const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, projectName, timestamp, turn));
4209
+ const command = substituteTokens(config.command.path, event, message, sessionTitle, projectName, timestamp, turn);
4195
4210
  const proc = spawn2(command, args, {
4196
4211
  stdio: "ignore",
4197
4212
  detached: true
@@ -4451,6 +4466,7 @@ var pendingIdleTimers = new Map;
4451
4466
  var sessionIdleSequence = new Map;
4452
4467
  var sessionErrorSuppressionAt = new Map;
4453
4468
  var sessionLastBusyAt = new Map;
4469
+ var sessionTurnCount = new Map;
4454
4470
  setInterval(() => {
4455
4471
  const cutoff = Date.now() - 5 * 60 * 1000;
4456
4472
  for (const [sessionID] of sessionIdleSequence) {
@@ -4468,6 +4484,11 @@ setInterval(() => {
4468
4484
  sessionLastBusyAt.delete(sessionID);
4469
4485
  }
4470
4486
  }
4487
+ for (const [sessionID] of sessionTurnCount) {
4488
+ if (!pendingIdleTimers.has(sessionID) && !sessionLastBusyAt.has(sessionID)) {
4489
+ sessionTurnCount.delete(sessionID);
4490
+ }
4491
+ }
4471
4492
  }, 5 * 60 * 1000);
4472
4493
  function getNotificationTitle(config, projectName) {
4473
4494
  if (config.showProjectName && projectName) {
@@ -4475,15 +4496,33 @@ function getNotificationTitle(config, projectName) {
4475
4496
  }
4476
4497
  return "OpenCode";
4477
4498
  }
4478
- async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle) {
4499
+ function formatTimestamp() {
4500
+ const now = new Date;
4501
+ const h = String(now.getHours()).padStart(2, "0");
4502
+ const m = String(now.getMinutes()).padStart(2, "0");
4503
+ const s = String(now.getSeconds()).padStart(2, "0");
4504
+ return `${h}:${m}:${s}`;
4505
+ }
4506
+ function incrementSessionTurn(sessionID) {
4507
+ if (!sessionID)
4508
+ return 1;
4509
+ const next = (sessionTurnCount.get(sessionID) ?? 0) + 1;
4510
+ sessionTurnCount.set(sessionID, next);
4511
+ return next;
4512
+ }
4513
+ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID) {
4479
4514
  if (config.suppressWhenFocused && isTerminalFocused()) {
4480
4515
  return;
4481
4516
  }
4482
4517
  const promises = [];
4518
+ const timestamp = formatTimestamp();
4519
+ const turn = incrementSessionTurn(sessionID ?? null);
4483
4520
  const rawMessage = getMessage(config, eventType);
4484
4521
  const message = interpolateMessage(rawMessage, {
4485
4522
  sessionTitle: config.showSessionTitle ? sessionTitle : null,
4486
- projectName
4523
+ projectName,
4524
+ timestamp,
4525
+ turn
4487
4526
  });
4488
4527
  if (isEventNotificationEnabled(config, eventType)) {
4489
4528
  const title = getNotificationTitle(config, projectName);
@@ -4498,7 +4537,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
4498
4537
  const minDuration = config.command?.minDuration;
4499
4538
  const shouldSkipCommand = typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
4500
4539
  if (!shouldSkipCommand) {
4501
- runCommand2(config, eventType, message, sessionTitle, projectName);
4540
+ runCommand2(config, eventType, message, sessionTitle, projectName, timestamp, turn);
4502
4541
  }
4503
4542
  await Promise.allSettled(promises);
4504
4543
  }
@@ -4632,7 +4671,7 @@ async function handleEventWithElapsedTime(client, config, eventType, projectName
4632
4671
  const info = await getSessionInfo(client, sessionID);
4633
4672
  sessionTitle = info.title;
4634
4673
  }
4635
- await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle);
4674
+ await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID);
4636
4675
  }
4637
4676
  var NotifierPlugin = async ({ client, directory }) => {
4638
4677
  const getConfig = () => loadConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohak34/opencode-notifier",
3
- "version": "0.1.30-beta.1",
3
+ "version": "0.1.30-beta.2",
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",