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

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 +64 -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
@@ -3728,6 +3728,7 @@ var require_node_notifier = __commonJS((exports, module) => {
3728
3728
 
3729
3729
  // src/index.ts
3730
3730
  import { basename } from "path";
3731
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
3731
3732
 
3732
3733
  // src/config.ts
3733
3734
  import { readFileSync, existsSync } from "fs";
@@ -3798,6 +3799,10 @@ function getConfigPath() {
3798
3799
  }
3799
3800
  return join(homedir(), ".config", "opencode", "opencode-notifier.json");
3800
3801
  }
3802
+ function getStatePath() {
3803
+ const configPath = getConfigPath();
3804
+ return join(dirname(configPath), "opencode-notifier-state.json");
3805
+ }
3801
3806
  function parseEventConfig(userEvent, defaultConfig) {
3802
3807
  if (userEvent === undefined) {
3803
3808
  return defaultConfig;
@@ -3850,7 +3855,7 @@ function loadConfig() {
3850
3855
  showSessionTitle: userConfig.showSessionTitle ?? DEFAULT_CONFIG.showSessionTitle,
3851
3856
  showIcon: userConfig.showIcon ?? DEFAULT_CONFIG.showIcon,
3852
3857
  suppressWhenFocused: userConfig.suppressWhenFocused ?? DEFAULT_CONFIG.suppressWhenFocused,
3853
- notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : "osascript",
3858
+ notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : userConfig.notificationSystem === "ghostty" ? "ghostty" : "osascript",
3854
3859
  linux: {
3855
3860
  grouping: typeof userConfig.linux?.grouping === "boolean" ? userConfig.linux.grouping : DEFAULT_CONFIG.linux.grouping
3856
3861
  },
@@ -3936,6 +3941,10 @@ function interpolateMessage(message, context) {
3936
3941
  result = result.replaceAll("{sessionTitle}", sessionTitle);
3937
3942
  const projectName = context.projectName || "";
3938
3943
  result = result.replaceAll("{projectName}", projectName);
3944
+ const timestamp = context.timestamp || "";
3945
+ result = result.replaceAll("{timestamp}", timestamp);
3946
+ const turn = context.turn != null ? String(context.turn) : "";
3947
+ result = result.replaceAll("{turn}", turn);
3939
3948
  result = result.replace(/\s*[:\-|]\s*$/, "").trim();
3940
3949
  result = result.replace(/\s{2,}/g, " ");
3941
3950
  return result;
@@ -4009,6 +4018,15 @@ async function sendNotification(title, message, timeout, iconPath, notificationS
4009
4018
  return;
4010
4019
  }
4011
4020
  lastNotificationTime[message] = now;
4021
+ if (notificationSystem === "ghostty") {
4022
+ return new Promise((resolve) => {
4023
+ const escapedTitle = title.replace(/[;\x07\x1b\n\r]/g, "");
4024
+ const escapedMessage = message.replace(/[;\x07\x1b\n\r]/g, "");
4025
+ process.stdout.write(`\x1B]777;notify;${escapedTitle};${escapedMessage}\x07`, () => {
4026
+ resolve();
4027
+ });
4028
+ });
4029
+ }
4012
4030
  if (platform === "Darwin") {
4013
4031
  if (notificationSystem === "node-notifier") {
4014
4032
  return new Promise((resolve) => {
@@ -4180,18 +4198,20 @@ async function playSound(event, customPath, volume) {
4180
4198
 
4181
4199
  // src/command.ts
4182
4200
  import { spawn as spawn2 } from "child_process";
4183
- function substituteTokens(value, event, message, sessionTitle, projectName) {
4201
+ function substituteTokens(value, event, message, sessionTitle, projectName, timestamp, turn) {
4184
4202
  let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
4185
4203
  result = result.replaceAll("{sessionTitle}", sessionTitle || "");
4186
4204
  result = result.replaceAll("{projectName}", projectName || "");
4205
+ result = result.replaceAll("{timestamp}", timestamp || "");
4206
+ result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
4187
4207
  return result;
4188
4208
  }
4189
- function runCommand2(config, event, message, sessionTitle, projectName) {
4209
+ function runCommand2(config, event, message, sessionTitle, projectName, timestamp, turn) {
4190
4210
  if (!config.command.enabled || !config.command.path) {
4191
4211
  return;
4192
4212
  }
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);
4213
+ const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, projectName, timestamp, turn));
4214
+ const command = substituteTokens(config.command.path, event, message, sessionTitle, projectName, timestamp, turn);
4195
4215
  const proc = spawn2(command, args, {
4196
4216
  stdio: "ignore",
4197
4217
  detached: true
@@ -4451,6 +4471,30 @@ var pendingIdleTimers = new Map;
4451
4471
  var sessionIdleSequence = new Map;
4452
4472
  var sessionErrorSuppressionAt = new Map;
4453
4473
  var sessionLastBusyAt = new Map;
4474
+ var globalTurnCount = null;
4475
+ function loadTurnCount() {
4476
+ try {
4477
+ const content = readFileSync3(getStatePath(), "utf-8");
4478
+ const state = JSON.parse(content);
4479
+ if (typeof state.turn === "number" && Number.isFinite(state.turn) && state.turn >= 0) {
4480
+ return state.turn;
4481
+ }
4482
+ } catch {}
4483
+ return 0;
4484
+ }
4485
+ function saveTurnCount(count) {
4486
+ try {
4487
+ writeFileSync2(getStatePath(), JSON.stringify({ turn: count }));
4488
+ } catch {}
4489
+ }
4490
+ function incrementTurnCount() {
4491
+ if (globalTurnCount === null) {
4492
+ globalTurnCount = loadTurnCount();
4493
+ }
4494
+ globalTurnCount++;
4495
+ saveTurnCount(globalTurnCount);
4496
+ return globalTurnCount;
4497
+ }
4454
4498
  setInterval(() => {
4455
4499
  const cutoff = Date.now() - 5 * 60 * 1000;
4456
4500
  for (const [sessionID] of sessionIdleSequence) {
@@ -4475,15 +4519,26 @@ function getNotificationTitle(config, projectName) {
4475
4519
  }
4476
4520
  return "OpenCode";
4477
4521
  }
4478
- async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle) {
4522
+ function formatTimestamp() {
4523
+ const now = new Date;
4524
+ const h = String(now.getHours()).padStart(2, "0");
4525
+ const m = String(now.getMinutes()).padStart(2, "0");
4526
+ const s = String(now.getSeconds()).padStart(2, "0");
4527
+ return `${h}:${m}:${s}`;
4528
+ }
4529
+ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID) {
4479
4530
  if (config.suppressWhenFocused && isTerminalFocused()) {
4480
4531
  return;
4481
4532
  }
4482
4533
  const promises = [];
4534
+ const timestamp = formatTimestamp();
4535
+ const turn = incrementTurnCount();
4483
4536
  const rawMessage = getMessage(config, eventType);
4484
4537
  const message = interpolateMessage(rawMessage, {
4485
4538
  sessionTitle: config.showSessionTitle ? sessionTitle : null,
4486
- projectName
4539
+ projectName,
4540
+ timestamp,
4541
+ turn
4487
4542
  });
4488
4543
  if (isEventNotificationEnabled(config, eventType)) {
4489
4544
  const title = getNotificationTitle(config, projectName);
@@ -4498,7 +4553,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
4498
4553
  const minDuration = config.command?.minDuration;
4499
4554
  const shouldSkipCommand = typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
4500
4555
  if (!shouldSkipCommand) {
4501
- runCommand2(config, eventType, message, sessionTitle, projectName);
4556
+ runCommand2(config, eventType, message, sessionTitle, projectName, timestamp, turn);
4502
4557
  }
4503
4558
  await Promise.allSettled(promises);
4504
4559
  }
@@ -4632,7 +4687,7 @@ async function handleEventWithElapsedTime(client, config, eventType, projectName
4632
4687
  const info = await getSessionInfo(client, sessionID);
4633
4688
  sessionTitle = info.title;
4634
4689
  }
4635
- await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle);
4690
+ await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID);
4636
4691
  }
4637
4692
  var NotifierPlugin = async ({ client, directory }) => {
4638
4693
  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.3",
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",