@epoch-agent/plugin-lsp 0.1.0 → 0.3.1
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/README.md +8 -3
- package/dist/index.d.ts +10 -2
- package/dist/index.js +4 -91
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -122,9 +122,14 @@ pull 模式(`initialize` 的结果里没有 `diagnosticProvider`)。server
|
|
|
122
122
|
跳过。**「没装」不计入失败次数**:那不是「试了没成」,计进去的话用户装完 server
|
|
123
123
|
还得重启 epoch 才能用上。
|
|
124
124
|
|
|
125
|
-
**退出时宿主什么都不用做**:进程在 infra
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
**退出时宿主什么都不用做**:进程在 infra 的**进程级兜底表**里,最后一个 runtime
|
|
126
|
+
`dispose()` 时一并收掉。`disposeLspServers()` 是给「想早点收、且要走 LSP `shutdown`
|
|
127
|
+
礼节」的宿主用的。
|
|
128
|
+
|
|
129
|
+
⚠️ 落在兜底表而不是某个 runtime 名下是**刻意的**(方案 60 §2.6):这个池按项目根
|
|
130
|
+
共享,两个 runtime 开在同一个项目上时它们本来就该共用同一个 tsserver ——
|
|
131
|
+
它不是任何一个 runtime 的私产。代价写下来不掩饰:同进程里 A 和 B 并存时,
|
|
132
|
+
B 的 `dispose()` 收不掉这些 server,要等 A 也走。
|
|
128
133
|
|
|
129
134
|
## 为什么是 `file_read` 而不是 `command`
|
|
130
135
|
|
package/dist/index.d.ts
CHANGED
|
@@ -201,8 +201,16 @@ declare function resolveServer(spec: ServerSpec, projectRoot: string, options?:
|
|
|
201
201
|
* (外加 `didClose` 做逐出、`shutdown` + `exit` 做优雅退出)。
|
|
202
202
|
*
|
|
203
203
|
* 进程**一律走 infra 的 `startLongLivedProcess`**(方案 36),这个包里不长
|
|
204
|
-
* 第二套进程管理 —— 顺带的好处是 `epoch` 退出时
|
|
205
|
-
*
|
|
204
|
+
* 第二套进程管理 —— 顺带的好处是 `epoch` 退出时 server 被天然一并收掉(验收 7),
|
|
205
|
+
* 我们一行清理代码都不用写。
|
|
206
|
+
*
|
|
207
|
+
* 收它的那条链子(方案 60 之后):`startLongLivedProcess` 登记进的是**进程级
|
|
208
|
+
* 兜底表** —— 这个池是模块级单例、按项目根共享,压根没有 `sessionId`,归兜底表
|
|
209
|
+
* 是结论不是妥协(判据在 [README](../README.md))。最后一个 runtime
|
|
210
|
+
* `dispose()` → `OwnedProcessTable.release()` 把兜底表的引用计数减到 0,那一下
|
|
211
|
+
* 连兜底表一起收。一个进程一个 runtime 的 CLI / TUI 每次 dispose 都归零,
|
|
212
|
+
* 所以和方案 60 之前逐字节相同;两个 runtime 并存时先走的那个碰不到这个池
|
|
213
|
+
* (否则表现是「跑了条定时任务,之后 `lsp_diagnostics` 每次都要重新冷启 tsserver」)。
|
|
206
214
|
*
|
|
207
215
|
* ## 诊断是**推**过来的,所以「什么时候算收全了」得自己判
|
|
208
216
|
*
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// src/diagnose.ts
|
|
2
2
|
import { readFile, stat } from "fs/promises";
|
|
3
3
|
import { extname, isAbsolute, relative, resolve as resolve2, sep } from "path";
|
|
4
|
-
|
|
5
4
|
// src/client.ts
|
|
6
5
|
import { basename } from "path";
|
|
7
6
|
import { pathToFileURL } from "url";
|
|
@@ -24,8 +23,8 @@ import {
|
|
|
24
23
|
WorkDoneProgressCreateRequest
|
|
25
24
|
} from "vscode-languageserver-protocol";
|
|
26
25
|
import {
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
collectProcessTreeStamped,
|
|
27
|
+
killStampedPids,
|
|
29
28
|
killTrackedProcess,
|
|
30
29
|
startLongLivedProcess
|
|
31
30
|
} from "@epoch-agent/infra";
|
|
@@ -73,13 +72,9 @@ var LspClient = class _LspClient {
|
|
|
73
72
|
connection;
|
|
74
73
|
pidValue;
|
|
75
74
|
spec;
|
|
76
|
-
/** 归一键 → 打开状态。按插入顺序逐出 */
|
|
77
75
|
openDocs = /* @__PURE__ */ new Map();
|
|
78
|
-
/** 归一键 → server 最近一次推来的诊断 */
|
|
79
76
|
latest = /* @__PURE__ */ new Map();
|
|
80
|
-
/** 最近一次「有动静」的时刻 —— 我们发出去的和 server 推回来的都算 */
|
|
81
77
|
lastActivityAt = 0;
|
|
82
|
-
/** 这一批里已经答过的文件 */
|
|
83
78
|
answered = /* @__PURE__ */ new Set();
|
|
84
79
|
dead = null;
|
|
85
80
|
disposing = false;
|
|
@@ -87,17 +82,9 @@ var LspClient = class _LspClient {
|
|
|
87
82
|
get pid() {
|
|
88
83
|
return this.pidValue;
|
|
89
84
|
}
|
|
90
|
-
/** 还能用吗。池按这个决定要不要重启(验收 9) */
|
|
91
85
|
get alive() {
|
|
92
86
|
return this.dead === null;
|
|
93
87
|
}
|
|
94
|
-
/**
|
|
95
|
-
* 起进程 + `initialize` + `initialized`。
|
|
96
|
-
*
|
|
97
|
-
* 起不来 / 初始化超时都**返回 `null` 而不抛** —— 调用方据此把这个语言标记
|
|
98
|
-
* 不可用,整轮照常出结果(验收 8)。抛出去的话一个慢 server 就能让整次工具
|
|
99
|
-
* 调用失败,而那正是这条验收要避免的。
|
|
100
|
-
*/
|
|
101
88
|
static async start(options) {
|
|
102
89
|
const { spec, launch, projectRoot } = options;
|
|
103
90
|
let self = null;
|
|
@@ -106,13 +93,8 @@ var LspClient = class _LspClient {
|
|
|
106
93
|
args: launch.args,
|
|
107
94
|
cwd: projectRoot,
|
|
108
95
|
spawnOptions: {
|
|
109
|
-
// infra 的默认值是 `['ignore', 'pipe', 'pipe']`,而 LSP over stdio **要
|
|
110
|
-
// stdin**。`spawnOptions` 在 infra 里是最后展开的,所以这里盖得掉
|
|
111
96
|
stdio: ["pipe", "pipe", "pipe"],
|
|
112
97
|
...launch.verbatim ? { windowsVerbatimArguments: true } : {},
|
|
113
|
-
// `launch.env` 只有项目本地那一档有(它拿 `execPath` 当 node 使)。
|
|
114
|
-
// **必须连 `process.env` 一起摊开**:给 `env` 赋值是整份替换而不是叠加,
|
|
115
|
-
// 只放那一格等于把 PATH / HOME 抽掉,server 当场起不来
|
|
116
98
|
...launch.env ? { env: { ...process.env, ...launch.env } } : {}
|
|
117
99
|
},
|
|
118
100
|
onOutput: (chunk, stream) => {
|
|
@@ -141,7 +123,6 @@ var LspClient = class _LspClient {
|
|
|
141
123
|
}
|
|
142
124
|
return self;
|
|
143
125
|
}
|
|
144
|
-
/** 订阅推送 + 兜住 server 反过来发的那几种请求 */
|
|
145
126
|
wire() {
|
|
146
127
|
this.connection.onNotification(
|
|
147
128
|
PublishDiagnosticsNotification.type,
|
|
@@ -184,9 +165,6 @@ var LspClient = class _LspClient {
|
|
|
184
165
|
willSaveWaitUntil: false,
|
|
185
166
|
didSave: false
|
|
186
167
|
},
|
|
187
|
-
// **这一段不能省**:typescript-language-server 用它算
|
|
188
|
-
// `features.diagnosticsSupport`,不声明就一条诊断都不推 ——
|
|
189
|
-
// 表现成「所有文件都没问题」,最坏的那种失败
|
|
190
168
|
publishDiagnostics: { relatedInformation: true, versionSupport: false }
|
|
191
169
|
}
|
|
192
170
|
},
|
|
@@ -204,11 +182,6 @@ var LspClient = class _LspClient {
|
|
|
204
182
|
return false;
|
|
205
183
|
}
|
|
206
184
|
}
|
|
207
|
-
/**
|
|
208
|
-
* 打开 / 更新这批文件,等到静默,交出诊断。
|
|
209
|
-
*
|
|
210
|
-
* @throws {LspServerDownError} 进程中途没了(验收 9 的输入)
|
|
211
|
-
*/
|
|
212
185
|
async diagnose(documents, wait) {
|
|
213
186
|
if (this.dead) throw new LspServerDownError(this.dead);
|
|
214
187
|
if (documents.length === 0) return { byPath: /* @__PURE__ */ new Map(), pending: [] };
|
|
@@ -237,7 +210,6 @@ var LspClient = class _LspClient {
|
|
|
237
210
|
}
|
|
238
211
|
return { byPath, pending };
|
|
239
212
|
}
|
|
240
|
-
/** 没打开过就 `didOpen`,打开过就 `didChange`(全量) */
|
|
241
213
|
async sync(doc) {
|
|
242
214
|
const uri = pathToFileURL(doc.path).toString();
|
|
243
215
|
const key = uriKey(uri);
|
|
@@ -253,12 +225,9 @@ var LspClient = class _LspClient {
|
|
|
253
225
|
open.version += 1;
|
|
254
226
|
await this.connection.sendNotification(DidChangeTextDocumentNotification.type, {
|
|
255
227
|
textDocument: { uri: open.uri, version: open.version },
|
|
256
|
-
// 全量替换。增量同步要自己维护一份影子文档才算得出 range,而我们每次拿到的
|
|
257
|
-
// 本来就是磁盘上的完整内容 —— 算 diff 只是把复杂度换成一个新的错误来源
|
|
258
228
|
contentChanges: [{ text: doc.text }]
|
|
259
229
|
});
|
|
260
230
|
}
|
|
261
|
-
/** 超过上限就把最早打开、且不在本批里的文档关掉 */
|
|
262
231
|
async evictOverflow(keep) {
|
|
263
232
|
for (const [key, open] of [...this.openDocs]) {
|
|
264
233
|
if (this.openDocs.size <= MAX_OPEN_DOCUMENTS) return;
|
|
@@ -270,31 +239,10 @@ var LspClient = class _LspClient {
|
|
|
270
239
|
});
|
|
271
240
|
}
|
|
272
241
|
}
|
|
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
242
|
async dispose() {
|
|
295
243
|
if (this.disposing) return;
|
|
296
244
|
this.disposing = true;
|
|
297
|
-
const tree = await
|
|
245
|
+
const tree = await collectProcessTreeStamped(this.pidValue).catch(() => []);
|
|
298
246
|
try {
|
|
299
247
|
await withTimeoutOrNull(
|
|
300
248
|
(async () => {
|
|
@@ -311,7 +259,7 @@ var LspClient = class _LspClient {
|
|
|
311
259
|
}
|
|
312
260
|
this.dead ??= "\u5DF2\u4E3B\u52A8\u5173\u95ED";
|
|
313
261
|
await killTrackedProcess(this.pidValue);
|
|
314
|
-
if (tree.length > 0) await
|
|
262
|
+
if (tree.length > 0) await killStampedPids(tree, { escalate: true });
|
|
315
263
|
}
|
|
316
264
|
};
|
|
317
265
|
function sleep(ms) {
|
|
@@ -333,10 +281,8 @@ async function withTimeoutOrNull(promise, ms) {
|
|
|
333
281
|
if (timer) clearTimeout(timer);
|
|
334
282
|
}
|
|
335
283
|
}
|
|
336
|
-
|
|
337
284
|
// src/servers.ts
|
|
338
285
|
import { dirname as dirname2, join as join2 } from "path";
|
|
339
|
-
|
|
340
286
|
// src/find-up.ts
|
|
341
287
|
import { existsSync } from "fs";
|
|
342
288
|
import { dirname, join, resolve } from "path";
|
|
@@ -353,7 +299,6 @@ function findUp(startDir, relativePath, options = {}) {
|
|
|
353
299
|
}
|
|
354
300
|
return null;
|
|
355
301
|
}
|
|
356
|
-
|
|
357
302
|
// src/servers.ts
|
|
358
303
|
var TS_LANGUAGE_IDS = {
|
|
359
304
|
".ts": "typescript",
|
|
@@ -391,15 +336,10 @@ function serverForExtension(ext) {
|
|
|
391
336
|
function supportedExtensions() {
|
|
392
337
|
return Object.keys(TS_LANGUAGE_IDS).sort();
|
|
393
338
|
}
|
|
394
|
-
|
|
395
339
|
// src/diagnose.ts
|
|
396
340
|
var MAX_FILES_PER_CALL = 50;
|
|
397
341
|
var DEFAULT_WAIT = {
|
|
398
|
-
// tsserver 一个文件分三次推(语法 / 语义 / 建议),各带 50ms 去抖。
|
|
399
|
-
// 400ms 静默足够让三次都落地,又不会平白给每次调用加一秒
|
|
400
342
|
settleMs: 400,
|
|
401
|
-
// 内容没变时 server 干脆不推(见 client.ts)。这时要等的是
|
|
402
|
-
// typescript-language-server 那个 300–800ms 的触发延迟走完,确认它真的不打算说话
|
|
403
343
|
fallbackMs: 1500,
|
|
404
344
|
timeoutMs: 15e3
|
|
405
345
|
};
|
|
@@ -425,7 +365,6 @@ function messageOf(message) {
|
|
|
425
365
|
function toItem(diag, file) {
|
|
426
366
|
const item = {
|
|
427
367
|
file,
|
|
428
|
-
// LSP 的行列都是 0 起的,人和其它工具用的是 1 起
|
|
429
368
|
line: diag.range.start.line + 1,
|
|
430
369
|
column: diag.range.start.character + 1,
|
|
431
370
|
severity: severityOf(diag.severity),
|
|
@@ -566,7 +505,6 @@ async function diagnosePaths(input) {
|
|
|
566
505
|
sortItems(outcome.diagnostics);
|
|
567
506
|
return outcome;
|
|
568
507
|
}
|
|
569
|
-
|
|
570
508
|
// src/format.ts
|
|
571
509
|
var MAX_ITEMS = 100;
|
|
572
510
|
var MAX_MESSAGE = 400;
|
|
@@ -638,7 +576,6 @@ function formatOutcome(outcome) {
|
|
|
638
576
|
}
|
|
639
577
|
return lines.join("\n");
|
|
640
578
|
}
|
|
641
|
-
|
|
642
579
|
// src/detect.ts
|
|
643
580
|
import { existsSync as existsSync2, readFileSync } from "fs";
|
|
644
581
|
import { dirname as dirname3, join as join3, posix, win32 } from "path";
|
|
@@ -686,8 +623,6 @@ function projectLaunch(spec, projectRoot, o) {
|
|
|
686
623
|
file: o.execPath,
|
|
687
624
|
args: [entry, ...spec.args],
|
|
688
625
|
origin: `\u9879\u76EE node_modules\uFF08${packageDir}\uFF09`,
|
|
689
|
-
// 嵌进 Electron 的宿主里 `execPath` 是宿主自己的可执行文件,少了这一格
|
|
690
|
-
// 起 language server 等于启动第二个 app 实例
|
|
691
626
|
env: EXEC_PATH_AS_NODE_ENV
|
|
692
627
|
};
|
|
693
628
|
}
|
|
@@ -697,8 +632,6 @@ function shimLaunch(shim, spec, o) {
|
|
|
697
632
|
return {
|
|
698
633
|
tier: "path",
|
|
699
634
|
file: o.env["ComSpec"] ?? o.env["COMSPEC"] ?? "cmd.exe",
|
|
700
|
-
// 整条命令再包一层引号是 cmd 的老规矩:`/s` 让它只剥最外层,
|
|
701
|
-
// 里面那对(路径带空格时的)引号才留得住
|
|
702
635
|
args: ["/d", "/s", "/c", `"${command}"`],
|
|
703
636
|
origin: `PATH\uFF08${shim}\uFF09`,
|
|
704
637
|
verbatim: true
|
|
@@ -734,7 +667,6 @@ function resolveServer(spec, projectRoot, options = {}) {
|
|
|
734
667
|
install: spec.install
|
|
735
668
|
};
|
|
736
669
|
}
|
|
737
|
-
|
|
738
670
|
// src/pool.ts
|
|
739
671
|
var DEFAULT_IDLE_MS = 10 * 60 * 1e3;
|
|
740
672
|
var DEFAULT_SWEEP_MS = 60 * 1e3;
|
|
@@ -742,7 +674,6 @@ var MAX_FAILURES = 2;
|
|
|
742
674
|
var LspServerPool = class {
|
|
743
675
|
entries = /* @__PURE__ */ new Map();
|
|
744
676
|
failures = /* @__PURE__ */ new Map();
|
|
745
|
-
/** 同一个 key 上并发的 acquire 共用一次启动,不然会起出两个 tsserver */
|
|
746
677
|
starting = /* @__PURE__ */ new Map();
|
|
747
678
|
timer = null;
|
|
748
679
|
sweeping = false;
|
|
@@ -758,11 +689,9 @@ var LspServerPool = class {
|
|
|
758
689
|
this.resolve = options.resolve ?? ((spec, root) => resolveServer(spec, root));
|
|
759
690
|
this.start = options.start ?? ((opts) => LspClient.start(opts));
|
|
760
691
|
}
|
|
761
|
-
/** 活着的实例数。验收 5(懒启动)就是断言它一开始是 0 */
|
|
762
692
|
get size() {
|
|
763
693
|
return this.entries.size;
|
|
764
694
|
}
|
|
765
|
-
/** 拿一个可用的 client;拿不到就说清为什么 */
|
|
766
695
|
async acquire(spec, projectRoot) {
|
|
767
696
|
const key = `${projectRoot}\0${spec.id}`;
|
|
768
697
|
const existing = this.entries.get(key);
|
|
@@ -821,12 +750,6 @@ var LspServerPool = class {
|
|
|
821
750
|
const prev = this.failures.get(key);
|
|
822
751
|
this.failures.set(key, { count: (prev?.count ?? 0) + 1, detail });
|
|
823
752
|
}
|
|
824
|
-
/**
|
|
825
|
-
* 回收空闲实例。定时器调它,测试也直接调它。
|
|
826
|
-
*
|
|
827
|
-
* 重入保护不是洁癖:`dispose()` 里有一次最多 2s 的优雅退出,而扫描间隔是
|
|
828
|
-
* 1 分钟 —— 真卡住的时候两次扫描会叠在一起,对同一个 client 调两次 dispose。
|
|
829
|
-
*/
|
|
830
753
|
async sweep() {
|
|
831
754
|
if (this.sweeping) return;
|
|
832
755
|
this.sweeping = true;
|
|
@@ -844,7 +767,6 @@ var LspServerPool = class {
|
|
|
844
767
|
this.sweeping = false;
|
|
845
768
|
}
|
|
846
769
|
}
|
|
847
|
-
/** 全部关掉。宿主 dispose 时调;也是测试的收尾 */
|
|
848
770
|
async disposeAll() {
|
|
849
771
|
this.stopSweeper();
|
|
850
772
|
const all = [...this.entries.values()].map((e) => e.client);
|
|
@@ -862,7 +784,6 @@ var LspServerPool = class {
|
|
|
862
784
|
this.timer = null;
|
|
863
785
|
}
|
|
864
786
|
};
|
|
865
|
-
|
|
866
787
|
// src/index.ts
|
|
867
788
|
var ERR_BAD_PATHS = 8001;
|
|
868
789
|
var ERR_UNEXPECTED = 8002;
|
|
@@ -908,16 +829,8 @@ var lspPlugin = {
|
|
|
908
829
|
},
|
|
909
830
|
required: ["paths"]
|
|
910
831
|
},
|
|
911
|
-
// 只读 + 不碰网络。`idempotentHint: false` 是因为文件内容会变 ——
|
|
912
|
-
// 同一批路径这一秒和下一秒的诊断可以不一样
|
|
913
832
|
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false },
|
|
914
|
-
// `file_read` 而不是 `command`:这个工具对外的语义就是「读几个文件、报问题」。
|
|
915
|
-
// 底下确实起了子进程,但那个进程是**我们表里写死的**(`servers.ts`),
|
|
916
|
-
// 参数里没有任何一段来自模型 —— 归到 command 只会让每次诊断都弹一次审批,
|
|
917
|
-
// 而 system prompt 恰恰要求模型每轮改完都调它
|
|
918
833
|
operation: "file_read",
|
|
919
|
-
// 审批目标是文件清单。不给的话调度侧会退回 `JSON.stringify(args)`,
|
|
920
|
-
// 换个顺序传同样几个文件就不命中缓存了
|
|
921
834
|
describeTarget: (args) => (toPaths(args["paths"]) ?? []).join(", "),
|
|
922
835
|
execute: async (args, ctx) => {
|
|
923
836
|
const started = Date.now();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@epoch-agent/plugin-lsp",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "LSP 诊断插件 — 探测项目自带的 language server,按文件取语义诊断",
|
|
6
6
|
"repository": {
|
|
@@ -24,8 +24,8 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"vscode-jsonrpc": "^9.0.1",
|
|
26
26
|
"vscode-languageserver-protocol": "^3.17.5",
|
|
27
|
-
"@epoch-agent/
|
|
28
|
-
"@epoch-agent/
|
|
27
|
+
"@epoch-agent/protocol": "0.3.1",
|
|
28
|
+
"@epoch-agent/infra": "0.3.1"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
31
|
"@types/node": "^26.1.2",
|