UNPKG

claude-notify

Version:

Transparent wrapper for Claude CLI that sends Slack notifications when Claude is waiting for input

375 lines (321 loc) 11 kB
#!/usr/bin/env node const pty = require('node-pty'); const axios = require('axios'); const { program } = require('commander'); const fs = require('fs'); const path = require('path'); // Parse CLI arguments program .name('claude-notify') .description('Transparent wrapper for Claude CLI that sends Slack notifications when Claude is waiting for input.\n\n' + 'This is an unofficial wrapper tool, not affiliated with Claude or Anthropic.\n' + 'It transparently passes all arguments to the claude command.') .version('1.0.0') .option('-t, --timeout <ms>', 'idle timeout in milliseconds before sending notification', '15000') .option('-w, --webhook-url <url>', 'Slack webhook URL for notifications') .option('--disable-notifications', 'run without notifications (transparent pass-through only)') .option('-d, --debug', 'enable debug mode with state transition logging') .allowUnknownOption(true) .parse(); const options = program.opts(); // Configuration const NOTIFICATION_TIMEOUT = parseInt(options.timeout); const SLACK_WEBHOOK_URL = options.webhookUrl || process.env.SLACK_WEBHOOK_URL; const NOTIFICATIONS_DISABLED = options.disableNotifications; const DEBUG_MODE = options.debug; // Audit logging setup const AUDIT_LOG_FILE = path.join(process.cwd(), 'claude-notify-audit.log'); const auditLog = []; function logStateTransition(event, context = {}) { const entry = { timestamp: new Date().toISOString(), event, state: { currentState, userHasEngaged, notificationSent, isClaudeWorking, timers: { notificationTimer: !!notificationTimer } }, context, decision: null }; auditLog.push(entry); // Write to audit file only in debug mode if (DEBUG_MODE) { fs.appendFileSync(AUDIT_LOG_FILE, JSON.stringify(entry) + '\n'); } return entry; } // State variables let userHasEngaged = false; let notificationSent = false; let isClaudeWorking = false; let notificationTimer = null; let currentState = 'IDLE'; // IDLE, WORKING, WAITING, NOTIFIED // Initialize audit log logStateTransition('SESSION_STARTED', { config: { notificationTimeout: NOTIFICATION_TIMEOUT, notificationsEnabled: !NOTIFICATIONS_DISABLED, hasWebhook: !!SLACK_WEBHOOK_URL } }); // Get arguments for Claude const claudeArgs = program.args; // Validate configuration if (!NOTIFICATIONS_DISABLED && !SLACK_WEBHOOK_URL) { console.error('Error: SLACK_WEBHOOK_URL environment variable or --webhook-url option is required.'); console.error('Use --disable-notifications to run without notifications.'); process.exit(1); } // State transition functions function transitionToState(newState, trigger, context = {}) { const oldState = currentState; currentState = newState; logStateTransition('STATE_TRANSITION', { from: oldState, to: newState, trigger, ...context }); } function transitionToWorking(trigger) { if (currentState !== 'WORKING') { isClaudeWorking = true; clearAllTimers('entered_working_state'); transitionToState('WORKING', trigger); } } function transitionToWaiting(trigger) { if (currentState !== 'WAITING') { isClaudeWorking = false; transitionToState('WAITING', trigger); // Start notification timer only on state transition if (shouldStartNotificationTimer()) { logStateTransition('NOTIFICATION_TIMER_STARTED', { timeoutMs: NOTIFICATION_TIMEOUT }); notificationTimer = setTimeout(() => { logStateTransition('NOTIFICATION_TIMER_EXPIRED'); notificationTimer = null; if (shouldSendNotification()) { sendSlackNotification(); notificationSent = true; transitionToState('NOTIFIED', 'notification_sent'); } }, NOTIFICATION_TIMEOUT); } } } function shouldSendNotification() { const entry = logStateTransition('NOTIFICATION_EVALUATION', { conditions: { userHasEngaged, notificationNotSent: !notificationSent, claudeNotWorking: !isClaudeWorking, notificationsEnabled: !NOTIFICATIONS_DISABLED } }); const decision = userHasEngaged && !notificationSent && !isClaudeWorking && !NOTIFICATIONS_DISABLED; entry.decision = decision ? 'SEND_NOTIFICATION' : 'SKIP_NOTIFICATION'; entry.context.reason = decision ? 'all_conditions_met' : getSkipReason(); return decision; } function getSkipReason() { if (!userHasEngaged) return 'user_not_engaged'; if (notificationSent) return 'notification_already_sent'; if (isClaudeWorking) return 'claude_still_working'; if (NOTIFICATIONS_DISABLED) return 'notifications_disabled'; return 'unknown'; } function shouldStartNotificationTimer() { const entry = logStateTransition('NOTIFICATION_TIMER_EVALUATION', { currentState, claudeNotWorking: !isClaudeWorking, noNotificationTimer: !notificationTimer, userEngaged: userHasEngaged }); const decision = currentState === 'WAITING' && !notificationTimer; entry.decision = decision ? 'START_TIMER' : 'SKIP_TIMER'; entry.context.reason = decision ? 'conditions_met' : 'not_in_waiting_state_or_timer_active'; return decision; } function clearAllTimers(reason) { if (notificationTimer) { clearTimeout(notificationTimer); notificationTimer = null; logStateTransition('NOTIFICATION_TIMER_CLEARED', { reason }); } } // Find the claude binary path function findClaudeBinary() { // Try to use the bundled claude first try { const bundledPath = path.resolve(__dirname, '../node_modules/.bin/claude'); if (fs.existsSync(bundledPath)) { return bundledPath; } } catch (e) { // Fall through to global claude } // Fall back to global claude command return 'claude'; } // Spawn claude subprocess with PTY const claudeBinary = findClaudeBinary(); const claude = pty.spawn(claudeBinary, claudeArgs, { name: 'xterm-256color', cols: process.stdout.columns || 80, rows: process.stdout.rows || 24, cwd: process.cwd(), env: { ...process.env, FORCE_COLOR: '1', TERM: 'xterm-256color' } }); // Handle spawn errors claude.on('error', (error) => { logStateTransition('CLAUDE_SPAWN_ERROR', { error: error.message }); process.exit(1); }); // Monitor Claude's output claude.onData((data) => { process.stdout.write(data); const containsInterruptText = data.includes('to interrupt)'); logStateTransition('CLAUDE_OUTPUT_RECEIVED', { dataLength: data.length, containsInterruptText, currentState, cleanedPreview: data.toString().replace(/\x1b\[[0-9;]*m/g, '').substring(0, 100) }); // State machine logic - only Claude's output drives state transitions if (containsInterruptText && currentState !== 'WORKING') { // First time seeing "to interrupt)" - Claude started working transitionToWorking('interrupt_text_appeared'); } else if (!containsInterruptText && currentState === 'WORKING') { // "to interrupt)" disappeared - Claude needs interaction transitionToWaiting('interrupt_text_disappeared'); } else if (!containsInterruptText && currentState === 'IDLE') { // Still in startup - Claude hasn't needed interaction yet logStateTransition('REMAINING_IN_IDLE', { reason: 'startup_output_no_interrupt_text' }); } else if (!containsInterruptText && (currentState === 'WAITING' || currentState === 'NOTIFIED')) { // Additional output while waiting - stay in current state, don't restart timers logStateTransition('OUTPUT_WHILE_WAITING', { reason: 'claude_output_in_waiting_state', currentState }); } // All other cases: stay in current state without logging }); // Set up raw mode for proper terminal input handling process.stdin.setRawMode(true); // Forward stdin directly to claude process.stdin.on('data', (data) => { claude.write(data); const isEnterKey = data.includes('\r') || data.includes('\n'); const previousUserHasEngaged = userHasEngaged; const previousNotificationSent = notificationSent; logStateTransition('USER_INPUT_RECEIVED', { dataLength: data.length, isEnterKey, currentState, keyPreview: data.toString('hex').substring(0, 20) }); // Always clear notification timer when user types (shows awareness) clearAllTimers('user_input_received'); notificationSent = false; if (notificationSent !== previousNotificationSent) { logStateTransition('NOTIFICATION_STATE_RESET', { from: previousNotificationSent, to: notificationSent, trigger: 'user_input' }); } // Mark user as engaged when they submit input (Enter key) if (isEnterKey && !userHasEngaged) { userHasEngaged = true; logStateTransition('USER_ENGAGEMENT_CHANGED', { from: previousUserHasEngaged, to: userHasEngaged, trigger: 'first_message_submitted' }); } // Handle state transitions - user input resets us from NOTIFIED back to WAITING if (currentState === 'NOTIFIED') { transitionToState('WAITING', 'user_input_after_notification'); } }); // Handle claude exit claude.onExit(({ exitCode }) => { logStateTransition('CLAUDE_EXITED', { exitCode }); cleanup(); process.exit(exitCode); }); // Send Slack notification async function sendSlackNotification() { logStateTransition('SLACK_NOTIFICATION_ATTEMPT'); if (NOTIFICATIONS_DISABLED) { logStateTransition('SLACK_NOTIFICATION_SKIPPED', { reason: 'notifications_disabled' }); return; } if (!SLACK_WEBHOOK_URL) { logStateTransition('SLACK_NOTIFICATION_FAILED', { reason: 'no_webhook_url' }); // Silently fail - no console output return; } try { const sessionId = process.pid; const cwd = process.cwd(); const messageText = `Claude is waiting for your input`; const payload = { text: messageText, blocks: [ { type: "section", text: { type: "mrkdwn", text: `🤖 *${messageText}*` } }, { type: "context", elements: [ { type: "mrkdwn", text: `Session ID: ${sessionId} | Directory: \`${cwd}\`` } ] } ] }; await axios.post(SLACK_WEBHOOK_URL, payload); logStateTransition('SLACK_NOTIFICATION_SUCCESS'); } catch (error) { logStateTransition('SLACK_NOTIFICATION_FAILED', { error: error.message, reason: 'api_error' }); // Silently fail - no console output } } // Cleanup function function cleanup() { logStateTransition('CLEANUP_STARTED'); clearAllTimers('cleanup'); if (process.stdin.setRawMode) { process.stdin.setRawMode(false); } process.stdin.pause(); logStateTransition('CLEANUP_COMPLETED'); } // Handle process termination process.on('SIGINT', () => { logStateTransition('SIGINT_RECEIVED'); cleanup(); claude.kill(); }); process.on('SIGTERM', () => { logStateTransition('SIGTERM_RECEIVED'); cleanup(); claude.kill(); }); // Resume stdin process.stdin.resume();