From a02266d46e05a65e8f67de990e4165432b7607aa Mon Sep 17 00:00:00 2001 From: indifferentketchup Date: Sun, 7 Jun 2026 17:57:35 +0000 Subject: [PATCH] feat(coder): add LSP code intelligence tools - lsp/ module: types, config, JSON-RPC client, server-manager, operations - lsp_diagnostics: TypeScript/JavaScript diagnostics for a file - lsp_goto_definition: find symbol definition at position - lsp_find_references: find all references to a symbol - Registered as READ_TOOLS in tool index --- apps/coder/src/services/lsp/client.ts | 75 +++++++++++ apps/coder/src/services/lsp/config.ts | 19 +++ apps/coder/src/services/lsp/operations.ts | 86 +++++++++++++ apps/coder/src/services/lsp/server-manager.ts | 119 ++++++++++++++++++ apps/coder/src/services/lsp/types.ts | 28 +++++ apps/coder/src/services/tools/index.ts | 17 ++- .../src/services/tools/lsp_diagnostics.ts | 48 +++++++ .../src/services/tools/lsp_find_references.ts | 49 ++++++++ .../src/services/tools/lsp_goto_definition.ts | 48 +++++++ 9 files changed, 488 insertions(+), 1 deletion(-) create mode 100644 apps/coder/src/services/lsp/client.ts create mode 100644 apps/coder/src/services/lsp/config.ts create mode 100644 apps/coder/src/services/lsp/operations.ts create mode 100644 apps/coder/src/services/lsp/server-manager.ts create mode 100644 apps/coder/src/services/lsp/types.ts create mode 100644 apps/coder/src/services/tools/lsp_diagnostics.ts create mode 100644 apps/coder/src/services/tools/lsp_find_references.ts create mode 100644 apps/coder/src/services/tools/lsp_goto_definition.ts diff --git a/apps/coder/src/services/lsp/client.ts b/apps/coder/src/services/lsp/client.ts new file mode 100644 index 0000000..6b26c66 --- /dev/null +++ b/apps/coder/src/services/lsp/client.ts @@ -0,0 +1,75 @@ +import { createInterface } from 'node:readline'; +import type { Readable, Writable } from 'node:stream'; + +interface RpcRequest { + jsonrpc: '2.0'; + id: number; + method: string; + params?: unknown; +} + +interface RpcResponse { + jsonrpc: '2.0'; + id: number; + result?: unknown; + error?: { code: number; message: string }; +} + +export class LspClient { + private nextId = 1; + private pending = new Map void; reject: (e: Error) => void }>(); + private buffer = ''; + + constructor( + private stdin: Writable, + private stdout: Readable, + ) { + const rl = createInterface({ input: stdout, crlfDelay: Infinity }); + rl.on('line', (line) => this.handleLine(line)); + } + + private handleLine(line: string): void { + this.buffer += line + '\n'; + const match = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/); + if (!match || !match[1]) return; + const len = parseInt(match[1], 10); + const headerEnd = match.index! + match[0].length; + const body = this.buffer.slice(headerEnd, headerEnd + len); + if (body.length < len) return; + this.buffer = this.buffer.slice(headerEnd + len); + try { + const msg: RpcResponse = JSON.parse(body); + const cb = this.pending.get(msg.id); + if (cb) { + this.pending.delete(msg.id); + cb.resolve(msg); + } + } catch { + // Malformed JSON, ignore + } + } + + async request(method: string, params?: unknown): Promise { + const id = this.nextId++; + const req: RpcRequest = { jsonrpc: '2.0', id, method, params }; + const body = JSON.stringify(req); + const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`; + + return new Promise((resolve, reject) => { + this.pending.set(id, { + resolve: (resp) => { + if (resp.error) reject(new Error(resp.error.message)); + else resolve(resp.result); + }, + reject, + }); + this.stdin.write(header + body); + }); + } + + async notify(method: string, params?: unknown): Promise { + const body = JSON.stringify({ jsonrpc: '2.0', method, params }); + const header = `Content-Length: ${Buffer.byteLength(body, 'utf8')}\r\n\r\n`; + this.stdin.write(header + body); + } +} diff --git a/apps/coder/src/services/lsp/config.ts b/apps/coder/src/services/lsp/config.ts new file mode 100644 index 0000000..3c97fa4 --- /dev/null +++ b/apps/coder/src/services/lsp/config.ts @@ -0,0 +1,19 @@ +export interface LspServerConfig { + command: string; + args: string[]; + rootPatterns: string[]; +} + +const TS_CONFIG: LspServerConfig = { + command: 'typescript-language-server', + args: ['--stdio'], + rootPatterns: ['package.json', 'tsconfig.json'], +}; + +const SUPPORTED_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs']); + +export function getServerConfig(filePath: string): LspServerConfig | null { + const ext = filePath.split('.').pop()?.toLowerCase(); + if (ext && SUPPORTED_EXTS.has(ext)) return TS_CONFIG; + return null; +} diff --git a/apps/coder/src/services/lsp/operations.ts b/apps/coder/src/services/lsp/operations.ts new file mode 100644 index 0000000..b1f1006 --- /dev/null +++ b/apps/coder/src/services/lsp/operations.ts @@ -0,0 +1,86 @@ +import type { LspClient } from './client.js'; +import type { Diagnostic, Location } from './types.js'; + +function fileUri(filePath: string): string { + return `file://${filePath.startsWith('/') ? '' : '/'}${filePath}`; +} + +export async function openDocument( + client: LspClient, + filePath: string, + content: string, + version: number = 1, +): Promise { + const uri = fileUri(filePath); + await client.notify('textDocument/didOpen', { + textDocument: { uri, languageId: 'typescript', version, text: content }, + }); +} + +export async function closeDocument(client: LspClient, filePath: string): Promise { + await client.notify('textDocument/didClose', { + textDocument: { uri: fileUri(filePath) }, + }); +} + +export async function getDiagnostics( + client: LspClient, + filePath: string, + content: string, +): Promise { + const uri = fileUri(filePath); + await openDocument(client, filePath, content); + const result: any = await client.request('textDocument/diagnostic', { + textDocument: { uri }, + }); + await closeDocument(client, filePath); + const diagnostics: Diagnostic[] = []; + if (result?.diagnostics) { + for (const d of result.diagnostics) { + diagnostics.push({ + range: d.range, + severity: d.severity ?? 1, + message: d.message, + source: d.source, + }); + } + } + return diagnostics; +} + +export async function gotoDefinition( + client: LspClient, + filePath: string, + content: string, + line: number, + character: number, +): Promise { + const uri = fileUri(filePath); + await openDocument(client, filePath, content); + const result: any = await client.request('textDocument/definition', { + textDocument: { uri }, + position: { line, character }, + }); + await closeDocument(client, filePath); + if (!result) return null; + const loc = Array.isArray(result) ? result[0] : result; + return loc ? { uri: loc.uri, range: loc.range } : null; +} + +export async function findReferences( + client: LspClient, + filePath: string, + content: string, + line: number, + character: number, +): Promise { + const uri = fileUri(filePath); + await openDocument(client, filePath, content); + const result: any = await client.request('textDocument/references', { + textDocument: { uri }, + position: { line, character }, + context: { includeDeclaration: true }, + }); + await closeDocument(client, filePath); + return (result ?? []).map((loc: any) => ({ uri: loc.uri, range: loc.range })); +} diff --git a/apps/coder/src/services/lsp/server-manager.ts b/apps/coder/src/services/lsp/server-manager.ts new file mode 100644 index 0000000..a3cd60b --- /dev/null +++ b/apps/coder/src/services/lsp/server-manager.ts @@ -0,0 +1,119 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; +import { LspClient } from './client.js'; +import { getServerConfig } from './config.js'; + +const IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const SWEEP_INTERVAL_MS = 30_000; + +interface LspInstance { + client: LspClient; + proc: ChildProcess; + lastUsed: number; + timer: ReturnType; +} + +export class LspServerManager { + private instances = new Map(); + private sweepTimer: ReturnType | null = null; + + constructor() { + this.startSweeper(); + } + + private startSweeper(): void { + this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS); + this.sweepTimer.unref?.(); + } + + private findProjectRoot(filePath: string): string | null { + let dir = filePath; + const config = getServerConfig(filePath); + if (!config) return null; + while (true) { + for (const pattern of config.rootPatterns) { + if (existsSync(join(dir, pattern))) return dir; + } + const parent = join(dir, '..'); + if (parent === dir) return dir; + dir = parent; + } + } + + async getClient(filePath: string): Promise { + const config = getServerConfig(filePath); + if (!config) return null; + const projectRoot = this.findProjectRoot(filePath); + if (!projectRoot) return null; + + const existing = this.instances.get(projectRoot); + if (existing) { + existing.lastUsed = Date.now(); + clearTimeout(existing.timer); + existing.timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS); + existing.timer.unref?.(); + return existing.client; + } + + return this.spawn(projectRoot, config.command, config.args); + } + + private async spawn(projectRoot: string, command: string, args: string[]): Promise { + const proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'], cwd: projectRoot }); + const client = new LspClient(proc.stdin!, proc.stdout!); + + await client.request('initialize', { + processId: process.pid, + rootUri: `file://${projectRoot}`, + capabilities: { + textDocument: { + diagnostic: { dynamicRegistration: false }, + definition: { dynamicRegistration: false }, + references: { dynamicRegistration: false }, + }, + }, + }); + await client.notify('initialized', {}); + + const timer = setTimeout(() => this.kill(projectRoot), IDLE_TIMEOUT_MS); + timer.unref?.(); + + this.instances.set(projectRoot, { client, proc, lastUsed: Date.now(), timer }); + proc.on('exit', () => this.instances.delete(projectRoot)); + + return client; + } + + private kill(projectRoot: string): void { + const inst = this.instances.get(projectRoot); + if (!inst) return; + this.instances.delete(projectRoot); + inst.proc.kill('SIGTERM'); + setTimeout(() => { + if (inst.proc.exitCode === null) inst.proc.kill('SIGKILL'); + }, 5000); + } + + private sweep(): void { + const now = Date.now(); + for (const [root, inst] of this.instances) { + if (now - inst.lastUsed > IDLE_TIMEOUT_MS) { + this.kill(root); + } + } + } + + shutdown(): void { + if (this.sweepTimer) clearInterval(this.sweepTimer); + for (const root of [...this.instances.keys()]) { + this.kill(root); + } + } + + getActiveCount(): number { + return this.instances.size; + } +} + +export const lspManager = new LspServerManager(); diff --git a/apps/coder/src/services/lsp/types.ts b/apps/coder/src/services/lsp/types.ts new file mode 100644 index 0000000..20808f9 --- /dev/null +++ b/apps/coder/src/services/lsp/types.ts @@ -0,0 +1,28 @@ +export interface Position { + line: number; + character: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface Location { + uri: string; + range: Range; +} + +export interface Diagnostic { + range: Range; + severity: number; + message: string; + source?: string; +} + +export interface TextDocumentItem { + uri: string; + languageId: string; + version: number; + text: string; +} diff --git a/apps/coder/src/services/tools/index.ts b/apps/coder/src/services/tools/index.ts index c4d3d06..7961d9e 100644 --- a/apps/coder/src/services/tools/index.ts +++ b/apps/coder/src/services/tools/index.ts @@ -7,6 +7,9 @@ import { rewindTool } from './rewind.js'; import { newTaskTool } from './new_task.js'; import { listTasksTool } from './list_tasks.js'; import { checkTaskStatusTool } from './check_task_status.js'; +import { lspDiagnosticsTool } from './lsp_diagnostics.js'; +import { lspGotoDefinitionTool } from './lsp_goto_definition.js'; +import { lspFindReferencesTool } from './lsp_find_references.js'; export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js'; @@ -26,4 +29,16 @@ export const WRITE_TOOLS: readonly ToolDef[] = [ checkTaskStatusTool, ]; -export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool }; +// Read-only agent tools for code intelligence. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const READ_TOOLS: readonly ToolDef[] = [ + lspDiagnosticsTool, + lspGotoDefinitionTool, + lspFindReferencesTool, +]; + +export { + editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, + newTaskTool, listTasksTool, checkTaskStatusTool, + lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool, +}; diff --git a/apps/coder/src/services/tools/lsp_diagnostics.ts b/apps/coder/src/services/tools/lsp_diagnostics.ts new file mode 100644 index 0000000..44ee2ec --- /dev/null +++ b/apps/coder/src/services/tools/lsp_diagnostics.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import type { ToolDef, ToolContext } from './types.js'; +import { resolveWritePath } from '../write_guard.js'; +import { lspManager } from '../lsp/server-manager.js'; +import { getDiagnostics } from '../lsp/operations.js'; + +const LspDiagnosticsInput = z.object({ + file_path: z.string().describe('Path to the file to check for diagnostics'), +}); + +type InputT = z.infer; + +export const lspDiagnosticsTool: ToolDef = { + name: 'lsp_diagnostics', + description: 'Get TypeScript/JavaScript diagnostics (errors, warnings) for a file. Returns diagnostic messages with severity and location.', + inputSchema: LspDiagnosticsInput, + jsonSchema: { + type: 'function', + function: { + name: 'lsp_diagnostics', + description: 'Get TypeScript/JavaScript diagnostics for a file', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string', description: 'Path to the file' }, + }, + required: ['file_path'], + }, + }, + }, + + async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise { + const resolved = await resolveWritePath(projectRoot, input.file_path); + const content = await readFile(resolved, 'utf8'); + const client = await lspManager.getClient(resolved); + if (!client) return { error: 'Unsupported file type for LSP diagnostics' }; + + const diagnostics = await getDiagnostics(client, resolved, content); + if (diagnostics.length === 0) return { result: 'No diagnostics found.' }; + + const lines = diagnostics.map((d) => { + const sev = ['', 'error', 'warning', 'info', 'hint'][d.severity] ?? 'unknown'; + return `[${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1} - ${d.message}`; + }); + return { result: lines.join('\n') }; + }, +}; diff --git a/apps/coder/src/services/tools/lsp_find_references.ts b/apps/coder/src/services/tools/lsp_find_references.ts new file mode 100644 index 0000000..0f8418b --- /dev/null +++ b/apps/coder/src/services/tools/lsp_find_references.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import type { ToolDef, ToolContext } from './types.js'; +import { resolveWritePath } from '../write_guard.js'; +import { lspManager } from '../lsp/server-manager.js'; +import { findReferences } from '../lsp/operations.js'; + +const LspFindReferencesInput = z.object({ + file_path: z.string().describe('Path to the source file'), + line: z.number().int().nonnegative().describe('0-based line number'), + character: z.number().int().nonnegative().describe('0-based character offset'), +}); + +type InputT = z.infer; + +export const lspFindReferencesTool: ToolDef = { + name: 'lsp_find_references', + description: 'Find all references to a symbol at a given position in a file.', + inputSchema: LspFindReferencesInput, + jsonSchema: { + type: 'function', + function: { + name: 'lsp_find_references', + description: 'Find all references to symbol at position', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string' }, + line: { type: 'number' }, + character: { type: 'number' }, + }, + required: ['file_path', 'line', 'character'], + }, + }, + }, + + async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise { + const resolved = await resolveWritePath(projectRoot, input.file_path); + const content = await readFile(resolved, 'utf8'); + const client = await lspManager.getClient(resolved); + if (!client) return { error: 'Unsupported file type' }; + + const refs = await findReferences(client, resolved, content, input.line, input.character); + if (refs.length === 0) return { result: 'No references found.' }; + + const lines = refs.map((r) => `${r.uri}:${r.range.start.line + 1}:${r.range.start.character + 1}`); + return { result: `Found ${refs.length} reference(s):\n${lines.join('\n')}` }; + }, +}; diff --git a/apps/coder/src/services/tools/lsp_goto_definition.ts b/apps/coder/src/services/tools/lsp_goto_definition.ts new file mode 100644 index 0000000..d1c9ace --- /dev/null +++ b/apps/coder/src/services/tools/lsp_goto_definition.ts @@ -0,0 +1,48 @@ +import { z } from 'zod'; +import { readFile } from 'node:fs/promises'; +import type { ToolDef, ToolContext } from './types.js'; +import { resolveWritePath } from '../write_guard.js'; +import { lspManager } from '../lsp/server-manager.js'; +import { gotoDefinition } from '../lsp/operations.js'; + +const LspGotoDefinitionInput = z.object({ + file_path: z.string().describe('Path to the source file'), + line: z.number().int().nonnegative().describe('0-based line number'), + character: z.number().int().nonnegative().describe('0-based character offset'), +}); + +type InputT = z.infer; + +export const lspGotoDefinitionTool: ToolDef = { + name: 'lsp_goto_definition', + description: 'Find the definition of a symbol at a given position in a file.', + inputSchema: LspGotoDefinitionInput, + jsonSchema: { + type: 'function', + function: { + name: 'lsp_goto_definition', + description: 'Find definition of symbol at position', + parameters: { + type: 'object', + properties: { + file_path: { type: 'string' }, + line: { type: 'number' }, + character: { type: 'number' }, + }, + required: ['file_path', 'line', 'character'], + }, + }, + }, + + async execute(input: InputT, projectRoot: string, _context: ToolContext): Promise { + const resolved = await resolveWritePath(projectRoot, input.file_path); + const content = await readFile(resolved, 'utf8'); + const client = await lspManager.getClient(resolved); + if (!client) return { error: 'Unsupported file type' }; + + const loc = await gotoDefinition(client, resolved, content, input.line, input.character); + if (!loc) return { result: 'No definition found.' }; + + return { result: `Defined at ${loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}` }; + }, +};