UNPKG

cost-claude

Version:

Claude Code cost monitoring, analytics, and optimization toolkit

231 lines โ€ข 9.05 kB
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