mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Implement unified notification system via Unix socket
- 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.
This commit is contained in:
parent
a5741a56bb
commit
a53091d04b
7 changed files with 713 additions and 118 deletions
101
docs/unified-notifications-test-plan.md
Normal file
101
docs/unified-notifications-test-plan.md
Normal file
|
|
@ -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
|
||||||
|
|
@ -15,6 +15,7 @@ enum ControlProtocol {
|
||||||
case git
|
case git
|
||||||
case system
|
case system
|
||||||
case notification
|
case notification
|
||||||
|
case sessionMonitor = "session-monitor"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Base message for runtime dispatch
|
// MARK: - Base message for runtime dispatch
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,19 @@ final class NotificationControlHandler {
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
private init() {
|
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
|
SharedUnixSocketManager.shared.registerControlHandler(for: .notification) { [weak self] data in
|
||||||
_ = await self?.handleMessage(data)
|
_ = await self?.handleMessage(data)
|
||||||
return nil // No response needed for notifications
|
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
|
// MARK: - Message Handling
|
||||||
|
|
@ -91,6 +97,104 @@ final class NotificationControlHandler {
|
||||||
|
|
||||||
return nil
|
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
|
// MARK: - Supporting Types
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import Observation
|
import Observation
|
||||||
import os.log
|
import os.log
|
||||||
import UserNotifications
|
|
||||||
|
|
||||||
/// Server session information returned by the API.
|
/// Server session information returned by the API.
|
||||||
///
|
///
|
||||||
|
|
@ -75,11 +74,6 @@ final class SessionMonitor {
|
||||||
private var previousSessions: [String: ServerSessionInfo] = [:]
|
private var previousSessions: [String: ServerSessionInfo] = [:]
|
||||||
private var firstFetchDone = false
|
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<String> = []
|
|
||||||
|
|
||||||
/// Detect sessions that transitioned from running to not running
|
/// Detect sessions that transitioned from running to not running
|
||||||
static func detectEndedSessions(
|
static func detectEndedSessions(
|
||||||
from old: [String: ServerSessionInfo],
|
from old: [String: ServerSessionInfo],
|
||||||
|
|
@ -108,13 +102,7 @@ final class SessionMonitor {
|
||||||
/// Reference to GitRepositoryMonitor for pre-caching
|
/// Reference to GitRepositoryMonitor for pre-caching
|
||||||
weak var gitRepositoryMonitor: GitRepositoryMonitor?
|
weak var gitRepositoryMonitor: GitRepositoryMonitor?
|
||||||
|
|
||||||
/// Timer for periodic refresh
|
private init() {}
|
||||||
private var refreshTimer: Timer?
|
|
||||||
|
|
||||||
private init() {
|
|
||||||
// Start periodic refresh
|
|
||||||
startPeriodicRefresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the local auth token for server requests
|
/// Set the local auth token for server requests
|
||||||
func setLocalAuthToken(_ token: String?) {}
|
func setLocalAuthToken(_ token: String?) {}
|
||||||
|
|
@ -146,7 +134,7 @@ final class SessionMonitor {
|
||||||
private func fetchSessions() async {
|
private func fetchSessions() async {
|
||||||
do {
|
do {
|
||||||
// Snapshot previous sessions for exit notifications
|
// Snapshot previous sessions for exit notifications
|
||||||
let oldSessions = sessions
|
let _ = sessions
|
||||||
|
|
||||||
let sessionsArray = try await serverManager.performRequest(
|
let sessionsArray = try await serverManager.performRequest(
|
||||||
endpoint: APIEndpoints.sessions,
|
endpoint: APIEndpoints.sessions,
|
||||||
|
|
@ -163,31 +151,7 @@ final class SessionMonitor {
|
||||||
self.sessions = sessionsDict
|
self.sessions = sessionsDict
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
|
|
||||||
// Notify for sessions that have just ended
|
// Sessions have been updated
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set firstFetchDone AFTER detecting ended sessions
|
// Set firstFetchDone AFTER detecting ended sessions
|
||||||
firstFetchDone = true
|
firstFetchDone = true
|
||||||
|
|
@ -269,78 +233,4 @@ final class SessionMonitor {
|
||||||
"Pre-caching Git data for \(uniqueDirectoriesToCheck.count) unique directories (from \(sessions.count) sessions)"
|
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -38,6 +38,7 @@ import { HQClient } from './services/hq-client.js';
|
||||||
import { mdnsService } from './services/mdns-service.js';
|
import { mdnsService } from './services/mdns-service.js';
|
||||||
import { PushNotificationService } from './services/push-notification-service.js';
|
import { PushNotificationService } from './services/push-notification-service.js';
|
||||||
import { RemoteRegistry } from './services/remote-registry.js';
|
import { RemoteRegistry } from './services/remote-registry.js';
|
||||||
|
import { SessionMonitor } from './services/session-monitor.js';
|
||||||
import { StreamWatcher } from './services/stream-watcher.js';
|
import { StreamWatcher } from './services/stream-watcher.js';
|
||||||
import { TerminalManager } from './services/terminal-manager.js';
|
import { TerminalManager } from './services/terminal-manager.js';
|
||||||
import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.js';
|
import { closeLogger, createLogger, initLogger, setDebugMode } from './utils/logger.js';
|
||||||
|
|
@ -458,6 +459,14 @@ export async function createApp(): Promise<AppInstance> {
|
||||||
const streamWatcher = new StreamWatcher(sessionManager);
|
const streamWatcher = new StreamWatcher(sessionManager);
|
||||||
logger.debug('Initialized stream watcher');
|
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
|
// Initialize activity monitor
|
||||||
const activityMonitor = new ActivityMonitor(CONTROL_DIR);
|
const activityMonitor = new ActivityMonitor(CONTROL_DIR);
|
||||||
logger.debug('Initialized activity monitor');
|
logger.debug('Initialized activity monitor');
|
||||||
|
|
@ -905,7 +914,7 @@ export async function createApp(): Promise<AppInstance> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mount events router for SSE streaming
|
// Mount events router for SSE streaming
|
||||||
app.use('/api', createEventsRouter(ptyManager));
|
app.use('/api', createEventsRouter(ptyManager, sessionMonitor));
|
||||||
logger.debug('Mounted events routes');
|
logger.debug('Mounted events routes');
|
||||||
|
|
||||||
// Initialize control socket
|
// Initialize control socket
|
||||||
|
|
|
||||||
465
web/src/server/services/session-monitor.ts
Normal file
465
web/src/server/services/session-monitor.ts
Normal file
|
|
@ -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<string, SessionState>();
|
||||||
|
private claudeIdleNotified = new Set<string>();
|
||||||
|
private lastActivityState = new Map<string, boolean>();
|
||||||
|
private commandThresholdMs = MIN_COMMAND_DURATION_MS;
|
||||||
|
private claudeIdleTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
|
||||||
|
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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ControlMessageType = 'request' | 'response' | 'event';
|
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 {
|
export interface ControlMessage {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -52,6 +52,31 @@ export interface SystemPingResponse {
|
||||||
timestamp: number;
|
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
|
// Helper to create control messages
|
||||||
export function createControlMessage(
|
export function createControlMessage(
|
||||||
category: ControlCategory,
|
category: ControlCategory,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue