pi-web-ui 0.94.0 → 0.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +751 -206
- package/dist/server/agent-service.js +1869 -85
- package/dist/server/approval-rules.js +550 -0
- package/dist/server/attachment-store.js +112 -0
- package/dist/server/attachments.js +62 -171
- package/dist/server/client-state.js +4 -0
- package/dist/server/compact-context-tool.js +125 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/dangling-tools.js +162 -0
- package/dist/server/dsh/dsh-agent-service.js +50 -60
- package/dist/server/edit-soft-tool.js +70 -5
- package/dist/server/eval-tool.js +535 -0
- package/dist/server/files-service.js +35 -7
- package/dist/server/goal-service.js +240 -68
- package/dist/server/hashline-engine.js +665 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/http-proxy.js +11 -1
- package/dist/server/index.js +157 -9
- package/dist/server/lsp-tool.js +679 -0
- package/dist/server/model-admin.js +20 -0
- package/dist/server/office-parse.js +309 -0
- package/dist/server/patch-tool.js +91 -0
- package/dist/server/plan-manager.js +104 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +47 -11
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugins.js +745 -161
- package/dist/server/process-utils.js +12 -6
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/serialize.js +13 -3
- package/dist/server/settings-service.js +108 -1
- package/dist/server/subagents.js +41 -0
- package/dist/server/terminals.js +69 -14
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +214 -6
- package/dist/server/workspace-snapshot.js +113 -0
- package/package.json +3 -2
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/dark-teal.css +20 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
- package/web/dist/assets/index-BXnCQL9p.js +364 -0
- package/web/dist/assets/index-RpIGFyi_.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-18jK6al2.js +0 -364
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lsp-tool.ts — 导出给 AI Agent 的原生 LSP 语言服务器工具(Native Opt-in LSP Tool)。
|
|
3
|
+
*
|
|
4
|
+
* 核心设计:
|
|
5
|
+
* - 零重型外部依赖:基于 Node.js 标准流与原生 JSON-RPC 2.0 实现轻量级 LSP 客户端。
|
|
6
|
+
* - 多会话共享池(Project-level Pool):同项目多会话与并发子代理复用同一个后台语言服务器实例,
|
|
7
|
+
* 避免多子代理重复拉起语言服务器导致内存爆炸(参考 omp 的 lspmux 理念)。
|
|
8
|
+
* - 支持 4 大核心语义动作:
|
|
9
|
+
* 1. definition:跳转到符号定义位置(支持展示定义代码片段);
|
|
10
|
+
* 2. references:列出工作区内所有引用/调用处;
|
|
11
|
+
* 3. hover:获取类型签名与文档说明(Docstring/Markdown);
|
|
12
|
+
* 4. diagnostics:获取文件或项目的实时编译与类型检查错误。
|
|
13
|
+
* - 闲置自动回收:15 分钟无请求自动休眠退出,释放系统内存。
|
|
14
|
+
*/
|
|
15
|
+
import { spawn, execSync } from "node:child_process";
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
18
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
19
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
export const LSP_TOOL_NAME = "lsp";
|
|
22
|
+
const LANGUAGE_SERVER_CATALOG = {
|
|
23
|
+
ts: {
|
|
24
|
+
languageId: "typescript",
|
|
25
|
+
commands: [
|
|
26
|
+
{
|
|
27
|
+
bin: "typescript-language-server",
|
|
28
|
+
args: ["--stdio"],
|
|
29
|
+
installHint: "npm install -g typescript-language-server typescript",
|
|
30
|
+
},
|
|
31
|
+
{ bin: "vtsls", args: ["--stdio"], installHint: "npm install -g @vtsls/language-server" },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
js: {
|
|
35
|
+
languageId: "javascript",
|
|
36
|
+
commands: [
|
|
37
|
+
{
|
|
38
|
+
bin: "typescript-language-server",
|
|
39
|
+
args: ["--stdio"],
|
|
40
|
+
installHint: "npm install -g typescript-language-server typescript",
|
|
41
|
+
},
|
|
42
|
+
{ bin: "vtsls", args: ["--stdio"], installHint: "npm install -g @vtsls/language-server" },
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
py: {
|
|
46
|
+
languageId: "python",
|
|
47
|
+
commands: [
|
|
48
|
+
{ bin: "pyright-langserver", args: ["--stdio"], installHint: "npm install -g pyright" },
|
|
49
|
+
{ bin: "pyright", args: ["--stdio"], installHint: "pip install pyright" },
|
|
50
|
+
{ bin: "pylsp", args: [], installHint: "pip install python-lsp-server" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
rs: {
|
|
54
|
+
languageId: "rust",
|
|
55
|
+
commands: [{ bin: "rust-analyzer", args: [], installHint: "rustup component add rust-analyzer" }],
|
|
56
|
+
},
|
|
57
|
+
go: {
|
|
58
|
+
languageId: "go",
|
|
59
|
+
commands: [{ bin: "gopls", args: ["serve"], installHint: "go install golang.org/x/tools/gopls@latest" }],
|
|
60
|
+
},
|
|
61
|
+
c: {
|
|
62
|
+
languageId: "c",
|
|
63
|
+
commands: [{ bin: "clangd", args: [], installHint: "Install LLVM/clangd from package manager" }],
|
|
64
|
+
},
|
|
65
|
+
cpp: {
|
|
66
|
+
languageId: "cpp",
|
|
67
|
+
commands: [{ bin: "clangd", args: [], installHint: "Install LLVM/clangd from package manager" }],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
function getLanguageForPath(filePath) {
|
|
71
|
+
const ext = extname(filePath).toLowerCase().replace(/^\./, "");
|
|
72
|
+
if (["ts", "tsx", "mts", "cts"].includes(ext))
|
|
73
|
+
return { langKey: "ts", config: LANGUAGE_SERVER_CATALOG.ts };
|
|
74
|
+
if (["js", "jsx", "mjs", "cjs"].includes(ext))
|
|
75
|
+
return { langKey: "js", config: LANGUAGE_SERVER_CATALOG.js };
|
|
76
|
+
if (["py", "pyi"].includes(ext))
|
|
77
|
+
return { langKey: "py", config: LANGUAGE_SERVER_CATALOG.py };
|
|
78
|
+
if (ext === "rs")
|
|
79
|
+
return { langKey: "rs", config: LANGUAGE_SERVER_CATALOG.rs };
|
|
80
|
+
if (ext === "go")
|
|
81
|
+
return { langKey: "go", config: LANGUAGE_SERVER_CATALOG.go };
|
|
82
|
+
if (["c", "h"].includes(ext))
|
|
83
|
+
return { langKey: "c", config: LANGUAGE_SERVER_CATALOG.c };
|
|
84
|
+
if (["cpp", "cc", "cxx", "hpp", "hxx"].includes(ext))
|
|
85
|
+
return { langKey: "cpp", config: LANGUAGE_SERVER_CATALOG.cpp };
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
/** 探测二进制是否可在当前系统执行(PATH 或 workspace node_modules/.bin) */
|
|
89
|
+
function resolveBinary(cmd, cwd) {
|
|
90
|
+
const isWin = process.platform === "win32";
|
|
91
|
+
const candidates = [];
|
|
92
|
+
if (isAbsolute(cmd)) {
|
|
93
|
+
candidates.push(cmd);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// 1. 本地 node_modules/.bin
|
|
97
|
+
const localBin = resolve(cwd, "node_modules", ".bin", cmd);
|
|
98
|
+
candidates.push(localBin);
|
|
99
|
+
if (isWin) {
|
|
100
|
+
candidates.push(`${localBin}.cmd`);
|
|
101
|
+
candidates.push(`${localBin}.exe`);
|
|
102
|
+
}
|
|
103
|
+
// 2. 系统 PATH 候选
|
|
104
|
+
candidates.push(cmd);
|
|
105
|
+
if (isWin) {
|
|
106
|
+
candidates.push(`${cmd}.cmd`);
|
|
107
|
+
candidates.push(`${cmd}.exe`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const cand of candidates) {
|
|
111
|
+
if (isAbsolute(cand) && existsSync(cand))
|
|
112
|
+
return cand;
|
|
113
|
+
}
|
|
114
|
+
// 尝试 where / which 嗅探
|
|
115
|
+
try {
|
|
116
|
+
const checkCmd = isWin ? `where "${cmd}"` : `which "${cmd}"`;
|
|
117
|
+
const out = execSync(checkCmd, { stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" }).trim();
|
|
118
|
+
if (out)
|
|
119
|
+
return out.split(/\r?\n/)[0];
|
|
120
|
+
}
|
|
121
|
+
catch { }
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
// ----------------------------------------------------------------------------
|
|
125
|
+
// JSON-RPC 2.0 传输层与单项目 LSP 客户端
|
|
126
|
+
// ----------------------------------------------------------------------------
|
|
127
|
+
export class LspClient {
|
|
128
|
+
projectCwd;
|
|
129
|
+
langKey;
|
|
130
|
+
binPath;
|
|
131
|
+
binArgs;
|
|
132
|
+
languageId;
|
|
133
|
+
onIdleEvict;
|
|
134
|
+
proc = null;
|
|
135
|
+
nextId = 1;
|
|
136
|
+
pendingRequests = new Map();
|
|
137
|
+
diagnosticsCache = new Map(); // fileUri -> diagnostics
|
|
138
|
+
openFiles = new Set(); // fileUri
|
|
139
|
+
docVersions = new Map(); // fileUri -> version
|
|
140
|
+
buffer = Buffer.alloc(0);
|
|
141
|
+
idleTimer = null;
|
|
142
|
+
initialized = false;
|
|
143
|
+
isShuttingDown = false;
|
|
144
|
+
constructor(projectCwd, langKey, binPath, binArgs, languageId, onIdleEvict) {
|
|
145
|
+
this.projectCwd = projectCwd;
|
|
146
|
+
this.langKey = langKey;
|
|
147
|
+
this.binPath = binPath;
|
|
148
|
+
this.binArgs = binArgs;
|
|
149
|
+
this.languageId = languageId;
|
|
150
|
+
this.onIdleEvict = onIdleEvict;
|
|
151
|
+
}
|
|
152
|
+
async start() {
|
|
153
|
+
this.touch();
|
|
154
|
+
this.proc = spawn(this.binPath, this.binArgs, {
|
|
155
|
+
cwd: this.projectCwd,
|
|
156
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
157
|
+
shell: process.platform === "win32" && this.binPath.endsWith(".cmd"),
|
|
158
|
+
});
|
|
159
|
+
this.proc.stdout?.on("data", (chunk) => this.handleData(chunk));
|
|
160
|
+
this.proc.stderr?.on("data", (d) => {
|
|
161
|
+
// 可选记录 debug 日志,不干扰输出
|
|
162
|
+
});
|
|
163
|
+
this.proc.on("exit", (code) => {
|
|
164
|
+
this.proc = null;
|
|
165
|
+
this.rejectAllPending(new Error(`Language server exited with code ${code}`));
|
|
166
|
+
});
|
|
167
|
+
try {
|
|
168
|
+
// 发送 initialize 握手
|
|
169
|
+
const initRes = await this.request("initialize", {
|
|
170
|
+
processId: process.pid,
|
|
171
|
+
rootUri: pathToFileURL(this.projectCwd).toString(),
|
|
172
|
+
workspaceFolders: [
|
|
173
|
+
{
|
|
174
|
+
name: "workspace",
|
|
175
|
+
uri: pathToFileURL(this.projectCwd).toString(),
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
capabilities: {
|
|
179
|
+
textDocument: {
|
|
180
|
+
synchronization: { dynamicRegistration: false, willSave: false, willSaveWaitUntil: false, didSave: true },
|
|
181
|
+
definition: { dynamicRegistration: false },
|
|
182
|
+
references: { dynamicRegistration: false },
|
|
183
|
+
hover: { dynamicRegistration: false, contentFormat: ["markdown", "plaintext"] },
|
|
184
|
+
publishDiagnostics: { relatedInformation: true, tagSupport: { valueSet: [1, 2] } },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
this.notify("initialized", {});
|
|
189
|
+
this.initialized = true;
|
|
190
|
+
}
|
|
191
|
+
catch (err) {
|
|
192
|
+
if (this.proc) {
|
|
193
|
+
try {
|
|
194
|
+
this.proc.kill();
|
|
195
|
+
}
|
|
196
|
+
catch { }
|
|
197
|
+
this.proc = null;
|
|
198
|
+
}
|
|
199
|
+
throw err;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
touch() {
|
|
203
|
+
if (this.isShuttingDown)
|
|
204
|
+
return;
|
|
205
|
+
if (this.idleTimer)
|
|
206
|
+
clearTimeout(this.idleTimer);
|
|
207
|
+
// 15 分钟无调用自动回收
|
|
208
|
+
this.idleTimer = setTimeout(() => {
|
|
209
|
+
this.shutdown().catch(() => { });
|
|
210
|
+
this.onIdleEvict();
|
|
211
|
+
}, 15 * 60 * 1000);
|
|
212
|
+
this.idleTimer.unref();
|
|
213
|
+
}
|
|
214
|
+
handleData(chunk) {
|
|
215
|
+
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
216
|
+
while (true) {
|
|
217
|
+
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
218
|
+
if (headerEnd === -1)
|
|
219
|
+
break;
|
|
220
|
+
const header = this.buffer.slice(0, headerEnd).toString("utf8");
|
|
221
|
+
const lenMatch = header.match(/Content-Length:\s*(\d+)/i);
|
|
222
|
+
if (!lenMatch) {
|
|
223
|
+
this.buffer = this.buffer.slice(headerEnd + 4);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const length = parseInt(lenMatch[1], 10);
|
|
227
|
+
const bodyStart = headerEnd + 4;
|
|
228
|
+
if (this.buffer.length < bodyStart + length)
|
|
229
|
+
break;
|
|
230
|
+
const bodyBuf = this.buffer.slice(bodyStart, bodyStart + length);
|
|
231
|
+
this.buffer = this.buffer.slice(bodyStart + length);
|
|
232
|
+
try {
|
|
233
|
+
const msg = JSON.parse(bodyBuf.toString("utf8"));
|
|
234
|
+
this.handleMessage(msg);
|
|
235
|
+
}
|
|
236
|
+
catch { }
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
handleMessage(msg) {
|
|
240
|
+
if (msg.id !== undefined) {
|
|
241
|
+
if (msg.method) {
|
|
242
|
+
// 服务端向客户端发起的请求(如 window/workDoneProgress/create, client/registerCapability)
|
|
243
|
+
// 标准 JSON-RPC 必须回复 result: null,防止上游语言服务器挂起等待
|
|
244
|
+
const reply = JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: null });
|
|
245
|
+
const wire = `Content-Length: ${Buffer.byteLength(reply, "utf8")}\r\n\r\n${reply}`;
|
|
246
|
+
try {
|
|
247
|
+
this.proc?.stdin?.write(wire);
|
|
248
|
+
}
|
|
249
|
+
catch { }
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (this.pendingRequests.has(msg.id)) {
|
|
253
|
+
const { resolve, reject, timer } = this.pendingRequests.get(msg.id);
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
this.pendingRequests.delete(msg.id);
|
|
256
|
+
if (msg.error) {
|
|
257
|
+
reject(new Error(msg.error.message || `LSP error ${msg.error.code}`));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
resolve(msg.result);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
else if (msg.method === "textDocument/publishDiagnostics") {
|
|
265
|
+
const params = msg.params;
|
|
266
|
+
if (params?.uri && Array.isArray(params?.diagnostics)) {
|
|
267
|
+
this.diagnosticsCache.set(params.uri, params.diagnostics);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
request(method, params, timeoutMs = 15000) {
|
|
272
|
+
this.touch();
|
|
273
|
+
if (!this.proc || !this.proc.stdin) {
|
|
274
|
+
return Promise.reject(new Error("Language server is not running"));
|
|
275
|
+
}
|
|
276
|
+
const id = this.nextId++;
|
|
277
|
+
const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params });
|
|
278
|
+
const wire = `Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`;
|
|
279
|
+
return new Promise((res, rej) => {
|
|
280
|
+
const timer = setTimeout(() => {
|
|
281
|
+
this.pendingRequests.delete(id);
|
|
282
|
+
rej(new Error(`LSP request ${method} timed out after ${timeoutMs}ms`));
|
|
283
|
+
}, timeoutMs);
|
|
284
|
+
this.pendingRequests.set(id, { resolve: res, reject: rej, timer });
|
|
285
|
+
this.proc.stdin.write(wire);
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
notify(method, params) {
|
|
289
|
+
this.touch();
|
|
290
|
+
if (!this.proc || !this.proc.stdin)
|
|
291
|
+
return;
|
|
292
|
+
const payload = JSON.stringify({ jsonrpc: "2.0", method, params });
|
|
293
|
+
const wire = `Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`;
|
|
294
|
+
this.proc.stdin.write(wire);
|
|
295
|
+
}
|
|
296
|
+
async syncDocument(absPath) {
|
|
297
|
+
const uri = pathToFileURL(absPath).toString();
|
|
298
|
+
if (!existsSync(absPath))
|
|
299
|
+
return uri;
|
|
300
|
+
const content = readFileSync(absPath, "utf8");
|
|
301
|
+
if (!this.openFiles.has(uri)) {
|
|
302
|
+
this.docVersions.set(uri, 1);
|
|
303
|
+
this.notify("textDocument/didOpen", {
|
|
304
|
+
textDocument: {
|
|
305
|
+
uri,
|
|
306
|
+
languageId: this.languageId,
|
|
307
|
+
version: 1,
|
|
308
|
+
text: content,
|
|
309
|
+
},
|
|
310
|
+
});
|
|
311
|
+
this.openFiles.add(uri);
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const nextVer = (this.docVersions.get(uri) ?? 1) + 1;
|
|
315
|
+
this.docVersions.set(uri, nextVer);
|
|
316
|
+
this.notify("textDocument/didChange", {
|
|
317
|
+
textDocument: { uri, version: nextVer },
|
|
318
|
+
contentChanges: [{ text: content }],
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return uri;
|
|
322
|
+
}
|
|
323
|
+
getDiagnostics(uri) {
|
|
324
|
+
return this.diagnosticsCache.get(uri) ?? [];
|
|
325
|
+
}
|
|
326
|
+
getAllDiagnostics() {
|
|
327
|
+
return new Map(this.diagnosticsCache);
|
|
328
|
+
}
|
|
329
|
+
isAlive() {
|
|
330
|
+
return Boolean(this.proc && !this.isShuttingDown);
|
|
331
|
+
}
|
|
332
|
+
rejectAllPending(err) {
|
|
333
|
+
for (const { reject, timer } of this.pendingRequests.values()) {
|
|
334
|
+
clearTimeout(timer);
|
|
335
|
+
reject(err);
|
|
336
|
+
}
|
|
337
|
+
this.pendingRequests.clear();
|
|
338
|
+
}
|
|
339
|
+
async shutdown() {
|
|
340
|
+
this.isShuttingDown = true;
|
|
341
|
+
if (this.idleTimer) {
|
|
342
|
+
clearTimeout(this.idleTimer);
|
|
343
|
+
this.idleTimer = null;
|
|
344
|
+
}
|
|
345
|
+
if (!this.proc)
|
|
346
|
+
return;
|
|
347
|
+
try {
|
|
348
|
+
await this.request("shutdown", {}, 3000);
|
|
349
|
+
this.notify("exit", {});
|
|
350
|
+
}
|
|
351
|
+
catch { }
|
|
352
|
+
if (this.proc) {
|
|
353
|
+
try {
|
|
354
|
+
this.proc.kill();
|
|
355
|
+
}
|
|
356
|
+
catch { }
|
|
357
|
+
this.proc = null;
|
|
358
|
+
}
|
|
359
|
+
this.rejectAllPending(new Error("LSP client shut down"));
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// ----------------------------------------------------------------------------
|
|
363
|
+
// 全局项目级语言服务器管理池(Project-Level LSP Pool)
|
|
364
|
+
// ----------------------------------------------------------------------------
|
|
365
|
+
class LspServerPool {
|
|
366
|
+
pool = new Map(); // key: `${cwd}::${langKey}`
|
|
367
|
+
inFlight = new Map();
|
|
368
|
+
shuttingDown = false;
|
|
369
|
+
hasAliveClient(projectCwd, filePath) {
|
|
370
|
+
const langInfo = getLanguageForPath(filePath);
|
|
371
|
+
if (!langInfo)
|
|
372
|
+
return false;
|
|
373
|
+
const poolKey = `${projectCwd}::${langInfo.langKey}`;
|
|
374
|
+
const client = this.pool.get(poolKey);
|
|
375
|
+
return Boolean(client && client.isAlive());
|
|
376
|
+
}
|
|
377
|
+
async getClient(projectCwd, filePath) {
|
|
378
|
+
if (this.shuttingDown) {
|
|
379
|
+
return { error: "LSP server pool is shutting down" };
|
|
380
|
+
}
|
|
381
|
+
const langInfo = getLanguageForPath(filePath);
|
|
382
|
+
if (!langInfo) {
|
|
383
|
+
return { error: `No language server mapping for file extension: ${extname(filePath)}` };
|
|
384
|
+
}
|
|
385
|
+
const poolKey = `${projectCwd}::${langInfo.langKey}`;
|
|
386
|
+
const client = this.pool.get(poolKey);
|
|
387
|
+
if (client) {
|
|
388
|
+
client.touch();
|
|
389
|
+
return { client };
|
|
390
|
+
}
|
|
391
|
+
const pending = this.inFlight.get(poolKey);
|
|
392
|
+
if (pending) {
|
|
393
|
+
return pending;
|
|
394
|
+
}
|
|
395
|
+
const task = (async () => {
|
|
396
|
+
// 寻找可用命令
|
|
397
|
+
let resolvedBin = null;
|
|
398
|
+
let resolvedArgs = [];
|
|
399
|
+
let hint = "";
|
|
400
|
+
for (const cmd of langInfo.config.commands) {
|
|
401
|
+
const found = resolveBinary(cmd.bin, projectCwd);
|
|
402
|
+
if (found) {
|
|
403
|
+
resolvedBin = found;
|
|
404
|
+
resolvedArgs = cmd.args;
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
hint = cmd.installHint;
|
|
408
|
+
}
|
|
409
|
+
if (!resolvedBin) {
|
|
410
|
+
return {
|
|
411
|
+
error: `Language server for ${langInfo.config.languageId} not found.\nPlease install it: \`${hint}\``,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
const newClient = new LspClient(projectCwd, langInfo.langKey, resolvedBin, resolvedArgs, langInfo.config.languageId, () => {
|
|
415
|
+
if (this.pool.get(poolKey) === newClient) {
|
|
416
|
+
this.pool.delete(poolKey);
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
try {
|
|
420
|
+
await newClient.start();
|
|
421
|
+
if (this.shuttingDown) {
|
|
422
|
+
await newClient.shutdown();
|
|
423
|
+
return { error: "LSP server pool is shutting down" };
|
|
424
|
+
}
|
|
425
|
+
this.pool.set(poolKey, newClient);
|
|
426
|
+
return { client: newClient };
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
return { error: `Failed to start ${langInfo.config.languageId} language server: ${err.message}` };
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
this.inFlight.delete(poolKey);
|
|
433
|
+
}
|
|
434
|
+
})();
|
|
435
|
+
this.inFlight.set(poolKey, task);
|
|
436
|
+
return task;
|
|
437
|
+
}
|
|
438
|
+
async shutdownAll() {
|
|
439
|
+
this.shuttingDown = true;
|
|
440
|
+
const inFlightTasks = [...this.inFlight.values()];
|
|
441
|
+
this.inFlight.clear();
|
|
442
|
+
await Promise.allSettled(inFlightTasks);
|
|
443
|
+
const promises = [...this.pool.values()].map((c) => c.shutdown());
|
|
444
|
+
this.pool.clear();
|
|
445
|
+
await Promise.allSettled(promises);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
export const globalLspPool = new LspServerPool();
|
|
449
|
+
/**
|
|
450
|
+
* 辅助函数:在文件改动后,若已有存活的语言服务器,获取即时编译报错(Writethrough Diagnostics)
|
|
451
|
+
*/
|
|
452
|
+
export async function getLiveLspDiagnostics(absPath, cwd) {
|
|
453
|
+
if (!globalLspPool.hasAliveClient(cwd, absPath)) {
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
const res = await globalLspPool.getClient(cwd, absPath);
|
|
457
|
+
if ("error" in res)
|
|
458
|
+
return null;
|
|
459
|
+
const { client } = res;
|
|
460
|
+
const uri = await client.syncDocument(absPath);
|
|
461
|
+
// 稍等 80ms 让后台增量分析返回
|
|
462
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
463
|
+
const diags = client.getDiagnostics(uri);
|
|
464
|
+
const errors = diags.filter((d) => d.severity === 1 || !d.severity);
|
|
465
|
+
if (errors.length === 0)
|
|
466
|
+
return null;
|
|
467
|
+
const rel = absPath.startsWith(cwd) ? absPath.slice(cwd.length).replace(/^[/\\]/, "") : absPath;
|
|
468
|
+
const lines = errors.slice(0, 5).map((e) => {
|
|
469
|
+
const line = e.range.start.line + 1;
|
|
470
|
+
const col = e.range.start.character + 1;
|
|
471
|
+
const code = e.code ? ` (${e.code})` : "";
|
|
472
|
+
return `• ${rel}:${line}:${col} - ${e.message}${code}`;
|
|
473
|
+
});
|
|
474
|
+
return `⚠️ Post-edit Diagnostics (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`;
|
|
475
|
+
}
|
|
476
|
+
export function makeLspTool(options) {
|
|
477
|
+
const cwd = options.cwd;
|
|
478
|
+
return defineTool({
|
|
479
|
+
name: LSP_TOOL_NAME,
|
|
480
|
+
label: "LSP code intelligence",
|
|
481
|
+
description: `Query language intelligence from Language Server Protocol (LSP) across the workspace.
|
|
482
|
+
Provides IDE-grade semantic analysis to prevent guessing and hallucinating symbol references.
|
|
483
|
+
Supported actions:
|
|
484
|
+
- \`definition\`: Jump to definition of the symbol at \`line\` & \`character\` in \`path\` (returns file, line, and code snippet).
|
|
485
|
+
- \`references\`: Find all workspace references/usages of the symbol at \`line\` & \`character\` in \`path\`.
|
|
486
|
+
- \`hover\`: Get type signature and documentation (Docstring/Markdown) for symbol at \`line\` & \`character\`.
|
|
487
|
+
- \`diagnostics\`: Get compiler/type errors and warnings for \`path\` (or pass no line to check whole file).
|
|
488
|
+
Note: Line numbers are 1-indexed.`,
|
|
489
|
+
parameters: Type.Object({
|
|
490
|
+
action: Type.Union([Type.Literal("definition"), Type.Literal("references"), Type.Literal("hover"), Type.Literal("diagnostics")], {
|
|
491
|
+
description: "The LSP operation to perform.",
|
|
492
|
+
}),
|
|
493
|
+
path: Type.String({
|
|
494
|
+
description: "Workspace-relative or absolute path to the target source file.",
|
|
495
|
+
}),
|
|
496
|
+
line: Type.Optional(Type.Number({
|
|
497
|
+
description: "1-indexed line number in the source file.",
|
|
498
|
+
})),
|
|
499
|
+
character: Type.Optional(Type.Number({
|
|
500
|
+
description: "1-indexed column/character position (defaults to 1).",
|
|
501
|
+
})),
|
|
502
|
+
timeout: Type.Optional(Type.Number({
|
|
503
|
+
description: "Timeout in seconds (defaults to 15).",
|
|
504
|
+
})),
|
|
505
|
+
}),
|
|
506
|
+
async execute(_callId, params, _signal, _onUpdate, _ctx) {
|
|
507
|
+
const action = params.action;
|
|
508
|
+
const targetPath = params.path;
|
|
509
|
+
const absPath = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
|
|
510
|
+
const line = typeof params.line === "number" ? Math.max(1, params.line) : 1;
|
|
511
|
+
const character = typeof params.character === "number" ? Math.max(1, params.character) : 1;
|
|
512
|
+
const timeoutMs = (params.timeout ?? 15) * 1000;
|
|
513
|
+
const rel = relative(cwd, absPath);
|
|
514
|
+
if (rel === ".." ||
|
|
515
|
+
rel.startsWith(".." + sep) ||
|
|
516
|
+
rel.startsWith("../") ||
|
|
517
|
+
rel.startsWith("..\\") ||
|
|
518
|
+
isAbsolute(rel)) {
|
|
519
|
+
return {
|
|
520
|
+
content: [{ type: "text", text: `Error: Path traversal denied: ${targetPath} is outside workspace.` }],
|
|
521
|
+
details: { ok: false, error: "Path traversal denied" },
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (!existsSync(absPath)) {
|
|
525
|
+
return {
|
|
526
|
+
content: [{ type: "text", text: `Error: File not found: ${targetPath}` }],
|
|
527
|
+
details: { ok: false, error: "File not found" },
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
const clientRes = await globalLspPool.getClient(cwd, absPath);
|
|
531
|
+
if ("error" in clientRes) {
|
|
532
|
+
return {
|
|
533
|
+
content: [{ type: "text", text: `LSP Error: ${clientRes.error}` }],
|
|
534
|
+
details: { ok: false, error: clientRes.error },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
const client = clientRes.client;
|
|
538
|
+
const uri = await client.syncDocument(absPath);
|
|
539
|
+
// 0-indexed positions for LSP protocol
|
|
540
|
+
const position = {
|
|
541
|
+
line: line - 1,
|
|
542
|
+
character: character - 1,
|
|
543
|
+
};
|
|
544
|
+
try {
|
|
545
|
+
if (action === "definition") {
|
|
546
|
+
const result = await client.request("textDocument/definition", { textDocument: { uri }, position }, timeoutMs);
|
|
547
|
+
const locs = Array.isArray(result) ? result : result ? [result] : [];
|
|
548
|
+
if (locs.length === 0) {
|
|
549
|
+
return {
|
|
550
|
+
content: [{ type: "text", text: `No definition found for symbol at ${targetPath}:${line}:${character}` }],
|
|
551
|
+
details: { ok: true, locations: [] },
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
const formatted = locs.map((loc) => {
|
|
555
|
+
const targetUri = loc.targetUri || loc.uri || "";
|
|
556
|
+
let defPath = targetUri;
|
|
557
|
+
try {
|
|
558
|
+
if (targetUri.startsWith("file:")) {
|
|
559
|
+
defPath = fileURLToPath(targetUri);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
catch { }
|
|
563
|
+
const targetRange = loc.targetSelectionRange || loc.targetRange || loc.range;
|
|
564
|
+
const defRel = defPath.startsWith(cwd) ? defPath.slice(cwd.length).replace(/^[/\\]/, "") : defPath;
|
|
565
|
+
const defLine = (targetRange?.start?.line ?? 0) + 1;
|
|
566
|
+
const defCol = (targetRange?.start?.character ?? 0) + 1;
|
|
567
|
+
let snippet = "";
|
|
568
|
+
if (existsSync(defPath)) {
|
|
569
|
+
const fileLines = readFileSync(defPath, "utf8").split(/\r?\n/);
|
|
570
|
+
const startL = Math.max(0, defLine - 1);
|
|
571
|
+
const endL = Math.min(fileLines.length, defLine + 3);
|
|
572
|
+
snippet = fileLines
|
|
573
|
+
.slice(startL, endL)
|
|
574
|
+
.map((l, idx) => `${startL + idx + 1}: ${l}`)
|
|
575
|
+
.join("\n");
|
|
576
|
+
}
|
|
577
|
+
return `• ${defRel}:${defLine}:${defCol}\n\`\`\`\n${snippet}\n\`\`\``;
|
|
578
|
+
});
|
|
579
|
+
return {
|
|
580
|
+
content: [{ type: "text", text: `Definitions (${locs.length}):\n\n${formatted.join("\n\n")}` }],
|
|
581
|
+
details: { ok: true, locations: locs },
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
if (action === "references") {
|
|
585
|
+
const result = await client.request("textDocument/references", {
|
|
586
|
+
textDocument: { uri },
|
|
587
|
+
position,
|
|
588
|
+
context: { includeDeclaration: true },
|
|
589
|
+
}, timeoutMs);
|
|
590
|
+
const locs = Array.isArray(result) ? result : [];
|
|
591
|
+
if (locs.length === 0) {
|
|
592
|
+
return {
|
|
593
|
+
content: [{ type: "text", text: `No references found for symbol at ${targetPath}:${line}:${character}` }],
|
|
594
|
+
details: { ok: true, references: [] },
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
const formatted = locs.slice(0, 25).map((loc) => {
|
|
598
|
+
const refPath = fileURLToPath(loc.uri);
|
|
599
|
+
const refRel = refPath.startsWith(cwd) ? refPath.slice(cwd.length).replace(/^[/\\]/, "") : refPath;
|
|
600
|
+
const refLine = loc.range.start.line + 1;
|
|
601
|
+
const refCol = loc.range.start.character + 1;
|
|
602
|
+
return `• ${refRel}:${refLine}:${refCol}`;
|
|
603
|
+
});
|
|
604
|
+
const tail = locs.length > 25 ? `\n... and ${locs.length - 25} more references` : "";
|
|
605
|
+
return {
|
|
606
|
+
content: [
|
|
607
|
+
{
|
|
608
|
+
type: "text",
|
|
609
|
+
text: `Found ${locs.length} reference${locs.length > 1 ? "s" : ""}:\n${formatted.join("\n")}${tail}`,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
details: { ok: true, count: locs.length, references: locs },
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
if (action === "hover") {
|
|
616
|
+
const result = await client.request("textDocument/hover", { textDocument: { uri }, position }, timeoutMs);
|
|
617
|
+
if (!result || !result.contents) {
|
|
618
|
+
return {
|
|
619
|
+
content: [{ type: "text", text: `No hover information available at ${targetPath}:${line}:${character}` }],
|
|
620
|
+
details: { ok: true, hover: null },
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
let hoverText = "";
|
|
624
|
+
const c = result.contents;
|
|
625
|
+
if (typeof c === "string") {
|
|
626
|
+
hoverText = c;
|
|
627
|
+
}
|
|
628
|
+
else if (Array.isArray(c)) {
|
|
629
|
+
hoverText = c.map((item) => (typeof item === "string" ? item : item.value)).join("\n\n");
|
|
630
|
+
}
|
|
631
|
+
else if (typeof c === "object" && c.value) {
|
|
632
|
+
hoverText = c.value;
|
|
633
|
+
}
|
|
634
|
+
return {
|
|
635
|
+
content: [{ type: "text", text: hoverText ? `Hover Info:\n${hoverText}` : "Empty hover information" }],
|
|
636
|
+
details: { ok: true, hover: result },
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
if (action === "diagnostics") {
|
|
640
|
+
// 给予极短缓冲以确保 publishDiagnostics 缓存已收到
|
|
641
|
+
await new Promise((r) => setTimeout(r, 120));
|
|
642
|
+
const diags = client.getDiagnostics(uri);
|
|
643
|
+
if (diags.length === 0) {
|
|
644
|
+
return {
|
|
645
|
+
content: [{ type: "text", text: `No diagnostics (clean): ${targetPath}` }],
|
|
646
|
+
details: { ok: true, diagnostics: [] },
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
const formatted = diags.slice(0, 30).map((d) => {
|
|
650
|
+
const sev = d.severity === 1 ? "ERROR" : d.severity === 2 ? "WARN" : "INFO";
|
|
651
|
+
const startLine = d.range.start.line + 1;
|
|
652
|
+
const startCol = d.range.start.character + 1;
|
|
653
|
+
const code = d.code ? ` [${d.code}]` : "";
|
|
654
|
+
return `[${sev}] line ${startLine}:${startCol}${code} - ${d.message}`;
|
|
655
|
+
});
|
|
656
|
+
return {
|
|
657
|
+
content: [
|
|
658
|
+
{
|
|
659
|
+
type: "text",
|
|
660
|
+
text: `Diagnostics for ${targetPath} (${diags.length}):\n${formatted.join("\n")}`,
|
|
661
|
+
},
|
|
662
|
+
],
|
|
663
|
+
details: { ok: true, diagnostics: diags },
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
content: [{ type: "text", text: `Unsupported action: ${action}` }],
|
|
668
|
+
details: { ok: false, error: "Unsupported action" },
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
return {
|
|
673
|
+
content: [{ type: "text", text: `LSP request failed: ${err.message}` }],
|
|
674
|
+
details: { ok: false, error: err.message },
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
}
|