Compare commits

...

9 Commits

Author SHA1 Message Date
Affaan Mustafa
2595c92983 fix(hooks): preserve WSL desktop notify success path 2026-03-30 03:02:17 -04:00
QWsin
99ff568c0e perf(hooks): reduce PowerShell spawns from 3 to 1 per notification
Merge findPowerShell version check and isBurntToastAvailable check
into a single notifyWindows call. Now just tries to send directly;
if it fails, tries next PowerShell path. Version field was unused.

Net effect: up to 3 spawns reduced to 1 in the happy path.
2026-03-30 14:12:05 +08:00
QWsin
3d5ae70c74 perf(hooks): memoize isWSL detection at module load
Avoids reading /proc/version twice (once in run(), once in findPowerShell())
by computing the result once when the module loads.
2026-03-30 14:09:59 +08:00
QWsin
4ea72dec99 fix(hooks): probe WSL interop PATH before hardcoded paths
Adds 'pwsh.exe' and 'powershell.exe' as candidates to leverage
WSL's Windows interop PATH resolution, making the hook work with
non-default WSL mount prefixes or Windows drives.
2026-03-30 14:08:47 +08:00
QWsin
02206e87a5 fix(hooks): remove external repo URL from tip message
BurntToast module is a well-known Microsoft module but per project
policy avoiding unvetted external links in user-facing output.
2026-03-30 14:08:08 +08:00
QWsin
76c13ac5fb fix(hooks): quote PowerShell path in install tip for shell safety
The PowerShell path contains spaces and needs to be quoted
when displayed as a copy-pasteable command.
2026-03-30 14:07:23 +08:00
QWsin
2c1ae27a3a fix(hooks): capture stderr properly in notifyWindows
Change stdio to ['ignore', 'pipe', 'pipe'] so stderr is captured
and can be logged on errors. Without this, result.stderr is null
and error logs show 'undefined' instead of the actual error.
2026-03-30 14:00:24 +08:00
boss
d9d16a0d4a docs(hooks): update desktop-notify description to include WSL
Updates the hook description in hooks.json to reflect the newly
added WSL notification support alongside macOS.
2026-03-30 13:55:42 +08:00
boss
a57c3c5dd5 fix(hooks): add WSL desktop notification support via PowerShell + BurntToast
Adds WSL (Windows Subsystem for Linux) desktop notification support to the
existing desktop-notify hook. The hook now detects WSL, finds available
PowerShell (7 or Windows PowerShell), checks for BurntToast module, and
sends Windows toast notifications.

New functions:
- isWSL(): detects WSL environment
- findPowerShell(): finds PowerShell 7 or Windows PowerShell on WSL
- isBurntToastAvailable(): checks if BurntToast module is installed
- notifyWindows(): sends Windows toast notification via BurntToast

If BurntToast is not installed, logs helpful tip for installation.
Falls back silently on non-WSL/non-macOS platforms.
2026-03-30 13:46:48 +08:00
3 changed files with 197 additions and 11 deletions

View File

@@ -310,7 +310,7 @@
"timeout": 10
}
],
"description": "Send macOS desktop notification with task summary when Claude responds"
"description": "Send desktop notification (macOS/WSL) with task summary when Claude responds"
}
],
"SessionEnd": [

View File

@@ -3,9 +3,11 @@
* Desktop Notification Hook (Stop)
*
* Sends a native desktop notification with the task summary when Claude
* finishes responding. Currently supports macOS (osascript); other
* platforms exit silently. Windows (PowerShell) and Linux (notify-send)
* support is planned.
* finishes responding. Supports:
* - macOS: osascript (native)
* - WSL: PowerShell 7 or Windows PowerShell + BurntToast module
*
* On WSL, if BurntToast is not installed, logs a tip for installation.
*
* Hook ID : stop:desktop-notify
* Profiles: standard, strict
@@ -19,6 +21,64 @@ const { isMacOS, log } = require('../lib/utils');
const TITLE = 'Claude Code';
const MAX_BODY_LENGTH = 100;
/**
* Memoized WSL detection at module load (avoids repeated /proc/version reads).
*/
let isWSL = false;
if (process.platform === 'linux') {
try {
isWSL = require('fs').readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
} catch {
isWSL = false;
}
}
/**
* Find available PowerShell executable on WSL.
* Returns first accessible path, or null if none found.
*/
function findPowerShell() {
if (!isWSL) return null;
const candidates = [
'pwsh.exe', // WSL interop resolves from Windows PATH
'powershell.exe', // WSL interop for Windows PowerShell
'/mnt/c/Program Files/PowerShell/7/pwsh.exe', // PowerShell 7 (default install)
'/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe', // Windows PowerShell
];
for (const path of candidates) {
try {
const result = spawnSync(path, ['-Command', 'exit 0'],
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 1000 });
if (result.status === 0) {
return path;
}
} catch {
// continue
}
}
return null;
}
/**
* Send a Windows Toast notification via PowerShell BurntToast.
* Returns true on success, false on failure.
*/
function notifyWindows(pwshPath, title, body) {
const safeBody = body.replace(/'/g, "''");
const safeTitle = title.replace(/'/g, "''");
const command = `Import-Module BurntToast; New-BurntToastNotification -Text '${safeTitle}', '${safeBody}'`;
const result = spawnSync(pwshPath, ['-Command', command],
{ stdio: ['ignore', 'pipe', 'pipe'], timeout: 5000 });
if (result.error || result.status !== 0) {
const stderr = typeof result.stderr?.toString === 'function' ? result.stderr.toString().trim() : '';
log(`[DesktopNotify] BurntToast failed (exit ${result.status}): ${result.error ? result.error.message : stderr}`);
return false;
}
return true;
}
/**
* Extract a short summary from the last assistant message.
* Takes the first non-empty line and truncates to MAX_BODY_LENGTH chars.
@@ -53,20 +113,28 @@ function notifyMacOS(title, body) {
}
}
// TODO: future platform support
// function notifyWindows(title, body) { ... }
// function notifyLinux(title, body) { ... }
/**
* Fast-path entry point for run-with-flags.js (avoids extra process spawn).
*/
function run(raw) {
try {
if (!isMacOS) return raw;
const input = raw.trim() ? JSON.parse(raw) : {};
const summary = extractSummary(input.last_assistant_message);
notifyMacOS(TITLE, summary);
if (isMacOS) {
notifyMacOS(TITLE, summary);
} else if (isWSL) {
const ps = findPowerShell();
if (ps && notifyWindows(ps, TITLE, summary)) {
// notification sent successfully
} else if (ps) {
// PowerShell found but BurntToast not available
log('[DesktopNotify] Tip: Install BurntToast module to enable notifications');
} else {
// No PowerShell found
log('[DesktopNotify] Tip: Install BurntToast module in PowerShell for notifications');
}
}
} catch (err) {
log(`[DesktopNotify] Error: ${err.message}`);
}

View File

@@ -0,0 +1,118 @@
/**
* Tests for scripts/hooks/desktop-notify.js
*/
const assert = require('assert');
const fs = require('fs');
const Module = require('module');
const path = require('path');
const modulePath = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'desktop-notify.js');
const moduleSource = fs.readFileSync(modulePath, 'utf8');
function test(name, fn) {
try {
fn();
console.log(`${name}`);
return true;
} catch (error) {
console.log(`${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function loadDesktopNotify({ procVersion = 'Linux version microsoft', spawnImpl, isMacOS = false }) {
const logs = [];
const mod = new Module(modulePath, module);
mod.filename = modulePath;
mod.paths = Module._nodeModulePaths(path.dirname(modulePath));
const originalRequire = mod.require.bind(mod);
mod.require = request => {
if (request === 'child_process') {
return { spawnSync: spawnImpl };
}
if (request === '../lib/utils') {
return {
isMacOS,
log: message => logs.push(message),
};
}
if (request === 'fs') {
return {
...fs,
readFileSync(target, encoding) {
if (target === '/proc/version') {
return procVersion;
}
return fs.readFileSync(target, encoding);
}
};
}
return originalRequire(request);
};
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux',
});
try {
mod._compile(moduleSource, modulePath);
} finally {
Object.defineProperty(process, 'platform', platformDescriptor);
}
return { run: mod.exports.run, logs };
}
let passed = 0;
let failed = 0;
if (
test('successful WSL toast does not log BurntToast install guidance', () => {
const calls = [];
const { run, logs } = loadDesktopNotify({
spawnImpl(command, args) {
calls.push({ command, args });
if (calls.length === 1) {
return { status: 0, stderr: Buffer.from('') };
}
return { status: 0, stderr: Buffer.from('') };
}
});
const payload = JSON.stringify({ last_assistant_message: 'Build completed successfully' });
assert.strictEqual(run(payload), payload);
assert.strictEqual(calls.length, 2, 'Expected PowerShell probe and notification send');
assert.strictEqual(logs.length, 0, `Expected no warnings, got: ${logs.join('\n')}`);
})
)
passed++;
else failed++;
if (
test('failed WSL toast logs failure and install guidance once', () => {
const { run, logs } = loadDesktopNotify({
spawnImpl(command, args) {
if (args[1] === 'exit 0') {
return { status: 0, stderr: Buffer.from('') };
}
return { status: 1, stderr: Buffer.from('module missing') };
}
});
const payload = JSON.stringify({ last_assistant_message: 'Done' });
assert.strictEqual(run(payload), payload);
assert.ok(logs.some(message => message.includes('BurntToast failed')), 'Expected BurntToast failure log');
assert.ok(logs.some(message => message.includes('Install BurntToast module')), 'Expected install tip');
})
)
passed++;
else failed++;
console.log(`\nPassed: ${passed}`);
console.log(`Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);