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
This commit is contained in:
2026-06-07 17:57:35 +00:00
parent bc83475a3d
commit ee749d8698
9 changed files with 488 additions and 1 deletions

View File

@@ -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<number, { resolve: (v: RpcResponse) => 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<unknown> {
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<void> {
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);
}
}

View File

@@ -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;
}

View File

@@ -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<void> {
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<void> {
await client.notify('textDocument/didClose', {
textDocument: { uri: fileUri(filePath) },
});
}
export async function getDiagnostics(
client: LspClient,
filePath: string,
content: string,
): Promise<Diagnostic[]> {
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<Location | null> {
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<Location[]> {
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 }));
}

View File

@@ -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<typeof setTimeout>;
}
export class LspServerManager {
private instances = new Map<string, LspInstance>();
private sweepTimer: ReturnType<typeof setInterval> | 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<LspClient | null> {
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<LspClient> {
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();

View File

@@ -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;
}

View File

@@ -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<any>[] = [
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<any>[] = [
lspDiagnosticsTool,
lspGotoDefinitionTool,
lspFindReferencesTool,
];
export {
editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool,
newTaskTool, listTasksTool, checkTaskStatusTool,
lspDiagnosticsTool, lspGotoDefinitionTool, lspFindReferencesTool,
};

View File

@@ -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<typeof LspDiagnosticsInput>;
export const lspDiagnosticsTool: ToolDef<InputT> = {
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<unknown> {
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') };
},
};

View File

@@ -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<typeof LspFindReferencesInput>;
export const lspFindReferencesTool: ToolDef<InputT> = {
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<unknown> {
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')}` };
},
};

View File

@@ -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<typeof LspGotoDefinitionInput>;
export const lspGotoDefinitionTool: ToolDef<InputT> = {
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<unknown> {
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}` };
},
};