unsnooze 1.7.0 → 1.8.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/CHANGELOG.md +29 -0
- package/README.md +56 -13
- package/bin/unsnooze.js +7 -6
- package/package.json +3 -2
- package/src/agents/codex.js +22 -2
- package/src/cli.js +2 -1
- package/src/config.js +3 -2
- package/src/hook.js +20 -7
- package/src/install.js +1 -1
- package/src/launcher.js +40 -21
- package/src/lease.js +87 -0
- package/src/monitor.js +33 -20
- package/src/multiplexer.js +58 -0
- package/src/multiplexers/tmux.js +200 -0
- package/src/multiplexers/zellij.js +193 -0
- package/src/notify-tty.js +311 -0
- package/src/notify.js +131 -14
- package/src/report.js +11 -4
- package/src/resumer.js +172 -73
- package/src/settings.js +6 -0
- package/src/state.js +29 -6
- package/src/watcher.js +4 -2
- package/src/wizard.js +6 -5
- package/src/tmux.js +0 -95
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Terminal-branded notifications via OSC to client tty + BEL to pane tty.
|
|
2
|
+
//
|
|
3
|
+
// Dialect support matrix:
|
|
4
|
+
// OSC 9 (\x1b]9;title: body\x07) — iterm2, kitty, wezterm, ghostty, warp
|
|
5
|
+
// OSC 777 (\x1b]777;notify;title;body\x07) — rxvt only
|
|
6
|
+
// unsupported (Apple_Terminal, vscode, alacritty, zed) — skip in auto
|
|
7
|
+
// unknown (null) — skip in auto; OSC 9 when force
|
|
8
|
+
//
|
|
9
|
+
// force (notifyChannel=osc): unknown always gets OSC 9. Denylist from
|
|
10
|
+
// per-client evidence (termname / caller env) always blocks under force —
|
|
11
|
+
// including when globalEnv is *also* denylisted (server layer would otherwise
|
|
12
|
+
// shadow caller). Denylist from globalEnv alone does not block force (stale
|
|
13
|
+
// show-environment -g after reattach). Auto is unchanged: any unsupported
|
|
14
|
+
// source is skipped.
|
|
15
|
+
//
|
|
16
|
+
// One-write rule: open O_WRONLY|O_NOCTTY|O_NONBLOCK, exactly one write of the
|
|
17
|
+
// whole sequence (<1 KB), no retry on short write (fail fast under O_NONBLOCK
|
|
18
|
+
// / frozen clients). Never acquire a controlling terminal. All errors
|
|
19
|
+
// swallowed. All deps injectable — never touch a real tty from tests.
|
|
20
|
+
|
|
21
|
+
import nodeFs from 'node:fs';
|
|
22
|
+
|
|
23
|
+
const TITLE_MAX = 100;
|
|
24
|
+
const BODY_MAX = 200;
|
|
25
|
+
|
|
26
|
+
/** Server-side env keys used for terminal brand detection (tmux show-environment -g). */
|
|
27
|
+
export const DETECT_ENV_NAMES = [
|
|
28
|
+
'TERM_PROGRAM',
|
|
29
|
+
'LC_TERMINAL',
|
|
30
|
+
'KITTY_WINDOW_ID',
|
|
31
|
+
'WEZTERM_EXECUTABLE',
|
|
32
|
+
'GHOSTTY_RESOURCES_DIR',
|
|
33
|
+
'TERM',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// C0 controls (0x00–0x1F) and DEL (0x7F) — includes ESC and BEL.
|
|
37
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/g;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strip C0/DEL/ESC/BEL, collapse newlines to spaces, truncate to max.
|
|
41
|
+
* When stripSemicolons is set (OSC 777 title and body), also remove `;`
|
|
42
|
+
* field separators.
|
|
43
|
+
*/
|
|
44
|
+
export function sanitizeOscText(s, max = TITLE_MAX, { stripSemicolons = false } = {}) {
|
|
45
|
+
let t = String(s ?? '');
|
|
46
|
+
// Collapse newlines (and CR) to a single space before stripping other C0.
|
|
47
|
+
t = t.replace(/[\r\n]+/g, ' ');
|
|
48
|
+
t = t.replace(CONTROL_RE, '');
|
|
49
|
+
if (stripSemicolons) t = t.replace(/;/g, '');
|
|
50
|
+
// Collapse runs of spaces left by newline collapse / stripped controls.
|
|
51
|
+
t = t.replace(/ {2,}/g, ' ').trim();
|
|
52
|
+
if (typeof max === 'number' && max >= 0 && t.length > max) t = t.slice(0, max);
|
|
53
|
+
return t;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** OSC 9: `\x1b]9;title: body\x07` */
|
|
57
|
+
export function buildOsc9(title, body) {
|
|
58
|
+
const t = sanitizeOscText(title, TITLE_MAX);
|
|
59
|
+
const b = sanitizeOscText(body, BODY_MAX);
|
|
60
|
+
return `\x1b]9;${t}: ${b}\x07`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** OSC 777 (rxvt notify): `\x1b]777;notify;title;body\x07` — `;` stripped from title and body. */
|
|
64
|
+
export function buildOsc777(title, body) {
|
|
65
|
+
const t = sanitizeOscText(title, TITLE_MAX, { stripSemicolons: true });
|
|
66
|
+
const b = sanitizeOscText(body, BODY_MAX, { stripSemicolons: true });
|
|
67
|
+
return `\x1b]777;notify;${t};${b}\x07`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function termnameBrand(termname) {
|
|
71
|
+
if (!termname) return null;
|
|
72
|
+
const n = String(termname).toLowerCase();
|
|
73
|
+
if (n.includes('kitty')) return 'kitty';
|
|
74
|
+
if (n.includes('ghostty')) return 'ghostty';
|
|
75
|
+
if (n.includes('wezterm')) return 'wezterm';
|
|
76
|
+
// rxvt, rxvt-unicode, rxvt-unicode-256color, etc.
|
|
77
|
+
if (n.includes('rxvt')) return 'rxvt';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function envBrand(env = {}) {
|
|
82
|
+
if (!env || typeof env !== 'object') return null;
|
|
83
|
+
const termProgram = String(env.TERM_PROGRAM || '');
|
|
84
|
+
const lcTerminal = String(env.LC_TERMINAL || '');
|
|
85
|
+
const term = String(env.TERM || '');
|
|
86
|
+
|
|
87
|
+
// Known good brands (env presence / TERM_PROGRAM / LC_TERMINAL).
|
|
88
|
+
if (/^iterm/i.test(termProgram) || /^iterm/i.test(lcTerminal)) return 'iterm2';
|
|
89
|
+
if (/^warp/i.test(termProgram) || /^warp/i.test(lcTerminal)) return 'warp';
|
|
90
|
+
if (env.KITTY_WINDOW_ID) return 'kitty';
|
|
91
|
+
if (env.WEZTERM_EXECUTABLE) return 'wezterm';
|
|
92
|
+
if (env.GHOSTTY_RESOURCES_DIR) return 'ghostty';
|
|
93
|
+
if (/rxvt/i.test(term)) return 'rxvt';
|
|
94
|
+
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const DENY_TERM_PROGRAM = new Set([
|
|
99
|
+
'apple_terminal', 'vscode', 'alacritty', 'zed',
|
|
100
|
+
]);
|
|
101
|
+
|
|
102
|
+
function isDenied({ termname, env = {} }) {
|
|
103
|
+
const termProgram = String(env.TERM_PROGRAM || '').toLowerCase();
|
|
104
|
+
if (DENY_TERM_PROGRAM.has(termProgram)) return true;
|
|
105
|
+
if (termProgram.includes('apple_terminal')) return true;
|
|
106
|
+
|
|
107
|
+
const term = String(env.TERM || '').toLowerCase();
|
|
108
|
+
const name = String(termname || '').toLowerCase();
|
|
109
|
+
// Alacritty often reports TERM=alacritty rather than TERM_PROGRAM.
|
|
110
|
+
if (term.includes('alacritty') || name.includes('alacritty')) return true;
|
|
111
|
+
if (name === 'vscode' || name.includes('vscode')) return true;
|
|
112
|
+
if (name === 'zed' || name.startsWith('zed')) return true;
|
|
113
|
+
// Symmetry with TERM_PROGRAM denylist (rarely seen as termname, but cheap).
|
|
114
|
+
if (name.includes('apple_terminal')) return true;
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Classify a terminal brand from client termname and/or env.
|
|
120
|
+
* @returns {'iterm2'|'kitty'|'wezterm'|'ghostty'|'warp'|'rxvt'|'unsupported'|null}
|
|
121
|
+
*/
|
|
122
|
+
export function classifyTerminal({ termname = null, env = {} } = {}) {
|
|
123
|
+
// (1) termname layer — per-client #{client_termname}
|
|
124
|
+
const fromName = termnameBrand(termname);
|
|
125
|
+
if (fromName) return fromName;
|
|
126
|
+
|
|
127
|
+
// (3) denylist before unknown env brands that might be ambiguous
|
|
128
|
+
if (isDenied({ termname, env })) return 'unsupported';
|
|
129
|
+
|
|
130
|
+
// (2) env layer — TERM_PROGRAM / LC_TERMINAL / presence vars / TERM
|
|
131
|
+
const fromEnv = envBrand(env);
|
|
132
|
+
if (fromEnv) return fromEnv;
|
|
133
|
+
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Map brand → OSC dialect. unsupported/null → null. */
|
|
138
|
+
export function dialectFor(terminal) {
|
|
139
|
+
switch (terminal) {
|
|
140
|
+
case 'iterm2':
|
|
141
|
+
case 'kitty':
|
|
142
|
+
case 'wezterm':
|
|
143
|
+
case 'ghostty':
|
|
144
|
+
case 'warp':
|
|
145
|
+
return 'osc9';
|
|
146
|
+
case 'rxvt':
|
|
147
|
+
return 'osc777';
|
|
148
|
+
default:
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Write data to a tty path: one open, one write, close. Never throws.
|
|
155
|
+
* Single attempt only — short writes (bytesWritten < length) under O_NONBLOCK
|
|
156
|
+
* count as failure; no retry loop so frozen clients fail fast.
|
|
157
|
+
* @returns {Promise<boolean>} true only when the full buffer was written
|
|
158
|
+
*/
|
|
159
|
+
export async function writeToTty(ttyPath, data, { fs: fsMod = nodeFs } = {}) {
|
|
160
|
+
let fh;
|
|
161
|
+
try {
|
|
162
|
+
const c = fsMod.constants || nodeFs.constants;
|
|
163
|
+
const flags = c.O_WRONLY | c.O_NOCTTY | c.O_NONBLOCK;
|
|
164
|
+
const open = fsMod.promises?.open?.bind(fsMod.promises) || nodeFs.promises.open;
|
|
165
|
+
fh = await open(ttyPath, flags);
|
|
166
|
+
const buf = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
|
167
|
+
// Exactly one write of the whole sequence; partial write → false.
|
|
168
|
+
const result = await fh.write(buf, 0, buf.length, null);
|
|
169
|
+
const written = result?.bytesWritten ?? 0;
|
|
170
|
+
return written === buf.length;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
} finally {
|
|
174
|
+
if (fh) {
|
|
175
|
+
try { await fh.close(); } catch { /* swallow */ }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sequenceFor(dialect, title, body) {
|
|
181
|
+
if (dialect === 'osc9') return buildOsc9(title, body);
|
|
182
|
+
if (dialect === 'osc777') return buildOsc777(title, body);
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Classify a client using layered detection:
|
|
188
|
+
* termname → server env (mux.globalEnv) → caller env.
|
|
189
|
+
* @returns {{ brand: string|null, source: 'termname'|'server'|'caller'|null }}
|
|
190
|
+
*/
|
|
191
|
+
function classifyClient(termname, serverEnv, callerEnv) {
|
|
192
|
+
let brand = classifyTerminal({ termname, env: {} });
|
|
193
|
+
if (brand != null) return { brand, source: 'termname' };
|
|
194
|
+
brand = classifyTerminal({ termname, env: serverEnv || {} });
|
|
195
|
+
if (brand != null) return { brand, source: 'server' };
|
|
196
|
+
brand = classifyTerminal({ termname, env: callerEnv || {} });
|
|
197
|
+
if (brand != null) return { brand, source: 'caller' };
|
|
198
|
+
return { brand: null, source: null };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve OSC dialect for a classified client.
|
|
203
|
+
* force (notifyChannel=osc): null → osc9; unsupported from globalEnv alone →
|
|
204
|
+
* osc9 (stale server env) *unless* per-client evidence (termname / caller env)
|
|
205
|
+
* independently denylists; unsupported from termname/caller still blocks.
|
|
206
|
+
* Auto: unsupported from any source stays skipped.
|
|
207
|
+
*/
|
|
208
|
+
function dialectForClient(brand, source, force, { termname, callerEnv } = {}) {
|
|
209
|
+
const dialect = dialectFor(brand);
|
|
210
|
+
if (dialect) return dialect;
|
|
211
|
+
if (!force) return null;
|
|
212
|
+
if (brand == null) return 'osc9';
|
|
213
|
+
// Stale show-environment -g denylist must not defeat forced OSC — but only
|
|
214
|
+
// when the caller/process env and termname are not themselves denylisted.
|
|
215
|
+
// classifyClient stops at the first non-null layer, so re-check per-client
|
|
216
|
+
// evidence here before upgrading server unsupported → OSC 9.
|
|
217
|
+
if (brand === 'unsupported' && source === 'server') {
|
|
218
|
+
const perClient = classifyTerminal({ termname, env: callerEnv || {} });
|
|
219
|
+
if (perClient === 'unsupported') return null;
|
|
220
|
+
return 'osc9';
|
|
221
|
+
}
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Send OSC notification to every attached client tty for `pane`.
|
|
227
|
+
* @returns {Promise<number>} number of successful tty writes
|
|
228
|
+
*/
|
|
229
|
+
export async function sendOsc(title, body, {
|
|
230
|
+
mux,
|
|
231
|
+
pane,
|
|
232
|
+
env = process.env,
|
|
233
|
+
writeTty = (path, data) => writeToTty(path, data),
|
|
234
|
+
force = false,
|
|
235
|
+
} = {}) {
|
|
236
|
+
try {
|
|
237
|
+
if (!mux || typeof mux.clientTtys !== 'function') return 0;
|
|
238
|
+
|
|
239
|
+
let clients;
|
|
240
|
+
try {
|
|
241
|
+
clients = await mux.clientTtys(pane);
|
|
242
|
+
} catch {
|
|
243
|
+
return 0;
|
|
244
|
+
}
|
|
245
|
+
if (!Array.isArray(clients) || clients.length === 0) return 0;
|
|
246
|
+
|
|
247
|
+
let serverEnv = {};
|
|
248
|
+
if (typeof mux.globalEnv === 'function') {
|
|
249
|
+
try {
|
|
250
|
+
serverEnv = (await mux.globalEnv(DETECT_ENV_NAMES)) || {};
|
|
251
|
+
} catch { /* empty server env */ }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Dedupe by tty path (multiple clients can share a path in edge cases).
|
|
255
|
+
const seen = new Set();
|
|
256
|
+
let delivered = 0;
|
|
257
|
+
|
|
258
|
+
for (const client of clients) {
|
|
259
|
+
const tty = client?.tty;
|
|
260
|
+
if (!tty || seen.has(tty)) continue;
|
|
261
|
+
seen.add(tty);
|
|
262
|
+
|
|
263
|
+
const { brand, source } = classifyClient(client.termname, serverEnv, env);
|
|
264
|
+
const dialect = dialectForClient(brand, source, force, {
|
|
265
|
+
termname: client.termname,
|
|
266
|
+
callerEnv: env,
|
|
267
|
+
});
|
|
268
|
+
if (!dialect) continue;
|
|
269
|
+
|
|
270
|
+
const seq = sequenceFor(dialect, title, body);
|
|
271
|
+
if (!seq) continue;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
const ok = await writeTty(tty, seq);
|
|
275
|
+
if (ok) delivered += 1;
|
|
276
|
+
} catch { /* never reject */ }
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return delivered;
|
|
280
|
+
} catch {
|
|
281
|
+
return 0;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Write BEL (`\x07`) to the pane tty (resolved at send time). Never throws.
|
|
287
|
+
* @returns {Promise<boolean>}
|
|
288
|
+
*/
|
|
289
|
+
export async function sendBell({
|
|
290
|
+
mux,
|
|
291
|
+
pane,
|
|
292
|
+
writeTty = (path, data) => writeToTty(path, data),
|
|
293
|
+
} = {}) {
|
|
294
|
+
try {
|
|
295
|
+
if (!mux || typeof mux.paneTty !== 'function') return false;
|
|
296
|
+
let tty;
|
|
297
|
+
try {
|
|
298
|
+
tty = await mux.paneTty(pane);
|
|
299
|
+
} catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (!tty) return false;
|
|
303
|
+
try {
|
|
304
|
+
return !!(await writeTty(tty, '\x07'));
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
} catch {
|
|
309
|
+
return false;
|
|
310
|
+
}
|
|
311
|
+
}
|
package/src/notify.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
-
// Desktop notifications — fire-and-forget, zero deps, never blocks
|
|
2
|
-
// (detection and resume paths must not care whether a notifier exists).
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
1
|
+
// Desktop / terminal notifications — fire-and-forget, zero deps, never blocks
|
|
2
|
+
// or throws (detection and resume paths must not care whether a notifier exists).
|
|
3
|
+
//
|
|
4
|
+
// Channels (notifyChannel / opts.channel):
|
|
5
|
+
// native — OS toast (osascript / notify-send / powershell) + tmux display-message
|
|
6
|
+
// osc — OSC 9/777 to client ttys (force); native fallback if 0 deliveries
|
|
7
|
+
// bell — BEL to pane tty; native fallback if undeliverable
|
|
8
|
+
// auto — OSC (detection-gated) + BEL when pane-capable; native only if OSC
|
|
9
|
+
// delivered 0 (avoids double banners). Without pane context → native.
|
|
10
|
+
//
|
|
11
|
+
// notifications=false remains the master off-switch.
|
|
6
12
|
|
|
7
13
|
import { spawn } from 'node:child_process';
|
|
8
14
|
import { release as osRelease } from 'node:os';
|
|
9
15
|
import { getConfig } from './settings.js';
|
|
16
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
17
|
+
import { sendOsc as defaultSendOsc, sendBell as defaultSendBell } from './notify-tty.js';
|
|
10
18
|
|
|
11
19
|
function defaultSpawner(cmd, args) {
|
|
12
20
|
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
|
|
@@ -42,22 +50,131 @@ function powershellToast(spawner, title, message) {
|
|
|
42
50
|
spawner('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
|
|
43
51
|
}
|
|
44
52
|
|
|
53
|
+
const CHANNELS = new Set(['auto', 'native', 'osc', 'bell']);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Platform native notification (OS toast / tmux status-line fallback).
|
|
57
|
+
* Body unchanged from the original notify() platform switch.
|
|
58
|
+
*/
|
|
59
|
+
export function nativeNotify(title, message, {
|
|
60
|
+
platform = process.platform,
|
|
61
|
+
wsl = isWsl(platform),
|
|
62
|
+
spawner = defaultSpawner,
|
|
63
|
+
} = {}) {
|
|
64
|
+
const mux = process.env.UNSNOOZE_MUX
|
|
65
|
+
|| (process.env.ZELLIJ ? 'zellij' : (process.env.TMUX ? 'tmux' : null));
|
|
66
|
+
if (platform === 'darwin') {
|
|
67
|
+
spawner('osascript', ['-e',
|
|
68
|
+
`display notification ${appleScriptString(message)} with title ${appleScriptString(title)}`]);
|
|
69
|
+
} else if (platform === 'win32' || wsl) {
|
|
70
|
+
powershellToast(spawner, title, message);
|
|
71
|
+
} else if (platform === 'linux') {
|
|
72
|
+
spawner('notify-send', ['-a', 'unsnooze', title, message]);
|
|
73
|
+
// Zellij has no statusline-inject equivalent, so its fallback is intentionally a no-op.
|
|
74
|
+
} else if (mux === 'tmux' && process.env.TMUX) {
|
|
75
|
+
spawner('tmux', ['display-message', `${title}: ${message}`]);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolveChannel(channel) {
|
|
80
|
+
if (channel != null) {
|
|
81
|
+
return CHANNELS.has(channel) ? channel : 'auto';
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
const c = getConfig('notifyChannel');
|
|
85
|
+
return CHANNELS.has(c) ? c : 'auto';
|
|
86
|
+
} catch {
|
|
87
|
+
return 'auto';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True when we can attempt OSC/BEL: pane context + mux backend with clientTtys
|
|
93
|
+
* (tmux). Zellij and other backends without clientTtys are not capable.
|
|
94
|
+
*/
|
|
95
|
+
function resolveCapableBackend(context, getMux) {
|
|
96
|
+
if (!context || !context.pane) return null;
|
|
97
|
+
if (!context.mux) return null;
|
|
98
|
+
let backend;
|
|
99
|
+
try {
|
|
100
|
+
backend = getMux(context.mux, { owner: context.paneOwner ?? null });
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!backend || typeof backend.clientTtys !== 'function') return null;
|
|
105
|
+
return backend;
|
|
106
|
+
}
|
|
107
|
+
|
|
45
108
|
export function notify(title, message, {
|
|
46
109
|
platform = process.platform,
|
|
47
110
|
wsl = isWsl(platform),
|
|
48
111
|
spawner = defaultSpawner,
|
|
112
|
+
context = null,
|
|
113
|
+
channel = null,
|
|
114
|
+
tty = { sendOsc: defaultSendOsc, sendBell: defaultSendBell },
|
|
115
|
+
getMux = getMultiplexer,
|
|
49
116
|
} = {}) {
|
|
50
117
|
try {
|
|
51
118
|
if (!getConfig('notifications')) return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
spawner('tmux', ['display-message', `${title}: ${message}`]);
|
|
119
|
+
|
|
120
|
+
const nativeOpts = { platform, wsl, spawner };
|
|
121
|
+
const fireNative = () => nativeNotify(title, message, nativeOpts);
|
|
122
|
+
|
|
123
|
+
const ch = resolveChannel(channel);
|
|
124
|
+
if (ch === 'native') {
|
|
125
|
+
fireNative();
|
|
126
|
+
return;
|
|
61
127
|
}
|
|
128
|
+
|
|
129
|
+
const backend = resolveCapableBackend(context, getMux);
|
|
130
|
+
if (!backend) {
|
|
131
|
+
// No pane context, unknown mux, or backend without clientTtys → native sync.
|
|
132
|
+
fireNative();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const pane = context.pane;
|
|
137
|
+
const sendOsc = tty?.sendOsc ?? defaultSendOsc;
|
|
138
|
+
const sendBell = tty?.sendBell ?? defaultSendBell;
|
|
139
|
+
|
|
140
|
+
if (ch === 'osc') {
|
|
141
|
+
Promise.resolve()
|
|
142
|
+
.then(async () => {
|
|
143
|
+
let n = 0;
|
|
144
|
+
try {
|
|
145
|
+
n = await sendOsc(title, message, { mux: backend, pane, force: true });
|
|
146
|
+
} catch { /* treat as undeliverable */ }
|
|
147
|
+
if (n === 0) fireNative();
|
|
148
|
+
})
|
|
149
|
+
.catch(() => {});
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (ch === 'bell') {
|
|
154
|
+
Promise.resolve()
|
|
155
|
+
.then(async () => {
|
|
156
|
+
let ok = false;
|
|
157
|
+
try {
|
|
158
|
+
ok = await sendBell({ mux: backend, pane });
|
|
159
|
+
} catch { /* treat as undeliverable */ }
|
|
160
|
+
if (!ok) fireNative();
|
|
161
|
+
})
|
|
162
|
+
.catch(() => {});
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// auto: OSC (detection-gated) + BEL; native only if OSC delivered 0.
|
|
167
|
+
Promise.resolve()
|
|
168
|
+
.then(async () => {
|
|
169
|
+
let n = 0;
|
|
170
|
+
try {
|
|
171
|
+
n = await sendOsc(title, message, { mux: backend, pane, force: false });
|
|
172
|
+
} catch { /* treat as undeliverable */ }
|
|
173
|
+
try {
|
|
174
|
+
await sendBell({ mux: backend, pane });
|
|
175
|
+
} catch { /* BEL is best-effort alongside OSC */ }
|
|
176
|
+
if (n === 0) fireNative();
|
|
177
|
+
})
|
|
178
|
+
.catch(() => {});
|
|
62
179
|
} catch { /* never let a notification break the caller */ }
|
|
63
180
|
}
|
package/src/report.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Build users can contribute real limit-banner text (the patterns for grok
|
|
4
4
|
// are generic until someone shows us the actual banner).
|
|
5
5
|
|
|
6
|
-
import {
|
|
6
|
+
import { getMultiplexer } from './multiplexer.js';
|
|
7
7
|
import { stripAnsi } from './patterns.js';
|
|
8
8
|
|
|
9
9
|
export const REPO_URL = 'https://github.com/saaranshM/unsnooze';
|
|
@@ -25,14 +25,21 @@ export function buildIssueUrl(agentId, captureText) {
|
|
|
25
25
|
|
|
26
26
|
export async function cmdReport(rest) {
|
|
27
27
|
const [agentId = 'grok', paneArg] = rest;
|
|
28
|
-
const
|
|
28
|
+
const selected = getMultiplexer();
|
|
29
|
+
let paneOwner = selected.name === 'zellij'
|
|
30
|
+
? (process.env.UNSNOOZE_PANE_OWNER || process.env.ZELLIJ_SESSION_NAME || null) : null;
|
|
31
|
+
let pane = paneArg || selected.currentPaneId();
|
|
32
|
+
if (paneArg && selected.name === 'zellij' && paneArg.includes(':')) {
|
|
33
|
+
[paneOwner, pane] = paneArg.split(/:(.*)/s, 2);
|
|
34
|
+
}
|
|
29
35
|
if (!pane) {
|
|
30
|
-
console.error('unsnooze report: no pane (run inside
|
|
36
|
+
console.error('unsnooze report: no pane (run inside a multiplexer or pass %3 / owner:3)');
|
|
31
37
|
return 2;
|
|
32
38
|
}
|
|
39
|
+
const mux = getMultiplexer(selected.name, { owner: paneOwner });
|
|
33
40
|
let text;
|
|
34
41
|
try {
|
|
35
|
-
text = stripAnsi(await capturePane(pane, 200));
|
|
42
|
+
text = stripAnsi(await mux.capturePane(pane, 200));
|
|
36
43
|
} catch (err) {
|
|
37
44
|
console.error(`unsnooze report: cannot capture pane ${pane}: ${err.message}`);
|
|
38
45
|
return 1;
|