@epoch-agent/plugin-lsp 0.1.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/LICENSE +219 -0
- package/README.md +185 -0
- package/dist/index.d.ts +580 -0
- package/dist/index.js +972 -0
- package/package.json +46 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,972 @@
|
|
|
1
|
+
// src/diagnose.ts
|
|
2
|
+
import { readFile, stat } from "fs/promises";
|
|
3
|
+
import { extname, isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
4
|
+
|
|
5
|
+
// src/client.ts
|
|
6
|
+
import { basename } from "path";
|
|
7
|
+
import { pathToFileURL } from "url";
|
|
8
|
+
import {
|
|
9
|
+
createMessageConnection,
|
|
10
|
+
StreamMessageReader,
|
|
11
|
+
StreamMessageWriter
|
|
12
|
+
} from "vscode-jsonrpc/node";
|
|
13
|
+
import {
|
|
14
|
+
DidChangeTextDocumentNotification,
|
|
15
|
+
DidCloseTextDocumentNotification,
|
|
16
|
+
DidOpenTextDocumentNotification,
|
|
17
|
+
ExitNotification,
|
|
18
|
+
InitializedNotification,
|
|
19
|
+
InitializeRequest,
|
|
20
|
+
PublishDiagnosticsNotification,
|
|
21
|
+
RegistrationRequest,
|
|
22
|
+
ShutdownRequest,
|
|
23
|
+
UnregistrationRequest,
|
|
24
|
+
WorkDoneProgressCreateRequest
|
|
25
|
+
} from "vscode-languageserver-protocol";
|
|
26
|
+
import {
|
|
27
|
+
collectProcessTree,
|
|
28
|
+
killPids,
|
|
29
|
+
killTrackedProcess,
|
|
30
|
+
startLongLivedProcess
|
|
31
|
+
} from "@epoch-agent/infra";
|
|
32
|
+
var SHUTDOWN_BUDGET_MS = 2e3;
|
|
33
|
+
var STDERR_KEEP = 4e3;
|
|
34
|
+
var MAX_OPEN_DOCUMENTS = 64;
|
|
35
|
+
var POLL_STEP_MS = 25;
|
|
36
|
+
var LspServerDownError = class extends Error {
|
|
37
|
+
constructor(detail) {
|
|
38
|
+
super(`language server \u5DF2\u9000\u51FA\uFF1A${detail}`);
|
|
39
|
+
this.detail = detail;
|
|
40
|
+
this.name = "LspServerDownError";
|
|
41
|
+
}
|
|
42
|
+
detail;
|
|
43
|
+
};
|
|
44
|
+
function uriKey(uri, platform = process.platform) {
|
|
45
|
+
const prefix = "file://";
|
|
46
|
+
if (!uri.startsWith(prefix)) return uri;
|
|
47
|
+
let path;
|
|
48
|
+
try {
|
|
49
|
+
path = decodeURIComponent(uri.slice(prefix.length));
|
|
50
|
+
} catch {
|
|
51
|
+
return uri;
|
|
52
|
+
}
|
|
53
|
+
if (/^\/[a-zA-Z]:/.test(path)) path = path.slice(1);
|
|
54
|
+
path = path.replace(/\\/g, "/");
|
|
55
|
+
return platform === "win32" ? path.toLowerCase() : path;
|
|
56
|
+
}
|
|
57
|
+
var SILENT_LOGGER = {
|
|
58
|
+
error: () => {
|
|
59
|
+
},
|
|
60
|
+
warn: () => {
|
|
61
|
+
},
|
|
62
|
+
info: () => {
|
|
63
|
+
},
|
|
64
|
+
log: () => {
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
var LspClient = class _LspClient {
|
|
68
|
+
constructor(connection, pidValue, spec) {
|
|
69
|
+
this.connection = connection;
|
|
70
|
+
this.pidValue = pidValue;
|
|
71
|
+
this.spec = spec;
|
|
72
|
+
}
|
|
73
|
+
connection;
|
|
74
|
+
pidValue;
|
|
75
|
+
spec;
|
|
76
|
+
/** 归一键 → 打开状态。按插入顺序逐出 */
|
|
77
|
+
openDocs = /* @__PURE__ */ new Map();
|
|
78
|
+
/** 归一键 → server 最近一次推来的诊断 */
|
|
79
|
+
latest = /* @__PURE__ */ new Map();
|
|
80
|
+
/** 最近一次「有动静」的时刻 —— 我们发出去的和 server 推回来的都算 */
|
|
81
|
+
lastActivityAt = 0;
|
|
82
|
+
/** 这一批里已经答过的文件 */
|
|
83
|
+
answered = /* @__PURE__ */ new Set();
|
|
84
|
+
dead = null;
|
|
85
|
+
disposing = false;
|
|
86
|
+
stderr = "";
|
|
87
|
+
get pid() {
|
|
88
|
+
return this.pidValue;
|
|
89
|
+
}
|
|
90
|
+
/** 还能用吗。池按这个决定要不要重启(验收 9) */
|
|
91
|
+
get alive() {
|
|
92
|
+
return this.dead === null;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 起进程 + `initialize` + `initialized`。
|
|
96
|
+
*
|
|
97
|
+
* 起不来 / 初始化超时都**返回 `null` 而不抛** —— 调用方据此把这个语言标记
|
|
98
|
+
* 不可用,整轮照常出结果(验收 8)。抛出去的话一个慢 server 就能让整次工具
|
|
99
|
+
* 调用失败,而那正是这条验收要避免的。
|
|
100
|
+
*/
|
|
101
|
+
static async start(options) {
|
|
102
|
+
const { spec, launch, projectRoot } = options;
|
|
103
|
+
let self = null;
|
|
104
|
+
const tracked = startLongLivedProcess({
|
|
105
|
+
file: launch.file,
|
|
106
|
+
args: launch.args,
|
|
107
|
+
cwd: projectRoot,
|
|
108
|
+
spawnOptions: {
|
|
109
|
+
// infra 的默认值是 `['ignore', 'pipe', 'pipe']`,而 LSP over stdio **要
|
|
110
|
+
// stdin**。`spawnOptions` 在 infra 里是最后展开的,所以这里盖得掉
|
|
111
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
112
|
+
...launch.verbatim ? { windowsVerbatimArguments: true } : {},
|
|
113
|
+
// `launch.env` 只有项目本地那一档有(它拿 `execPath` 当 node 使)。
|
|
114
|
+
// **必须连 `process.env` 一起摊开**:给 `env` 赋值是整份替换而不是叠加,
|
|
115
|
+
// 只放那一格等于把 PATH / HOME 抽掉,server 当场起不来
|
|
116
|
+
...launch.env ? { env: { ...process.env, ...launch.env } } : {}
|
|
117
|
+
},
|
|
118
|
+
onOutput: (chunk, stream) => {
|
|
119
|
+
if (stream === "stderr") self?.appendStderr(chunk);
|
|
120
|
+
},
|
|
121
|
+
onExit: (code, signal) => {
|
|
122
|
+
self?.markDead(`exit code=${code ?? "null"} signal=${signal ?? "null"}`, options.onCrash);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
const { stdin, stdout } = tracked.child;
|
|
126
|
+
if (!stdin || !stdout) {
|
|
127
|
+
await killTrackedProcess(tracked.pid);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
const connection = createMessageConnection(
|
|
131
|
+
new StreamMessageReader(stdout),
|
|
132
|
+
new StreamMessageWriter(stdin),
|
|
133
|
+
SILENT_LOGGER
|
|
134
|
+
);
|
|
135
|
+
self = new _LspClient(connection, tracked.pid, spec);
|
|
136
|
+
self.wire();
|
|
137
|
+
connection.listen();
|
|
138
|
+
if (!await self.initialize(projectRoot)) {
|
|
139
|
+
await self.dispose();
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
return self;
|
|
143
|
+
}
|
|
144
|
+
/** 订阅推送 + 兜住 server 反过来发的那几种请求 */
|
|
145
|
+
wire() {
|
|
146
|
+
this.connection.onNotification(
|
|
147
|
+
PublishDiagnosticsNotification.type,
|
|
148
|
+
(params) => {
|
|
149
|
+
const key = uriKey(params.uri);
|
|
150
|
+
this.latest.set(key, params.diagnostics);
|
|
151
|
+
this.answered.add(key);
|
|
152
|
+
this.lastActivityAt = Date.now();
|
|
153
|
+
}
|
|
154
|
+
);
|
|
155
|
+
this.connection.onRequest(RegistrationRequest.type, () => void 0);
|
|
156
|
+
this.connection.onRequest(UnregistrationRequest.type, () => void 0);
|
|
157
|
+
this.connection.onRequest(WorkDoneProgressCreateRequest.type, () => void 0);
|
|
158
|
+
this.connection.onError(() => this.markDead("JSON-RPC \u8FDE\u63A5\u9519\u8BEF"));
|
|
159
|
+
this.connection.onClose(() => this.markDead("JSON-RPC \u8FDE\u63A5\u5173\u95ED"));
|
|
160
|
+
}
|
|
161
|
+
appendStderr(chunk) {
|
|
162
|
+
this.stderr = (this.stderr + chunk).slice(-STDERR_KEEP);
|
|
163
|
+
}
|
|
164
|
+
markDead(detail, onCrash) {
|
|
165
|
+
if (this.dead) return;
|
|
166
|
+
const tail = this.stderr.trim();
|
|
167
|
+
this.dead = tail ? `${detail}\uFF1Bstderr: ${tail.slice(-500)}` : detail;
|
|
168
|
+
if (!this.disposing) onCrash?.(this.dead);
|
|
169
|
+
}
|
|
170
|
+
async initialize(projectRoot) {
|
|
171
|
+
const rootUri = pathToFileURL(projectRoot).toString();
|
|
172
|
+
const initOptions = this.spec.initializationOptions?.(projectRoot);
|
|
173
|
+
const params = {
|
|
174
|
+
processId: process.pid,
|
|
175
|
+
clientInfo: { name: "epoch-agent" },
|
|
176
|
+
rootUri,
|
|
177
|
+
workspaceFolders: [{ uri: rootUri, name: basename(projectRoot) || projectRoot }],
|
|
178
|
+
capabilities: {
|
|
179
|
+
workspace: { workspaceFolders: true },
|
|
180
|
+
textDocument: {
|
|
181
|
+
synchronization: {
|
|
182
|
+
dynamicRegistration: false,
|
|
183
|
+
willSave: false,
|
|
184
|
+
willSaveWaitUntil: false,
|
|
185
|
+
didSave: false
|
|
186
|
+
},
|
|
187
|
+
// **这一段不能省**:typescript-language-server 用它算
|
|
188
|
+
// `features.diagnosticsSupport`,不声明就一条诊断都不推 ——
|
|
189
|
+
// 表现成「所有文件都没问题」,最坏的那种失败
|
|
190
|
+
publishDiagnostics: { relatedInformation: true, versionSupport: false }
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
...initOptions ? { initializationOptions: initOptions } : {}
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
const result = await withTimeoutOrNull(
|
|
197
|
+
this.connection.sendRequest(InitializeRequest.type, params),
|
|
198
|
+
this.spec.initTimeoutMs
|
|
199
|
+
);
|
|
200
|
+
if (result === null) return false;
|
|
201
|
+
await this.connection.sendNotification(InitializedNotification.type, {});
|
|
202
|
+
return true;
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* 打开 / 更新这批文件,等到静默,交出诊断。
|
|
209
|
+
*
|
|
210
|
+
* @throws {LspServerDownError} 进程中途没了(验收 9 的输入)
|
|
211
|
+
*/
|
|
212
|
+
async diagnose(documents, wait) {
|
|
213
|
+
if (this.dead) throw new LspServerDownError(this.dead);
|
|
214
|
+
if (documents.length === 0) return { byPath: /* @__PURE__ */ new Map(), pending: [] };
|
|
215
|
+
const wanted = documents.map((doc) => uriKey(pathToFileURL(doc.path).toString()));
|
|
216
|
+
this.answered = /* @__PURE__ */ new Set();
|
|
217
|
+
for (const doc of documents) await this.sync(doc);
|
|
218
|
+
await this.evictOverflow(new Set(wanted));
|
|
219
|
+
const deadline = Date.now() + wait.timeoutMs;
|
|
220
|
+
for (; ; ) {
|
|
221
|
+
if (this.dead) throw new LspServerDownError(this.dead);
|
|
222
|
+
const quiet = Date.now() - this.lastActivityAt;
|
|
223
|
+
if (wanted.every((key) => this.answered.has(key)) && quiet >= wait.settleMs) break;
|
|
224
|
+
if (wanted.every((key) => this.answered.has(key) || this.latest.has(key)) && quiet >= wait.fallbackMs) {
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
if (Date.now() >= deadline) break;
|
|
228
|
+
await sleep(POLL_STEP_MS);
|
|
229
|
+
}
|
|
230
|
+
const byPath = /* @__PURE__ */ new Map();
|
|
231
|
+
const pending = [];
|
|
232
|
+
for (const key of wanted) {
|
|
233
|
+
const known = this.latest.get(key);
|
|
234
|
+
if (known) byPath.set(key, known);
|
|
235
|
+
else if (this.answered.has(key)) byPath.set(key, []);
|
|
236
|
+
else pending.push(key);
|
|
237
|
+
}
|
|
238
|
+
return { byPath, pending };
|
|
239
|
+
}
|
|
240
|
+
/** 没打开过就 `didOpen`,打开过就 `didChange`(全量) */
|
|
241
|
+
async sync(doc) {
|
|
242
|
+
const uri = pathToFileURL(doc.path).toString();
|
|
243
|
+
const key = uriKey(uri);
|
|
244
|
+
this.lastActivityAt = Date.now();
|
|
245
|
+
const open = this.openDocs.get(key);
|
|
246
|
+
if (!open) {
|
|
247
|
+
this.openDocs.set(key, { uri, version: 1 });
|
|
248
|
+
await this.connection.sendNotification(DidOpenTextDocumentNotification.type, {
|
|
249
|
+
textDocument: { uri, languageId: doc.languageId, version: 1, text: doc.text }
|
|
250
|
+
});
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
open.version += 1;
|
|
254
|
+
await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
255
|
+
textDocument: { uri: open.uri, version: open.version },
|
|
256
|
+
// 全量替换。增量同步要自己维护一份影子文档才算得出 range,而我们每次拿到的
|
|
257
|
+
// 本来就是磁盘上的完整内容 —— 算 diff 只是把复杂度换成一个新的错误来源
|
|
258
|
+
contentChanges: [{ text: doc.text }]
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
/** 超过上限就把最早打开、且不在本批里的文档关掉 */
|
|
262
|
+
async evictOverflow(keep) {
|
|
263
|
+
for (const [key, open] of [...this.openDocs]) {
|
|
264
|
+
if (this.openDocs.size <= MAX_OPEN_DOCUMENTS) return;
|
|
265
|
+
if (keep.has(key)) continue;
|
|
266
|
+
this.openDocs.delete(key);
|
|
267
|
+
this.latest.delete(key);
|
|
268
|
+
await this.connection.sendNotification(DidCloseTextDocumentNotification.type, {
|
|
269
|
+
textDocument: { uri: open.uri }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* 关掉这个 server。
|
|
275
|
+
*
|
|
276
|
+
* 先按协议 `shutdown` + `exit`,给它清临时文件的机会;超预算就直接杀树。
|
|
277
|
+
* **杀树那一步不能省** —— `typescript-language-server` 自己还 fork 了一个
|
|
278
|
+
* `tsserver.js`,只杀父进程会留下孤儿(验收 7)。
|
|
279
|
+
*
|
|
280
|
+
* ## ⚠️ 三步的**顺序**是判据:拍快照 → 优雅关 → 清扫快照
|
|
281
|
+
*
|
|
282
|
+
* 这里原来只有两步(优雅关 → `killTrackedProcess`),而那是**漏的**:
|
|
283
|
+
* `killTrackedProcess` 底下是 `killProcessTree`,靠的是**现场遍历**
|
|
284
|
+
* (`pgrep -P` / `taskkill /t`)。等我们优雅关完,直接子进程已经退了 ——
|
|
285
|
+
* 树的根没了,孙进程的 PPID 指向一个死 pid,遍历再也找不到它们。
|
|
286
|
+
* 于是 `tsserver` 那一层在 POSIX 上被 init 收养、在 Windows 上直接常驻到关机。
|
|
287
|
+
*
|
|
288
|
+
* 所以快照必须在**动手之前**拍。这不是新发明:`plugin-mcp` 的
|
|
289
|
+
* `closeTransportAndReap` 就是这三步,而它有一条会红的用例守着;
|
|
290
|
+
* 我们这边验收 #7 一直挂着「双平台手验」没人做,于是漏了一年半。
|
|
291
|
+
* 2026-08-12 补的 [orphan-reap.test.ts](../__tests__/orphan-reap.test.ts)
|
|
292
|
+
* 把这条钉死了 —— 把下面的快照挪到 `killTrackedProcess` 之后,它当场红。
|
|
293
|
+
*/
|
|
294
|
+
async dispose() {
|
|
295
|
+
if (this.disposing) return;
|
|
296
|
+
this.disposing = true;
|
|
297
|
+
const tree = await collectProcessTree(this.pidValue).catch(() => []);
|
|
298
|
+
try {
|
|
299
|
+
await withTimeoutOrNull(
|
|
300
|
+
(async () => {
|
|
301
|
+
await this.connection.sendRequest(ShutdownRequest.type);
|
|
302
|
+
await this.connection.sendNotification(ExitNotification.type);
|
|
303
|
+
})(),
|
|
304
|
+
SHUTDOWN_BUDGET_MS
|
|
305
|
+
);
|
|
306
|
+
} catch {
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
this.connection.dispose();
|
|
310
|
+
} catch {
|
|
311
|
+
}
|
|
312
|
+
this.dead ??= "\u5DF2\u4E3B\u52A8\u5173\u95ED";
|
|
313
|
+
await killTrackedProcess(this.pidValue);
|
|
314
|
+
if (tree.length > 0) await killPids(tree, { escalate: true });
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
function sleep(ms) {
|
|
318
|
+
return new Promise((resolve3) => {
|
|
319
|
+
setTimeout(resolve3, ms).unref?.();
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
async function withTimeoutOrNull(promise, ms) {
|
|
323
|
+
let timer;
|
|
324
|
+
try {
|
|
325
|
+
return await Promise.race([
|
|
326
|
+
promise,
|
|
327
|
+
new Promise((resolve3) => {
|
|
328
|
+
timer = setTimeout(() => resolve3(null), ms);
|
|
329
|
+
timer.unref?.();
|
|
330
|
+
})
|
|
331
|
+
]);
|
|
332
|
+
} finally {
|
|
333
|
+
if (timer) clearTimeout(timer);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/servers.ts
|
|
338
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
339
|
+
|
|
340
|
+
// src/find-up.ts
|
|
341
|
+
import { existsSync } from "fs";
|
|
342
|
+
import { dirname, join, resolve } from "path";
|
|
343
|
+
function findUp(startDir, relativePath, options = {}) {
|
|
344
|
+
const exists = options.exists ?? existsSync;
|
|
345
|
+
const maxDepth = options.maxDepth ?? 64;
|
|
346
|
+
let dir = resolve(startDir);
|
|
347
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
348
|
+
const candidate = join(dir, relativePath);
|
|
349
|
+
if (exists(candidate)) return candidate;
|
|
350
|
+
const parent = dirname(dir);
|
|
351
|
+
if (parent === dir) break;
|
|
352
|
+
dir = parent;
|
|
353
|
+
}
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/servers.ts
|
|
358
|
+
var TS_LANGUAGE_IDS = {
|
|
359
|
+
".ts": "typescript",
|
|
360
|
+
".mts": "typescript",
|
|
361
|
+
".cts": "typescript",
|
|
362
|
+
".tsx": "typescriptreact",
|
|
363
|
+
".js": "javascript",
|
|
364
|
+
".mjs": "javascript",
|
|
365
|
+
".cjs": "javascript",
|
|
366
|
+
".jsx": "javascriptreact"
|
|
367
|
+
};
|
|
368
|
+
function tsserverPath(projectRoot) {
|
|
369
|
+
const manifest = findUp(projectRoot, join2("node_modules", "typescript", "package.json"));
|
|
370
|
+
if (!manifest) return void 0;
|
|
371
|
+
return join2(dirname2(manifest), "lib", "tsserver.js");
|
|
372
|
+
}
|
|
373
|
+
var TYPESCRIPT = {
|
|
374
|
+
id: "typescript",
|
|
375
|
+
label: "TypeScript / JavaScript",
|
|
376
|
+
pkg: "typescript-language-server",
|
|
377
|
+
bin: "typescript-language-server",
|
|
378
|
+
args: ["--stdio"],
|
|
379
|
+
install: "npm i -D typescript-language-server typescript\uFF08\u6216\u5168\u5C40 npm i -g\uFF09",
|
|
380
|
+
initTimeoutMs: 3e4,
|
|
381
|
+
initializationOptions: (projectRoot) => {
|
|
382
|
+
const path = tsserverPath(projectRoot);
|
|
383
|
+
return path ? { tsserver: { path } } : void 0;
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
var SERVERS = [TYPESCRIPT];
|
|
387
|
+
function serverForExtension(ext) {
|
|
388
|
+
const languageId = TS_LANGUAGE_IDS[ext.toLowerCase()];
|
|
389
|
+
return languageId ? { spec: TYPESCRIPT, languageId } : null;
|
|
390
|
+
}
|
|
391
|
+
function supportedExtensions() {
|
|
392
|
+
return Object.keys(TS_LANGUAGE_IDS).sort();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/diagnose.ts
|
|
396
|
+
var MAX_FILES_PER_CALL = 50;
|
|
397
|
+
var DEFAULT_WAIT = {
|
|
398
|
+
// tsserver 一个文件分三次推(语法 / 语义 / 建议),各带 50ms 去抖。
|
|
399
|
+
// 400ms 静默足够让三次都落地,又不会平白给每次调用加一秒
|
|
400
|
+
settleMs: 400,
|
|
401
|
+
// 内容没变时 server 干脆不推(见 client.ts)。这时要等的是
|
|
402
|
+
// typescript-language-server 那个 300–800ms 的触发延迟走完,确认它真的不打算说话
|
|
403
|
+
fallbackMs: 1500,
|
|
404
|
+
timeoutMs: 15e3
|
|
405
|
+
};
|
|
406
|
+
function severityOf(value) {
|
|
407
|
+
switch (value) {
|
|
408
|
+
case 2:
|
|
409
|
+
return "warning";
|
|
410
|
+
case 3:
|
|
411
|
+
return "information";
|
|
412
|
+
case 4:
|
|
413
|
+
return "hint";
|
|
414
|
+
default:
|
|
415
|
+
return "error";
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function toDisplayPath(absPath, workDir) {
|
|
419
|
+
const rel = relative(workDir, absPath);
|
|
420
|
+
return (rel || absPath).split(sep).join("/");
|
|
421
|
+
}
|
|
422
|
+
function messageOf(message) {
|
|
423
|
+
return typeof message === "string" ? message : message.value;
|
|
424
|
+
}
|
|
425
|
+
function toItem(diag, file) {
|
|
426
|
+
const item = {
|
|
427
|
+
file,
|
|
428
|
+
// LSP 的行列都是 0 起的,人和其它工具用的是 1 起
|
|
429
|
+
line: diag.range.start.line + 1,
|
|
430
|
+
column: diag.range.start.character + 1,
|
|
431
|
+
severity: severityOf(diag.severity),
|
|
432
|
+
message: messageOf(diag.message)
|
|
433
|
+
};
|
|
434
|
+
if (diag.source) item.source = diag.source;
|
|
435
|
+
if (diag.code !== void 0 && diag.code !== null) item.code = String(diag.code);
|
|
436
|
+
return item;
|
|
437
|
+
}
|
|
438
|
+
var SEVERITY_ORDER = {
|
|
439
|
+
error: 0,
|
|
440
|
+
warning: 1,
|
|
441
|
+
information: 2,
|
|
442
|
+
hint: 3
|
|
443
|
+
};
|
|
444
|
+
function sortItems(items) {
|
|
445
|
+
return items.sort(
|
|
446
|
+
(a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity] || a.message.localeCompare(b.message)
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
async function classify(paths, workDir, outcome) {
|
|
450
|
+
const root = resolve2(workDir);
|
|
451
|
+
const candidates = [];
|
|
452
|
+
const seen = /* @__PURE__ */ new Set();
|
|
453
|
+
for (const raw of paths) {
|
|
454
|
+
const absPath = isAbsolute(raw) ? resolve2(raw) : resolve2(root, raw);
|
|
455
|
+
const rel = relative(root, absPath);
|
|
456
|
+
if (rel !== "" && (rel.startsWith("..") || isAbsolute(rel))) {
|
|
457
|
+
outcome.rejected.push({ path: raw, reason: `\u4E0D\u5728\u5DE5\u4F5C\u533A ${root} \u5185` });
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
const key = uriKey(`file://${absPath.split(sep).join("/")}`);
|
|
461
|
+
if (seen.has(key)) continue;
|
|
462
|
+
seen.add(key);
|
|
463
|
+
const display = toDisplayPath(absPath, root);
|
|
464
|
+
try {
|
|
465
|
+
const info = await stat(absPath);
|
|
466
|
+
if (!info.isFile()) {
|
|
467
|
+
outcome.rejected.push({ path: display, reason: "\u4E0D\u662F\u6587\u4EF6" });
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
} catch {
|
|
471
|
+
outcome.rejected.push({ path: display, reason: "\u6587\u4EF6\u4E0D\u5B58\u5728" });
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
const match = serverForExtension(extname(absPath));
|
|
475
|
+
if (!match) {
|
|
476
|
+
outcome.unsupported.push(display);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
candidates.push({ absPath, display, languageId: match.languageId, spec: match.spec });
|
|
480
|
+
}
|
|
481
|
+
return candidates;
|
|
482
|
+
}
|
|
483
|
+
function groupBySpec(candidates) {
|
|
484
|
+
const groups = /* @__PURE__ */ new Map();
|
|
485
|
+
for (const c of candidates) {
|
|
486
|
+
const group = groups.get(c.spec.id) ?? { spec: c.spec, files: [] };
|
|
487
|
+
group.files.push(c);
|
|
488
|
+
groups.set(c.spec.id, group);
|
|
489
|
+
}
|
|
490
|
+
return groups;
|
|
491
|
+
}
|
|
492
|
+
function skip(spec, reason, files, install) {
|
|
493
|
+
return {
|
|
494
|
+
label: spec.label,
|
|
495
|
+
reason,
|
|
496
|
+
...install ? { install } : {},
|
|
497
|
+
files: files.map((f) => f.display)
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
async function runGroup(spec, files, input, outcome) {
|
|
501
|
+
const acquired = await input.pool.acquire(spec, resolve2(input.workDir));
|
|
502
|
+
if (!acquired.ok) {
|
|
503
|
+
outcome.skipped.push(skip(spec, acquired.reason, files, acquired.install));
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const documents = [];
|
|
507
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
508
|
+
for (const file of files) {
|
|
509
|
+
let text;
|
|
510
|
+
try {
|
|
511
|
+
text = await readFile(file.absPath, "utf-8");
|
|
512
|
+
} catch (err) {
|
|
513
|
+
outcome.rejected.push({
|
|
514
|
+
path: file.display,
|
|
515
|
+
reason: `\u8BFB\u4E0D\u51FA\u6765\uFF08${err instanceof Error ? err.message : String(err)}\uFF09`
|
|
516
|
+
});
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
documents.push({ path: file.absPath, languageId: file.languageId, text });
|
|
520
|
+
byKey.set(uriKey(`file://${file.absPath.split(sep).join("/")}`), file);
|
|
521
|
+
}
|
|
522
|
+
if (documents.length === 0) return;
|
|
523
|
+
let batch;
|
|
524
|
+
try {
|
|
525
|
+
batch = await acquired.client.diagnose(documents, input.wait ?? DEFAULT_WAIT);
|
|
526
|
+
} catch (err) {
|
|
527
|
+
const reason = err instanceof LspServerDownError ? err.message : `\u8BCA\u65AD\u5931\u8D25\uFF08${String(err)}\uFF09`;
|
|
528
|
+
outcome.skipped.push(skip(spec, reason, files));
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
for (const [key, diags] of batch.byPath) {
|
|
532
|
+
const file = byKey.get(key);
|
|
533
|
+
if (!file) continue;
|
|
534
|
+
outcome.checked.push(file.display);
|
|
535
|
+
for (const diag of diags) outcome.diagnostics.push(toItem(diag, file.display));
|
|
536
|
+
}
|
|
537
|
+
if (batch.pending.length > 0) {
|
|
538
|
+
const stuck = batch.pending.map((key) => byKey.get(key)).filter((f) => !!f);
|
|
539
|
+
outcome.skipped.push(
|
|
540
|
+
skip(spec, `server \u672A\u5728 ${(input.wait ?? DEFAULT_WAIT).timeoutMs}ms \u5185\u7ED9\u51FA\u7ED3\u679C`, stuck)
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async function diagnosePaths(input) {
|
|
545
|
+
const outcome = {
|
|
546
|
+
checked: [],
|
|
547
|
+
diagnostics: [],
|
|
548
|
+
skipped: [],
|
|
549
|
+
unsupported: [],
|
|
550
|
+
rejected: []
|
|
551
|
+
};
|
|
552
|
+
const paths = input.paths.slice(0, MAX_FILES_PER_CALL);
|
|
553
|
+
for (const extra of input.paths.slice(MAX_FILES_PER_CALL)) {
|
|
554
|
+
outcome.rejected.push({ path: extra, reason: `\u4E00\u6B21\u6700\u591A ${MAX_FILES_PER_CALL} \u4E2A\u6587\u4EF6` });
|
|
555
|
+
}
|
|
556
|
+
const candidates = await classify(paths, resolve2(input.workDir), outcome);
|
|
557
|
+
for (const group of groupBySpec(candidates).values()) {
|
|
558
|
+
if (input.signal?.aborted) {
|
|
559
|
+
outcome.skipped.push(skip(group.spec, "\u5DF2\u53D6\u6D88", group.files));
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
await runGroup(group.spec, group.files, input, outcome);
|
|
563
|
+
}
|
|
564
|
+
outcome.checked.sort();
|
|
565
|
+
outcome.unsupported.sort();
|
|
566
|
+
sortItems(outcome.diagnostics);
|
|
567
|
+
return outcome;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// src/format.ts
|
|
571
|
+
var MAX_ITEMS = 100;
|
|
572
|
+
var MAX_MESSAGE = 400;
|
|
573
|
+
var MAX_FILE_NAMES = 20;
|
|
574
|
+
var SEVERITY_LABEL = {
|
|
575
|
+
error: "error",
|
|
576
|
+
warning: "warning",
|
|
577
|
+
information: "info",
|
|
578
|
+
hint: "hint"
|
|
579
|
+
};
|
|
580
|
+
var KEEP_ORDER = ["error", "warning", "information", "hint"];
|
|
581
|
+
function oneLine(message) {
|
|
582
|
+
const flat = message.replace(/\s+/g, " ").trim();
|
|
583
|
+
return flat.length > MAX_MESSAGE ? `${flat.slice(0, MAX_MESSAGE)}\u2026` : flat;
|
|
584
|
+
}
|
|
585
|
+
function renderItem(item) {
|
|
586
|
+
const origin = `${item.source ?? ""}${item.code ? `(${item.code})` : ""}`;
|
|
587
|
+
const tag = origin ? `${SEVERITY_LABEL[item.severity]} ${origin}` : SEVERITY_LABEL[item.severity];
|
|
588
|
+
return `${item.file}:${item.line}:${item.column} ${tag}: ${oneLine(item.message)}`;
|
|
589
|
+
}
|
|
590
|
+
function renderCounts(items) {
|
|
591
|
+
const counts = /* @__PURE__ */ new Map();
|
|
592
|
+
for (const item of items) counts.set(item.severity, (counts.get(item.severity) ?? 0) + 1);
|
|
593
|
+
return KEEP_ORDER.filter((s) => counts.has(s)).map((s) => `${counts.get(s) ?? 0} ${SEVERITY_LABEL[s]}`).join(" \xB7 ");
|
|
594
|
+
}
|
|
595
|
+
function renderFiles(files) {
|
|
596
|
+
if (files.length <= MAX_FILE_NAMES) return files.join(", ");
|
|
597
|
+
return `${files.slice(0, MAX_FILE_NAMES).join(", ")} \u7B49 ${files.length} \u4E2A`;
|
|
598
|
+
}
|
|
599
|
+
function pickItems(items) {
|
|
600
|
+
if (items.length <= MAX_ITEMS) return { shown: items, dropped: 0 };
|
|
601
|
+
const keep = /* @__PURE__ */ new Set();
|
|
602
|
+
for (const severity of KEEP_ORDER) {
|
|
603
|
+
for (const item of items) {
|
|
604
|
+
if (keep.size >= MAX_ITEMS) break;
|
|
605
|
+
if (item.severity === severity) keep.add(item);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return { shown: items.filter((i) => keep.has(i)), dropped: items.length - keep.size };
|
|
609
|
+
}
|
|
610
|
+
function formatOutcome(outcome) {
|
|
611
|
+
const lines = [];
|
|
612
|
+
const { checked, diagnostics, skipped, unsupported, rejected } = outcome;
|
|
613
|
+
if (diagnostics.length === 0) {
|
|
614
|
+
lines.push(
|
|
615
|
+
checked.length > 0 ? `\u672A\u53D1\u73B0\u95EE\u9898 \xB7 \u5DF2\u68C0\u67E5 ${checked.length} \u4E2A\u6587\u4EF6\uFF1A${renderFiles(checked)}` : "\u672A\u68C0\u67E5\u4EFB\u4F55\u6587\u4EF6"
|
|
616
|
+
);
|
|
617
|
+
} else {
|
|
618
|
+
const { shown, dropped } = pickItems(diagnostics);
|
|
619
|
+
lines.push(
|
|
620
|
+
`${diagnostics.length} \u6761\u8BCA\u65AD\uFF08${renderCounts(diagnostics)}\uFF09 \xB7 \u5DF2\u68C0\u67E5 ${checked.length} \u4E2A\u6587\u4EF6`,
|
|
621
|
+
"",
|
|
622
|
+
...shown.map(renderItem)
|
|
623
|
+
);
|
|
624
|
+
if (dropped > 0) {
|
|
625
|
+
lines.push("", `\uFF08\u8FD8\u6709 ${dropped} \u6761\u672A\u5217\u51FA\uFF0C\u5DF2\u4F18\u5148\u4FDD\u7559 error\uFF09`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
for (const skip2 of skipped) {
|
|
629
|
+
lines.push("", `\u8DF3\u8FC7 ${skip2.label}\uFF1A${skip2.reason}`);
|
|
630
|
+
if (skip2.install) lines.push(` \u5B89\u88C5\uFF1A${skip2.install}`);
|
|
631
|
+
if (skip2.files.length > 0) lines.push(` \u672A\u68C0\u67E5\uFF1A${renderFiles(skip2.files)}`);
|
|
632
|
+
}
|
|
633
|
+
if (unsupported.length > 0) {
|
|
634
|
+
lines.push("", `\u65E0\u5BF9\u5E94 language server\uFF0C\u672A\u68C0\u67E5\uFF1A${renderFiles(unsupported)}`);
|
|
635
|
+
}
|
|
636
|
+
for (const bad of rejected) {
|
|
637
|
+
lines.push("", `\u65E0\u6CD5\u68C0\u67E5 ${bad.path}\uFF1A${bad.reason}`);
|
|
638
|
+
}
|
|
639
|
+
return lines.join("\n");
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// src/detect.ts
|
|
643
|
+
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
644
|
+
import { dirname as dirname3, join as join3, posix, win32 } from "path";
|
|
645
|
+
import { EXEC_PATH_AS_NODE_ENV } from "@epoch-agent/infra";
|
|
646
|
+
function defaultReadManifest(path) {
|
|
647
|
+
try {
|
|
648
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
649
|
+
} catch {
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
function normalize(options) {
|
|
654
|
+
return {
|
|
655
|
+
env: options.env ?? process.env,
|
|
656
|
+
platform: options.platform ?? process.platform,
|
|
657
|
+
fileExists: options.fileExists ?? existsSync2,
|
|
658
|
+
readManifest: options.readManifest ?? defaultReadManifest,
|
|
659
|
+
execPath: options.execPath ?? process.execPath
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
function binEntry(manifest, binName) {
|
|
663
|
+
if (typeof manifest !== "object" || manifest === null) return null;
|
|
664
|
+
const bin = manifest.bin;
|
|
665
|
+
if (typeof bin === "string") return bin;
|
|
666
|
+
if (typeof bin !== "object" || bin === null) return null;
|
|
667
|
+
const table = bin;
|
|
668
|
+
const exact = table[binName];
|
|
669
|
+
if (typeof exact === "string") return exact;
|
|
670
|
+
const values = Object.values(table).filter((v) => typeof v === "string");
|
|
671
|
+
return values.length === 1 ? values[0] ?? null : null;
|
|
672
|
+
}
|
|
673
|
+
function projectLaunch(spec, projectRoot, o) {
|
|
674
|
+
if (!spec.pkg) return null;
|
|
675
|
+
const manifestPath = findUp(projectRoot, join3("node_modules", spec.pkg, "package.json"), {
|
|
676
|
+
exists: o.fileExists
|
|
677
|
+
});
|
|
678
|
+
if (!manifestPath) return null;
|
|
679
|
+
const entryRel = binEntry(o.readManifest(manifestPath), spec.bin);
|
|
680
|
+
if (!entryRel) return null;
|
|
681
|
+
const packageDir = dirname3(manifestPath);
|
|
682
|
+
const entry = join3(packageDir, entryRel);
|
|
683
|
+
if (!o.fileExists(entry)) return null;
|
|
684
|
+
return {
|
|
685
|
+
tier: "project",
|
|
686
|
+
file: o.execPath,
|
|
687
|
+
args: [entry, ...spec.args],
|
|
688
|
+
origin: `\u9879\u76EE node_modules\uFF08${packageDir}\uFF09`,
|
|
689
|
+
// 嵌进 Electron 的宿主里 `execPath` 是宿主自己的可执行文件,少了这一格
|
|
690
|
+
// 起 language server 等于启动第二个 app 实例
|
|
691
|
+
env: EXEC_PATH_AS_NODE_ENV
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
function shimLaunch(shim, spec, o) {
|
|
695
|
+
const quote = (s) => /[\s"]/.test(s) ? `"${s}"` : s;
|
|
696
|
+
const command = [quote(shim), ...spec.args.map(quote)].join(" ");
|
|
697
|
+
return {
|
|
698
|
+
tier: "path",
|
|
699
|
+
file: o.env["ComSpec"] ?? o.env["COMSPEC"] ?? "cmd.exe",
|
|
700
|
+
// 整条命令再包一层引号是 cmd 的老规矩:`/s` 让它只剥最外层,
|
|
701
|
+
// 里面那对(路径带空格时的)引号才留得住
|
|
702
|
+
args: ["/d", "/s", "/c", `"${command}"`],
|
|
703
|
+
origin: `PATH\uFF08${shim}\uFF09`,
|
|
704
|
+
verbatim: true
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
function pathLaunch(spec, o) {
|
|
708
|
+
const windows = o.platform === "win32";
|
|
709
|
+
const api = windows ? win32 : posix;
|
|
710
|
+
const dirs = (o.env["PATH"] ?? o.env["Path"] ?? "").split(api.delimiter).filter(Boolean);
|
|
711
|
+
const exts = windows ? (o.env["PATHEXT"] ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
|
|
712
|
+
for (const dir of dirs) {
|
|
713
|
+
for (const ext of exts) {
|
|
714
|
+
const candidate = api.join(dir, `${spec.bin}${ext.toLowerCase()}`);
|
|
715
|
+
if (!o.fileExists(candidate)) continue;
|
|
716
|
+
if (windows && /\.(cmd|bat)$/i.test(candidate)) return shimLaunch(candidate, spec, o);
|
|
717
|
+
return {
|
|
718
|
+
tier: "path",
|
|
719
|
+
file: candidate,
|
|
720
|
+
args: [...spec.args],
|
|
721
|
+
origin: `PATH\uFF08${candidate}\uFF09`
|
|
722
|
+
};
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return null;
|
|
726
|
+
}
|
|
727
|
+
function resolveServer(spec, projectRoot, options = {}) {
|
|
728
|
+
const o = normalize(options);
|
|
729
|
+
const launch = projectLaunch(spec, projectRoot, o) ?? pathLaunch(spec, o);
|
|
730
|
+
if (launch) return { available: true, launch };
|
|
731
|
+
return {
|
|
732
|
+
available: false,
|
|
733
|
+
reason: `\u6CA1\u627E\u5230 ${spec.bin}\uFF08\u9879\u76EE node_modules \u548C PATH \u91CC\u90FD\u6CA1\u6709\uFF09`,
|
|
734
|
+
install: spec.install
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// src/pool.ts
|
|
739
|
+
var DEFAULT_IDLE_MS = 10 * 60 * 1e3;
|
|
740
|
+
var DEFAULT_SWEEP_MS = 60 * 1e3;
|
|
741
|
+
var MAX_FAILURES = 2;
|
|
742
|
+
var LspServerPool = class {
|
|
743
|
+
entries = /* @__PURE__ */ new Map();
|
|
744
|
+
failures = /* @__PURE__ */ new Map();
|
|
745
|
+
/** 同一个 key 上并发的 acquire 共用一次启动,不然会起出两个 tsserver */
|
|
746
|
+
starting = /* @__PURE__ */ new Map();
|
|
747
|
+
timer = null;
|
|
748
|
+
sweeping = false;
|
|
749
|
+
idleMs;
|
|
750
|
+
sweepMs;
|
|
751
|
+
now;
|
|
752
|
+
resolve;
|
|
753
|
+
start;
|
|
754
|
+
constructor(options = {}) {
|
|
755
|
+
this.idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
|
|
756
|
+
this.sweepMs = options.sweepMs ?? DEFAULT_SWEEP_MS;
|
|
757
|
+
this.now = options.now ?? Date.now;
|
|
758
|
+
this.resolve = options.resolve ?? ((spec, root) => resolveServer(spec, root));
|
|
759
|
+
this.start = options.start ?? ((opts) => LspClient.start(opts));
|
|
760
|
+
}
|
|
761
|
+
/** 活着的实例数。验收 5(懒启动)就是断言它一开始是 0 */
|
|
762
|
+
get size() {
|
|
763
|
+
return this.entries.size;
|
|
764
|
+
}
|
|
765
|
+
/** 拿一个可用的 client;拿不到就说清为什么 */
|
|
766
|
+
async acquire(spec, projectRoot) {
|
|
767
|
+
const key = `${projectRoot}\0${spec.id}`;
|
|
768
|
+
const existing = this.entries.get(key);
|
|
769
|
+
if (existing) {
|
|
770
|
+
if (existing.client.alive) {
|
|
771
|
+
existing.lastUsedAt = this.now();
|
|
772
|
+
return { ok: true, client: existing.client };
|
|
773
|
+
}
|
|
774
|
+
this.entries.delete(key);
|
|
775
|
+
this.noteFailure(key, "language server \u8FDB\u7A0B\u5DF2\u9000\u51FA");
|
|
776
|
+
}
|
|
777
|
+
const failed = this.failures.get(key);
|
|
778
|
+
if (failed && failed.count >= MAX_FAILURES) {
|
|
779
|
+
return {
|
|
780
|
+
ok: false,
|
|
781
|
+
reason: `${failed.detail}\uFF08\u5DF2\u8FDE\u7EED\u5931\u8D25 ${failed.count} \u6B21\uFF0C\u672C\u6B21\u4F1A\u8BDD\u4E0D\u518D\u91CD\u542F\uFF09`
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
const inflight = this.starting.get(key);
|
|
785
|
+
if (inflight) return inflight;
|
|
786
|
+
const pending = this.startFor(key, spec, projectRoot);
|
|
787
|
+
this.starting.set(key, pending);
|
|
788
|
+
try {
|
|
789
|
+
return await pending;
|
|
790
|
+
} finally {
|
|
791
|
+
this.starting.delete(key);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
async startFor(key, spec, projectRoot) {
|
|
795
|
+
const resolution = this.resolve(spec, projectRoot);
|
|
796
|
+
if (!resolution.available) {
|
|
797
|
+
return { ok: false, reason: resolution.reason, install: resolution.install };
|
|
798
|
+
}
|
|
799
|
+
const client = await this.start({
|
|
800
|
+
spec,
|
|
801
|
+
launch: resolution.launch,
|
|
802
|
+
projectRoot,
|
|
803
|
+
onCrash: (detail) => {
|
|
804
|
+
this.entries.delete(key);
|
|
805
|
+
this.noteFailure(key, `language server \u5D29\u4E86\uFF08${detail}\uFF09`);
|
|
806
|
+
}
|
|
807
|
+
});
|
|
808
|
+
if (!client) {
|
|
809
|
+
this.noteFailure(key, `language server \u8D77\u4E0D\u6765\u6216\u521D\u59CB\u5316\u8D85\u65F6\uFF08>${spec.initTimeoutMs}ms\uFF09`);
|
|
810
|
+
const now = this.failures.get(key);
|
|
811
|
+
return {
|
|
812
|
+
ok: false,
|
|
813
|
+
reason: now && now.count >= MAX_FAILURES ? `${now.detail}\uFF08\u672C\u6B21\u4F1A\u8BDD\u4E0D\u518D\u91CD\u542F\uFF09` : now?.detail ?? "\u542F\u52A8\u5931\u8D25"
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
this.entries.set(key, { client, lastUsedAt: this.now() });
|
|
817
|
+
this.ensureSweeper();
|
|
818
|
+
return { ok: true, client };
|
|
819
|
+
}
|
|
820
|
+
noteFailure(key, detail) {
|
|
821
|
+
const prev = this.failures.get(key);
|
|
822
|
+
this.failures.set(key, { count: (prev?.count ?? 0) + 1, detail });
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* 回收空闲实例。定时器调它,测试也直接调它。
|
|
826
|
+
*
|
|
827
|
+
* 重入保护不是洁癖:`dispose()` 里有一次最多 2s 的优雅退出,而扫描间隔是
|
|
828
|
+
* 1 分钟 —— 真卡住的时候两次扫描会叠在一起,对同一个 client 调两次 dispose。
|
|
829
|
+
*/
|
|
830
|
+
async sweep() {
|
|
831
|
+
if (this.sweeping) return;
|
|
832
|
+
this.sweeping = true;
|
|
833
|
+
try {
|
|
834
|
+
const cutoff = this.now() - this.idleMs;
|
|
835
|
+
const dying = [];
|
|
836
|
+
for (const [key, entry] of [...this.entries]) {
|
|
837
|
+
if (entry.lastUsedAt > cutoff && entry.client.alive) continue;
|
|
838
|
+
this.entries.delete(key);
|
|
839
|
+
dying.push(entry.client);
|
|
840
|
+
}
|
|
841
|
+
await Promise.all(dying.map((c) => c.dispose()));
|
|
842
|
+
if (this.entries.size === 0) this.stopSweeper();
|
|
843
|
+
} finally {
|
|
844
|
+
this.sweeping = false;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/** 全部关掉。宿主 dispose 时调;也是测试的收尾 */
|
|
848
|
+
async disposeAll() {
|
|
849
|
+
this.stopSweeper();
|
|
850
|
+
const all = [...this.entries.values()].map((e) => e.client);
|
|
851
|
+
this.entries.clear();
|
|
852
|
+
await Promise.all(all.map((c) => c.dispose()));
|
|
853
|
+
}
|
|
854
|
+
ensureSweeper() {
|
|
855
|
+
if (this.timer) return;
|
|
856
|
+
this.timer = setInterval(() => void this.sweep(), this.sweepMs);
|
|
857
|
+
this.timer.unref?.();
|
|
858
|
+
}
|
|
859
|
+
stopSweeper() {
|
|
860
|
+
if (!this.timer) return;
|
|
861
|
+
clearInterval(this.timer);
|
|
862
|
+
this.timer = null;
|
|
863
|
+
}
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// src/index.ts
|
|
867
|
+
var ERR_BAD_PATHS = 8001;
|
|
868
|
+
var ERR_UNEXPECTED = 8002;
|
|
869
|
+
var pool = new LspServerPool();
|
|
870
|
+
async function disposeLspServers() {
|
|
871
|
+
await pool.disposeAll();
|
|
872
|
+
}
|
|
873
|
+
function toPaths(value) {
|
|
874
|
+
const raw = typeof value === "string" ? [value] : value;
|
|
875
|
+
if (!Array.isArray(raw)) return null;
|
|
876
|
+
const out = [];
|
|
877
|
+
for (const item of raw) {
|
|
878
|
+
if (typeof item !== "string") return null;
|
|
879
|
+
const trimmed = item.trim();
|
|
880
|
+
if (trimmed) out.push(trimmed);
|
|
881
|
+
}
|
|
882
|
+
return out.length > 0 ? out : null;
|
|
883
|
+
}
|
|
884
|
+
function fail(code, message, suggestion, started) {
|
|
885
|
+
return {
|
|
886
|
+
success: false,
|
|
887
|
+
output: "",
|
|
888
|
+
error: { code, message, suggestion },
|
|
889
|
+
duration: Date.now() - started
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
var lspPlugin = {
|
|
893
|
+
name: "@epoch-agent/plugin-lsp",
|
|
894
|
+
version: "0.0.0",
|
|
895
|
+
description: "LSP \u8BCA\u65AD\u63D2\u4EF6 \u2014 \u7528\u9879\u76EE\u81EA\u5DF1\u7684 language server \u53D6\u7C7B\u578B / \u8BED\u6CD5\u9519\u8BEF",
|
|
896
|
+
tools: [
|
|
897
|
+
{
|
|
898
|
+
name: "lsp_diagnostics",
|
|
899
|
+
description: `\u7528\u9879\u76EE\u81EA\u5DF1\u7684 language server \u68C0\u67E5\u6587\u4EF6\uFF0C\u8FD4\u56DE\u7C7B\u578B\u9519\u8BEF\u3001\u8BED\u6CD5\u9519\u8BEF\u548C\u672A\u4F7F\u7528\u53D8\u91CF\u7B49\u8BCA\u65AD\u3002\u53C2\u6570: paths(\u5FC5\u586B, \u6587\u4EF6\u8DEF\u5F84\u6570\u7EC4, \u76F8\u5BF9\u5DE5\u4F5C\u76EE\u5F55)\u3002\u5F53\u524D\u652F\u6301 ${supportedExtensions().join(" ")}\u3002**\u6539\u5B8C\u4EE3\u7801\u4E4B\u540E\u3001\u5BA3\u5E03\u5B8C\u6210\u4E4B\u524D\u8C03\u4E00\u6B21**\uFF0C\u628A\u672C\u8F6E\u65B0\u5EFA\u6216\u4FEE\u6539\u8FC7\u7684\u6E90\u6587\u4EF6\u90FD\u5217\u8FDB\u53BB \u2014\u2014 \u5B83\u6BD4\u8BA9 shell \u8DD1\u4E00\u904D tsc \u5FEB\u5F97\u591A\uFF0C\u56E0\u4E3A language server \u662F\u5E38\u9A7B\u7684\u3002\u6CE8\u610F\u5B83\u53EA\u505A\u5355\u6587\u4EF6\u7EA7\u8BCA\u65AD\uFF0C\u4E0D\u7B49\u4E8E\u4E00\u6B21\u5B8C\u6574\u6784\u5EFA\uFF1A\u6784\u5EFA\u811A\u672C\u3001\u6253\u5305\u914D\u7F6E\u3001\u8DE8\u5305\u7C7B\u578B\u89E3\u6790\u51FA\u7684\u95EE\u9898\u5B83\u770B\u4E0D\u5230\u3002\u6CA1\u88C5\u5BF9\u5E94 language server \u65F6\u4F1A\u5982\u5B9E\u62A5\u300C\u8DF3\u8FC7\u300D\u5E76\u7ED9\u51FA\u5B89\u88C5\u547D\u4EE4\uFF0C\u4E0D\u4F1A\u8BA9\u8FD9\u6B21\u8C03\u7528\u5931\u8D25\u3002`,
|
|
900
|
+
parameters: {
|
|
901
|
+
type: "object",
|
|
902
|
+
properties: {
|
|
903
|
+
paths: {
|
|
904
|
+
type: "array",
|
|
905
|
+
items: { type: "string" },
|
|
906
|
+
description: '\u8981\u68C0\u67E5\u7684\u6587\u4EF6\u8DEF\u5F84\uFF08\u76F8\u5BF9\u5DE5\u4F5C\u76EE\u5F55\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF09\uFF0C\u4F8B\u5982 ["src/a.ts", "src/b.tsx"]'
|
|
907
|
+
}
|
|
908
|
+
},
|
|
909
|
+
required: ["paths"]
|
|
910
|
+
},
|
|
911
|
+
// 只读 + 不碰网络。`idempotentHint: false` 是因为文件内容会变 ——
|
|
912
|
+
// 同一批路径这一秒和下一秒的诊断可以不一样
|
|
913
|
+
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
|
|
914
|
+
// `file_read` 而不是 `command`:这个工具对外的语义就是「读几个文件、报问题」。
|
|
915
|
+
// 底下确实起了子进程,但那个进程是**我们表里写死的**(`servers.ts`),
|
|
916
|
+
// 参数里没有任何一段来自模型 —— 归到 command 只会让每次诊断都弹一次审批,
|
|
917
|
+
// 而 system prompt 恰恰要求模型每轮改完都调它
|
|
918
|
+
operation: "file_read",
|
|
919
|
+
// 审批目标是文件清单。不给的话调度侧会退回 `JSON.stringify(args)`,
|
|
920
|
+
// 换个顺序传同样几个文件就不命中缓存了
|
|
921
|
+
describeTarget: (args) => (toPaths(args["paths"]) ?? []).join(", "),
|
|
922
|
+
execute: async (args, ctx) => {
|
|
923
|
+
const started = Date.now();
|
|
924
|
+
const paths = toPaths(args["paths"]);
|
|
925
|
+
if (!paths) {
|
|
926
|
+
return fail(
|
|
927
|
+
ERR_BAD_PATHS,
|
|
928
|
+
"paths \u5FC5\u987B\u662F\u975E\u7A7A\u7684\u6587\u4EF6\u8DEF\u5F84\u6570\u7EC4",
|
|
929
|
+
'\u628A\u672C\u8F6E\u6539\u52A8\u8FC7\u7684\u6E90\u6587\u4EF6\u5217\u8FDB\u53BB\uFF0C\u4F8B\u5982 {"paths": ["src/a.ts"]}',
|
|
930
|
+
started
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
try {
|
|
934
|
+
const outcome = await diagnosePaths({
|
|
935
|
+
paths,
|
|
936
|
+
workDir: ctx.workDir,
|
|
937
|
+
pool,
|
|
938
|
+
...ctx.signal ? { signal: ctx.signal } : {}
|
|
939
|
+
});
|
|
940
|
+
return { success: true, output: formatOutcome(outcome), duration: Date.now() - started };
|
|
941
|
+
} catch (err) {
|
|
942
|
+
return fail(
|
|
943
|
+
ERR_UNEXPECTED,
|
|
944
|
+
`\u8BCA\u65AD\u5931\u8D25: ${err instanceof Error ? err.message : String(err)}`,
|
|
945
|
+
"\u53EF\u4EE5\u5148\u7EE7\u7EED\uFF0C\u6216\u6539\u7528 terminal \u8DD1\u9879\u76EE\u81EA\u5DF1\u7684\u7C7B\u578B\u68C0\u67E5\u547D\u4EE4",
|
|
946
|
+
started
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
]
|
|
952
|
+
};
|
|
953
|
+
export {
|
|
954
|
+
DEFAULT_IDLE_MS,
|
|
955
|
+
DEFAULT_SWEEP_MS,
|
|
956
|
+
DEFAULT_WAIT,
|
|
957
|
+
LspClient,
|
|
958
|
+
LspServerDownError,
|
|
959
|
+
LspServerPool,
|
|
960
|
+
MAX_FILES_PER_CALL,
|
|
961
|
+
MAX_ITEMS,
|
|
962
|
+
SERVERS,
|
|
963
|
+
diagnosePaths,
|
|
964
|
+
disposeLspServers,
|
|
965
|
+
findUp,
|
|
966
|
+
formatOutcome,
|
|
967
|
+
lspPlugin,
|
|
968
|
+
resolveServer,
|
|
969
|
+
serverForExtension,
|
|
970
|
+
supportedExtensions,
|
|
971
|
+
uriKey
|
|
972
|
+
};
|