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