services/lsp/ — Language Server Protocol Service

Code intelligence via Language Server Protocol

Manages multiple language server processes, provides go-to-definition, find-references, hover info, and real-time diagnostics. Uses a factory-function architecture with closures for state management, routing requests to the correct server based on file extensions. The eyes and ears that let Claude Code understand your codebase.

7
Files
~2.5 k
Lines
4
LSP Ops
30
Max Diagnostics

📚 What is this & why does it exist?

The LSP service gives Claude Code the ability to understand code structurally, not just as text. When you ask "where is this function defined?" or "what calls this method?", the LSP service routes that question to the right language server (TypeScript, Python, Rust, etc.) and returns precise, compiler-grade answers. It also passively collects diagnostics — errors and warnings — so Claude can proactively notice when its edits break something.

💡 Real-world analogy

Think of a large hospital with specialist departments — cardiology, neurology, orthopedics. The LSP Manager is the front-desk receptionist who knows which department handles which condition. When a patient (file) arrives, the receptionist checks their symptoms (file extension), routes them to the right specialist (language server), and makes sure the specialist is staffed and ready. Each specialist can diagnose problems (diagnostics), point to related cases (references), and explain medical history (hover info). If a specialist crashes, the receptionist tries to restart them up to three times before giving up.

Contents

Architecture

flowchart TD
  MGR["manager.ts\nSingleton Lifecycle"] -->|creates| SM["LSPServerManager\n(createLSPServerManager)"]
  CFG["config.ts\nPlugin Config Loader"] -->|server configs| SM
  SM -->|per-language| SI["LSPServerInstance\n(createLSPServerInstance)"]
  SI -->|spawns| CL["LSPClient\n(createLSPClient)"]
  CL -->|JSON-RPC| LS["Language Server\n(external process)"]
  LS -->|publishDiagnostics| PF["passiveFeedback.ts\nNotification Handler"]
  PF -->|registers| DR["LSPDiagnosticRegistry\nDedup & Volume Limit"]
  SM -->|extension routing| EM["Extension Map\next → server name"]
  SM -->|file sync| OF["Open Files Map\nfileUri → serverName"]

Server Instance State Machine

stateDiagram-v2
  [*] --> stopped
  stopped --> starting : start()
  starting --> running : initialize OK
  starting --> error : init failed
  error --> starting : restart (count < maxRestarts)
  error --> stopped : max restarts exceeded
  running --> stopping : stop()
  running --> error : crash detected
  stopping --> stopped : cleanup complete
  stopped --> [*]

Key Classes & Functions

Class / FunctionFilePurpose
createLSPClient()LSPClient.tsFactory creating JSON-RPC client wrapper over a spawned language server process
createLSPServerInstance()LSPServerInstance.tsFactory creating managed server instance with state machine, crash recovery, retry logic
createLSPServerManager()LSPServerManager.tsFactory creating multi-server orchestrator with file routing and synchronization
registerLSPNotificationHandlers()passiveFeedback.tsRegisters textDocument/publishDiagnostics handlers on all servers
formatDiagnosticsForAttachment()passiveFeedback.tsConverts LSP diagnostic params to Claude-compatible attachment format
registerPendingLSPDiagnostic()LSPDiagnosticRegistry.tsQueues a diagnostic notification for deduplication and delivery
checkForLSPDiagnostics()LSPDiagnosticRegistry.tsRetrieves deduplicated diagnostics with volume limiting (10/file, 30 total)
initializeLspServerManager()manager.tsCreates and initializes the singleton manager (non-blocking, skips in bare mode)
reinitializeLspServerManager()manager.tsForce re-init after plugin cache refresh; uses generation counter for stale invalidation
getLspServerManager()manager.tsReturns singleton LSPServerManager | undefined
getAllLspServers()config.tsLoads LSP server configs from all enabled plugins in parallel
isLspConnected()manager.tsReturns true if at least one language server is healthy
waitForInitialization()manager.tsPromise that resolves when LSP initialization completes (success or failure)
getInitializationStatus()manager.tsReturns current state: 'not-started' | 'pending' | 'success' | 'failed'

Data Structures

TypeNameDescription
typeLSPClientInterface: capabilities, isInitialized, start(), initialize(), sendRequest<T>(), sendNotification(), onNotification(), stop()
typeLSPServerInstanceInterface: name, config, state, startTime, lastError, restartCount, lifecycle methods
typeLSPServerManagerInterface: initialize(), shutdown(), getServerForFile(), sendRequest<T>(), openFile(), changeFile(), saveFile(), closeFile()
typePendingLSPDiagnostic{ serverName, files: DiagnosticFile[], timestamp, attachmentSent }
typeHandlerRegistrationResult{ totalServers, successCount, registrationErrors, diagnosticFailures }
typeInitializationState'not-started' | 'pending' | 'success' | 'failed'
typeScopedLspServerConfigServer config with command, args, env, extensionToLanguage, initializationOptions
MapserversMap<string, LSPServerInstance> — registered server instances
MapextensionMapMap<string, string[]> — file extension → server name routing
MapopenedFilesMap<string, string> — fileUri → serverName tracking for sync
MappendingDiagnosticsMap<string, PendingLSPDiagnostic> — UUID-keyed diagnostic queue
LRUCachedeliveredDiagnosticsLRUCache<string, Set<string>> — cross-turn dedup (max 500 files)

Interactions

DirectionPartnerInterfaceDescription
→Language ServersJSON-RPC (stdio)LSP protocol: initialize, textDocument/*, workspace/* requests
←Language ServerstextDocument/publishDiagnosticsPassive diagnostic notifications pushed from servers
←LSPToolsendRequest()Agent tools invoke go-to-definition, find-references, hover
←Plugin SystemgetPluginLspServers()Plugins register language server configs
→Agent Message LoopcheckForLSPDiagnostics()Diagnostics attached to agent messages for proactive error awareness
↔File SystemdidOpen / didChange / didSaveFile synchronization protocol keeps servers in sync with edits
←Language Serversworkspace/configurationServer requests handled by returning null config items

Configuration

Knob / ParameterDefaultDescription
command—LSP server executable path
args[]Command-line arguments passed to server
env{}Environment variable overrides for server process
extensionToLanguage—Map of file extensions to LSP language IDs (e.g., .ts → typescript)
initializationOptions{}Server-specific initialization parameters
startupTimeout—Initialization timeout in milliseconds
maxRestarts3Maximum crash recovery attempts before giving up
MAX_RETRIES_FOR_TRANSIENT_ERRORS3Retry attempts for ContentModified (-32801) errors
RETRY_BASE_DELAY_MS500Base exponential backoff delay for transient retries
MAX_DIAGNOSTICS_PER_FILE10Volume cap per file in diagnostic delivery
MAX_TOTAL_DIAGNOSTICS30Volume cap across all files in a single delivery
MAX_DELIVERED_FILES500LRU cache size for cross-turn diagnostic deduplication

Directory Map

Source tree for lsp
lsp/
├── LSPClient.ts               ← JSON-RPC client wrapper (448 lines)
├── LSPDiagnosticRegistry.ts   ← Diagnostic dedup & volume limiting (387 lines)
├── LSPServerInstance.ts       ← Single server lifecycle & state machine (512 lines)
├── LSPServerManager.ts        ← Multi-server orchestration & file routing (421 lines)
├── config.ts                  ← Plugin-based config loading (80 lines)
├── manager.ts                 ← Singleton lifecycle & initialization (290 lines)
└── passiveFeedback.ts         ← Diagnostic notification handlers (329 lines)

Deep-Dive Highlights

Core mechanisms from this sub-module have been extracted into dedicated focus areas. Each topic covers one protocol flow, subsystem, or algorithm in detail.

Deep-Dive Pages

Diagnostic Pipeline Focus

Within-batch and cross-turn deduplication, severity-sorted volume limiting, and LRU delivery tracking.

LSPDiagnosticRegistry + passiveFeedback deep dive

Server Lifecycle & Crash Recovery Focus

State machine transitions, exponential backoff on transient errors, max-restart guard, and generation-based invalidation.

LSPServerInstance + manager deep dive

File Synchronization Protocol Focus

didOpen/didChange/didSave/didClose flow, auto-promotion from change to open, extension-based language ID mapping.

LSPServerManager deep dive

Capabilities Negotiated

During the LSP initialize handshake, the client advertises these capabilities to each language server:

CapabilitySupported FeaturesNotes
textDocument/synchronizationdidOpen, didChange, didSave, didCloseFull content sync (no incremental)
textDocument/publishDiagnosticsrelatedInformation, tagSupport (Unnecessary, Deprecated), codeDescriptionSupportPassive push from server
textDocument/hovermarkdown, plaintext content formatsUsed by LSPTool for type info
textDocument/definitionlinkSupport enabledGo-to-definition navigation
textDocument/referencesBasic find-referencesUsed for call-site analysis
textDocument/documentSymbolhierarchicalDocumentSymbolSupportFile outline / symbol tree
textDocument/callHierarchyIncoming/outgoing call chainsDeep dependency tracing
general/positionEncodingsutf-16Standard LSP encoding

Diagnostic Severity Mapping

LSP numeric severity values are mapped to human-readable strings for Claude's consumption:

// passiveFeedback.ts — mapLSPSeverity()
function mapLSPSeverity(lspSeverity: number | undefined): string {
  switch (lspSeverity) {
    case 1: return 'Error';     // DiagnosticSeverity.Error
    case 2: return 'Warning';   // DiagnosticSeverity.Warning
    case 3: return 'Info';      // DiagnosticSeverity.Information
    case 4: return 'Hint';      // DiagnosticSeverity.Hint
    default: return 'Error';    // Unknown → treat as Error
  }
}
// Diagnostics sorted by severity so Errors are delivered first
// when volume limits truncate results

Code Examples

Example 1: Server Instance with Transient Error Retry

import { createLSPServerInstance } from './LSPServerInstance';

const instance = createLSPServerInstance('typescript', {
  command: 'typescript-language-server',
  args: ['--stdio'],
  extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
  maxRestarts: 3,
});

await instance.start();

// Requests automatically retry on ContentModified (-32801) errors
// with exponential backoff: 500ms, 1000ms, 2000ms
try {
  const result = await instance.sendRequest('textDocument/definition', {
    textDocument: { uri: 'file:///project/src/index.ts' },
    position: { line: 42, character: 10 },
  });
  console.log('Definition at:', result);
} catch (error) {
  // After MAX_RETRIES_FOR_TRANSIENT_ERRORS (3), throws with full context
  console.error(`LSP request failed: ${error.message}`);
}

Example 2: File Synchronization Protocol

import { createLSPServerManager } from './LSPServerManager';

const manager = createLSPServerManager();
await manager.initialize();

// Open a file — auto-routes to the right server via extensionMap
// Sends textDocument/didOpen with languageId from extensionToLanguage
await manager.openFile('src/app.ts', fileContent);

// After editing — sends textDocument/didChange (full content replacement)
// If file wasn't previously opened, auto-promotes to didOpen
await manager.changeFile('src/app.ts', updatedContent);

// After saving — sends textDocument/didSave
await manager.saveFile('src/app.ts');

// Check if file is tracked
console.log(manager.isFileOpen('src/app.ts')); // true

// Close — sends textDocument/didClose and removes from tracking
await manager.closeFile('src/app.ts');

Example 3: Diagnostic Collection with Deduplication

import { registerLSPNotificationHandlers } from './passiveFeedback';
import {
  checkForLSPDiagnostics,
  registerPendingLSPDiagnostic,
} from './LSPDiagnosticRegistry';

// Register handlers on all running servers
const result = registerLSPNotificationHandlers(manager);
console.log(`Registered on ${result.successCount}/${result.totalServers} servers`);

// Diagnostics arrive asynchronously via textDocument/publishDiagnostics
// and are queued in the registry. To retrieve them:

const diagnostics = checkForLSPDiagnostics();
// Returns deduplicated results:
//   - Within-batch dedup (same file + range + message)
//   - Cross-turn dedup via LRU cache (won't re-report same error)
//   - Sorted by severity: Error < Warning < Info < Hint
//   - Capped: 10 per file, 30 total across all files

for (const file of diagnostics) {
  console.log(`${file.uri}: ${file.diagnostics.length} issues`);
  for (const d of file.diagnostics) {
    console.log(`  [${d.severity}] L${d.range.start.line}: ${d.message}`);
  }
}