cost-claude
Version:
Claude Code cost monitoring, analytics, and optimization toolkit
231 lines โข 9.05 kB
JavaScript
import notifier from 'node-notifier';
import { EventEmitter } from 'events';
import { logger } from '../utils/logger.js';
import { formatCostAdaptive, formatDuration, formatCompactNumber, shortenProjectName } from '../utils/format.js';
export class NotificationService extends EventEmitter {
config;
iconPath;
soundEnabled;
platform;
lastNotificationTime = 0;
notificationThrottle = 1000;
constructor(config = {}) {
super();
this.config = config;
this.platform = process.platform;
this.iconPath = this.resolveIconPath();
this.soundEnabled = config.soundEnabled ?? true;
}
async notifyCostUpdate(data) {
const now = Date.now();
if (now - this.lastNotificationTime < this.notificationThrottle) {
logger.debug('Notification throttled');
return;
}
this.lastNotificationTime = now;
const title = this.formatTitle(data);
const message = this.formatMessage(data);
const subtitle = this.formatSubtitle(data);
const options = {
title,
message,
subtitle,
sound: this.soundEnabled ? this.getSound() : false,
icon: this.iconPath,
};
await this.notify(options);
}
async notify(options) {
return new Promise((resolve, reject) => {
try {
logger.debug('Sending notification:', {
title: options.title,
sound: options.sound,
platform: this.platform
});
const notificationOptions = {
title: options.title,
message: options.message,
sound: options.sound,
icon: options.icon,
contentImage: options.icon,
wait: false,
};
if (this.platform === 'darwin') {
notificationOptions.subtitle = options.subtitle;
notificationOptions.closeLabel = options.closeLabel;
notificationOptions.actions = options.actions;
notificationOptions.dropdownLabel = options.dropdownLabel;
notificationOptions.timeout = options.timeout ?? 86400;
notificationOptions.sender = 'com.apple.Terminal';
notificationOptions.activate = 'com.apple.Terminal';
notificationOptions.bundleId = 'com.apple.Terminal';
}
else if (this.platform === 'win32') {
notificationOptions.appID = 'Cost Claude';
notificationOptions.timeout = options.timeout ?? 86400;
}
else {
notificationOptions.timeout = options.timeout ?? 86400;
}
const clickHandler = (_notifierObject, options, event) => {
logger.debug('Notification clicked');
this.emit('click', { options, event });
};
const timeoutHandler = (_notifierObject, options) => {
logger.debug('Notification timed out');
this.emit('timeout', options);
};
notifier.removeAllListeners('click');
notifier.removeAllListeners('timeout');
notifier.on('click', clickHandler);
notifier.on('timeout', timeoutHandler);
logger.debug('Sending notification with options:', {
title: notificationOptions.title,
platform: this.platform,
sender: notificationOptions.sender,
bundleId: notificationOptions.bundleId,
ignoreDnD: notificationOptions.ignoreDnD,
timeout: notificationOptions.timeout
});
notifier.notify(notificationOptions, (err, response) => {
notifier.removeListener('click', clickHandler);
notifier.removeListener('timeout', timeoutHandler);
if (err) {
if (err.signal === 'SIGINT' || err.killed) {
logger.debug('Notification cancelled due to process termination');
resolve();
}
else if (err.message && err.message.includes('JSON')) {
logger.debug('Notification JSON parse warning (notification likely still sent):', err.message);
resolve();
}
else {
logger.error('Notification error:', err);
logger.debug('Full error details:', err);
this.emit('error', err);
reject(err);
}
}
else {
logger.debug('Notification sent successfully', response);
this.emit('sent', { options, response });
resolve();
}
});
}
catch (error) {
logger.error('Failed to send notification:', error);
reject(error);
}
});
}
formatTitle(data) {
const emoji = this.getCostEmoji(data.cost);
const projectPart = data.projectName ? ` - ${shortenProjectName(data.projectName)}` : '';
return `${formatCostAdaptive(data.cost)} ${emoji}${projectPart}`;
}
formatMessage(data) {
const lines = [];
lines.push(`โฑ๏ธ ${formatDuration(data.duration)}`);
const totalTokens = data.tokens.input + data.tokens.output;
lines.push(`๐ ${formatCompactNumber(totalTokens)} tokens`);
if (data.sessionTotal !== undefined && data.sessionTotal > data.cost) {
lines.push(`ฮฃ ${formatCostAdaptive(data.sessionTotal)}`);
}
return lines.join(' โข ');
}
formatSubtitle(_data) {
return undefined;
}
getCostEmoji(cost) {
if (cost < 0.01)
return 'โ
';
if (cost < 0.05)
return '๐';
if (cost < 0.10)
return '๐';
if (cost < 0.50)
return '๐งก';
if (cost < 1.00)
return 'โค๏ธ';
return '๐ธ';
}
resolveIconPath() {
if (this.config.customIcon) {
return this.config.customIcon;
}
return this.platform === 'darwin' ? 'Terminal' : '';
}
getSound(type) {
if (!this.soundEnabled)
return false;
if (this.platform === 'darwin') {
if (type === 'task' && this.config.taskCompleteSound) {
return this.config.taskCompleteSound;
}
if (type === 'session' && this.config.sessionCompleteSound) {
return this.config.sessionCompleteSound;
}
if (this.config.customSound) {
return this.config.customSound;
}
switch (type) {
case 'task':
return 'Pop';
case 'session':
return 'Glass';
default:
return 'Ping';
}
}
else if (this.platform === 'win32') {
return true;
}
return false;
}
async sendCustom(title, message, options) {
const { soundType, ...notificationOptions } = options || {};
const sound = notificationOptions.sound !== undefined
? notificationOptions.sound
: (this.soundEnabled ? this.getSound(soundType) : false);
await this.notify({
title,
message,
sound,
icon: this.iconPath,
...notificationOptions,
});
}
async sendError(error) {
const message = error instanceof Error ? error.message : error;
await this.sendCustom('โ Claude Code Error', message, {
sound: true,
});
}
async sendWarning(message) {
await this.sendCustom('โ ๏ธ Claude Code Warning', message, {
sound: this.soundEnabled,
});
}
async sendSuccess(message) {
await this.sendCustom('โ
Claude Code', message, {
sound: false,
});
}
isSupported() {
return ['darwin', 'win32', 'linux'].includes(this.platform);
}
updateConfig(config) {
if (config.soundEnabled !== undefined) {
this.soundEnabled = config.soundEnabled;
}
if (config.customIcon !== undefined) {
this.iconPath = config.customIcon;
}
if (config.thresholds !== undefined) {
this.config.thresholds = { ...this.config.thresholds, ...config.thresholds };
}
}
}
//# sourceMappingURL=notification.js.map