pi-web-ui 0.95.0 → 0.96.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.
Files changed (74) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/bin/pi-web-ui.mjs +159 -31
  3. package/dist/server/agent-service.js +880 -240
  4. package/dist/server/approval-rules.js +56 -10
  5. package/dist/server/attachment-store.js +21 -4
  6. package/dist/server/attachments.js +30 -3
  7. package/dist/server/bg-servers.js +45 -7
  8. package/dist/server/claim-files-tool.js +4 -8
  9. package/dist/server/claim-store.js +4 -2
  10. package/dist/server/client-state.js +63 -14
  11. package/dist/server/compact-context-tool.js +6 -5
  12. package/dist/server/composer-drafts.js +9 -0
  13. package/dist/server/control-socket.js +57 -28
  14. package/dist/server/conversation-read-tool.js +7 -17
  15. package/dist/server/dangling-tools.js +72 -5
  16. package/dist/server/delegate-task.js +25 -28
  17. package/dist/server/dsh/dsh-agent-service.js +19 -6
  18. package/dist/server/edit-soft-tool.js +14 -11
  19. package/dist/server/eval-tool.js +67 -14
  20. package/dist/server/file-archives.js +16 -5
  21. package/dist/server/files-service.js +30 -11
  22. package/dist/server/goal-service.js +141 -19
  23. package/dist/server/hashline-engine.js +59 -21
  24. package/dist/server/host-guard.js +94 -0
  25. package/dist/server/i18n.js +3 -3
  26. package/dist/server/index.js +306 -78
  27. package/dist/server/lsp-tool.js +752 -60
  28. package/dist/server/mcp-bridge.js +47 -3
  29. package/dist/server/model-admin.js +94 -34
  30. package/dist/server/office-parse.js +96 -30
  31. package/dist/server/patch-tool.js +9 -15
  32. package/dist/server/permission-preset.js +25 -0
  33. package/dist/server/plan-manager.js +11 -1
  34. package/dist/server/plugin-catalog-sync.js +36 -19
  35. package/dist/server/plugin-catalog.js +11 -4
  36. package/dist/server/plugin-facilities.js +92 -21
  37. package/dist/server/plugin-llm.js +71 -65
  38. package/dist/server/plugin-project.js +70 -2
  39. package/dist/server/plugin-updater.js +115 -12
  40. package/dist/server/plugins.js +205 -44
  41. package/dist/server/present-files-tool.js +9 -12
  42. package/dist/server/process-utils.js +4 -0
  43. package/dist/server/prompt-composer.js +9 -0
  44. package/dist/server/protocol-version.js +1 -1
  45. package/dist/server/read-tool.js +69 -30
  46. package/dist/server/resolve-global-sdk.js +30 -16
  47. package/dist/server/schedule-agent-tool.js +12 -15
  48. package/dist/server/sdk-origin.js +18 -2
  49. package/dist/server/serialize.js +108 -21
  50. package/dist/server/settings-service.js +4 -2
  51. package/dist/server/skill-tool.js +5 -7
  52. package/dist/server/subagent-templates.js +51 -0
  53. package/dist/server/subagents.js +48 -72
  54. package/dist/server/terminals.js +181 -69
  55. package/dist/server/tool-manager.js +24 -7
  56. package/dist/server/tool-overrides.js +59 -0
  57. package/dist/server/update-check.js +28 -1
  58. package/dist/server/uploads.js +17 -2
  59. package/dist/server/wait-subscription-scan.js +18 -21
  60. package/dist/server/ws-client-id.js +29 -0
  61. package/dist/server/ws-pending-queue.js +60 -0
  62. package/extensions/webui.ts +60 -2
  63. package/package.json +4 -2
  64. package/plugins/catalog.json +20 -0
  65. package/themes/aetheris.css +457 -0
  66. package/themes/zhupi-dark.css +658 -0
  67. package/themes/zhupi.css +711 -0
  68. package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-DDChcYOh.js} +1 -1
  69. package/web/dist/assets/index-Do9RgJC3.js +366 -0
  70. package/web/dist/assets/index-DryOsILO.css +1 -0
  71. package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
  72. package/web/dist/index.html +3 -3
  73. package/web/dist/assets/index-BXnCQL9p.js +0 -364
  74. package/web/dist/assets/index-RpIGFyi_.css +0 -1
@@ -12,40 +12,86 @@
12
12
  * 4. diagnostics:获取文件或项目的实时编译与类型检查错误。
13
13
  * - 闲置自动回收:15 分钟无请求自动休眠退出,释放系统内存。
14
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";
15
+ import { spawn } from "node:child_process";
16
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { delimiter, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
18
19
  import { pathToFileURL, fileURLToPath } from "node:url";
19
20
  import { defineTool } from "@earendil-works/pi-coding-agent";
20
21
  import { Type } from "typebox";
21
22
  export const LSP_TOOL_NAME = "lsp";
23
+ export const LSP_SYMBOL_KINDS = {
24
+ 1: "File",
25
+ 2: "Module",
26
+ 3: "Namespace",
27
+ 4: "Package",
28
+ 5: "Class",
29
+ 6: "Method",
30
+ 7: "Property",
31
+ 8: "Field",
32
+ 9: "Constructor",
33
+ 10: "Enum",
34
+ 11: "Interface",
35
+ 12: "Function",
36
+ 13: "Variable",
37
+ 14: "Constant",
38
+ 15: "String",
39
+ 16: "Number",
40
+ 17: "Boolean",
41
+ 18: "Array",
42
+ 19: "Object",
43
+ 20: "Key",
44
+ 21: "Null",
45
+ 22: "EnumMember",
46
+ 23: "Struct",
47
+ 24: "Event",
48
+ 25: "Operator",
49
+ 26: "TypeParameter",
50
+ };
22
51
  const LANGUAGE_SERVER_CATALOG = {
23
52
  ts: {
24
53
  languageId: "typescript",
25
54
  commands: [
55
+ {
56
+ bin: "vtsls",
57
+ args: ["--stdio"],
58
+ installHint: "npm install -g @vtsls/language-server",
59
+ npmPackage: "@vtsls/language-server typescript",
60
+ },
26
61
  {
27
62
  bin: "typescript-language-server",
28
63
  args: ["--stdio"],
29
64
  installHint: "npm install -g typescript-language-server typescript",
65
+ npmPackage: "typescript-language-server typescript",
30
66
  },
31
- { bin: "vtsls", args: ["--stdio"], installHint: "npm install -g @vtsls/language-server" },
32
67
  ],
33
68
  },
34
69
  js: {
35
70
  languageId: "javascript",
36
71
  commands: [
72
+ {
73
+ bin: "vtsls",
74
+ args: ["--stdio"],
75
+ installHint: "npm install -g @vtsls/language-server",
76
+ npmPackage: "@vtsls/language-server typescript",
77
+ },
37
78
  {
38
79
  bin: "typescript-language-server",
39
80
  args: ["--stdio"],
40
81
  installHint: "npm install -g typescript-language-server typescript",
82
+ npmPackage: "typescript-language-server typescript",
41
83
  },
42
- { bin: "vtsls", args: ["--stdio"], installHint: "npm install -g @vtsls/language-server" },
43
84
  ],
44
85
  },
45
86
  py: {
46
87
  languageId: "python",
47
88
  commands: [
48
- { bin: "pyright-langserver", args: ["--stdio"], installHint: "npm install -g pyright" },
89
+ {
90
+ bin: "pyright-langserver",
91
+ args: ["--stdio"],
92
+ installHint: "npm install -g pyright",
93
+ npmPackage: "pyright",
94
+ },
49
95
  { bin: "pyright", args: ["--stdio"], installHint: "pip install pyright" },
50
96
  { bin: "pylsp", args: [], installHint: "pip install python-lsp-server" },
51
97
  ],
@@ -85,42 +131,158 @@ function getLanguageForPath(filePath) {
85
131
  return { langKey: "cpp", config: LANGUAGE_SERVER_CATALOG.cpp };
86
132
  return null;
87
133
  }
88
- /** 探测二进制是否可在当前系统执行(PATH 或 workspace node_modules/.bin) */
89
- function resolveBinary(cmd, cwd) {
134
+ /** 二进制探测结果的内存缓存(cwd + cmd 为 key;用户态安装成功后按包失效) */
135
+ const resolveBinaryCache = new Map();
136
+ export function clearResolveBinaryCache() {
137
+ resolveBinaryCache.clear();
138
+ }
139
+ /** 探测二进制是否可在当前系统执行(优先项目本地、Pi 生态共享目录、用户态托管目录、系统 PATH) */
140
+ export function resolveBinary(cmd, cwd) {
141
+ const cacheKey = `${cwd}::${cmd}`;
142
+ const cached = resolveBinaryCache.get(cacheKey);
143
+ if (cached !== undefined)
144
+ return cached;
145
+ const found = resolveBinaryUncached(cmd, cwd);
146
+ resolveBinaryCache.set(cacheKey, found);
147
+ return found;
148
+ }
149
+ function resolveBinaryUncached(cmd, cwd) {
90
150
  const isWin = process.platform === "win32";
91
- const candidates = [];
151
+ const exts = isWin ? [".cmd", ".exe", ".bat", ""] : [""];
92
152
  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);
153
+ if (existsSync(cmd))
154
+ return cmd;
99
155
  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`);
156
+ for (const ext of exts) {
157
+ if (ext && existsSync(cmd + ext))
158
+ return cmd + ext;
159
+ }
108
160
  }
161
+ return null;
162
+ }
163
+ // 1. 本地 workspace node_modules/.bin 优先
164
+ const localBinDir = resolve(cwd, "node_modules", ".bin");
165
+ for (const ext of exts) {
166
+ const target = join(localBinDir, cmd + ext);
167
+ if (existsSync(target))
168
+ return target;
109
169
  }
110
- for (const cand of candidates) {
111
- if (isAbsolute(cand) && existsSync(cand))
112
- return cand;
170
+ // 2. Pi 生态工具与用户态目录(pi-lens / pi-web 用户态托管,零权限直接复用)
171
+ const home = homedir();
172
+ const sharedDirs = [
173
+ join(home, ".pi-lens", "tools", "node_modules", ".bin"),
174
+ join(home, ".pi-web", "lsp-servers", "node_modules", ".bin"),
175
+ join(home, ".pi", "agent", "tools", "node_modules", ".bin"),
176
+ ];
177
+ for (const sharedDir of sharedDirs) {
178
+ for (const ext of exts) {
179
+ const target = join(sharedDir, cmd + ext);
180
+ if (existsSync(target))
181
+ return target;
182
+ }
113
183
  }
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];
184
+ // 3. 遍历系统 PATH 目录(纯文件系统检查,零子进程开销、杜绝 Windows cmd.exe 引号卡死)
185
+ const pathDirs = (process.env.PATH || "").split(delimiter);
186
+ for (const dir of pathDirs) {
187
+ if (!dir)
188
+ continue;
189
+ for (const ext of exts) {
190
+ const target = join(dir, cmd + ext);
191
+ if (existsSync(target))
192
+ return target;
193
+ }
120
194
  }
121
- catch { }
122
195
  return null;
123
196
  }
197
+ const installTasks = new Map();
198
+ /** 安装失败的负缓存(pkg -> 失败时间戳):失败后 5 分钟内不再重复执行 120s 的 npm install */
199
+ const installFailedAt = new Map();
200
+ const INSTALL_FAIL_NEGATIVE_CACHE_MS = 5 * 60 * 1000;
201
+ /**
202
+ * 在用户专属目录(~/.pi-web/lsp-servers)执行无侵入、免 sudo/root 的语言服务包按需安装。
203
+ * 注意:必须经用户显式授权(lsp 工具的 allowInstall 参数)后才能调用,不得静默触发。
204
+ */
205
+ export async function autoInstallLanguageServer(pkg) {
206
+ const existing = installTasks.get(pkg);
207
+ if (existing)
208
+ return existing;
209
+ const failedAt = installFailedAt.get(pkg);
210
+ if (failedAt !== undefined && Date.now() - failedAt < INSTALL_FAIL_NEGATIVE_CACHE_MS) {
211
+ console.warn(`[LSP] Skip auto-install for ${pkg}: failed recently, retry later with allowInstall`);
212
+ return false;
213
+ }
214
+ const task = (async () => {
215
+ try {
216
+ const userLspDir = join(homedir(), ".pi-web", "lsp-servers");
217
+ console.warn(`[LSP] Installing language server [${pkg}] into ${userLspDir} (user-space, no sudo)…`);
218
+ mkdirSync(userLspDir, { recursive: true });
219
+ const pkgJson = join(userLspDir, "package.json");
220
+ if (!existsSync(pkgJson)) {
221
+ writeFileSync(pkgJson, JSON.stringify({ name: "pi-web-lsp-servers", private: true }) + "\n");
222
+ }
223
+ const pkgs = pkg.split(/\s+/).filter(Boolean);
224
+ const isWin = process.platform === "win32";
225
+ const npmCmd = isWin ? "npm.cmd" : "npm";
226
+ let stderr = "";
227
+ await new Promise((resolve, reject) => {
228
+ const proc = spawn(npmCmd, ["install", "--no-audit", "--no-fund", "--save-dev", ...pkgs], {
229
+ cwd: userLspDir,
230
+ stdio: ["ignore", "ignore", "pipe"],
231
+ shell: isWin,
232
+ });
233
+ const timer = setTimeout(() => {
234
+ if (isWin) {
235
+ // shell:true 时 proc.pid 只是 cmd.exe 的 PID——proc.kill() 只杀得到
236
+ // cmd.exe,npm/node 整棵子进程树会残留。taskkill /T 连树强杀。
237
+ if (typeof proc.pid === "number") {
238
+ const killer = spawn("taskkill", ["/PID", String(proc.pid), "/T", "/F"], { stdio: "ignore" });
239
+ killer.on("error", () => { });
240
+ }
241
+ }
242
+ else {
243
+ // POSIX:npm 由非 shell 直接 spawn,kill 其主进程即可让安装流程终止
244
+ //(取 SIGKILL 而非 detached 进程组方案:不改变子进程组语义,安装
245
+ // 超时本身是罕见路径,残余 node 子进程随 npm 主进程退出被回收)。
246
+ try {
247
+ proc.kill("SIGKILL");
248
+ }
249
+ catch { }
250
+ }
251
+ reject(new Error("npm install timed out"));
252
+ }, 120_000);
253
+ proc.stderr?.on("data", (chunk) => {
254
+ stderr += chunk.toString("utf8");
255
+ });
256
+ proc.on("error", (err) => {
257
+ clearTimeout(timer);
258
+ reject(err);
259
+ });
260
+ proc.on("exit", (code) => {
261
+ clearTimeout(timer);
262
+ if (code === 0)
263
+ resolve();
264
+ else
265
+ reject(new Error(`npm install exited with code ${code}${stderr ? `: ${stderr.slice(-500)}` : ""}`));
266
+ });
267
+ });
268
+ console.warn(`[LSP] Language server [${pkg}] installed, resolving binaries…`);
269
+ // 新二进制落盘,探测缓存失效
270
+ clearResolveBinaryCache();
271
+ installFailedAt.delete(pkg);
272
+ return true;
273
+ }
274
+ catch (err) {
275
+ console.warn(`[LSP] Auto-install failed for ${pkg}: ${err.message}`);
276
+ installFailedAt.set(pkg, Date.now());
277
+ return false;
278
+ }
279
+ finally {
280
+ installTasks.delete(pkg);
281
+ }
282
+ })();
283
+ installTasks.set(pkg, task);
284
+ return task;
285
+ }
124
286
  // ----------------------------------------------------------------------------
125
287
  // JSON-RPC 2.0 传输层与单项目 LSP 客户端
126
288
  // ----------------------------------------------------------------------------
@@ -130,7 +292,7 @@ export class LspClient {
130
292
  binPath;
131
293
  binArgs;
132
294
  languageId;
133
- onIdleEvict;
295
+ onEvict;
134
296
  proc = null;
135
297
  nextId = 1;
136
298
  pendingRequests = new Map();
@@ -141,28 +303,46 @@ export class LspClient {
141
303
  idleTimer = null;
142
304
  initialized = false;
143
305
  isShuttingDown = false;
144
- constructor(projectCwd, langKey, binPath, binArgs, languageId, onIdleEvict) {
306
+ constructor(projectCwd, langKey, binPath, binArgs, languageId,
307
+ /** 客户端不可用(空闲回收 / 进程退出 / spawn 失败)时从池移除的回调 */
308
+ onEvict) {
145
309
  this.projectCwd = projectCwd;
146
310
  this.langKey = langKey;
147
311
  this.binPath = binPath;
148
312
  this.binArgs = binArgs;
149
313
  this.languageId = languageId;
150
- this.onIdleEvict = onIdleEvict;
314
+ this.onEvict = onEvict;
151
315
  }
152
316
  async start() {
153
317
  this.touch();
154
318
  this.proc = spawn(this.binPath, this.binArgs, {
155
319
  cwd: this.projectCwd,
156
320
  stdio: ["pipe", "pipe", "pipe"],
157
- shell: process.platform === "win32" && this.binPath.endsWith(".cmd"),
321
+ shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(this.binPath),
158
322
  });
159
323
  this.proc.stdout?.on("data", (chunk) => this.handleData(chunk));
160
324
  this.proc.stderr?.on("data", (d) => {
161
325
  // 可选记录 debug 日志,不干扰输出
162
326
  });
327
+ // spawn 失败(ENOENT/EACCES 等)只触发 'error',进程从未启动时不会触发
328
+ // 'exit'——不监听会让 initialize 等请求挂到超时,客户端还留在池里被误判存活。
329
+ this.proc.on("error", (err) => {
330
+ this.proc = null;
331
+ this.rejectAllPending(new Error(`Language server failed to start: ${err.message}`));
332
+ this.onEvict();
333
+ });
334
+ // stdin 写错误(对端退出后的 EPIPE 等)在 stream 上异步 emit,不监听会以
335
+ // uncaughtException 崩掉整个服务进程。这里统一兜底:标记死亡 + 清理挂起请求。
336
+ this.proc.stdin?.on("error", (err) => {
337
+ this.proc = null;
338
+ this.rejectAllPending(new Error(`Language server stdin error: ${err.message}`));
339
+ this.onEvict();
340
+ });
163
341
  this.proc.on("exit", (code) => {
164
342
  this.proc = null;
165
343
  this.rejectAllPending(new Error(`Language server exited with code ${code}`));
344
+ // 进程退出即从池移除,防止后续请求命中死客户端
345
+ this.onEvict();
166
346
  });
167
347
  try {
168
348
  // 发送 initialize 握手
@@ -207,7 +387,7 @@ export class LspClient {
207
387
  // 15 分钟无调用自动回收
208
388
  this.idleTimer = setTimeout(() => {
209
389
  this.shutdown().catch(() => { });
210
- this.onIdleEvict();
390
+ this.onEvict();
211
391
  }, 15 * 60 * 1000);
212
392
  this.idleTimer.unref();
213
393
  }
@@ -282,7 +462,16 @@ export class LspClient {
282
462
  rej(new Error(`LSP request ${method} timed out after ${timeoutMs}ms`));
283
463
  }, timeoutMs);
284
464
  this.pendingRequests.set(id, { resolve: res, reject: rej, timer });
285
- this.proc.stdin.write(wire);
465
+ try {
466
+ this.proc.stdin.write(wire);
467
+ }
468
+ catch (err) {
469
+ // 同步写失败(stream 已销毁等):撤销挂起请求;异步 EPIPE 由 start() 里的
470
+ // stdin 'error' 监听兜底,这里只需保证 promise 被 reject 而非向上抛。
471
+ clearTimeout(timer);
472
+ this.pendingRequests.delete(id);
473
+ rej(new Error(`Language server stdin write failed: ${err.message}`));
474
+ }
286
475
  });
287
476
  }
288
477
  notify(method, params) {
@@ -291,7 +480,12 @@ export class LspClient {
291
480
  return;
292
481
  const payload = JSON.stringify({ jsonrpc: "2.0", method, params });
293
482
  const wire = `Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}`;
294
- this.proc.stdin.write(wire);
483
+ try {
484
+ this.proc.stdin.write(wire);
485
+ }
486
+ catch {
487
+ // 通知写失败只影响状态同步,不值得打断调用链;异步 EPIPE 由 stdin 'error' 监听兜底
488
+ }
295
489
  }
296
490
  async syncDocument(absPath) {
297
491
  const uri = pathToFileURL(absPath).toString();
@@ -374,7 +568,7 @@ class LspServerPool {
374
568
  const client = this.pool.get(poolKey);
375
569
  return Boolean(client && client.isAlive());
376
570
  }
377
- async getClient(projectCwd, filePath) {
571
+ async getClient(projectCwd, filePath, opts) {
378
572
  if (this.shuttingDown) {
379
573
  return { error: "LSP server pool is shutting down" };
380
574
  }
@@ -385,8 +579,13 @@ class LspServerPool {
385
579
  const poolKey = `${projectCwd}::${langInfo.langKey}`;
386
580
  const client = this.pool.get(poolKey);
387
581
  if (client) {
388
- client.touch();
389
- return { client };
582
+ // 池命中必须复检存活:进程退出事件与请求之间有竞态窗口(exit 回调排队、
583
+ // spawn error 未触发 exit),死客户端留在池里会让所有请求挂到超时。
584
+ if (client.isAlive()) {
585
+ client.touch();
586
+ return { client };
587
+ }
588
+ this.pool.delete(poolKey);
390
589
  }
391
590
  const pending = this.inFlight.get(poolKey);
392
591
  if (pending) {
@@ -397,6 +596,7 @@ class LspServerPool {
397
596
  let resolvedBin = null;
398
597
  let resolvedArgs = [];
399
598
  let hint = "";
599
+ let autoPkg = undefined;
400
600
  for (const cmd of langInfo.config.commands) {
401
601
  const found = resolveBinary(cmd.bin, projectCwd);
402
602
  if (found) {
@@ -405,10 +605,29 @@ class LspServerPool {
405
605
  break;
406
606
  }
407
607
  hint = cmd.installHint;
608
+ if (!autoPkg && cmd.npmPackage)
609
+ autoPkg = cmd.npmPackage;
610
+ }
611
+ // 用户态按需安装必须经用户显式授权(allowInstall),避免在工具调用链里无提示联网 npm install
612
+ if (!resolvedBin && autoPkg && opts?.allowInstall === true) {
613
+ const ok = await autoInstallLanguageServer(autoPkg);
614
+ if (ok) {
615
+ for (const cmd of langInfo.config.commands) {
616
+ const found = resolveBinary(cmd.bin, projectCwd);
617
+ if (found) {
618
+ resolvedBin = found;
619
+ resolvedArgs = cmd.args;
620
+ break;
621
+ }
622
+ }
623
+ }
408
624
  }
409
625
  if (!resolvedBin) {
626
+ const installGateHint = autoPkg
627
+ ? `\nOr retry this tool call with { "allowInstall": true } to install \`${autoPkg}\` into ~/.pi-web/lsp-servers (user-space, no sudo) automatically.`
628
+ : "";
410
629
  return {
411
- error: `Language server for ${langInfo.config.languageId} not found.\nPlease install it: \`${hint}\``,
630
+ error: `Language server for ${langInfo.config.languageId} not found.\nPlease install it: \`${hint}\`${installGateHint}`,
412
631
  };
413
632
  }
414
633
  const newClient = new LspClient(projectCwd, langInfo.langKey, resolvedBin, resolvedArgs, langInfo.config.languageId, () => {
@@ -416,6 +635,8 @@ class LspServerPool {
416
635
  this.pool.delete(poolKey);
417
636
  }
418
637
  });
638
+ // 启动失败时客户端尚在 start() 内部、池里没有它,onEvict 是空操作;
639
+ // 成功后再退出/回收才会真正从池移除。
419
640
  try {
420
641
  await newClient.start();
421
642
  if (this.shuttingDown) {
@@ -473,26 +694,140 @@ export async function getLiveLspDiagnostics(absPath, cwd) {
473
694
  });
474
695
  return `⚠️ Post-edit Diagnostics (${errors.length} error${errors.length > 1 ? "s" : ""}):\n${lines.join("\n")}`;
475
696
  }
697
+ /**
698
+ * 递归格式化 DocumentSymbol 列表为缩进的符号大纲树(做条数上限保护)
699
+ */
700
+ function formatDocumentSymbols(symbols, indent = "", lines = []) {
701
+ for (const sym of symbols) {
702
+ if (lines.length >= 300) {
703
+ lines.push(`${indent}• ... [Truncated: outline exceeds 300 symbols]`);
704
+ break;
705
+ }
706
+ const kind = LSP_SYMBOL_KINDS[sym.kind] || `Kind(${sym.kind})`;
707
+ const range = sym.range || sym.location?.range;
708
+ const startLine = range ? range.start.line + 1 : "?";
709
+ const endLine = range ? range.end.line + 1 : "?";
710
+ const lineSpan = startLine === endLine ? `line ${startLine}` : `lines ${startLine}-${endLine}`;
711
+ const detail = sym.detail ? ` (${sym.detail})` : "";
712
+ lines.push(`${indent}• [${kind}] ${sym.name}${detail} (${lineSpan})`);
713
+ if (Array.isArray(sym.children) && sym.children.length > 0) {
714
+ formatDocumentSymbols(sym.children, indent + " ", lines);
715
+ }
716
+ }
717
+ return lines;
718
+ }
719
+ /**
720
+ * 递归单趟查找符号(两遍扫描:先严格精确匹配,未命中再执行大小写忽略回退,避免遮蔽后续精确符号;支持 containerName 点分路径)
721
+ */
722
+ function findSymbolPass(symbols, target, mode, parentName = "") {
723
+ const targetLower = target.toLowerCase();
724
+ for (const sym of symbols) {
725
+ const qualifiedName = sym.containerName
726
+ ? `${sym.containerName}.${sym.name}`
727
+ : parentName
728
+ ? `${parentName}.${sym.name}`
729
+ : sym.name;
730
+ if (mode === "exact") {
731
+ if (sym.name === target || qualifiedName === target) {
732
+ return { symbol: sym, fullName: qualifiedName };
733
+ }
734
+ }
735
+ else {
736
+ if (sym.name.toLowerCase() === targetLower || qualifiedName.toLowerCase() === targetLower) {
737
+ return { symbol: sym, fullName: qualifiedName };
738
+ }
739
+ }
740
+ if (Array.isArray(sym.children) && sym.children.length > 0) {
741
+ const found = findSymbolPass(sym.children, target, mode, qualifiedName);
742
+ if (found)
743
+ return found;
744
+ }
745
+ }
746
+ return null;
747
+ }
748
+ function findSymbol(symbols, target) {
749
+ return findSymbolPass(symbols, target, "exact") ?? findSymbolPass(symbols, target, "ci");
750
+ }
751
+ /**
752
+ * 收集文件内可用的顶层符号全名清单(最多收集 50 条,供找不到符号时提供备选提示)
753
+ */
754
+ function collectSymbolNames(symbols, prefix = "", names = []) {
755
+ for (const sym of symbols) {
756
+ if (names.length >= 50)
757
+ break;
758
+ const current = sym.containerName
759
+ ? `${sym.containerName}.${sym.name}`
760
+ : prefix
761
+ ? `${prefix}.${sym.name}`
762
+ : sym.name;
763
+ const kind = LSP_SYMBOL_KINDS[sym.kind] || "Symbol";
764
+ names.push(`${current} [${kind}]`);
765
+ if (Array.isArray(sym.children) && sym.children.length > 0) {
766
+ collectSymbolNames(sym.children, current, names);
767
+ }
768
+ }
769
+ return names;
770
+ }
771
+ /**
772
+ * 当未传 path 且执行工作区级操作(如 workspaceSymbol)时,寻找工作区默认主文件以定位语言服务
773
+ */
774
+ function findDefaultSourceFileForLsp(cwd) {
775
+ const candidates = [
776
+ "src/index.ts",
777
+ "src/main.ts",
778
+ "src/app.ts",
779
+ "index.ts",
780
+ "main.ts",
781
+ "app.ts",
782
+ "server.ts",
783
+ "main.py",
784
+ "app.py",
785
+ "main.go",
786
+ "src/main.rs",
787
+ ];
788
+ for (const c of candidates) {
789
+ if (existsSync(join(cwd, c)))
790
+ return c;
791
+ }
792
+ return null;
793
+ }
476
794
  export function makeLspTool(options) {
477
795
  const cwd = options.cwd;
478
796
  return defineTool({
479
797
  name: LSP_TOOL_NAME,
480
798
  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.`,
799
+ description: `IDE-grade semantic analysis (LSP) across the workspace. Actions:
800
+ - \`definition\`: definition of the symbol at \`line\`/\`character\` in \`path\` (file, line, snippet).
801
+ - \`references\`: all workspace usages of that symbol.
802
+ - \`hover\`: type signature and docs for that symbol.
803
+ - \`diagnostics\`: compiler/type errors and warnings for \`path\` (whole file).
804
+ - \`documentSymbol\`: hierarchical symbol outline with line spans for \`path\`.
805
+ - \`read_symbol\`: read the body of \`symbol\` in \`path\` (e.g. "parseConfig").
806
+ - \`workspaceSymbol\`: search symbols across the workspace by \`query\`.
807
+ - \`cascade\`: impact check for \`path\` — report diagnostics of files referencing it.
808
+ Lines are 1-indexed.`,
489
809
  parameters: Type.Object({
490
- action: Type.Union([Type.Literal("definition"), Type.Literal("references"), Type.Literal("hover"), Type.Literal("diagnostics")], {
810
+ action: Type.Union([
811
+ Type.Literal("definition"),
812
+ Type.Literal("references"),
813
+ Type.Literal("hover"),
814
+ Type.Literal("diagnostics"),
815
+ Type.Literal("documentSymbol"),
816
+ Type.Literal("read_symbol"),
817
+ Type.Literal("workspaceSymbol"),
818
+ Type.Literal("cascade"),
819
+ ], {
491
820
  description: "The LSP operation to perform.",
492
821
  }),
493
- path: Type.String({
494
- description: "Workspace-relative or absolute path to the target source file.",
495
- }),
822
+ path: Type.Optional(Type.String({
823
+ description: "Workspace-relative or absolute path to the target source file (required for all actions except workspaceSymbol).",
824
+ })),
825
+ symbol: Type.Optional(Type.String({
826
+ description: "Symbol name to read for 'read_symbol' action (e.g. 'functionName' or 'ClassName.methodName').",
827
+ })),
828
+ query: Type.Optional(Type.String({
829
+ description: "Search query for 'workspaceSymbol' action.",
830
+ })),
496
831
  line: Type.Optional(Type.Number({
497
832
  description: "1-indexed line number in the source file.",
498
833
  })),
@@ -502,10 +837,33 @@ Note: Line numbers are 1-indexed.`,
502
837
  timeout: Type.Optional(Type.Number({
503
838
  description: "Timeout in seconds (defaults to 15).",
504
839
  })),
840
+ allowInstall: Type.Optional(Type.Boolean({
841
+ description: "Install the missing language server into ~/.pi-web/lsp-servers (user-space, no sudo). Default false: the tool returns an installHint instead.",
842
+ })),
505
843
  }),
506
844
  async execute(_callId, params, _signal, _onUpdate, _ctx) {
507
845
  const action = params.action;
508
- const targetPath = params.path;
846
+ let targetPath = params.path;
847
+ if (!targetPath && action === "workspaceSymbol") {
848
+ targetPath = findDefaultSourceFileForLsp(cwd) ?? undefined;
849
+ if (!targetPath) {
850
+ return {
851
+ content: [
852
+ {
853
+ type: "text",
854
+ text: `Error: Could not automatically detect a primary project source file to route language server. Please provide 'path' (pointing to any source file in the project, e.g. path='src/index.ts') to select the language server.`,
855
+ },
856
+ ],
857
+ details: { ok: false, error: "Missing path: cannot route language server" },
858
+ };
859
+ }
860
+ }
861
+ if (!targetPath) {
862
+ return {
863
+ content: [{ type: "text", text: `Error: 'path' parameter is required for action '${action}'.` }],
864
+ details: { ok: false, error: "Missing path parameter" },
865
+ };
866
+ }
509
867
  const absPath = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
510
868
  const line = typeof params.line === "number" ? Math.max(1, params.line) : 1;
511
869
  const character = typeof params.character === "number" ? Math.max(1, params.character) : 1;
@@ -527,7 +885,29 @@ Note: Line numbers are 1-indexed.`,
527
885
  details: { ok: false, error: "File not found" },
528
886
  };
529
887
  }
530
- const clientRes = await globalLspPool.getClient(cwd, absPath);
888
+ if (action === "read_symbol" && !params.symbol?.trim()) {
889
+ return {
890
+ content: [
891
+ {
892
+ type: "text",
893
+ text: `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName").`,
894
+ },
895
+ ],
896
+ details: { ok: false, error: "Missing symbol parameter" },
897
+ };
898
+ }
899
+ if (action === "workspaceSymbol" && !(params.query ?? "").trim()) {
900
+ return {
901
+ content: [
902
+ {
903
+ type: "text",
904
+ text: `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router').`,
905
+ },
906
+ ],
907
+ details: { ok: false, error: "Empty query parameter" },
908
+ };
909
+ }
910
+ const clientRes = await globalLspPool.getClient(cwd, absPath, { allowInstall: params.allowInstall });
531
911
  if ("error" in clientRes) {
532
912
  return {
533
913
  content: [{ type: "text", text: `LSP Error: ${clientRes.error}` }],
@@ -551,7 +931,10 @@ Note: Line numbers are 1-indexed.`,
551
931
  details: { ok: true, locations: [] },
552
932
  };
553
933
  }
554
- const formatted = locs.map((loc) => {
934
+ // 与 references(:25) 同口径:每个位置都要整读一次目标文件拼 snippet,
935
+ // 无上限的 definition 列表会放大成几十次同步 IO 拖死请求。
936
+ const MAX_DEFS = 25;
937
+ const formatted = locs.slice(0, MAX_DEFS).map((loc) => {
555
938
  const targetUri = loc.targetUri || loc.uri || "";
556
939
  let defPath = targetUri;
557
940
  try {
@@ -576,8 +959,14 @@ Note: Line numbers are 1-indexed.`,
576
959
  }
577
960
  return `• ${defRel}:${defLine}:${defCol}\n\`\`\`\n${snippet}\n\`\`\``;
578
961
  });
962
+ const defTail = locs.length > MAX_DEFS ? `\n\n... and ${locs.length - MAX_DEFS} more definitions` : "";
579
963
  return {
580
- content: [{ type: "text", text: `Definitions (${locs.length}):\n\n${formatted.join("\n\n")}` }],
964
+ content: [
965
+ {
966
+ type: "text",
967
+ text: `Definitions (${locs.length}):\n\n${formatted.join("\n\n")}${defTail}`,
968
+ },
969
+ ],
581
970
  details: { ok: true, locations: locs },
582
971
  };
583
972
  }
@@ -663,6 +1052,309 @@ Note: Line numbers are 1-indexed.`,
663
1052
  details: { ok: true, diagnostics: diags },
664
1053
  };
665
1054
  }
1055
+ if (action === "documentSymbol") {
1056
+ const result = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
1057
+ const symbols = Array.isArray(result) ? result : [];
1058
+ if (symbols.length === 0) {
1059
+ return {
1060
+ content: [{ type: "text", text: `No symbols found in ${targetPath}` }],
1061
+ details: { ok: true, symbols: [] },
1062
+ };
1063
+ }
1064
+ const lines = formatDocumentSymbols(symbols);
1065
+ // details 随会话持久且整体 ≤64KB(超限整条丢弃):symbols 与文本大纲同口径截断
1066
+ const detailsSymbols = symbols.length > 300 ? symbols.slice(0, 300) : symbols;
1067
+ return {
1068
+ content: [
1069
+ {
1070
+ type: "text",
1071
+ text: `Symbols in ${targetPath} (${symbols.length} top-level):\n${lines.join("\n")}`,
1072
+ },
1073
+ ],
1074
+ details: { ok: true, count: symbols.length, symbols: detailsSymbols },
1075
+ };
1076
+ }
1077
+ if (action === "read_symbol") {
1078
+ const targetSymbol = params.symbol?.trim();
1079
+ if (!targetSymbol) {
1080
+ return {
1081
+ content: [
1082
+ {
1083
+ type: "text",
1084
+ text: `Error: 'symbol' parameter is required for 'read_symbol' action (e.g. symbol="parseConfig" or "ClassName.methodName").`,
1085
+ },
1086
+ ],
1087
+ details: { ok: false, error: "Missing symbol parameter" },
1088
+ };
1089
+ }
1090
+ const result = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
1091
+ const symbols = Array.isArray(result) ? result : [];
1092
+ const match = findSymbol(symbols, targetSymbol);
1093
+ if (!match) {
1094
+ const available = collectSymbolNames(symbols);
1095
+ const listSnippet = available.length > 0
1096
+ ? `\nAvailable symbols in ${targetPath}:\n${available
1097
+ .slice(0, 30)
1098
+ .map((s) => `• ${s}`)
1099
+ .join("\n")}${available.length > 30 ? `\n... and ${available.length - 30} more` : ""}`
1100
+ : "";
1101
+ return {
1102
+ content: [
1103
+ {
1104
+ type: "text",
1105
+ text: `Symbol '${targetSymbol}' not found in ${targetPath}.${listSnippet}`,
1106
+ },
1107
+ ],
1108
+ details: { ok: false, error: "Symbol not found", availableSymbols: available },
1109
+ };
1110
+ }
1111
+ const range = match.symbol.range || match.symbol.location?.range;
1112
+ if (!range) {
1113
+ return {
1114
+ content: [
1115
+ {
1116
+ type: "text",
1117
+ text: `Symbol '${targetSymbol}' found, but no range information was provided by language server.`,
1118
+ },
1119
+ ],
1120
+ details: { ok: false, error: "Missing range" },
1121
+ };
1122
+ }
1123
+ let startLine = range.start.line; // 0-indexed
1124
+ let endLine = range.end.line; // 0-indexed
1125
+ // 针对行尾排他边界(end.character === 0 且跨行时)避免多读末尾空行
1126
+ if (range.end.character === 0 && endLine > startLine) {
1127
+ endLine -= 1;
1128
+ }
1129
+ const fileLines = readFileSync(absPath, "utf8").split(/\r?\n/);
1130
+ const totalSymbolLines = Math.max(0, endLine - startLine + 1);
1131
+ const MAX_SYMBOL_READ_LINES = 400;
1132
+ const isTruncated = totalSymbolLines > MAX_SYMBOL_READ_LINES;
1133
+ const sliceEndLine = isTruncated ? startLine + MAX_SYMBOL_READ_LINES - 1 : endLine;
1134
+ const symbolLines = fileLines.slice(startLine, sliceEndLine + 1);
1135
+ let formattedSnippet = symbolLines.map((l, idx) => `${startLine + idx + 1}: ${l}`).join("\n");
1136
+ if (isTruncated) {
1137
+ formattedSnippet += `\n// ... [Truncated: symbol body has ${totalSymbolLines} lines, showing first ${MAX_SYMBOL_READ_LINES} lines. Use 'documentSymbol' to inspect nested methods/members and read them individually]`;
1138
+ }
1139
+ const kind = LSP_SYMBOL_KINDS[match.symbol.kind] || `Kind(${match.symbol.kind})`;
1140
+ return {
1141
+ content: [
1142
+ {
1143
+ type: "text",
1144
+ text: `// Symbol: ${match.fullName} [${kind}]\n// File: ${rel}:${startLine + 1}-${endLine + 1}\n\`\`\`\n${formattedSnippet}\n\`\`\``,
1145
+ },
1146
+ ],
1147
+ details: {
1148
+ ok: true,
1149
+ symbol: match.symbol,
1150
+ fullName: match.fullName,
1151
+ startLine: startLine + 1,
1152
+ endLine: endLine + 1,
1153
+ code: symbolLines.join("\n"),
1154
+ totalLines: totalSymbolLines,
1155
+ truncated: isTruncated,
1156
+ },
1157
+ };
1158
+ }
1159
+ if (action === "workspaceSymbol") {
1160
+ const query = (params.query ?? "").trim();
1161
+ if (!query) {
1162
+ return {
1163
+ content: [
1164
+ {
1165
+ type: "text",
1166
+ text: `Error: 'query' parameter cannot be empty for 'workspaceSymbol' action. Please provide a search term (e.g. query='User' or 'Router').`,
1167
+ },
1168
+ ],
1169
+ details: { ok: false, error: "Empty query parameter" },
1170
+ };
1171
+ }
1172
+ const result = await client.request("workspace/symbol", { query }, timeoutMs);
1173
+ const locs = Array.isArray(result) ? result : [];
1174
+ if (locs.length === 0) {
1175
+ return {
1176
+ content: [{ type: "text", text: `No symbols found across workspace matching '${query}'` }],
1177
+ details: { ok: true, symbols: [] },
1178
+ };
1179
+ }
1180
+ const MAX_WORKSPACE_SYMBOLS = 100;
1181
+ const isTruncated = locs.length > MAX_WORKSPACE_SYMBOLS;
1182
+ const cappedLocs = isTruncated ? locs.slice(0, MAX_WORKSPACE_SYMBOLS) : locs;
1183
+ const formatted = locs.slice(0, 30).map((sym) => {
1184
+ const targetUri = sym.location?.uri || sym.uri || "";
1185
+ let filePath = targetUri;
1186
+ try {
1187
+ if (targetUri.startsWith("file:"))
1188
+ filePath = fileURLToPath(targetUri);
1189
+ }
1190
+ catch { }
1191
+ const fileRel = filePath.startsWith(cwd) ? filePath.slice(cwd.length).replace(/^[/\\]/, "") : filePath;
1192
+ const range = sym.location?.range || sym.range;
1193
+ const lineNum = range ? range.start.line + 1 : 1;
1194
+ const kind = LSP_SYMBOL_KINDS[sym.kind] || `Kind(${sym.kind})`;
1195
+ const container = sym.containerName ? ` in ${sym.containerName}` : "";
1196
+ return `• [${kind}] ${sym.name}${container} (${fileRel}:${lineNum})`;
1197
+ });
1198
+ const tail = locs.length > 30 ? `\n... and ${locs.length - 30} more symbols` : "";
1199
+ return {
1200
+ content: [
1201
+ {
1202
+ type: "text",
1203
+ text: `Found ${locs.length} symbol${locs.length > 1 ? "s" : ""} matching '${query}' (via ${rel}):\n${formatted.join("\n")}${tail}`,
1204
+ },
1205
+ ],
1206
+ details: { ok: true, count: locs.length, symbols: cappedLocs, truncated: isTruncated },
1207
+ };
1208
+ }
1209
+ if (action === "cascade") {
1210
+ // 影响级联(Impact Cascade):找出引用本文件(或本文件某个符号)的工作区文件,
1211
+ // 聚合它们的实时诊断——让"改了签名/导出,下游编译炸了"在编辑当轮就暴露,
1212
+ // 而不是等到构建或提交时才发现。
1213
+ const MAX_SEEDS = 20;
1214
+ const MAX_DEPENDENTS = 25;
1215
+ const DIAGS_BUDGET_MS = 1500;
1216
+ const normalizePath = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
1217
+ // 1. 收集种子位置:给了 line/character 就只查那个符号;否则查全部顶层符号。
1218
+ const seeds = [];
1219
+ if (typeof params.line === "number") {
1220
+ seeds.push({ line: line - 1, character: character - 1 });
1221
+ }
1222
+ else {
1223
+ const symResult = await client.request("textDocument/documentSymbol", { textDocument: { uri } }, timeoutMs);
1224
+ const topSymbols = Array.isArray(symResult) ? symResult : [];
1225
+ for (const sym of topSymbols.slice(0, MAX_SEEDS)) {
1226
+ const pos = sym.selectionRange?.start ?? sym.range?.start ?? sym.location?.range?.start;
1227
+ if (pos && typeof pos.line === "number") {
1228
+ seeds.push({ line: pos.line, character: pos.character ?? 0 });
1229
+ }
1230
+ }
1231
+ }
1232
+ if (seeds.length === 0) {
1233
+ return {
1234
+ content: [{ type: "text", text: `No symbols to trace in ${targetPath} — nothing to cascade.` }],
1235
+ details: { ok: true, impacted: [], clean: [], notReported: [], referencedFiles: [] },
1236
+ };
1237
+ }
1238
+ // 2. 对每个种子查 references(不含声明处),汇总工作区内的引用方文件。
1239
+ const selfNorm = normalizePath(absPath);
1240
+ const depPaths = new Set();
1241
+ const seedResults = await Promise.allSettled(seeds.map((pos) => client.request("textDocument/references", { textDocument: { uri }, position: pos, context: { includeDeclaration: false } }, timeoutMs)));
1242
+ for (const r of seedResults) {
1243
+ if (r.status !== "fulfilled" || !Array.isArray(r.value))
1244
+ continue;
1245
+ for (const loc of r.value) {
1246
+ const refUri = loc?.uri ?? "";
1247
+ if (!refUri.startsWith("file:"))
1248
+ continue;
1249
+ let refPath;
1250
+ try {
1251
+ refPath = fileURLToPath(refUri);
1252
+ }
1253
+ catch {
1254
+ continue;
1255
+ }
1256
+ if (normalizePath(refPath) === selfNorm)
1257
+ continue; // 排除自身
1258
+ const relRef = relative(cwd, refPath);
1259
+ if (relRef === ".." || relRef.startsWith(".." + sep) || relRef.startsWith("../") || isAbsolute(relRef)) {
1260
+ continue; // 只看工作区内
1261
+ }
1262
+ if (relRef.split(sep).includes("node_modules"))
1263
+ continue;
1264
+ depPaths.add(refPath);
1265
+ }
1266
+ }
1267
+ const dependents = [...depPaths].sort().slice(0, MAX_DEPENDENTS);
1268
+ if (dependents.length === 0) {
1269
+ return {
1270
+ content: [
1271
+ {
1272
+ type: "text",
1273
+ text: `No referencing files found for ${targetPath} — no impact cascade needed.`,
1274
+ },
1275
+ ],
1276
+ details: { ok: true, impacted: [], clean: [], notReported: [], referencedFiles: [] },
1277
+ };
1278
+ }
1279
+ // 3. 逐个 didOpen/didChange 触发服务器分析,轮询等待 publishDiagnostics 回流。
1280
+ const depUris = [];
1281
+ for (const dep of dependents) {
1282
+ try {
1283
+ depUris.push(await client.syncDocument(dep));
1284
+ }
1285
+ catch {
1286
+ depUris.push("");
1287
+ }
1288
+ }
1289
+ const pending = new Set(depUris.filter(Boolean));
1290
+ const deadline = Date.now() + DIAGS_BUDGET_MS;
1291
+ while (pending.size > 0 && Date.now() < deadline) {
1292
+ await new Promise((r) => setTimeout(r, 120));
1293
+ const known = client.getAllDiagnostics();
1294
+ for (const u of [...pending]) {
1295
+ if (known.has(u))
1296
+ pending.delete(u);
1297
+ }
1298
+ }
1299
+ // 4. 聚合输出:有错误的排前面,其次警告,clean 与未上报的折叠列出。
1300
+ const impacted = [];
1301
+ const clean = [];
1302
+ const notReported = [];
1303
+ const knownFinal = client.getAllDiagnostics();
1304
+ for (let i = 0; i < dependents.length; i++) {
1305
+ const dep = dependents[i];
1306
+ const depUri = depUris[i];
1307
+ const depRel = relative(cwd, dep).replace(/\\/g, "/");
1308
+ if (!depUri) {
1309
+ notReported.push(depRel);
1310
+ continue;
1311
+ }
1312
+ const diags = client.getDiagnostics(depUri);
1313
+ const errors = diags.filter((d) => d.severity === 1).length;
1314
+ const warnings = diags.filter((d) => d.severity === 2).length;
1315
+ if (errors + warnings > 0) {
1316
+ impacted.push({ path: depRel, errors, warnings, diagnostics: diags.slice(0, 10) });
1317
+ }
1318
+ else if (diags.length === 0 && !knownFinal.has(depUri)) {
1319
+ notReported.push(depRel); // 预算内服务器未上报(可能仍在分析)
1320
+ }
1321
+ else {
1322
+ clean.push(depRel);
1323
+ }
1324
+ }
1325
+ impacted.sort((a, b) => b.errors - a.errors || b.warnings - a.warnings);
1326
+ const out = [];
1327
+ out.push(`Impact cascade for ${targetPath}: ${dependents.length} referencing file(s), ${impacted.length} with findings.`);
1328
+ for (const item of impacted) {
1329
+ out.push(`• ${item.path} — ${item.errors} error(s), ${item.warnings} warning(s)`);
1330
+ for (const d of item.diagnostics.slice(0, 5)) {
1331
+ const sev = d.severity === 1 ? "ERROR" : d.severity === 2 ? "WARN" : "INFO";
1332
+ const code = d.code ? ` [${d.code}]` : "";
1333
+ const msg = String(d.message).split("\n")[0];
1334
+ out.push(` [${sev}] line ${d.range.start.line + 1}:${d.range.start.character + 1}${code} - ${msg}`);
1335
+ }
1336
+ }
1337
+ if (clean.length > 0) {
1338
+ const shown = clean
1339
+ .slice(0, 10)
1340
+ .map((p) => `• ${p}`)
1341
+ .join("\n");
1342
+ out.push(`Clean (${clean.length}):\n${shown}${clean.length > 10 ? `\n... and ${clean.length - 10} more` : ""}`);
1343
+ }
1344
+ if (notReported.length > 0) {
1345
+ out.push(`Diagnostics not reported in time (${notReported.length}, server may still be analyzing): ${notReported.slice(0, 5).join(", ")}${notReported.length > 5 ? ", ..." : ""}`);
1346
+ }
1347
+ return {
1348
+ content: [{ type: "text", text: out.join("\n") }],
1349
+ details: {
1350
+ ok: true,
1351
+ impacted,
1352
+ clean,
1353
+ notReported,
1354
+ referencedFiles: dependents.map((p) => relative(cwd, p).replace(/\\/g, "/")),
1355
+ },
1356
+ };
1357
+ }
666
1358
  return {
667
1359
  content: [{ type: "text", text: `Unsupported action: ${action}` }],
668
1360
  details: { ok: false, error: "Unsupported action" },