@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
JavaScript
/**
* 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