@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.d.ts
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { EpochPlugin } from '@epoch-agent/protocol';
|
|
2
|
+
import { Diagnostic } from 'vscode-languageserver-protocol';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* LSP 诊断插件的公共类型(方案 38)。
|
|
6
|
+
*
|
|
7
|
+
* 这里只放**跨文件共用**的形状。LSP 协议自己的类型一律从
|
|
8
|
+
* `vscode-languageserver-protocol` 拿,不在这儿抄第二份 —— 抄一份的下场是
|
|
9
|
+
* 协议升一版之后两边说法不一致,而 tsc 不会告诉你哪一份才是真的。
|
|
10
|
+
*/
|
|
11
|
+
/** 严重级别。LSP 的四档原样保留,不合并成「错 / 不错」两档 */
|
|
12
|
+
type DiagnosticSeverityName = 'error' | 'warning' | 'information' | 'hint';
|
|
13
|
+
/**
|
|
14
|
+
* 一条诊断 —— 交给模型的最终形状。
|
|
15
|
+
*
|
|
16
|
+
* `file` 是**相对工作区的正斜杠路径**:绝对路径在 Windows 上会把
|
|
17
|
+
* `C:\Users\…\epoch-agent\packages\…` 这一长串灌进上下文,而模型接下来要拿它去
|
|
18
|
+
* 调 `file_read`,那个工具认的本来就是相对路径。
|
|
19
|
+
*/
|
|
20
|
+
interface DiagnosticItem {
|
|
21
|
+
file: string;
|
|
22
|
+
/** 1 起(LSP 是 0 起,这里已经换过) */
|
|
23
|
+
line: number;
|
|
24
|
+
/** 1 起 */
|
|
25
|
+
column: number;
|
|
26
|
+
severity: DiagnosticSeverityName;
|
|
27
|
+
message: string;
|
|
28
|
+
/** 谁报的(LSP 的 `source`,TypeScript 那边是 `typescript`) */
|
|
29
|
+
source?: string;
|
|
30
|
+
/** 规则号 / 错误码(TS 是 `2322`,eslint 是规则名) */
|
|
31
|
+
code?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* 一种语言被跳过了,以及怎么才能不被跳过。
|
|
35
|
+
*
|
|
36
|
+
* **必须带 `install`**:只说「跳过了」等于让用户自己去猜该装什么,
|
|
37
|
+
* 而这条信息我们是知道的(见 `servers.ts` 的 `install` 字段)。
|
|
38
|
+
*/
|
|
39
|
+
interface SkippedServer {
|
|
40
|
+
/** 给人看的语言名 */
|
|
41
|
+
label: string;
|
|
42
|
+
/** 为什么跳过 */
|
|
43
|
+
reason: string;
|
|
44
|
+
/** 装法。已经装了但起不来时没有这一项 */
|
|
45
|
+
install?: string;
|
|
46
|
+
/** 这次因此没被检查的文件(相对路径) */
|
|
47
|
+
files: string[];
|
|
48
|
+
}
|
|
49
|
+
/** 已支持的 server id。池按 `(项目根, serverId)` 隔离 */
|
|
50
|
+
type ServerId = 'typescript';
|
|
51
|
+
/**
|
|
52
|
+
* 一种 language server 的启动方式。
|
|
53
|
+
*
|
|
54
|
+
* 定义放在 types.ts 而不是 servers.ts,是为了让 `detect.ts` 只依赖类型 ——
|
|
55
|
+
* 否则 `servers.ts`(要用 `findUp`)和 `detect.ts`(要用 `ServerSpec`)互相 import,
|
|
56
|
+
* `import/no-cycle` 当场红。
|
|
57
|
+
*/
|
|
58
|
+
interface ServerSpec {
|
|
59
|
+
id: ServerId;
|
|
60
|
+
/** 给人看的名字,出现在 skipped 那行里 */
|
|
61
|
+
label: string;
|
|
62
|
+
/**
|
|
63
|
+
* npm 包名。给了就优先按**项目本地的 node_modules** 解析。
|
|
64
|
+
*
|
|
65
|
+
* ⚠️ 走包的 `bin` 字段拿到真实入口再用 `node` 跑它,**不碰 `node_modules/.bin`**:
|
|
66
|
+
* Windows 上 `.bin` 里躺的是 npm 生成的 `.cmd` 垫片,而 Node 的 `spawn` 不能
|
|
67
|
+
* 直接执行 `.cmd`(必须过一次 shell)。绕开垫片两个平台就是同一条路径。
|
|
68
|
+
*/
|
|
69
|
+
pkg?: string;
|
|
70
|
+
/** PATH 那一档的可执行文件名(不带 `.exe` / `.cmd`) */
|
|
71
|
+
bin: string;
|
|
72
|
+
/** 启动参数。LSP over stdio 基本都是 `--stdio` */
|
|
73
|
+
args: readonly string[];
|
|
74
|
+
/** 没装时告诉用户怎么装。**必填** —— 见 `SkippedServer.install` */
|
|
75
|
+
install: string;
|
|
76
|
+
/**
|
|
77
|
+
* `initialize` 的超时。超时就把这个语言标记不可用,**整轮不失败**(验收 8)。
|
|
78
|
+
*
|
|
79
|
+
* 每种 server 单独给:`rust-analyzer` 首次索引一个大仓要几十秒,
|
|
80
|
+
* 拿 tsserver 的值去要求它等于「Rust 永远不可用」。
|
|
81
|
+
*/
|
|
82
|
+
initTimeoutMs: number;
|
|
83
|
+
/**
|
|
84
|
+
* 按项目算出要传给 server 的 `initializationOptions`。
|
|
85
|
+
*
|
|
86
|
+
* 这是「用项目自己的工具链」(验收 3)落地的地方。
|
|
87
|
+
*/
|
|
88
|
+
initializationOptions?: (projectRoot: string) => Record<string, unknown> | undefined;
|
|
89
|
+
}
|
|
90
|
+
/** 一次 `lsp_diagnostics` 的完整结果 */
|
|
91
|
+
interface DiagnoseOutcome {
|
|
92
|
+
/** 真的过了 language server 的文件(相对路径) */
|
|
93
|
+
checked: string[];
|
|
94
|
+
/** 全部诊断,按文件 → 行 → 列排好 */
|
|
95
|
+
diagnostics: DiagnosticItem[];
|
|
96
|
+
/** 有 server 但用不上的语言 */
|
|
97
|
+
skipped: SkippedServer[];
|
|
98
|
+
/**
|
|
99
|
+
* 压根没有对应 server 的文件(`.md` / `.json` / 第一版还没支持的语言)。
|
|
100
|
+
*
|
|
101
|
+
* 和 `skipped` 分开:那个是「装一下就能用」,这个是「我们还没做」——
|
|
102
|
+
* 混成一句话会让用户去装一个根本不存在的东西。
|
|
103
|
+
*/
|
|
104
|
+
unsupported: string[];
|
|
105
|
+
/** 路径本身有问题的(不存在 / 越界 / 是目录) */
|
|
106
|
+
rejected: Array<{
|
|
107
|
+
path: string;
|
|
108
|
+
reason: string;
|
|
109
|
+
}>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* server 从哪来 —— 三级探测(方案 38)。
|
|
114
|
+
*
|
|
115
|
+
* ```
|
|
116
|
+
* 1. 项目本地 node_modules/<pkg> ← TypeScript 那条「版本跟项目走」的硬要求
|
|
117
|
+
* 2. PATH
|
|
118
|
+
* 3. 都没有 → 该语言 skipped + 安装命令,**其它语言不受影响**(验收 4)
|
|
119
|
+
* ```
|
|
120
|
+
*
|
|
121
|
+
* ## 为什么不碰 `node_modules/.bin`
|
|
122
|
+
*
|
|
123
|
+
* 方案里写的探测点是 `node_modules/.bin/typescript-language-server`,实现故意
|
|
124
|
+
* 绕开了它:Windows 上 `.bin` 里躺的是 npm 生成的 **`.cmd` 垫片**,而 Node 的
|
|
125
|
+
* `spawn` 不过 shell 就执行不了 `.cmd`。走 `node_modules/<pkg>/package.json` 的
|
|
126
|
+
* `bin` 字段拿到真实入口、再用 `process.execPath` 跑它,两个平台是同一条代码路径,
|
|
127
|
+
* 而且**必然是项目里那一份**(`.bin` 在 pnpm 下是符号链接,跟到哪儿要看安装布局)。
|
|
128
|
+
*
|
|
129
|
+
* ## 和 infra/ripgrep.ts 的探测为什么不一样
|
|
130
|
+
*
|
|
131
|
+
* 那边扫完 PATH **故意把绝对路径丢掉**、只返回 boolean,然后 spawn 裸名字
|
|
132
|
+
* `'rg'`,理由是把解析交还给 OS、避开 Windows 上 cwd 优先的历史行为。
|
|
133
|
+
* 这里反过来**留着绝对路径**,两个原因:
|
|
134
|
+
*
|
|
135
|
+
* 1. 我们得知道命中的是 `.exe` 还是 `.cmd` —— 后者要走 `cmd.exe /d /s /c` 才起得来
|
|
136
|
+
* 2. 拿 PATH 里扫出来的绝对路径去 spawn,比让 shell 再解析一次**更**安全:
|
|
137
|
+
* 我们的扫描列表里没有 cwd,而 `shell: true` + 裸名字在 Windows 上有
|
|
138
|
+
* (目标仓库根目录里放一个同名 `.cmd` 就能劫持 —— 而这个工具的输入
|
|
139
|
+
* 正是「别人的仓库」)
|
|
140
|
+
*/
|
|
141
|
+
|
|
142
|
+
/** 命中的是哪一级 */
|
|
143
|
+
type LaunchTier = 'project' | 'path';
|
|
144
|
+
/** 怎么把这个 server 起起来 —— 直接喂给 `startLongLivedProcess` */
|
|
145
|
+
interface ServerLaunch {
|
|
146
|
+
tier: LaunchTier;
|
|
147
|
+
/** `spawn` 的第一个参数 */
|
|
148
|
+
file: string;
|
|
149
|
+
args: string[];
|
|
150
|
+
/** 一句人话,进日志和 `epoch doctor` */
|
|
151
|
+
origin: string;
|
|
152
|
+
/**
|
|
153
|
+
* 要不要 `windowsVerbatimArguments`。
|
|
154
|
+
*
|
|
155
|
+
* 只有「Windows + `.cmd` / `.bat` 垫片」那条走 `cmd.exe /d /s /c "<整条命令>"`,
|
|
156
|
+
* 而那个形态下 Node 必须**原样**把最后一个参数交给 cmd,不能再套一层引号。
|
|
157
|
+
*/
|
|
158
|
+
verbatim?: boolean;
|
|
159
|
+
/**
|
|
160
|
+
* 这条启动方式**自己要的**环境变量,合并在继承来的 env 之上。
|
|
161
|
+
*
|
|
162
|
+
* 只有 `project` 那一档有:它拿 `process.execPath` 当 node 使,而在嵌入
|
|
163
|
+
* Electron 的宿主里那不是 node(全文见 infra 的 `EXEC_PATH_AS_NODE_ENV`)。
|
|
164
|
+
* `path` 那两档起的是真可执行文件,不需要。
|
|
165
|
+
*/
|
|
166
|
+
env?: Readonly<Record<string, string>>;
|
|
167
|
+
}
|
|
168
|
+
/** 探测结果。`available: false` 是**正常结果**,不是错误 —— 见验收 4 */
|
|
169
|
+
type ServerResolution = {
|
|
170
|
+
available: true;
|
|
171
|
+
launch: ServerLaunch;
|
|
172
|
+
} | {
|
|
173
|
+
available: false;
|
|
174
|
+
reason: string;
|
|
175
|
+
install: string;
|
|
176
|
+
};
|
|
177
|
+
/** 测试注入点。生产调用一律不传 */
|
|
178
|
+
interface ResolveServerOptions {
|
|
179
|
+
env?: NodeJS.ProcessEnv;
|
|
180
|
+
platform?: string;
|
|
181
|
+
fileExists?: (path: string) => boolean;
|
|
182
|
+
/** 读 package.json;读不出来返回 `null`(坏 JSON / 权限不对都算) */
|
|
183
|
+
readManifest?: (path: string) => unknown;
|
|
184
|
+
/** 用哪个 node 去跑项目本地那个入口 */
|
|
185
|
+
execPath?: string;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* 这台机器上、这个项目里,这个 server 能不能起来、怎么起。
|
|
189
|
+
*
|
|
190
|
+
* **不 spawn 任何东西**:这条在每次 `lsp_diagnostics` 都会跑,起一个进程只为了
|
|
191
|
+
* 问「你在不在」是纯浪费;而「某个可执行文件在不在」本来就是纯文件系统问题。
|
|
192
|
+
*/
|
|
193
|
+
declare function resolveServer(spec: ServerSpec, projectRoot: string, options?: ResolveServerOptions): ServerResolution;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 一个 language server 连接(方案 38)。
|
|
197
|
+
*
|
|
198
|
+
* 协议实现**不手写**:`vscode-jsonrpc` 负责分帧和 JSON-RPC,
|
|
199
|
+
* `vscode-languageserver-protocol` 提供消息描述符和类型。真正用到的只有五条:
|
|
200
|
+
* `initialize` / `initialized` / `didOpen` / `didChange` / `publishDiagnostics`
|
|
201
|
+
* (外加 `didClose` 做逐出、`shutdown` + `exit` 做优雅退出)。
|
|
202
|
+
*
|
|
203
|
+
* 进程**一律走 infra 的 `startLongLivedProcess`**(方案 36),这个包里不长
|
|
204
|
+
* 第二套进程管理 —— 顺带的好处是 `epoch` 退出时 `killAllTrackedProcesses()`
|
|
205
|
+
* 天然把 server 一并收掉(验收 7),我们一行清理代码都不用写。
|
|
206
|
+
*
|
|
207
|
+
* ## 诊断是**推**过来的,所以「什么时候算收全了」得自己判
|
|
208
|
+
*
|
|
209
|
+
* 这个协议里没有「诊断请求」这种东西(typescript-language-server 4.4 也不支持
|
|
210
|
+
* 3.17 的 pull 模式 —— `initialize` 的结果里没有 `diagnosticProvider`)。
|
|
211
|
+
* server 想发就发,而 tsserver 一个文件会**分三次**发(语法 / 语义 / 建议,
|
|
212
|
+
* 各自 50ms 去抖),先到的那一份是不全的。
|
|
213
|
+
*
|
|
214
|
+
* 判据因此是**静默**:请求的文件都答过了,且最近一次动静距今超过 `settleMs`。
|
|
215
|
+
*
|
|
216
|
+
* ### 还有一条更阴的:内容没变时 server 干脆不发
|
|
217
|
+
*
|
|
218
|
+
* typescript-language-server 的 `FileDiagnostics.update()` 里有这么一句
|
|
219
|
+
* (lib/cli.mjs):
|
|
220
|
+
*
|
|
221
|
+
* ```js
|
|
222
|
+
* if (this.diagnosticsPerKind.get(kind)?.length === 0 && diagnostics.length === 0) return;
|
|
223
|
+
* ```
|
|
224
|
+
*
|
|
225
|
+
* 也就是「这一类上次是空、这次还是空」就**不推送**。于是一个干净的文件被诊断
|
|
226
|
+
* 第二次时一条消息都收不到 —— 如果按「没答复 = 没结果」处理,就会把它报成
|
|
227
|
+
* 「server 没回答」,而它其实是最正常的那种情况。
|
|
228
|
+
*
|
|
229
|
+
* 所以有第二条判据:**没答复但有上一次的结果,就用上一次的**。这在语义上是
|
|
230
|
+
* 对的,因为那句 `return` 恰好只在「什么都没变、也没什么可报」时生效;一旦某一类
|
|
231
|
+
* 从有变没或从没变有,它就会照常推送。
|
|
232
|
+
*/
|
|
233
|
+
|
|
234
|
+
/** 一次批量诊断的输入 */
|
|
235
|
+
interface DocumentInput {
|
|
236
|
+
/** 绝对路径 */
|
|
237
|
+
path: string;
|
|
238
|
+
/** LSP 的 `languageId`(`typescript` / `javascriptreact` …) */
|
|
239
|
+
languageId: string;
|
|
240
|
+
/** 文件当前内容 */
|
|
241
|
+
text: string;
|
|
242
|
+
}
|
|
243
|
+
/** 一次批量诊断的产出 */
|
|
244
|
+
interface DiagnoseBatch {
|
|
245
|
+
/** 归一后的路径 → 该文件的诊断。**只有有结论的文件才有键** */
|
|
246
|
+
byPath: Map<string, Diagnostic[]>;
|
|
247
|
+
/** 到点了仍然没有任何结论的文件。调用方要如实报出去,不许当成「没问题」 */
|
|
248
|
+
pending: string[];
|
|
249
|
+
}
|
|
250
|
+
interface StartClientOptions {
|
|
251
|
+
spec: ServerSpec;
|
|
252
|
+
launch: ServerLaunch;
|
|
253
|
+
/** 项目根 —— server 的 cwd、`rootUri`,也是 `initializationOptions` 的输入 */
|
|
254
|
+
projectRoot: string;
|
|
255
|
+
/** 进程**意外**退出时调一次。主动 `dispose()` 不触发 */
|
|
256
|
+
onCrash?: (detail: string) => void;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* 池和编排层真正用到的那部分 client 能力。
|
|
260
|
+
*
|
|
261
|
+
* 抽出来不是为了将来换实现,是为了**测试能不起进程**:`LspClient` 的构造函数是
|
|
262
|
+
* 私有的(只能走 `start()`,因为它必须先 spawn 再 `initialize`),
|
|
263
|
+
* 拿类当类型就等于每个用例都得真起一个 tsserver。
|
|
264
|
+
*/
|
|
265
|
+
interface DiagnosticsClient {
|
|
266
|
+
/** 还能用吗 */
|
|
267
|
+
readonly alive: boolean;
|
|
268
|
+
diagnose(documents: DocumentInput[], wait: WaitOptions): Promise<DiagnoseBatch>;
|
|
269
|
+
dispose(): Promise<void>;
|
|
270
|
+
}
|
|
271
|
+
interface WaitOptions {
|
|
272
|
+
/** 全都答复了之后,再静默这么久就收工 */
|
|
273
|
+
settleMs: number;
|
|
274
|
+
/** 有文件没答复、但都有上一次的结果时,静默这么久就用旧结果收工 */
|
|
275
|
+
fallbackMs: number;
|
|
276
|
+
/** 整批的上限。到点就交出手上有的 */
|
|
277
|
+
timeoutMs: number;
|
|
278
|
+
}
|
|
279
|
+
/** server 已经不在了。调用方应当把这次算作「该语言不可用」,而不是整轮失败 */
|
|
280
|
+
declare class LspServerDownError extends Error {
|
|
281
|
+
readonly detail: string;
|
|
282
|
+
constructor(detail: string);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* 把 URI 归一成可比较的键。
|
|
286
|
+
*
|
|
287
|
+
* **不能直接拿 URI 字符串当键**:我们发出去的是 `pathToFileURL()` 的形态,
|
|
288
|
+
* 而 server 回来的那条经过它自己的 URI 实现往返一趟 —— Windows 上盘符大小写
|
|
289
|
+
* 和 `%3A` 的转义方式都可能不一样,两边字符串不等,诊断就永远对不上文件。
|
|
290
|
+
* 用真实路径当键,这一整类不匹配就没了。
|
|
291
|
+
*/
|
|
292
|
+
declare function uriKey(uri: string, platform?: string): string;
|
|
293
|
+
declare class LspClient implements DiagnosticsClient {
|
|
294
|
+
private readonly connection;
|
|
295
|
+
private readonly pidValue;
|
|
296
|
+
private readonly spec;
|
|
297
|
+
/** 归一键 → 打开状态。按插入顺序逐出 */
|
|
298
|
+
private readonly openDocs;
|
|
299
|
+
/** 归一键 → server 最近一次推来的诊断 */
|
|
300
|
+
private readonly latest;
|
|
301
|
+
/** 最近一次「有动静」的时刻 —— 我们发出去的和 server 推回来的都算 */
|
|
302
|
+
private lastActivityAt;
|
|
303
|
+
/** 这一批里已经答过的文件 */
|
|
304
|
+
private answered;
|
|
305
|
+
private dead;
|
|
306
|
+
private disposing;
|
|
307
|
+
private stderr;
|
|
308
|
+
private constructor();
|
|
309
|
+
get pid(): number;
|
|
310
|
+
/** 还能用吗。池按这个决定要不要重启(验收 9) */
|
|
311
|
+
get alive(): boolean;
|
|
312
|
+
/**
|
|
313
|
+
* 起进程 + `initialize` + `initialized`。
|
|
314
|
+
*
|
|
315
|
+
* 起不来 / 初始化超时都**返回 `null` 而不抛** —— 调用方据此把这个语言标记
|
|
316
|
+
* 不可用,整轮照常出结果(验收 8)。抛出去的话一个慢 server 就能让整次工具
|
|
317
|
+
* 调用失败,而那正是这条验收要避免的。
|
|
318
|
+
*/
|
|
319
|
+
static start(options: StartClientOptions): Promise<LspClient | null>;
|
|
320
|
+
/** 订阅推送 + 兜住 server 反过来发的那几种请求 */
|
|
321
|
+
private wire;
|
|
322
|
+
private appendStderr;
|
|
323
|
+
private markDead;
|
|
324
|
+
private initialize;
|
|
325
|
+
/**
|
|
326
|
+
* 打开 / 更新这批文件,等到静默,交出诊断。
|
|
327
|
+
*
|
|
328
|
+
* @throws {LspServerDownError} 进程中途没了(验收 9 的输入)
|
|
329
|
+
*/
|
|
330
|
+
diagnose(documents: DocumentInput[], wait: WaitOptions): Promise<DiagnoseBatch>;
|
|
331
|
+
/** 没打开过就 `didOpen`,打开过就 `didChange`(全量) */
|
|
332
|
+
private sync;
|
|
333
|
+
/** 超过上限就把最早打开、且不在本批里的文档关掉 */
|
|
334
|
+
private evictOverflow;
|
|
335
|
+
/**
|
|
336
|
+
* 关掉这个 server。
|
|
337
|
+
*
|
|
338
|
+
* 先按协议 `shutdown` + `exit`,给它清临时文件的机会;超预算就直接杀树。
|
|
339
|
+
* **杀树那一步不能省** —— `typescript-language-server` 自己还 fork 了一个
|
|
340
|
+
* `tsserver.js`,只杀父进程会留下孤儿(验收 7)。
|
|
341
|
+
*
|
|
342
|
+
* ## ⚠️ 三步的**顺序**是判据:拍快照 → 优雅关 → 清扫快照
|
|
343
|
+
*
|
|
344
|
+
* 这里原来只有两步(优雅关 → `killTrackedProcess`),而那是**漏的**:
|
|
345
|
+
* `killTrackedProcess` 底下是 `killProcessTree`,靠的是**现场遍历**
|
|
346
|
+
* (`pgrep -P` / `taskkill /t`)。等我们优雅关完,直接子进程已经退了 ——
|
|
347
|
+
* 树的根没了,孙进程的 PPID 指向一个死 pid,遍历再也找不到它们。
|
|
348
|
+
* 于是 `tsserver` 那一层在 POSIX 上被 init 收养、在 Windows 上直接常驻到关机。
|
|
349
|
+
*
|
|
350
|
+
* 所以快照必须在**动手之前**拍。这不是新发明:`plugin-mcp` 的
|
|
351
|
+
* `closeTransportAndReap` 就是这三步,而它有一条会红的用例守着;
|
|
352
|
+
* 我们这边验收 #7 一直挂着「双平台手验」没人做,于是漏了一年半。
|
|
353
|
+
* 2026-08-12 补的 [orphan-reap.test.ts](../__tests__/orphan-reap.test.ts)
|
|
354
|
+
* 把这条钉死了 —— 把下面的快照挪到 `killTrackedProcess` 之后,它当场红。
|
|
355
|
+
*/
|
|
356
|
+
dispose(): Promise<void>;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* language server 进程池 —— 懒启动 / 空闲回收 / 崩溃恢复(方案 38,验收 5/6/8/9)。
|
|
361
|
+
*
|
|
362
|
+
* | 策略 | 值 |
|
|
363
|
+
* | ---------- | --------------------------------------------- |
|
|
364
|
+
* | 启动时机 | 懒启动:第一次 `lsp_diagnostics` 才起(验收 5)|
|
|
365
|
+
* | 空闲回收 | 10 分钟没用就关(验收 6) |
|
|
366
|
+
* | 并发 | 每种语言一个实例,按项目根隔离 |
|
|
367
|
+
* | 初始化超时 | 每种 server 自己给(`ServerSpec.initTimeoutMs`)|
|
|
368
|
+
*
|
|
369
|
+
* ## 「不可用」是正常结果,不是异常
|
|
370
|
+
*
|
|
371
|
+
* 这个池的每一条失败路径 —— 没装、起不来、初始化超时、崩了 —— 交出来的都是
|
|
372
|
+
* `{ ok: false, reason }` 而不是 throw。原因是验收 4 和 8 的措辞一模一样:
|
|
373
|
+
* **其它语言不受影响 / 整轮不失败**。一个 server 起不来只该让它那门语言的文件
|
|
374
|
+
* 被跳过,不该让整次工具调用红掉。
|
|
375
|
+
*/
|
|
376
|
+
|
|
377
|
+
/** 空闲多久回收。10 分钟是方案定的 —— 大项目的 tsserver 能吃几个 G */
|
|
378
|
+
declare const DEFAULT_IDLE_MS: number;
|
|
379
|
+
/** 多久扫一次空闲。1 分钟:回收本身不急,扫太勤只是白烧 CPU */
|
|
380
|
+
declare const DEFAULT_SWEEP_MS: number;
|
|
381
|
+
type Acquired = {
|
|
382
|
+
ok: true;
|
|
383
|
+
client: DiagnosticsClient;
|
|
384
|
+
} | {
|
|
385
|
+
ok: false;
|
|
386
|
+
reason: string;
|
|
387
|
+
install?: string;
|
|
388
|
+
};
|
|
389
|
+
/**
|
|
390
|
+
* 编排层(`diagnose.ts`)真正用到的那部分池能力。
|
|
391
|
+
*
|
|
392
|
+
* 用 `Pick` 而不是另写一个 interface:这样它**永远跟着类走** ——
|
|
393
|
+
* `acquire` 的签名改了,这里自动跟着改,不会出现「接口和实现各说一套」。
|
|
394
|
+
*/
|
|
395
|
+
type ServerPool = Pick<LspServerPool, 'acquire'>;
|
|
396
|
+
interface PoolOptions {
|
|
397
|
+
idleMs?: number;
|
|
398
|
+
sweepMs?: number;
|
|
399
|
+
/** 时钟。**只给测试注入** —— 验收 6 要能在不真等 10 分钟的前提下验 */
|
|
400
|
+
now?: () => number;
|
|
401
|
+
/** 探测。只给测试注入 */
|
|
402
|
+
resolve?: (spec: ServerSpec, projectRoot: string) => ServerResolution;
|
|
403
|
+
/** 起 client。只给测试注入(真起一个 tsserver 太慢,也不该在单测里做) */
|
|
404
|
+
start?: (options: StartClientOptions) => Promise<DiagnosticsClient | null>;
|
|
405
|
+
}
|
|
406
|
+
declare class LspServerPool {
|
|
407
|
+
private readonly entries;
|
|
408
|
+
private readonly failures;
|
|
409
|
+
/** 同一个 key 上并发的 acquire 共用一次启动,不然会起出两个 tsserver */
|
|
410
|
+
private readonly starting;
|
|
411
|
+
private timer;
|
|
412
|
+
private sweeping;
|
|
413
|
+
private readonly idleMs;
|
|
414
|
+
private readonly sweepMs;
|
|
415
|
+
private readonly now;
|
|
416
|
+
private readonly resolve;
|
|
417
|
+
private readonly start;
|
|
418
|
+
constructor(options?: PoolOptions);
|
|
419
|
+
/** 活着的实例数。验收 5(懒启动)就是断言它一开始是 0 */
|
|
420
|
+
get size(): number;
|
|
421
|
+
/** 拿一个可用的 client;拿不到就说清为什么 */
|
|
422
|
+
acquire(spec: ServerSpec, projectRoot: string): Promise<Acquired>;
|
|
423
|
+
private startFor;
|
|
424
|
+
private noteFailure;
|
|
425
|
+
/**
|
|
426
|
+
* 回收空闲实例。定时器调它,测试也直接调它。
|
|
427
|
+
*
|
|
428
|
+
* 重入保护不是洁癖:`dispose()` 里有一次最多 2s 的优雅退出,而扫描间隔是
|
|
429
|
+
* 1 分钟 —— 真卡住的时候两次扫描会叠在一起,对同一个 client 调两次 dispose。
|
|
430
|
+
*/
|
|
431
|
+
sweep(): Promise<void>;
|
|
432
|
+
/** 全部关掉。宿主 dispose 时调;也是测试的收尾 */
|
|
433
|
+
disposeAll(): Promise<void>;
|
|
434
|
+
private ensureSweeper;
|
|
435
|
+
private stopSweeper;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 一次 `lsp_diagnostics` 的编排:路径校验 → 按语言分组 → 取 server → 收诊断。
|
|
440
|
+
*
|
|
441
|
+
* 这一层不认识 JSON-RPC,也不认识进程 —— 它只负责把「一堆路径」变成
|
|
442
|
+
* `DiagnoseOutcome`,并且保证**每个输入路径都有下落**:要么在 `checked` 里,
|
|
443
|
+
* 要么在 `skipped` / `unsupported` / `rejected` 里。少一条就等于对模型说
|
|
444
|
+
* 「这个文件没问题」,而事实是我们根本没看。
|
|
445
|
+
*/
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* 一次最多看几个文件。
|
|
449
|
+
*
|
|
450
|
+
* 不是性能上限,是**防呆**:模型偶尔会把「本轮改过的文件」理解成「整个仓库」,
|
|
451
|
+
* 而每个文件都要在 tsserver 里打开一遍。超出的部分如实报出去,不静默截断 ——
|
|
452
|
+
* 静默截断读起来跟「都检查过了」一模一样。
|
|
453
|
+
*/
|
|
454
|
+
declare const MAX_FILES_PER_CALL = 50;
|
|
455
|
+
/** 等待参数。数字的来由见 client.ts 文件头的「静默判据」 */
|
|
456
|
+
declare const DEFAULT_WAIT: WaitOptions;
|
|
457
|
+
interface DiagnoseInput {
|
|
458
|
+
paths: string[];
|
|
459
|
+
workDir: string;
|
|
460
|
+
pool: ServerPool;
|
|
461
|
+
wait?: WaitOptions;
|
|
462
|
+
signal?: {
|
|
463
|
+
readonly aborted: boolean;
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/** 编排入口。任何一门语言出问题都不会让整次调用失败 —— 那是验收 4 和 8 的要求 */
|
|
467
|
+
declare function diagnosePaths(input: DiagnoseInput): Promise<DiagnoseOutcome>;
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 从一个目录逐级往上找文件。
|
|
471
|
+
*
|
|
472
|
+
* 单独一个文件而不是并进 `detect.ts`:`servers.ts` 要用它算 tsserver 的路径,
|
|
473
|
+
* 而 `detect.ts` 要用 `servers.ts` 那张表的类型 —— 两边合在一起就是
|
|
474
|
+
* `servers.ts ↔ detect.ts` 互相 import,`import/no-cycle` 当场红。
|
|
475
|
+
* 把这十几行摘出来,两边都只依赖它,环就没了。
|
|
476
|
+
*/
|
|
477
|
+
interface FindUpOptions {
|
|
478
|
+
/** 存在性判定。**只给测试注入**,生产走 `existsSync` */
|
|
479
|
+
exists?: (path: string) => boolean;
|
|
480
|
+
/**
|
|
481
|
+
* 最多往上走几层。
|
|
482
|
+
*
|
|
483
|
+
* 不是性能考虑,是**兜底**:`dirname('/') === '/'`(Windows 上
|
|
484
|
+
* `dirname('C:\\') === 'C:\\'`)本来就能让循环停下,但网络路径 / 某些挂载点上
|
|
485
|
+
* 这个不动点不成立,没有上限就是一个死循环。
|
|
486
|
+
*/
|
|
487
|
+
maxDepth?: number;
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* 从 `startDir` 开始逐级向上找 `relativePath`,返回**第一个命中的绝对路径**。
|
|
491
|
+
*
|
|
492
|
+
* @param startDir 起点目录(不必存在)
|
|
493
|
+
* @param relativePath 相对每一级目录的路径,例如 `node_modules/typescript/package.json`
|
|
494
|
+
* @returns 命中的绝对路径;一路到根都没有就是 `null`
|
|
495
|
+
*/
|
|
496
|
+
declare function findUp(startDir: string, relativePath: string, options?: FindUpOptions): string | null;
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* 把 `DiagnoseOutcome` 渲染成模型读的那段文字。
|
|
500
|
+
*
|
|
501
|
+
* ## 为什么不直接返回方案 38 原本写的 `[{file, line, ...}]`
|
|
502
|
+
*
|
|
503
|
+
* core 的 tool-executor 对非字符串 `output` 一律 `JSON.stringify`(紧凑模式,
|
|
504
|
+
* 见 tool-executor.ts 里的 `typeof r.output === 'string' ? … : JSON.stringify(…)`)。
|
|
505
|
+
* 那一行 JSON 里每条诊断都要重复一遍 `"file":"…","line":…,"column":…,"severity":…`
|
|
506
|
+
* 七个键名 —— 对 30 条诊断来说是白烧一倍多的 token,而模型从
|
|
507
|
+
* `src/a.ts:12:5 error TS2322: …` 这种形态里读到的信息一模一样(它每天在
|
|
508
|
+
* tsc / eslint 的输出里见的就是这个)。字段本身一个没少,只是换了排布。
|
|
509
|
+
*
|
|
510
|
+
* ## 三件事必须出现在输出里
|
|
511
|
+
*
|
|
512
|
+
* 1. **检查了哪些文件** —— 没有这一行,「没发现问题」和「一个文件都没检查」
|
|
513
|
+
* 在模型眼里长得一样
|
|
514
|
+
* 2. **跳过的语言 + 装法** —— 否则用户永远不知道自己少了半个功能
|
|
515
|
+
* 3. **被截断了多少** —— 见 `MAX_ITEMS`
|
|
516
|
+
*/
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 最多列几条诊断。
|
|
520
|
+
*
|
|
521
|
+
* 一个刚被大改过的文件冒出几百条错误是常态(改错一个 import 就够了),
|
|
522
|
+
* 而那几百条里真正有用的是前几条 —— 剩下的多半是同一个根因的回声。
|
|
523
|
+
*/
|
|
524
|
+
declare const MAX_ITEMS = 100;
|
|
525
|
+
/** 渲染 `DiagnoseOutcome`。**任何一段都可能为空,但整段结果永远不为空** */
|
|
526
|
+
declare function formatOutcome(outcome: DiagnoseOutcome): string;
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* 支持哪些 language server,以及每种的启动方式(方案 38)。
|
|
530
|
+
*
|
|
531
|
+
* **不捆绑任何 server**,一律探测(与方案 24 的 ripgrep 相反)。理由:
|
|
532
|
+
* ripgrep 是一个 6MB 的单文件,language server 是一堆,而且强绑定用户的项目配置 ——
|
|
533
|
+
* 用全局的 tsc 去检查一个 pin 了 5.8 的项目,报出来的错是假的。
|
|
534
|
+
*
|
|
535
|
+
* 第一版只有 TypeScript / JavaScript(PR-1)。Python / Rust / Go 是 PR-3,
|
|
536
|
+
* 加一种语言就是往 `SERVERS` 里加一条,不用动 client / pool / diagnose 任何一行 ——
|
|
537
|
+
* 这张表是唯一需要改的地方,别让语言判定漏到别处去。
|
|
538
|
+
*/
|
|
539
|
+
|
|
540
|
+
/** 全部 server。加语言就往这里加一条 */
|
|
541
|
+
declare const SERVERS: readonly ServerSpec[];
|
|
542
|
+
/** 这个扩展名交给谁、按哪个 `languageId` 打开。都没有就是 `null` */
|
|
543
|
+
declare function serverForExtension(ext: string): {
|
|
544
|
+
spec: ServerSpec;
|
|
545
|
+
languageId: string;
|
|
546
|
+
} | null;
|
|
547
|
+
/** 我们认得的全部扩展名,给工具描述和报错提示用(排序后的,输出才稳定) */
|
|
548
|
+
declare function supportedExtensions(): string[];
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* `@epoch-agent/plugin-lsp` —— 用项目自己的 language server 取诊断(方案 38)。
|
|
552
|
+
*
|
|
553
|
+
* 一个工具:`lsp_diagnostics`。它回答的是「我刚改的这几个文件,编译器怎么说」,
|
|
554
|
+
* 而以前只能靠让模型自己去跑 `tsc`(慢、要猜命令、还依赖项目里恰好配了脚本)。
|
|
555
|
+
*
|
|
556
|
+
* ## 与方案 38 原文的两处偏差
|
|
557
|
+
*
|
|
558
|
+
* 1. **`paths` 是必填的**,不是 `paths?`。方案里「不传就检查本轮改过的文件」
|
|
559
|
+
* (验收 10)要拿到 `CheckpointManager` 里那份本轮改动清单,而插件的
|
|
560
|
+
* `ToolContext` 只有 `{sessionId, workDir, extraRoots, permissionLevel, signal,
|
|
561
|
+
* onOutput}` —— 补这条得改 core 的 checkpoint 接口,那在方案 38 的独占文件表之外。
|
|
562
|
+
* 先做成必填(模型知道自己刚改了哪几个文件),那一条留给后续 PR。
|
|
563
|
+
* 2. **输出是排好版的文本而不是 JSON 数组**,理由见 `format.ts` 文件头。
|
|
564
|
+
*
|
|
565
|
+
* ## 进程生命周期
|
|
566
|
+
*
|
|
567
|
+
* 池是**模块级单例**:工具每次调用都是一个新的 `execute`,而 language server
|
|
568
|
+
* 的意义全在于「活着、缓存着上次的语法树」。挂在调用上等于每次都冷启一个 tsserver,
|
|
569
|
+
* 那比让模型自己跑 tsc 还慢。
|
|
570
|
+
*
|
|
571
|
+
* 退出时不用宿主做任何事(验收 7):进程是 infra 的 `startLongLivedProcess` 起的,
|
|
572
|
+
* 已经在它的进程表里,`cleanupBackgroundProcesses()` 会一并收掉。
|
|
573
|
+
* `disposeLspServers()` 是给「想早点收、且要走 LSP 的 `shutdown` 礼节」的宿主用的。
|
|
574
|
+
*/
|
|
575
|
+
|
|
576
|
+
/** 主动关掉所有 language server。宿主 dispose 时可以调,不调也不会漏进程 */
|
|
577
|
+
declare function disposeLspServers(): Promise<void>;
|
|
578
|
+
declare const lspPlugin: EpochPlugin;
|
|
579
|
+
|
|
580
|
+
export { type Acquired, DEFAULT_IDLE_MS, DEFAULT_SWEEP_MS, DEFAULT_WAIT, type DiagnoseBatch, type DiagnoseInput, type DiagnoseOutcome, type DiagnosticItem, type DiagnosticSeverityName, type DiagnosticsClient, type DocumentInput, type LaunchTier, LspClient, LspServerDownError, LspServerPool, MAX_FILES_PER_CALL, MAX_ITEMS, type PoolOptions, type ResolveServerOptions, SERVERS, type ServerId, type ServerLaunch, type ServerPool, type ServerResolution, type ServerSpec, type SkippedServer, type StartClientOptions, type WaitOptions, diagnosePaths, disposeLspServers, findUp, formatOutcome, lspPlugin, resolveServer, serverForExtension, supportedExtensions, uriKey };
|