@banbolee/dsh-lsp-diagnostics 0.1.1 → 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
@@ -1,10 +1,13 @@
1
1
  # @banbolee/dsh-lsp-diagnostics
2
2
 
3
+ **English** | [中文](./README.zh.md)
4
+
3
5
  Host-plane Cordis bundle plugin for DeepSeek Harness: after an official
4
6
  `write`/`edit`/`str_replace_editor` mutation lands inside the session
5
- workspace, the tool result still succeeds and the next model inference carries
6
- a single persistent LSP diagnostics plugin notice. It also registers the
7
- model-callable `lsp_diagnostics(file_path)` tool for explicit diagnosis.
7
+ workspace, or inside a marker-rooted sibling worktree/project, the tool result
8
+ still succeeds and the next model inference carries a single persistent LSP
9
+ diagnostics plugin notice. It also registers the model-callable
10
+ `lsp_diagnostics(file_path)` tool for explicit diagnosis.
8
11
 
9
12
  The plugin is a named namespace function plugin: it exports `name`, `inject`,
10
13
  `Config`, and `apply`, and has no default export. The real Loader reads the
@@ -16,7 +19,13 @@ monkey-patches official objects.
16
19
 
17
20
  ## Usage
18
21
 
19
- Install the bundle into any DSH profile from the repository root. The
22
+ **Install from npm (recommended)** — no clone needed:
23
+
24
+ ```sh
25
+ dsh plugin --profile <name> add @banbolee/dsh-lsp-diagnostics
26
+ ```
27
+
28
+ For local development, install the bundle into any DSH profile from the repository root. The
20
29
  workspace-root flag `-w` is required: without it pnpm cannot resolve the local
21
30
  package inside the profile's generated workspace.
22
31
 
@@ -59,20 +68,22 @@ deviation from the closed extension route fail loud at load time.
59
68
  | `servers` | TypeScript + Go | Closed provider catalog; TypeScript and Go are enabled by default, while `clangd`, `rust`, and `python` are opt-in. |
60
69
 
61
70
  The extension route is closed: `.ts` → typescript/typescript, `.tsx` →
62
- typescript/typescriptreact, `.go` → go/go, `.c` → clangd/c,
63
- `.cc`/`.cpp`/`.cxx` → clangd/cpp, `.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp,
64
- `.rs` → rust/rust, and `.py`/`.pyi` → python/python; any other extension key,
65
- missing canonical provider entry, cross-provider collision, or rewritten
66
- language id fails loud at load time, while other extensions at runtime are
67
- silently ignored.
71
+ typescript/typescriptreact, optional explicit `.js` → typescript/javascript,
72
+ `.go` → go/go, `.c` → clangd/c, `.cc`/`.cpp`/`.cxx` → clangd/cpp,
73
+ `.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp, `.rs` → rust/rust, and
74
+ `.py`/`.pyi` → python/python; any other extension key, missing canonical
75
+ provider entry, cross-provider collision, or rewritten language id fails loud at
76
+ load time, while other extensions at runtime are silently ignored.
68
77
 
69
78
  The `servers` block is a provider-level partial overlay: omitting `servers` or
70
79
  supplying `servers: {}` keeps exactly the TypeScript and Go defaults, while
71
80
  supplying a known optional `clangd`, `rust`, or `python` key activates that
72
81
  provider. For example, `servers: { go: { command: '/path/trae-gopls' } }`
73
82
  changes only Go without requiring a TypeScript block. The legacy
74
- `extensionToLanguage` field is accepted only when it exactly equals that
75
- provider's canonical mapping; it never defines new routes.
83
+ `extensionToLanguage` field is accepted only when it contains that provider's
84
+ canonical mapping and only supported provider-owned extras. `.js` is accepted
85
+ for compatibility with explicit TypeScript configs, but it is not enabled by
86
+ default.
76
87
 
77
88
  Default commands are `typescript-language-server --stdio`, `gopls`, `clangd`,
78
89
  `rust-analyzer`, and `pyright-langserver --stdio`. clangd, rust-analyzer, and
@@ -81,13 +92,16 @@ gopls receive no default arguments. Per-server `env`, `configuration`, and
81
92
 
82
93
  ## Behavior
83
94
 
84
- Only a non-empty `session.header.cwd` that canonicalizes to a directory and
85
- contains the target is eligible; missing or empty cwd, non-directory
86
- canonicalization, and outside-workspace targets are ineligible and silently
87
- ignored — never a `workspace unavailable` or `outside workspace` notice.
88
- Workspace, cwd, and URI are always derived through the public `ctx.fs` API
89
- (`resolve`/`stat`/`contains`/`targetKey`/`processPath`/`fileUrl`), never from
90
- `displayPath` or the host `process.cwd()`.
95
+ A non-empty `session.header.cwd` that canonicalizes to a directory is required.
96
+ Automatic feedback first keeps the old behavior for targets contained by that
97
+ workspace; targets outside it are eligible only when a target-local `.git` file
98
+ or directory identifies their own git worktree root. Missing or empty cwd,
99
+ non-directory canonicalization, and outside targets without a marker-rooted
100
+ git worktree are silently ignored — never a `workspace unavailable` or
101
+ `outside workspace` notice. Workspace, cwd, and URI are always
102
+ derived through the public `ctx.fs` API (`resolve`/`stat`/`contains`/`targetKey`/
103
+ `processPath`/`fileUrl`), never from raw `displayPath` or the host
104
+ `process.cwd()`.
91
105
 
92
106
  Fail-open is plugin-owned post-processing failure only: after the downstream
93
107
  `await next()` decision succeeded, a diagnostics error, missing or crashed
@@ -187,13 +201,14 @@ natural-close wait → conditional `handle.terminate()` (only while still alive)
187
201
  existing file that `ctx.fs` permits the session to read. It accepts a
188
202
  workspace-relative or absolute path and uses every configured provider and the
189
203
  closed extension route documented above. It has the same path authority as the
190
- official `read` tool: the session cwd is only the LSP project root, so an
191
- explicit direct call may diagnose a readable file outside that root. It never
192
- creates, edits, or deletes a file. Invalid requests fail explicitly with
193
- `file_path must be a non-empty string`, `session workspace cwd`, `session
194
- workspace is not an existing directory`, `target does not exist`, `target is
195
- not a regular file`, `no configured diagnostics provider`, or `target changed
196
- during diagnosis`.
204
+ official `read` tool: for files contained by `session.header.cwd`, that cwd
205
+ remains the LSP project root; for a readable file outside the session cwd, the
206
+ tool prefers the target's marker-rooted project/worktree root and then falls
207
+ back to the session cwd. It never creates, edits, or deletes a file. Invalid
208
+ requests fail explicitly with `file_path must be a non-empty string`, `session
209
+ workspace cwd`, `session workspace is not an existing directory`, `target does
210
+ not exist`, `target is not a regular file`, `no configured diagnostics
211
+ provider`, or `target changed during diagnosis`.
197
212
 
198
213
  The direct tool has exactly three canonical outcomes: `diagnostics` with the
199
214
  normalized list sorted by the same comparator as automatic rendering and sliced
@@ -231,13 +246,13 @@ unavailable`.
231
246
  - shell redirection, `sed -i`, scripts, formatters, generators, and external
232
247
  editors that bypass `ctx.fs`/`fs/observed` are out of scope for automatic
233
248
  feedback; the model may call `lsp_diagnostics(file_path)` afterward. There is no
234
- filesystem watcher and no project-root probing — the session cwd is the
235
- workspace root.
249
+ filesystem watcher; project-root probing is marker-based and only runs for
250
+ supported source files.
236
251
  - For automatic feedback, agentless tool execution, missing/empty or
237
- non-directory cwd, and outside-workspace targets are silently out of scope
238
- and never produce a notice. Direct calls use official `read`-equivalent path
239
- authority instead: a readable file outside the session cwd is eligible, while
240
- that cwd remains the LSP project root.
252
+ non-directory cwd, and outside targets without a marker-rooted git worktree are
253
+ silently out of scope and never produce a notice. Direct calls use official
254
+ `read`-equivalent path authority instead: a readable file outside the session
255
+ cwd is eligible, using its marker-rooted project/worktree root when available.
241
256
  - The plugin does not install or download `typescript-language-server`,
242
257
  `gopls`, `clangd`, `rust-analyzer`, or `pyright-langserver`; install requested
243
258
  executables yourself and configure their paths. A missing server fails open
@@ -245,9 +260,9 @@ unavailable`.
245
260
  - Portable deterministic tests use the repository fixture server and never use
246
261
  a real server or the network. The separate explicit real-server lane is
247
262
  opt-in, local-filesystem/process-only, and never installs or downloads tools.
248
- - Implementation and tests use the `0.1.5-rc.1` dependency family; the peers
263
+ - Implementation and tests use the `0.1.7-rc.2` dependency family; the peers
249
264
  `@deepseek-ai/dsh-fs`, `@deepseek-ai/dsh-llm`, `@deepseek-ai/dsh-subprocess`,
250
- and `@deepseek-ai/dsh-tools` are `^0.1.5-rc.1`.
265
+ and `@deepseek-ai/dsh-tools` are `^0.1.7-rc.2`.
251
266
 
252
267
  ## Verification
253
268
 
package/README.zh.md ADDED
@@ -0,0 +1,144 @@
1
+ # @banbolee/dsh-lsp-diagnostics
2
+
3
+ [English](./README.md) | **中文**
4
+
5
+ DeepSeek Harness 的 host 面 Cordis bundle 插件:当一次官方 `write`/`edit`/`str_replace_editor` 变更落在 session workspace 内,或落在带 marker 根的兄弟 worktree/项目内时,工具结果仍然成功,而下一次模型推理会携带一条持久的 LSP diagnostics 插件 notice。它还注册了模型可调用的 `lsp_diagnostics(file_path)` 工具,用于显式诊断。
6
+
7
+ 本插件是具名 namespace 函数插件:导出 `name`、`inject`、`Config` 和 `apply`,没有 default export。真实 Loader 读取模块 namespace(`exports.default ?? exports`),Cordis 从插件对象读取 `inject`/`Config`,因此每个公开元数据字段都能在安装后存活。插件从不修改 `deepseek-harness`,从不扩展 `ctx.lsp`,从不 import `@deepseek-ai/*/src/*`,也从不 monkey-patch 官方对象。
8
+
9
+ ## 用法
10
+
11
+ **从 npm 安装(推荐)** —— 无需 clone:
12
+
13
+ ```sh
14
+ dsh plugin --profile <name> add @banbolee/dsh-lsp-diagnostics
15
+ ```
16
+
17
+ 本地开发时,可在仓库根目录把本 bundle 安装到任意 DSH profile。必须带上 workspace 根标志 `-w`:否则 pnpm 无法在 profile 生成的 workspace 内解析本地包。
18
+
19
+ ```bash
20
+ dsh plugin --profile <profile> add -w ./plugins/dsh-lsp-diagnostics
21
+ ```
22
+
23
+ 若要用一条命令安装到隔离的 profile:
24
+
25
+ ```bash
26
+ DSH_HOME="$(mktemp -d)" scripts/sync-lsp-diagnostics-to-profile.sh <profile>
27
+ ```
28
+
29
+ sync 辅助脚本要求设置 `DSH_HOME`,这样测试与手工 QA 始终作用于隔离的 profile 状态,而绝不是用户的默认 Harness home。
30
+
31
+ 插件注册一个真正的三参数 `tools/post-execute` waterfall 监听器 `(exec, _result, next)`:它恰好调用一次 `await next()`,且在任何插件 catch 之外,也从不消费 `_result` 占位符。
32
+
33
+ ## 配置
34
+
35
+ 所有开关都经由插件的 `Config` schema 与严格校验器。未知 key、不是合法 JSON 可表示的值、非法 timer/cap,或对封闭扩展路由的任何偏离,都会在加载时 fail loud。
36
+
37
+ | 配置项 | 默认值 | 含义 |
38
+ | --- | --- | --- |
39
+ | `enabled` | `true` | `enabled=false` 时插件不创建任何 collector、runtime、coordinator 或 tool,不注册任何 tools/listeners/effects,也不 spawn 任何进程。 |
40
+ | `timeoutMs` | `5000` | 单个 post-execute 聚合的不可延长 deadline。 |
41
+ | `settleMs` | `200` | 静默窗口;必须 `< timeoutMs`。 |
42
+ | `shutdownTimeoutMs` | `1000` | 优雅 `shutdown`/`exit` 的预算。 |
43
+ | `killGraceMs` | `500` | 进程树的 TERM→KILL 宽限期。 |
44
+ | `maxDocumentBytes` | `2097152` | 经 `didOpen` 发送的文档的硬性 UTF-8 字节上限。 |
45
+ | `maxMessageBytes` | `4194304` | LSP 消息体上限。 |
46
+ | `maxStderrBytes` | `16384` | 有界的 stderr 尾部上限。 |
47
+ | `maxDiagnostics` | `50` | 自动 diagnostic 行的全局上限,也是 canonical direct-tool diagnostics 数组的每次调用上限。 |
48
+ | `maxResultChars` | `8000` | 最终聚合文本的 Unicode 码点上限。 |
49
+ | `reportClean` | `true` | 显式空结果是否渲染为 `Status: clean`。 |
50
+ | `servers` | TypeScript + Go | 封闭的 provider catalog;TypeScript 和 Go 默认启用,而 `clangd`、`rust` 和 `python` 需显式开启。 |
51
+
52
+ 扩展路由是封闭的:`.ts` → typescript/typescript,`.tsx` → typescript/typescriptreact,可选的显式 `.js` → typescript/javascript,`.go` → go/go,`.c` → clangd/c,`.cc`/`.cpp`/`.cxx` → clangd/cpp,`.h`/`.hh`/`.hpp`/`.hxx` → clangd/cpp,`.rs` → rust/rust,`.py`/`.pyi` → python/python;任何其他扩展名 key、缺失的 canonical provider 条目、跨 provider 冲突或被改写的 language id 都会在加载时 fail loud,而运行时的其他扩展名会被静默忽略。
53
+
54
+ `servers` 块是 provider 级别的部分 overlay:省略 `servers` 或提供 `servers: {}` 会精确保留 TypeScript 和 Go 默认值,而提供已知的可选 key `clangd`、`rust` 或 `python` 会激活对应 provider。例如 `servers: { go: { command: '/path/trae-gopls' } }` 只改动 Go,无需提供 TypeScript 块。legacy 的 `extensionToLanguage` 字段仅当它包含该 provider 的 canonical 映射且只包含受支持的 provider 自有扩展时才被接受。`.js` 为兼容显式 TypeScript 配置而被接受,但默认不启用。
55
+
56
+ 默认命令为 `typescript-language-server --stdio`、`gopls`、`clangd`、`rust-analyzer` 和 `pyright-langserver --stdio`。clangd、rust-analyzer 和 gopls 不接受任何默认参数。per-server 的 `env`、`configuration` 和 `initializationOptions` 可以被覆盖,且必须是 JSON 可表示的。
57
+
58
+ ## 行为
59
+
60
+ 要求存在非空、且能 canonicalize 为目录的 `session.header.cwd`。自动反馈首先对该 workspace 包含的 target 保持旧行为;workspace 之外的 target 只有在 target 本地的 `.git` 文件或目录能标识其自身 git worktree 根时才符合条件。cwd 缺失或为空、canonicalize 结果不是目录,以及没有 marker 根 git worktree 的外部 target,都会被静默忽略 —— 永远不会出现 `workspace unavailable` 或 `outside workspace` notice。workspace、cwd 和 URI 始终通过公开的 `ctx.fs` API(`resolve`/`stat`/`contains`/`targetKey`/`processPath`/`fileUrl`)推导,绝不来自原始的 `displayPath` 或宿主的 `process.cwd()`。
61
+
62
+ Fail-open 仅限插件自身的后处理失败:在下游 `await next()` 决策已经成功之后,diagnostics 错误、服务端缺失或崩溃、协议失败、读取错误或超时都不会改变该决策或结果,也永远不会返回 `kind: 'block'`。调用方 abort 由 ToolRuntime 和下游监听器决定;本插件从不吞掉、改写调用方 abort,也不承诺把它变成成功。
63
+
64
+ 新鲜度使用写入后的 `FsVersion`,加上一个独立的 per-target 单调 generation 计数器,其取值在一个插件生命周期内永不重用。`retireIfCurrent` 只移除匹配的 active marker(generation + version),从不触碰计数器。当 generation 计数器耗尽时,插件 fail safe,所有更旧的候选都保持 stale。
65
+
66
+ 符合条件的 target 由唯一的共享 comparator `(renderPath, String(targetKey), canonicalUri)` 按 Unicode 码点排序,同一顺序同时驱动诊断调度和渲染出的 section —— 绝不使用原始 `displayPath` 元组、`localeCompare` 或默认的 UTF-16 排序。
67
+
68
+ 一次 exec 最多产生一条聚合 notice,其中恰好有一个标题 `[LSP diagnostics after write]`,每个保留的文件一个 section,section 之间恰好一个空行,且没有开头或结尾换行。在全局 `maxDiagnostics` 数量上限之后,保留行为零的 diagnostics 文件会被整体省略,绝不留下空 section;clean 和 unavailable 的 section 不消耗该上限。仅当至少保留一行 diagnostic 时,才追加恰好一条全局 advisory,并以一个空行分隔。`maxResultChars` 上限只在完整的 canonical 聚合构建完成之后才应用:固定 marker `…(truncated)` 替换被截断的后缀,输出永不超过该上限。
69
+
70
+ 文档在 `stat.size` 预检之后用有界的 `ctx.fs.readBytes(target, signal, maxDocumentBytes)` 读取,并采用严格 UTF-8 解码;绝不使用无界的 `readText`。已知大小超过上限,或未知大小的读取抛出 `code === 'FS_TOO_LARGE'` 的 `FsError`,都映射为 `document too large`;任何其他读取错误映射为 `diagnostics unavailable`。公开接缝保证成功的 `readBytes` 是完整的,且 `bytes.length <= maxDocumentBytes`;插件从不依赖或返回超过上限的字节。
71
+
72
+ `publishDiagnostics` 的关联顺序是精确 URI 优先,其次按文档 version;只有被消费的字段 `range`、`severity`、`code`、`source` 和 `message` 会被校验,而标准可选字段 `tags`、`relatedInformation`、`codeDescription`、`data` 以及未知扩展字段会被安全忽略,绝不进入规范化 schema 或 renderer。位置是基于 0 的 UTF-16,渲染为基于 1 的起止坐标。
73
+
74
+ coordinator 同时持有 active augment-operation registry 和 `retiredIo` late-final-stat registry。Unload 按精确顺序静默:停止 direct-tool 准入 → 停止 coordinator 准入(同时关闭 runtime 准入)→ offTool → offPost → offObserved → abort coordinator 操作 → abort direct-tool 操作 → await 所有 active augment promise → await 所有 active direct-tool promise → await 所有 `retiredIo` → `runtime.dispose()`。清理在单个失败之后仍继续并聚合这些失败;因此没有任何直接调用能与 runtime disposal 竞争。每个操作最外层的 `finally` 会清除 deadline timer 并移除 caller/cleanup relay 监听器,不留任何残留。
75
+
76
+ `stopAdmission()` 只关闭闸门和 runtime 自身的准入;它从不 abort 操作。Active 操作在更靠后的 abort 步骤被 abort,即在两个 listener disposer 都运行之后,与文档记载的顺序完全一致。已经进入但仍阻塞在下游 `next()` 中的监听器没有插件自有的取消接缝,因此清理会在 pending-invocation registry 中跟踪每一个这样的调用并等待其结算(它在下游结果或错误返回之前取走并 retire 该 exec 的候选,包括 `next()` 同步抛出的情况)。deadline 使用注入的单调时钟,绝不使用 `Date.now()`,因此墙钟回拨无法延长硬 deadline。
77
+
78
+ Session teardown 还负责协议写入的静默:每个在途的 server-request handler 和序列化的写入尾部都在 cleanup 返回前结算,关闭期间不会启动新的 server-request handler —— 因此 cleanup 完成后不可能再写入任何排队的帧(responses、`shutdown`、`exit`)。与 `didOpen` 写入竞争的匹配 `publishDiagnostics` 会被缓冲,直到 open 写入成功;只有成功写入的 open generation 才接受通知,而失败的 `didOpen` 写入仍允许被允许的 fresh-instance 重试。共享 runtime 采用通用的 correctness-over-performance 策略:它仍会把一个 provider/workspace session 跨不同的 canonical URI 池化,但在该 session 已打开过的 URI 被再次诊断之前,它会驱逐并异步 retire 该 session,然后在一个全新进程中以 version 1 打开该 URI;这避免了 provider VFS 缓存返回同一 URI 的陈旧 diagnostics,而 retired teardown 在 unload 时仍被跟踪并排空。
79
+
80
+ `timeoutMs` 是整个 post-execute 的单个不可延长 deadline;最后一轮 freshness 是一次性的 final stat/deadline/generation 闸门。direct tool 同样从其注入的单调时钟捕获一个绝对 deadline,并且当 `now() >= deadlineAt` 时每个闸门都转为 timeout,即使事件循环饥饿尚未运行已排定的 timer 回调。deadline/caller/cleanup 丢失时绝不等待迟到的 final stat:未结算的记录在决策返回前被 retire 进 `retiredIo`。Session teardown 是唯一顺序:`shutdown` 请求 → `exit` 通知/自然关闭等待 → 有条件的 `handle.terminate()`(仅当仍存活时)→ await `handle.done` 与 `waitForExit()` → `processLifetimeController.abort()`;`terminate()` 是唯一的硬停止。
81
+
82
+ ## 模型体验
83
+
84
+ `lsp_diagnostics(file_path)` 是模型可调用的只读工具,作用于一个已存在、且 `ctx.fs` 允许该 session 读取的文件。它接受 workspace 相对路径或绝对路径,并使用所有已配置的 provider 以及上文记载的封闭扩展路由。它具有与官方 `read` 工具相同的路径权限:对于被 `session.header.cwd` 包含的文件,该 cwd 仍是 LSP 项目根;对于 session cwd 之外的可读文件,工具优先使用 target 的 marker 根项目/worktree 根,然后回退到 session cwd。它从不创建、编辑或删除文件。非法请求会显式失败,并给出 `file_path must be a non-empty string`、`session workspace cwd`、`session workspace is not an existing directory`、`target does not exist`、`target is not a regular file`、`no configured diagnostics provider` 或 `target changed during diagnosis`。
85
+
86
+ direct tool 恰好有三种 canonical 结果:`diagnostics`,其规范化列表按与自动渲染相同的 comparator 排序,并在 ToolRuntime/PTC 收到之前被切片到 `maxDiagnostics`,外加一个始终存在的非负整数 `omitted_diagnostics`;`no_diagnostics`,渲染为 `No diagnostics reported for this file snapshot.`;以及 `unavailable`,带一个封闭 reason。renderer 会保留来自该 canonical 计数的省略 marker。当你需要显式诊断一个已存在的文件时调用它,尤其是在 shell 命令、formatter 或 generator 绕过了自动 `fs/observed` 反馈之后;当已有新鲜的自动反馈时,请避免冗余调用。在受支持的 write/edit 变更之后,自动的写入后反馈仍是默认行为,而 direct tool 是刻意调用的,并且会报告路径/workspace 错误,而不是把它们静默当作不符合条件。
87
+
88
+ 从模型的角度看,每个变更工具(`write`、`edit`、`str_replace_editor` 的 create/str_replace/insert)都原样返回其结果;插件为下一次推理向 `decision.additionalContexts` 追加一条有界聚合 notice。notice 包含:
89
+
90
+ - 每个保留的文件一个 `File: <renderPath>` section —— diagnostics 为 `- <severity> <startLine>:<startCharacter>-<endLine>:<endCharacter> source=... code=... message` 行,或 `Status: clean`,或 `Status: diagnostics unavailable (<reason>)` —— 按共享的码点顺序排列;
91
+ - 当至少保留一行 diagnostic 时,恰好一条全局 advisory;
92
+ - 对不支持的扩展名、不符合条件的 workspace、`enabled=false` 或没有 workspace 的 session,则不产生任何内容。
93
+
94
+ Unavailable reason 是封闭的:`server not found`、`server crashed`、`timeout`、`malformed response`、`document too large`、`diagnostics unavailable`。
95
+
96
+ ## 已知限制与待办工作
97
+
98
+ - 绕过 `ctx.fs`/`fs/observed` 的 shell 重定向、`sed -i`、脚本、formatter、generator 以及外部编辑器不在自动反馈的范围内;模型可以随后调用 `lsp_diagnostics(file_path)`。没有 filesystem watcher;项目根探测基于 marker,且只对受支持的源文件运行。
99
+ - 对于自动反馈,无 agent 的工具执行、cwd 缺失/为空或不是目录,以及没有 marker 根 git worktree 的外部 target,都被静默排除在范围之外,永不产生 notice。直接调用则改用与官方 `read` 等价的路径权限:session cwd 之外的可读文件符合条件,并在可用时使用其 marker 根项目/worktree 根。
100
+ - 插件不安装也不下载 `typescript-language-server`、`gopls`、`clangd`、`rust-analyzer` 或 `pyright-langserver`;请自行安装所需可执行文件并配置其路径。服务端缺失时 fail open,给出 `diagnostics unavailable (server not found)`。
101
+ - 可移植的确定性测试使用仓库内的 fixture 服务端,绝不使用真实服务端或网络。独立的显式 real-server lane 需显式开启,只使用本地文件系统/进程,且从不安装或下载工具。
102
+ - 实现与测试使用 `0.1.7-rc.2` 依赖族;peer 依赖 `@deepseek-ai/dsh-fs`、`@deepseek-ai/dsh-llm`、`@deepseek-ai/dsh-subprocess` 和 `@deepseek-ai/dsh-tools` 为 `^0.1.7-rc.2`。
103
+
104
+ ## 验证
105
+
106
+ 确定性的包内测试(fixture 服务端、无网络、无真实 LSP 服务端、隔离的临时 workspace/profile):
107
+
108
+ ```bash
109
+ pnpm exec vitest run plugins/dsh-lsp-diagnostics tests/package-shape.spec.ts
110
+ ```
111
+
112
+ 文档形态测试(必需章节与契约字符串):
113
+
114
+ ```bash
115
+ pnpm exec vitest run tests/docs-shape-lsp-diagnostics.spec.ts
116
+ ```
117
+
118
+ 类型检查与 sync 脚本帮助:
119
+
120
+ ```bash
121
+ pnpm dlx --package=typescript@6.0.3 tsc -p plugins/dsh-lsp-diagnostics/tsconfig.json --noEmit
122
+ bash scripts/sync-lsp-diagnostics-to-profile.sh --help
123
+ ```
124
+
125
+ 显式的 real-server 验证与可移植覆盖是分开的。它要求 `RUN_REAL_LSP_SERVERS=1`、非空的 `REAL_LSP_PROVIDERS` 列表、每个被请求 provider 的显式可执行文件路径,以及一个证据路径:
126
+
127
+ ```bash
128
+ RUN_REAL_LSP_SERVERS=1 \
129
+ REAL_LSP_PROVIDERS=rust \
130
+ REAL_LSP_RUST_COMMAND=/data00/home/lixingxin/.cargo/bin/rust-analyzer \
131
+ REAL_LSP_EVIDENCE_PATH=/tmp/dsh-real-rust.json \
132
+ pnpm exec vitest run tests/composition/lsp-real-servers.spec.ts
133
+ ```
134
+
135
+ operator wrapper 执行同样的检查:
136
+
137
+ ```bash
138
+ node scripts/qa/run-lsp-real-servers.mjs \
139
+ --providers rust \
140
+ --rust-command /data00/home/lixingxin/.cargo/bin/rust-analyzer \
141
+ --evidence /tmp/dsh-real-rust.json
142
+ ```
143
+
144
+ 每个被请求的 provider 都必须完成 bad → diagnostic → repair → clean。该 JSON 文件是机器可读的证据;被请求的可执行文件缺失时会记录 `status: "blocked"` 并以非零码退出,而不是跳过或通过。
package/coordinator.js CHANGED
@@ -15,15 +15,17 @@
15
15
  * @module @banbolee/dsh-lsp-diagnostics/coordinator
16
16
  */
17
17
 
18
+ /// <reference path="./message-source.d.ts" />
18
19
  import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm'
19
20
  import { compareEligibleTargets, renderDiagnostics, sanitizeDisplayPath } from './render.js'
21
+ import { resolveWorkspaceRoot } from './workspace-root.js'
20
22
 
21
23
  /**
22
24
  * Loose structural seams, mirroring the runtime's own public shapes. The
23
25
  * coordinator only consumes the documented surface (`take/isCurrent/
24
26
  * retireIfCurrent`, `diagnose/stopAdmission/dispose`, `resolve/stat/
25
- * contains/fileUrl`); keeping these structural keeps both the real services
26
- * and the deterministic test fakes assignable.
27
+ * contains/fileUrl/processPath`); keeping these structural keeps both the real
28
+ * services and the deterministic test fakes assignable.
27
29
  * @typedef {object} CollectorSeam
28
30
  * @property {(exec: any, target: any, observation: any) => void} observe
29
31
  * @property {(exec: any) => import('./collector.js').MutationCandidate[]} take
@@ -44,6 +46,7 @@ import { compareEligibleTargets, renderDiagnostics, sanitizeDisplayPath } from '
44
46
  * @property {(target: any, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
45
47
  * @property {(parent: any, child: any) => boolean} contains
46
48
  * @property {(target: any) => string} fileUrl
49
+ * @property {(target: any) => string} processPath
47
50
  */
48
51
 
49
52
  /**
@@ -382,10 +385,12 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
382
385
  retireAll()
383
386
  return decision
384
387
  }
385
- // 2. Workspace root from the session header; missing/empty is ineligible.
388
+ // 2. Canonicalize the session workspace once. Files inside it keep the
389
+ // original behavior; files outside it may still resolve to their own
390
+ // project/worktree root.
386
391
  const execRecord = /** @type {{ agent?: { session?: { header?: { cwd?: unknown } } } }} */ (exec)
387
- const workspaceRoot = execRecord?.agent?.session?.header?.cwd
388
- if (typeof workspaceRoot !== 'string' || workspaceRoot.length === 0) {
392
+ const sessionRoot = execRecord?.agent?.session?.header?.cwd
393
+ if (typeof sessionRoot !== 'string' || sessionRoot.length === 0) {
389
394
  retireAll()
390
395
  return decision
391
396
  }
@@ -393,10 +398,9 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
393
398
  retireAll()
394
399
  return decision
395
400
  }
396
- // 3. Canonicalize the workspace exactly once.
397
- let workspaceTarget
401
+ let sessionWorkspace
398
402
  try {
399
- workspaceTarget = await fs.resolve(workspaceRoot, { signal })
403
+ sessionWorkspace = await fs.resolve(sessionRoot, { signal })
400
404
  } catch {
401
405
  retireAll()
402
406
  return decision
@@ -405,9 +409,9 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
405
409
  retireAll()
406
410
  return decision
407
411
  }
408
- let workspaceInfo
412
+ let sessionWorkspaceInfo
409
413
  try {
410
- workspaceInfo = await fs.stat(workspaceTarget, signal)
414
+ sessionWorkspaceInfo = await fs.stat(sessionWorkspace, signal)
411
415
  } catch {
412
416
  retireAll()
413
417
  return decision
@@ -416,26 +420,43 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
416
420
  retireAll()
417
421
  return decision
418
422
  }
419
- if (workspaceInfo === undefined || workspaceInfo.type !== 'directory') {
423
+ if (sessionWorkspaceInfo === undefined || sessionWorkspaceInfo.type !== 'directory') {
420
424
  retireAll()
421
425
  return decision
422
426
  }
423
- // 4. Per-target eligibility: contains with pre/post abort checks, then
424
- // freeze renderPath + canonicalUri exactly once. Every early exit and
425
- // abort break retires the remaining taken candidates.
426
- /** @type {{ candidate: import('./collector.js').MutationCandidate, renderPath: string, targetKey: string, canonicalUri: string }[]} */
427
+ // 3. Per-target eligibility: use session cwd when it contains the target;
428
+ // otherwise resolve the LSP workspace from target-local markers
429
+ // (go.mod/.git/package markers). Then freeze renderPath + canonicalUri.
430
+ /** @type {{ candidate: import('./collector.js').MutationCandidate, workspaceTarget: any, renderPath: string, targetKey: string, canonicalUri: string }[]} */
427
431
  const eligible = []
428
432
  for (const route of routed) {
429
433
  const { candidate, canonicalUri } = route
430
434
  if (signal.aborted) break
431
435
  let contained = false
432
436
  try {
433
- contained = fs.contains(workspaceTarget, candidate.target) === true
437
+ contained = fs.contains(sessionWorkspace, candidate.target) === true
434
438
  } catch {
435
439
  contained = false
436
440
  }
437
441
  if (signal.aborted) break
438
- if (!contained) {
442
+ let workspaceTarget = contained ? sessionWorkspace : undefined
443
+ if (workspaceTarget === undefined) {
444
+ try {
445
+ workspaceTarget = await resolveWorkspaceRoot({
446
+ fs,
447
+ target: candidate.target,
448
+ extension: extensionOf(canonicalUri),
449
+ sessionRoot,
450
+ signal,
451
+ sessionFallback: 'never',
452
+ markerMode: 'git',
453
+ })
454
+ } catch {
455
+ workspaceTarget = undefined
456
+ }
457
+ }
458
+ if (signal.aborted) break
459
+ if (workspaceTarget === undefined) {
439
460
  collector.retireIfCurrent(candidate)
440
461
  continue
441
462
  }
@@ -448,6 +469,7 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
448
469
  }
449
470
  eligible.push({
450
471
  candidate,
472
+ workspaceTarget,
451
473
  renderPath,
452
474
  targetKey: String(candidate.target.targetKey ?? ''),
453
475
  canonicalUri,
@@ -470,7 +492,7 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
470
492
  /** @type {import('./runtime.js').DiagnosisOutcome} */
471
493
  let outcome
472
494
  try {
473
- outcome = await runtime.diagnose(target.candidate, workspaceTarget, target.canonicalUri, signal)
495
+ outcome = await runtime.diagnose(target.candidate, target.workspaceTarget, target.canonicalUri, signal)
474
496
  } catch (error) {
475
497
  if (signal.aborted) break
476
498
  // Plugin-owned failure after eligibility: bounded unavailable with
@@ -609,9 +631,12 @@ export function createDiagnosticsCoordinator({ collector, runtime, config, fs, n
609
631
  if (rendered.text !== null) {
610
632
  const message = createUserMessage({
611
633
  content: [{ type: 'text', text: rendered.text }],
634
+ // 0.1.7-rc.2 vocabulary: the producer declares its own
635
+ // `MessageSourceMap` kind (message-source.d.ts) and owns its bounded
636
+ // one-line `notice` account; there is no shared catch-all `plugin`
637
+ // kind and no producer-name field.
612
638
  source: {
613
- kind: 'plugin',
614
- plugin: '@banbolee/dsh-lsp-diagnostics',
639
+ kind: 'lsp-diagnostics',
615
640
  form: 'notice',
616
641
  summary: boundContextSummary(rendered.text),
617
642
  },
package/index.js CHANGED
@@ -90,6 +90,7 @@ const DEFAULT_ENABLED_PROVIDERS = Object.freeze(['typescript', 'go'])
90
90
  * @type {Readonly<Record<string, Readonly<{ provider: ProviderId, language: string }>>>}
91
91
  */
92
92
  const CANONICAL_ROUTE = Object.freeze({
93
+ '.js': Object.freeze({ provider: 'typescript', language: 'javascript' }),
93
94
  '.ts': Object.freeze({ provider: 'typescript', language: 'typescript' }),
94
95
  '.tsx': Object.freeze({ provider: 'typescript', language: 'typescriptreact' }),
95
96
  '.go': Object.freeze({ provider: 'go', language: 'go' }),
@@ -290,11 +291,8 @@ function assertExtensionToLanguage(map, provider) {
290
291
  if (defaults === undefined) fail(`provider ${provider} has no catalog defaults`)
291
292
  const expected = defaults.extensionToLanguage
292
293
  const expectedExtensions = Object.keys(expected)
293
- if (
294
- routes.size !== expectedExtensions.length
295
- || !expectedExtensions.every((extension) => routes.get(extension) === expected[extension])
296
- ) {
297
- fail(`servers.${provider}.extensionToLanguage must equal the canonical provider mapping`)
294
+ if (!expectedExtensions.every((extension) => routes.get(extension) === expected[extension])) {
295
+ fail(`servers.${provider}.extensionToLanguage must include the canonical provider mapping`)
298
296
  }
299
297
  return Object.fromEntries(routes)
300
298
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The plugin-owned `MessageSourceMap` entry this plugin's aggregate diagnostics
3
+ * notice needs.
4
+ *
5
+ * `@deepseek-ai/dsh-llm` 0.1.7-rc.2 removed the shared catch-all
6
+ * `MessageSourceMap['plugin']` kind: the map is merge-extensible, so every
7
+ * producer declares its own `kind` in its own module (as `dsh-agent` does for
8
+ * `model-selection` and `dsh-tools` for `tool-registry`). A `declare module`
9
+ * augmentation cannot live in a checked JavaScript file (TS8006), so it lives
10
+ * here and `coordinator.js` pulls it into the program with
11
+ * `/// <reference path="./message-source.d.ts" />`.
12
+ *
13
+ * @module @banbolee/dsh-lsp-diagnostics/message-source
14
+ */
15
+
16
+ import type { ContextFormed } from '@deepseek-ai/dsh-llm'
17
+
18
+ declare module '@deepseek-ai/dsh-llm' {
19
+ interface MessageSourceMap {
20
+ /** One aggregate post-write diagnostics notice produced by this plugin. */
21
+ 'lsp-diagnostics': { kind: 'lsp-diagnostics' } & ContextFormed
22
+ }
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banbolee/dsh-lsp-diagnostics",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Automatic post-write and model-callable read-only LSP diagnostics for existing files in DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -16,8 +16,11 @@
16
16
  "tool.js",
17
17
  "render.js",
18
18
  "coordinator.js",
19
+ "message-source.d.ts",
20
+ "workspace-root.js",
19
21
  "cordis.patch.yml",
20
- "README.md"
22
+ "README.md",
23
+ "README.zh.md"
21
24
  ],
22
25
  "dsh": {
23
26
  "bundle": {
@@ -25,33 +28,31 @@
25
28
  }
26
29
  },
27
30
  "peerDependencies": {
28
- "@deepseek-ai/cordis": "^4.0.2",
29
- "@deepseek-ai/dsh-fs": "^0.1.5-rc.1",
30
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
31
- "@deepseek-ai/dsh-subprocess": "^0.1.5-rc.1",
32
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.1"
31
+ "@deepseek-ai/cordis": "~4.0.4",
32
+ "@deepseek-ai/dsh-fs": "^0.1.7-rc.2",
33
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
34
+ "@deepseek-ai/dsh-subprocess": "^0.1.7-rc.2",
35
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2"
33
36
  },
34
37
  "devDependencies": {
35
- "@deepseek-ai/cordis": "^4.0.2",
36
- "@deepseek-ai/dsh-agent": "^0.1.5-rc.1",
37
- "@deepseek-ai/dsh-agent-loop": "^0.1.5-rc.1",
38
- "@deepseek-ai/dsh-attachment": "^0.1.5-rc.1",
39
- "@deepseek-ai/dsh-code-runtime": "^0.1.5-rc.1",
40
- "@deepseek-ai/dsh-code-runtime-worker-thread": "^0.1.5-rc.1",
41
- "@deepseek-ai/dsh-fs": "^0.1.5-rc.1",
42
- "@deepseek-ai/dsh-fs-local": "^0.1.5-rc.1",
43
- "@deepseek-ai/dsh-invariants": "^0.1.5-rc.1",
44
- "@deepseek-ai/dsh-llm": "^0.1.5-rc.1",
45
- "@deepseek-ai/dsh-scope": "^0.1.5-rc.1",
46
- "@deepseek-ai/dsh-session": "^0.1.5-rc.1",
47
- "@deepseek-ai/dsh-session-persistence": "^0.1.5-rc.1",
48
- "@deepseek-ai/dsh-subprocess": "^0.1.5-rc.1",
49
- "@deepseek-ai/dsh-subprocess-local": "^0.1.5-rc.1",
50
- "@deepseek-ai/dsh-system-prompt": "^0.1.5-rc.1",
51
- "@deepseek-ai/dsh-tool-fs": "^0.1.5-rc.1",
52
- "@deepseek-ai/dsh-tool-str-replace-editor": "^0.1.5-rc.1",
53
- "@deepseek-ai/dsh-tools": "^0.1.5-rc.1",
54
- "@deepseek-ai/dsh-user-approval": "^0.1.5-rc.1"
38
+ "@deepseek-ai/cordis": "~4.0.4",
39
+ "@deepseek-ai/dsh-agent": "^0.1.7-rc.2",
40
+ "@deepseek-ai/dsh-agent-loop": "^0.1.7-rc.2",
41
+ "@deepseek-ai/dsh-attachment": "^0.1.7-rc.2",
42
+ "@deepseek-ai/dsh-fs": "^0.1.7-rc.2",
43
+ "@deepseek-ai/dsh-fs-local": "^0.1.7-rc.2",
44
+ "@deepseek-ai/dsh-invariants": "^0.1.7-rc.2",
45
+ "@deepseek-ai/dsh-llm": "^0.1.7-rc.2",
46
+ "@deepseek-ai/dsh-scope": "^0.1.7-rc.2",
47
+ "@deepseek-ai/dsh-session": "^0.1.7-rc.2",
48
+ "@deepseek-ai/dsh-session-persistence": "^0.1.7-rc.2",
49
+ "@deepseek-ai/dsh-subprocess": "^0.1.7-rc.2",
50
+ "@deepseek-ai/dsh-subprocess-local": "^0.1.7-rc.2",
51
+ "@deepseek-ai/dsh-system-prompt": "^0.1.7-rc.2",
52
+ "@deepseek-ai/dsh-tool-fs": "^0.1.7-rc.2",
53
+ "@deepseek-ai/dsh-tool-str-replace-editor": "^0.1.7-rc.2",
54
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2",
55
+ "@deepseek-ai/dsh-user-approval": "^0.1.7-rc.2"
55
56
  },
56
57
  "keywords": [
57
58
  "dsh",
package/runtime.js CHANGED
@@ -473,7 +473,27 @@ export class DiagnosticsRuntime {
473
473
  for (const session of sessions) this.startSessionTeardown(session)
474
474
  /** @type {unknown[]} */
475
475
  const errors = []
476
- const queueResults = await Promise.allSettled(queues)
476
+ // Bounded for the same reason every wait inside `teardown` is: these are the
477
+ // serialized write queues, and a queue whose peer is gone never settles. The
478
+ // sessions' own teardowns are already bounded, but this await is a SECOND,
479
+ // independent one on the same tails — bounding it inside `teardown` does not
480
+ // reach it, and an unbounded await here keeps `dispose()` from ever
481
+ // resolving, which is what leaves the fiber alive and the plugin never
482
+ // reporting itself disposed.
483
+ const queues$ = new AbortController()
484
+ const queuesTimer = setTimeout(() => queues$.abort(), this.config.shutdownTimeoutMs)
485
+ /** @type {PromiseSettledResult<unknown>[]} */
486
+ let queueResults = []
487
+ try {
488
+ queueResults = await /** @type {Promise<PromiseSettledResult<unknown>[]>} */ (
489
+ abortable(Promise.allSettled(queues), queues$.signal)
490
+ )
491
+ } catch (error) {
492
+ if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
493
+ queueResults = []
494
+ } finally {
495
+ clearTimeout(queuesTimer)
496
+ }
477
497
  for (const result of queueResults) {
478
498
  if (result.status === 'rejected') errors.push(result.reason)
479
499
  }
@@ -1505,17 +1525,37 @@ class LspSession {
1505
1525
  }
1506
1526
 
1507
1527
  /**
1508
- * The unique teardown sequence: shutdown request within an independent
1509
- * graceful budget, exit notification plus natural-close wait, whole-process-
1510
- * tree liveness probe (bounded by the same budget), conditional
1511
- * `terminate()` (the only hard-stop, exactly once, tree-scoped), then
1512
- * `handle.done` and `waitForExit()`, and only then the process-lifetime
1513
- * controller abort. Terminate is decided by whole-tree liveness, never by
1514
- * direct-process `done` alone, so a root that exits while a helper remains
1515
- * alive still escalates and the teardown cannot wait forever.
1528
+ * The unique teardown sequence: own the session's startup, then shutdown
1529
+ * request within an independent graceful budget, exit notification plus
1530
+ * natural-close wait, whole-process-tree liveness probe (bounded by the same
1531
+ * budget), conditional `terminate()` (the only hard-stop, exactly once,
1532
+ * tree-scoped), then `handle.done` and `waitForExit()`, and only then the
1533
+ * process-lifetime controller abort. Terminate is decided by whole-tree
1534
+ * liveness, never by direct-process `done` alone, so a root that exits while
1535
+ * a helper remains alive still escalates and the teardown cannot wait forever.
1516
1536
  * @returns {Promise<void>}
1517
1537
  */
1518
1538
  async teardown() {
1539
+ // A session can be retired while its bootstrap is still running: the pool
1540
+ // publishes the session before its process exists. Acting on the process
1541
+ // now would find no stdin and no handle, so the sequence below would
1542
+ // complete as a no-op while the process the bootstrap spawns afterwards
1543
+ // stays owned by nobody — never sent shutdown/exit and never terminated —
1544
+ // and dispose() would have reported a finished teardown. Own the startup
1545
+ // first, so the single transaction always runs against the process this
1546
+ // session actually has. This wait is deliberately not charged to the
1547
+ // graceful budget, which bounds the shutdown handshake against a live but
1548
+ // hung server: the bootstrap cannot outlive the admitted operation's signal
1549
+ // (cleanup aborts it before disposing) and its pending initialize request
1550
+ // was already rejected by `startTeardown`, so it settles without a process
1551
+ // or with the one the sequence must tear down.
1552
+ if (this.handle === undefined && this.ready !== undefined) {
1553
+ try {
1554
+ await this.ready
1555
+ } catch {
1556
+ // the bootstrap failed without spawning; the transaction continues
1557
+ }
1558
+ }
1519
1559
  /** @type {unknown[]} */
1520
1560
  const errors = []
1521
1561
  const budget = new AbortController()
@@ -1565,31 +1605,65 @@ class LspSession {
1565
1605
  errors.push(error)
1566
1606
  }
1567
1607
  }
1568
- if (this.handle !== undefined) {
1569
- try {
1570
- await this.handle.done
1571
- } catch (error) {
1572
- errors.push(error)
1573
- }
1608
+ if (this.handle !== undefined) {
1609
+ // These two waits must be BOUNDED. `terminate()` above is the hard stop —
1610
+ // once it has been issued there is nothing left to decide, only a process
1611
+ // tree to observe going away. On a platform whose containment never
1612
+ // reports the tree as exited (a systemd scope whose direct runner dies
1613
+ // while the scope lives, say), `waitForExit()` never resolves, and an
1614
+ // unbounded await here deadlocks the teardown BEFORE the lifetime abort at
1615
+ // the end of this method — the one action that would actually tear the
1616
+ // subprocess down. The method's own contract says it "cannot wait
1617
+ // forever"; this is where that promise is kept.
1618
+ //
1619
+ // A fresh graceful budget is used rather than the one above: that one
1620
+ // bounded the shutdown handshake, which is a different question from how
1621
+ // long to wait for a killed tree to disappear.
1622
+ const settle = new AbortController()
1623
+ const settleTimer = setTimeout(() => settle.abort(), this.config.shutdownTimeoutMs)
1574
1624
  try {
1575
- await this.handle.waitForExit()
1576
- } catch (error) {
1577
- errors.push(error)
1625
+ try {
1626
+ await abortable(this.handle.done, settle.signal)
1627
+ } catch (error) {
1628
+ if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
1629
+ }
1630
+ try {
1631
+ await this.handle.waitForExit(settle.signal)
1632
+ } catch (error) {
1633
+ if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
1634
+ }
1635
+ } finally {
1636
+ clearTimeout(settleTimer)
1578
1637
  }
1579
1638
  }
1580
1639
  // Own the protocol write quiescence: every server-request handler and the
1581
1640
  // serialized write tail must settle before cleanup returns, so no queued
1582
1641
  // frame (responses, shutdown, exit) can be written after dispose resolves.
1583
- // No new server-request handlers start while closing, so this terminates.
1584
- for (;;) {
1585
- while (this.serverRequests.size > 0) {
1586
- await Promise.allSettled([...this.serverRequests])
1642
+ // No new server-request handlers start while closing, so this terminates —
1643
+ // EXCEPT when the peer is gone and a write never settles, which is precisely
1644
+ // the case teardown exists to survive. The loop is therefore bounded by the
1645
+ // same graceful budget as the waits above: quiescence is a cleanliness
1646
+ // guarantee, not something worth hanging the whole teardown for. An expired
1647
+ // budget is not an error — the lifetime abort below is the lever.
1648
+ const quiesce = new AbortController()
1649
+ const quiesceTimer = setTimeout(() => quiesce.abort(), this.config.shutdownTimeoutMs)
1650
+ try {
1651
+ for (;;) {
1652
+ while (this.serverRequests.size > 0) {
1653
+ await Promise.allSettled([...this.serverRequests])
1654
+ if (quiesce.signal.aborted) break
1655
+ }
1656
+ const tail = this.writeTail
1657
+ await abortable(tail, quiesce.signal).catch((error) => {
1658
+ if (!(error instanceof Error && error.name === 'AbortError')) errors.push(error)
1659
+ })
1660
+ if (quiesce.signal.aborted) break
1661
+ if (this.writeTail === tail && this.serverRequests.size === 0) break
1587
1662
  }
1588
- const tail = this.writeTail
1589
- await tail
1590
- if (this.writeTail === tail && this.serverRequests.size === 0) break
1663
+ } finally {
1664
+ clearTimeout(quiesceTimer)
1591
1665
  }
1592
- this.lifetimeController.abort()
1666
+ this.lifetimeController.abort()
1593
1667
  this.detachStreams()
1594
1668
  if (errors.length === 1) throw errors[0]
1595
1669
  if (errors.length > 1) {
package/tool.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools'
2
2
  import { compareDiagnostics, sanitizeDisplayPath } from './render.js'
3
+ import { resolveWorkspaceRoot } from './workspace-root.js'
3
4
 
4
5
  /** Closed runtime reasons exposed by the canonical tool output. */
5
6
  const UNAVAILABLE_REASONS = /** @type {const} */ ([
@@ -105,7 +106,9 @@ const OUTPUT_SCHEMA = /** @type {const} */ ({
105
106
  * @typedef {object} FsSeam
106
107
  * @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<import('@deepseek-ai/dsh-fs').FsTarget>} resolve
107
108
  * @property {(target: import('@deepseek-ai/dsh-fs').FsTarget, signal?: AbortSignal) => Promise<import('@deepseek-ai/dsh-fs').FsInfo | undefined>} stat
109
+ * @property {(parent: import('@deepseek-ai/dsh-fs').FsTarget, child: import('@deepseek-ai/dsh-fs').FsTarget) => boolean} contains
108
110
  * @property {(target: import('@deepseek-ai/dsh-fs').FsTarget) => string} fileUrl
111
+ * @property {(target: import('@deepseek-ai/dsh-fs').FsTarget) => string} processPath
109
112
  */
110
113
 
111
114
  /**
@@ -351,13 +354,13 @@ export function createDiagnosticsTool({ fs, runtime, config, now = () => perform
351
354
  let early = abortOutcome(operation)
352
355
  if (early !== undefined) return early
353
356
  const signal = operation.controller.signal
354
- const workspace = await fs.resolve(workspaceRoot, { signal })
357
+ const sessionWorkspace = await fs.resolve(workspaceRoot, { signal })
355
358
  early = abortOutcome(operation)
356
359
  if (early !== undefined) return early
357
- const workspaceInfo = await fs.stat(workspace, signal)
360
+ const sessionWorkspaceInfo = await fs.stat(sessionWorkspace, signal)
358
361
  early = abortOutcome(operation)
359
362
  if (early !== undefined) return early
360
- if (workspaceInfo === undefined || workspaceInfo.type !== 'directory') {
363
+ if (sessionWorkspaceInfo === undefined || sessionWorkspaceInfo.type !== 'directory') {
361
364
  throw new Error('lsp_diagnostics: session workspace is not an existing directory')
362
365
  }
363
366
 
@@ -379,6 +382,28 @@ export function createDiagnosticsTool({ fs, runtime, config, now = () => perform
379
382
  const label = extension.length === 0 ? '(none)' : extension
380
383
  throw new Error(`lsp_diagnostics: no configured diagnostics provider for extension ${label}`)
381
384
  }
385
+ /** @type {import('@deepseek-ai/dsh-fs').FsTarget | undefined} */
386
+ let workspace = sessionWorkspace
387
+ try {
388
+ if (fs.contains(sessionWorkspace, target) !== true) workspace = undefined
389
+ } catch {
390
+ workspace = undefined
391
+ }
392
+ if (workspace === undefined) {
393
+ workspace = await resolveWorkspaceRoot({
394
+ fs,
395
+ target,
396
+ extension,
397
+ sessionRoot: workspaceRoot,
398
+ signal,
399
+ sessionFallback: 'always',
400
+ })
401
+ }
402
+ early = abortOutcome(operation)
403
+ if (early !== undefined) return early
404
+ if (workspace === undefined) {
405
+ throw new Error('lsp_diagnostics: workspace root could not be resolved')
406
+ }
382
407
 
383
408
  const outcome = await runtime.diagnoseTarget(
384
409
  target,
@@ -0,0 +1,155 @@
1
+ import { dirname, isAbsolute, join } from 'node:path'
2
+
3
+ /** @typedef {'always' | 'if-contained' | 'never'} SessionFallback */
4
+
5
+ /**
6
+ * @typedef {object} FsSeam
7
+ * @property {(path: string, opts?: { cwd?: string, signal?: AbortSignal }) => Promise<any>} resolve
8
+ * @property {(statTarget: any, signal?: AbortSignal) => Promise<{ version: string, type: string, size?: number } | undefined>} stat
9
+ * @property {(parentTarget: any, childTarget: any) => boolean} contains
10
+ * @property {(processTarget: any) => string} processPath
11
+ */
12
+
13
+ const GO_MARKERS = /** @type {const} */ (['go.work', 'go.mod', '.git'])
14
+ const TYPESCRIPT_MARKERS = /** @type {const} */ (['tsconfig.json', 'jsconfig.json', 'package.json', '.git'])
15
+ const DEFAULT_MARKERS = /** @type {const} */ (['.git'])
16
+
17
+ /**
18
+ * Return project markers for the already-routed source extension.
19
+ * @param {string} extension
20
+ * @returns {readonly string[]}
21
+ */
22
+ function markersForExtension(extension) {
23
+ if (extension === '.go') return GO_MARKERS
24
+ if (extension === '.ts' || extension === '.tsx') return TYPESCRIPT_MARKERS
25
+ return DEFAULT_MARKERS
26
+ }
27
+
28
+ /**
29
+ * Stat one marker path. Missing or inaccessible markers are simply not roots.
30
+ * @param {FsSeam} fs
31
+ * @param {string} markerPath
32
+ * @param {AbortSignal | undefined} signal
33
+ * @returns {Promise<boolean>}
34
+ */
35
+ async function markerExists(fs, markerPath, signal) {
36
+ try {
37
+ if (signal?.aborted) return false
38
+ const marker = await fs.resolve(markerPath, { signal })
39
+ if (signal?.aborted) return false
40
+ const info = await fs.stat(marker, signal)
41
+ if (signal?.aborted) return false
42
+ return info?.type === 'file' || info?.type === 'directory'
43
+ } catch {
44
+ return false
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Resolve an existing directory root candidate.
50
+ * @param {FsSeam} fs
51
+ * @param {string} directory
52
+ * @param {AbortSignal | undefined} signal
53
+ * @returns {Promise<any | undefined>}
54
+ */
55
+ async function resolveDirectory(fs, directory, signal) {
56
+ try {
57
+ if (signal?.aborted) return undefined
58
+ const root = await fs.resolve(directory, { signal })
59
+ if (signal?.aborted) return undefined
60
+ const info = await fs.stat(root, signal)
61
+ if (signal?.aborted) return undefined
62
+ return info?.type === 'directory' ? root : undefined
63
+ } catch {
64
+ return undefined
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Walk from the target's directory to the filesystem root looking for project markers.
70
+ * @param {FsSeam} fs
71
+ * @param {string} startDirectory
72
+ * @param {readonly string[]} markers
73
+ * @param {AbortSignal | undefined} signal
74
+ * @returns {Promise<any | undefined>}
75
+ */
76
+ async function nearestMarkedDirectory(fs, startDirectory, markers, signal) {
77
+ let directory = startDirectory
78
+ for (;;) {
79
+ for (const marker of markers) {
80
+ if (await markerExists(fs, join(directory, marker), signal)) {
81
+ return resolveDirectory(fs, directory, signal)
82
+ }
83
+ }
84
+ const parent = dirname(directory)
85
+ if (parent === directory) return undefined
86
+ directory = parent
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Resolve the session root fallback and optionally require it to contain target.
92
+ * @param {FsSeam} fs
93
+ * @param {string} sessionRoot
94
+ * @param {any} target
95
+ * @param {SessionFallback} fallback
96
+ * @param {AbortSignal | undefined} signal
97
+ * @returns {Promise<any | undefined>}
98
+ */
99
+ async function resolveSessionFallback(fs, sessionRoot, target, fallback, signal) {
100
+ if (fallback === 'never') return undefined
101
+ if (typeof sessionRoot !== 'string' || sessionRoot.trim().length === 0) return undefined
102
+ const workspace = await resolveDirectory(fs, sessionRoot, signal)
103
+ if (workspace === undefined) return undefined
104
+ if (fallback === 'always') return workspace
105
+ try {
106
+ return fs.contains(workspace, target) === true ? workspace : undefined
107
+ } catch {
108
+ return undefined
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Resolve the LSP workspace for a target. Prefer a project marker rooted at the
114
+ * target itself (so sibling git worktrees work), then fall back to the session
115
+ * workspace according to the caller's policy.
116
+ *
117
+ * @param {object} input
118
+ * @param {FsSeam} input.fs
119
+ * @param {any} input.target
120
+ * @param {string} input.extension
121
+ * @param {string} input.sessionRoot
122
+ * @param {AbortSignal} [input.signal]
123
+ * @param {SessionFallback} [input.sessionFallback]
124
+ * @param {'language' | 'git'} [input.markerMode]
125
+ * @returns {Promise<any | undefined>}
126
+ */
127
+ export async function resolveWorkspaceRoot({
128
+ fs,
129
+ target,
130
+ extension,
131
+ sessionRoot,
132
+ signal,
133
+ sessionFallback = 'if-contained',
134
+ markerMode = 'language',
135
+ }) {
136
+ if (sessionFallback === 'if-contained') {
137
+ const sessionWorkspace = await resolveSessionFallback(fs, sessionRoot, target, sessionFallback, signal)
138
+ if (sessionWorkspace !== undefined) return sessionWorkspace
139
+ }
140
+
141
+ try {
142
+ if (signal?.aborted) return undefined
143
+ const targetPath = fs.processPath(target)
144
+ if (typeof targetPath === 'string' && isAbsolute(targetPath)) {
145
+ const markers = markerMode === 'git' ? DEFAULT_MARKERS : markersForExtension(extension)
146
+ const marked = await nearestMarkedDirectory(fs, dirname(targetPath), markers, signal)
147
+ if (marked !== undefined) return marked
148
+ }
149
+ } catch {
150
+ // Fall through to the configured session fallback.
151
+ }
152
+
153
+ if (sessionFallback === 'if-contained') return undefined
154
+ return resolveSessionFallback(fs, sessionRoot, target, sessionFallback, signal)
155
+ }