@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.
- package/README.md +22 -11
- package/dist/index.js +87 -25
- 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
|
-
|
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
4262
|
-
|
|
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 ===
|
|
4303
|
+
if (currentPid === targetPid)
|
|
4265
4304
|
return true;
|
|
4266
4305
|
if (currentPid <= 1)
|
|
4267
4306
|
return false;
|
|
4268
|
-
|
|
4269
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|