@epoch-agent/plugin-lsp 0.1.0 → 0.2.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/README.md CHANGED
@@ -122,9 +122,14 @@ pull 模式(`initialize` 的结果里没有 `diagnosticProvider`)。server
122
122
  跳过。**「没装」不计入失败次数**:那不是「试了没成」,计进去的话用户装完 server
123
123
  还得重启 epoch 才能用上。
124
124
 
125
- **退出时宿主什么都不用做**:进程在 infra 的进程表里,`cleanupBackgroundProcesses()`
126
- 会一并收掉。`disposeLspServers()` 是给「想早点收、且要走 LSP `shutdown` 礼节」的
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` 退出时 `killAllTrackedProcesses()`
205
- * 天然把 server 一并收掉(验收 7),我们一行清理代码都不用写。
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
- collectProcessTree,
28
- killPids,
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 collectProcessTree(this.pidValue).catch(() => []);
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 killPids(tree, { escalate: true });
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.0",
3
+ "version": "0.2.0",
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/infra": "0.1.0",
28
- "@epoch-agent/protocol": "0.1.0"
27
+ "@epoch-agent/infra": "0.2.0",
28
+ "@epoch-agent/protocol": "0.2.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@types/node": "^26.1.2",