@nm156/opencode-notifier 0.3.1
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 +679 -0
- package/dist/index.js +2440 -0
- package/logos/opencode-logo-dark.png +0 -0
- package/logos/opencode-logo-light.png +0 -0
- package/package.json +62 -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,2440 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { basename } from "path";
|
|
3
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync2 } 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
|
+
|
|
11
|
+
// node_modules/is-wsl/index.js
|
|
12
|
+
import process2 from "node:process";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import fs3 from "node:fs";
|
|
15
|
+
|
|
16
|
+
// node_modules/is-inside-container/index.js
|
|
17
|
+
import fs2 from "node:fs";
|
|
18
|
+
|
|
19
|
+
// node_modules/is-docker/index.js
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
var isDockerCached;
|
|
22
|
+
function hasDockerEnv() {
|
|
23
|
+
try {
|
|
24
|
+
fs.statSync("/.dockerenv");
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function hasDockerCGroup() {
|
|
31
|
+
try {
|
|
32
|
+
return fs.readFileSync("/proc/self/cgroup", "utf8").includes("docker");
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function isDocker() {
|
|
38
|
+
if (isDockerCached === undefined) {
|
|
39
|
+
isDockerCached = hasDockerEnv() || hasDockerCGroup();
|
|
40
|
+
}
|
|
41
|
+
return isDockerCached;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// node_modules/is-inside-container/index.js
|
|
45
|
+
var cachedResult;
|
|
46
|
+
var hasContainerEnv = () => {
|
|
47
|
+
try {
|
|
48
|
+
fs2.statSync("/run/.containerenv");
|
|
49
|
+
return true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
function isInsideContainer() {
|
|
55
|
+
if (cachedResult === undefined) {
|
|
56
|
+
cachedResult = hasContainerEnv() || isDocker();
|
|
57
|
+
}
|
|
58
|
+
return cachedResult;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// node_modules/is-wsl/index.js
|
|
62
|
+
var isWsl = () => {
|
|
63
|
+
if (process2.platform !== "linux") {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (os.release().toLowerCase().includes("microsoft")) {
|
|
67
|
+
if (isInsideContainer()) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
if (fs3.readFileSync("/proc/version", "utf8").toLowerCase().includes("microsoft")) {
|
|
74
|
+
return !isInsideContainer();
|
|
75
|
+
}
|
|
76
|
+
} catch {}
|
|
77
|
+
if (fs3.existsSync("/proc/sys/fs/binfmt_misc/WSLInterop") || fs3.existsSync("/run/WSL")) {
|
|
78
|
+
return !isInsideContainer();
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
};
|
|
82
|
+
var is_wsl_default = process2.env.__IS_WSL_TEST__ ? isWsl : isWsl();
|
|
83
|
+
|
|
84
|
+
// src/config.ts
|
|
85
|
+
var DEFAULT_EVENT_CONFIG = {
|
|
86
|
+
sound: true,
|
|
87
|
+
notification: true,
|
|
88
|
+
command: true,
|
|
89
|
+
bell: false
|
|
90
|
+
};
|
|
91
|
+
var DEFAULT_CONFIG = {
|
|
92
|
+
sound: true,
|
|
93
|
+
notification: true,
|
|
94
|
+
bell: false,
|
|
95
|
+
timeout: 5,
|
|
96
|
+
showProjectName: true,
|
|
97
|
+
showFullPath: false,
|
|
98
|
+
showSessionTitle: false,
|
|
99
|
+
showIcon: true,
|
|
100
|
+
customIconPath: null,
|
|
101
|
+
suppressWhenFocused: true,
|
|
102
|
+
enableOnDesktop: false,
|
|
103
|
+
notificationSystem: "osascript",
|
|
104
|
+
suppressGhosttySound: false,
|
|
105
|
+
linux: {
|
|
106
|
+
grouping: false
|
|
107
|
+
},
|
|
108
|
+
windows: {
|
|
109
|
+
appID: "opencode"
|
|
110
|
+
},
|
|
111
|
+
minDuration: 0,
|
|
112
|
+
command: {
|
|
113
|
+
enabled: false,
|
|
114
|
+
path: "",
|
|
115
|
+
minDuration: 0
|
|
116
|
+
},
|
|
117
|
+
events: {
|
|
118
|
+
permission: { ...DEFAULT_EVENT_CONFIG },
|
|
119
|
+
complete: { ...DEFAULT_EVENT_CONFIG },
|
|
120
|
+
subagent_complete: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false },
|
|
121
|
+
error: { ...DEFAULT_EVENT_CONFIG },
|
|
122
|
+
question: { ...DEFAULT_EVENT_CONFIG },
|
|
123
|
+
interrupted: { ...DEFAULT_EVENT_CONFIG },
|
|
124
|
+
user_cancelled: { ...DEFAULT_EVENT_CONFIG, sound: false, notification: false },
|
|
125
|
+
plan_exit: { ...DEFAULT_EVENT_CONFIG },
|
|
126
|
+
session_started: { ...DEFAULT_EVENT_CONFIG, notification: false },
|
|
127
|
+
user_message: { ...DEFAULT_EVENT_CONFIG, notification: false },
|
|
128
|
+
client_connected: { ...DEFAULT_EVENT_CONFIG, notification: false }
|
|
129
|
+
},
|
|
130
|
+
messages: {
|
|
131
|
+
permission: "Session needs permission: {sessionTitle}",
|
|
132
|
+
complete: "Session has finished: {sessionTitle}",
|
|
133
|
+
subagent_complete: "Subagent task completed: {sessionTitle}",
|
|
134
|
+
error: "Session encountered an error: {sessionTitle}",
|
|
135
|
+
question: "Session has a question: {sessionTitle}",
|
|
136
|
+
interrupted: "Session was interrupted: {sessionTitle}",
|
|
137
|
+
user_cancelled: "Session was cancelled by user: {sessionTitle}",
|
|
138
|
+
plan_exit: "Plan ready for review: {sessionTitle}",
|
|
139
|
+
session_started: "Session started: {sessionTitle}",
|
|
140
|
+
user_message: "User sent a message: {sessionTitle}",
|
|
141
|
+
client_connected: "OpenCode connected"
|
|
142
|
+
},
|
|
143
|
+
sounds: {
|
|
144
|
+
permission: null,
|
|
145
|
+
complete: null,
|
|
146
|
+
subagent_complete: null,
|
|
147
|
+
error: null,
|
|
148
|
+
question: null,
|
|
149
|
+
interrupted: null,
|
|
150
|
+
user_cancelled: null,
|
|
151
|
+
plan_exit: null,
|
|
152
|
+
session_started: null,
|
|
153
|
+
user_message: null,
|
|
154
|
+
client_connected: null
|
|
155
|
+
},
|
|
156
|
+
volumes: {
|
|
157
|
+
permission: 1,
|
|
158
|
+
complete: 1,
|
|
159
|
+
subagent_complete: 1,
|
|
160
|
+
error: 1,
|
|
161
|
+
question: 1,
|
|
162
|
+
interrupted: 1,
|
|
163
|
+
user_cancelled: 1,
|
|
164
|
+
plan_exit: 1,
|
|
165
|
+
session_started: 1,
|
|
166
|
+
user_message: 1,
|
|
167
|
+
client_connected: 1
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
function getConfigPath() {
|
|
171
|
+
if (process.env.OPENCODE_NOTIFIER_CONFIG_PATH) {
|
|
172
|
+
return process.env.OPENCODE_NOTIFIER_CONFIG_PATH;
|
|
173
|
+
}
|
|
174
|
+
return join(homedir(), ".config", "opencode", "opencode-notifier.json");
|
|
175
|
+
}
|
|
176
|
+
function getStatePath() {
|
|
177
|
+
const configPath = getConfigPath();
|
|
178
|
+
return join(dirname(configPath), "opencode-notifier-state.json");
|
|
179
|
+
}
|
|
180
|
+
function parseEventConfig(userEvent, defaultConfig) {
|
|
181
|
+
if (userEvent === undefined) {
|
|
182
|
+
return defaultConfig;
|
|
183
|
+
}
|
|
184
|
+
if (typeof userEvent === "boolean") {
|
|
185
|
+
return {
|
|
186
|
+
sound: userEvent,
|
|
187
|
+
notification: userEvent,
|
|
188
|
+
command: userEvent,
|
|
189
|
+
bell: defaultConfig.bell
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return {
|
|
193
|
+
sound: userEvent.sound ?? defaultConfig.sound,
|
|
194
|
+
notification: userEvent.notification ?? defaultConfig.notification,
|
|
195
|
+
command: userEvent.command ?? defaultConfig.command,
|
|
196
|
+
bell: userEvent.bell ?? defaultConfig.bell
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function parseVolume(value, defaultVolume) {
|
|
200
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
201
|
+
return defaultVolume;
|
|
202
|
+
}
|
|
203
|
+
if (value < 0) {
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
if (value > 1) {
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
function loadConfig() {
|
|
212
|
+
const configPath = getConfigPath();
|
|
213
|
+
if (!existsSync(configPath)) {
|
|
214
|
+
return DEFAULT_CONFIG;
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const fileContent = readFileSync(configPath, "utf-8");
|
|
218
|
+
const userConfig = JSON.parse(fileContent);
|
|
219
|
+
const globalSound = userConfig.sound ?? DEFAULT_CONFIG.sound;
|
|
220
|
+
const globalNotification = userConfig.notification ?? DEFAULT_CONFIG.notification;
|
|
221
|
+
const globalBell = userConfig.bell ?? DEFAULT_CONFIG.bell;
|
|
222
|
+
const defaultWithGlobal = {
|
|
223
|
+
sound: globalSound,
|
|
224
|
+
notification: globalNotification,
|
|
225
|
+
command: true,
|
|
226
|
+
bell: globalBell
|
|
227
|
+
};
|
|
228
|
+
const userCommand = userConfig.command ?? {};
|
|
229
|
+
const commandArgs = Array.isArray(userCommand.args) ? userCommand.args.filter((arg) => typeof arg === "string") : undefined;
|
|
230
|
+
const commandMinDuration = typeof userCommand.minDuration === "number" && Number.isFinite(userCommand.minDuration) && userCommand.minDuration > 0 ? userCommand.minDuration : 0;
|
|
231
|
+
return {
|
|
232
|
+
sound: globalSound,
|
|
233
|
+
notification: globalNotification,
|
|
234
|
+
bell: globalBell,
|
|
235
|
+
timeout: typeof userConfig.timeout === "number" && userConfig.timeout > 0 ? userConfig.timeout : DEFAULT_CONFIG.timeout,
|
|
236
|
+
showProjectName: userConfig.showProjectName ?? DEFAULT_CONFIG.showProjectName,
|
|
237
|
+
showFullPath: userConfig.showFullPath ?? DEFAULT_CONFIG.showFullPath,
|
|
238
|
+
showSessionTitle: userConfig.showSessionTitle ?? DEFAULT_CONFIG.showSessionTitle,
|
|
239
|
+
showIcon: userConfig.showIcon ?? DEFAULT_CONFIG.showIcon,
|
|
240
|
+
customIconPath: userConfig.customIconPath ?? DEFAULT_CONFIG.customIconPath,
|
|
241
|
+
suppressWhenFocused: userConfig.suppressWhenFocused ?? DEFAULT_CONFIG.suppressWhenFocused,
|
|
242
|
+
enableOnDesktop: typeof userConfig.enableOnDesktop === "boolean" ? userConfig.enableOnDesktop : DEFAULT_CONFIG.enableOnDesktop,
|
|
243
|
+
notificationSystem: userConfig.notificationSystem === "node-notifier" ? "node-notifier" : userConfig.notificationSystem === "ghostty" ? "ghostty" : "osascript",
|
|
244
|
+
suppressGhosttySound: typeof userConfig.suppressGhosttySound === "boolean" ? userConfig.suppressGhosttySound : DEFAULT_CONFIG.suppressGhosttySound,
|
|
245
|
+
linux: {
|
|
246
|
+
grouping: typeof userConfig.linux?.grouping === "boolean" ? userConfig.linux.grouping : DEFAULT_CONFIG.linux.grouping
|
|
247
|
+
},
|
|
248
|
+
windows: {
|
|
249
|
+
appID: typeof userConfig.windows?.appID === "string" && userConfig.windows.appID.length > 0 ? userConfig.windows.appID : DEFAULT_CONFIG.windows.appID
|
|
250
|
+
},
|
|
251
|
+
minDuration: typeof userConfig.minDuration === "number" && Number.isFinite(userConfig.minDuration) && userConfig.minDuration >= 0 ? userConfig.minDuration : DEFAULT_CONFIG.minDuration,
|
|
252
|
+
command: {
|
|
253
|
+
enabled: typeof userCommand.enabled === "boolean" ? userCommand.enabled : DEFAULT_CONFIG.command.enabled,
|
|
254
|
+
path: typeof userCommand.path === "string" ? userCommand.path : DEFAULT_CONFIG.command.path,
|
|
255
|
+
args: commandArgs,
|
|
256
|
+
minDuration: commandMinDuration
|
|
257
|
+
},
|
|
258
|
+
events: {
|
|
259
|
+
permission: parseEventConfig(userConfig.events?.permission ?? userConfig.permission, defaultWithGlobal),
|
|
260
|
+
complete: parseEventConfig(userConfig.events?.complete ?? userConfig.complete, defaultWithGlobal),
|
|
261
|
+
subagent_complete: parseEventConfig(userConfig.events?.subagent_complete ?? userConfig.subagent_complete, { sound: false, notification: false, command: true, bell: false }),
|
|
262
|
+
error: parseEventConfig(userConfig.events?.error ?? userConfig.error, defaultWithGlobal),
|
|
263
|
+
question: parseEventConfig(userConfig.events?.question ?? userConfig.question, defaultWithGlobal),
|
|
264
|
+
interrupted: parseEventConfig(userConfig.events?.interrupted ?? userConfig.interrupted, defaultWithGlobal),
|
|
265
|
+
user_cancelled: parseEventConfig(userConfig.events?.user_cancelled ?? userConfig.user_cancelled, { sound: false, notification: false, command: true, bell: false }),
|
|
266
|
+
plan_exit: parseEventConfig(userConfig.events?.plan_exit ?? userConfig.plan_exit, defaultWithGlobal),
|
|
267
|
+
session_started: parseEventConfig(userConfig.events?.session_started ?? userConfig.session_started, { ...defaultWithGlobal, notification: false }),
|
|
268
|
+
user_message: parseEventConfig(userConfig.events?.user_message ?? userConfig.user_message, { ...defaultWithGlobal, notification: false }),
|
|
269
|
+
client_connected: parseEventConfig(userConfig.events?.client_connected ?? userConfig.client_connected, { ...defaultWithGlobal, notification: false })
|
|
270
|
+
},
|
|
271
|
+
messages: {
|
|
272
|
+
permission: userConfig.messages?.permission ?? DEFAULT_CONFIG.messages.permission,
|
|
273
|
+
complete: userConfig.messages?.complete ?? DEFAULT_CONFIG.messages.complete,
|
|
274
|
+
subagent_complete: userConfig.messages?.subagent_complete ?? DEFAULT_CONFIG.messages.subagent_complete,
|
|
275
|
+
error: userConfig.messages?.error ?? DEFAULT_CONFIG.messages.error,
|
|
276
|
+
question: userConfig.messages?.question ?? DEFAULT_CONFIG.messages.question,
|
|
277
|
+
interrupted: userConfig.messages?.interrupted ?? DEFAULT_CONFIG.messages.interrupted,
|
|
278
|
+
user_cancelled: userConfig.messages?.user_cancelled ?? DEFAULT_CONFIG.messages.user_cancelled,
|
|
279
|
+
plan_exit: userConfig.messages?.plan_exit ?? DEFAULT_CONFIG.messages.plan_exit,
|
|
280
|
+
session_started: userConfig.messages?.session_started ?? DEFAULT_CONFIG.messages.session_started,
|
|
281
|
+
user_message: userConfig.messages?.user_message ?? DEFAULT_CONFIG.messages.user_message,
|
|
282
|
+
client_connected: userConfig.messages?.client_connected ?? DEFAULT_CONFIG.messages.client_connected
|
|
283
|
+
},
|
|
284
|
+
sounds: {
|
|
285
|
+
permission: userConfig.sounds?.permission ?? DEFAULT_CONFIG.sounds.permission,
|
|
286
|
+
complete: userConfig.sounds?.complete ?? DEFAULT_CONFIG.sounds.complete,
|
|
287
|
+
subagent_complete: userConfig.sounds?.subagent_complete ?? DEFAULT_CONFIG.sounds.subagent_complete,
|
|
288
|
+
error: userConfig.sounds?.error ?? DEFAULT_CONFIG.sounds.error,
|
|
289
|
+
question: userConfig.sounds?.question ?? DEFAULT_CONFIG.sounds.question,
|
|
290
|
+
interrupted: userConfig.sounds?.interrupted ?? DEFAULT_CONFIG.sounds.interrupted,
|
|
291
|
+
user_cancelled: userConfig.sounds?.user_cancelled ?? DEFAULT_CONFIG.sounds.user_cancelled,
|
|
292
|
+
plan_exit: userConfig.sounds?.plan_exit ?? DEFAULT_CONFIG.sounds.plan_exit,
|
|
293
|
+
session_started: userConfig.sounds?.session_started ?? DEFAULT_CONFIG.sounds.session_started,
|
|
294
|
+
user_message: userConfig.sounds?.user_message ?? DEFAULT_CONFIG.sounds.user_message,
|
|
295
|
+
client_connected: userConfig.sounds?.client_connected ?? DEFAULT_CONFIG.sounds.client_connected
|
|
296
|
+
},
|
|
297
|
+
volumes: {
|
|
298
|
+
permission: parseVolume(userConfig.volumes?.permission, DEFAULT_CONFIG.volumes.permission),
|
|
299
|
+
complete: parseVolume(userConfig.volumes?.complete, DEFAULT_CONFIG.volumes.complete),
|
|
300
|
+
subagent_complete: parseVolume(userConfig.volumes?.subagent_complete, DEFAULT_CONFIG.volumes.subagent_complete),
|
|
301
|
+
error: parseVolume(userConfig.volumes?.error, DEFAULT_CONFIG.volumes.error),
|
|
302
|
+
question: parseVolume(userConfig.volumes?.question, DEFAULT_CONFIG.volumes.question),
|
|
303
|
+
interrupted: parseVolume(userConfig.volumes?.interrupted, DEFAULT_CONFIG.volumes.interrupted),
|
|
304
|
+
user_cancelled: parseVolume(userConfig.volumes?.user_cancelled, DEFAULT_CONFIG.volumes.user_cancelled),
|
|
305
|
+
plan_exit: parseVolume(userConfig.volumes?.plan_exit, DEFAULT_CONFIG.volumes.plan_exit),
|
|
306
|
+
session_started: parseVolume(userConfig.volumes?.session_started, DEFAULT_CONFIG.volumes.session_started),
|
|
307
|
+
user_message: parseVolume(userConfig.volumes?.user_message, DEFAULT_CONFIG.volumes.user_message),
|
|
308
|
+
client_connected: parseVolume(userConfig.volumes?.client_connected, DEFAULT_CONFIG.volumes.client_connected)
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
} catch {
|
|
312
|
+
return DEFAULT_CONFIG;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
function isEventSoundEnabled(config, event) {
|
|
316
|
+
return config.events[event].sound;
|
|
317
|
+
}
|
|
318
|
+
function isEventNotificationEnabled(config, event) {
|
|
319
|
+
return config.events[event].notification;
|
|
320
|
+
}
|
|
321
|
+
function isEventCommandEnabled(config, event) {
|
|
322
|
+
return config.events[event].command;
|
|
323
|
+
}
|
|
324
|
+
function isEventBellEnabled(config, event) {
|
|
325
|
+
return config.events[event].bell;
|
|
326
|
+
}
|
|
327
|
+
function getMessage(config, event) {
|
|
328
|
+
return config.messages[event];
|
|
329
|
+
}
|
|
330
|
+
function getSoundPath(config, event) {
|
|
331
|
+
return config.sounds[event];
|
|
332
|
+
}
|
|
333
|
+
function getSoundVolume(config, event) {
|
|
334
|
+
return config.volumes[event];
|
|
335
|
+
}
|
|
336
|
+
function getIconPath(config) {
|
|
337
|
+
if (!config.showIcon) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
let iconPath;
|
|
342
|
+
if (config.customIconPath) {
|
|
343
|
+
iconPath = config.customIconPath;
|
|
344
|
+
} else {
|
|
345
|
+
const __filename2 = fileURLToPath(import.meta.url);
|
|
346
|
+
const __dirname2 = dirname(__filename2);
|
|
347
|
+
iconPath = join(__dirname2, "..", "logos", "opencode-logo-dark.png");
|
|
348
|
+
}
|
|
349
|
+
if (is_wsl_default || existsSync(iconPath)) {
|
|
350
|
+
return iconPath;
|
|
351
|
+
}
|
|
352
|
+
} catch {}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
function interpolateMessage(message, context) {
|
|
356
|
+
let result = message;
|
|
357
|
+
const sessionTitle = context.sessionTitle || "";
|
|
358
|
+
result = result.replaceAll("{sessionTitle}", sessionTitle);
|
|
359
|
+
const agentName = context.agentName || "";
|
|
360
|
+
result = result.replaceAll("{agentName}", agentName);
|
|
361
|
+
const projectName = context.projectName || "";
|
|
362
|
+
result = result.replaceAll("{projectName}", projectName);
|
|
363
|
+
const timestamp = context.timestamp || "";
|
|
364
|
+
result = result.replaceAll("{timestamp}", timestamp);
|
|
365
|
+
const turn = context.turn != null ? String(context.turn) : "";
|
|
366
|
+
result = result.replaceAll("{turn}", turn);
|
|
367
|
+
result = result.replace(/\s*[:\-|]\s*$/, "").trim();
|
|
368
|
+
result = result.replace(/\s{2,}/g, " ");
|
|
369
|
+
return result;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// src/notify.ts
|
|
373
|
+
import os2 from "os";
|
|
374
|
+
import { execFile, spawn } from "child_process";
|
|
375
|
+
import notifier from "node-notifier";
|
|
376
|
+
var DEBOUNCE_MS = 1000;
|
|
377
|
+
var platform = os2.type();
|
|
378
|
+
var platformNotifier;
|
|
379
|
+
if (platform === "Windows_NT" || is_wsl_default) {
|
|
380
|
+
const { WindowsToaster } = notifier;
|
|
381
|
+
platformNotifier = new WindowsToaster({ withFallback: false });
|
|
382
|
+
} else if (platform === "Linux" || platform.match(/BSD$/)) {
|
|
383
|
+
const { NotifySend } = notifier;
|
|
384
|
+
platformNotifier = new NotifySend({ withFallback: false });
|
|
385
|
+
} else if (platform !== "Darwin") {
|
|
386
|
+
platformNotifier = notifier;
|
|
387
|
+
}
|
|
388
|
+
var LINUX_FOCUS_ACTION_KEY = "focus-terminal";
|
|
389
|
+
var LINUX_FOCUS_ACTION_LABEL = "Jump to terminal";
|
|
390
|
+
var lastNotificationTime = {};
|
|
391
|
+
var lastLinuxNotificationId = null;
|
|
392
|
+
var linuxNotifySendSupportsReplace = null;
|
|
393
|
+
function sanitizeGhosttyField(value) {
|
|
394
|
+
return value.replace(/[;\u0000-\u001f\u007f-\u009f]/g, "");
|
|
395
|
+
}
|
|
396
|
+
function formatGhosttyNotificationSequence(title, message, env = process.env) {
|
|
397
|
+
const escapedTitle = sanitizeGhosttyField(title);
|
|
398
|
+
const escapedMessage = sanitizeGhosttyField(message);
|
|
399
|
+
const payload = `\x1B]9;${escapedTitle}: ${escapedMessage}\x07`;
|
|
400
|
+
if (env.TMUX) {
|
|
401
|
+
return `\x1BPtmux;\x1B${payload}\x1B\\`;
|
|
402
|
+
}
|
|
403
|
+
return payload;
|
|
404
|
+
}
|
|
405
|
+
function detectNotifySendCapabilities() {
|
|
406
|
+
return new Promise((resolve) => {
|
|
407
|
+
execFile("notify-send", ["--version"], (error, stdout) => {
|
|
408
|
+
if (error) {
|
|
409
|
+
resolve(false);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
const match = stdout.match(/(\d+)\.(\d+)/);
|
|
413
|
+
if (match) {
|
|
414
|
+
const major = parseInt(match[1], 10);
|
|
415
|
+
const minor = parseInt(match[2], 10);
|
|
416
|
+
resolve(major > 0 || major === 0 && minor >= 8);
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
resolve(false);
|
|
420
|
+
});
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
function sendLinuxNotificationDirect(title, message, timeout, iconPath, grouping = true, onAction) {
|
|
424
|
+
return new Promise((resolve) => {
|
|
425
|
+
if (onAction) {
|
|
426
|
+
sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping, onAction).then(() => resolve()).catch(() => resolve());
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const args = [];
|
|
430
|
+
args.push("--app-name", "opencode");
|
|
431
|
+
if (iconPath) {
|
|
432
|
+
args.push("--icon", iconPath);
|
|
433
|
+
}
|
|
434
|
+
args.push("--expire-time", String(timeout * 1000));
|
|
435
|
+
if (grouping && lastLinuxNotificationId !== null) {
|
|
436
|
+
args.push("--replace-id", String(lastLinuxNotificationId));
|
|
437
|
+
}
|
|
438
|
+
if (grouping) {
|
|
439
|
+
args.push("--print-id");
|
|
440
|
+
}
|
|
441
|
+
args.push("--", title, message);
|
|
442
|
+
execFile("notify-send", args, (error, stdout) => {
|
|
443
|
+
if (!error && grouping && stdout) {
|
|
444
|
+
const id = parseInt(stdout.trim(), 10);
|
|
445
|
+
if (!isNaN(id)) {
|
|
446
|
+
lastLinuxNotificationId = id;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
resolve();
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
async function sendLinuxNotificationWithActions(title, message, timeout, iconPath, grouping = true, onAction) {
|
|
454
|
+
const args = ["--app-name", "opencode"];
|
|
455
|
+
if (iconPath) {
|
|
456
|
+
args.push("--icon", iconPath);
|
|
457
|
+
}
|
|
458
|
+
args.push("--expire-time", String(timeout * 1000));
|
|
459
|
+
if (grouping && lastLinuxNotificationId !== null) {
|
|
460
|
+
args.push("--replace-id", String(lastLinuxNotificationId));
|
|
461
|
+
}
|
|
462
|
+
args.push("--print-id");
|
|
463
|
+
args.push("--action", `${LINUX_FOCUS_ACTION_KEY}=${LINUX_FOCUS_ACTION_LABEL}`);
|
|
464
|
+
args.push("--", title, message);
|
|
465
|
+
return new Promise((resolve) => {
|
|
466
|
+
const child = spawn("notify-send", args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
467
|
+
let stdout = "";
|
|
468
|
+
const consumeStdout = () => {
|
|
469
|
+
const lines = stdout.split(/\r?\n/);
|
|
470
|
+
stdout = lines.pop() ?? "";
|
|
471
|
+
for (const rawLine of lines) {
|
|
472
|
+
const line = rawLine.trim();
|
|
473
|
+
if (!line) {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
const parsed = parseNotifySendOutputLine(line);
|
|
477
|
+
if (!parsed) {
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (parsed.type === "id") {
|
|
481
|
+
if (grouping) {
|
|
482
|
+
lastLinuxNotificationId = parsed.id;
|
|
483
|
+
}
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (onAction) {
|
|
487
|
+
if (parsed.action === "focus") {
|
|
488
|
+
onAction("focus");
|
|
489
|
+
} else if (parsed.action === "close") {
|
|
490
|
+
onAction("close");
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
child.stdout?.on("data", (data) => {
|
|
496
|
+
stdout += data.toString();
|
|
497
|
+
consumeStdout();
|
|
498
|
+
});
|
|
499
|
+
child.on("close", () => {
|
|
500
|
+
if (stdout.trim().length > 0) {
|
|
501
|
+
stdout += `
|
|
502
|
+
`;
|
|
503
|
+
consumeStdout();
|
|
504
|
+
}
|
|
505
|
+
resolve();
|
|
506
|
+
});
|
|
507
|
+
child.on("error", () => {
|
|
508
|
+
resolve();
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
function parseNotifySendOutputLine(line) {
|
|
513
|
+
const trimmed = line.trim();
|
|
514
|
+
if (!trimmed) {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
if (/^\d+$/.test(trimmed)) {
|
|
518
|
+
const id = parseInt(trimmed, 10);
|
|
519
|
+
if (!isNaN(id)) {
|
|
520
|
+
return { type: "id", id };
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (trimmed === LINUX_FOCUS_ACTION_KEY) {
|
|
524
|
+
return { type: "action", action: "focus" };
|
|
525
|
+
}
|
|
526
|
+
if (trimmed === "close") {
|
|
527
|
+
return { type: "action", action: "close" };
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
function buildOsascriptNotificationArgs(title, message) {
|
|
532
|
+
return [
|
|
533
|
+
"-e",
|
|
534
|
+
`on run argv
|
|
535
|
+
display notification (item 1 of argv) with title (item 2 of argv)
|
|
536
|
+
end run`,
|
|
537
|
+
message,
|
|
538
|
+
title
|
|
539
|
+
];
|
|
540
|
+
}
|
|
541
|
+
async function sendNotification(title, message, timeout, iconPath, notificationSystem = "osascript", linuxGrouping = true, onClick, windowsAppID) {
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
if (lastNotificationTime[message] && now - lastNotificationTime[message] < DEBOUNCE_MS) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
lastNotificationTime[message] = now;
|
|
547
|
+
if (notificationSystem === "ghostty") {
|
|
548
|
+
return new Promise((resolve) => {
|
|
549
|
+
const sequence = formatGhosttyNotificationSequence(title, message);
|
|
550
|
+
process.stdout.write(sequence, () => {
|
|
551
|
+
resolve();
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (platform === "Darwin") {
|
|
556
|
+
if (notificationSystem === "node-notifier") {
|
|
557
|
+
return new Promise((resolve) => {
|
|
558
|
+
const notificationOptions = {
|
|
559
|
+
title,
|
|
560
|
+
message,
|
|
561
|
+
timeout,
|
|
562
|
+
icon: iconPath
|
|
563
|
+
};
|
|
564
|
+
notifier.notify(notificationOptions, () => {
|
|
565
|
+
resolve();
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
return new Promise((resolve) => {
|
|
570
|
+
execFile("osascript", buildOsascriptNotificationArgs(title, message), () => {
|
|
571
|
+
resolve();
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
if ((platform === "Linux" || platform.match(/BSD$/)) && !is_wsl_default) {
|
|
576
|
+
if (!process.env.DBUS_SESSION_BUS_ADDRESS)
|
|
577
|
+
return;
|
|
578
|
+
if (onClick) {
|
|
579
|
+
if (linuxGrouping) {
|
|
580
|
+
if (linuxNotifySendSupportsReplace === null) {
|
|
581
|
+
linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
|
|
582
|
+
}
|
|
583
|
+
if (linuxNotifySendSupportsReplace) {
|
|
584
|
+
return sendLinuxNotificationDirect(title, message, timeout, iconPath, true, () => onClick());
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return sendLinuxNotificationDirect(title, message, timeout, iconPath, false, () => onClick());
|
|
588
|
+
}
|
|
589
|
+
if (linuxGrouping) {
|
|
590
|
+
if (linuxNotifySendSupportsReplace === null) {
|
|
591
|
+
linuxNotifySendSupportsReplace = await detectNotifySendCapabilities();
|
|
592
|
+
}
|
|
593
|
+
if (linuxNotifySendSupportsReplace) {
|
|
594
|
+
return sendLinuxNotificationDirect(title, message, timeout, iconPath, true);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return new Promise((resolve) => {
|
|
599
|
+
const notificationOptions = {
|
|
600
|
+
title,
|
|
601
|
+
message,
|
|
602
|
+
timeout,
|
|
603
|
+
icon: iconPath,
|
|
604
|
+
appName: windowsAppID ?? "opencode"
|
|
605
|
+
};
|
|
606
|
+
platformNotifier.notify(notificationOptions, (err, response, metadata) => {
|
|
607
|
+
if (onClick && metadata?.activationType === "default") {
|
|
608
|
+
onClick();
|
|
609
|
+
}
|
|
610
|
+
resolve();
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// src/sound.ts
|
|
616
|
+
import { platform as platform2 } from "os";
|
|
617
|
+
import { join as join2, dirname as dirname2 } from "path";
|
|
618
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
619
|
+
import { existsSync as existsSync2 } from "fs";
|
|
620
|
+
import { spawn as spawn2 } from "child_process";
|
|
621
|
+
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
622
|
+
var DEBOUNCE_MS2 = 1000;
|
|
623
|
+
var FULL_VOLUME_PERCENT = 100;
|
|
624
|
+
var FULL_VOLUME_PULSE = 65536;
|
|
625
|
+
var lastSoundTime = {};
|
|
626
|
+
function getBundledSoundPath(event) {
|
|
627
|
+
const soundFilename = `${event}.wav`;
|
|
628
|
+
const possiblePaths = [
|
|
629
|
+
join2(__dirname2, "..", "sounds", soundFilename),
|
|
630
|
+
join2(__dirname2, "sounds", soundFilename)
|
|
631
|
+
];
|
|
632
|
+
for (const path of possiblePaths) {
|
|
633
|
+
if (existsSync2(path)) {
|
|
634
|
+
return path;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return join2(__dirname2, "..", "sounds", soundFilename);
|
|
638
|
+
}
|
|
639
|
+
function getSoundFilePath(event, customPath) {
|
|
640
|
+
if (customPath && existsSync2(customPath)) {
|
|
641
|
+
return customPath;
|
|
642
|
+
}
|
|
643
|
+
const bundledPath = getBundledSoundPath(event);
|
|
644
|
+
if (existsSync2(bundledPath)) {
|
|
645
|
+
return bundledPath;
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
async function runCommand(command, args) {
|
|
650
|
+
return new Promise((resolve, reject) => {
|
|
651
|
+
const proc = spawn2(command, args, {
|
|
652
|
+
stdio: "ignore",
|
|
653
|
+
detached: false,
|
|
654
|
+
windowsHide: true
|
|
655
|
+
});
|
|
656
|
+
proc.on("error", (err) => {
|
|
657
|
+
reject(err);
|
|
658
|
+
});
|
|
659
|
+
proc.on("close", (code) => {
|
|
660
|
+
if (code === 0) {
|
|
661
|
+
resolve();
|
|
662
|
+
} else {
|
|
663
|
+
reject(new Error(`Command exited with code ${code}`));
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
function normalizeVolume(volume) {
|
|
669
|
+
if (!Number.isFinite(volume)) {
|
|
670
|
+
return 1;
|
|
671
|
+
}
|
|
672
|
+
if (volume < 0) {
|
|
673
|
+
return 0;
|
|
674
|
+
}
|
|
675
|
+
if (volume > 1) {
|
|
676
|
+
return 1;
|
|
677
|
+
}
|
|
678
|
+
return volume;
|
|
679
|
+
}
|
|
680
|
+
function toPercentVolume(volume) {
|
|
681
|
+
return Math.round(volume * FULL_VOLUME_PERCENT);
|
|
682
|
+
}
|
|
683
|
+
function toPulseVolume(volume) {
|
|
684
|
+
return Math.round(volume * FULL_VOLUME_PULSE);
|
|
685
|
+
}
|
|
686
|
+
async function playOnLinux(soundPath, volume) {
|
|
687
|
+
const percentVolume = toPercentVolume(volume);
|
|
688
|
+
const pulseVolume = toPulseVolume(volume);
|
|
689
|
+
const players = [
|
|
690
|
+
{ command: "paplay", args: [`--volume=${pulseVolume}`, soundPath] },
|
|
691
|
+
{ command: "aplay", args: [soundPath] },
|
|
692
|
+
{
|
|
693
|
+
command: "mpv",
|
|
694
|
+
args: [
|
|
695
|
+
"--no-video",
|
|
696
|
+
"--no-terminal",
|
|
697
|
+
"--script-opts=autoload-disabled=yes",
|
|
698
|
+
"--keep-open=no",
|
|
699
|
+
"--idle=no",
|
|
700
|
+
`--volume=${percentVolume}`,
|
|
701
|
+
soundPath
|
|
702
|
+
]
|
|
703
|
+
},
|
|
704
|
+
{ command: "ffplay", args: ["-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", `${percentVolume}`, soundPath] }
|
|
705
|
+
];
|
|
706
|
+
for (const player of players) {
|
|
707
|
+
try {
|
|
708
|
+
await runCommand(player.command, player.args);
|
|
709
|
+
return;
|
|
710
|
+
} catch {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
async function playOnMac(soundPath, volume) {
|
|
716
|
+
await runCommand("afplay", ["-v", `${volume}`, soundPath]);
|
|
717
|
+
}
|
|
718
|
+
async function playOnWindows(soundPath) {
|
|
719
|
+
const script = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`;
|
|
720
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
721
|
+
await runCommand("powershell", ["-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]);
|
|
722
|
+
}
|
|
723
|
+
async function playSound(event, customPath, volume) {
|
|
724
|
+
const now = Date.now();
|
|
725
|
+
if (lastSoundTime[event] && now - lastSoundTime[event] < DEBOUNCE_MS2) {
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
lastSoundTime[event] = now;
|
|
729
|
+
const soundPath = getSoundFilePath(event, customPath);
|
|
730
|
+
const normalizedVolume = normalizeVolume(volume);
|
|
731
|
+
if (!soundPath) {
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
const os = platform2();
|
|
735
|
+
try {
|
|
736
|
+
switch (os) {
|
|
737
|
+
case "darwin":
|
|
738
|
+
await playOnMac(soundPath, normalizedVolume);
|
|
739
|
+
break;
|
|
740
|
+
case "linux":
|
|
741
|
+
await playOnLinux(soundPath, normalizedVolume);
|
|
742
|
+
break;
|
|
743
|
+
case "win32":
|
|
744
|
+
await playOnWindows(soundPath);
|
|
745
|
+
break;
|
|
746
|
+
default:
|
|
747
|
+
break;
|
|
748
|
+
}
|
|
749
|
+
} catch {}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// src/bell.ts
|
|
753
|
+
var lastBellTime = 0;
|
|
754
|
+
var BELL_DEBOUNCE_MS = 500;
|
|
755
|
+
function ringBell(now = Date.now()) {
|
|
756
|
+
if (!process.stdout.isTTY) {
|
|
757
|
+
return Promise.resolve();
|
|
758
|
+
}
|
|
759
|
+
if (now - lastBellTime < BELL_DEBOUNCE_MS) {
|
|
760
|
+
return Promise.resolve();
|
|
761
|
+
}
|
|
762
|
+
lastBellTime = now;
|
|
763
|
+
return new Promise((resolve) => {
|
|
764
|
+
process.stdout.write("\x07", () => {
|
|
765
|
+
resolve();
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// src/command.ts
|
|
771
|
+
import { spawn as spawn3 } from "child_process";
|
|
772
|
+
function substituteTokens(value, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
|
|
773
|
+
let result = value.replaceAll("{event}", event).replaceAll("{message}", message);
|
|
774
|
+
result = result.replaceAll("{sessionTitle}", sessionTitle || "");
|
|
775
|
+
result = result.replaceAll("{agentName}", agentName || "");
|
|
776
|
+
result = result.replaceAll("{projectName}", projectName || "");
|
|
777
|
+
result = result.replaceAll("{timestamp}", timestamp || "");
|
|
778
|
+
result = result.replaceAll("{turn}", turn != null ? String(turn) : "");
|
|
779
|
+
return result;
|
|
780
|
+
}
|
|
781
|
+
function runCommand2(config, event, message, sessionTitle, agentName, projectName, timestamp, turn) {
|
|
782
|
+
if (!config.command.enabled || !config.command.path) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const args = (config.command.args ?? []).map((arg) => substituteTokens(arg, event, message, sessionTitle, agentName, projectName, timestamp, turn));
|
|
786
|
+
const command = substituteTokens(config.command.path, event, message, sessionTitle, agentName, projectName, timestamp, turn);
|
|
787
|
+
const proc = spawn3(command, args, {
|
|
788
|
+
shell: false,
|
|
789
|
+
stdio: "ignore",
|
|
790
|
+
detached: true,
|
|
791
|
+
windowsHide: true
|
|
792
|
+
});
|
|
793
|
+
proc.on("error", () => {});
|
|
794
|
+
proc.unref();
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/focus.ts
|
|
798
|
+
import { execFileSync, execSync } from "child_process";
|
|
799
|
+
import { accessSync, constants, mkdtempSync, readFileSync as readFileSync2, rmSync, statSync, writeFileSync } from "fs";
|
|
800
|
+
import { tmpdir } from "os";
|
|
801
|
+
import { delimiter, join as join3 } from "path";
|
|
802
|
+
var LINUX_TERMINAL_APPS = new Set([
|
|
803
|
+
"ghostty",
|
|
804
|
+
"konsole",
|
|
805
|
+
"gnome-terminal",
|
|
806
|
+
"xterm",
|
|
807
|
+
"urxvt",
|
|
808
|
+
"alacritty",
|
|
809
|
+
"kitty",
|
|
810
|
+
"wezterm",
|
|
811
|
+
"wezterm-gui",
|
|
812
|
+
"tilix",
|
|
813
|
+
"terminator",
|
|
814
|
+
"xfce4-terminal",
|
|
815
|
+
"lxterminal",
|
|
816
|
+
"mate-terminal",
|
|
817
|
+
"deepin-terminal",
|
|
818
|
+
"foot",
|
|
819
|
+
"footclient"
|
|
820
|
+
]);
|
|
821
|
+
var MAC_TERMINAL_APP_NAMES = new Set([
|
|
822
|
+
"terminal",
|
|
823
|
+
"iterm2",
|
|
824
|
+
"ghostty",
|
|
825
|
+
"wezterm-gui",
|
|
826
|
+
"alacritty",
|
|
827
|
+
"kitty",
|
|
828
|
+
"hyper",
|
|
829
|
+
"warp",
|
|
830
|
+
"tabby",
|
|
831
|
+
"cursor",
|
|
832
|
+
"visual studio code",
|
|
833
|
+
"code",
|
|
834
|
+
"code insiders",
|
|
835
|
+
"zed",
|
|
836
|
+
"rio"
|
|
837
|
+
]);
|
|
838
|
+
function execWithTimeout(command, timeoutMs = 500) {
|
|
839
|
+
try {
|
|
840
|
+
return execSync(command, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
841
|
+
} catch {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function execFileWithTimeout(command, args, timeoutMs = 500) {
|
|
846
|
+
try {
|
|
847
|
+
return execFileSync(command, args, { timeout: timeoutMs, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], windowsHide: true }).trim();
|
|
848
|
+
} catch {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
function isNumericWindowId(value) {
|
|
853
|
+
return /^\d+$/.test(value);
|
|
854
|
+
}
|
|
855
|
+
function isSafeCompositorWindowId(value) {
|
|
856
|
+
return /^[A-Za-z0-9._:-]+$/.test(value);
|
|
857
|
+
}
|
|
858
|
+
function firstNumericWindowId(output) {
|
|
859
|
+
if (!output)
|
|
860
|
+
return null;
|
|
861
|
+
const value = output.split(/\s+/).find((part) => isNumericWindowId(part));
|
|
862
|
+
return value ?? null;
|
|
863
|
+
}
|
|
864
|
+
function getHyprlandActiveWindowId() {
|
|
865
|
+
const output = execWithTimeout("hyprctl activewindow -j");
|
|
866
|
+
if (!output)
|
|
867
|
+
return null;
|
|
868
|
+
try {
|
|
869
|
+
const data = JSON.parse(output);
|
|
870
|
+
return typeof data?.address === "string" && isSafeCompositorWindowId(data.address) ? data.address : null;
|
|
871
|
+
} catch {
|
|
872
|
+
return null;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function findFocusedWindowId(node) {
|
|
876
|
+
if (node.focused === true && typeof node.id === "number") {
|
|
877
|
+
return String(node.id);
|
|
878
|
+
}
|
|
879
|
+
if (Array.isArray(node.nodes)) {
|
|
880
|
+
for (const child of node.nodes) {
|
|
881
|
+
const id = findFocusedWindowId(child);
|
|
882
|
+
if (id !== null)
|
|
883
|
+
return id;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (Array.isArray(node.floating_nodes)) {
|
|
887
|
+
for (const child of node.floating_nodes) {
|
|
888
|
+
const id = findFocusedWindowId(child);
|
|
889
|
+
if (id !== null)
|
|
890
|
+
return id;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
return null;
|
|
894
|
+
}
|
|
895
|
+
function getSwayActiveWindowId() {
|
|
896
|
+
const output = execWithTimeout("swaymsg -t get_tree", 1000);
|
|
897
|
+
if (!output)
|
|
898
|
+
return null;
|
|
899
|
+
try {
|
|
900
|
+
const tree = JSON.parse(output);
|
|
901
|
+
return findFocusedWindowId(tree);
|
|
902
|
+
} catch {
|
|
903
|
+
return null;
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
function getNiriActiveWindowId() {
|
|
907
|
+
const output = execWithTimeout("niri msg --json focused-window", 1000);
|
|
908
|
+
if (!output)
|
|
909
|
+
return null;
|
|
910
|
+
try {
|
|
911
|
+
const data = JSON.parse(output);
|
|
912
|
+
return typeof data?.id === "number" ? String(data.id) : null;
|
|
913
|
+
} catch {
|
|
914
|
+
return null;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
function parseWezTermFocusedPaneId(output) {
|
|
918
|
+
try {
|
|
919
|
+
const data = JSON.parse(output);
|
|
920
|
+
if (!Array.isArray(data))
|
|
921
|
+
return null;
|
|
922
|
+
for (const client of data) {
|
|
923
|
+
if (typeof client?.focused_pane_id === "number") {
|
|
924
|
+
return String(client.focused_pane_id);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return null;
|
|
928
|
+
} catch {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
function getLinuxWaylandActiveWindowId() {
|
|
933
|
+
const env = process.env;
|
|
934
|
+
if (env.HYPRLAND_INSTANCE_SIGNATURE)
|
|
935
|
+
return getHyprlandActiveWindowId();
|
|
936
|
+
if (env.NIRI_SOCKET)
|
|
937
|
+
return getNiriActiveWindowId();
|
|
938
|
+
if (env.SWAYSOCK)
|
|
939
|
+
return getSwayActiveWindowId();
|
|
940
|
+
if (env.KDE_SESSION_VERSION)
|
|
941
|
+
return firstNumericWindowId(execWithTimeout("kdotool getactivewindow"));
|
|
942
|
+
if (isGnomeLikeSession(env))
|
|
943
|
+
return getGnomeAtspiActiveWindowKey();
|
|
944
|
+
return null;
|
|
945
|
+
}
|
|
946
|
+
var ATSPI_ACTIVE_BIT_INDEX = 1;
|
|
947
|
+
var ATSPI_TERMINAL_PATH_MARKERS = ["mitchellh/ghostty"];
|
|
948
|
+
var ATSPI_TERMINAL_APP_ALIASES = new Set([
|
|
949
|
+
"gnome-terminal-server"
|
|
950
|
+
]);
|
|
951
|
+
var GNOME_LIKE_DESKTOPS = new Set(["gnome", "ubuntu", "pop"]);
|
|
952
|
+
function isGnomeLikeSession(env = process.env) {
|
|
953
|
+
const desktop = `${env.XDG_CURRENT_DESKTOP ?? ""} ${env.DESKTOP_SESSION ?? ""}`.toLowerCase();
|
|
954
|
+
return desktop.split(/[:;\s]+/).some((token) => GNOME_LIKE_DESKTOPS.has(token));
|
|
955
|
+
}
|
|
956
|
+
function getLinuxFocusBackendName(env = process.env) {
|
|
957
|
+
if (env.HYPRLAND_INSTANCE_SIGNATURE)
|
|
958
|
+
return "hyprland";
|
|
959
|
+
if (env.NIRI_SOCKET)
|
|
960
|
+
return "niri";
|
|
961
|
+
if (env.SWAYSOCK)
|
|
962
|
+
return "sway";
|
|
963
|
+
if (env.KDE_SESSION_VERSION)
|
|
964
|
+
return "kde";
|
|
965
|
+
if (isGnomeLikeSession(env))
|
|
966
|
+
return "gnome-atspi";
|
|
967
|
+
if (env.DISPLAY)
|
|
968
|
+
return "x11";
|
|
969
|
+
if (env.WAYLAND_DISPLAY)
|
|
970
|
+
return "wayland-unsupported";
|
|
971
|
+
return "none";
|
|
972
|
+
}
|
|
973
|
+
function parseAtspiString(output) {
|
|
974
|
+
if (!output)
|
|
975
|
+
return null;
|
|
976
|
+
const match = output.match(/'((?:[^'\\]|\\.)*)'/);
|
|
977
|
+
if (!match)
|
|
978
|
+
return null;
|
|
979
|
+
try {
|
|
980
|
+
return JSON.parse(`"${match[1].replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`);
|
|
981
|
+
} catch {
|
|
982
|
+
return match[1];
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
function parseAtspiObjectRefs(output) {
|
|
986
|
+
if (!output)
|
|
987
|
+
return [];
|
|
988
|
+
const refs = [];
|
|
989
|
+
const re = /\('([^']+)',\s*(?:objectpath\s+)?'([^']+)'\)/g;
|
|
990
|
+
let match;
|
|
991
|
+
while ((match = re.exec(output)) !== null) {
|
|
992
|
+
refs.push({ bus: match[1], path: match[2] });
|
|
993
|
+
}
|
|
994
|
+
return refs;
|
|
995
|
+
}
|
|
996
|
+
function parseAtspiStateActive(output) {
|
|
997
|
+
if (!output)
|
|
998
|
+
return null;
|
|
999
|
+
const match = output.match(/uint32\s+(\d+)/);
|
|
1000
|
+
if (!match)
|
|
1001
|
+
return null;
|
|
1002
|
+
const firstWord = Number(match[1]) >>> 0;
|
|
1003
|
+
return (firstWord >>> ATSPI_ACTIVE_BIT_INDEX & 1) === 1;
|
|
1004
|
+
}
|
|
1005
|
+
function isAtspiTerminalWindow(appName, windowPath) {
|
|
1006
|
+
if (ATSPI_TERMINAL_PATH_MARKERS.some((marker) => windowPath.toLowerCase().includes(marker))) {
|
|
1007
|
+
return true;
|
|
1008
|
+
}
|
|
1009
|
+
if (!appName)
|
|
1010
|
+
return false;
|
|
1011
|
+
const normalized = appName.trim().toLowerCase();
|
|
1012
|
+
return LINUX_TERMINAL_APPS.has(normalized) || ATSPI_TERMINAL_APP_ALIASES.has(normalized);
|
|
1013
|
+
}
|
|
1014
|
+
var ATSPI_ROLES = new Set(["window", "frame", "dialog"]);
|
|
1015
|
+
function isAtspiWindowRoleAccepted(role) {
|
|
1016
|
+
return role !== null && ATSPI_ROLES.has(role);
|
|
1017
|
+
}
|
|
1018
|
+
function callAtspi(address, dest, path, method, args = [], timeoutMs = 500) {
|
|
1019
|
+
return execFileWithTimeout("gdbus", [
|
|
1020
|
+
"call",
|
|
1021
|
+
"--address",
|
|
1022
|
+
address,
|
|
1023
|
+
"--dest",
|
|
1024
|
+
dest,
|
|
1025
|
+
"--object-path",
|
|
1026
|
+
path,
|
|
1027
|
+
"--method",
|
|
1028
|
+
method,
|
|
1029
|
+
...args
|
|
1030
|
+
], timeoutMs);
|
|
1031
|
+
}
|
|
1032
|
+
var cachedAtspiAddress = null;
|
|
1033
|
+
var ATSPI_ADDRESS_CACHE_TTL_MS = 1e4;
|
|
1034
|
+
function getAtspiBusAddress() {
|
|
1035
|
+
const now = Date.now();
|
|
1036
|
+
if (cachedAtspiAddress && now - cachedAtspiAddress.at < ATSPI_ADDRESS_CACHE_TTL_MS) {
|
|
1037
|
+
return cachedAtspiAddress.address;
|
|
1038
|
+
}
|
|
1039
|
+
cachedAtspiAddress = null;
|
|
1040
|
+
const output = execFileWithTimeout("gdbus", [
|
|
1041
|
+
"call",
|
|
1042
|
+
"--session",
|
|
1043
|
+
"--dest",
|
|
1044
|
+
"org.a11y.Bus",
|
|
1045
|
+
"--object-path",
|
|
1046
|
+
"/org/a11y/bus",
|
|
1047
|
+
"--method",
|
|
1048
|
+
"org.a11y.Bus.GetAddress"
|
|
1049
|
+
], 1000);
|
|
1050
|
+
const address = parseAtspiString(output);
|
|
1051
|
+
if (address) {
|
|
1052
|
+
cachedAtspiAddress = { address, at: now };
|
|
1053
|
+
}
|
|
1054
|
+
return address;
|
|
1055
|
+
}
|
|
1056
|
+
function getAtspiWindowRole(address, ref) {
|
|
1057
|
+
const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetRoleName");
|
|
1058
|
+
return parseAtspiString(output);
|
|
1059
|
+
}
|
|
1060
|
+
function isAtspiWindowActive(address, ref) {
|
|
1061
|
+
const output = callAtspi(address, ref.bus, ref.path, "org.a11y.atspi.Accessible.GetState");
|
|
1062
|
+
return parseAtspiStateActive(output);
|
|
1063
|
+
}
|
|
1064
|
+
function getAtspiTerminalWindowRefs(address) {
|
|
1065
|
+
const refs = [];
|
|
1066
|
+
const rootOutput = callAtspi(address, "org.a11y.atspi.Registry", "/org/a11y/atspi/accessible/root", "org.a11y.atspi.Accessible.GetChildren", [], 1000);
|
|
1067
|
+
for (const app of parseAtspiObjectRefs(rootOutput)) {
|
|
1068
|
+
const appName = parseAtspiString(callAtspi(address, app.bus, "/org/a11y/atspi/accessible/root", "org.freedesktop.DBus.Properties.Get", ["org.a11y.atspi.Accessible", "Name"]));
|
|
1069
|
+
const childrenOutput = callAtspi(address, app.bus, app.path, "org.a11y.atspi.Accessible.GetChildren");
|
|
1070
|
+
for (const child of parseAtspiObjectRefs(childrenOutput)) {
|
|
1071
|
+
if (!isAtspiTerminalWindow(appName, child.path))
|
|
1072
|
+
continue;
|
|
1073
|
+
const role = getAtspiWindowRole(address, child)?.toLowerCase() ?? null;
|
|
1074
|
+
if (isAtspiWindowRoleAccepted(role)) {
|
|
1075
|
+
refs.push(child);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
return refs;
|
|
1080
|
+
}
|
|
1081
|
+
function getGnomeAtspiActiveWindowKey() {
|
|
1082
|
+
try {
|
|
1083
|
+
const address = getAtspiBusAddress();
|
|
1084
|
+
if (!address)
|
|
1085
|
+
return null;
|
|
1086
|
+
for (const ref of getAtspiTerminalWindowRefs(address)) {
|
|
1087
|
+
if (isAtspiWindowActive(address, ref) === true) {
|
|
1088
|
+
return `atspi:${ref.bus}@${ref.path}`;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
} catch {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function debugFocusState(message) {
|
|
1097
|
+
if (process.env.OPENCODE_NOTIFIER_DEBUG) {
|
|
1098
|
+
console.error(`[opencode-notifier] ${message}`);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
var WINDOWS_TERMINAL_WINDOW_CLASSES = new Set([
|
|
1102
|
+
"cascadia_hosting_window_class",
|
|
1103
|
+
"consolewindowclass",
|
|
1104
|
+
"windowsterminalwindowclass"
|
|
1105
|
+
]);
|
|
1106
|
+
var WINDOWS_TERMINAL_PROCESS_NAMES = new Set([
|
|
1107
|
+
"windowsterminal",
|
|
1108
|
+
"windowsterminalpreview",
|
|
1109
|
+
"conhost",
|
|
1110
|
+
"alacritty",
|
|
1111
|
+
"wezterm",
|
|
1112
|
+
"wezterm-gui",
|
|
1113
|
+
"kitty",
|
|
1114
|
+
"hyper",
|
|
1115
|
+
"cursor",
|
|
1116
|
+
"code",
|
|
1117
|
+
"code - insiders"
|
|
1118
|
+
]);
|
|
1119
|
+
function getWindowsActiveWindowInfo() {
|
|
1120
|
+
const script = `
|
|
1121
|
+
$p=Add-Type -Name NFI -Namespace OpenCodeNotifier -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();[DllImport("user32.dll", CharSet=CharSet.Auto)] public static extern int GetClassName(IntPtr h,System.Text.StringBuilder b,int n);[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h,out uint p);' -PassThru;
|
|
1122
|
+
$h=$p::GetForegroundWindow();
|
|
1123
|
+
if(!$h){return}
|
|
1124
|
+
$sb=New-Object System.Text.StringBuilder 256;
|
|
1125
|
+
$p::GetClassName($h,$sb,256)|Out-Null;
|
|
1126
|
+
$c=$sb.ToString();
|
|
1127
|
+
$procId=0;
|
|
1128
|
+
$p::GetWindowThreadProcessId($h,[ref]$procId)|Out-Null;
|
|
1129
|
+
$pn='';
|
|
1130
|
+
try{$pn=(Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName}catch{}
|
|
1131
|
+
Write-Output "$c|$pn"
|
|
1132
|
+
`.trim().replace(/\n/g, "; ");
|
|
1133
|
+
let output = execFileWithTimeout("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 5000);
|
|
1134
|
+
if (!output)
|
|
1135
|
+
output = execFileWithTimeout("pwsh", ["-NoProfile", "-NonInteractive", "-Command", script], 1000);
|
|
1136
|
+
if (!output)
|
|
1137
|
+
return null;
|
|
1138
|
+
const line = output.split(`
|
|
1139
|
+
`).map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith("#<"));
|
|
1140
|
+
if (!line)
|
|
1141
|
+
return null;
|
|
1142
|
+
const sep = line.indexOf("|");
|
|
1143
|
+
if (sep === -1)
|
|
1144
|
+
return null;
|
|
1145
|
+
const className = line.substring(0, sep) || null;
|
|
1146
|
+
const processName = line.substring(sep + 1) || null;
|
|
1147
|
+
return { className, processName };
|
|
1148
|
+
}
|
|
1149
|
+
function getMacOSActiveWindowId() {
|
|
1150
|
+
return execWithTimeout(`osascript -e 'tell application "System Events" to return id of window 1 of (first application process whose frontmost is true)'`);
|
|
1151
|
+
}
|
|
1152
|
+
function getMacOSFrontmostAppName() {
|
|
1153
|
+
const lsappinfo = execWithTimeout(`lsappinfo info -only name \`lsappinfo front\` | sed 's/.*="\\([^"]*\\)".*/\\1/'`);
|
|
1154
|
+
if (lsappinfo)
|
|
1155
|
+
return lsappinfo;
|
|
1156
|
+
return execWithTimeout(`osascript -e 'tell application "System Events" to return name of first application process whose frontmost is true'`);
|
|
1157
|
+
}
|
|
1158
|
+
function normalizeMacAppName(value) {
|
|
1159
|
+
return value.trim().toLowerCase().replace(/\.app$/i, "").replace(/\s+/g, " ");
|
|
1160
|
+
}
|
|
1161
|
+
function getExpectedMacTerminalAppNames(env) {
|
|
1162
|
+
const expected = new Set;
|
|
1163
|
+
const termProgram = typeof env.TERM_PROGRAM === "string" ? normalizeMacAppName(env.TERM_PROGRAM) : "";
|
|
1164
|
+
if (env.TMUX && (termProgram === "tmux" || termProgram === "screen" || termProgram.length === 0)) {
|
|
1165
|
+
return new Set(MAC_TERMINAL_APP_NAMES);
|
|
1166
|
+
}
|
|
1167
|
+
if (termProgram === "apple_terminal") {
|
|
1168
|
+
expected.add("terminal");
|
|
1169
|
+
} else if (termProgram === "iterm" || termProgram === "iterm2") {
|
|
1170
|
+
expected.add("iterm2");
|
|
1171
|
+
} else if (termProgram === "vscode") {
|
|
1172
|
+
expected.add("visual studio code");
|
|
1173
|
+
expected.add("code");
|
|
1174
|
+
expected.add("code insiders");
|
|
1175
|
+
} else if (termProgram === "warpterminal") {
|
|
1176
|
+
expected.add("warp");
|
|
1177
|
+
} else if (termProgram === "wezterm") {
|
|
1178
|
+
expected.add("wezterm-gui");
|
|
1179
|
+
} else if (termProgram.length > 0) {
|
|
1180
|
+
expected.add(termProgram);
|
|
1181
|
+
}
|
|
1182
|
+
if (expected.size > 0) {
|
|
1183
|
+
return expected;
|
|
1184
|
+
}
|
|
1185
|
+
return new Set(MAC_TERMINAL_APP_NAMES);
|
|
1186
|
+
}
|
|
1187
|
+
function buildOsascriptActivateAppArgs(appName) {
|
|
1188
|
+
const escapedAppName = appName.replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replaceAll("\\", "\\\\").replaceAll('"', "\\\"");
|
|
1189
|
+
return [
|
|
1190
|
+
"-e",
|
|
1191
|
+
`tell application "${escapedAppName}" to activate`
|
|
1192
|
+
];
|
|
1193
|
+
}
|
|
1194
|
+
function isMacTerminalAppFocused(frontmostAppName, env = process.env) {
|
|
1195
|
+
if (!frontmostAppName) {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
const normalizedFrontmost = normalizeMacAppName(frontmostAppName);
|
|
1199
|
+
if (!normalizedFrontmost) {
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
const expectedApps = getExpectedMacTerminalAppNames(env);
|
|
1203
|
+
return expectedApps.has(normalizedFrontmost);
|
|
1204
|
+
}
|
|
1205
|
+
function getActiveWindowId() {
|
|
1206
|
+
const platform = process.platform;
|
|
1207
|
+
if (platform === "darwin")
|
|
1208
|
+
return getMacOSActiveWindowId();
|
|
1209
|
+
if (platform === "linux") {
|
|
1210
|
+
if (process.env.WAYLAND_DISPLAY)
|
|
1211
|
+
return getLinuxWaylandActiveWindowId();
|
|
1212
|
+
if (process.env.DISPLAY)
|
|
1213
|
+
return firstNumericWindowId(execWithTimeout("xdotool getactivewindow"));
|
|
1214
|
+
return null;
|
|
1215
|
+
}
|
|
1216
|
+
if (platform === "win32")
|
|
1217
|
+
return null;
|
|
1218
|
+
return null;
|
|
1219
|
+
}
|
|
1220
|
+
var cachedWindowId = getActiveWindowId();
|
|
1221
|
+
var cachedWindowTitleValue;
|
|
1222
|
+
function getCachedWindowTitle() {
|
|
1223
|
+
if (cachedWindowTitleValue !== undefined) {
|
|
1224
|
+
return cachedWindowTitleValue;
|
|
1225
|
+
}
|
|
1226
|
+
cachedWindowTitleValue = process.platform === "linux" && !!process.env.KDE_SESSION_VERSION && cachedWindowId ? getWindowTitleFromKdotool(cachedWindowId) : null;
|
|
1227
|
+
return cachedWindowTitleValue;
|
|
1228
|
+
}
|
|
1229
|
+
function isTmuxPaneFocused(tmuxPane, probeResult) {
|
|
1230
|
+
if (!tmuxPane)
|
|
1231
|
+
return false;
|
|
1232
|
+
if (!probeResult)
|
|
1233
|
+
return false;
|
|
1234
|
+
const [sessionAttached, windowActive, paneActive] = probeResult.split(" ");
|
|
1235
|
+
return Number(sessionAttached) > 0 && windowActive === "1" && paneActive === "1";
|
|
1236
|
+
}
|
|
1237
|
+
function isLinuxTerminalFocused(params) {
|
|
1238
|
+
const { cachedWindowId, currentWindowId, wezTermPaneActive, tmuxPaneActive } = params;
|
|
1239
|
+
if (!cachedWindowId) {
|
|
1240
|
+
if (!wezTermPaneActive)
|
|
1241
|
+
return false;
|
|
1242
|
+
if (tmuxPaneActive !== null)
|
|
1243
|
+
return tmuxPaneActive;
|
|
1244
|
+
return false;
|
|
1245
|
+
}
|
|
1246
|
+
if (currentWindowId !== cachedWindowId)
|
|
1247
|
+
return false;
|
|
1248
|
+
if (!wezTermPaneActive)
|
|
1249
|
+
return false;
|
|
1250
|
+
if (tmuxPaneActive !== null)
|
|
1251
|
+
return tmuxPaneActive;
|
|
1252
|
+
return true;
|
|
1253
|
+
}
|
|
1254
|
+
function isWindowsTerminalFocused(params) {
|
|
1255
|
+
const { className, processName } = params;
|
|
1256
|
+
const classLower = className?.toLowerCase() ?? "";
|
|
1257
|
+
const processLower = processName?.toLowerCase() ?? "";
|
|
1258
|
+
return WINDOWS_TERMINAL_WINDOW_CLASSES.has(classLower) || WINDOWS_TERMINAL_PROCESS_NAMES.has(processLower);
|
|
1259
|
+
}
|
|
1260
|
+
function isTmuxPaneActive() {
|
|
1261
|
+
const tmuxPane = process.env.TMUX_PANE ?? null;
|
|
1262
|
+
const result = execFileWithTimeout("tmux", ["display-message", "-t", tmuxPane ?? "", "-p", "#{session_attached} #{window_active} #{pane_active}"]);
|
|
1263
|
+
return isTmuxPaneFocused(tmuxPane, result);
|
|
1264
|
+
}
|
|
1265
|
+
function isWezTermPaneActive() {
|
|
1266
|
+
const weztermPane = process.env.WEZTERM_PANE ?? null;
|
|
1267
|
+
if (!weztermPane)
|
|
1268
|
+
return true;
|
|
1269
|
+
const output = execFileWithTimeout("wezterm", ["cli", "list-clients", "--format", "json"], 1000);
|
|
1270
|
+
if (!output)
|
|
1271
|
+
return false;
|
|
1272
|
+
const focusedPaneId = parseWezTermFocusedPaneId(output);
|
|
1273
|
+
if (!focusedPaneId)
|
|
1274
|
+
return false;
|
|
1275
|
+
return focusedPaneId === weztermPane;
|
|
1276
|
+
}
|
|
1277
|
+
function isTerminalFocused() {
|
|
1278
|
+
try {
|
|
1279
|
+
if (process.platform === "darwin") {
|
|
1280
|
+
const frontmostAppName = getMacOSFrontmostAppName();
|
|
1281
|
+
if (!isMacTerminalAppFocused(frontmostAppName, process.env)) {
|
|
1282
|
+
return false;
|
|
1283
|
+
}
|
|
1284
|
+
if (!isWezTermPaneActive()) {
|
|
1285
|
+
return false;
|
|
1286
|
+
}
|
|
1287
|
+
if (process.env.TMUX) {
|
|
1288
|
+
return isTmuxPaneActive();
|
|
1289
|
+
}
|
|
1290
|
+
return true;
|
|
1291
|
+
}
|
|
1292
|
+
if (process.platform === "win32") {
|
|
1293
|
+
const info = getWindowsActiveWindowInfo();
|
|
1294
|
+
return isWindowsTerminalFocused({
|
|
1295
|
+
className: info?.className ?? null,
|
|
1296
|
+
processName: info?.processName ?? null
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
const tmuxPaneActive = process.env.TMUX ? isTmuxPaneActive() : null;
|
|
1300
|
+
const currentWindowId = getActiveWindowId();
|
|
1301
|
+
const focused = isLinuxTerminalFocused({
|
|
1302
|
+
cachedWindowId,
|
|
1303
|
+
currentWindowId,
|
|
1304
|
+
wezTermPaneActive: isWezTermPaneActive(),
|
|
1305
|
+
tmuxPaneActive
|
|
1306
|
+
});
|
|
1307
|
+
debugFocusState(`linux focus: backend=${getLinuxFocusBackendName()} session=${process.env.XDG_SESSION_TYPE ?? "?"} desktop=${process.env.XDG_CURRENT_DESKTOP ?? process.env.DESKTOP_SESSION ?? "?"} cached=${cachedWindowId ?? "null"} current=${currentWindowId ?? "null"} tmux=${String(tmuxPaneActive)} focused=${focused}`);
|
|
1308
|
+
return focused;
|
|
1309
|
+
} catch {
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
function getWindowIdFromXdotool(searchTerm) {
|
|
1314
|
+
return firstNumericWindowId(execFileWithTimeout("xdotool", ["search", "--classname", searchTerm]));
|
|
1315
|
+
}
|
|
1316
|
+
function getWindowIdFromKdotool(searchTerm) {
|
|
1317
|
+
return firstNumericWindowId(execFileWithTimeout("kdotool", ["search", "--classname", searchTerm]));
|
|
1318
|
+
}
|
|
1319
|
+
function getWindowTitleFromKdotool(windowId) {
|
|
1320
|
+
if (!isNumericWindowId(windowId))
|
|
1321
|
+
return null;
|
|
1322
|
+
return execFileWithTimeout("kdotool", ["getwindowname", windowId]);
|
|
1323
|
+
}
|
|
1324
|
+
var cachedKDEJumpBackSupport = null;
|
|
1325
|
+
function isKDEJumpBackSupported() {
|
|
1326
|
+
if (process.platform !== "linux" || !process.env.KDE_SESSION_VERSION) {
|
|
1327
|
+
return false;
|
|
1328
|
+
}
|
|
1329
|
+
if (cachedKDEJumpBackSupport !== null) {
|
|
1330
|
+
return cachedKDEJumpBackSupport;
|
|
1331
|
+
}
|
|
1332
|
+
cachedKDEJumpBackSupport = execFileWithTimeout("kdotool", ["--help"], 1000) !== null;
|
|
1333
|
+
return cachedKDEJumpBackSupport;
|
|
1334
|
+
}
|
|
1335
|
+
function getWindowClassX11(windowId) {
|
|
1336
|
+
if (!isNumericWindowId(windowId))
|
|
1337
|
+
return null;
|
|
1338
|
+
const output = execFileWithTimeout("xprop", ["-id", windowId, "WM_CLASS"]);
|
|
1339
|
+
if (!output)
|
|
1340
|
+
return null;
|
|
1341
|
+
const matches = [...output.matchAll(/"([^"]*)"/g)].map((match) => match[1]);
|
|
1342
|
+
return matches[1] ?? matches[0] ?? null;
|
|
1343
|
+
}
|
|
1344
|
+
function getWaylandAppId(windowId) {
|
|
1345
|
+
if (process.env.HYPRLAND_INSTANCE_SIGNATURE) {
|
|
1346
|
+
const output = execWithTimeout(`hyprctl clients -j`);
|
|
1347
|
+
if (!output)
|
|
1348
|
+
return null;
|
|
1349
|
+
try {
|
|
1350
|
+
const clients = JSON.parse(output);
|
|
1351
|
+
for (const client of clients) {
|
|
1352
|
+
if (String(client.address) === windowId) {
|
|
1353
|
+
return client.class?.toLowerCase() || client.initialClass?.toLowerCase() || null;
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
} catch {
|
|
1357
|
+
return null;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
if (process.env.SWAYSOCK) {
|
|
1361
|
+
const output = execWithTimeout(`swaymsg -t get_tree`, 1000);
|
|
1362
|
+
if (!output)
|
|
1363
|
+
return null;
|
|
1364
|
+
try {
|
|
1365
|
+
const tree = JSON.parse(output);
|
|
1366
|
+
const findWindow = (node) => {
|
|
1367
|
+
if (String(node.id) === windowId) {
|
|
1368
|
+
return node.app_id?.toLowerCase() || node.window_properties?.class?.toLowerCase() || null;
|
|
1369
|
+
}
|
|
1370
|
+
if (Array.isArray(node.nodes)) {
|
|
1371
|
+
for (const child of node.nodes) {
|
|
1372
|
+
const result = findWindow(child);
|
|
1373
|
+
if (result)
|
|
1374
|
+
return result;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
if (Array.isArray(node.floating_nodes)) {
|
|
1378
|
+
for (const child of node.floating_nodes) {
|
|
1379
|
+
const result = findWindow(child);
|
|
1380
|
+
if (result)
|
|
1381
|
+
return result;
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
return null;
|
|
1385
|
+
};
|
|
1386
|
+
return findWindow(tree);
|
|
1387
|
+
} catch {
|
|
1388
|
+
return null;
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
if (process.env.NIRI_SOCKET) {
|
|
1392
|
+
const output = execWithTimeout(`niri msg --json windows`);
|
|
1393
|
+
if (!output)
|
|
1394
|
+
return null;
|
|
1395
|
+
try {
|
|
1396
|
+
const windows = JSON.parse(output);
|
|
1397
|
+
for (const window of windows) {
|
|
1398
|
+
if (String(window.id) === windowId) {
|
|
1399
|
+
return window.app_id?.toLowerCase() || null;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
} catch {
|
|
1403
|
+
return null;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
return null;
|
|
1407
|
+
}
|
|
1408
|
+
function getTerminalWindowId() {
|
|
1409
|
+
if (process.platform !== "linux")
|
|
1410
|
+
return null;
|
|
1411
|
+
const term = process.env.TERM_PROGRAM?.toLowerCase() || "";
|
|
1412
|
+
const desktopSession = process.env.DESKTOP_SESSION?.toLowerCase() || "";
|
|
1413
|
+
const isKDE = process.env.KDE_SESSION_VERSION || desktopSession.includes("plasma");
|
|
1414
|
+
if (process.env.WAYLAND_DISPLAY) {
|
|
1415
|
+
const cachedId = cachedWindowId;
|
|
1416
|
+
if (cachedId) {
|
|
1417
|
+
const appId = getWaylandAppId(cachedId);
|
|
1418
|
+
if (appId && LINUX_TERMINAL_APPS.has(appId)) {
|
|
1419
|
+
return cachedId;
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (isKDE) {
|
|
1423
|
+
return cachedId || "kde-wayland";
|
|
1424
|
+
}
|
|
1425
|
+
return cachedId;
|
|
1426
|
+
}
|
|
1427
|
+
if (process.env.DISPLAY) {
|
|
1428
|
+
const cachedId = cachedWindowId;
|
|
1429
|
+
if (cachedId) {
|
|
1430
|
+
const windowClass = getWindowClassX11(cachedId);
|
|
1431
|
+
if (windowClass && LINUX_TERMINAL_APPS.has(windowClass.toLowerCase())) {
|
|
1432
|
+
return cachedId;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
for (const app of LINUX_TERMINAL_APPS) {
|
|
1436
|
+
const id = isKDE ? getWindowIdFromKdotool(app) : getWindowIdFromXdotool(app);
|
|
1437
|
+
if (id)
|
|
1438
|
+
return id;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
function focusLinuxWindowX11(windowId) {
|
|
1444
|
+
if (!isNumericWindowId(windowId))
|
|
1445
|
+
return;
|
|
1446
|
+
try {
|
|
1447
|
+
execFileSync("xdotool", ["windowactivate", windowId], { timeout: 1000, stdio: "ignore" });
|
|
1448
|
+
} catch {}
|
|
1449
|
+
}
|
|
1450
|
+
function findTerminalPid() {
|
|
1451
|
+
try {
|
|
1452
|
+
let currentPid = process.pid;
|
|
1453
|
+
while (currentPid > 1) {
|
|
1454
|
+
try {
|
|
1455
|
+
const statContent = readFileSync2(`/proc/${currentPid}/stat`, "utf-8");
|
|
1456
|
+
const match = statContent.match(/^\d+\s+\([^)]+\)\s+\S\s+(\d+)/);
|
|
1457
|
+
if (!match)
|
|
1458
|
+
break;
|
|
1459
|
+
const ppid = parseInt(match[1], 10);
|
|
1460
|
+
const cmdline = readFileSync2(`/proc/${ppid}/comm`, "utf-8").trim();
|
|
1461
|
+
if (cmdline.match(/ghostty|konsole|gnome-terminal|xterm|alacritty|kitty|wezterm|terminator|tilix|foot/i)) {
|
|
1462
|
+
return ppid;
|
|
1463
|
+
}
|
|
1464
|
+
currentPid = ppid;
|
|
1465
|
+
} catch {
|
|
1466
|
+
break;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
return process.ppid;
|
|
1470
|
+
} catch {
|
|
1471
|
+
return process.ppid;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
var cachedQdbusBinary;
|
|
1475
|
+
var QDBUS_CANDIDATES = ["qdbus-qt6", "qdbus6", "qdbus-qt5", "qdbus5", "qdbus"];
|
|
1476
|
+
function isExecutableOnPath(name) {
|
|
1477
|
+
const pathEnv = process.env.PATH ?? "";
|
|
1478
|
+
for (const dir of pathEnv.split(delimiter)) {
|
|
1479
|
+
if (!dir)
|
|
1480
|
+
continue;
|
|
1481
|
+
const full = join3(dir, name);
|
|
1482
|
+
try {
|
|
1483
|
+
accessSync(full, constants.X_OK);
|
|
1484
|
+
if (statSync(full).isFile())
|
|
1485
|
+
return true;
|
|
1486
|
+
} catch {}
|
|
1487
|
+
}
|
|
1488
|
+
return false;
|
|
1489
|
+
}
|
|
1490
|
+
function findQdbusBinary(candidates = QDBUS_CANDIDATES, isExecutable = isExecutableOnPath) {
|
|
1491
|
+
for (const candidate of candidates) {
|
|
1492
|
+
if (isExecutable(candidate)) {
|
|
1493
|
+
return candidate;
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
return null;
|
|
1497
|
+
}
|
|
1498
|
+
function resolveQdbusBinary() {
|
|
1499
|
+
if (cachedQdbusBinary !== undefined) {
|
|
1500
|
+
return cachedQdbusBinary;
|
|
1501
|
+
}
|
|
1502
|
+
cachedQdbusBinary = findQdbusBinary();
|
|
1503
|
+
return cachedQdbusBinary;
|
|
1504
|
+
}
|
|
1505
|
+
function focusKDEWithKWinScript() {
|
|
1506
|
+
try {
|
|
1507
|
+
const pinnedWindowId = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim() || null;
|
|
1508
|
+
if (pinnedWindowId && isNumericWindowId(pinnedWindowId)) {
|
|
1509
|
+
try {
|
|
1510
|
+
execFileSync("kdotool", ["windowactivate", pinnedWindowId], { timeout: 1500, stdio: "ignore" });
|
|
1511
|
+
return;
|
|
1512
|
+
} catch {}
|
|
1513
|
+
}
|
|
1514
|
+
if (cachedWindowId && isNumericWindowId(cachedWindowId)) {
|
|
1515
|
+
try {
|
|
1516
|
+
execFileSync("kdotool", ["windowactivate", cachedWindowId], { timeout: 1500, stdio: "ignore" });
|
|
1517
|
+
return;
|
|
1518
|
+
} catch {}
|
|
1519
|
+
}
|
|
1520
|
+
const terminalPid = findTerminalPid();
|
|
1521
|
+
const currentPid = process.pid;
|
|
1522
|
+
const termProgram = (process.env.TERM_PROGRAM || "terminal").toLowerCase();
|
|
1523
|
+
const cwd = process.cwd().toLowerCase();
|
|
1524
|
+
const cwdBase = cwd.split("/").filter(Boolean).pop() || "";
|
|
1525
|
+
const cachedTitle = (getCachedWindowTitle() || "").toLowerCase();
|
|
1526
|
+
const scriptContent = `
|
|
1527
|
+
function activateTargetWindow(window) {
|
|
1528
|
+
// Jump to the window's desktop/activity first, then activate.
|
|
1529
|
+
// This works more reliably on Plasma than moving windows between desktops.
|
|
1530
|
+
try {
|
|
1531
|
+
if (window.desktops && window.desktops.length > 0) {
|
|
1532
|
+
workspace.currentDesktop = window.desktops[0];
|
|
1533
|
+
} else if (typeof window.desktop === "number" && window.desktop > 0) {
|
|
1534
|
+
workspace.currentDesktop = window.desktop;
|
|
1535
|
+
}
|
|
1536
|
+
} catch (e) {}
|
|
1537
|
+
|
|
1538
|
+
try {
|
|
1539
|
+
if (window.activities && window.activities.length > 0 && typeof workspace.currentActivity !== "undefined") {
|
|
1540
|
+
workspace.currentActivity = window.activities[0];
|
|
1541
|
+
}
|
|
1542
|
+
} catch (e) {}
|
|
1543
|
+
|
|
1544
|
+
try { window.minimized = false; } catch (e) {}
|
|
1545
|
+
|
|
1546
|
+
try { workspace.activeWindow = window; } catch (e) {}
|
|
1547
|
+
try {
|
|
1548
|
+
if (typeof workspace.activateWindow === "function") {
|
|
1549
|
+
workspace.activateWindow(window);
|
|
1550
|
+
}
|
|
1551
|
+
} catch (e) {}
|
|
1552
|
+
try { window.active = true; } catch (e) {}
|
|
1553
|
+
|
|
1554
|
+
// Nudge stacking so KWin treats this like an explicit user jump.
|
|
1555
|
+
try {
|
|
1556
|
+
window.keepAbove = true;
|
|
1557
|
+
window.keepAbove = false;
|
|
1558
|
+
} catch (e) {}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
function isLikelyTerminal(window) {
|
|
1562
|
+
var resourceClass = (window.resourceClass || "").toLowerCase();
|
|
1563
|
+
var resourceName = (window.resourceName || "").toLowerCase();
|
|
1564
|
+
var caption = (window.caption || "").toLowerCase();
|
|
1565
|
+
|
|
1566
|
+
return resourceClass.indexOf("ghostty") !== -1 ||
|
|
1567
|
+
resourceName.indexOf("ghostty") !== -1 ||
|
|
1568
|
+
caption.indexOf("ghostty") !== -1 ||
|
|
1569
|
+
resourceClass.indexOf("konsole") !== -1 ||
|
|
1570
|
+
resourceName.indexOf("konsole") !== -1 ||
|
|
1571
|
+
caption.indexOf("konsole") !== -1 ||
|
|
1572
|
+
resourceClass.indexOf("terminal") !== -1 ||
|
|
1573
|
+
resourceName.indexOf("terminal") !== -1;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
function findAndActivateTerminal() {
|
|
1577
|
+
var allWindows = workspace.windowList();
|
|
1578
|
+
var terminalPid = ${terminalPid};
|
|
1579
|
+
var termProgramHint = ${JSON.stringify(termProgram)};
|
|
1580
|
+
var cwdHint = ${JSON.stringify(cwd)};
|
|
1581
|
+
var cwdBaseHint = ${JSON.stringify(cwdBase)};
|
|
1582
|
+
var cachedTitleHint = ${JSON.stringify(cachedTitle)};
|
|
1583
|
+
|
|
1584
|
+
function contains(haystack, needle) {
|
|
1585
|
+
return !!needle && needle.length > 0 && haystack.indexOf(needle) !== -1;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function windowScore(window) {
|
|
1589
|
+
var resourceClass = (window.resourceClass || "").toLowerCase();
|
|
1590
|
+
var resourceName = (window.resourceName || "").toLowerCase();
|
|
1591
|
+
var caption = (window.caption || "").toLowerCase();
|
|
1592
|
+
var score = 0;
|
|
1593
|
+
|
|
1594
|
+
if (window.pid === terminalPid) score += 30;
|
|
1595
|
+
if (contains(caption, "opencode")) score += 60;
|
|
1596
|
+
if (contains(caption, cachedTitleHint)) score += 50;
|
|
1597
|
+
if (contains(caption, cwdBaseHint)) score += 35;
|
|
1598
|
+
if (contains(caption, cwdHint)) score += 20;
|
|
1599
|
+
if (contains(resourceClass, termProgramHint) || contains(resourceName, termProgramHint) || contains(caption, termProgramHint)) score += 20;
|
|
1600
|
+
if (isLikelyTerminal(window)) score += 10;
|
|
1601
|
+
if (window.minimized === true) score -= 5;
|
|
1602
|
+
|
|
1603
|
+
return score;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
var bestWindow = null;
|
|
1607
|
+
var bestScore = -1;
|
|
1608
|
+
|
|
1609
|
+
for (var i = 0; i < allWindows.length; i++) {
|
|
1610
|
+
var candidate = allWindows[i];
|
|
1611
|
+
var score = windowScore(candidate);
|
|
1612
|
+
if (score > bestScore) {
|
|
1613
|
+
bestWindow = candidate;
|
|
1614
|
+
bestScore = score;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Require enough confidence to avoid jumping to unrelated terminals.
|
|
1619
|
+
if (bestWindow && bestScore >= 30) {
|
|
1620
|
+
activateTargetWindow(bestWindow);
|
|
1621
|
+
return true;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
return false;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
findAndActivateTerminal();
|
|
1628
|
+
`;
|
|
1629
|
+
const pluginName = `opencode-focus-${currentPid}`;
|
|
1630
|
+
let scriptDirectory = null;
|
|
1631
|
+
let scriptPath = null;
|
|
1632
|
+
let scriptLoaded = false;
|
|
1633
|
+
const cleanupScript = () => {
|
|
1634
|
+
if (!scriptPath || !scriptDirectory)
|
|
1635
|
+
return;
|
|
1636
|
+
try {
|
|
1637
|
+
rmSync(scriptPath, { force: true });
|
|
1638
|
+
} catch {}
|
|
1639
|
+
try {
|
|
1640
|
+
rmSync(scriptDirectory, { recursive: true, force: true });
|
|
1641
|
+
} catch {}
|
|
1642
|
+
};
|
|
1643
|
+
const qdbus = resolveQdbusBinary();
|
|
1644
|
+
if (!qdbus) {
|
|
1645
|
+
throw new Error("qdbus not found");
|
|
1646
|
+
}
|
|
1647
|
+
try {
|
|
1648
|
+
scriptDirectory = mkdtempSync(join3(tmpdir(), "opencode-focus-"));
|
|
1649
|
+
scriptPath = join3(scriptDirectory, "script.kwinscript");
|
|
1650
|
+
writeFileSync(scriptPath, scriptContent, { encoding: "utf-8", mode: 384, flag: "wx" });
|
|
1651
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.loadScript", scriptPath, pluginName], { encoding: "utf-8", timeout: 2000, stdio: "ignore" });
|
|
1652
|
+
scriptLoaded = true;
|
|
1653
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.start"], {
|
|
1654
|
+
timeout: 2000,
|
|
1655
|
+
stdio: "ignore"
|
|
1656
|
+
});
|
|
1657
|
+
cleanupScript();
|
|
1658
|
+
setTimeout(() => {
|
|
1659
|
+
try {
|
|
1660
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
|
|
1661
|
+
timeout: 500,
|
|
1662
|
+
stdio: "ignore"
|
|
1663
|
+
});
|
|
1664
|
+
} catch {}
|
|
1665
|
+
}, 1000);
|
|
1666
|
+
} catch {
|
|
1667
|
+
if (scriptLoaded) {
|
|
1668
|
+
try {
|
|
1669
|
+
execFileSync(qdbus, ["org.kde.KWin", "/Scripting", "org.kde.kwin.Scripting.unloadScript", pluginName], {
|
|
1670
|
+
timeout: 500,
|
|
1671
|
+
stdio: "ignore"
|
|
1672
|
+
});
|
|
1673
|
+
} catch {}
|
|
1674
|
+
}
|
|
1675
|
+
cleanupScript();
|
|
1676
|
+
throw new Error("Unable to activate the terminal through KWin");
|
|
1677
|
+
}
|
|
1678
|
+
} catch {
|
|
1679
|
+
try {
|
|
1680
|
+
const cachedId = cachedWindowId;
|
|
1681
|
+
if (cachedId && isNumericWindowId(cachedId)) {
|
|
1682
|
+
execFileSync("xdotool", ["windowactivate", cachedId], { timeout: 1000, stdio: "ignore" });
|
|
1683
|
+
}
|
|
1684
|
+
} catch {}
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
function focusLinuxWindowHyprland(windowId) {
|
|
1688
|
+
if (!isSafeCompositorWindowId(windowId))
|
|
1689
|
+
return;
|
|
1690
|
+
try {
|
|
1691
|
+
execFileSync("hyprctl", ["dispatch", "focuswindow", `address:${windowId}`], { timeout: 1000, stdio: "ignore" });
|
|
1692
|
+
} catch {}
|
|
1693
|
+
}
|
|
1694
|
+
function focusLinuxWindowSway(windowId) {
|
|
1695
|
+
if (!isNumericWindowId(windowId))
|
|
1696
|
+
return;
|
|
1697
|
+
try {
|
|
1698
|
+
execFileSync("swaymsg", [`[con_id=${windowId}] focus`], { timeout: 1000, stdio: "ignore" });
|
|
1699
|
+
} catch {}
|
|
1700
|
+
}
|
|
1701
|
+
function focusLinuxWindowNiri(windowId) {
|
|
1702
|
+
if (!isNumericWindowId(windowId))
|
|
1703
|
+
return;
|
|
1704
|
+
try {
|
|
1705
|
+
execFileSync("niri", ["msg", "action", "focus-window", "--id", windowId], { timeout: 1000, stdio: "ignore" });
|
|
1706
|
+
} catch {}
|
|
1707
|
+
}
|
|
1708
|
+
function captureStartupWindowId() {
|
|
1709
|
+
if (!isKDEJumpBackSupported()) {
|
|
1710
|
+
return;
|
|
1711
|
+
}
|
|
1712
|
+
const existing = process.env.OPENCODE_NOTIFIER_WINDOW_ID?.trim();
|
|
1713
|
+
if (existing) {
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const detected = execWithTimeout("kdotool getactivewindow", 1000);
|
|
1717
|
+
if (detected && /^\d+$/.test(detected)) {
|
|
1718
|
+
process.env.OPENCODE_NOTIFIER_WINDOW_ID = detected;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
async function focusTerminal() {
|
|
1722
|
+
if (process.platform === "darwin") {
|
|
1723
|
+
try {
|
|
1724
|
+
const frontmostAppName = getMacOSFrontmostAppName();
|
|
1725
|
+
if (frontmostAppName && isMacTerminalAppFocused(frontmostAppName, process.env)) {
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
const expectedApps = getExpectedMacTerminalAppNames(process.env);
|
|
1729
|
+
for (const app of expectedApps) {
|
|
1730
|
+
try {
|
|
1731
|
+
execFileSync("osascript", buildOsascriptActivateAppArgs(app), { timeout: 1000, stdio: "ignore" });
|
|
1732
|
+
return;
|
|
1733
|
+
} catch {}
|
|
1734
|
+
}
|
|
1735
|
+
execFileSync("osascript", buildOsascriptActivateAppArgs("Terminal"), { timeout: 1000, stdio: "ignore" });
|
|
1736
|
+
} catch {}
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
if (process.platform === "linux") {
|
|
1740
|
+
const env = process.env;
|
|
1741
|
+
if (env.KDE_SESSION_VERSION) {
|
|
1742
|
+
focusKDEWithKWinScript();
|
|
1743
|
+
return;
|
|
1744
|
+
}
|
|
1745
|
+
const windowId = getTerminalWindowId();
|
|
1746
|
+
if (!windowId)
|
|
1747
|
+
return;
|
|
1748
|
+
if (env.HYPRLAND_INSTANCE_SIGNATURE) {
|
|
1749
|
+
focusLinuxWindowHyprland(windowId);
|
|
1750
|
+
} else if (env.SWAYSOCK) {
|
|
1751
|
+
focusLinuxWindowSway(windowId);
|
|
1752
|
+
} else if (env.NIRI_SOCKET) {
|
|
1753
|
+
focusLinuxWindowNiri(windowId);
|
|
1754
|
+
} else if (env.DISPLAY) {
|
|
1755
|
+
focusLinuxWindowX11(windowId);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
// src/permission-dedupe.ts
|
|
1761
|
+
var PERMISSION_DEDUPE_WINDOW_MS = 1000;
|
|
1762
|
+
var sessionLastPermissionAt = new Map;
|
|
1763
|
+
var globalLastPermissionAt = 0;
|
|
1764
|
+
function shouldSuppressPermissionAlert(sessionID, now = Date.now()) {
|
|
1765
|
+
const sessionLastAt = sessionID ? sessionLastPermissionAt.get(sessionID) : undefined;
|
|
1766
|
+
const latestSeen = Math.max(globalLastPermissionAt, sessionLastAt ?? 0);
|
|
1767
|
+
const isDuplicate = latestSeen > 0 && now - latestSeen < PERMISSION_DEDUPE_WINDOW_MS;
|
|
1768
|
+
if (isDuplicate) {
|
|
1769
|
+
return true;
|
|
1770
|
+
}
|
|
1771
|
+
globalLastPermissionAt = now;
|
|
1772
|
+
if (sessionID) {
|
|
1773
|
+
sessionLastPermissionAt.set(sessionID, now);
|
|
1774
|
+
}
|
|
1775
|
+
return false;
|
|
1776
|
+
}
|
|
1777
|
+
function prunePermissionAlertState(cutoffMs) {
|
|
1778
|
+
for (const [sessionID, timestamp] of sessionLastPermissionAt) {
|
|
1779
|
+
if (timestamp < cutoffMs) {
|
|
1780
|
+
sessionLastPermissionAt.delete(sessionID);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
if (globalLastPermissionAt < cutoffMs) {
|
|
1784
|
+
globalLastPermissionAt = 0;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// src/index.ts
|
|
1789
|
+
var IDLE_COMPLETE_DELAY_MS = 350;
|
|
1790
|
+
function isCLIClient(clientEnv) {
|
|
1791
|
+
return !clientEnv || clientEnv === "cli";
|
|
1792
|
+
}
|
|
1793
|
+
var pendingIdleTimers = new Map;
|
|
1794
|
+
var sessionIdleSequence = new Map;
|
|
1795
|
+
var sessionErrorSuppressionAt = new Map;
|
|
1796
|
+
var sessionLastBusyAt = new Map;
|
|
1797
|
+
var subagentSessionIds = new Set;
|
|
1798
|
+
function asRecord(value) {
|
|
1799
|
+
return value !== null && typeof value === "object" ? value : null;
|
|
1800
|
+
}
|
|
1801
|
+
function getNestedRecord(root, ...path) {
|
|
1802
|
+
let current = root;
|
|
1803
|
+
for (const key of path) {
|
|
1804
|
+
const record = asRecord(current);
|
|
1805
|
+
if (!record || !(key in record)) {
|
|
1806
|
+
return null;
|
|
1807
|
+
}
|
|
1808
|
+
current = record[key];
|
|
1809
|
+
}
|
|
1810
|
+
return asRecord(current);
|
|
1811
|
+
}
|
|
1812
|
+
function getStringField(record, key) {
|
|
1813
|
+
if (!record) {
|
|
1814
|
+
return null;
|
|
1815
|
+
}
|
|
1816
|
+
const value = record[key];
|
|
1817
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
1818
|
+
}
|
|
1819
|
+
var globalTurnCount = null;
|
|
1820
|
+
function loadTurnCount() {
|
|
1821
|
+
try {
|
|
1822
|
+
const content = readFileSync3(getStatePath(), "utf-8");
|
|
1823
|
+
const state = JSON.parse(content);
|
|
1824
|
+
if (typeof state.turn === "number" && Number.isFinite(state.turn) && state.turn >= 0) {
|
|
1825
|
+
return state.turn;
|
|
1826
|
+
}
|
|
1827
|
+
} catch {}
|
|
1828
|
+
return 0;
|
|
1829
|
+
}
|
|
1830
|
+
function saveTurnCount(count) {
|
|
1831
|
+
try {
|
|
1832
|
+
writeFileSync2(getStatePath(), JSON.stringify({ turn: count }));
|
|
1833
|
+
} catch {}
|
|
1834
|
+
}
|
|
1835
|
+
function incrementTurnCount() {
|
|
1836
|
+
if (globalTurnCount === null) {
|
|
1837
|
+
globalTurnCount = loadTurnCount();
|
|
1838
|
+
}
|
|
1839
|
+
globalTurnCount++;
|
|
1840
|
+
saveTurnCount(globalTurnCount);
|
|
1841
|
+
return globalTurnCount;
|
|
1842
|
+
}
|
|
1843
|
+
var cleanupInterval = setInterval(() => {
|
|
1844
|
+
const cutoff = Date.now() - 5 * 60 * 1000;
|
|
1845
|
+
for (const [sessionID] of sessionIdleSequence) {
|
|
1846
|
+
if (!pendingIdleTimers.has(sessionID)) {
|
|
1847
|
+
sessionIdleSequence.delete(sessionID);
|
|
1848
|
+
subagentSessionIds.delete(sessionID);
|
|
1849
|
+
}
|
|
1850
|
+
}
|
|
1851
|
+
for (const [sessionID, timestamp] of sessionErrorSuppressionAt) {
|
|
1852
|
+
if (timestamp < cutoff) {
|
|
1853
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
for (const [sessionID, timestamp] of sessionLastBusyAt) {
|
|
1857
|
+
if (timestamp < cutoff) {
|
|
1858
|
+
sessionLastBusyAt.delete(sessionID);
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
prunePermissionAlertState(cutoff);
|
|
1862
|
+
}, 5 * 60 * 1000);
|
|
1863
|
+
cleanupInterval.unref();
|
|
1864
|
+
function getNotificationTitle(config, projectName) {
|
|
1865
|
+
if (config.showProjectName && projectName) {
|
|
1866
|
+
return `OpenCode (${projectName})`;
|
|
1867
|
+
}
|
|
1868
|
+
return "OpenCode";
|
|
1869
|
+
}
|
|
1870
|
+
function formatTimestamp() {
|
|
1871
|
+
const now = new Date;
|
|
1872
|
+
const h = String(now.getHours()).padStart(2, "0");
|
|
1873
|
+
const m = String(now.getMinutes()).padStart(2, "0");
|
|
1874
|
+
const s = String(now.getSeconds()).padStart(2, "0");
|
|
1875
|
+
return `${h}:${m}:${s}`;
|
|
1876
|
+
}
|
|
1877
|
+
function extractAgentNameFromSessionTitle(sessionTitle) {
|
|
1878
|
+
if (typeof sessionTitle !== "string" || sessionTitle.length === 0) {
|
|
1879
|
+
return "";
|
|
1880
|
+
}
|
|
1881
|
+
const match = sessionTitle.match(/\s*\(@([^\s)]+)\s+subagent\)\s*$/);
|
|
1882
|
+
return match ? match[1] : "";
|
|
1883
|
+
}
|
|
1884
|
+
function shouldResolveAgentNameForEvent(config, eventType) {
|
|
1885
|
+
if (getMessage(config, eventType).includes("{agentName}")) {
|
|
1886
|
+
return true;
|
|
1887
|
+
}
|
|
1888
|
+
if (!config.command.enabled || !isEventCommandEnabled(config, eventType)) {
|
|
1889
|
+
return false;
|
|
1890
|
+
}
|
|
1891
|
+
if (config.command.path.includes("{agentName}")) {
|
|
1892
|
+
return true;
|
|
1893
|
+
}
|
|
1894
|
+
return (config.command.args ?? []).some((arg) => arg.includes("{agentName}"));
|
|
1895
|
+
}
|
|
1896
|
+
async function handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName) {
|
|
1897
|
+
if (config.suppressWhenFocused && isTerminalFocused()) {
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
if ((eventType === "complete" || eventType === "subagent_complete") && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < config.minDuration) {
|
|
1901
|
+
return;
|
|
1902
|
+
}
|
|
1903
|
+
const promises = [];
|
|
1904
|
+
const timestamp = formatTimestamp();
|
|
1905
|
+
const turn = incrementTurnCount();
|
|
1906
|
+
const rawMessage = getMessage(config, eventType);
|
|
1907
|
+
const message = interpolateMessage(rawMessage, {
|
|
1908
|
+
sessionTitle: config.showSessionTitle ? sessionTitle : null,
|
|
1909
|
+
agentName,
|
|
1910
|
+
projectName,
|
|
1911
|
+
timestamp,
|
|
1912
|
+
turn
|
|
1913
|
+
});
|
|
1914
|
+
const notificationEnabled = isEventNotificationEnabled(config, eventType);
|
|
1915
|
+
if (notificationEnabled) {
|
|
1916
|
+
const title = getNotificationTitle(config, projectName);
|
|
1917
|
+
const iconPath = getIconPath(config);
|
|
1918
|
+
const onNotificationClick = isKDEJumpBackSupported() ? () => void focusTerminal() : undefined;
|
|
1919
|
+
promises.push(sendNotification(title, message, config.timeout, iconPath, config.notificationSystem, config.linux.grouping, onNotificationClick, config.windows.appID));
|
|
1920
|
+
}
|
|
1921
|
+
if (isEventSoundEnabled(config, eventType)) {
|
|
1922
|
+
const customSoundPath = getSoundPath(config, eventType);
|
|
1923
|
+
const ghosttyOnMac = process.platform === "darwin" && config.notificationSystem === "ghostty" && notificationEnabled && config.suppressGhosttySound;
|
|
1924
|
+
if (!ghosttyOnMac) {
|
|
1925
|
+
const soundVolume = getSoundVolume(config, eventType);
|
|
1926
|
+
promises.push(playSound(eventType, customSoundPath, soundVolume));
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
if (isEventBellEnabled(config, eventType)) {
|
|
1930
|
+
promises.push(ringBell());
|
|
1931
|
+
}
|
|
1932
|
+
const minDuration = config.command?.minDuration;
|
|
1933
|
+
const shouldSkipCommand = !isEventCommandEnabled(config, eventType) || typeof minDuration === "number" && Number.isFinite(minDuration) && minDuration > 0 && typeof elapsedSeconds === "number" && Number.isFinite(elapsedSeconds) && elapsedSeconds < minDuration;
|
|
1934
|
+
if (!shouldSkipCommand) {
|
|
1935
|
+
runCommand2(config, eventType, message, sessionTitle, agentName, projectName, timestamp, turn);
|
|
1936
|
+
}
|
|
1937
|
+
await Promise.allSettled(promises);
|
|
1938
|
+
}
|
|
1939
|
+
function getSessionIDFromEvent(event) {
|
|
1940
|
+
const properties = getNestedRecord(event, "properties");
|
|
1941
|
+
return getStringField(properties, "sessionID");
|
|
1942
|
+
}
|
|
1943
|
+
function getPermissionIDFromEvent(event) {
|
|
1944
|
+
const properties = getNestedRecord(event, "properties");
|
|
1945
|
+
const id = getStringField(properties, "id");
|
|
1946
|
+
if (id) {
|
|
1947
|
+
return id;
|
|
1948
|
+
}
|
|
1949
|
+
const request = getNestedRecord(event, "properties", "request");
|
|
1950
|
+
return getStringField(request, "id");
|
|
1951
|
+
}
|
|
1952
|
+
var PERMISSION_PENDING_GRACE_MS = 300;
|
|
1953
|
+
async function isPermissionStillPending(client, permissionID) {
|
|
1954
|
+
try {
|
|
1955
|
+
const inner = client?._client || client?.session?._client;
|
|
1956
|
+
if (!inner || typeof inner.get !== "function") {
|
|
1957
|
+
return true;
|
|
1958
|
+
}
|
|
1959
|
+
const listResponse = await inner.get({ url: "/permission" });
|
|
1960
|
+
const body = listResponse?.data ?? listResponse;
|
|
1961
|
+
const pendingList = Array.isArray(body) ? body : Array.isArray(body?.data) ? body.data : null;
|
|
1962
|
+
if (!pendingList) {
|
|
1963
|
+
return true;
|
|
1964
|
+
}
|
|
1965
|
+
return pendingList.some((p) => p?.id === permissionID);
|
|
1966
|
+
} catch {
|
|
1967
|
+
return true;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
function getSessionLifecycleInfo(event) {
|
|
1971
|
+
const info = getNestedRecord(event, "properties", "info");
|
|
1972
|
+
return {
|
|
1973
|
+
id: getStringField(info, "id"),
|
|
1974
|
+
title: getStringField(info, "title"),
|
|
1975
|
+
parentID: getStringField(info, "parentID")
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
function getMessageUpdatedInfo(event) {
|
|
1979
|
+
const info = getNestedRecord(event, "properties", "info");
|
|
1980
|
+
return {
|
|
1981
|
+
role: getStringField(info, "role"),
|
|
1982
|
+
sessionID: getStringField(info, "sessionID")
|
|
1983
|
+
};
|
|
1984
|
+
}
|
|
1985
|
+
function clearPendingIdleTimer(sessionID) {
|
|
1986
|
+
const timer = pendingIdleTimers.get(sessionID);
|
|
1987
|
+
if (!timer) {
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
clearTimeout(timer);
|
|
1991
|
+
pendingIdleTimers.delete(sessionID);
|
|
1992
|
+
}
|
|
1993
|
+
function bumpSessionIdleSequence(sessionID) {
|
|
1994
|
+
const nextSequence = (sessionIdleSequence.get(sessionID) ?? 0) + 1;
|
|
1995
|
+
sessionIdleSequence.set(sessionID, nextSequence);
|
|
1996
|
+
return nextSequence;
|
|
1997
|
+
}
|
|
1998
|
+
function hasCurrentSessionIdleSequence(sessionID, sequence) {
|
|
1999
|
+
return sessionIdleSequence.get(sessionID) === sequence;
|
|
2000
|
+
}
|
|
2001
|
+
function markSessionError(sessionID) {
|
|
2002
|
+
if (!sessionID) {
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
sessionErrorSuppressionAt.set(sessionID, Date.now());
|
|
2006
|
+
bumpSessionIdleSequence(sessionID);
|
|
2007
|
+
clearPendingIdleTimer(sessionID);
|
|
2008
|
+
}
|
|
2009
|
+
function markSessionBusy(sessionID) {
|
|
2010
|
+
const now = Date.now();
|
|
2011
|
+
sessionLastBusyAt.set(sessionID, now);
|
|
2012
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
2013
|
+
bumpSessionIdleSequence(sessionID);
|
|
2014
|
+
clearPendingIdleTimer(sessionID);
|
|
2015
|
+
}
|
|
2016
|
+
function shouldSuppressSessionIdle(sessionID, consume = true) {
|
|
2017
|
+
const errorAt = sessionErrorSuppressionAt.get(sessionID);
|
|
2018
|
+
if (errorAt === undefined) {
|
|
2019
|
+
return false;
|
|
2020
|
+
}
|
|
2021
|
+
const busyAt = sessionLastBusyAt.get(sessionID);
|
|
2022
|
+
if (typeof busyAt === "number" && busyAt > errorAt) {
|
|
2023
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
2024
|
+
return false;
|
|
2025
|
+
}
|
|
2026
|
+
if (consume) {
|
|
2027
|
+
sessionErrorSuppressionAt.delete(sessionID);
|
|
2028
|
+
}
|
|
2029
|
+
return true;
|
|
2030
|
+
}
|
|
2031
|
+
async function getElapsedSinceLastPrompt(client, sessionID, nowMs = Date.now()) {
|
|
2032
|
+
try {
|
|
2033
|
+
const lastUserMessageTime = await client.lastUserMessageAt(sessionID);
|
|
2034
|
+
if (lastUserMessageTime !== null) {
|
|
2035
|
+
return (nowMs - lastUserMessageTime) / 1000;
|
|
2036
|
+
}
|
|
2037
|
+
} catch {}
|
|
2038
|
+
return null;
|
|
2039
|
+
}
|
|
2040
|
+
async function getSessionInfo(client, sessionID) {
|
|
2041
|
+
try {
|
|
2042
|
+
return await client.sessionInfo(sessionID);
|
|
2043
|
+
} catch {
|
|
2044
|
+
return { isChild: false, title: null };
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
async function processSessionIdle(client, config, projectName, sessionID, sequence, idleReceivedAtMs) {
|
|
2048
|
+
if (!hasCurrentSessionIdleSequence(sessionID, sequence)) {
|
|
2049
|
+
return;
|
|
2050
|
+
}
|
|
2051
|
+
if (shouldSuppressSessionIdle(sessionID)) {
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
if (subagentSessionIds.has(sessionID)) {
|
|
2055
|
+
await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, sessionID, idleReceivedAtMs, null);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
const sessionInfo = await getSessionInfo(client, sessionID);
|
|
2059
|
+
if (!hasCurrentSessionIdleSequence(sessionID, sequence)) {
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
if (shouldSuppressSessionIdle(sessionID)) {
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
if (!sessionInfo.isChild) {
|
|
2066
|
+
await handleEventWithElapsedTime(client, config, "complete", projectName, sessionID, idleReceivedAtMs, sessionInfo.title);
|
|
2067
|
+
return;
|
|
2068
|
+
}
|
|
2069
|
+
subagentSessionIds.add(sessionID);
|
|
2070
|
+
await handleEventWithElapsedTime(client, config, "subagent_complete", projectName, sessionID, idleReceivedAtMs, sessionInfo.title);
|
|
2071
|
+
}
|
|
2072
|
+
function scheduleSessionIdle(client, config, projectName, sessionID) {
|
|
2073
|
+
clearPendingIdleTimer(sessionID);
|
|
2074
|
+
const sequence = bumpSessionIdleSequence(sessionID);
|
|
2075
|
+
const idleReceivedAtMs = Date.now();
|
|
2076
|
+
const timer = setTimeout(() => {
|
|
2077
|
+
pendingIdleTimers.delete(sessionID);
|
|
2078
|
+
processSessionIdle(client, config, projectName, sessionID, sequence, idleReceivedAtMs).catch(() => {
|
|
2079
|
+
return;
|
|
2080
|
+
});
|
|
2081
|
+
}, IDLE_COMPLETE_DELAY_MS);
|
|
2082
|
+
pendingIdleTimers.set(sessionID, timer);
|
|
2083
|
+
}
|
|
2084
|
+
async function handleEventWithElapsedTime(client, config, eventType, projectName, sessionID, elapsedReferenceNowMs, preloadedSessionTitle) {
|
|
2085
|
+
const commandMinDuration = config.command?.minDuration;
|
|
2086
|
+
const shouldLookupElapsedForCommand = !!config.command?.enabled && typeof config.command?.path === "string" && config.command.path.length > 0 && typeof commandMinDuration === "number" && Number.isFinite(commandMinDuration) && commandMinDuration > 0;
|
|
2087
|
+
const shouldLookupElapsedForNotification = typeof config.minDuration === "number" && Number.isFinite(config.minDuration) && config.minDuration > 0;
|
|
2088
|
+
const shouldLookupElapsed = shouldLookupElapsedForCommand || shouldLookupElapsedForNotification;
|
|
2089
|
+
let elapsedSeconds = null;
|
|
2090
|
+
if (shouldLookupElapsed) {
|
|
2091
|
+
if (sessionID) {
|
|
2092
|
+
elapsedSeconds = await getElapsedSinceLastPrompt(client, sessionID, elapsedReferenceNowMs);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
let sessionTitle = preloadedSessionTitle ?? null;
|
|
2096
|
+
const shouldLookupSessionInfo = sessionID && !sessionTitle && (config.showSessionTitle || shouldResolveAgentNameForEvent(config, eventType));
|
|
2097
|
+
if (shouldLookupSessionInfo) {
|
|
2098
|
+
const info = await getSessionInfo(client, sessionID);
|
|
2099
|
+
sessionTitle = info.title;
|
|
2100
|
+
}
|
|
2101
|
+
const agentName = extractAgentNameFromSessionTitle(sessionTitle);
|
|
2102
|
+
await handleEvent(config, eventType, projectName, elapsedSeconds, sessionTitle, sessionID, agentName);
|
|
2103
|
+
}
|
|
2104
|
+
function createNotifierRuntime(client, directory, clientEnv) {
|
|
2105
|
+
captureStartupWindowId();
|
|
2106
|
+
if (clientEnv && clientEnv !== "cli") {
|
|
2107
|
+
const config = loadConfig();
|
|
2108
|
+
if (!config.enableOnDesktop)
|
|
2109
|
+
return null;
|
|
2110
|
+
}
|
|
2111
|
+
const getConfig = () => loadConfig();
|
|
2112
|
+
const projectName = directory ? getConfig().showFullPath ? directory : basename(directory) : null;
|
|
2113
|
+
const isCLI = isCLIClient(clientEnv);
|
|
2114
|
+
if (isCLI) {
|
|
2115
|
+
handleEvent(getConfig(), "client_connected", projectName, null);
|
|
2116
|
+
} else {
|
|
2117
|
+
setTimeout(() => {
|
|
2118
|
+
handleEvent(getConfig(), "client_connected", projectName, null);
|
|
2119
|
+
}, 100);
|
|
2120
|
+
}
|
|
2121
|
+
const dispatch = async (event) => {
|
|
2122
|
+
const config = getConfig();
|
|
2123
|
+
switch (event.type) {
|
|
2124
|
+
case "session.created": {
|
|
2125
|
+
if (event.parentID && event.sessionID) {
|
|
2126
|
+
subagentSessionIds.add(event.sessionID);
|
|
2127
|
+
} else {
|
|
2128
|
+
await handleEvent(config, "session_started", projectName, null, event.title, event.sessionID, null);
|
|
2129
|
+
}
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
case "session.updated": {
|
|
2133
|
+
if (event.parentID && event.sessionID) {
|
|
2134
|
+
subagentSessionIds.add(event.sessionID);
|
|
2135
|
+
}
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
case "session.deleted": {
|
|
2139
|
+
if (event.sessionID) {
|
|
2140
|
+
subagentSessionIds.delete(event.sessionID);
|
|
2141
|
+
}
|
|
2142
|
+
return;
|
|
2143
|
+
}
|
|
2144
|
+
case "permission.asked": {
|
|
2145
|
+
const { sessionID, permissionID } = event;
|
|
2146
|
+
let stillPending = true;
|
|
2147
|
+
if (permissionID) {
|
|
2148
|
+
await new Promise((resolve) => setTimeout(resolve, PERMISSION_PENDING_GRACE_MS));
|
|
2149
|
+
stillPending = await client.permissionPending(sessionID, permissionID);
|
|
2150
|
+
}
|
|
2151
|
+
if (stillPending && !shouldSuppressPermissionAlert(sessionID)) {
|
|
2152
|
+
await handleEventWithElapsedTime(client, config, "permission", projectName, sessionID);
|
|
2153
|
+
}
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
case "permission.ask": {
|
|
2157
|
+
if (!shouldSuppressPermissionAlert(null)) {
|
|
2158
|
+
await handleEvent(config, "permission", projectName, null);
|
|
2159
|
+
}
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
case "session.idle": {
|
|
2163
|
+
const sessionID = event.sessionID;
|
|
2164
|
+
if (sessionID) {
|
|
2165
|
+
if (isCLI) {
|
|
2166
|
+
const idleReceivedAtMs = Date.now();
|
|
2167
|
+
const sequence = bumpSessionIdleSequence(sessionID);
|
|
2168
|
+
await processSessionIdle(client, config, projectName, sessionID, sequence, idleReceivedAtMs);
|
|
2169
|
+
} else {
|
|
2170
|
+
scheduleSessionIdle(client, config, projectName, sessionID);
|
|
2171
|
+
}
|
|
2172
|
+
} else {
|
|
2173
|
+
await handleEventWithElapsedTime(client, config, "complete", projectName, null);
|
|
2174
|
+
}
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
case "session.busy": {
|
|
2178
|
+
markSessionBusy(event.sessionID);
|
|
2179
|
+
return;
|
|
2180
|
+
}
|
|
2181
|
+
case "session.error": {
|
|
2182
|
+
markSessionError(event.sessionID);
|
|
2183
|
+
const eventType = event.userCancelled ? "user_cancelled" : "error";
|
|
2184
|
+
let sessionTitle = null;
|
|
2185
|
+
if (event.sessionID && config.showSessionTitle) {
|
|
2186
|
+
const info = await getSessionInfo(client, event.sessionID);
|
|
2187
|
+
sessionTitle = info.title;
|
|
2188
|
+
}
|
|
2189
|
+
await handleEventWithElapsedTime(client, config, eventType, projectName, event.sessionID, undefined, sessionTitle);
|
|
2190
|
+
return;
|
|
2191
|
+
}
|
|
2192
|
+
case "message.user": {
|
|
2193
|
+
const sessionID = event.sessionID;
|
|
2194
|
+
if (!sessionID || !subagentSessionIds.has(sessionID)) {
|
|
2195
|
+
await handleEvent(config, "user_message", projectName, null, null, sessionID, null);
|
|
2196
|
+
}
|
|
2197
|
+
return;
|
|
2198
|
+
}
|
|
2199
|
+
case "tool.before": {
|
|
2200
|
+
if (event.tool === "question") {
|
|
2201
|
+
await handleEvent(config, "question", projectName, null);
|
|
2202
|
+
}
|
|
2203
|
+
if (event.tool === "plan_exit") {
|
|
2204
|
+
await handleEvent(config, "plan_exit", projectName, null);
|
|
2205
|
+
}
|
|
2206
|
+
return;
|
|
2207
|
+
}
|
|
2208
|
+
}
|
|
2209
|
+
};
|
|
2210
|
+
return { dispatch };
|
|
2211
|
+
}
|
|
2212
|
+
function normalizeV1Event(event) {
|
|
2213
|
+
const events = [];
|
|
2214
|
+
const type = getStringField(asRecord(event), "type");
|
|
2215
|
+
if (type === "session.created") {
|
|
2216
|
+
const info = getSessionLifecycleInfo(event);
|
|
2217
|
+
events.push({ type: "session.created", sessionID: info.id, parentID: info.parentID, title: info.title });
|
|
2218
|
+
}
|
|
2219
|
+
if (type === "session.updated") {
|
|
2220
|
+
const info = getSessionLifecycleInfo(event);
|
|
2221
|
+
events.push({ type: "session.updated", sessionID: info.id, parentID: info.parentID });
|
|
2222
|
+
}
|
|
2223
|
+
if (type === "session.deleted") {
|
|
2224
|
+
const info = getSessionLifecycleInfo(event);
|
|
2225
|
+
events.push({ type: "session.deleted", sessionID: info.id });
|
|
2226
|
+
}
|
|
2227
|
+
if (type === "permission.asked") {
|
|
2228
|
+
events.push({
|
|
2229
|
+
type: "permission.asked",
|
|
2230
|
+
sessionID: getSessionIDFromEvent(event),
|
|
2231
|
+
permissionID: getPermissionIDFromEvent(event)
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
if (type === "session.idle") {
|
|
2235
|
+
events.push({ type: "session.idle", sessionID: getSessionIDFromEvent(event) });
|
|
2236
|
+
}
|
|
2237
|
+
if (type === "session.status") {
|
|
2238
|
+
const status = getNestedRecord(event, "properties", "status");
|
|
2239
|
+
const sessionID = getStringField(getNestedRecord(event, "properties"), "sessionID");
|
|
2240
|
+
if (getStringField(status, "type") === "busy" && sessionID) {
|
|
2241
|
+
events.push({ type: "session.busy", sessionID });
|
|
2242
|
+
}
|
|
2243
|
+
}
|
|
2244
|
+
if (type === "session.error") {
|
|
2245
|
+
const error = getNestedRecord(event, "properties", "error");
|
|
2246
|
+
events.push({
|
|
2247
|
+
type: "session.error",
|
|
2248
|
+
sessionID: getSessionIDFromEvent(event),
|
|
2249
|
+
userCancelled: getStringField(error, "name") === "MessageAbortedError"
|
|
2250
|
+
});
|
|
2251
|
+
}
|
|
2252
|
+
if (type === "message.updated") {
|
|
2253
|
+
const info = getMessageUpdatedInfo(event);
|
|
2254
|
+
if (info.role === "user") {
|
|
2255
|
+
events.push({ type: "message.user", sessionID: info.sessionID });
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
return events;
|
|
2259
|
+
}
|
|
2260
|
+
function normalizeV2Event(event) {
|
|
2261
|
+
const events = [];
|
|
2262
|
+
const record = asRecord(event);
|
|
2263
|
+
const type = getStringField(record, "type");
|
|
2264
|
+
const data = getNestedRecord(event, "data") ?? {};
|
|
2265
|
+
if (type === "session.created") {
|
|
2266
|
+
events.push({
|
|
2267
|
+
type: "session.created",
|
|
2268
|
+
sessionID: getStringField(data, "sessionID"),
|
|
2269
|
+
parentID: getStringField(data, "parentID"),
|
|
2270
|
+
title: getStringField(data, "title")
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
if (type === "session.deleted") {
|
|
2274
|
+
events.push({ type: "session.deleted", sessionID: getStringField(data, "sessionID") });
|
|
2275
|
+
}
|
|
2276
|
+
if (type === "permission.asked") {
|
|
2277
|
+
events.push({
|
|
2278
|
+
type: "permission.asked",
|
|
2279
|
+
sessionID: getStringField(data, "sessionID"),
|
|
2280
|
+
permissionID: getStringField(data, "id")
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
2283
|
+
if (type === "session.execution.started") {
|
|
2284
|
+
const sessionID = getStringField(data, "sessionID");
|
|
2285
|
+
if (sessionID) {
|
|
2286
|
+
events.push({ type: "session.busy", sessionID });
|
|
2287
|
+
}
|
|
2288
|
+
}
|
|
2289
|
+
if (type === "session.execution.succeeded") {
|
|
2290
|
+
events.push({ type: "session.idle", sessionID: getStringField(data, "sessionID") });
|
|
2291
|
+
}
|
|
2292
|
+
if (type === "session.execution.failed") {
|
|
2293
|
+
events.push({ type: "session.error", sessionID: getStringField(data, "sessionID"), userCancelled: false });
|
|
2294
|
+
}
|
|
2295
|
+
if (type === "session.execution.interrupted") {
|
|
2296
|
+
const reason = getStringField(data, "reason");
|
|
2297
|
+
if (reason === "user" || reason === "inactivity") {
|
|
2298
|
+
events.push({
|
|
2299
|
+
type: "session.error",
|
|
2300
|
+
sessionID: getStringField(data, "sessionID"),
|
|
2301
|
+
userCancelled: reason === "user"
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
}
|
|
2305
|
+
if (type === "session.inbox.enqueued") {
|
|
2306
|
+
const item = getNestedRecord(event, "data", "item");
|
|
2307
|
+
if (getStringField(item, "type") === "user") {
|
|
2308
|
+
events.push({ type: "message.user", sessionID: getStringField(data, "sessionID") });
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
return events;
|
|
2312
|
+
}
|
|
2313
|
+
function createV1SessionClient(client) {
|
|
2314
|
+
return {
|
|
2315
|
+
async lastUserMessageAt(sessionID) {
|
|
2316
|
+
const response = await client.session.messages({ path: { id: sessionID } });
|
|
2317
|
+
const messages = response.data ?? [];
|
|
2318
|
+
let lastUserMessageTime = null;
|
|
2319
|
+
for (const msg of messages) {
|
|
2320
|
+
const info = msg.info;
|
|
2321
|
+
if (info.role === "user" && typeof info.time?.created === "number") {
|
|
2322
|
+
if (lastUserMessageTime === null || info.time.created > lastUserMessageTime) {
|
|
2323
|
+
lastUserMessageTime = info.time.created;
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
}
|
|
2327
|
+
return lastUserMessageTime;
|
|
2328
|
+
},
|
|
2329
|
+
async sessionInfo(sessionID) {
|
|
2330
|
+
const response = await client.session.get({ path: { id: sessionID } });
|
|
2331
|
+
return {
|
|
2332
|
+
isChild: !!response.data?.parentID,
|
|
2333
|
+
title: typeof response.data?.title === "string" ? response.data.title : null
|
|
2334
|
+
};
|
|
2335
|
+
},
|
|
2336
|
+
permissionPending(_sessionID, permissionID) {
|
|
2337
|
+
return isPermissionStillPending(client, permissionID);
|
|
2338
|
+
}
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
function createV2SessionClient(ctx) {
|
|
2342
|
+
return {
|
|
2343
|
+
async lastUserMessageAt(sessionID) {
|
|
2344
|
+
const messages = await ctx.session.context({ sessionID });
|
|
2345
|
+
let lastUserMessageTime = null;
|
|
2346
|
+
for (const msg of messages ?? []) {
|
|
2347
|
+
if (msg.type === "user" && typeof msg.time?.created === "number") {
|
|
2348
|
+
if (lastUserMessageTime === null || msg.time.created > lastUserMessageTime) {
|
|
2349
|
+
lastUserMessageTime = msg.time.created;
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}
|
|
2353
|
+
return lastUserMessageTime;
|
|
2354
|
+
},
|
|
2355
|
+
async sessionInfo(sessionID) {
|
|
2356
|
+
const info = await ctx.session.get({ sessionID });
|
|
2357
|
+
return {
|
|
2358
|
+
isChild: !!info?.parentID,
|
|
2359
|
+
title: typeof info?.title === "string" ? info.title : null
|
|
2360
|
+
};
|
|
2361
|
+
},
|
|
2362
|
+
async permissionPending(sessionID, permissionID) {
|
|
2363
|
+
if (!sessionID)
|
|
2364
|
+
return true;
|
|
2365
|
+
try {
|
|
2366
|
+
const pending = await ctx.permission.list({ sessionID });
|
|
2367
|
+
if (!Array.isArray(pending))
|
|
2368
|
+
return true;
|
|
2369
|
+
return pending.some((p) => p?.id === permissionID);
|
|
2370
|
+
} catch {
|
|
2371
|
+
return true;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
var NotifierPlugin = async ({ client, directory }) => {
|
|
2377
|
+
const runtime = createNotifierRuntime(createV1SessionClient(client), directory ?? null, process.env.OPENCODE_CLIENT);
|
|
2378
|
+
if (!runtime)
|
|
2379
|
+
return {};
|
|
2380
|
+
return {
|
|
2381
|
+
event: async ({ event }) => {
|
|
2382
|
+
for (const normalized of normalizeV1Event(event)) {
|
|
2383
|
+
await runtime.dispatch(normalized);
|
|
2384
|
+
}
|
|
2385
|
+
},
|
|
2386
|
+
"permission.ask": async () => {
|
|
2387
|
+
await runtime.dispatch({ type: "permission.ask" });
|
|
2388
|
+
},
|
|
2389
|
+
"tool.execute.before": async (input) => {
|
|
2390
|
+
await runtime.dispatch({ type: "tool.before", tool: input.tool });
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
};
|
|
2394
|
+
function isV2Context(ctx) {
|
|
2395
|
+
const record = asRecord(ctx);
|
|
2396
|
+
return typeof asRecord(record?.event)?.subscribe === "function" && typeof asRecord(record?.tool)?.hook === "function" && typeof asRecord(record?.session)?.get === "function";
|
|
2397
|
+
}
|
|
2398
|
+
var NotifierPluginV2 = {
|
|
2399
|
+
id: "opencode-notifier",
|
|
2400
|
+
async setup(ctx) {
|
|
2401
|
+
if (!isV2Context(ctx))
|
|
2402
|
+
return;
|
|
2403
|
+
const runtime = createNotifierRuntime(createV2SessionClient(ctx), ctx.location?.directory ?? null, process.env.OPENCODE_CLIENT);
|
|
2404
|
+
if (!runtime)
|
|
2405
|
+
return;
|
|
2406
|
+
const controller = new AbortController;
|
|
2407
|
+
(async () => {
|
|
2408
|
+
try {
|
|
2409
|
+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
|
|
2410
|
+
for (const normalized of normalizeV2Event(event)) {
|
|
2411
|
+
await runtime.dispatch(normalized);
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
} catch {}
|
|
2415
|
+
})();
|
|
2416
|
+
await ctx.tool.hook("execute.before", (event) => {
|
|
2417
|
+
runtime.dispatch({ type: "tool.before", tool: event.tool });
|
|
2418
|
+
});
|
|
2419
|
+
return () => controller.abort();
|
|
2420
|
+
}
|
|
2421
|
+
};
|
|
2422
|
+
var pluginModule = {
|
|
2423
|
+
id: "opencode-notifier",
|
|
2424
|
+
setup: NotifierPluginV2.setup,
|
|
2425
|
+
server: NotifierPlugin
|
|
2426
|
+
};
|
|
2427
|
+
var src_default = pluginModule;
|
|
2428
|
+
export {
|
|
2429
|
+
NotifierPlugin,
|
|
2430
|
+
NotifierPluginV2,
|
|
2431
|
+
PERMISSION_PENDING_GRACE_MS,
|
|
2432
|
+
src_default as default,
|
|
2433
|
+
extractAgentNameFromSessionTitle,
|
|
2434
|
+
getPermissionIDFromEvent,
|
|
2435
|
+
isCLIClient,
|
|
2436
|
+
isPermissionStillPending,
|
|
2437
|
+
isV2Context,
|
|
2438
|
+
normalizeV1Event,
|
|
2439
|
+
normalizeV2Event
|
|
2440
|
+
};
|