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 system
|
||||
case notification
|
||||
case sessionMonitor = "session-monitor"
|
||||
}
|
||||
|
||||
// MARK: - Base message for runtime dispatch
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> = []
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<AppInstance> {
|
|||
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<AppInstance> {
|
|||
}
|
||||
|
||||
// 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
|
||||
|
|
|
|||
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 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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue