npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 原文的偏差

  1. paths 是必填的。 方案里「不传就检查本轮改过的文件」要拿 CheckpointManager 那份本轮改动清单,而插件的 ToolContext 只有 {sessionId, workDir, extraRoots, permissionLevel, signal, onOutput} —— 补这条得改 core 的 checkpoint 接口。先做成必填(模型知道自己刚改了哪几个文件),那条留给后续 PR (验收记录 8.2)。
  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 不够。