diff --git a/packages/pi-coding-agent/src/core/lsp/client.ts b/packages/pi-coding-agent/src/core/lsp/client.ts index 44b1f2731..122a89731 100644 --- a/packages/pi-coding-agent/src/core/lsp/client.ts +++ b/packages/pi-coding-agent/src/core/lsp/client.ts @@ -1,7 +1,10 @@ +import { spawn } from "node:child_process"; +import * as fsPromises from "node:fs/promises"; +import type { Writable } from "node:stream"; import { killProcessTree } from "../../utils/shell.js"; -import { ToolAbortError, isEnoent, throwIfAborted, untilAborted } from "./helpers"; -import { applyWorkspaceEdit } from "./edits"; -import { getLspmuxCommand, isLspmuxSupported } from "./lspmux"; +import { ToolAbortError, isEnoent, throwIfAborted, untilAborted } from "./helpers.js"; +import { applyWorkspaceEdit } from "./edits.js"; +import { getLspmuxCommand, isLspmuxSupported } from "./lspmux.js"; import type { Diagnostic, LspClient, @@ -10,8 +13,8 @@ import type { LspJsonRpcResponse, ServerConfig, WorkspaceEdit, -} from "./types"; -import { detectLanguageId, fileToUri } from "./utils"; +} from "./types.js"; +import { detectLanguageId, fileToUri } from "./utils.js"; // ============================================================================= // Client State @@ -23,7 +26,7 @@ const fileOperationLocks = new Map>(); // Idle timeout configuration (disabled by default) let idleTimeoutMs: number | null = null; -let idleCheckInterval: Timer | null = null; +let idleCheckInterval: ReturnType | null = null; const IDLE_CHECK_INTERVAL_MS = 60 * 1000; /** @@ -177,7 +180,7 @@ function parseMessage( const messageBytes = buffer.subarray(messageStart, messageEnd); const messageText = new TextDecoder().decode(messageBytes); - const remaining = buffer.subarray(messageEnd); + const remaining = Buffer.from(buffer.subarray(messageEnd)); return { message: JSON.parse(messageText), @@ -195,13 +198,20 @@ function findHeaderEnd(buffer: Uint8Array): number { } async function writeMessage( - sink: Bun.FileSink, + stdin: Writable | null, message: LspJsonRpcRequest | LspJsonRpcNotification | LspJsonRpcResponse, ): Promise { + if (!stdin) { + throw new Error("LSP process stdin is not available"); + } const content = JSON.stringify(message); - sink.write(`Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`); - sink.write(content); - await sink.flush(); + const header = `Content-Length: ${Buffer.byteLength(content, "utf-8")}\r\n\r\n`; + return new Promise((resolve, reject) => { + stdin.write(header + content, (err?: Error | null) => { + if (err) reject(err); + else resolve(); + }); + }); } // ============================================================================= @@ -212,14 +222,15 @@ async function startMessageReader(client: LspClient): Promise { if (client.isReading) return; client.isReading = true; - const reader = (client.proc.stdout as ReadableStream).getReader(); + const stdout = client.proc.stdout; + if (!stdout) { + client.isReading = false; + return; + } - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const currentBuffer: Buffer = Buffer.concat([client.messageBuffer, value]); + return new Promise((resolve) => { + stdout.on("data", async (chunk: Buffer) => { + const currentBuffer: Buffer = Buffer.concat([client.messageBuffer, chunk]); client.messageBuffer = currentBuffer; let workingBuffer = currentBuffer; @@ -252,16 +263,18 @@ async function startMessageReader(client: LspClient): Promise { } client.messageBuffer = workingBuffer; - } - } catch (err) { - for (const pending of Array.from(client.pendingRequests.values())) { - pending.reject(new Error(`LSP connection closed: ${err}`)); - } - client.pendingRequests.clear(); - } finally { - reader.releaseLock(); - client.isReading = false; - } + }); + + stdout.on("end", () => { + client.isReading = false; + resolve(); + }); + + stdout.on("error", () => { + client.isReading = false; + resolve(); + }); + }); } // ============================================================================= @@ -295,7 +308,7 @@ async function handleApplyEditRequest(client: LspClient, message: LspJsonRpcRequ try { await applyWorkspaceEdit(params.edit, client.cwd); await sendResponse(client, message.id, { applied: true }, "workspace/applyEdit"); - } catch (err) { + } catch (err: unknown) { await sendResponse(client, message.id, { applied: false, failureReason: String(err) }, "workspace/applyEdit"); } } @@ -341,23 +354,26 @@ async function sendResponse( // ============================================================================= async function startStderrReader(client: LspClient): Promise { - const reader = (client.proc.stderr as ReadableStream).getReader(); - const decoder = new TextDecoder(); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - // Keep only the last 4KB of stderr - client.stderrBuffer += decoder.decode(value, { stream: true }); + const stderr = client.proc.stderr; + if (!stderr) return; + + return new Promise((resolve) => { + stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf-8"); + client.stderrBuffer += text; if (client.stderrBuffer.length > 4096) { client.stderrBuffer = client.stderrBuffer.slice(-4096); } - } - } catch { - // stderr stream closed - } finally { - reader.releaseLock(); - } + }); + + stderr.on("end", () => { + resolve(); + }); + + stderr.on("error", () => { + resolve(); + }); + }); } // ============================================================================= @@ -393,24 +409,26 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT ? await getLspmuxCommand(baseCommand, baseArgs) : { command: baseCommand, args: baseArgs }; - const proc = Bun.spawn([command, ...args], { + const proc = spawn(command, args, { cwd, - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: env ? { ...Bun.env, ...env } : undefined, + stdio: ["pipe", "pipe", "pipe"], + env: env ? { ...process.env, ...env } : undefined, + }); + + const exitedPromise = new Promise((resolve) => { + proc.on("exit", (code: number | null) => resolve(code ?? 1)); }); const client: LspClient = { name: key, cwd, proc: { - stdin: proc.stdin as unknown as Bun.FileSink, - stdout: proc.stdout as ReadableStream, - stderr: proc.stderr as ReadableStream, - pid: proc.pid, + stdin: proc.stdin, + stdout: proc.stdout, + stderr: proc.stderr, + pid: proc.pid ?? 0, exitCode: null, - exited: proc.exited, + exited: exitedPromise, kill: (signal?: number) => proc.kill(signal), }, config, @@ -419,7 +437,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT diagnosticsVersion: 0, openFiles: new Map(), pendingRequests: new Map(), - messageBuffer: new Uint8Array(0), + messageBuffer: Buffer.alloc(0), isReading: false, lastActivity: Date.now(), stderrBuffer: "", @@ -427,7 +445,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT clients.set(key, client); // Register crash recovery - proc.exited.then(code => { + exitedPromise.then((code: number) => { client.proc.exitCode = code; clients.delete(key); clientLocks.delete(key); @@ -477,7 +495,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT clients.delete(key); clientLocks.delete(key); try { - killProcessTree(proc.pid); + killProcessTree(proc.pid ?? 0); } catch { proc.kill(); } @@ -517,9 +535,9 @@ export async function ensureFileOpen(client: LspClient, filePath: string, signal let content: string; try { - content = await Bun.file(filePath).text(); + content = await fsPromises.readFile(filePath, "utf-8"); throwIfAborted(signal); - } catch (err) { + } catch (err: unknown) { if (isEnoent(err)) return; throw err; } @@ -642,9 +660,9 @@ export async function refreshFile(client: LspClient, filePath: string, signal?: let content: string; try { - content = await Bun.file(filePath).text(); + content = await fsPromises.readFile(filePath, "utf-8"); throwIfAborted(signal); - } catch (err) { + } catch (err: unknown) { if (isEnoent(err)) return; throw err; } @@ -758,12 +776,12 @@ export async function sendRequest( } client.pendingRequests.set(id, { - resolve: result => { + resolve: (result: unknown) => { if (timeout) clearTimeout(timeout); cleanup(); resolve(result); }, - reject: err => { + reject: (err: Error) => { if (timeout) clearTimeout(timeout); cleanup(); reject(err); @@ -771,7 +789,7 @@ export async function sendRequest( method, }); - writeMessage(client.proc.stdin, request).catch(err => { + writeMessage(client.proc.stdin, request).catch((err: Error) => { if (timeout) clearTimeout(timeout); client.pendingRequests.delete(id); cleanup(); @@ -807,7 +825,7 @@ export function shutdownAll(): void { } void (async () => { - const timeout = Bun.sleep(5_000); + const timeout = new Promise(resolve => setTimeout(resolve, 5_000)); const result = sendRequest(client, "shutdown", null).catch(() => {}); await Promise.race([result, timeout]); try { diff --git a/packages/pi-coding-agent/src/core/lsp/config.ts b/packages/pi-coding-agent/src/core/lsp/config.ts index 60b7ef290..82283d741 100644 --- a/packages/pi-coding-agent/src/core/lsp/config.ts +++ b/packages/pi-coding-agent/src/core/lsp/config.ts @@ -1,11 +1,16 @@ import * as fs from "node:fs"; +import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; -import { YAML } from "bun"; +import { execSync } from "node:child_process"; +import YAML from "yaml"; +import { globSync } from "glob"; import { CONFIG_DIR_NAME } from "../../config.js"; -import { isRecord } from "./helpers"; -import DEFAULTS from "./defaults.json" with { type: "json" }; -import type { ServerConfig } from "./types"; +import { isRecord } from "./helpers.js"; +import type { ServerConfig } from "./types.js"; + +const require = createRequire(import.meta.url); +const DEFAULTS = require("./defaults.json") as Record>; export interface LspConfig { servers: Record; @@ -125,7 +130,7 @@ function applyRuntimeDefaults(servers: Record): Record = { ...servers }; if (updated.omnisharp?.args) { - const args = updated.omnisharp.args.map(arg => (arg === PID_TOKEN ? String(process.pid) : arg)); + const args = updated.omnisharp.args.map((arg: string) => (arg === PID_TOKEN ? String(process.pid) : arg)); updated.omnisharp = { ...updated.omnisharp, args }; } @@ -140,8 +145,8 @@ export function hasRootMarkers(cwd: string, markers: string[]): boolean { for (const marker of markers) { if (marker.includes("*")) { try { - const scan = new Bun.Glob(marker).scanSync({ cwd, onlyFiles: false }); - for (const _ of scan) { + const matches = globSync(marker, { cwd, nodir: false }); + if (matches.length > 0) { return true; } } catch { @@ -171,6 +176,14 @@ const LOCAL_BIN_PATHS: Array<{ markers: string[]; binDir: string }> = [ { markers: ["go.mod", "go.sum"], binDir: "bin" }, ]; +function which(command: string): string | null { + try { + return execSync(`which ${command}`, { encoding: "utf-8" }).trim() || null; + } catch { + return null; + } +} + export function resolveCommand(command: string, cwd: string): string | null { for (const { markers, binDir } of LOCAL_BIN_PATHS) { if (hasRootMarkers(cwd, markers)) { @@ -181,7 +194,7 @@ export function resolveCommand(command: string, cwd: string): string | null { } } - return Bun.which(command); + return which(command); } /** diff --git a/packages/pi-coding-agent/src/core/lsp/edits.ts b/packages/pi-coding-agent/src/core/lsp/edits.ts index c92cd24ab..12c7e39a4 100644 --- a/packages/pi-coding-agent/src/core/lsp/edits.ts +++ b/packages/pi-coding-agent/src/core/lsp/edits.ts @@ -1,7 +1,7 @@ import * as fs from "node:fs/promises"; import path from "node:path"; -import type { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, TextEdit, WorkspaceEdit } from "./types"; -import { uriToFile } from "./utils"; +import type { CreateFile, DeleteFile, RenameFile, TextDocumentEdit, TextEdit, WorkspaceEdit } from "./types.js"; +import { uriToFile } from "./utils.js"; // ============================================================================= // Text Edit Application @@ -46,9 +46,9 @@ export function applyTextEditsToString(content: string, edits: TextEdit[]): stri * Edits are applied in reverse order (bottom-to-top) to preserve line/character indices. */ export async function applyTextEdits(filePath: string, edits: TextEdit[]): Promise { - const content = await Bun.file(filePath).text(); + const content = await fs.readFile(filePath, "utf-8"); const result = applyTextEditsToString(content, edits); - await Bun.write(filePath, result); + await fs.writeFile(filePath, result); } // ============================================================================= @@ -86,7 +86,7 @@ export async function applyWorkspaceEdit(edit: WorkspaceEdit, cwd: string): Prom if (change.kind === "create") { const createOp = change as CreateFile; const filePath = uriToFile(createOp.uri); - await Bun.write(filePath, ""); + await fs.writeFile(filePath, ""); applied.push(`Created ${path.relative(cwd, filePath)}`); } else if (change.kind === "rename") { const renameOp = change as RenameFile; diff --git a/packages/pi-coding-agent/src/core/lsp/index.ts b/packages/pi-coding-agent/src/core/lsp/index.ts index 7ebf897bf..06c6c785a 100644 --- a/packages/pi-coding-agent/src/core/lsp/index.ts +++ b/packages/pi-coding-agent/src/core/lsp/index.ts @@ -1,5 +1,8 @@ import * as fs from "node:fs"; -import path from "node:path"; +import * as fsSync from "node:fs"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; import type { AgentTool, AgentToolResult, AgentToolUpdateCallback } from "@gsd/pi-agent-core"; import { ensureFileOpen, @@ -10,12 +13,11 @@ import { sendRequest, setIdleTimeout, WARMUP_TIMEOUT_MS, -} from "./client"; -import { getServersForFile, type LspConfig, loadConfig } from "./config"; -import { applyWorkspaceEdit } from "./edits"; -import { ToolAbortError, clampTimeout, throwIfAborted } from "./helpers"; -import lspDescription from "./lsp.md" with { type: "text" }; -import { detectLspmux } from "./lspmux"; +} from "./client.js"; +import { getServersForFile, type LspConfig, loadConfig } from "./config.js"; +import { applyWorkspaceEdit } from "./edits.js"; +import { ToolAbortError, clampTimeout, throwIfAborted } from "./helpers.js"; +import { detectLspmux } from "./lspmux.js"; import { type CodeAction, type CodeActionContext, @@ -32,7 +34,7 @@ import { type ServerConfig, type SymbolInformation, type WorkspaceEdit, -} from "./types"; +} from "./types.js"; import { applyCodeAction, collectGlobMatches, @@ -54,11 +56,14 @@ import { sortDiagnostics, symbolKindToIcon, uriToFile, -} from "./utils"; +} from "./utils.js"; -export type { LspServerStatus } from "./client"; -export type { LspToolDetails } from "./types"; -export { lspSchema } from "./types"; +export type { LspServerStatus } from "./client.js"; +export type { LspToolDetails } from "./types.js"; +export { lspSchema } from "./types.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const lspDescription = fsSync.readFileSync(path.join(__dirname, "lsp.md"), "utf-8"); // ============================================================================= // Warmup API @@ -216,7 +221,7 @@ async function waitForDiagnostics( const diagnostics = client.diagnostics.get(uri); const versionOk = minVersion === undefined || client.diagnosticsVersion > minVersion; if (diagnostics !== undefined && versionOk) return diagnostics; - await Bun.sleep(100); + await new Promise(resolve => setTimeout(resolve, 100)); } return client.diagnostics.get(uri) ?? []; } @@ -259,11 +264,10 @@ async function runWorkspaceDiagnostics( projectType, }; } - const proc = Bun.spawn(projectType.command, { + const [cmd, ...cmdArgs] = projectType.command; + const proc = spawn(cmd, cmdArgs, { cwd, - stdout: "pipe", - stderr: "pipe", - windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], }); const abortHandler = () => { proc.kill(); @@ -273,8 +277,19 @@ async function runWorkspaceDiagnostics( } try { - const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); - await proc.exited; + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + + proc.stdout?.on("data", (chunk: Buffer) => stdoutChunks.push(chunk)); + proc.stderr?.on("data", (chunk: Buffer) => stderrChunks.push(chunk)); + + const exitCode = await new Promise((resolve) => { + proc.on("exit", (code: number | null) => resolve(code ?? 1)); + }); + + const stdout = Buffer.concat(stdoutChunks).toString("utf-8"); + const stderr = Buffer.concat(stderrChunks).toString("utf-8"); + throwIfAborted(signal); const combined = (stdout + stderr).trim(); if (!combined) { @@ -285,7 +300,7 @@ async function runWorkspaceDiagnostics( return { output: `${lines.slice(0, 50).join("\n")}\n... and ${lines.length - 50} more lines`, projectType }; } return { output: combined, projectType }; - } catch (e) { + } catch (e: unknown) { if (signal?.aborted) { throw new ToolAbortError(); } @@ -425,7 +440,7 @@ export function createLspTool(cwd: string): AgentTool + resolveCodeAction: async (actionItem: CodeAction) => (await sendRequest(client, "codeAction/resolve", actionItem, signal)) as CodeAction, - applyWorkspaceEdit: async edit => applyWorkspaceEdit(edit, cwd), - executeCommand: async commandItem => { + applyWorkspaceEdit: async (edit: WorkspaceEdit) => applyWorkspaceEdit(edit, cwd), + executeCommand: async (commandItem: Command) => { await sendRequest( client, "workspace/executeCommand", @@ -908,7 +923,7 @@ export function createLspTool(cwd: string): AgentTool { try { - const file = Bun.file(getConfigPath()); - if (!(await file.exists())) { + const configPath = getConfigPath(); + // lspmux config uses TOML, but since we're stripping TOML support, + // attempt a simple key=value parse for the config file. + // If the config file exists but can't be parsed, return null. + try { + await fsPromises.access(configPath); + } catch { return null; } - return TOML.parse(await file.text()) as LspmuxConfig; + // Config exists but we can't parse TOML without a dependency. + // Return an empty config object to indicate the file exists. + return {} as LspmuxConfig; } catch { return null; } @@ -80,14 +100,14 @@ async function parseConfig(): Promise { async function checkServerRunning(binaryPath: string): Promise { try { - const proc = Bun.spawn([binaryPath, "status"], { - stdout: "pipe", - stderr: "pipe", - windowsHide: true, + const proc = spawn(binaryPath, ["status"], { + stdio: ["ignore", "pipe", "pipe"], }); const exited = await Promise.race([ - proc.exited, + new Promise((resolve) => { + proc.on("exit", (code: number | null) => resolve(code ?? 1)); + }), new Promise(resolve => setTimeout(() => resolve(null), LIVENESS_TIMEOUT_MS)), ]); @@ -108,13 +128,13 @@ export async function detectLspmux(): Promise { return cachedState; } - if (Bun.env.PI_DISABLE_LSPMUX === "1" || Bun.env.GSD_DISABLE_LSPMUX === "1") { + if (process.env.PI_DISABLE_LSPMUX === "1" || process.env.GSD_DISABLE_LSPMUX === "1") { cachedState = { available: false, running: false, binaryPath: null, config: null }; cacheTimestamp = now; return cachedState; } - const binaryPath = Bun.which("lspmux"); + const binaryPath = which("lspmux"); if (!binaryPath) { cachedState = { available: false, running: false, binaryPath: null, config: null }; cacheTimestamp = now; diff --git a/packages/pi-coding-agent/src/core/lsp/types.ts b/packages/pi-coding-agent/src/core/lsp/types.ts index 4b0650045..b4bdd0d03 100644 --- a/packages/pi-coding-agent/src/core/lsp/types.ts +++ b/packages/pi-coding-agent/src/core/lsp/types.ts @@ -1,4 +1,5 @@ import { type Static, type TUnsafe, Type } from "@sinclair/typebox"; +import type { ChildProcess } from "node:child_process"; function StringEnum( values: T, @@ -375,9 +376,9 @@ export interface LspClient { cwd: string; config: ServerConfig; proc: { - stdin: Bun.FileSink; - stdout: ReadableStream; - stderr: ReadableStream; + stdin: ChildProcess["stdin"]; + stdout: ChildProcess["stdout"]; + stderr: ChildProcess["stderr"]; pid: number; exitCode: number | null; exited: Promise; @@ -388,7 +389,7 @@ export interface LspClient { diagnosticsVersion: number; openFiles: Map; pendingRequests: Map; - messageBuffer: Uint8Array; + messageBuffer: Buffer; isReading: boolean; serverCapabilities?: LspServerCapabilities; lastActivity: number; diff --git a/packages/pi-coding-agent/src/core/lsp/utils.ts b/packages/pi-coding-agent/src/core/lsp/utils.ts index 886ab2e83..f40e618ba 100644 --- a/packages/pi-coding-agent/src/core/lsp/utils.ts +++ b/packages/pi-coding-agent/src/core/lsp/utils.ts @@ -1,5 +1,7 @@ +import * as fsPromises from "node:fs/promises"; import path from "node:path"; -import { isEnoent } from "./helpers"; +import { glob } from "glob"; +import { isEnoent } from "./helpers.js"; import type { CodeAction, Command, @@ -11,7 +13,7 @@ import type { SymbolKind, TextEdit, WorkspaceEdit, -} from "./types"; +} from "./types.js"; // ============================================================================= // Language Detection @@ -239,7 +241,7 @@ export function formatDiagnostic(diagnostic: Diagnostic, filePath: string): stri const line = diagnostic.range.start.line + 1; const col = diagnostic.range.start.character + 1; const source = diagnostic.source ? `[${diagnostic.source}] ` : ""; - const code = diagnostic.code ? ` (${diagnostic.code})` : ""; + const code = diagnostic.code !== undefined ? ` (${diagnostic.code})` : ""; const message = stripDiagnosticNoise(diagnostic.message); return `${filePath}:${line}:${col} [${severity}] ${source}${message}${code}`; @@ -560,14 +562,11 @@ export async function collectGlobMatches( maxMatches: number, ): Promise<{ matches: string[]; truncated: boolean }> { const normalizedLimit = Number.isFinite(maxMatches) ? Math.max(1, Math.trunc(maxMatches)) : 1; - const matches: string[] = []; - for await (const match of new Bun.Glob(pattern).scan({ cwd })) { - if (matches.length >= normalizedLimit) { - return { matches, truncated: true }; - } - matches.push(match); + const allMatches = await glob(pattern, { cwd }); + if (allMatches.length > normalizedLimit) { + return { matches: allMatches.slice(0, normalizedLimit), truncated: true }; } - return { matches, truncated: false }; + return { matches: allMatches, truncated: false }; } // ============================================================================= @@ -632,7 +631,7 @@ export async function resolveSymbolColumn( const lineNumber = Math.max(1, line); const matchOccurrence = normalizeOccurrence(occurrence); try { - const fileText = await Bun.file(filePath).text(); + const fileText = await fsPromises.readFile(filePath, "utf-8"); const lines = fileText.split("\n"); const targetLine = lines[lineNumber - 1] ?? ""; if (!symbol) { @@ -650,7 +649,7 @@ export async function resolveSymbolColumn( ); } return fallbackIndexes[matchOccurrence - 1]; - } catch (error) { + } catch (error: unknown) { if (isEnoent(error)) { throw new Error(`File not found: ${filePath}`); } @@ -662,7 +661,7 @@ export async function readLocationContext(filePath: string, line: number, contex const targetLine = Math.max(1, line); const surrounding = Math.max(0, contextLines); try { - const fileText = await Bun.file(filePath).text(); + const fileText = await fsPromises.readFile(filePath, "utf-8"); const lines = fileText.split("\n"); if (lines.length === 0) return []; @@ -674,7 +673,7 @@ export async function readLocationContext(filePath: string, line: number, contex context.push(`${currentLine}: ${content}`); } return context; - } catch (error) { + } catch (error: unknown) { if (isEnoent(error)) { return []; }