UNPKG

@nanocollective/nanocoder

Version:

A local-first CLI coding agent that brings the power of agentic coding tools like Claude Code and Gemini CLI to local models or controlled APIs like OpenRouter

402 lines 12.2 kB
/** * WebSocket server for VS Code extension communication */ import { readFile } from 'node:fs/promises'; import { randomUUID } from 'crypto'; import { WebSocket, WebSocketServer } from 'ws'; import { BoundedMap } from '../utils/bounded-map.js'; import { formatError } from '../utils/error-formatter.js'; import { getLogger } from '../utils/logging/index.js'; import { getShutdownManager } from '../utils/shutdown/index.js'; import { DEFAULT_PORT, PROTOCOL_VERSION, } from './protocol.js'; let cachedCliVersion = null; async function getCliVersion() { if (cachedCliVersion) { return cachedCliVersion; } try { const content = await readFile(new URL('../../package.json', import.meta.url), 'utf-8'); const packageJson = JSON.parse(content); cachedCliVersion = packageJson.version ?? '0.0.0'; return cachedCliVersion; } catch (error) { console.warn('Failed to load CLI version from package.json:', error); cachedCliVersion = '0.0.0'; return cachedCliVersion; } } export class VSCodeServer { port; wss = null; clients = new Set(); pendingChanges = new BoundedMap({ maxSize: 1000, ttl: 30 * 60 * 1000, // 30 minutes }); callbacks = {}; currentModel; currentProvider; cliVersion = '0.0.0'; constructor(port = DEFAULT_PORT) { this.port = port; } /** * Get the actual port the server is listening on */ getPort() { return this.port; } /** * Try to start the WebSocket server on a specific port */ async tryStartOnPort(port) { return new Promise(resolve => { try { const wss = new WebSocketServer({ port, host: '127.0.0.1', // Only accept local connections }); wss.on('listening', () => { this.wss = wss; this.port = port; this.wss.on('connection', ws => { this.handleConnection(ws); }); resolve(true); }); wss.on('error', _error => { wss.close(); resolve(false); }); } catch (_error) { resolve(false); } }); } /** * Start the WebSocket server with automatic port fallback * If the requested port is in use, tries up to 10 alternative ports */ async start() { this.cliVersion = await getCliVersion(); const logger = getLogger(); const requestedPort = this.port; const maxRetries = 10; // Try the requested port first const success = await this.tryStartOnPort(requestedPort); if (success) { logger.info(`VS Code server listening on port ${this.port}`); return true; } // If failed, try alternative ports logger.warn(`Port ${requestedPort} is in use, trying alternative ports...`); for (let i = 1; i <= maxRetries; i++) { const alternativePort = requestedPort + i; const success = await this.tryStartOnPort(alternativePort); if (success) { logger.info(`VS Code server listening on port ${this.port} (requested ${requestedPort} was in use)`); return true; } } // All ports failed logger.error(`Failed to start VS Code server. Tried ports ${requestedPort}-${requestedPort + maxRetries}`); console.error(`[VS Code] Could not start server. Ports ${requestedPort}-${requestedPort + maxRetries} are all in use.`); console.error('[VS Code] Try closing other nanocoder instances or VS Code windows.'); return false; } /** * Stop the WebSocket server */ async stop() { // Close all client connections for (const client of this.clients) { client.close(); } this.clients.clear(); // Close server return new Promise(resolve => { if (this.wss) { this.wss.close(() => { this.wss = null; resolve(); }); } else { resolve(); } }); } /** * Register callbacks for client messages */ onCallbacks(callbacks) { this.callbacks = { ...this.callbacks, ...callbacks }; } /** * Check if any clients are connected */ hasConnections() { return this.clients.size > 0; } /** * Get number of connected clients */ getConnectionCount() { return this.clients.size; } /** * Send a file change notification to VS Code */ sendFileChange(filePath, originalContent, newContent, toolName, toolArgs) { const id = randomUUID(); // Store pending change this.pendingChanges.set(id, { id, filePath, originalContent, newContent, toolName, timestamp: Date.now(), }); const message = { type: 'file_change', id, filePath, originalContent, newContent, toolName, toolArgs, }; this.broadcast(message); return id; } /** * Send an assistant message to VS Code */ sendAssistantMessage(content, isGenerating = false) { const message = { type: 'assistant_message', content, isGenerating, }; this.broadcast(message); } /** * Send status update to VS Code */ sendStatus(model, provider) { this.currentModel = model; this.currentProvider = provider; const message = { type: 'status', connected: true, model, provider, workingDirectory: process.cwd(), }; this.broadcast(message); } /** * Request diagnostics from VS Code */ requestDiagnostics(filePath) { const message = { type: 'diagnostics_request', filePath, }; this.broadcast(message); } /** * Close diff preview in VS Code (when tool is confirmed/rejected in CLI) */ closeDiff(id) { const message = { type: 'close_diff', id, }; this.broadcast(message); // Also remove from pending changes this.pendingChanges.delete(id); } /** * Close all pending diff previews */ closeAllDiffs() { const pendingIds = Array.from(this.pendingChanges.keys()); for (const id of pendingIds) { this.closeDiff(id); } } /** * Open a file in VS Code editor */ openFileInVSCode(filePath) { const message = { type: 'open_file', filePath, }; this.broadcast(message); } /** * Get a pending change by ID */ getPendingChange(id) { return this.pendingChanges.get(id); } /** * Remove a pending change */ removePendingChange(id) { this.pendingChanges.delete(id); } /** * Get all pending changes */ getAllPendingChanges() { return Array.from(this.pendingChanges.values()); } handleConnection(ws) { this.clients.add(ws); // Send connection acknowledgment const ack = { type: 'connection_ack', protocolVersion: PROTOCOL_VERSION, cliVersion: this.cliVersion, }; ws.send(JSON.stringify(ack)); // Send current status if (this.currentModel || this.currentProvider) { this.sendStatus(this.currentModel, this.currentProvider); } // Notify callback this.callbacks.onConnect?.(); ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.handleMessage(message); } catch (error) { const logger = getLogger(); logger.error({ error: formatError(error) }, 'Failed to parse message from VS Code'); } }); ws.on('close', () => { this.clients.delete(ws); this.callbacks.onDisconnect?.(); }); ws.on('error', _error => { this.clients.delete(ws); }); } handleMessage(message) { switch (message.type) { case 'send_prompt': this.callbacks.onPrompt?.(message.prompt, message.context); break; case 'apply_change': this.pendingChanges.delete(message.id); this.callbacks.onChangeApplied?.(message.id); break; case 'reject_change': this.pendingChanges.delete(message.id); this.callbacks.onChangeRejected?.(message.id); break; case 'get_status': this.sendStatus(this.currentModel, this.currentProvider); break; case 'context': this.callbacks.onContext?.({ workspaceFolder: message.workspaceFolder, openFiles: message.openFiles, activeFile: message.activeFile, diagnostics: message.diagnostics, }); break; case 'diagnostics_response': this.callbacks.onDiagnosticsResponse?.(message.diagnostics); break; } } broadcast(message) { const data = JSON.stringify(message); for (const client of this.clients) { if (client.readyState === WebSocket.OPEN) { client.send(data); } } } } // Singleton instance for global access let serverInstance = null; let serverInitPromise = null; /** * Get or create the VS Code server singleton * Uses promise-based initialization to prevent race conditions */ export async function getVSCodeServer(port) { if (serverInstance) { return serverInstance; } if (serverInitPromise) { return serverInitPromise; } // Create server synchronously to ensure serverInstance is set immediately // This is important for synchronous functions like sendFileChangeToVSCode serverInstance = new VSCodeServer(port); serverInitPromise = Promise.resolve(serverInstance); getShutdownManager().register({ name: 'vscode-server', priority: 10, handler: async () => { if (serverInstance) { await serverInstance.stop(); } }, }); return serverInitPromise; } /** * Get the VS Code server instance if it exists (synchronous) * Returns null if not yet initialized * Use this when you need synchronous access and the server may not be initialized */ export function getVSCodeServerSync() { return serverInstance; } /** * Check if VS Code server is active and has connections */ export function isVSCodeConnected() { return serverInstance?.hasConnections() ?? false; } /** * Send a file change to VS Code for preview/approval * This is the main entry point for tools to integrate with VS Code */ export function sendFileChangeToVSCode(filePath, originalContent, newContent, toolName, toolArgs) { if (!serverInstance?.hasConnections()) { return null; } return serverInstance.sendFileChange(filePath, originalContent, newContent, toolName, toolArgs); } /** * Close a diff preview in VS Code (when tool confirmed/rejected in CLI) */ export function closeDiffInVSCode(id) { if (!id || !serverInstance?.hasConnections()) { return; } serverInstance.closeDiff(id); } /** * Close all pending diff previews in VS Code */ export function closeAllDiffsInVSCode() { if (!serverInstance?.hasConnections()) { return; } serverInstance.closeAllDiffs(); } //# sourceMappingURL=vscode-server.js.map