import * as plugins from './plugins.js'; import * as paths from './paths.js'; import type { IpcMethodMap, RequestForMethod, ResponseForMethod, } from './ipc.types.js'; /** * IPC client for communicating with the TSPM daemon */ export class TspmIpcClient { private ipcClient: plugins.smartipc.IpcClient | null = null; private socketPath: string; private daemonPidFile: string; private isConnected: boolean = false; constructor() { this.socketPath = plugins.path.join(paths.tspmDir, 'tspm.sock'); this.daemonPidFile = plugins.path.join(paths.tspmDir, 'daemon.pid'); } /** * Connect to the daemon, starting it if necessary */ public async connect(): Promise { // Check if already connected if (this.isConnected && this.ipcClient) { return; } // Check if daemon is running const daemonRunning = await this.isDaemonRunning(); if (!daemonRunning) { throw new Error( 'TSPM daemon is not running.\n\n' + 'To start the daemon, run one of:\n' + ' tspm daemon start - Start daemon for this session\n' + ' tspm enable - Enable daemon as system service (recommended)\n' ); } // Create IPC client this.ipcClient = plugins.smartipc.SmartIpc.createClient({ id: 'tspm-cli', socketPath: this.socketPath, clientId: `cli-${process.pid}`, connectRetry: { enabled: true, initialDelay: 100, maxDelay: 2000, maxAttempts: 30, totalTimeout: 15000, }, registerTimeoutMs: 8000, heartbeat: true, heartbeatInterval: 5000, heartbeatTimeout: 20000, heartbeatInitialGracePeriodMs: 10000, heartbeatThrowOnTimeout: false // Don't throw, emit events instead }); // Connect to the daemon try { await this.ipcClient.connect({ waitForReady: true }); this.isConnected = true; // Handle heartbeat timeouts gracefully this.ipcClient.on('heartbeatTimeout', () => { console.warn('Heartbeat timeout detected, connection may be degraded'); this.isConnected = false; }); console.log('Connected to TSPM daemon'); } catch (error) { console.error('Failed to connect to daemon:', error); throw new Error( 'Could not connect to TSPM daemon. Please try running "tspm daemon start" or "tspm enable".', ); } } /** * Disconnect from the daemon */ public async disconnect(): Promise { if (this.ipcClient) { await this.ipcClient.disconnect(); this.ipcClient = null; this.isConnected = false; } } /** * Send a request to the daemon */ public async request( method: M, params: RequestForMethod, ): Promise> { if (!this.isConnected || !this.ipcClient) { throw new Error( 'Not connected to TSPM daemon.\n' + 'Run "tspm daemon start" or "tspm enable" first.' ); } try { const response = await this.ipcClient!.request< RequestForMethod, ResponseForMethod >(method, params); return response; } catch (error) { // Don't try to auto-reconnect, just throw the error throw error; } } /** * Subscribe to log updates for a specific process */ public async subscribe(processId: string, handler: (log: any) => void): Promise { if (!this.ipcClient || !this.isConnected) { throw new Error('Not connected to daemon'); } const topic = `logs.${processId}`; await this.ipcClient.subscribe(`topic:${topic}`, handler); } /** * Unsubscribe from log updates for a specific process */ public async unsubscribe(processId: string): Promise { if (!this.ipcClient || !this.isConnected) { throw new Error('Not connected to daemon'); } const topic = `logs.${processId}`; await this.ipcClient.unsubscribe(`topic:${topic}`); } /** * Check if the daemon is running */ private async isDaemonRunning(): Promise { try { const fs = await import('fs'); // Check if PID file exists try { const pidContent = await fs.promises.readFile( this.daemonPidFile, 'utf-8', ); const pid = parseInt(pidContent.trim(), 10); // Check if process is running try { process.kill(pid, 0); // Also check if socket exists and is accessible try { await fs.promises.access(this.socketPath); return true; } catch { // Socket doesn't exist, daemon might be starting return false; } } catch { // Process doesn't exist, clean up stale PID file await fs.promises.unlink(this.daemonPidFile).catch(() => {}); return false; } } catch { // PID file doesn't exist return false; } } catch { return false; } } /** * Stop the daemon */ public async stopDaemon(graceful: boolean = true): Promise { if (!(await this.isDaemonRunning())) { console.log('Daemon is not running'); return; } try { await this.connect(); await this.request('daemon:shutdown', { graceful, timeout: 10000, }); console.log('Daemon shutdown initiated'); // Wait for daemon to actually stop const maxWaitTime = 15000; // 15 seconds const startTime = Date.now(); while (Date.now() - startTime < maxWaitTime) { if (!(await this.isDaemonRunning())) { console.log('Daemon stopped successfully'); return; } await new Promise((resolve) => setTimeout(resolve, 500)); } console.warn( 'Daemon did not stop within timeout, it may still be running', ); } catch (error) { console.error('Error stopping daemon:', error); // Try to kill the process directly if graceful shutdown failed try { const fs = await import('fs'); const pidContent = await fs.promises.readFile( this.daemonPidFile, 'utf-8', ); const pid = parseInt(pidContent.trim(), 10); process.kill(pid, 'SIGKILL'); console.log('Force killed daemon process'); } catch { console.error('Could not force kill daemon'); } } } /** * Get daemon status */ public async getDaemonStatus(): Promise | null> { try { if (!(await this.isDaemonRunning())) { return null; } await this.connect(); return await this.request('daemon:status', {}); } catch (error) { console.error('Error getting daemon status:', error); return null; } } } // Singleton instance export const tspmIpcClient = new TspmIpcClient();