From a53091d04bd5736d7b77ec67ff7c9da6df4f0b4a Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 28 Jul 2025 14:35:29 +0200 Subject: [PATCH] Implement unified notification system via Unix socket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extended control protocol to support session-monitor events - Modified SessionMonitor to emit events via Unix socket to Mac app - Removed duplicate notification logic from Mac SessionMonitor - All notifications now flow: Server → Unix Socket → Mac NotificationControlHandler - Respects user notification preferences on Mac side - Single source of truth for all notification events (server-side) This eliminates the need for SSE connection from Mac app and removes polling-based duplicate detection, simplifying the architecture. --- docs/unified-notifications-test-plan.md | 101 ++++ .../Core/Services/ControlProtocol.swift | 1 + .../Services/NotificationControlHandler.swift | 108 +++- .../Core/Services/SessionMonitor.swift | 118 +---- web/src/server/server.ts | 11 +- web/src/server/services/session-monitor.ts | 465 ++++++++++++++++++ web/src/server/websocket/control-protocol.ts | 27 +- 7 files changed, 713 insertions(+), 118 deletions(-) create mode 100644 docs/unified-notifications-test-plan.md create mode 100644 web/src/server/services/session-monitor.ts diff --git a/docs/unified-notifications-test-plan.md b/docs/unified-notifications-test-plan.md new file mode 100644 index 00000000..892bbaf2 --- /dev/null +++ b/docs/unified-notifications-test-plan.md @@ -0,0 +1,101 @@ +# Unified Notification System Test Plan + +## Overview +Test the new unified notification system that sends all notifications from the server to Mac via Unix socket. + +## Architecture Changes +- Server SessionMonitor detects all notification events +- Events sent to Mac via Unix socket (session-monitor category) +- Mac NotificationControlHandler processes and displays notifications +- No more polling or duplicate detection logic + +## Test Scenarios + +### 1. Bell Notification Test +```bash +# In any VibeTunnel session +echo -e '\a' +# or +printf '\007' +``` +**Expected**: Bell notification appears on both Mac and Web + +### 2. Claude Turn Notification Test +```bash +# Start a Claude session +claude "Tell me a joke" +# Wait for Claude to finish responding +``` +**Expected**: "Claude has finished responding" notification on both platforms + +### 3. Command Completion Test (>3 seconds) +```bash +# Run a command that takes more than 3 seconds +sleep 4 +# or +find / -name "*.txt" 2>/dev/null | head -100 +``` +**Expected**: Command completion notification after command finishes + +### 4. Command Error Test +```bash +# Run a command that fails +ls /nonexistent/directory +# or +false +``` +**Expected**: Command error notification with exit code + +### 5. Session Start/Exit Test +```bash +# From another terminal or web UI +# Create new session +# Exit session with 'exit' command +``` +**Expected**: Session start and exit notifications + +## Verification Steps + +1. **Enable all notifications in Mac Settings**: + - Open VibeTunnel → Settings → Notifications + - Enable "Show Session Notifications" + - Enable all notification types + - Enable sound if desired + +2. **Monitor Unix socket traffic** (optional): + ```bash + # In a separate terminal, monitor the control socket + sudo dtrace -n 'syscall::write:entry /execname == "VibeTunnel" || execname == "node"/ { printf("%d: %s", pid, copyinstr(arg1, 200)); }' + ``` + +3. **Check logs**: + ```bash + # Monitor VibeTunnel logs + ./scripts/vtlog.sh -f -c NotificationControl + + # Check for session-monitor events + ./scripts/vtlog.sh -f | grep "session-monitor" + ``` + +## Success Criteria + +1. ✅ All notification types work on Mac via Unix socket +2. ✅ No duplicate notifications +3. ✅ Notifications respect user preferences (on/off toggles) +4. ✅ No more 3-second polling from Mac SessionMonitor +5. ✅ Single source of truth (server) for all notification events + +## Troubleshooting + +- If no notifications appear, check: + - Mac app is connected to server (check Unix socket connection) + - Notifications are enabled in settings + - Check vtlog for any errors + +- If notifications are delayed: + - Check if bell detection is working (should be instant) + - Claude turn has 2-second debounce by design + +- If getting duplicate notifications: + - Ensure only one VibeTunnel instance is running + - Check that old SessionMonitor code is not running \ No newline at end of file diff --git a/mac/VibeTunnel/Core/Services/ControlProtocol.swift b/mac/VibeTunnel/Core/Services/ControlProtocol.swift index 199d62ec..248e3b20 100644 --- a/mac/VibeTunnel/Core/Services/ControlProtocol.swift +++ b/mac/VibeTunnel/Core/Services/ControlProtocol.swift @@ -15,6 +15,7 @@ enum ControlProtocol { case git case system case notification + case sessionMonitor = "session-monitor" } // MARK: - Base message for runtime dispatch diff --git a/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift b/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift index a8f94a84..0eb98053 100644 --- a/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift +++ b/mac/VibeTunnel/Core/Services/NotificationControlHandler.swift @@ -18,13 +18,19 @@ final class NotificationControlHandler { // MARK: - Initialization private init() { - // Register handler with the shared socket manager + // Register handler with the shared socket manager for notification category SharedUnixSocketManager.shared.registerControlHandler(for: .notification) { [weak self] data in _ = await self?.handleMessage(data) return nil // No response needed for notifications } + + // Also register for session-monitor category + SharedUnixSocketManager.shared.registerControlHandler(for: .sessionMonitor) { [weak self] data in + _ = await self?.handleSessionMonitorMessage(data) + return nil // No response needed for events + } - logger.info("NotificationControlHandler initialized") + logger.info("NotificationControlHandler initialized for notification and session-monitor categories") } // MARK: - Message Handling @@ -91,6 +97,104 @@ final class NotificationControlHandler { return nil } + + // MARK: - Session Monitor Message Handling + + private func handleSessionMonitorMessage(_ data: Data) async -> Data? { + do { + // Decode the control message + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let action = json["action"] as? String, + let payload = json["payload"] as? [String: Any] + { + logger.debug("Received session-monitor event: \(action)") + + // Check if notifications are enabled + guard ConfigManager.shared.notificationsEnabled else { + logger.debug("Notifications disabled, ignoring session-monitor event") + return nil + } + + // Map action to notification preference check + let shouldNotify = switch action { + case "session-start": ConfigManager.shared.notificationSessionStart + case "session-exit": ConfigManager.shared.notificationSessionExit + case "command-finished": ConfigManager.shared.notificationCommandCompletion + case "command-error": ConfigManager.shared.notificationCommandError + case "bell": ConfigManager.shared.notificationBell + case "claude-turn": ConfigManager.shared.notificationClaudeTurn + default: false + } + + guard shouldNotify else { + logger.debug("Notification type \(action) disabled by user preference") + return nil + } + + // Extract common fields + let sessionId = payload["sessionId"] as? String + let sessionName = payload["sessionName"] as? String ?? "Session" + let timestamp = payload["timestamp"] as? String + + // Map to ServerEventType + let eventType: ServerEventType? = switch action { + case "session-start": .sessionStart + case "session-exit": .sessionExit + case "command-finished": .commandFinished + case "command-error": .commandError + case "bell": .bell + case "claude-turn": .claudeTurn + default: nil + } + + guard let eventType else { + logger.warning("Unknown session-monitor action: \(action)") + return nil + } + + // Extract event-specific fields + let exitCode = payload["exitCode"] as? Int + let command = payload["command"] as? String + let duration = payload["duration"] as? Int + + // Create message based on event type + let message: String? = switch eventType { + case .claudeTurn: "Claude has finished responding" + default: nil + } + + // Parse timestamp if provided, otherwise use current date + let eventDate: Date + if let timestamp { + let formatter = ISO8601DateFormatter() + eventDate = formatter.date(from: timestamp) ?? Date() + } else { + eventDate = Date() + } + + // Create ServerEvent + let serverEvent = ServerEvent( + type: eventType, + sessionId: sessionId, + sessionName: sessionName, + command: command, + exitCode: exitCode, + duration: duration, + message: message, + timestamp: eventDate + ) + + // Send notification + await notificationService.sendNotification(for: serverEvent) + + logger.info("Processed session-monitor event: \(action) for session: \(sessionName)") + } + } catch { + logger.error("Failed to decode session-monitor message: \(error)") + } + + return nil + } } // MARK: - Supporting Types diff --git a/mac/VibeTunnel/Core/Services/SessionMonitor.swift b/mac/VibeTunnel/Core/Services/SessionMonitor.swift index c4a45023..113e9a9b 100644 --- a/mac/VibeTunnel/Core/Services/SessionMonitor.swift +++ b/mac/VibeTunnel/Core/Services/SessionMonitor.swift @@ -1,7 +1,6 @@ import Foundation import Observation import os.log -import UserNotifications /// Server session information returned by the API. /// @@ -75,11 +74,6 @@ final class SessionMonitor { private var previousSessions: [String: ServerSessionInfo] = [:] private var firstFetchDone = false - /// Track last known activity state per session for Claude transition detection - private var lastActivityState: [String: Bool] = [:] - /// Sessions that have already triggered a "Your Turn" alert - private var claudeIdleNotified: Set = [] - /// Detect sessions that transitioned from running to not running static func detectEndedSessions( from old: [String: ServerSessionInfo], @@ -108,13 +102,7 @@ final class SessionMonitor { /// Reference to GitRepositoryMonitor for pre-caching weak var gitRepositoryMonitor: GitRepositoryMonitor? - /// Timer for periodic refresh - private var refreshTimer: Timer? - - private init() { - // Start periodic refresh - startPeriodicRefresh() - } + private init() {} /// Set the local auth token for server requests func setLocalAuthToken(_ token: String?) {} @@ -146,7 +134,7 @@ final class SessionMonitor { private func fetchSessions() async { do { // Snapshot previous sessions for exit notifications - let oldSessions = sessions + let _ = sessions let sessionsArray = try await serverManager.performRequest( endpoint: APIEndpoints.sessions, @@ -163,31 +151,7 @@ final class SessionMonitor { self.sessions = sessionsDict self.lastError = nil - // Notify for sessions that have just ended - if firstFetchDone && ConfigManager.shared.notificationsEnabled { - let ended = Self.detectEndedSessions(from: oldSessions, to: sessionsDict) - for session in ended { - let id = session.id - let title = "Session Completed" - let displayName = session.name - let content = UNMutableNotificationContent() - content.title = title - content.body = displayName - content.sound = .default - let request = UNNotificationRequest(identifier: "session_\(id)", content: content, trigger: nil) - do { - try await UNUserNotificationCenter.current().add(request) - } catch { - self.logger - .error( - "Failed to deliver session notification: \(error.localizedDescription, privacy: .public)" - ) - } - } - - // Detect Claude "Your Turn" transitions - await detectAndNotifyClaudeTurns(from: oldSessions, to: sessionsDict) - } + // Sessions have been updated // Set firstFetchDone AFTER detecting ended sessions firstFetchDone = true @@ -269,78 +233,4 @@ final class SessionMonitor { "Pre-caching Git data for \(uniqueDirectoriesToCheck.count) unique directories (from \(sessions.count) sessions)" ) } - - /// Start periodic refresh of sessions - private func startPeriodicRefresh() { - // Clean up any existing timer - refreshTimer?.invalidate() - - // Create a new timer that fires every 3 seconds - refreshTimer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in - Task { @MainActor [weak self] in - await self?.refresh() - } - } - } - - /// Detect and notify when Claude sessions transition from active to inactive ("Your Turn") - private func detectAndNotifyClaudeTurns( - from old: [String: ServerSessionInfo], - to new: [String: ServerSessionInfo] - ) - async - { - // Check if Claude notifications are enabled using ConfigManager - // Must check both master switch and specific preference - guard ConfigManager.shared.notificationsEnabled && ConfigManager.shared.notificationClaudeTurn else { return } - - for (id, newSession) in new { - // Only process running sessions - guard newSession.isRunning else { continue } - - // Check if this is a Claude session - let isClaudeSession = newSession.activityStatus?.specificStatus?.app.lowercased() - .contains("claude") ?? false || - newSession.command.joined(separator: " ").lowercased().contains("claude") - - guard isClaudeSession else { continue } - - // Get current activity state - let currentActive = newSession.activityStatus?.isActive ?? false - - // Get previous activity state (from our tracking or old session data) - let previousActive = lastActivityState[id] ?? (old[id]?.activityStatus?.isActive ?? false) - - // Reset when Claude speaks again - if !previousActive && currentActive { - claudeIdleNotified.remove(id) - } - - // First active ➜ idle transition ⇒ alert - let alreadyNotified = claudeIdleNotified.contains(id) - if previousActive && !currentActive && !alreadyNotified { - logger.info("🔔 Detected Claude transition to idle for session: \(id)") - let sessionName = newSession.name - - // Create a claude-turn event for the notification - let claudeTurnEvent = ServerEvent.claudeTurn( - sessionId: id, - sessionName: sessionName - ) - await NotificationService.shared.sendNotification(for: claudeTurnEvent) - claudeIdleNotified.insert(id) - } - - // Update tracking *after* detection logic - lastActivityState[id] = currentActive - } - - // Clean up tracking for ended/closed sessions - for id in lastActivityState.keys { - if new[id] == nil || !(new[id]?.isRunning ?? false) { - lastActivityState.removeValue(forKey: id) - claudeIdleNotified.remove(id) - } - } - } -} +} \ No newline at end of file diff --git a/web/src/server/server.ts b/web/src/server/server.ts index d28d6ab3..029b2d2f 100644 --- a/web/src/server/server.ts +++ b/web/src/server/server.ts @@ -38,6 +38,7 @@ import { HQClient } from './services/hq-client.js'; import { mdnsService } from './services/mdns-service.js'; import { PushNotificationService } from './services/push-notification-service.js'; import { RemoteRegistry } from './services/remote-registry.js'; +import { SessionMonitor } from './services/session-monitor.js'; import { StreamWatcher } from './services/stream-watcher.js'; import { TerminalManager } from './services/terminal-manager.js'; import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.js'; @@ -458,6 +459,14 @@ export async function createApp(): Promise { const streamWatcher = new StreamWatcher(sessionManager); logger.debug('Initialized stream watcher'); + // Initialize session monitor with PTY manager and control handler + const sessionMonitor = new SessionMonitor(ptyManager, controlUnixHandler); + await sessionMonitor.initialize(); + + // Set the session monitor on PTY manager for data tracking + ptyManager.setSessionMonitor(sessionMonitor); + logger.debug('Initialized session monitor'); + // Initialize activity monitor const activityMonitor = new ActivityMonitor(CONTROL_DIR); logger.debug('Initialized activity monitor'); @@ -905,7 +914,7 @@ export async function createApp(): Promise { } // Mount events router for SSE streaming - app.use('/api', createEventsRouter(ptyManager)); + app.use('/api', createEventsRouter(ptyManager, sessionMonitor)); logger.debug('Mounted events routes'); // Initialize control socket diff --git a/web/src/server/services/session-monitor.ts b/web/src/server/services/session-monitor.ts new file mode 100644 index 00000000..cec774d7 --- /dev/null +++ b/web/src/server/services/session-monitor.ts @@ -0,0 +1,465 @@ +/** + * SessionMonitor - Server-side monitoring of terminal sessions + * + * Replaces the Mac app's polling-based SessionMonitor with real-time + * event detection directly from PTY streams. Tracks session states, + * command execution, and Claude-specific activity transitions. + */ + +import { EventEmitter } from 'events'; +import { ServerEventType } from '../../shared/types.js'; +import type { PtyManager } from '../pty/pty-manager.js'; +import { createLogger } from '../utils/logger.js'; +import { createControlEvent, type SessionMonitorEvent } from '../websocket/control-protocol.js'; +import type { ControlUnixHandler } from '../websocket/control-unix-handler.js'; + +const logger = createLogger('session-monitor'); + +// Command tracking thresholds +const MIN_COMMAND_DURATION_MS = 3000; // Minimum duration for command completion notifications +const CLAUDE_IDLE_DEBOUNCE_MS = 2000; // Debounce period for Claude idle detection + +export interface SessionState { + id: string; + name: string; + command: string[]; + workingDir: string; + status: 'running' | 'exited'; + isRunning: boolean; + pid?: number; + + // Activity tracking + activityStatus?: { + isActive: boolean; + lastActivity?: Date; + specificStatus?: { + app: string; + status: string; + }; + }; + + // Command tracking + commandStartTime?: Date; + lastCommand?: string; + lastExitCode?: number; + + // Claude-specific tracking + isClaudeSession?: boolean; + claudeActivityState?: 'active' | 'idle' | 'unknown'; +} + +export interface CommandFinishedEvent { + sessionId: string; + sessionName: string; + command: string; + duration: number; + exitCode: number; +} + +export interface ClaudeTurnEvent { + sessionId: string; + sessionName: string; + message?: string; +} + +export class SessionMonitor extends EventEmitter { + private sessions = new Map(); + private claudeIdleNotified = new Set(); + private lastActivityState = new Map(); + private commandThresholdMs = MIN_COMMAND_DURATION_MS; + private claudeIdleTimers = new Map(); + + constructor( + private ptyManager: PtyManager, + private controlHandler?: ControlUnixHandler + ) { + super(); + this.setupEventListeners(); + logger.info('SessionMonitor initialized'); + } + + private setupEventListeners() { + // Listen for session lifecycle events + this.ptyManager.on('sessionStarted', (sessionId: string, sessionName: string) => { + this.handleSessionStarted(sessionId, sessionName); + }); + + this.ptyManager.on( + 'sessionExited', + (sessionId: string, sessionName: string, exitCode?: number) => { + this.handleSessionExited(sessionId, sessionName, exitCode); + } + ); + + // Listen for command tracking events + this.ptyManager.on('commandFinished', (data: CommandFinishedEvent) => { + this.handleCommandFinished(data); + }); + + // Listen for Claude activity events (if available) + this.ptyManager.on('claudeTurn', (sessionId: string, sessionName: string) => { + this.handleClaudeTurn(sessionId, sessionName); + }); + } + + /** + * Update session state with activity information + */ + public updateSessionActivity(sessionId: string, isActive: boolean, specificApp?: string) { + const session = this.sessions.get(sessionId); + if (!session) return; + + const previousActive = session.activityStatus?.isActive ?? false; + + // Update activity status + session.activityStatus = { + isActive, + lastActivity: isActive ? new Date() : session.activityStatus?.lastActivity, + specificStatus: specificApp + ? { + app: specificApp, + status: isActive ? 'active' : 'idle', + } + : session.activityStatus?.specificStatus, + }; + + // Check if this is a Claude session + if (this.isClaudeSession(session)) { + this.trackClaudeActivity(sessionId, session, previousActive, isActive); + } + + this.lastActivityState.set(sessionId, isActive); + } + + /** + * Track PTY output for activity detection and bell characters + */ + public trackPtyOutput(sessionId: string, data: string) { + const session = this.sessions.get(sessionId); + if (!session) return; + + // Update last activity + this.updateSessionActivity(sessionId, true); + + // Detect bell character + if (data.includes('\x07')) { + this.emitNotificationEvent({ + type: 'bell', + sessionId, + sessionName: session.name, + timestamp: new Date().toISOString(), + }); + } + + // Detect Claude-specific patterns in output + if (this.isClaudeSession(session)) { + this.detectClaudePatterns(sessionId, session, data); + } + } + + /** + * Emit notification event to both local listeners and Mac app via Unix socket + */ + private emitNotificationEvent(event: SessionMonitorEvent) { + // Emit locally for web clients (via SSE) + this.emit('notification', { + type: this.mapActionToServerEventType(event.type), + sessionId: event.sessionId, + sessionName: event.sessionName, + timestamp: event.timestamp, + exitCode: event.exitCode, + command: event.command, + duration: event.duration, + message: event.type === 'claude-turn' ? 'Claude has finished responding' : undefined, + }); + + // Send to Mac via Unix socket + if (this.controlHandler?.isMacAppConnected()) { + const message = createControlEvent('session-monitor', event.type, event); + this.controlHandler.sendToMac(message); + logger.debug(`Sent ${event.type} notification to Mac app via Unix socket`); + } + } + + /** + * Map session monitor action to ServerEventType + */ + private mapActionToServerEventType(action: SessionMonitorEvent['type']): ServerEventType { + const mapping = { + 'session-start': ServerEventType.SessionStart, + 'session-exit': ServerEventType.SessionExit, + 'command-finished': ServerEventType.CommandFinished, + 'command-error': ServerEventType.CommandError, + bell: ServerEventType.Bell, + 'claude-turn': ServerEventType.ClaudeTurn, + }; + return mapping[action]; + } + + /** + * Update command information for a session + */ + public updateCommand(sessionId: string, command: string) { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.lastCommand = command; + session.commandStartTime = new Date(); + + // Mark as active when a new command starts + this.updateSessionActivity(sessionId, true); + } + + /** + * Handle command completion + */ + public handleCommandCompletion(sessionId: string, exitCode: number) { + const session = this.sessions.get(sessionId); + if (!session || !session.commandStartTime || !session.lastCommand) return; + + const duration = Date.now() - session.commandStartTime.getTime(); + session.lastExitCode = exitCode; + + // Only emit event if command ran long enough + if (duration >= this.commandThresholdMs) { + const _event: CommandFinishedEvent = { + sessionId, + sessionName: session.name, + command: session.lastCommand, + duration, + exitCode, + }; + + // Emit appropriate event based on exit code + if (exitCode === 0) { + this.emitNotificationEvent({ + type: 'command-finished', + sessionId, + sessionName: session.name, + command: session.lastCommand, + duration, + exitCode, + timestamp: new Date().toISOString(), + }); + } else { + this.emitNotificationEvent({ + type: 'command-error', + sessionId, + sessionName: session.name, + command: session.lastCommand, + duration, + exitCode, + timestamp: new Date().toISOString(), + }); + } + } + + // Clear command tracking + session.commandStartTime = undefined; + session.lastCommand = undefined; + } + + private handleSessionStarted(sessionId: string, sessionName: string) { + // Get full session info from PtyManager + const ptySession = this.ptyManager.getSession(sessionId); + if (!ptySession) return; + + const state: SessionState = { + id: sessionId, + name: sessionName, + command: ptySession.command || [], + workingDir: ptySession.workingDir || process.cwd(), + status: 'running', + isRunning: true, + pid: ptySession.pid, + isClaudeSession: this.detectClaudeCommand(ptySession.command || []), + }; + + this.sessions.set(sessionId, state); + logger.info(`Session started: ${sessionId} - ${sessionName}`); + + // Emit notification event + this.emitNotificationEvent({ + type: 'session-start', + sessionId, + sessionName, + timestamp: new Date().toISOString(), + }); + } + + private handleSessionExited(sessionId: string, sessionName: string, exitCode?: number) { + const session = this.sessions.get(sessionId); + if (!session) return; + + session.status = 'exited'; + session.isRunning = false; + + logger.info(`Session exited: ${sessionId} - ${sessionName} (exit code: ${exitCode})`); + + // Clean up Claude tracking + this.claudeIdleNotified.delete(sessionId); + this.lastActivityState.delete(sessionId); + if (this.claudeIdleTimers.has(sessionId)) { + const timer = this.claudeIdleTimers.get(sessionId); + if (timer) clearTimeout(timer); + this.claudeIdleTimers.delete(sessionId); + } + + // Emit notification event + this.emitNotificationEvent({ + type: 'session-exit', + sessionId, + sessionName, + exitCode, + timestamp: new Date().toISOString(), + }); + + // Remove session after a delay to allow final events to process + setTimeout(() => { + this.sessions.delete(sessionId); + }, 5000); + } + + private handleCommandFinished(data: CommandFinishedEvent) { + // Forward to our handler which will emit the appropriate notification + this.handleCommandCompletion(data.sessionId, data.exitCode); + } + + private handleClaudeTurn(sessionId: string, _sessionName: string) { + const session = this.sessions.get(sessionId); + if (!session) return; + + // Mark Claude as idle + this.updateSessionActivity(sessionId, false, 'claude'); + } + + private isClaudeSession(session: SessionState): boolean { + return session.isClaudeSession ?? false; + } + + private detectClaudeCommand(command: string[]): boolean { + const commandStr = command.join(' ').toLowerCase(); + return commandStr.includes('claude'); + } + + private trackClaudeActivity( + sessionId: string, + session: SessionState, + previousActive: boolean, + currentActive: boolean + ) { + // Clear any existing idle timer + if (this.claudeIdleTimers.has(sessionId)) { + const timer = this.claudeIdleTimers.get(sessionId); + if (timer) clearTimeout(timer); + this.claudeIdleTimers.delete(sessionId); + } + + // Claude went from active to potentially idle + if (previousActive && !currentActive && !this.claudeIdleNotified.has(sessionId)) { + // Set a debounce timer before declaring Claude idle + const timer = setTimeout(() => { + // Check if still idle + const currentSession = this.sessions.get(sessionId); + if (currentSession?.activityStatus && !currentSession.activityStatus.isActive) { + logger.info(`🔔 Claude turn detected for session: ${sessionId}`); + + this.emitNotificationEvent({ + type: 'claude-turn', + sessionId, + sessionName: session.name, + timestamp: new Date().toISOString(), + activityStatus: { + isActive: false, + app: 'claude', + }, + }); + + this.claudeIdleNotified.add(sessionId); + } + + this.claudeIdleTimers.delete(sessionId); + }, CLAUDE_IDLE_DEBOUNCE_MS); + + this.claudeIdleTimers.set(sessionId, timer); + } + + // Claude became active again - reset notification flag + if (!previousActive && currentActive) { + this.claudeIdleNotified.delete(sessionId); + } + } + + private detectClaudePatterns(sessionId: string, _session: SessionState, data: string) { + // Detect patterns that indicate Claude is working or has finished + const workingPatterns = ['Thinking...', 'Analyzing', 'Working on', 'Let me']; + + const idlePatterns = [ + "I've completed", + "I've finished", + 'Done!', + "Here's", + 'The task is complete', + ]; + + // Check for working patterns + for (const pattern of workingPatterns) { + if (data.includes(pattern)) { + this.updateSessionActivity(sessionId, true, 'claude'); + return; + } + } + + // Check for idle patterns + for (const pattern of idlePatterns) { + if (data.includes(pattern)) { + // Delay marking as idle to allow for follow-up output + setTimeout(() => { + this.updateSessionActivity(sessionId, false, 'claude'); + }, 1000); + return; + } + } + } + + /** + * Get all active sessions + */ + public getActiveSessions(): SessionState[] { + return Array.from(this.sessions.values()).filter((s) => s.isRunning); + } + + /** + * Get a specific session + */ + public getSession(sessionId: string): SessionState | undefined { + return this.sessions.get(sessionId); + } + + /** + * Initialize monitor with existing sessions + */ + public async initialize() { + // Get all existing sessions from PtyManager + const existingSessions = await this.ptyManager.listSessions(); + + for (const session of existingSessions) { + if (session.status === 'running') { + const state: SessionState = { + id: session.id, + name: session.name, + command: session.command, + workingDir: session.workingDir, + status: 'running', + isRunning: true, + pid: session.pid, + isClaudeSession: this.detectClaudeCommand(session.command), + }; + + this.sessions.set(session.id, state); + } + } + + logger.info(`Initialized with ${this.sessions.size} existing sessions`); + } +} diff --git a/web/src/server/websocket/control-protocol.ts b/web/src/server/websocket/control-protocol.ts index 715dcc1e..0d194c28 100644 --- a/web/src/server/websocket/control-protocol.ts +++ b/web/src/server/websocket/control-protocol.ts @@ -3,7 +3,7 @@ */ export type ControlMessageType = 'request' | 'response' | 'event'; -export type ControlCategory = 'terminal' | 'git' | 'system' | 'notification'; +export type ControlCategory = 'terminal' | 'git' | 'system' | 'notification' | 'session-monitor'; export interface ControlMessage { id: string; @@ -52,6 +52,31 @@ export interface SystemPingResponse { timestamp: number; } +// Session monitor event types +export type SessionMonitorAction = + | 'session-start' + | 'session-exit' + | 'command-finished' + | 'command-error' + | 'bell' + | 'claude-turn'; + +export interface SessionMonitorEvent { + type: SessionMonitorAction; + sessionId: string; + sessionName: string; + timestamp: string; + + // Event-specific fields + exitCode?: number; + command?: string; + duration?: number; + activityStatus?: { + isActive: boolean; + app?: string; + }; +} + // Helper to create control messages export function createControlMessage( category: ControlCategory,