oc-plugin-binetz-notifier 1.0.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.
- package/LICENSE +21 -0
- package/README.md +459 -0
- package/dist/index.js +1091 -0
- package/logos/opencode-logo-dark.png +0 -0
- package/logos/opencode-logo-light.png +0 -0
- package/package.json +27 -0
- package/sounds/complete.wav +0 -0
- package/sounds/error.wav +0 -0
- package/sounds/permission.wav +0 -0
- package/sounds/question.wav +0 -0
- package/sounds/subagent_complete.wav +0 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1091 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { basename } from "path";
|
|
3
|
+
import { readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
6
|
+
import { readFileSync, existsSync } from "fs";
|
|
7
|
+
import { join, dirname } from "path";
|
|
8
|
+
import { homedir } from "os";
|
|
9
|
+
import { fileURLToPath } from "url";
|
|
10
|
+
var DEFAULT_EVENT_CONFIG = {
|
|
11
|
+
sound: true,
|
|
12
|
+
notification: true,
|
|
13
|
+
command: true
|
|
14
|
+
};
|
|
15
|
+
var DEFAULT_CONFIG = {
|
|
16
|
+
sound: true,
|
|
17
|
+
notification: true,
|
|
18
|
+
timeout: 5,
|
|
19
|
+
showProjectName: true,
|
|
20
|
+
showSessionTitle: false,
|
|
21
|
+
showIcon: true,
|
|
22
|
+
suppressWhenFocused: true,
|
|
23
|
+
enableOnDesktop: false,
|
|
24
|
+
notificationSystem: "osascript",
|
|
25
|
+
linux: {
|
|
26
|
+
grouping: false
|
|
27
|
+
},
|
|
28
|
+
command: {
|
|
29
|
+
enabled: false,
|
|
30
|
+
path: "",
|
|
31
|
+
minDuration: 0
|
|
32
|
+
},
|
|
33
|
+
events: {
|
|
34
|
+
permission: { ...DEFAULT_EVENT_CONFIG },
|
|
35
|
+
complete: { ...DEFAULT_EVENT_CONFIG },
|
|
36
|
+
subagent_complete: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false },
|
|
37
|
+
error: { ...DEFAULT_EVENT_CONFIG },
|
|
38
|
+
question: { ...DEFAULT_EVENT_CONFIG },
|
|
39
|
+
interrupted: { ...DEFAULT_EVENT_CONFIG },
|
|
40
|
+
user_cancelled: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false }
|
|
41
|
+
},
|
|
42
|
+
messages: {
|
|
43
|
+
permission: "Session needs permission: {sessionTitle}",
|
|
44
|
+
complete: "Session has finished: {sessionTitle}",
|
|
45
|
+
subagent_complete: "Subagent task completed: {sessionTitle}",
|
|
46
|
+
error: "Session encountered an error: {sessionTitle}",
|
|
47
|
+
question: "Session has a question: {sessionTitle}",
|
|
48
|
+
interrupted: "Session was interrupted: {sessionTitle}",
|
|
49
|
+
user_cancelled: "Session was cancelled by user: {sessionTitle}"
|
|
50
|
+
},
|
|
51
|
+
sounds: {
|
|
52
|
+
permission: null,
|
|
53
|
+
complete: null,
|
|
54
|
+
subagent_complete: null,
|
|
55
|
+
error: null,
|
|
56
|
+
question: null,
|
|
57
|
+
interrupted: null,
|
|
58
|
+
user_cancelled: null
|
|
59
|
+
},
|
|
60
|
+
volumes: {
|
|
61
|
+
permission: 1,
|
|
62
|
+
complete: 1,
|
|
63
|
+
subagent_complete: 1,
|
|
64
|
+
error: 1,
|
|
65
|
+
question: 1,
|
|
66
|
+
interrupted: 1,
|
|
67
|
+
user_cancelled: 1
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
function getConfigPath() {
|
|
71
|
+
if (process.env.OPENCODE_NOTIFIER_CONFIG_PATH) {
|
|
72
|
+
return process.env.OPENCODE_NOTIFIER_CONFIG_PATH;
|
|
73
|
+
}
|
|
74
|
+
return join(homedir(), ".config", "opencode", "opencode-notifier.json");
|
|
75
|
+
}
|
|
76
|
+
function getStatePath() {
|
|
77
|
+
const configPath = getConfigPath();
|
|
78
|
+
return join(dirname(configPath), "opencode-notifier-state.json");
|
|
79
|
+
}
|
|
80
|
+
function parseEventConfig(userEvent, defaultConfig) {
|
|
81
|
+
if (userEvent === undefined) {
|
|
82
|
+
return defaultConfig;
|
|
83
|
+
}
|
|
84
|
+
if (typeof userEvent === "boolean") {
|
|
85
|
+
return {
|
|
86
|
+
sound: userEvent,
|
|
87
|
+
notification: userEvent,
|
|
88
|
+
command: userEvent
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
sound: userEvent.sound ?? defaultConfig.sound,
|
|
93
|
+
notification: userEvent.notification ?? defaultConfig.notification,
|
|
94
|
+
command: userEvent.command ?? defaultConfig.command
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function parseVolume(value, defaultVolume) {
|
|
98
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
99
|
+
return defaultVolume;
|
|
100
|
+
}
|
|
101
|
+
if (value < 0) {
|
|
102
|
+
return 0;
|
|
103
|
+
}
|
|
104
|
+
if (value > 1) {
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function loadConfig() {
|
|
110
|
+
const configPath = getConfigPath();
|
|
111
|
+
if (!existsSync(configPath)) {
|
|
112
|
+
return DEFAULT_CONFIG;
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const fileContent = readFileSync(configPath, "utf-8");
|
|
116
|
+
const userConfig = JSON.parse(fileContent);
|
|
117
|
+
const globalSound = userConfig.sound ?? DEFAULT_CONFIG.sound;
|
|
118
|
+
const globalNotification = userConfig.notification ?? DEFAULT_CONFIG.notification;
|
|
119
|
+
const defaultWithGlobal = {
|
|
120
|
+
sound: globalSound,
|
|
121
|
+
notification: globalNotification,
|
|
122
|
+
command: true
|
|
123
|
+
};
|
|
124
|
+
const userCommand = userConfig.command ?? {};
|
|
125
|
+
const commandArgs = Array.isArray(userCommand.args) ? userCommand.args.filter((arg) => typeof arg === "string") : undefined;
|
|
126
|
+
const commandMinDuration = typeof userCommand.minDuration === "number" && Number.isFinite(userCommand.minDuration) && userCommand.minDuration > 0 ? userCommand.minDuration : 0;
|
|
127
|
+
return {
|
|
128
|
+
sound: globalSound,
|
|
129
|
+
notification: globalNotification,
|
|
130
|
+
timeout: typeof userConfig.timeout === "number" && userConfig.timeout > 0 ? userConfig.timeout : DEFAULT_CONFIG.timeout,
|
|
131
|
+
showProjectName: userConfig.showProjectName ?? DEFAULT_CONFIG.showProjectName,
|
|
132
|
+
showSessionTitle: userConfig.showSessionTitle ?? DEFAULT_CONFIG.showSessionTitle,
|
|
133
|
+
showIcon: userConfig.showIcon ?? DEFAULT_CONFIG.showIcon,
|
|
134
|
+
suppressWhenFocused: userConfig.suppressWhenFocused ?? DEFAULT_CONFIG.suppressWhenFocused,
|
|
135
|
+
enableOnDesktop: typeof userConfig.enableOnDesktop === "boolean" ? userConfig.enableOnDesktop : DEFAULT_CONFIG.enableOnDesktop,
|
|
136
|
+
notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : userConfig.notificationSystem === "ghostty" ? "ghostty" : "osascript",
|
|
137
|
+
linux: {
|
|
138
|
+
grouping: typeof userConfig.linux?.grouping === "boolean" ? userConfig.linux.grouping : DEFAULT_CONFIG.linux.grouping
|
|
139
|
+
},
|
|
140
|
+
command: {
|
|
141
|
+
enabled: typeof userCommand.enabled === "boolean" ? userCommand.enabled : DEFAULT_CONFIG.command.enabled,
|
|
142
|
+
path: typeof userCommand.path === "string" ? userCommand.path : DEFAULT_CONFIG.command.path,
|
|
143
|
+
args: commandArgs,
|
|
144
|
+
minDuration: commandMinDuration
|
|
145
|
+
},
|
|
146
|
+
events: {
|
|
147
|
+
permission: parseEventConfig(userConfig.events?.permission ?? userConfig.permission, defaultWithGlobal),
|
|
148
|
+
complete: parseEventConfig(userConfig.events?.complete ?? userConfig.complete, defaultWithGlobal),
|
|
149
|
+
subagent_complete: parseEventConfig(userConfig.events?.subagent_complete ?? userConfig.subagent_complete, { sound: false, notification: false, command: true }),
|
|
150
|
+
error: parseEventConfig(userConfig.events?.error ?? userConfig.error, defaultWithGlobal),
|
|
151
|
+
question: parseEventConfig(userConfig.events?.question ?? userConfig.question, defaultWithGlobal),
|
|
152
|
+
interrupted: parseEventConfig(userConfig.events?.interrupted ?? userConfig.interrupted, defaultWithGlobal),
|
|
153
|
+
user_cancelled: parseEventConfig(userConfig.events?.user_cancelled ?? userConfig.user_cancelled, { sound: false, notification: false, command: true })
|
|
154
|
+
},
|
|
155
|
+
messages: {
|
|
156
|
+
permission: userConfig.messages?.permission ?? DEFAULT_CONFIG.messages.permission,
|
|
157
|
+
complete: userConfig.messages?.complete ?? DEFAULT_CONFIG.messages.complete,
|
|
158
|
+
subagent_complete: userConfig.messages?.subagent_complete ?? DEFAULT_CONFIG.messages.subagent_complete,
|
|
159
|
+
error: userConfig.messages?.error ?? DEFAULT_CONFIG.messages.error,
|
|
160
|
+
question: userConfig.messages?.question ?? DEFAULT_CONFIG.messages.question,
|
|
161
|
+
interrupted: userConfig.messages?.interrupted ?? DEFAULT_CONFIG.messages.interrupted,
|
|
162
|
+
user_cancelled: userConfig.messages?.user_cancelled ?? DEFAULT_CONFIG.messages.user_cancelled
|
|
163
|
+
},
|
|
164
|
+
sounds: {
|
|
165
|
+
permission: userConfig.sounds?.permission ?? DEFAULT_CONFIG.sounds.permission,
|
|
166
|
+
complete: userConfig.sounds?.complete ?? DEFAULT_CONFIG.sounds.complete,
|
|
167
|
+
subagent_complete: userConfig.sounds?.subagent_complete ?? DEFAULT_CONFIG.sounds.subagent_complete,
|
|
168
|
+
error: userConfig.sounds?.error ?? DEFAULT_CONFIG.sounds.error,
|
|
169
|
+
question: userConfig.sounds?.question ?? DEFAULT_CONFIG.sounds.question,
|
|
170
|
+
interrupted: userConfig.sounds?.interrupted ?? DEFAULT_CONFIG.sounds.interrupted,
|
|
171
|
+
user_cancelled: userConfig.sounds?.user_cancelled ?? DEFAULT_CONFIG.sounds.user_cancelled
|
|
172
|
+
},
|
|
173
|
+
volumes: {
|
|
174
|
+
permission: parseVolume(userConfig.volumes?.permission, DEFAULT_CONFIG.volumes.permission),
|
|
175
|
+
complete: parseVolume(userConfig.volumes?.complete, DEFAULT_CONFIG.volumes.complete),
|
|
176
|
+
subagent_complete: parseVolume(userConfig.volumes?.subagent_complete, DEFAULT_CONFIG.volumes.subagent_complete),
|
|
177
|
+
error: parseVolume(userConfig.volumes?.error, DEFAULT_CONFIG.volumes.error),
|
|
178
|
+
question: parseVolume(userConfig.volumes?.question, DEFAULT_CONFIG.volumes.question),
|
|
179
|
+
interrupted: parseVolume(userConfig.volumes?.interrupted, DEFAULT_CONFIG.volumes.interrupted),
|
|
180
|
+
user_cancelled: parseVolume(userConfig.volumes?.user_cancelled, DEFAULT_CONFIG.volumes.user_cancelled)
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
} catch {
|
|
184
|
+
return DEFAULT_CONFIG;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
function isEventSoundEnabled(config, event) {
|
|
188
|
+
return config.events[event].sound;
|
|
189
|
+
}
|
|
190
|
+
function isEventNotificationEnabled(config, event) {
|
|
191
|
+
return config.events[event].notification;
|
|
192
|
+
}
|
|
193
|
+
function isEventCommandEnabled(config, event) {
|
|
194
|
+
return config.events[event].command;
|
|
195
|
+
}
|
|
196
|
+
function getMessage(config, event) {
|
|
197
|
+
return config.messages[event];
|
|
198
|
+
}
|
|
199
|
+
function getSoundPath(config, event) {
|
|
200
|
+
return config.sounds[event];
|
|
201
|
+
}
|
|
202
|
+
function getSoundVolume(config, event) {
|
|
203
|
+
return config.volumes[event];
|
|
204
|
+
}
|
|
205
|
+
function getIconPath(config) {
|
|
206
|
+
if (!config.showIcon) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
const __filename2 = fileURLToPath(import.meta.url);
|
|
211
|
+
const __dirname2 = dirname(__filename2);
|
|
212
|
+
const iconPath = join(__dirname2, "..", "logos", "opencode-logo-dark.png");
|
|
213
|
+
if (existsSync(iconPath)) {
|
|
214
|
+
return iconPath;
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
function interpolateMessage(message, context) {
|
|
220
|
+
let result = message;
|
|
221
|
+
const sessionTitle = context.sessionTitle || "";
|
|
222
|
+
result = result.replaceAll("{sessionTitle}", sessionTitle);
|
|
223
|
+
const agentName = context.agentName || "";
|
|
224
|
+
result = result.replaceAll("{agentName}", agentName);
|
|
225
|
+
const projectName = context.projectName || "";
|
|
226
|
+
result = result.replaceAll("{projectName}", projectName);
|
|
227
|
+
const timestamp = context.timestamp || "";
|
|
228
|
+
result = result.replaceAll("{timestamp}", timestamp);
|
|
229
|
+
const turn = context.turn != null ? String(context.turn) : "";
|
|
230
|
+
result = result.replaceAll("{turn}", turn);
|
|
231
|
+
result = result.replace(/\s*[:\-|]\s*$/, "").trim();
|
|
232
|
+
result = result.replace(/\s{2,}/g, " ");
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// src/notify.ts
|
|
237
|
+
import os from "os";
|
|
238
|
+
import { exec, execFile } from "child_process";
|
|
239
|
+
import notifier from "node-notifier";
|
|
240
|
+
var DEBOUNCE_MS = 1000;
|
|
241
|
+
var platform = os.type();
|
|
242
|
+
var platformNotifier;
|
|
243
|
+
if (platform === "Linux" || platform.match(/BSD$/)) {
|
|
244
|
+
const { NotifySend } = notifier;
|
|
245
|
+
platformNotifier = new NotifySend({ withFallback: false });
|
|
246
|
+
} else if (platform === "Windows_NT") {
|
|
247
|
+
const { WindowsToaster } = notifier;
|
|
248
|
+
platformNotifier = new WindowsToaster({ withFallback: false });
|
|
249
|
+
} else if (platform !== "Darwin") {
|
|
250
|
+
platformNotifier = notifier;
|
|
251
|
+
}
|
|
252
|
+
var lastNotificationTime = {};
|
|
253
|
+
var lastLinuxNotificationId = null;
|
|
254
|
+
var linuxNotifySendSupportsReplace = null;
|
|
255
|
+
function sanitizeGhosttyField(value) {
|
|
256
|
+
return value.replace(/[;\x07\x1b\n\r]/g, "");
|
|
257
|
+
}
|
|
258
|
+
function formatGhosttyNotificationSequence(title, message, env = process.env) {
|
|
259
|
+
const escapedTitle = sanitizeGhosttyField(title);
|
|
260
|
+
const escapedMessage = sanitizeGhosttyField(message);
|
|
261
|
+
const payload = `\x1B]9;${escapedTitle}: ${escapedMessage}\x07`;
|
|
262
|
+
if (env.TMUX) {
|
|
263
|
+
return `\x1BPtmux;\x1B${payload}\x1B\\`;
|
|
264
|
+
}
|
|
265
|
+
return payload;
|
|
266
|
+
}
|
|
267
|
+
function detectNotifySendCapabilities() {
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
execFile("notify-send", ["--version"], (error, stdout) => {
|
|
270
|
+
if (error) {
|
|
271
|
+
resolve(false);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const match = stdout.match(/(\d+)\.(\d+)/);
|
|
275
|
+
if (match) {
|
|
276
|
+
const major = parseInt(match[1], 10);
|
|
277
|
+
const minor = parseInt(match[2], 10);
|
|
278
|
+
resolve(major > 0 || major === 0 && minor >= 8);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
resolve(false);
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
function sendLinuxNotificationDirect(title, message, timeout, iconPath, grouping = true) {
|
|
286
|
+
return new Promise((resolve) => {
|
|
287
|
+
const args = [];
|
|
288
|
+
args.push("--app-name", "opencode");
|
|
289
|
+
if (iconPath) {
|
|
290
|
+
args.push("--icon", iconPath);
|
|
291
|
+
}
|
|
292
|
+
args.push("--expire-time", String(timeout * 1000));
|
|
293
|
+
if (grouping && lastLinuxNotificationId !== null) {
|
|
294
|
+
args.push("--replace-id", String(lastLinuxNotificationId));
|
|
295
|
+
}
|
|
296
|
+
if (grouping) {
|
|
297
|
+
args.push("--print-id");
|
|
298
|
+
}
|
|
299
|
+
args.push("--", title, message);
|
|
300
|
+
execFile("notify-send", args, (error, stdout) => {
|
|
301
|
+
if (!error && grouping && stdout) {
|
|
302
|
+
const id = parseInt(stdout.trim(), 10);
|
|
303
|
+
if (!isNaN(id)) {
|
|
304
|
+
lastLinuxNotificationId = id;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
resolve();
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
async function sendNotification(title, message, timeout, iconPath, notificationSystem = "osascript", linuxGrouping = true) {
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
lastNotificationTime[message] = now;
|
|
317
|
+
if (notificationSystem === "ghostty") {
|
|
318
|
+
return new Promise((resolve) => {
|
|
319
|
+
const sequence = formatGhosttyNotificationSequence(title, message);
|
|
320
|
+
process.stdout.write(sequence, () => {
|
|
321
|
+
resolve();
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
if (platform === "Darwin") {
|
|
326
|
+
if (notificationSystem === "node-notifier") {
|
|
327
|
+
return new Promise((resolve) => {
|
|
328
|
+
const notificationOptions = {
|
|
329
|
+
title,
|
|
330
|
+
message,
|
|
331
|
+
timeout,
|
|
332
|
+
icon: iconPath
|
|
333
|
+
};
|
|
334
|
+
notifier.notify(notificationOptions, () => {
|
|
335
|
+
resolve();
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
return new Promise((resolve) => {
|
|
340
|
+
const escapedMessage = message.replace(/"/g, "\\\"");
|
|
341
|
+
const escapedTitle = title.replace(/"/g, "\\\"");
|
|
342
|
+
exec(`osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`, () => {
|
|
343
|
+
resolve();
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
if (platform === "Linux" || platform.match(/BSD$/)) {
|
|
348
|
+
if (linuxGrouping) {
|
|
349
|
+
if (linuxNotifySendSupportsReplace === null) {
|
|
350
|
+
linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
|
|
351
|
+
}
|
|
352
|
+
if (linuxNotifySendSupportsReplace) {
|
|
353
|
+
return sendLinuxNotificationDirect(title, message, timeout, iconPath, true);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return new Promise((resolve) => {
|
|
358
|
+
const notificationOptions = {
|
|
359
|
+
title,
|
|
360
|
+
message,
|
|
361
|
+
timeout,
|
|
362
|
+
icon: iconPath,
|
|
363
|
+
"app-name": "opencode"
|
|
364
|
+
};
|
|
365
|
+
platformNotifier.notify(notificationOptions, () => {
|
|
366
|
+
resolve();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/sound.ts
|
|
372
|
+
import { platform as platform2 } from "os";
|
|
373
|
+
import { join as join2, dirname as dirname2 } from "path";
|
|
374
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
375
|
+
import { existsSync as existsSync2 } from "fs";
|
|
376
|
+
import { spawn } from "child_process";
|
|
377
|
+
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
378
|
+
var DEBOUNCE_MS2 = 1000;
|
|
379
|
+
var FULL_VOLUME_PERCENT = 100;
|
|
380
|
+
var FULL_VOLUME_PULSE = 65536;
|
|
381
|
+
var lastSoundTime = {};
|
|
382
|
+
function getBundledSoundPath(event) {
|
|
383
|
+
const soundFilename = `${event}.wav`;
|
|
384
|
+
const possiblePaths = [
|
|
385
|
+
join2(__dirname2, "..", "sounds", soundFilename),
|
|
386
|
+
join2(__dirname2, "sounds", soundFilename)
|
|
387
|
+
];
|
|
388
|
+
for (const path of possiblePaths) {
|
|
389
|
+
if (existsSync2(path)) {
|
|
390
|
+
return path;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return join2(__dirname2, "..", "sounds", soundFilename);
|
|
394
|
+
}
|
|
395
|
+
function getSoundFilePath(event, customPath) {
|
|
396
|
+
if (customPath && existsSync2(customPath)) {
|
|
397
|
+
return customPath;
|
|
398
|
+
}
|
|
399
|
+
const bundledPath = getBundledSoundPath(event);
|
|
400
|
+
if (existsSync2(bundledPath)) {
|
|
401
|
+
return bundledPath;
|
|
402
|
+
}
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
async function runCommand(command, args) {
|
|
406
|
+
return new Promise((resolve, reject) => {
|
|
407
|
+
const proc = spawn(command, args, {
|
|
408
|
+
stdio: "ignore",
|
|
409
|
+
detached: false
|
|
410
|
+
});
|
|
411
|
+
proc.on("error", (err) => {
|
|
412
|
+
reject(err);
|
|
413
|
+
});
|
|
414
|
+
proc.on("close", (code) => {
|
|
415
|
+
if (code === 0) {
|
|
416
|
+
resolve();
|
|
417
|
+
} else {
|
|
418
|
+
reject(new Error(`Command exited with code ${code}`));
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function normalizeVolume(volume) {
|
|
424
|
+
if (!Number.isFinite(volume)) {
|
|
425
|
+
return 1;
|
|
426
|
+
}
|
|
427
|
+
if (volume < 0) {
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
if (volume > 1) {
|
|
431
|
+
return 1;
|
|
432
|
+
}
|
|
433
|
+
return volume;
|
|
434
|
+
}
|
|
435
|
+
function toPercentVolume(volume) {
|
|
436
|
+
return Math.round(volume * FULL_VOLUME_PERCENT);
|
|
437
|
+
}
|
|
438
|
+
function toPulseVolume(volume) {
|
|
439
|
+
return Math.round(volume * FULL_VOLUME_PULSE);
|
|
440
|
+
}
|
|
441
|
+
async function playOnLinux(soundPath, volume) {
|
|
442
|
+
const percentVolume = toPercentVolume(volume);
|
|
443
|
+
const pulseVolume = toPulseVolume(volume);
|
|
444
|
+
const players = [
|
|
445
|
+
{ command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
|
|
446
|
+
{ command: "aplay", args: [soundPath] },
|
|
447
|
+
{ command: "mpv", args: ["--no-video", "--no-terminal", "--script-opts=autoload-disabled=yes", `--volume=${percentVolume}`, soundPath] },
|
|
448
|
+
{ command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
|
|
449
|
+
];
|
|
450
|
+
for (const player of players) {
|
|
451
|
+
try {
|
|
452
|
+
await runCommand(player.command, player.args);
|
|
453
|
+
return;
|
|
454
|
+
} catch {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
async function playOnMac(soundPath, volume) {
|
|
460
|
+
await runCommand("afplay", ["-v", `${volume}`, soundPath]);
|
|
461
|
+
}
|
|
462
|
+
async function playOnWindows(soundPath) {
|
|
463
|
+
const script = `& { (New-Object Media.SoundPlayer $args[0]).PlaySync() }`;
|
|
464
|
+
await runCommand("powershell", ["-c", script, soundPath]);
|
|
465
|
+
}
|
|
466
|
+
async function playSound(event, customPath, volume) {
|
|
467
|
+
const now = Date.now();
|
|
468
|
+
if (lastSoundTime[event] && now - lastSoundTime[event] < DEBOUNCE_MS2) {
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
lastSoundTime[event] = now;
|
|
472
|
+
const soundPath = getSoundFilePath(event, customPath);
|
|
473
|
+
const normalizedVolume = normalizeVolume(volume);
|
|
474
|
+
if (!soundPath) {
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const os2 = platform2();
|
|
478
|
+
try {
|
|
479
|
+
switch (os2) {
|
|
480
|
+
case "darwin":
|
|
481
|
+
await playOnMac(soundPath, normalizedVolume);
|
|
482
|
+
break;
|
|
483
|
+
case "linux":
|
|
484
|
+
await playOnLinux(soundPath, normalizedVolume);
|
|
485
|
+
break;
|
|
486
|
+
case "win32":
|
|
487
|
+
await playOnWindows(soundPath);
|
|
488
|
+
break;
|
|
489
|
+
default:
|
|
490
|
+
break;
|
|
491
|
+
}
|
|
492
|
+
} catch {}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// src/command.ts
|
|
496
|
+
import { spawn as spawn2 } from "child_process";
|
|
497
|
+
function substituteTokens(value, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
|
|
498
|
+
let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
|
|
499
|
+
result = result.replaceAll("{sessionTitle}", sessionTitle || "");
|
|
500
|
+
result = result.replaceAll("{agentName}", agentName || "");
|
|
501
|
+
result = result.replaceAll("{projectName}", projectName || "");
|
|
502
|
+
result = result.replaceAll("{timestamp}", timestamp || "");
|
|
503
|
+
result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
|
|
504
|
+
return result;
|
|
505
|
+
}
|
|
506
|
+
function runCommand2(config, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
|
|
507
|
+
if (!config.command.enabled || !config.command.path) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
|
|
511
|
+
const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
|
|
512
|
+
const proc = spawn2(command, args, {
|
|
513
|
+
stdio: "ignore",
|
|
514
|
+
detached: true
|
|
515
|
+
});
|
|
516
|
+
proc.on("error", () => {});
|
|
517
|
+
proc.unref();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// src/focus.ts
|
|
521
|
+
import { execFileSync, execSync } from "child_process";
|
|
522
|
+
var MAC_TERMINAL_APP_NAMES = new Set([
|
|
523
|
+
"terminal",
|
|
524
|
+
"iterm2",
|
|
525
|
+
"ghostty",
|
|
526
|
+
"wezterm",
|
|
527
|
+
"alacritty",
|
|
528
|
+
"kitty",
|
|
529
|
+
"hyper",
|
|
530
|
+
"warp",
|
|
531
|
+
"tabby",
|
|
532
|
+
"cursor",
|
|
533
|
+
"visual studio code",
|
|
534
|
+
"code",
|
|
535
|
+
"code insiders",
|
|
536
|
+
"zed",
|
|
537
|
+
"rio"
|
|
538
|
+
]);
|
|
539
|
+
function execWithTimeout(command, timeoutMs = 500) {
|
|
540
|
+
try {
|
|
541
|
+
return execSync(command, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
function execFileWithTimeout(command, args, timeoutMs = 500) {
|
|
547
|
+
try {
|
|
548
|
+
return execFileSync(command, args, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
549
|
+
} catch {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
function getHyprlandActiveWindowId() {
|
|
554
|
+
const output = execWithTimeout("hyprctl activewindow -j");
|
|
555
|
+
if (!output)
|
|
556
|
+
return null;
|
|
557
|
+
try {
|
|
558
|
+
const data = JSON.parse(output);
|
|
559
|
+
return typeof data?.address === "string" ? data.address : null;
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function findFocusedWindowId(node) {
|
|
565
|
+
if (node.focused === true && typeof node.id === "number") {
|
|
566
|
+
return String(node.id);
|
|
567
|
+
}
|
|
568
|
+
if (Array.isArray(node.nodes)) {
|
|
569
|
+
for (const child of node.nodes) {
|
|
570
|
+
const id = findFocusedWindowId(child);
|
|
571
|
+
if (id !== null)
|
|
572
|
+
return id;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (Array.isArray(node.floating_nodes)) {
|
|
576
|
+
for (const child of node.floating_nodes) {
|
|
577
|
+
const id = findFocusedWindowId(child);
|
|
578
|
+
if (id !== null)
|
|
579
|
+
return id;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return null;
|
|
583
|
+
}
|
|
584
|
+
function getSwayActiveWindowId() {
|
|
585
|
+
const output = execWithTimeout("swaymsg -t get_tree", 1000);
|
|
586
|
+
if (!output)
|
|
587
|
+
return null;
|
|
588
|
+
try {
|
|
589
|
+
const tree = JSON.parse(output);
|
|
590
|
+
return findFocusedWindowId(tree);
|
|
591
|
+
} catch {
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function getNiriActiveWindowId() {
|
|
596
|
+
const output = execWithTimeout("niri msg --json focused-window", 1000);
|
|
597
|
+
if (!output)
|
|
598
|
+
return null;
|
|
599
|
+
try {
|
|
600
|
+
const data = JSON.parse(output);
|
|
601
|
+
return typeof data?.id === "number" ? String(data.id) : null;
|
|
602
|
+
} catch {
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
function parseWezTermFocusedPaneId(output) {
|
|
607
|
+
try {
|
|
608
|
+
const data = JSON.parse(output);
|
|
609
|
+
if (!Array.isArray(data))
|
|
610
|
+
return null;
|
|
611
|
+
for (const client of data) {
|
|
612
|
+
if (typeof client?.focused_pane_id === "number") {
|
|
613
|
+
return String(client.focused_pane_id);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
} catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
function getLinuxWaylandActiveWindowId() {
|
|
622
|
+
const env = process.env;
|
|
623
|
+
if (env.HYPRLAND_INSTANCE_SIGNATURE)
|
|
624
|
+
return getHyprlandActiveWindowId();
|
|
625
|
+
if (env.NIRI_SOCKET)
|
|
626
|
+
return getNiriActiveWindowId();
|
|
627
|
+
if (env.SWAYSOCK)
|
|
628
|
+
return getSwayActiveWindowId();
|
|
629
|
+
if (env.KDE_SESSION_VERSION)
|
|
630
|
+
return execWithTimeout("kdotool getactivewindow");
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
function getWindowsActiveWindowId() {
|
|
634
|
+
const script = `$type=Add-Type -Name FocusHelper -Namespace OpenCodeNotifier -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();' -PassThru; $type::GetForegroundWindow()`;
|
|
635
|
+
let windowId = execFileWithTimeout("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
|
|
636
|
+
if (!windowId)
|
|
637
|
+
windowId = execFileWithTimeout("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
|
|
638
|
+
return windowId;
|
|
639
|
+
}
|
|
640
|
+
function getMacOSActiveWindowId() {
|
|
641
|
+
return execWithTimeout(`osascript -e 'tell application "System Events" to return id of window 1 of (first application process whose frontmost is true)'`);
|
|
642
|
+
}
|
|
643
|
+
function getMacOSFrontmostAppName() {
|
|
644
|
+
return execWithTimeout(`osascript -e 'tell application "System Events" to return name of first application process whose frontmost is true'`);
|
|
645
|
+
}
|
|
646
|
+
function normalizeMacAppName(value) {
|
|
647
|
+
return value.trim().toLowerCase().replace(/\.app$/i, "").replace(/\s+/g, " ");
|
|
648
|
+
}
|
|
649
|
+
function getExpectedMacTerminalAppNames(env) {
|
|
650
|
+
const expected = new Set;
|
|
651
|
+
const termProgram = typeof env.TERM_PROGRAM === "string" ? normalizeMacAppName(env.TERM_PROGRAM) : "";
|
|
652
|
+
if (env.TMUX && (termProgram === "tmux" || termProgram === "screen" || termProgram.length === 0)) {
|
|
653
|
+
return new Set(MAC_TERMINAL_APP_NAMES);
|
|
654
|
+
}
|
|
655
|
+
if (termProgram === "apple_terminal") {
|
|
656
|
+
expected.add("terminal");
|
|
657
|
+
} else if (termProgram === "iterm" || termProgram === "iterm2") {
|
|
658
|
+
expected.add("iterm2");
|
|
659
|
+
} else if (termProgram === "vscode") {
|
|
660
|
+
expected.add("visual studio code");
|
|
661
|
+
expected.add("code");
|
|
662
|
+
expected.add("code insiders");
|
|
663
|
+
} else if (termProgram === "warpterminal") {
|
|
664
|
+
expected.add("warp");
|
|
665
|
+
} else if (termProgram.length > 0) {
|
|
666
|
+
expected.add(termProgram);
|
|
667
|
+
}
|
|
668
|
+
if (expected.size > 0) {
|
|
669
|
+
return expected;
|
|
670
|
+
}
|
|
671
|
+
return new Set(MAC_TERMINAL_APP_NAMES);
|
|
672
|
+
}
|
|
673
|
+
function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
|
|
674
|
+
if (!frontmostAppName) {
|
|
675
|
+
return false;
|
|
676
|
+
}
|
|
677
|
+
const normalizedFrontmost = normalizeMacAppName(frontmostAppName);
|
|
678
|
+
if (!normalizedFrontmost) {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
const expectedApps = getExpectedMacTerminalAppNames(env);
|
|
682
|
+
return expectedApps.has(normalizedFrontmost);
|
|
683
|
+
}
|
|
684
|
+
function getActiveWindowId() {
|
|
685
|
+
const platform3 = process.platform;
|
|
686
|
+
if (platform3 === "darwin")
|
|
687
|
+
return getMacOSActiveWindowId();
|
|
688
|
+
if (platform3 === "linux") {
|
|
689
|
+
if (process.env.WAYLAND_DISPLAY)
|
|
690
|
+
return getLinuxWaylandActiveWindowId();
|
|
691
|
+
if (process.env.DISPLAY)
|
|
692
|
+
return execWithTimeout("xdotool getactivewindow");
|
|
693
|
+
return null;
|
|
694
|
+
}
|
|
695
|
+
if (platform3 === "win32")
|
|
696
|
+
return getWindowsActiveWindowId();
|
|
697
|
+
return null;
|
|
698
|
+
}
|
|
699
|
+
var cachedWindowId = getActiveWindowId();
|
|
700
|
+
function isTmuxPaneFocused(tmuxPane, probeResult) {
|
|
701
|
+
if (!tmuxPane)
|
|
702
|
+
return false;
|
|
703
|
+
if (!probeResult)
|
|
704
|
+
return false;
|
|
705
|
+
const [sessionAttached, windowActive, paneActive] = probeResult.split(" ");
|
|
706
|
+
return sessionAttached === "1" && windowActive === "1" && paneActive === "1";
|
|
707
|
+
}
|
|
708
|
+
function isTmuxPaneActive() {
|
|
709
|
+
const tmuxPane = process.env.TMUX_PANE ?? null;
|
|
710
|
+
const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
|
|
711
|
+
return isTmuxPaneFocused(tmuxPane, result);
|
|
712
|
+
}
|
|
713
|
+
function isWezTermPaneActive() {
|
|
714
|
+
const weztermPane = process.env.WEZTERM_PANE ?? null;
|
|
715
|
+
if (!weztermPane)
|
|
716
|
+
return true;
|
|
717
|
+
const output = execFileWithTimeout("wezterm", ["cli", "list-clients", "--format", "json"], 1000);
|
|
718
|
+
if (!output)
|
|
719
|
+
return false;
|
|
720
|
+
const focusedPaneId = parseWezTermFocusedPaneId(output);
|
|
721
|
+
if (!focusedPaneId)
|
|
722
|
+
return false;
|
|
723
|
+
return focusedPaneId === weztermPane;
|
|
724
|
+
}
|
|
725
|
+
function isTerminalFocused() {
|
|
726
|
+
try {
|
|
727
|
+
if (process.platform === "darwin") {
|
|
728
|
+
const frontmostAppName = getMacOSFrontmostAppName();
|
|
729
|
+
if (!isMacTerminalAppFocused(frontmostAppName, process.env)) {
|
|
730
|
+
return false;
|
|
731
|
+
}
|
|
732
|
+
if (!isWezTermPaneActive()) {
|
|
733
|
+
return false;
|
|
734
|
+
}
|
|
735
|
+
if (process.env.TMUX) {
|
|
736
|
+
return isTmuxPaneActive();
|
|
737
|
+
}
|
|
738
|
+
return true;
|
|
739
|
+
}
|
|
740
|
+
if (!cachedWindowId)
|
|
741
|
+
return false;
|
|
742
|
+
const currentId = getActiveWindowId();
|
|
743
|
+
if (currentId !== cachedWindowId)
|
|
744
|
+
return false;
|
|
745
|
+
if (!isWezTermPaneActive())
|
|
746
|
+
return false;
|
|
747
|
+
if (process.env.TMUX)
|
|
748
|
+
return isTmuxPaneActive();
|
|
749
|
+
return true;
|
|
750
|
+
} catch {
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/permission-dedupe.ts
|
|
756
|
+
var PERMISSION_DEDUPE_WINDOW_MS = 1000;
|
|
757
|
+
var sessionLastPermissionAt = new Map;
|
|
758
|
+
var globalLastPermissionAt = 0;
|
|
759
|
+
function shouldSuppressPermissionAlert(sessionID, now = Date.now()) {
|
|
760
|
+
const sessionLastAt = sessionID ? sessionLastPermissionAt.get(sessionID) : undefined;
|
|
761
|
+
const latestSeen = Math.max(globalLastPermissionAt, sessionLastAt ?? 0);
|
|
762
|
+
const isDuplicate = latestSeen > 0 && now - latestSeen < PERMISSION_DEDUPE_WINDOW_MS;
|
|
763
|
+
if (isDuplicate) {
|
|
764
|
+
return true;
|
|
765
|
+
}
|
|
766
|
+
globalLastPermissionAt = now;
|
|
767
|
+
if (sessionID) {
|
|
768
|
+
sessionLastPermissionAt.set(sessionID, now);
|
|
769
|
+
}
|
|
770
|
+
return false;
|
|
771
|
+
}
|
|
772
|
+
function prunePermissionAlertState(cutoffMs) {
|
|
773
|
+
for (const [sessionID, timestamp] of sessionLastPermissionAt) {
|
|
774
|
+
if (timestamp < cutoffMs) {
|
|
775
|
+
sessionLastPermissionAt.delete(sessionID);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
if (globalLastPermissionAt < cutoffMs) {
|
|
779
|
+
globalLastPermissionAt = 0;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// src/index.ts
|
|
784
|
+
var IDLE_COMPLETE_DELAY_MS = 350;
|
|
785
|
+
var pendingIdleTimers = new Map;
|
|
786
|
+
var sessionIdleSequence = new Map;
|
|
787
|
+
var sessionErrorSuppressionAt = new Map;
|
|
788
|
+
var sessionLastBusyAt = new Map;
|
|
789
|
+
var globalTurnCount = null;
|
|
790
|
+
function loadTurnCount() {
|
|
791
|
+
try {
|
|
792
|
+
const content = readFileSync2(getStatePath(), "utf-8");
|
|
793
|
+
const state = JSON.parse(content);
|
|
794
|
+
if (typeof state.turn === "number" && Number.isFinite(state.turn) && state.turn >= 0) {
|
|
795
|
+
return state.turn;
|
|
796
|
+
}
|
|
797
|
+
} catch {}
|
|
798
|
+
return 0;
|
|
799
|
+
}
|
|
800
|
+
function saveTurnCount(count) {
|
|
801
|
+
try {
|
|
802
|
+
writeFileSync(getStatePath(), JSON.stringify({ turn: count }));
|
|
803
|
+
} catch {}
|
|
804
|
+
}
|
|
805
|
+
function incrementTurnCount() {
|
|
806
|
+
if (globalTurnCount === null) {
|
|
807
|
+
globalTurnCount = loadTurnCount();
|
|
808
|
+
}
|
|
809
|
+
globalTurnCount++;
|
|
810
|
+
saveTurnCount(globalTurnCount);
|
|
811
|
+
return globalTurnCount;
|
|
812
|
+
}
|
|
813
|
+
setInterval(() => {
|
|
814
|
+
const cutoff = Date.now() - 5 * 60 * 1000;
|
|
815
|
+
for (const [sessionID] of sessionIdleSequence) {
|
|
816
|
+
if (!pendingIdleTimers.has(sessionID)) {
|
|
817
|
+
sessionIdleSequence.delete(sessionID);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
for (const [sessionID, timestamp] of sessionErrorSuppressionAt) {
|
|
821
|
+
if (timestamp < cutoff) {
|
|
822
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
for (const [sessionID, timestamp] of sessionLastBusyAt) {
|
|
826
|
+
if (timestamp < cutoff) {
|
|
827
|
+
sessionLastBusyAt.delete(sessionID);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
prunePermissionAlertState(cutoff);
|
|
831
|
+
}, 5 * 60 * 1000);
|
|
832
|
+
function getNotificationTitle(config, projectName) {
|
|
833
|
+
if (config.showProjectName && projectName) {
|
|
834
|
+
return `OpenCode (${projectName})`;
|
|
835
|
+
}
|
|
836
|
+
return "OpenCode";
|
|
837
|
+
}
|
|
838
|
+
function formatTimestamp() {
|
|
839
|
+
const now = new Date;
|
|
840
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
841
|
+
const m = String(now.getMinutes()).padStart(2, "0");
|
|
842
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
843
|
+
return `${h}:${m}:${s}`;
|
|
844
|
+
}
|
|
845
|
+
function extractAgentNameFromSessionTitle(sessionTitle) {
|
|
846
|
+
if (typeof sessionTitle !== "string" || sessionTitle.length === 0) {
|
|
847
|
+
return "";
|
|
848
|
+
}
|
|
849
|
+
const match = sessionTitle.match(/\s*\(@([^\s)]+)\s+subagent\)\s*$/);
|
|
850
|
+
return match ? match[1] : "";
|
|
851
|
+
}
|
|
852
|
+
function shouldResolveAgentNameForEvent(config, eventType) {
|
|
853
|
+
if (getMessage(config, eventType).includes("{agentName}")) {
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
if (!config.command.enabled || !isEventCommandEnabled(config, eventType)) {
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
if (config.command.path.includes("{agentName}")) {
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
return (config.command.args ?? []).some((arg) => arg.includes("{agentName}"));
|
|
863
|
+
}
|
|
864
|
+
async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName) {
|
|
865
|
+
if (config.suppressWhenFocused && isTerminalFocused()) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
const promises = [];
|
|
869
|
+
const timestamp = formatTimestamp();
|
|
870
|
+
const turn = incrementTurnCount();
|
|
871
|
+
const rawMessage = getMessage(config, eventType);
|
|
872
|
+
const message = interpolateMessage(rawMessage, {
|
|
873
|
+
sessionTitle: config.showSessionTitle ? sessionTitle : null,
|
|
874
|
+
agentName,
|
|
875
|
+
projectName,
|
|
876
|
+
timestamp,
|
|
877
|
+
turn
|
|
878
|
+
});
|
|
879
|
+
if (isEventNotificationEnabled(config, eventType)) {
|
|
880
|
+
const title = getNotificationTitle(config, projectName);
|
|
881
|
+
const iconPath = getIconPath(config);
|
|
882
|
+
promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping));
|
|
883
|
+
}
|
|
884
|
+
if (isEventSoundEnabled(config, eventType)) {
|
|
885
|
+
const customSoundPath = getSoundPath(config, eventType);
|
|
886
|
+
const soundVolume = getSoundVolume(config, eventType);
|
|
887
|
+
promises.push(playSound(eventType, customSoundPath, soundVolume));
|
|
888
|
+
}
|
|
889
|
+
const minDuration = config.command?.minDuration;
|
|
890
|
+
const shouldSkipCommand = !isEventCommandEnabled(config, eventType) || typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
|
|
891
|
+
if (!shouldSkipCommand) {
|
|
892
|
+
runCommand2(config, eventType, message, sessionTitle, agentName, projectName, timestamp, turn);
|
|
893
|
+
}
|
|
894
|
+
await Promise.allSettled(promises);
|
|
895
|
+
}
|
|
896
|
+
function getSessionIDFromEvent(event) {
|
|
897
|
+
const sessionID = event?.properties?.sessionID;
|
|
898
|
+
if (typeof sessionID === "string" && sessionID.length > 0) {
|
|
899
|
+
return sessionID;
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
function clearPendingIdleTimer(sessionID) {
|
|
904
|
+
const timer = pendingIdleTimers.get(sessionID);
|
|
905
|
+
if (!timer) {
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
clearTimeout(timer);
|
|
909
|
+
pendingIdleTimers.delete(sessionID);
|
|
910
|
+
}
|
|
911
|
+
function bumpSessionIdleSequence(sessionID) {
|
|
912
|
+
const nextSequence = (sessionIdleSequence.get(sessionID) ?? 0) + 1;
|
|
913
|
+
sessionIdleSequence.set(sessionID, nextSequence);
|
|
914
|
+
return nextSequence;
|
|
915
|
+
}
|
|
916
|
+
function hasCurrentSessionIdleSequence(sessionID, sequence) {
|
|
917
|
+
return sessionIdleSequence.get(sessionID) === sequence;
|
|
918
|
+
}
|
|
919
|
+
function markSessionError(sessionID) {
|
|
920
|
+
if (!sessionID) {
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
sessionErrorSuppressionAt.set(sessionID, Date.now());
|
|
924
|
+
bumpSessionIdleSequence(sessionID);
|
|
925
|
+
clearPendingIdleTimer(sessionID);
|
|
926
|
+
}
|
|
927
|
+
function markSessionBusy(sessionID) {
|
|
928
|
+
const now = Date.now();
|
|
929
|
+
sessionLastBusyAt.set(sessionID, now);
|
|
930
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
931
|
+
bumpSessionIdleSequence(sessionID);
|
|
932
|
+
clearPendingIdleTimer(sessionID);
|
|
933
|
+
}
|
|
934
|
+
function shouldSuppressSessionIdle(sessionID, consume = true) {
|
|
935
|
+
const errorAt = sessionErrorSuppressionAt.get(sessionID);
|
|
936
|
+
if (errorAt === undefined) {
|
|
937
|
+
return false;
|
|
938
|
+
}
|
|
939
|
+
const busyAt = sessionLastBusyAt.get(sessionID);
|
|
940
|
+
if (typeof busyAt === "number" && busyAt > errorAt) {
|
|
941
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
942
|
+
return false;
|
|
943
|
+
}
|
|
944
|
+
if (consume) {
|
|
945
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
946
|
+
}
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
async function getElapsedSinceLastPrompt(client, sessionID, nowMs = Date.now()) {
|
|
950
|
+
try {
|
|
951
|
+
const response = await client.session.messages({ path: { id: sessionID } });
|
|
952
|
+
const messages = response.data ?? [];
|
|
953
|
+
let lastUserMessageTime = null;
|
|
954
|
+
for (const msg of messages) {
|
|
955
|
+
const info = msg.info;
|
|
956
|
+
if (info.role === "user" && typeof info.time?.created === "number") {
|
|
957
|
+
if (lastUserMessageTime === null || info.time.created > lastUserMessageTime) {
|
|
958
|
+
lastUserMessageTime = info.time.created;
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (lastUserMessageTime !== null) {
|
|
963
|
+
return (nowMs - lastUserMessageTime) / 1000;
|
|
964
|
+
}
|
|
965
|
+
} catch {}
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
async function getSessionInfo(client, sessionID) {
|
|
969
|
+
try {
|
|
970
|
+
const response = await client.session.get({ path: { id: sessionID } });
|
|
971
|
+
const title = typeof response.data?.title === "string" ? response.data.title : null;
|
|
972
|
+
return {
|
|
973
|
+
isChild: !!response.data?.parentID,
|
|
974
|
+
title
|
|
975
|
+
};
|
|
976
|
+
} catch {
|
|
977
|
+
return { isChild: false, title: null };
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
async function processSessionIdle(client, config, projectName, event, sessionID, sequence, idleReceivedAtMs) {
|
|
981
|
+
if (!hasCurrentSessionIdleSequence(sessionID, sequence)) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (shouldSuppressSessionIdle(sessionID)) {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
const sessionInfo = await getSessionInfo(client, sessionID);
|
|
988
|
+
if (!hasCurrentSessionIdleSequence(sessionID, sequence)) {
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (shouldSuppressSessionIdle(sessionID)) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (!sessionInfo.isChild) {
|
|
995
|
+
await handleEventWithElapsedTime(client, config, "complete", projectName, event, idleReceivedAtMs, sessionInfo.title);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, event, idleReceivedAtMs, sessionInfo.title);
|
|
999
|
+
}
|
|
1000
|
+
function scheduleSessionIdle(client, config, projectName, event, sessionID) {
|
|
1001
|
+
clearPendingIdleTimer(sessionID);
|
|
1002
|
+
const sequence = bumpSessionIdleSequence(sessionID);
|
|
1003
|
+
const idleReceivedAtMs = Date.now();
|
|
1004
|
+
const timer = setTimeout(() => {
|
|
1005
|
+
pendingIdleTimers.delete(sessionID);
|
|
1006
|
+
processSessionIdle(client, config, projectName, event, sessionID, sequence, idleReceivedAtMs).catch(() => {
|
|
1007
|
+
return;
|
|
1008
|
+
});
|
|
1009
|
+
}, IDLE_COMPLETE_DELAY_MS);
|
|
1010
|
+
pendingIdleTimers.set(sessionID, timer);
|
|
1011
|
+
}
|
|
1012
|
+
async function handleEventWithElapsedTime(client, config, eventType, projectName, event, elapsedReferenceNowMs, preloadedSessionTitle) {
|
|
1013
|
+
const sessionID = getSessionIDFromEvent(event);
|
|
1014
|
+
const minDuration = config.command?.minDuration;
|
|
1015
|
+
const shouldLookupElapsed = !!config.command?.enabled && typeof config.command?.path === "string" && config.command.path.length > 0 && typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0;
|
|
1016
|
+
let elapsedSeconds = null;
|
|
1017
|
+
if (shouldLookupElapsed) {
|
|
1018
|
+
if (sessionID) {
|
|
1019
|
+
elapsedSeconds = await getElapsedSinceLastPrompt(client, sessionID, elapsedReferenceNowMs);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
let sessionTitle = preloadedSessionTitle ?? null;
|
|
1023
|
+
const shouldLookupSessionInfo = sessionID && !sessionTitle && (config.showSessionTitle || shouldResolveAgentNameForEvent(config, eventType));
|
|
1024
|
+
if (shouldLookupSessionInfo) {
|
|
1025
|
+
const info = await getSessionInfo(client, sessionID);
|
|
1026
|
+
sessionTitle = info.title;
|
|
1027
|
+
}
|
|
1028
|
+
const agentName = extractAgentNameFromSessionTitle(sessionTitle);
|
|
1029
|
+
await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName);
|
|
1030
|
+
}
|
|
1031
|
+
var NotifierPlugin = async ({ client, directory }) => {
|
|
1032
|
+
const clientEnv = process.env.OPENCODE_CLIENT;
|
|
1033
|
+
if (clientEnv && clientEnv !== "cli") {
|
|
1034
|
+
const config = loadConfig();
|
|
1035
|
+
if (!config.enableOnDesktop)
|
|
1036
|
+
return {};
|
|
1037
|
+
}
|
|
1038
|
+
const getConfig = () => loadConfig();
|
|
1039
|
+
const projectName = directory ? basename(directory) : null;
|
|
1040
|
+
return {
|
|
1041
|
+
event: async ({ event }) => {
|
|
1042
|
+
const config = getConfig();
|
|
1043
|
+
if (event.type === "permission.asked") {
|
|
1044
|
+
const sessionID = getSessionIDFromEvent(event);
|
|
1045
|
+
if (!shouldSuppressPermissionAlert(sessionID)) {
|
|
1046
|
+
await handleEventWithElapsedTime(client, config, "permission", projectName, event);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (event.type === "session.idle") {
|
|
1050
|
+
const sessionID = getSessionIDFromEvent(event);
|
|
1051
|
+
if (sessionID) {
|
|
1052
|
+
scheduleSessionIdle(client, config, projectName, event, sessionID);
|
|
1053
|
+
} else {
|
|
1054
|
+
await handleEventWithElapsedTime(client, config, "complete", projectName, event);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
if (event.type === "session.status" && event.properties.status.type === "busy") {
|
|
1058
|
+
markSessionBusy(event.properties.sessionID);
|
|
1059
|
+
}
|
|
1060
|
+
if (event.type === "session.error") {
|
|
1061
|
+
const sessionID = getSessionIDFromEvent(event);
|
|
1062
|
+
markSessionError(sessionID);
|
|
1063
|
+
const eventType = event.properties.error?.name === "MessageAbortedError" ? "user_cancelled" : "error";
|
|
1064
|
+
let sessionTitle = null;
|
|
1065
|
+
if (sessionID && config.showSessionTitle) {
|
|
1066
|
+
const info = await getSessionInfo(client, sessionID);
|
|
1067
|
+
sessionTitle = info.title;
|
|
1068
|
+
}
|
|
1069
|
+
await handleEventWithElapsedTime(client, config, eventType, projectName, event, undefined, sessionTitle);
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
"permission.ask": async () => {
|
|
1073
|
+
const config = getConfig();
|
|
1074
|
+
if (!shouldSuppressPermissionAlert(null)) {
|
|
1075
|
+
await handleEvent(config, "permission", projectName, null);
|
|
1076
|
+
}
|
|
1077
|
+
},
|
|
1078
|
+
"tool.execute.before": async (input) => {
|
|
1079
|
+
const config = getConfig();
|
|
1080
|
+
if (input.tool === "question") {
|
|
1081
|
+
await handleEvent(config, "question", projectName, null);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
};
|
|
1086
|
+
var src_default = NotifierPlugin;
|
|
1087
|
+
export {
|
|
1088
|
+
extractAgentNameFromSessionTitle,
|
|
1089
|
+
src_default as default,
|
|
1090
|
+
NotifierPlugin
|
|
1091
|
+
};
|