@epoch-agent/plugin-lsp
v0.23.0
Published
LSP 诊断插件 — 探测项目自带的 language server,按文件取语义诊断
Readme
@epoch-agent/plugin-lsp
LSP 诊断插件。一个工具:lsp_diagnostics —— 用项目自己的 language server
回答「我刚改的这几个文件,编译器怎么说」。
- ✅ 做:探测 language server(项目
node_modules→ PATH)、起一个常驻连接、didOpen/didChange后收publishDiagnostics、判「收全了没」、排版给模型看 - ❌ 不做:不捆绑任何 server(探不到就如实报「跳过 + 装法」)、不做补全 /
跳转 / 重命名、不做项目级构建、不自己 spawn(进程一律走
infra 的
startLongLivedProcess) - 依赖:protocol + infra +
vscode-jsonrpc/vscode-languageserver-protocol
工具参数
{ "paths": ["src/a.ts", "src/b.tsx"] }| 参数 | 必填 | 说明 |
| ------- | ---- | -------------------------------------------------------- |
| paths | ✅ | 文件路径数组,相对工作目录或绝对路径。一次最多 50 个文件 |
单个路径写成字符串("paths": "src/a.ts")也收 —— 那是很自然的手误,
为它回一条参数错误等于白烧一轮。
当前认得 8 个扩展名:.cjs .cts .js .jsx .mjs .mts .ts .tsx
(唯一真源是 servers.ts 的 supportedExtensions(),
工具描述和报错提示都从那儿取)。不认的扩展名进 unsupported,
工作目录外的路径进 rejected,都不会让这次调用失败。
输出是文本,不是 JSON 数组
2 条诊断(1 error · 1 warning) · 已检查 2 个文件
src/a.ts:12:5 error typescript(2322): 不能将类型 "string" 分配给类型 "number"
src/b.tsx:3:10 warning typescript(6133): "unused" 已声明但从未读取core 的 tool-executor 对非字符串 output 一律 JSON.stringify,那一行 JSON 里
每条诊断都要重复七个键名 —— 对 30 条诊断是白烧一倍多的 token,而模型从
src/a.ts:12:5 error … 里读到的信息一模一样(它每天在 tsc / eslint 的输出里
见的就是这个)。字段一个没少,只是换了排布。
三件事一定在输出里:检查了哪些文件(没这行,「没发现问题」和「一个文件都没 检查」长得一样)、跳过的语言 + 安装命令、被截断了多少(上限 100 条, 按 severity 优先保留 error)。
两处与方案 38 原文的偏差
paths是必填的。 方案里「不传就检查本轮改过的文件」要拿CheckpointManager那份本轮改动清单,而插件的ToolContext只有{sessionId, workDir, extraRoots, permissionLevel, signal, onOutput}—— 补这条得改 core 的 checkpoint 接口。先做成必填(模型知道自己刚改了哪几个文件),那条留给后续 PR (验收记录 8.2)。- 输出是排好版的文本而不是
[{file, line, ...}],理由见上一节。
三级探测,一个 server 都不捆绑
1. 项目本地 node_modules/<pkg> ← TypeScript 那条「版本跟项目走」的硬要求
2. PATH
3. 都没有 → 该语言 skipped + 安装命令,其它语言不受影响和 infra 的 ripgrep 探测反着来:那边捆绑一个 6MB
单文件,这边一个都不捆。language server 是一堆文件,而且强绑定用户的项目配置
—— 拿全局的 tsc 去检查一个 pin 了 5.8 的项目,报出来的错是假的。所以还额外把项目
自己那份 node_modules/typescript/lib/tsserver.js 显式传进
initializationOptions.tsserver.path:server 自己也会找 workspace 版本,
但那是它内部的一条兜底顺序,它升一版换个顺序我们就悄悄换了 TS 版本报错。
故意不碰 node_modules/.bin。 Windows 上那儿躺的是 npm 生成的 .cmd 垫片,
Node 的 spawn 不过 shell 就执行不了;而 pnpm 下它是符号链接,跟到哪儿要看安装
布局。走 node_modules/<pkg>/package.json 的 bin 字段拿真实入口、用
process.execPath 跑它,两个平台同一条代码路径,且必然是项目里那一份。
PATH 命中 .cmd / .bat 时才走 cmd.exe /d /s /c "<整条命令>" + verbatim。
⚠️ 项目本地那一档带
ELECTRON_RUN_AS_NODE=1(launch.env,值来自 infra 的EXEC_PATH_AS_NODE_ENV)。嵌进 Electron 主进程的宿主里process.execPath不是 node 而是宿主自己的可执行文件,少了那一格,起 language server 等于 启动第二个 app 实例。PATH 那两档起的是真可执行文件,不需要。 全仓「谁把 execPath 当 node 使」由根__tests__/exec-path-as-node.test.ts数着。
PATH 扫描保留绝对路径去 spawn,而不是像 ripgrep 那样丢掉路径 spawn 裸名字。 因为这个工具的输入正是「别人的仓库」:
shell: true+ 裸名字在 Windows 上, 目标仓库根目录里放一个同名.cmd就能劫持。我们的扫描列表里没有 cwd。
诊断是推过来的,所以「收全了没」要自己判
LSP 里没有「诊断请求」这种东西 —— typescript-language-server 4.4 也不支持 3.17 的
pull 模式(initialize 的结果里没有 diagnosticProvider)。server 想发就发,
而 tsserver 一个文件会分三次发(语法 / 语义 / 建议,各自 50ms 去抖),
先到的那一份是不全的。
判据是静默:请求的文件都答过了,且最近一次动静距今超过 settleMs(默认 400ms,
fallbackMs 1.5s,硬超时 15s)。
还有一条更阴的:内容没变时 server 干脆不发。 FileDiagnostics.update() 里有
一句「这一类上次是空、这次还是空就不推送」,于是一个干净的文件被诊断第二次时
一条消息都收不到。按「没答复 = 没结果」处理就会把最正常的那种情况报成
「server 没回答」。所以有第二条判据:没答复但有上一次的结果,就用上一次的。
e2e.test.ts 拿真 server 盯着这一条。
进程池:懒启动 / 10 分钟回收 / 崩一次重启一次
| 策略 | 值 |
| ---------- | --------------------------------------------- |
| 启动时机 | 懒启动 —— 第一次 lsp_diagnostics 才起 |
| 空闲回收 | 10 分钟没用就关(大项目的 tsserver 吃几个 G) |
| 并发 | 每种语言一个实例,按项目根隔离 |
| 初始化超时 | 每种 server 自己给(TypeScript 是 30s) |
| 崩溃 | 下次调用重启一次;再崩就本会话内不再试 |
池是模块级单例:工具每次调用都是一次新的 execute,而 language server 的意义
全在于「活着、缓存着上次的语法树」。挂在调用上等于每次冷启一个 tsserver,
那比让模型自己跑 tsc 还慢。
「不可用」的每一条路径 —— 没装、起不来、初始化超时、崩了 —— 交出来的都是
{ ok: false, reason } 而不是 throw。一个 server 起不来只该让它那门语言的文件被
跳过。「没装」不计入失败次数:那不是「试了没成」,计进去的话用户装完 server
还得重启 epoch 才能用上。
退出时宿主什么都不用做:进程在 infra 的进程级兜底表里,最后一个 runtime
dispose() 时一并收掉。disposeLspServers() 是给「想早点收、且要走 LSP shutdown
礼节」的宿主用的。
⚠️ 落在兜底表而不是某个 runtime 名下是刻意的(方案 60 §2.6):这个池按项目根
共享,两个 runtime 开在同一个项目上时它们本来就该共用同一个 tsserver ——
它不是任何一个 runtime 的私产。代价写下来不掩饰:同进程里 A 和 B 并存时,
B 的 dispose() 收不掉这些 server,要等 A 也走。
为什么是 file_read 而不是 command
annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: false }
operation: 'file_read'这个工具对外的语义就是「读几个文件、报问题」。底下确实起了子进程,但那个进程是
我们表里写死的(servers.ts),参数里没有任何一段来自模型。
归到 command 只会让每次诊断都弹一次审批 —— 而 core 的 system prompt 恰恰要求
模型每轮改完都调它。idempotentHint: false 是因为文件内容会变:同一批路径
这一秒和下一秒的诊断可以不一样。
describeTarget 给的是文件清单,不给就退回 JSON.stringify(args),
换个顺序传同样几个文件就不命中审批缓存了。
加一门语言 = 往 SERVERS 里加一条
servers.ts 的 SERVERS 是唯一需要改的地方,client / pool /
diagnose 一行都不用动。别让语言判定漏到别处去。Python / Rust / Go 是方案 38 的
PR-3,刻意砍掉的(不是做到一半放弃)—— 砍的判据和真要做时的两条成本写在
验收记录 8.1。
文件
| 文件 | 内容 |
| ------------- | ----------------------------------------------------------- |
| index.ts | lspPlugin + 模块级池 + disposeLspServers() |
| servers.ts | 支持哪些语言、怎么起、tsserver.path 怎么找 |
| detect.ts | 三级探测(不 spawn,纯文件系统问题) |
| client.ts | 一个 server 连接:五条消息 + 静默判据 + 崩溃回调 + 优雅退出 |
| pool.ts | 懒启动 / 空闲回收 / 并发去重 / 失败计数 |
| diagnose.ts | 编排:路径归一 → 边界校验 → 按 server 分组 → 收结果 |
| format.ts | 渲染成模型读的那段文字(截断 + 按 severity 优先) |
| find-up.ts | 逐级往上找一个相对路径 |
| types.ts | 本包的类型 |
错误码占 8xxx 段:8001 参数不对、8002 我们没想到的形态。别复用别人的段 ——
用户报错时那四位数是唯一能一眼定位到哪个包的东西。
开发
pnpm --filter @epoch-agent/plugin-lsp test七个用例文件。两处值得先看:
- fixtures/fake-server.mjs 是一个手写分帧
的假 LSP server(故意不用
vscode-jsonrpc,那样才是一份独立的参照实现)。process.argv[2]选行为:normal/three-kinds/silent/once/hang-init/crash-on-open - e2e.test.ts 用真的 typescript-language-server, 被检查的文件写在临时目录里 —— 故意报错的 fixture 不该进本仓库的 tsc / lint 范围
vitest.config.ts 的 testTimeout 是 60s:起一个真 tsserver 加初始化,
默认的 5000ms 不够。
