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:
Peter Steinberger 2025-07-28 14:35:29 +02:00
parent a5741a56bb
commit a53091d04b
7 changed files with 713 additions and 118 deletions

View 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

View file

@ -15,6 +15,7 @@ enum ControlProtocol {
case git
case system
case notification
case sessionMonitor = "session-monitor"
}
// MARK: - Base message for runtime dispatch

View file

@ -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
}
logger.info("NotificationControlHandler initialized")
// 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 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

View file

@ -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)
}
}
}
}

View file

@ -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

View 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`);
}
}

View file

@ -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,