@mohak34/opencode-notifier 0.1.30-beta.0 → 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 +87 -25
  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
@@ -4258,25 +4273,47 @@ function isMacOSFocused(terminal) {
4258
4273
  return false;
4259
4274
  return terminal.macProcessNames.some((name) => name.toLowerCase() === frontApp.toLowerCase());
4260
4275
  }
4261
- function isPidAncestorOfCurrentProcess(pid) {
4262
- let currentPid = process.pid;
4276
+ function getParentPid(pid) {
4277
+ try {
4278
+ const stat = readFileSync2(`/proc/${pid}/stat`, "utf-8");
4279
+ const closingParen = stat.lastIndexOf(")");
4280
+ if (closingParen === -1)
4281
+ return null;
4282
+ const fields = stat.slice(closingParen + 2).split(" ");
4283
+ const ppid = parseInt(fields[1], 10);
4284
+ return Number.isFinite(ppid) ? ppid : null;
4285
+ } catch {
4286
+ return null;
4287
+ }
4288
+ }
4289
+ function getProcessTreeRoot() {
4290
+ if (process.env.TMUX) {
4291
+ const clientPidStr = execWithTimeout("tmux display-message -p '#{client_pid}'");
4292
+ if (clientPidStr) {
4293
+ const clientPid = parseInt(clientPidStr, 10);
4294
+ if (Number.isFinite(clientPid) && clientPid > 0)
4295
+ return clientPid;
4296
+ }
4297
+ }
4298
+ return process.pid;
4299
+ }
4300
+ function isPidAncestorOfProcess(targetPid, startPid) {
4301
+ let currentPid = startPid;
4263
4302
  for (let depth = 0;depth < 20; depth++) {
4264
- if (currentPid === pid)
4303
+ if (currentPid === targetPid)
4265
4304
  return true;
4266
4305
  if (currentPid <= 1)
4267
4306
  return false;
4268
- try {
4269
- const stat = readFileSync2(`/proc/${currentPid}/stat`, "utf-8");
4270
- const match = stat.match(/^\d+\s+\(.*?\)\s+\S+\s+(\d+)/);
4271
- if (!match)
4272
- return false;
4273
- currentPid = parseInt(match[1], 10);
4274
- } catch {
4307
+ const ppid = getParentPid(currentPid);
4308
+ if (ppid === null)
4275
4309
  return false;
4276
- }
4310
+ currentPid = ppid;
4277
4311
  }
4278
4312
  return false;
4279
4313
  }
4314
+ function isFocusedWindowOurs(windowPid) {
4315
+ return isPidAncestorOfProcess(windowPid, getProcessTreeRoot());
4316
+ }
4280
4317
  function isLinuxX11Focused() {
4281
4318
  const pidStr = execWithTimeout("xdotool getactivewindow getwindowpid");
4282
4319
  if (!pidStr)
@@ -4284,7 +4321,7 @@ function isLinuxX11Focused() {
4284
4321
  const pid = parseInt(pidStr, 10);
4285
4322
  if (!Number.isFinite(pid) || pid <= 0)
4286
4323
  return false;
4287
- return isPidAncestorOfCurrentProcess(pid);
4324
+ return isFocusedWindowOurs(pid);
4288
4325
  }
4289
4326
  function isHyprlandFocused() {
4290
4327
  const output = execWithTimeout("hyprctl activewindow -j");
@@ -4295,7 +4332,7 @@ function isHyprlandFocused() {
4295
4332
  const pid = data?.pid;
4296
4333
  if (typeof pid !== "number" || pid <= 0)
4297
4334
  return false;
4298
- return isPidAncestorOfCurrentProcess(pid);
4335
+ return isFocusedWindowOurs(pid);
4299
4336
  } catch {
4300
4337
  return false;
4301
4338
  }
@@ -4309,7 +4346,7 @@ function isSwayFocused() {
4309
4346
  const pid = findFocusedPid(tree);
4310
4347
  if (pid === null)
4311
4348
  return false;
4312
- return isPidAncestorOfCurrentProcess(pid);
4349
+ return isFocusedWindowOurs(pid);
4313
4350
  } catch {
4314
4351
  return false;
4315
4352
  }
@@ -4344,7 +4381,7 @@ function isKDEWaylandFocused() {
4344
4381
  const pid = parseInt(pidStr, 10);
4345
4382
  if (!Number.isFinite(pid) || pid <= 0)
4346
4383
  return false;
4347
- return isPidAncestorOfCurrentProcess(pid);
4384
+ return isFocusedWindowOurs(pid);
4348
4385
  }
4349
4386
  function isLinuxWaylandFocused() {
4350
4387
  const env = process.env;
@@ -4380,7 +4417,8 @@ Write-Output $pid
4380
4417
  const pid = parseInt(pidStr, 10);
4381
4418
  if (!Number.isFinite(pid) || pid <= 0)
4382
4419
  return false;
4383
- let currentPid = process.pid;
4420
+ const startPid = getProcessTreeRoot();
4421
+ let currentPid = startPid;
4384
4422
  for (let depth = 0;depth < 20; depth++) {
4385
4423
  if (currentPid === pid)
4386
4424
  return true;
@@ -4428,6 +4466,7 @@ var pendingIdleTimers = new Map;
4428
4466
  var sessionIdleSequence = new Map;
4429
4467
  var sessionErrorSuppressionAt = new Map;
4430
4468
  var sessionLastBusyAt = new Map;
4469
+ var sessionTurnCount = new Map;
4431
4470
  setInterval(() => {
4432
4471
  const cutoff = Date.now() - 5 * 60 * 1000;
4433
4472
  for (const [sessionID] of sessionIdleSequence) {
@@ -4445,6 +4484,11 @@ setInterval(() => {
4445
4484
  sessionLastBusyAt.delete(sessionID);
4446
4485
  }
4447
4486
  }
4487
+ for (const [sessionID] of sessionTurnCount) {
4488
+ if (!pendingIdleTimers.has(sessionID) && !sessionLastBusyAt.has(sessionID)) {
4489
+ sessionTurnCount.delete(sessionID);
4490
+ }
4491
+ }
4448
4492
  }, 5 * 60 * 1000);
4449
4493
  function getNotificationTitle(config, projectName) {
4450
4494
  if (config.showProjectName && projectName) {
@@ -4452,15 +4496,33 @@ function getNotificationTitle(config, projectName) {
4452
4496
  }
4453
4497
  return "OpenCode";
4454
4498
  }
4455
- 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) {
4456
4514
  if (config.suppressWhenFocused && isTerminalFocused()) {
4457
4515
  return;
4458
4516
  }
4459
4517
  const promises = [];
4518
+ const timestamp = formatTimestamp();
4519
+ const turn = incrementSessionTurn(sessionID ?? null);
4460
4520
  const rawMessage = getMessage(config, eventType);
4461
4521
  const message = interpolateMessage(rawMessage, {
4462
4522
  sessionTitle: config.showSessionTitle ? sessionTitle : null,
4463
- projectName
4523
+ projectName,
4524
+ timestamp,
4525
+ turn
4464
4526
  });
4465
4527
  if (isEventNotificationEnabled(config, eventType)) {
4466
4528
  const title = getNotificationTitle(config, projectName);
@@ -4475,7 +4537,7 @@ async function handleEvent(config, eventType, projectName, elapsedSeconds, sessi
4475
4537
  const minDuration = config.command?.minDuration;
4476
4538
  const shouldSkipCommand = typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
4477
4539
  if (!shouldSkipCommand) {
4478
- runCommand2(config, eventType, message, sessionTitle, projectName);
4540
+ runCommand2(config, eventType, message, sessionTitle, projectName, timestamp, turn);
4479
4541
  }
4480
4542
  await Promise.allSettled(promises);
4481
4543
  }
@@ -4609,7 +4671,7 @@ async function handleEventWithElapsedTime(client, config, eventType, projectName
4609
4671
  const info = await getSessionInfo(client, sessionID);
4610
4672
  sessionTitle = info.title;
4611
4673
  }
4612
- await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle);
4674
+ await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID);
4613
4675
  }
4614
4676
  var NotifierPlugin = async ({ client, directory }) => {
4615
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.0",
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",