@herbertgao/pi-extensions 2026.8.6 → 2026.8.8

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.
Files changed (385) hide show
  1. package/README.md +8 -6
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +19 -7
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +30 -9
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +320 -22
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +79 -17
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +27 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +66 -17
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  16. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  17. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  18. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  19. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  26. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  27. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  29. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  30. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
  32. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  33. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +5 -4
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +174 -100
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +5 -1
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +10 -6
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +5 -2
  43. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
  44. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
  45. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
  46. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +140 -97
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +63 -28
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  49. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  50. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  51. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  52. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  53. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  54. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  55. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  56. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +86 -38
  57. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +76 -9
  58. package/node_modules/@narumitw/pi-btw/README.md +120 -111
  59. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  60. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  61. package/node_modules/@narumitw/pi-btw/package.json +10 -6
  62. package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
  63. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  64. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  65. package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
  66. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  67. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  68. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  69. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  70. package/node_modules/pi-lens/CHANGELOG.md +999 -0
  71. package/node_modules/pi-lens/README.md +117 -3
  72. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  73. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  74. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
  75. package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
  76. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  77. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  78. package/node_modules/pi-lens/dist/clients/bash-file-access.js +381 -13
  79. package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
  80. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  81. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +129 -0
  82. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  83. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  84. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +206 -0
  85. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  86. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  87. package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
  88. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  89. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  90. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  91. package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
  92. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  93. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  94. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +84 -14
  95. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  96. package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
  97. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  98. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  99. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
  100. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  101. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
  102. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
  103. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  104. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -2
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +25 -2
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +17 -8
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +32 -4
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +47 -3
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +14 -4
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +127 -28
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +188 -0
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +21 -10
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -2
  143. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  144. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  145. package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
  146. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  147. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +316 -0
  148. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  149. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  150. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  151. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  152. package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
  153. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  154. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
  155. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  156. package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
  157. package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
  158. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  159. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  160. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  161. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  162. package/node_modules/pi-lens/dist/clients/jscpd-client.js +87 -8
  163. package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
  164. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  165. package/node_modules/pi-lens/dist/clients/language-profile.js +9 -2
  166. package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
  167. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  168. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  169. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  170. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +128 -8
  171. package/node_modules/pi-lens/dist/clients/lsp/client.js +1660 -164
  172. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
  173. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  174. package/node_modules/pi-lens/dist/clients/lsp/index.js +2423 -151
  175. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  176. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  177. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  178. package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
  179. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  180. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  181. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
  182. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
  183. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  184. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  185. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  186. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  187. package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
  188. package/node_modules/pi-lens/dist/clients/module-report.js +22 -1
  189. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  190. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  191. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  192. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  193. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  194. package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
  195. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  196. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  197. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  198. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
  199. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  200. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  201. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +23 -9
  202. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  203. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  204. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  205. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  206. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  207. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +8 -3
  208. package/node_modules/pi-lens/dist/clients/read-guard.js +253 -10
  209. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  210. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  211. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  212. package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
  213. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  214. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  215. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
  216. package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
  217. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +352 -45
  218. package/node_modules/pi-lens/dist/clients/runtime-session.js +373 -9
  219. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  220. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
  221. package/node_modules/pi-lens/dist/clients/runtime-turn.js +620 -67
  222. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  223. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  224. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  225. package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
  226. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  227. package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
  228. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  229. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  230. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  231. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  232. package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
  233. package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
  234. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  235. package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  237. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  238. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  239. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  240. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  241. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  242. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  243. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  244. package/node_modules/pi-lens/dist/index.js +61407 -48577
  245. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +476 -57
  246. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
  247. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  248. package/node_modules/pi-lens/docs/agent-guide.md +6 -1
  249. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
  250. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  251. package/node_modules/pi-lens/docs/features.md +19 -5
  252. package/node_modules/pi-lens/docs/globalconfig.md +3 -0
  253. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  254. package/node_modules/pi-lens/docs/mcp.md +2 -2
  255. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  256. package/node_modules/pi-lens/docs/settings.md +19 -1
  257. package/node_modules/pi-lens/docs/usage.md +1 -0
  258. package/node_modules/pi-lens/package.json +27 -10
  259. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
  260. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
  261. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
  262. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
  263. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
  264. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
  265. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
  300. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
  301. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
  302. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
  303. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  304. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  305. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  306. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  307. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  308. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  309. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
  310. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  311. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  312. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  313. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  314. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  315. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  316. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  317. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  318. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  319. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
  320. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  321. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  322. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  323. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  324. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  325. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  326. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
  327. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
  328. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
  329. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  330. package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
  331. package/node_modules/pi-mcp-adapter/README.md +47 -5
  332. package/node_modules/pi-mcp-adapter/cli.js +163 -1
  333. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  334. package/node_modules/pi-mcp-adapter/config.ts +25 -7
  335. package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
  336. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  337. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  338. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  339. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  340. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  341. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  342. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  343. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  344. package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
  345. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  346. package/node_modules/pi-mcp-adapter/package.json +3 -1
  347. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  348. package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
  349. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  350. package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
  351. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  352. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  353. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  354. package/node_modules/pi-mcp-adapter/types.ts +2 -0
  355. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  356. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  357. package/node_modules/pi-web-access/CHANGELOG.md +37 -0
  358. package/node_modules/pi-web-access/README.md +32 -13
  359. package/node_modules/pi-web-access/anysearch.ts +4 -2
  360. package/node_modules/pi-web-access/brave.ts +16 -4
  361. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  362. package/node_modules/pi-web-access/curator-page.ts +6 -3
  363. package/node_modules/pi-web-access/curator-server.ts +4 -1
  364. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  365. package/node_modules/pi-web-access/exa.ts +18 -7
  366. package/node_modules/pi-web-access/extract.ts +40 -5
  367. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  368. package/node_modules/pi-web-access/gemini-search.ts +16 -4
  369. package/node_modules/pi-web-access/github-extract.ts +65 -36
  370. package/node_modules/pi-web-access/index.ts +60 -61
  371. package/node_modules/pi-web-access/openai-search.ts +17 -6
  372. package/node_modules/pi-web-access/package.json +1 -1
  373. package/node_modules/pi-web-access/page-query.ts +4 -2
  374. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  375. package/node_modules/pi-web-access/parallel.ts +2 -2
  376. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  377. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  378. package/node_modules/pi-web-access/serper.ts +211 -0
  379. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  380. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  381. package/node_modules/pi-web-access/summary-review.ts +44 -22
  382. package/node_modules/pi-web-access/tavily.ts +16 -4
  383. package/node_modules/pi-web-access/utils.ts +87 -0
  384. package/node_modules/pi-web-access/valyu.ts +199 -0
  385. package/package.json +14 -14
@@ -7,27 +7,32 @@
7
7
  * - Diagnostics with debouncing
8
8
  * - Request/response handling
9
9
  */
10
- import { logExtension } from "../extension-log.js";
11
10
  import { spawn as nodeSpawn } from "node:child_process";
12
11
  import { EventEmitter } from "node:events";
13
12
  import { access, readFile } from "node:fs/promises";
14
13
  import * as os from "node:os";
15
14
  import { pathToFileURL } from "node:url";
15
+ import { emitBounded } from "../bounded-telemetry.js";
16
16
  import { withTimeout } from "../deadline-utils.js";
17
- import { logLatency } from "../latency-logger.js";
17
+ import { incrementDegradationCount } from "../degradation-ledger.js";
18
18
  // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
19
19
  // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
20
20
  import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
21
+ import { logExtension } from "../extension-log.js";
22
+ import { recordLspChild, removeLspChild } from "../instance-registry.js";
23
+ import { logLatency } from "../latency-logger.js";
24
+ import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
21
25
  import { getAmbientAbortSignal } from "../safe-spawn.js";
26
+ import { raceToCompletion } from "./aggregation.js";
22
27
  import { hashDiagnosticContent, } from "./diagnostic-binding.js";
23
- import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
24
- import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16, } from "./edits.js";
25
- import { recordLspChild, removeLspChild } from "../instance-registry.js";
28
+ import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16 } from "./edits.js";
26
29
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
27
- import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
28
30
  import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
31
+ import { negotiateSyncKind, TEXT_DOCUMENT_SYNC_KIND_FULL, TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL, } from "./sync-kind.js";
32
+ import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
29
33
  import { getStrategy } from "./wait-policy/index.js";
30
34
  import { WatchedFilesQueue } from "./watch-queue.js";
35
+ import { clearAllWorkspaceDiagnosticsCaches, clearWorkspaceDiagnosticsCacheAtAndAbove, } from "./workspace-diagnostics-cache.js";
31
36
  // Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
32
37
  // hot-path cost. Surfaces each server's publish behavior (version + count) to
33
38
  // diagnose the clean-file affirmative-signal question (#240): which servers
@@ -48,6 +53,8 @@ const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
48
53
  * argument, without new server-specific code.
49
54
  */
50
55
  function extractSpawnMarker(args) {
56
+ if (!args)
57
+ return undefined;
51
58
  const tmpDir = os.tmpdir();
52
59
  for (let i = 0; i < args.length - 1; i++) {
53
60
  const flag = args[i];
@@ -126,7 +133,31 @@ const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_
126
133
  // the diagnostics flush. On timeout the request is treated as `unavailable`, which
127
134
  // (per #240) is NOT read as clean and falls through to the bounded push backstop.
128
135
  const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
136
+ // #1773: the smallest budget this codebase treats as usable — dispatching
137
+ // BELOW it is not a real attempt. The old clamp (`Math.max(1, ...)`) let an
138
+ // exhausted `budgetMs` (0 or negative) through as a 1ms pull — sent, timed
139
+ // out by construction, and recorded as a genuine `lsp_pull_diagnostic_timeout`
140
+ // with a fabricated `effectiveBudgetMs: 1` (observed live: both pull-timeout
141
+ // records in the 2026-08-20 plegma dogfood session carried exactly that).
142
+ // 5ms sits below the smallest budget this codebase's own regression fixtures
143
+ // already treat as a real dispatch (`tests/clients/lsp/pull-diagnostic-
144
+ // timeout-telemetry.test.ts` exercises 20ms and 30ms as genuinely-attempted-
145
+ // then-timed-out) and above the 1-4ms band that is indistinguishable from
146
+ // "already exhausted" — a local stdio server round trip (write + compute +
147
+ // parse) needs more than a handful of milliseconds even on the fast path.
148
+ // `budgetMs === PULL_MIN_USABLE_BUDGET_MS` dispatches (the comparison below
149
+ // is strict `<`), so the name reads literally: this is the floor value that
150
+ // still gets a real attempt.
151
+ const PULL_MIN_USABLE_BUDGET_MS = 5;
129
152
  const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
153
+ // #1620: the `exit` NOTIFICATION needs its own ceiling, for the same reason the
154
+ // `shutdown` REQUEST has one. A notification write on a pipe that is not
155
+ // draining neither resolves nor rejects, so `safeSendNotification`'s catch never
156
+ // runs and the await is unbounded. The #1459 wedged-write breaker calls this
157
+ // teardown precisely when that stdin has already been PROVEN wedged, so the
158
+ // unbounded await was the whole mechanism of the leak. Kept equal to the request
159
+ // budget: a healthy server still gets a brief graceful window before the kill.
160
+ const EXIT_NOTIFY_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXIT_NOTIFY_TIMEOUT_MS", 1000);
130
161
  // #1277: cheap liveness round-trip for the silent-clean gates (`index.ts`).
131
162
  // Those gates convert a diagnostics-wait timeout into a confirmed-clean
132
163
  // result from a STATIC capability classification (`silentOnClean`) alone —
@@ -146,6 +177,36 @@ const LIVENESS_PING_QUERY = "__pi_lens_liveness_ping__";
146
177
  // cheaply rebuilt by the next full pull, the worst case is just one extra full
147
178
  // (non-`unchanged`) report per affected file.
148
179
  const WORKSPACE_PULL_RESULT_CACHE_MAX = 4096;
180
+ // #1713: `workspace/diagnostic` has no per-file/per-identifier request to key
181
+ // telemetry identity on — it is one project-wide pull — so timeout/late-answer
182
+ // records use this fixed subject/scope instead of a real path.
183
+ const WORKSPACE_PULL_SCOPE = "*workspace*";
184
+ // #1669 review N2: cap on simultaneous re-pulls the `workspace/diagnostic/
185
+ // refresh` handler fires for open documents. A workspace with hundreds of
186
+ // open documents fanning out one `textDocument/diagnostic` request each,
187
+ // with no cap, floods the server the refresh itself just told us is under
188
+ // load. Small and fixed — this is a background improvement after the
189
+ // protocol reply, never something worth tuning per project.
190
+ const REFRESH_REPULL_CONCURRENCY = 4;
191
+ /** Run `mapper` over `items` with at most `concurrency` in flight at once.
192
+ * Same shape as `dependency-checker.ts`'s helper of the same name — a
193
+ * worker-pool pattern repeated per-file by design in this codebase rather
194
+ * than shared, so each caller can keep it un-exported and file-local. */
195
+ async function mapWithConcurrency(items, concurrency, mapper) {
196
+ if (items.length === 0)
197
+ return;
198
+ let nextIndex = 0;
199
+ const workerCount = Math.max(1, Math.min(concurrency, items.length));
200
+ const worker = async () => {
201
+ while (true) {
202
+ const index = nextIndex++;
203
+ if (index >= items.length)
204
+ return;
205
+ await mapper(items[index]);
206
+ }
207
+ };
208
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
209
+ }
149
210
  // Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
150
211
  // (30s): the command is mutating and legitimately long-running (a real server
151
212
  // refactor / organize-imports), so this must not truncate valid work — it only
@@ -414,10 +475,15 @@ function normalizeLspDiagnostic(diagnostic) {
414
475
  function normalizeLspDiagnostics(diagnostics) {
415
476
  return diagnostics.map(normalizeLspDiagnostic);
416
477
  }
417
- function mergeDiagnosticLists(push, pull) {
478
+ /**
479
+ * Union of diagnostic lists, first occurrence wins, exact duplicates dropped.
480
+ * Variadic since #1667 so the per-identifier pull sources merge through the
481
+ * SAME key as the push/pull merge rather than growing a second dedupe rule.
482
+ */
483
+ function mergeDiagnosticLists(...lists) {
418
484
  const merged = [];
419
485
  const seen = new Set();
420
- for (const diagnostic of [...(push ?? []), ...(pull ?? [])]) {
486
+ for (const diagnostic of lists.flatMap((list) => list ?? [])) {
421
487
  const key = [
422
488
  diagnostic.range.start.line,
423
489
  diagnostic.range.start.character,
@@ -435,14 +501,185 @@ function mergeDiagnosticLists(push, pull) {
435
501
  return merged;
436
502
  }
437
503
  function getMergedDiagnosticsForPath(state, normalizedPath) {
504
+ // SAFETY: `diagnostics` is the pre-split field name that older states (and
505
+ // older embedders holding a state built before `pushDiagnostics` existed)
506
+ // still carry. It is absent from `LSPClientState` on purpose — new code
507
+ // must not write it. The cast declares it OPTIONAL, so the read below is
508
+ // still `undefined`-guarded and a state without it falls through to the
509
+ // current field.
438
510
  const legacy = state;
439
511
  return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
440
512
  legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
441
513
  }
514
+ /**
515
+ * #1667: per-source pull-diagnostic bookkeeping.
516
+ *
517
+ * A pull-mode server can expose several diagnostic SOURCES, each named by its
518
+ * registration's `registerOptions.identifier` (Roslyn: syntax, semantic,
519
+ * analyzers). Each source answers independently, with its own `resultId` and
520
+ * its own findings, so per-path state that used to be a single slot has to
521
+ * become one slot per (path, source):
522
+ *
523
+ * - `pullResultIds` keys on `pullSourceKey(path, identifier)`, so an
524
+ * `unchanged` report is only ever answered against the id THAT source
525
+ * issued (#240/#1104 machinery, now per source).
526
+ * - `documentPullDiagnosticsBySource` holds each source's findings, and
527
+ * `documentPullDiagnostics` is their deduped union - recomputed whenever a
528
+ * source reports, so a fresh answer from one source never wipes another's.
529
+ *
530
+ * The bare (identifier-less) source keys on the plain path, so a single-source
531
+ * server stores exactly what it stored before this change.
532
+ */
533
+ // NUL is the one byte a filesystem path can never contain, so
534
+ // `path + SEP + identifier` is unambiguous: no path/identifier pair can
535
+ // produce the same key as a different pair, and the bare source's key (the
536
+ // plain path) can never collide with a named source's.
537
+ const PULL_SOURCE_KEY_SEPARATOR = "\u0000";
538
+ const BARE_PULL_SOURCE = "";
539
+ function pullSourceKey(normalizedPath, identifier) {
540
+ return identifier === undefined
541
+ ? normalizedPath
542
+ : `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
543
+ }
544
+ /** Every `pullResultIds` key belonging to a path - the bare key plus one per
545
+ * identifier. Used by the clear path so a resync drops EVERY source's basis. */
546
+ function pullSourceKeysForPath(state, normalizedPath) {
547
+ const prefix = `${normalizedPath}${PULL_SOURCE_KEY_SEPARATOR}`;
548
+ return [
549
+ normalizedPath,
550
+ ...[...(state.pullResultIds?.keys() ?? [])].filter((key) => key.startsWith(prefix)),
551
+ ];
552
+ }
553
+ function pullSourcesFor(state, normalizedPath) {
554
+ if (!state.documentPullDiagnosticsBySource) {
555
+ state.documentPullDiagnosticsBySource = new Map();
556
+ }
557
+ let sources = state.documentPullDiagnosticsBySource.get(normalizedPath);
558
+ if (!sources) {
559
+ sources = new Map();
560
+ state.documentPullDiagnosticsBySource.set(normalizedPath, sources);
561
+ }
562
+ return sources;
563
+ }
564
+ /** This source's currently stored findings for a path - what an `unchanged`
565
+ * report inherits. Falls back to the whole per-path list when the source map
566
+ * has no entry yet, so a state that pulled before this change (or a caller
567
+ * that seeded `documentPullDiagnostics` directly) still inherits correctly. */
568
+ function pullSourceDiagnostics(state, normalizedPath, identifier) {
569
+ const sources = state.documentPullDiagnosticsBySource?.get(normalizedPath);
570
+ const stored = sources?.get(identifier ?? BARE_PULL_SOURCE);
571
+ if (stored)
572
+ return stored;
573
+ return sources && sources.size > 0
574
+ ? []
575
+ : (state.documentPullDiagnostics.get(normalizedPath) ?? []);
576
+ }
577
+ /** Store one source's findings and republish the union to
578
+ * `documentPullDiagnostics`, which every reader outside this file uses. */
579
+ function storePullSourceDiagnostics(state, normalizedPath, identifier, diagnostics) {
580
+ const sources = pullSourcesFor(state, normalizedPath);
581
+ sources.set(identifier ?? BARE_PULL_SOURCE, diagnostics);
582
+ state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
583
+ }
584
+ /** #1667: the pull generation for a path. The fan-out deliberately lets losing
585
+ * pulls run to completion so their findings still reach the cache; a resync
586
+ * that lands first must win, so every late write re-checks this. */
587
+ function pullGenerationFor(state, normalizedPath) {
588
+ return state.pullGenerations?.get(normalizedPath) ?? 0;
589
+ }
590
+ function bumpPullGeneration(state, normalizedPath) {
591
+ if (!state.pullGenerations)
592
+ state.pullGenerations = new Map();
593
+ state.pullGenerations.set(normalizedPath, (state.pullGenerations.get(normalizedPath) ?? 0) + 1);
594
+ }
595
+ /** #1667: claim the newest-request slot for a source and return the claim.
596
+ * Called at REQUEST time; `isNewestPullRequest` re-checks it at WRITE time so
597
+ * a slow answer from an earlier fan-out cannot overwrite a newer one's. */
598
+ function claimPullRequestSequence(state, sourceKey) {
599
+ if (!state.pullRequestSequences)
600
+ state.pullRequestSequences = new Map();
601
+ const next = (state.pullRequestSequences.get(sourceKey) ?? 0) + 1;
602
+ state.pullRequestSequences.set(sourceKey, next);
603
+ return next;
604
+ }
605
+ function isNewestPullRequest(state, sourceKey, sequence) {
606
+ return (state.pullRequestSequences?.get(sourceKey) ?? 0) === sequence;
607
+ }
608
+ /**
609
+ * #1667: drop every trace of a diagnostic source the server just unregistered.
610
+ *
611
+ * `client/unregisterCapability` retires a source, but its findings sit in
612
+ * `documentPullDiagnosticsBySource` and its resultId in `pullResultIds`, so
613
+ * without this the union keeps serving a retired source's diagnostics until
614
+ * something resyncs the file. Roslyn and vtsls re-register their sources on
615
+ * solution reload, which is exactly when the old source's findings are stale.
616
+ *
617
+ * Purges the source's slice from every path and republishes each affected
618
+ * path's union, so the removal is visible on the next read rather than only
619
+ * after the next pull.
620
+ */
621
+ function retirePullSource(state, identifier) {
622
+ const suffix = `${PULL_SOURCE_KEY_SEPARATOR}${identifier}`;
623
+ for (const source of [state.pullResultIds, state.pullRequestSequences]) {
624
+ if (!source)
625
+ continue;
626
+ for (const key of [...source.keys()]) {
627
+ if (key.endsWith(suffix))
628
+ source.delete(key);
629
+ }
630
+ }
631
+ for (const [normalizedPath, sources,] of state.documentPullDiagnosticsBySource ?? []) {
632
+ if (!sources.delete(identifier))
633
+ continue;
634
+ state.documentPullDiagnostics.set(normalizedPath, mergeDiagnosticLists(...sources.values()));
635
+ bumpDiagnosticsVersion(state, normalizedPath);
636
+ state.diagnosticEmitter.emit("diagnostics", normalizedPath);
637
+ }
638
+ }
639
+ /**
640
+ * #1667: the diagnostic sources to pull for a document - always the bare
641
+ * request (a server may answer it even while registering named sources, and it
642
+ * is the ONLY request a single-source server understands), plus one per
643
+ * distinct `identifier` on a live `textDocument/diagnostic` registration.
644
+ *
645
+ * Derived from `dynamicRegistrations` on every call rather than cached in a
646
+ * parallel list: the registration map is the single source of truth, and
647
+ * `client/unregisterCapability` must be able to retire a source immediately.
648
+ */
649
+ export function pullDiagnosticSources(state) {
650
+ const identifiers = new Set();
651
+ for (const registration of state.dynamicRegistrations?.values() ?? []) {
652
+ if (registration.method !== "textDocument/diagnostic")
653
+ continue;
654
+ if (registration.identifier)
655
+ identifiers.add(registration.identifier);
656
+ }
657
+ // Sorted for a stable, reproducible request order in logs and tests; the
658
+ // fan-out is parallel, so order carries no priority.
659
+ return [undefined, ...[...identifiers].sort((a, b) => a.localeCompare(b))];
660
+ }
661
+ /** #1531: bump the client-global diagnostics counter and stamp the path it was
662
+ * bumped FOR. The single seam every "fresh diagnostics stored" site goes
663
+ * through, so the per-path stamp can never drift from the global counter. */
664
+ export function bumpDiagnosticsVersion(state, normalizedPath) {
665
+ state.diagnosticsVersion += 1;
666
+ state.diagnosticsVersionsByPath?.set(normalizedPath, state.diagnosticsVersion);
667
+ }
668
+ /** #1531: the global counter's value when diagnostics were last stored for
669
+ * `normalizedPath`, or 0 when nothing is stored. 0 is a safe floor because the
670
+ * counter starts at 0 and only ever increases, so any later publication for the
671
+ * path compares greater than a baseline read while it was absent. */
672
+ export function diagnosticsVersionForPath(state, normalizedPath) {
673
+ return state.diagnosticsVersionsByPath?.get(normalizedPath) ?? 0;
674
+ }
442
675
  /** Exported for tests: the quiet-window timer cancel on clear/resync is the
443
676
  * headline #1412 safety property (a stale versionless publication must never
444
677
  * land after the document content changed). */
445
678
  export function clearDiagnosticsForPath(state, normalizedPath) {
679
+ // SAFETY: same pre-split field names as `getMergedDiagnosticsForPath`.
680
+ // Clearing must cover the legacy maps too, or a state that still carries
681
+ // them replays a stale diagnostic after the clear. Both are declared
682
+ // OPTIONAL, so every use below is `?.`-guarded on a state that lacks them.
446
683
  const legacy = state;
447
684
  state.pushDiagnostics?.delete(normalizedPath);
448
685
  const pending = state.pendingDiagnostics?.get(normalizedPath);
@@ -458,37 +695,96 @@ export function clearDiagnosticsForPath(state, normalizedPath) {
458
695
  // `documentContentHashes` record is intentionally retained: it describes what
459
696
  // we sent, which the NEXT publish for that version still needs to bind to.)
460
697
  state.diagnosticBindings?.delete(normalizedPath);
698
+ // #1531: the per-path publication stamp describes diagnostics that no longer
699
+ // exist — drop it with them. Safe because the stamps hold the GLOBAL counter's
700
+ // value: a baseline captured before this clear still compares less than any
701
+ // later publication for the path, so dropping the entry cannot manufacture a
702
+ // missed answer.
703
+ state.diagnosticsVersionsByPath?.delete(normalizedPath);
461
704
  // #1104: a resync invalidates any `unchanged`-report basis too — the next
462
705
  // pull must not inherit a resultId/contentHash computed against the
463
706
  // content this resync just replaced.
464
- state.pullResultIds?.delete(normalizedPath);
707
+ // #1667: every SOURCE's basis, not just the bare one - a multi-source server
708
+ // holds one resultId per identifier, and leaving even one behind lets the
709
+ // next pull inherit against content this resync replaced.
710
+ for (const key of pullSourceKeysForPath(state, normalizedPath)) {
711
+ state.pullResultIds?.delete(key);
712
+ }
713
+ // `pullRequestSequences` is deliberately NOT cleared here. It is a
714
+ // monotonic per-source counter, and resetting it would let a request issued
715
+ // BEFORE this resync claim the same number as one issued after, so the stale
716
+ // answer would pass the newest-request check. The two guards cover different
717
+ // cases and neither subsumes the other: the generation bump below invalidates
718
+ // in-flight work when nothing newer was requested, and the sequence stamp
719
+ // invalidates it when something newer was.
720
+ state.documentPullDiagnosticsBySource?.delete(normalizedPath);
721
+ // #1667: invalidate any pull still in flight for this path. The fan-out lets
722
+ // losing pulls run to completion so their findings still reach the cache;
723
+ // without this bump one of them could resurrect what this resync cleared.
724
+ bumpPullGeneration(state, normalizedPath);
465
725
  state.workspacePullResultCache?.delete(normalizedPath);
466
726
  legacy.diagnostics?.delete(normalizedPath);
467
727
  legacy.diagnosticTimestamps?.delete(normalizedPath);
468
728
  }
469
- function logTypeScriptPullSettle(state, normalizedPath) {
729
+ /**
730
+ * #1639: `durationMs` measures the PULL SETTLE ITSELF — the caller passes how
731
+ * long `clientRequestPullDiagnostics` (plus any retries) took to resolve
732
+ * `found`/`clean` — never time-since-didOpen. Document age is a genuinely
733
+ * useful signal but a DIFFERENT one; it stays under its own honest name in
734
+ * `metadata.elapsedSinceDidOpenMs`, exactly like the push-path `logSequence`
735
+ * above already does. Before this fix the top-level field carried the same
736
+ * age value pull requests use for retry-budget bookkeeping, so a session's
737
+ * latency percentiles for this phase read multi-second document lifetimes as
738
+ * millisecond operation costs (147/239 records read >60s "durations" for
739
+ * settles that took milliseconds).
740
+ *
741
+ * `version` reads the real tracked version from `diagnosticsVersionsByPath`
742
+ * (bumped by `bumpDiagnosticsVersion` right after this pull's diagnostics
743
+ * were stored — see the `documentPullDiagnostics.set` call above it) instead
744
+ * of a hardcoded `null`, which made every pull settle look "unbound" to
745
+ * staleness forensics even though this codebase already tracks a real
746
+ * version for the path. Falls back to the explicit `"pull-unversioned"`
747
+ * marker only when nothing has been stored yet, so "no data" is never
748
+ * confused with "confirmed no version" (`null`).
749
+ *
750
+ * `pullSettleSource` distinguishes the two legitimate call paths that can
751
+ * both settle a pull for the same file close together: a real
752
+ * content-collecting touch ("pull") and `ensureWarmForSweep`'s warm-up-only
753
+ * touch ("pull-warmup"), which runs a real round trip purely to prove the
754
+ * server answers before the sweep's real touch immediately follows it.
755
+ * Before this fix both logged identically as settleSource "pull", so a
756
+ * session with warm-up sweeps double-counted: 239 records against 145
757
+ * touches, with same-file pairs ~60ms apart and near-identical document
758
+ * ages — the warm-up settle and the real touch's settle for the same file.
759
+ */
760
+ function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSource) {
470
761
  if (state.serverId !== "typescript")
471
762
  return;
472
763
  const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
473
764
  const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
474
- const diagnosticCodes = [...new Set(diagnostics
765
+ const diagnosticCodes = [
766
+ ...new Set(diagnostics
475
767
  .map((diagnostic) => diagnostic.code)
476
768
  .filter((code) => code !== undefined)
477
- .map(String))].slice(0, 8);
769
+ .map(String)),
770
+ ].slice(0, 8);
771
+ const trackedVersion = state.diagnosticsVersionsByPath?.get(normalizedPath);
478
772
  logLatency({
479
773
  type: "phase",
480
774
  phase: "lsp_typescript_diagnostic_sequence",
481
775
  filePath: normalizedPath,
482
- durationMs: elapsedSinceDidOpenMs,
776
+ durationMs,
483
777
  metadata: {
778
+ serverId: state.serverId,
779
+ outcome: "settled",
484
780
  launchVariant: state.launchVariant ?? "unknown",
485
781
  publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
486
- version: null,
782
+ version: trackedVersion ?? "pull-unversioned",
487
783
  diagnosticCount: diagnostics.length,
488
784
  diagnosticCodes,
489
785
  elapsedSinceDidOpenMs,
490
786
  settledReturn: true,
491
- settleSource: "pull",
787
+ settleSource: pullSettleSource,
492
788
  },
493
789
  });
494
790
  }
@@ -504,7 +800,93 @@ function recordSentContent(state, normalizedPath, version, content) {
504
800
  state.documentContentHashes.set(normalizedPath, {
505
801
  version,
506
802
  hash: hashDiagnosticContent(content),
803
+ // #1669: retain the full text only for Incremental — the sole reader
804
+ // (`buildContentChanges`) needs it to compute the NEXT change against
805
+ // what the server last saw; Full/None never read this field.
806
+ ...(state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL && {
807
+ text: content,
808
+ }),
507
809
  });
810
+ // #1641 criterion 3: the in-memory document's version + content length AT
811
+ // SEND TIME, so a later "diagnostic cited a line past current disk EOF"
812
+ // record (`diagnostic_past_eof`, clients/diagnostic-line-freshness.ts) can be
813
+ // paired with the send that produced the divergent in-memory document —
814
+ // today only the symptom (the stale citation) is observable; this is the
815
+ // cause side of the same timeline.
816
+ //
817
+ // Review round F4/F1: `contentLineCount` MUST use the same LSP-addressable
818
+ // convention as the gate itself (newline count + 1 — a trailing `\n` adds
819
+ // one more, empty, addressable line; an empty document is still 1 line),
820
+ // or the two records disagree by one at exactly the boundary this counter
821
+ // exists to help debug. Counted directly (no `.split("\n")`, which
822
+ // allocates one substring per line — measured at 13.3ms/200k allocations
823
+ // on a 7MB send) since only the COUNT is needed here, not the lines.
824
+ let newlineCount = 0;
825
+ for (let i = 0; i < content.length; i++) {
826
+ if (content.charCodeAt(i) === 10)
827
+ newlineCount++;
828
+ }
829
+ logLatency({
830
+ type: "phase",
831
+ phase: "lsp_document_send",
832
+ filePath: normalizedPath,
833
+ durationMs: 0,
834
+ metadata: {
835
+ version,
836
+ contentLength: content.length,
837
+ contentLineCount: newlineCount + 1,
838
+ },
839
+ });
840
+ }
841
+ /**
842
+ * #1669: the `contentChanges` array for a `textDocument/didChange` notification.
843
+ *
844
+ * Full/None (or unrecognized/absent) sync kind: unchanged — a single
845
+ * whole-document `{ text }` event, the shape pi-lens has always sent.
846
+ *
847
+ * Incremental: a server registering Incremental-only expects every change
848
+ * event to carry a `range`; a shapeless whole-document event is out of spec
849
+ * for it. Rather than hand-roll a real diff, send the SAFEST incremental
850
+ * representation of a full update — one ranged edit spanning the entire
851
+ * PREVIOUS document (start of file to its last character), replacing it with
852
+ * the entire new content. That is spec-valid for any Incremental server and
853
+ * semantically identical to the Full-sync event it replaces.
854
+ *
855
+ * The previous document's end position is computed from the text
856
+ * `recordSentContent` retained for THIS path the last time content was sent
857
+ * (reusing that seam rather than a second parallel content store — see its
858
+ * doc comment). No prior text on record (first change since Incremental was
859
+ * negotiated, or the path was never sent before) falls back to the
860
+ * whole-document shape — still spec-valid, and self-heals once the next
861
+ * change has a retained previous text to diff against.
862
+ */
863
+ function buildContentChanges(state, normalizedPath, content) {
864
+ if (state.syncKind !== TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
865
+ return [{ text: content }];
866
+ }
867
+ const previousText = state.documentContentHashes.get(normalizedPath)?.text;
868
+ if (previousText === undefined) {
869
+ return [{ text: content }];
870
+ }
871
+ // #1669 review F6: split on every LSP line terminator (\r\n, lone \r, or
872
+ // \n), not just \n — a document using CRLF or lone-CR line endings would
873
+ // otherwise be undercounted, computing a range that ends mid-document
874
+ // instead of at the real last line.
875
+ const previousLines = previousText.split(/\r\n|\r|\n/);
876
+ const lastLine = previousLines.length - 1;
877
+ const lastLineText = previousLines[lastLine] ?? "";
878
+ return [
879
+ {
880
+ range: {
881
+ start: { line: 0, character: 0 },
882
+ end: {
883
+ line: lastLine,
884
+ character: convertCharacterOffset(state.positionEncoding, lastLineText, lastLineText.length),
885
+ },
886
+ },
887
+ text: content,
888
+ },
889
+ ];
508
890
  }
509
891
  // Methods that can be registered dynamically and map to operationSupport keys
510
892
  const DYNAMIC_OPERATION_METHOD_MAP = {
@@ -522,14 +904,23 @@ const DYNAMIC_OPERATION_METHOD_MAP = {
522
904
  "textDocument/prepareCallHierarchy": "callHierarchy",
523
905
  };
524
906
  export function applyDynamicCapabilities(state) {
525
- const registeredMethods = new Set(state.dynamicRegistrations.values());
907
+ const registrations = [...state.dynamicRegistrations.values()];
908
+ const registeredMethods = new Set(registrations.map((r) => r.method));
526
909
  const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
527
910
  registeredMethods.has("workspace/diagnostic");
528
911
  if (hasDynamicPull) {
529
912
  state.workspaceDiagnosticsSupport = {
530
913
  advertised: true,
531
914
  mode: "pull",
532
- workspaceDiagnostics: registeredMethods.has("workspace/diagnostic"),
915
+ // #1667: workspace-pull support is what the REGISTRATION declares. The
916
+ // spec registers workspace pull as `registerOptions.workspaceDiagnostics`
917
+ // on a `textDocument/diagnostic` registration - `workspace/diagnostic` is
918
+ // not itself a registrable method - so reading the method name alone
919
+ // missed every conforming server and this client never issued a
920
+ // workspace pull for one. The method-name check stays as a fallback for
921
+ // servers that register it as a method anyway.
922
+ workspaceDiagnostics: registrations.some((r) => r.workspaceDiagnostics === true) ||
923
+ registeredMethods.has("workspace/diagnostic"),
533
924
  diagnosticProviderKind: "dynamic",
534
925
  };
535
926
  }
@@ -549,15 +940,6 @@ export function applyDynamicCapabilities(state) {
549
940
  }
550
941
  }
551
942
  }
552
- /**
553
- * Resolve a `workspace/configuration` request item's `section` (a dot-path,
554
- * e.g. "scan.jobs") against the server's `initializationOptions` blob.
555
- * - No section (undefined/empty) → the whole blob, per spec ("if a scope
556
- * isn't asked for" the client returns the full settings for that scope).
557
- * - An unresolvable path → `null`, never the whole blob — a server asking
558
- * for a section it doesn't get must not silently receive unrelated config.
559
- * Exported for the #983 regression test.
560
- */
561
943
  export function resolveConfigurationSection(initialization, section) {
562
944
  if (!initialization)
563
945
  return section ? null : {};
@@ -565,9 +947,7 @@ export function resolveConfigurationSection(initialization, section) {
565
947
  return initialization;
566
948
  let cur = initialization;
567
949
  for (const part of section.split(".")) {
568
- if (typeof cur !== "object" ||
569
- cur === null ||
570
- !Object.prototype.hasOwnProperty.call(cur, part)) {
950
+ if (typeof cur !== "object" || cur === null || !Object.hasOwn(cur, part)) {
571
951
  return null;
572
952
  }
573
953
  cur = cur[part];
@@ -579,6 +959,12 @@ export function resolveConfigurationSection(initialization, section) {
579
959
  // a real language server. Not part of the public client API surface.
580
960
  export function setupIncomingHandlers(state, initialization) {
581
961
  state.connection.onNotification("textDocument/publishDiagnostics", (params) => {
962
+ // #1639: the settle operation's own clock for THIS publish. A
963
+ // "quiet-window" settle's durationMs below measures from here (when
964
+ // this publish arrived and — armed or re-armed — the debounce timer)
965
+ // to when that timer actually fires, i.e. the real debounce wait, not
966
+ // time-since-didOpen.
967
+ const publishReceivedAt = Date.now();
582
968
  const filePath = uriToPath(params.uri);
583
969
  const normalizedPath = normalizeMapKey(filePath);
584
970
  // A server can flush a queued publish after didClose during teardown.
@@ -608,12 +994,38 @@ export function setupIncomingHandlers(state, initialization) {
608
994
  state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
609
995
  }
610
996
  const diagnosticCodes = isTypeScriptTelemetry
611
- ? [...new Set(newDiags
997
+ ? [
998
+ ...new Set(newDiags
612
999
  .map((diagnostic) => diagnostic.code)
613
1000
  .filter((code) => code !== undefined)
614
- .map(String))].slice(0, 8)
1001
+ .map(String)),
1002
+ ].slice(0, 8)
615
1003
  : [];
616
- const logSequence = (settledReturn, settleSource) => {
1004
+ // #1639: `durationMs` measures the settle operation, never
1005
+ // time-since-didOpen — document age keeps its own honest name in
1006
+ // `metadata.elapsedSinceDidOpenMs`, exactly like the pull-path
1007
+ // producer above. Before this fix EVERY call (settled or not) logged
1008
+ // the doc-age value as durationMs, which is what actually produced
1009
+ // the issue's cited evidence: this push-path producer, not the pull
1010
+ // path, emitted all 239 records (147 of them >60s "durations" for
1011
+ // settles that took milliseconds; a 61ms same-file pair is this
1012
+ // function's OWN unsettled-then-settled shape — the
1013
+ // `logSequence(false)` raw-receipt record immediately followed by
1014
+ // the `logSequence(true, ...)` settle record for the same publish).
1015
+ // - "first-push": settles immediately — the seed-first-push strategy
1016
+ // bypasses the debounce timer entirely, so there is no wait to
1017
+ // measure; durationMs is 0.
1018
+ // - "quiet-window": the real debounce wait, timed from THIS publish's
1019
+ // own receipt (a later publish that re-arms the timer captures its
1020
+ // OWN `publishReceivedAt`, so a re-armed wait is timed from the
1021
+ // push that actually won the debounce, not the first one that lost
1022
+ // it).
1023
+ // - "publication": a raw per-publication receipt, never itself a
1024
+ // settle — durationMs is 0 and `settledReturn` stays false. This
1025
+ // is the record that used to log with NO settleSource at all;
1026
+ // giving it one closes AC3 (distinguish, don't silently overload,
1027
+ // the phase's two shapes) for the pairs that actually occur.
1028
+ const logSequence = (settledReturn, settleSource, durationMs) => {
617
1029
  if (state.serverId !== "typescript")
618
1030
  return;
619
1031
  const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
@@ -622,16 +1034,18 @@ export function setupIncomingHandlers(state, initialization) {
622
1034
  type: "phase",
623
1035
  phase: "lsp_typescript_diagnostic_sequence",
624
1036
  filePath: normalizedPath,
625
- durationMs: elapsedSinceDidOpenMs,
1037
+ durationMs,
626
1038
  metadata: {
1039
+ serverId: state.serverId,
1040
+ outcome: settledReturn ? "settled" : "published",
627
1041
  launchVariant: state.launchVariant ?? "unknown",
628
1042
  publicationIndex,
629
- version: docVersion ?? null,
1043
+ version: docVersion ?? "push-unversioned",
630
1044
  diagnosticCount: newDiags.length,
631
1045
  diagnosticCodes,
632
1046
  elapsedSinceDidOpenMs,
633
1047
  settledReturn,
634
- ...(settleSource && { settleSource }),
1048
+ settleSource,
635
1049
  },
636
1050
  });
637
1051
  };
@@ -679,10 +1093,12 @@ export function setupIncomingHandlers(state, initialization) {
679
1093
  // Known, deliberately out-of-scope gaps: the pull-diagnostics path
680
1094
  // (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
681
1095
  // version stamp to compare against in this codebase's current handling,
682
- // so nothing analogous is applied there. And diagnosticsVersion is a
683
- // single global counter rather than per-path, so an unrelated path's
684
- // fresh push can still satisfy a wait for this path's version bump
685
- // both are separate, larger changes.
1096
+ // so nothing analogous is applied there. The other gap this note used to
1097
+ // record — `diagnosticsVersion` being a single global counter, so an
1098
+ // unrelated path's fresh push satisfied a wait for THIS path is closed by
1099
+ // #1531: every bump also stamps `diagnosticsVersionsByPath`, and both the
1100
+ // `minVersion` freshness gate and the auxiliary answered/silent evidence
1101
+ // check read that per-path stamp.
686
1102
  const isSupersededPush = () => {
687
1103
  if (docVersion === undefined)
688
1104
  return false;
@@ -698,12 +1114,14 @@ export function setupIncomingHandlers(state, initialization) {
698
1114
  state.pushDiagnostics.set(normalizedPath, newDiags);
699
1115
  state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
700
1116
  recordDocVersion();
701
- state.diagnosticsVersion += 1;
1117
+ bumpDiagnosticsVersion(state, normalizedPath);
702
1118
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
703
- logSequence(true, "first-push");
1119
+ // Immediate settle — no debounce wait to measure.
1120
+ logSequence(true, "first-push", 0);
704
1121
  return;
705
1122
  }
706
- logSequence(false);
1123
+ // A raw per-publication receipt, not itself a settle.
1124
+ logSequence(false, "publication", 0);
707
1125
  const existingTimer = state.pendingDiagnostics.get(normalizedPath);
708
1126
  if (existingTimer)
709
1127
  clearTimeout(existingTimer);
@@ -714,9 +1132,10 @@ export function setupIncomingHandlers(state, initialization) {
714
1132
  state.pushDiagnostics.set(normalizedPath, newDiags);
715
1133
  state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
716
1134
  recordDocVersion();
717
- state.diagnosticsVersion += 1;
1135
+ bumpDiagnosticsVersion(state, normalizedPath);
718
1136
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
719
- logSequence(true, "quiet-window");
1137
+ // The real debounce wait, timed from THIS publish's own receipt.
1138
+ logSequence(true, "quiet-window", Date.now() - publishReceivedAt);
720
1139
  }, strategy.debounceMs);
721
1140
  state.pendingDiagnostics.set(normalizedPath, timer);
722
1141
  });
@@ -725,25 +1144,57 @@ export function setupIncomingHandlers(state, initialization) {
725
1144
  ]);
726
1145
  state.connection.onRequest("client/registerCapability", async (params) => {
727
1146
  for (const reg of params?.registrations ?? []) {
1147
+ const options = reg.registerOptions;
1148
+ const commands = Array.isArray(options?.commands)
1149
+ ? options.commands.filter((cmd) => typeof cmd === "string")
1150
+ : undefined;
728
1151
  if (reg.id && reg.method) {
729
- state.dynamicRegistrations.set(reg.id, reg.method);
1152
+ // #1667: keep the whole registration, not just its method. An empty
1153
+ // `identifier` is treated as absent - it names no source, and
1154
+ // sending `identifier: ""` would be a second, indistinguishable
1155
+ // bare pull.
1156
+ state.dynamicRegistrations.set(reg.id, {
1157
+ method: reg.method,
1158
+ ...(typeof options?.identifier === "string" && options.identifier
1159
+ ? { identifier: options.identifier }
1160
+ : {}),
1161
+ ...(typeof options?.workspaceDiagnostics === "boolean"
1162
+ ? { workspaceDiagnostics: options.workspaceDiagnostics }
1163
+ : {}),
1164
+ ...(commands ? { commands } : {}),
1165
+ });
730
1166
  }
731
1167
  // executeCommand commands can arrive dynamically too — merge them
732
1168
  // into the allowlist so dynamically-registered commands are runnable.
733
- if (reg.method === "workspace/executeCommand" &&
734
- Array.isArray(reg.registerOptions?.commands)) {
735
- for (const cmd of reg.registerOptions.commands) {
736
- if (typeof cmd === "string")
737
- state.advertisedCommands.add(cmd);
738
- }
1169
+ if (reg.method === "workspace/executeCommand" && commands) {
1170
+ for (const cmd of commands)
1171
+ state.advertisedCommands.add(cmd);
739
1172
  }
740
1173
  }
741
1174
  applyDynamicCapabilities(state);
742
1175
  });
743
1176
  state.connection.onRequest("client/unregisterCapability", async (params) => {
1177
+ const retiredIdentifiers = new Set();
744
1178
  for (const unreg of params?.unregisterations ?? []) {
745
- if (unreg.id) {
746
- state.dynamicRegistrations.delete(unreg.id);
1179
+ if (!unreg.id)
1180
+ continue;
1181
+ const registration = state.dynamicRegistrations.get(unreg.id);
1182
+ state.dynamicRegistrations.delete(unreg.id);
1183
+ if (registration?.method === "textDocument/diagnostic" &&
1184
+ registration.identifier) {
1185
+ retiredIdentifiers.add(registration.identifier);
1186
+ }
1187
+ }
1188
+ // #1667: a retired source's cached findings and resultId must go with
1189
+ // it, or the union keeps serving them. Checked after the deletes above,
1190
+ // and only when NO surviving registration still speaks for that
1191
+ // identifier.
1192
+ const stillRegistered = new Set([...state.dynamicRegistrations.values()]
1193
+ .filter((r) => r.method === "textDocument/diagnostic")
1194
+ .map((r) => r.identifier));
1195
+ for (const identifier of retiredIdentifiers) {
1196
+ if (!stillRegistered.has(identifier)) {
1197
+ retirePullSource(state, identifier);
747
1198
  }
748
1199
  }
749
1200
  applyDynamicCapabilities(state);
@@ -792,6 +1243,128 @@ export function setupIncomingHandlers(state, initialization) {
792
1243
  return items.map((item) => resolveConfigurationSection(initialization, item?.section));
793
1244
  });
794
1245
  state.connection.onRequest("window/workDoneProgress/create", async () => { });
1246
+ // #1669: a server can send `workspace/diagnostic/refresh` (typically after a
1247
+ // project-wide config change) to say every pull result it has already
1248
+ // reported may be stale. Left unhandled, vscode-jsonrpc replies
1249
+ // MethodNotFound to a server explicitly telling us its results are stale —
1250
+ // this is exactly the signal `workspacePullResultCache` and the persisted
1251
+ // workspace-diagnostics cache want (the #1461 family, server-initiated for
1252
+ // once). Reply null per spec and drop both so the NEXT pull recomputes
1253
+ // instead of replaying — or inheriting an `unchanged` basis from — results
1254
+ // the server just told us to distrust.
1255
+ // #1669 review R1: single-flight coalescing latch for the re-pull pool
1256
+ // below, scoped to THIS client (one `setupIncomingHandlers` call per
1257
+ // connection). A refresh FLOOD — a watch-mode rebuild or a `git checkout`
1258
+ // can legitimately fire many `workspace/diagnostic/refresh` requests in a
1259
+ // tight burst — used to queue one independent capped pool PER refresh:
1260
+ // N refreshes meant N pools running concurrently (peak concurrency N x
1261
+ // the per-pool cap) and N full passes over every open document (N x
1262
+ // redundant pulls for the SAME documents). `refreshRepullRunning` gates a
1263
+ // SECOND pool from ever starting while one is already in flight;
1264
+ // `refreshRepullRerunRequested` remembers that at least one more refresh
1265
+ // arrived meanwhile and coalesces it into exactly ONE trailing rerun
1266
+ // after the current pool finishes, using a FRESH snapshot of open
1267
+ // documents at rerun time (not the stale list from whenever the burst
1268
+ // started) — so an arbitrarily large burst still costs at most 2 passes
1269
+ // and never more than `REFRESH_REPULL_CONCURRENCY` pulls at once.
1270
+ let refreshRepullRunning = false;
1271
+ let refreshRepullRerunRequested = false;
1272
+ const runRefreshRepullPool = async () => {
1273
+ do {
1274
+ refreshRepullRerunRequested = false;
1275
+ const toRepull = state.workspaceDiagnosticsSupport.mode === "pull"
1276
+ ? [...state.openDocuments]
1277
+ : [];
1278
+ if (toRepull.length > 0) {
1279
+ try {
1280
+ await mapWithConcurrency(toRepull, REFRESH_REPULL_CONCURRENCY, async (normalizedPath) => {
1281
+ const uri = state.openDocumentUris?.get(normalizedPath);
1282
+ const filePath = uri ? uriToPath(uri) : normalizedPath;
1283
+ await clientRequestPullDiagnostics(state, filePath);
1284
+ });
1285
+ }
1286
+ catch (err) {
1287
+ // #1669 review N2: rejections are observed, never
1288
+ // `void`-swallowed — `clientRequestPullDiagnostics` itself never
1289
+ // throws (it returns an `unavailable` outcome), so this only
1290
+ // fires on a genuine programming error, but a silent throw
1291
+ // inside `mapWithConcurrency`'s `Promise.all` must not vanish.
1292
+ incrementDegradationCount({
1293
+ kind: "lsp-pull-unconfirmed",
1294
+ subject: state.serverId,
1295
+ reason: `refresh re-pull threw: ${err instanceof Error ? err.message : String(err)}`,
1296
+ });
1297
+ }
1298
+ }
1299
+ // A refresh that arrived WHILE this pool was running gets exactly
1300
+ // one more pass, never a pass per refresh.
1301
+ } while (refreshRepullRerunRequested);
1302
+ refreshRepullRunning = false;
1303
+ };
1304
+ state.connection.onRequest("workspace/diagnostic/refresh", async () => {
1305
+ state.workspacePullResultCache.clear();
1306
+ // #1669 review F1: clear every on-disk sweep cache this process has
1307
+ // recorded a cwd for — `state.root` alone is a per-SERVER identity
1308
+ // marker that a monorepo/multi-root project routinely nests BELOW the
1309
+ // real sweep cwd, so clearing ONLY there would miss the cache file the
1310
+ // sweep actually reads/writes. See the doc comment on
1311
+ // `clearAllWorkspaceDiagnosticsCaches`.
1312
+ clearAllWorkspaceDiagnosticsCaches();
1313
+ // #1669 review N3: ALSO clear at `state.root` directly. The registry
1314
+ // above only knows cwds a sweep has already run under IN THIS PROCESS —
1315
+ // a refresh arriving before this project's first sweep this process
1316
+ // (e.g. right after a respawn) would otherwise clear nothing at all,
1317
+ // and a later sweep would load the untouched, now-genuinely-stale
1318
+ // on-disk cache a PRIOR process/session left behind. `state.root` is
1319
+ // the one cwd this handler can always reach regardless of registry
1320
+ // state — a cheap, durable backstop for the common single-root case
1321
+ // where root and sweep cwd coincide. It does NOT cover a monorepo
1322
+ // subproject a sweep hasn't reached yet under a DIFFERENT cwd than
1323
+ // `state.root` — that residual gap is tracked in #1707, not silently
1324
+ // claimed as solved (see `clearAllWorkspaceDiagnosticsCaches`'s doc
1325
+ // comment for the full shape).
1326
+ clearWorkspaceDiagnosticsCacheAtAndAbove(state.root);
1327
+ // #1669 review F3: a server-initiated "everything may be stale" signal
1328
+ // must drop the SAME per-document state a normal resync already drops
1329
+ // via `clearDiagnosticsForPath` (pullResultIds, pushDiagnostics,
1330
+ // documentPullDiagnostics, diagnosticBindings,
1331
+ // diagnosticsVersionsByPath, ...) — dropping only
1332
+ // `workspacePullResultCache` above left every open document's OTHER
1333
+ // pull state intact, so the next pull would echo a disowned
1334
+ // `previousResultId`, get back `kind: "unchanged"`, and re-confirm
1335
+ // stale diagnostics under a fresh timestamp; nothing would ever
1336
+ // re-pull on its own. This clear is cheap and always runs — every
1337
+ // refresh in a burst gets its own, unlike the re-pull pool below.
1338
+ for (const normalizedPath of state.openDocuments) {
1339
+ clearDiagnosticsForPath(state, normalizedPath);
1340
+ }
1341
+ // #1669 review N2: reply to the PROTOCOL request now — every clear
1342
+ // above is synchronous and already complete, so there is nothing left
1343
+ // this reply should wait on. The re-pull below is a background
1344
+ // improvement (so an open document reflects the refresh without
1345
+ // waiting on its next edit), never something worth delaying — or
1346
+ // risking — the required `null` reply over.
1347
+ if (state.workspaceDiagnosticsSupport.mode === "pull" &&
1348
+ state.openDocuments.size > 0) {
1349
+ if (refreshRepullRunning) {
1350
+ // #1669 review R1: a pool is already in flight from an earlier
1351
+ // refresh in this burst — coalesce instead of starting a second
1352
+ // one.
1353
+ refreshRepullRerunRequested = true;
1354
+ }
1355
+ else {
1356
+ refreshRepullRunning = true;
1357
+ // `setImmediate` defers even the SYNCHRONOUS prefix of the pool
1358
+ // (URI lookups, map bookkeeping) to the next tick, so it can
1359
+ // never compete with this reply for the current one regardless
1360
+ // of how many documents are open.
1361
+ setImmediate(() => {
1362
+ void runRefreshRepullPool();
1363
+ });
1364
+ }
1365
+ }
1366
+ return null;
1367
+ });
795
1368
  }
796
1369
  /**
797
1370
  * #1127: record the FIRST moment this client observed its own death. Detection
@@ -849,27 +1422,196 @@ function setupConnectionLifecycle(state, recentStderr) {
849
1422
  });
850
1423
  }
851
1424
  });
1425
+ // #1969: the exit CAUSE, in the degradation ledger.
1426
+ //
1427
+ // The `exit` log above is one latency.log line per death. That line is
1428
+ // necessary but not sufficient: an ast-grep child died 14 times in one day
1429
+ // with `code=1` and empty stderr, and the only thing visible downstream was
1430
+ // 19 `lsp_client_skipped_broken` cooldowns and 32 coverage gaps — fallout
1431
+ // with no cause attached. The ledger is what a session-end health read
1432
+ // looks at, so the cause belongs there too, counted per server.
1433
+ //
1434
+ // `close` rather than `exit`: `close` fires only after the child's stdio
1435
+ // streams have drained, so "stderr was empty" is a statement about the
1436
+ // server rather than a race against the pipe. `exit` can arrive first with
1437
+ // stderr still buffered.
1438
+ //
1439
+ // Gated on `shutdownRequested` exactly as the exit log is — an eviction or
1440
+ // an ordinary teardown is not a degradation and must not be recorded.
1441
+ // `incrementDegradationCount` counts every occurrence and retains the
1442
+ // latest reason per server, so a server dying in a loop shows as one entry
1443
+ // with an honest count rather than N entries.
1444
+ state.lspProcess.process.on("close", (code, signal) => {
1445
+ if (state.shutdownRequested)
1446
+ return;
1447
+ try {
1448
+ const stderrTail = recentStderr(20);
1449
+ incrementDegradationCount({
1450
+ kind: "lsp-server-unexpected-close",
1451
+ subject: state.serverId,
1452
+ reason: `closed unprompted: code=${code ?? "none"} signal=${signal ?? "none"} ` +
1453
+ `stderr=${stderrTail.length > 0 ? `present(${stderrTail.length}b)` : "empty"} root=${state.root}`,
1454
+ });
1455
+ }
1456
+ catch {
1457
+ // Telemetry must never break the observed path.
1458
+ }
1459
+ });
852
1460
  }
1461
+ /** Margin between each source's own request timeout and the race's backstop
1462
+ * deadline, so the natural "all sources settled" path wins over the backstop
1463
+ * and the race never reports `unavailable` for sources that did answer. */
1464
+ const PULL_RACE_BACKSTOP_MARGIN_MS = 25;
1465
+ /**
1466
+ * #1667: pull EVERY registered diagnostic source for a file, in parallel, and
1467
+ * answer as soon as the first source returns findings for that file.
1468
+ *
1469
+ * Roslyn, vtsls and some Java setups register `textDocument/diagnostic` once
1470
+ * per source (syntax, semantic, analyzers), each named by
1471
+ * `registerOptions.identifier`. One bare request asks only the server's default
1472
+ * source, so whole categories of findings were never requested. The fan-out
1473
+ * asks all of them plus the bare request.
1474
+ *
1475
+ * Timing is deliberate and load-bearing:
1476
+ * - PARALLEL, never one source after another. Sequencing would add every
1477
+ * slow source's latency to every touch.
1478
+ * - The race resolves on the FIRST source with findings for the file, with no
1479
+ * settle or debounce window after the match. A post-match wait is the exact
1480
+ * latency shape #1112/#1407 already cost this codebase once.
1481
+ * - Losing sources are NOT cancelled. Each source writes its own findings into
1482
+ * the pull cache before it resolves, so a late answer merges in the
1483
+ * background and the NEXT read of the file sees it. Late answers are not
1484
+ * dropped, they are just not waited for.
1485
+ *
1486
+ * The race runs inside the existing `raceToCompletion` primitive rather than a
1487
+ * second first-wins helper.
1488
+ */
853
1489
  async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
854
1490
  if (!isClientAlive(state))
855
1491
  return { status: "unavailable" };
856
- const uri = pathToFileURL(filePath).href;
1492
+ const sources = pullDiagnosticSources(state);
1493
+ if (sources.length === 1) {
1494
+ // The overwhelmingly common case: no named sources registered. Skip the
1495
+ // race entirely so a single-source server pays nothing for this feature.
1496
+ return aggregatePullOutcomes(state, filePath, [
1497
+ await pullDiagnosticSource(state, filePath, budgetMs, undefined),
1498
+ ]);
1499
+ }
1500
+ const attempts = sources.map((identifier) => pullDiagnosticSource(state, filePath, budgetMs, identifier));
1501
+ const settled = await raceToCompletion(attempts, (results) => results.some((r) => r.status === "found" && r.primaryCount > 0), {
1502
+ // No graceMs: finalize the instant a source answers for this file.
1503
+ // The per-request `withTimeout` inside each attempt is the real bound;
1504
+ // this deadline is only a backstop, so it sits just past it.
1505
+ timeoutMs: Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)) +
1506
+ PULL_RACE_BACKSTOP_MARGIN_MS,
1507
+ });
1508
+ return aggregatePullOutcomes(state, filePath, settled);
1509
+ }
1510
+ /**
1511
+ * Collapse the sources that answered into one outcome for the caller.
1512
+ * `found` beats `clean` beats `unavailable`: a source with findings is an
1513
+ * answer, an authoritative empty report is an answer, and only "nobody
1514
+ * answered" is unavailable (#240 — an empty result must distinguish clean
1515
+ * from errored).
1516
+ */
1517
+ function aggregatePullOutcomes(state, filePath, outcomes) {
1518
+ const found = outcomes.filter((o) => o.status === "found");
1519
+ if (found.length > 0) {
1520
+ const normalizedPath = normalizeMapKey(filePath);
1521
+ // The union for the requested file, which is what a caller reading the
1522
+ // cache will see. A source that only reported `relatedDocuments` findings
1523
+ // contributes nothing there, so fall back to its own count.
1524
+ const unionCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
1525
+ return {
1526
+ status: "found",
1527
+ count: Math.max(unionCount, ...found.map((o) => o.count)),
1528
+ };
1529
+ }
1530
+ // #240, applied across sources: `clean` is an authoritative "this file has no
1531
+ // findings", and one source saying so proves nothing about a source that
1532
+ // FAILED to answer. A fan-out where any source errored is therefore
1533
+ // unavailable, not clean - the caller falls through to the push-wait/timeout
1534
+ // backstop instead of recording a confirmed-clean touch it cannot support.
1535
+ // (`raceToCompletion` drops still-pending promises from its result array, so
1536
+ // "every source settled, none errored" is the only shape that reads clean.)
1537
+ if (outcomes.some((o) => o.status === "unavailable")) {
1538
+ return { status: "unavailable" };
1539
+ }
1540
+ return outcomes.some((o) => o.status === "clean")
1541
+ ? { status: "clean" }
1542
+ : { status: "unavailable" };
1543
+ }
1544
+ async function pullDiagnosticSource(state, filePath, budgetMs, identifier) {
1545
+ if (!isClientAlive(state))
1546
+ return { status: "unavailable" };
857
1547
  const normalizedPath = normalizeMapKey(filePath);
1548
+ // #1773: a budget at or below the usable floor cannot complete a real
1549
+ // round trip. Skip the dispatch entirely — no `safeSendRequest` call, no
1550
+ // generation/sequence claim — and say so with a distinct record, so
1551
+ // `lsp_pull_diagnostic_timeout` only ever means a pull was genuinely
1552
+ // attempted. The caller sees the same `unavailable` outcome a timeout
1553
+ // would have produced (#240: unavailable, never a false clean), so
1554
+ // nothing downstream of this call changes shape.
1555
+ if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
1556
+ emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${pullSourceKey(normalizedPath, identifier)}`, {
1557
+ type: "phase",
1558
+ filePath: normalizedPath,
1559
+ durationMs: 0,
1560
+ metadata: {
1561
+ identifier: identifier ?? "bare",
1562
+ remainingBudgetMs: budgetMs,
1563
+ server: state.serverId,
1564
+ },
1565
+ }, {
1566
+ ledgerKind: "lsp-pull-skipped-budget-exhausted",
1567
+ reason: `pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
1568
+ });
1569
+ return { status: "unavailable" };
1570
+ }
1571
+ const uri = pathToFileURL(filePath).href;
858
1572
  // #1104: echo the last resultId we hold for this document so a server that
859
1573
  // hasn't changed its view can answer `kind: "unchanged"` instead of
860
1574
  // recomputing — see the `kind === "unchanged"` branch below for how that's
861
1575
  // honored (inherit, never treat an omitted `items` as clean).
862
- const previousResultId = state.pullResultIds.get(normalizedPath);
1576
+ // #1667: read THIS source's id, never another source's. Result ids are
1577
+ // issued per source, so echoing a syntax id on a semantic request asks the
1578
+ // server to compare against a basis it never handed out.
1579
+ const sourceKey = pullSourceKey(normalizedPath, identifier);
1580
+ if (state.abandonedPullRequests?.has(sourceKey)) {
1581
+ return { status: "unavailable" };
1582
+ }
1583
+ const previousResultId = state.pullResultIds.get(sourceKey);
1584
+ // #1667: the generation this pull was computed against. A resync landing
1585
+ // while the request is in flight bumps it, and every write below is dropped
1586
+ // rather than resurrecting state the resync cleared.
1587
+ const generation = pullGenerationFor(state, normalizedPath);
1588
+ // #1667: claim the newest-request slot for THIS source. Two fan-outs can
1589
+ // overlap for the same file with no resync between them (`ensureWarmForSweep`
1590
+ // touches, then the real touch follows ~60ms later), so the generation check
1591
+ // alone would let a slow answer from the earlier round land on top of a newer
1592
+ // one. Re-checked at write time below.
1593
+ const requestSequence = claimPullRequestSequence(state, sourceKey);
1594
+ // #1889: give the request a cancellation token. `withTimeout` only abandons
1595
+ // its await; aborting in the timeout branch below also sends `$/cancelRequest`
1596
+ // so repeated touches cannot leave an aging backlog inside the server.
1597
+ const requestStartedAt = Date.now();
1598
+ const effectiveTimeoutMs = Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs));
1599
+ const pullAbort = new AbortController();
1600
+ const pullSettlement = { cancelled: false };
1601
+ const requestPromise = safeSendRequest(state.connection, "textDocument/diagnostic", {
1602
+ textDocument: { uri },
1603
+ // #1667: name the source this request is for, so the server answers
1604
+ // from that source instead of its default one.
1605
+ ...(identifier !== undefined && { identifier }),
1606
+ ...(previousResultId !== undefined && { previousResultId }),
1607
+ }, pullAbort.signal, pullSettlement);
863
1608
  try {
864
1609
  // withTimeout is the backstop against a hung pull-mode server: without it
865
1610
  // this await never settles unless the stream is destroyed. Bounded by the
866
1611
  // smaller of the absolute ceiling and the caller's remaining wait budget.
867
1612
  // On timeout the caught error yields `unavailable` below (never a false
868
1613
  // `clean`), so it falls through to the push-wait/timeout backstop.
869
- const report = await withTimeout(safeSendRequest(state.connection, "textDocument/diagnostic", {
870
- textDocument: { uri },
871
- ...(previousResultId !== undefined && { previousResultId }),
872
- }), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
1614
+ const report = await withTimeout(requestPromise, effectiveTimeoutMs);
873
1615
  if (!report) {
874
1616
  recordPullFailure(state, "textDocument/diagnostic", new Error("empty response"));
875
1617
  return { status: "unavailable" };
@@ -882,13 +1624,28 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
882
1624
  // response describes whatever the server had when it answered, which for
883
1625
  // a pi-lens-opened document is exactly that last-sent payload.
884
1626
  const sentHash = state.documentContentHashes.get(normalizedPath)?.hash;
885
- let totalCount;
1627
+ // #1667: two ways this answer can already be obsolete, and neither may
1628
+ // write. Report the round trip as unavailable - a superseded answer is not
1629
+ // evidence the file is clean (#240).
1630
+ // - A resync landed while the request was in flight, so these findings
1631
+ // describe content that no longer exists.
1632
+ // - A LATER request for this same source has already been issued (an
1633
+ // overlapping fan-out), so this answer is the stale loser and would
1634
+ // clobber the newer result.
1635
+ if (pullGenerationFor(state, normalizedPath) !== generation ||
1636
+ !isNewestPullRequest(state, sourceKey, requestSequence)) {
1637
+ return { status: "unavailable" };
1638
+ }
1639
+ let primaryCount;
886
1640
  if (report.kind === "unchanged") {
887
1641
  // #1104: same resultId basis as last time — an omitted `items` here
888
1642
  // means "no change", NOT "clean". Overwriting with `[]` would be the
889
1643
  // exact false-clean shape #570/#571 already fixed for the touch path;
890
1644
  // keep the previously stored diagnostics and binding as-is.
891
- totalCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
1645
+ // #1667: "the previously stored diagnostics" means THIS SOURCE's, so
1646
+ // one source reporting unchanged neither inherits nor disturbs another
1647
+ // source's findings for the same file.
1648
+ primaryCount = pullSourceDiagnostics(state, normalizedPath, identifier).length;
892
1649
  // Still a fresh confirmation as of `now` even though the content is
893
1650
  // unchanged — bump the timestamp so `getAllDiagnostics()` doesn't read
894
1651
  // this entry as aging purely because the server had nothing new to say.
@@ -896,30 +1653,43 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
896
1653
  }
897
1654
  else {
898
1655
  const primaryItems = normalizeLspDiagnostics(report.items ?? []);
899
- state.documentPullDiagnostics.set(normalizedPath, primaryItems);
1656
+ // #1667: replace only this source's slice; `documentPullDiagnostics`
1657
+ // becomes the deduped union of every source that has reported.
1658
+ storePullSourceDiagnostics(state, normalizedPath, identifier, primaryItems);
900
1659
  state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
901
- state.diagnosticsVersion += 1;
1660
+ bumpDiagnosticsVersion(state, normalizedPath);
902
1661
  state.diagnosticBindings.set(normalizedPath, { contentHash: sentHash });
903
- totalCount = primaryItems.length;
1662
+ primaryCount = primaryItems.length;
904
1663
  }
1664
+ let totalCount = primaryCount;
905
1665
  if (report.resultId !== undefined) {
906
- state.pullResultIds.set(normalizedPath, report.resultId);
1666
+ state.pullResultIds.set(sourceKey, report.resultId);
907
1667
  }
908
1668
  else {
909
- state.pullResultIds.delete(normalizedPath);
1669
+ state.pullResultIds.delete(sourceKey);
910
1670
  }
1671
+ // #1531 note (pre-existing, unchanged): a related document's diagnostics are
1672
+ // STORED below but earn no version bump and therefore no per-path stamp —
1673
+ // exactly as before, since this loop never bumped the global counter either.
1674
+ // A wait on a path that only ever hears about itself through some other
1675
+ // document's `relatedDocuments` sees no freshness evidence and rides its
1676
+ // timeout. Direction is under-detection, and correcting it means deciding
1677
+ // what a related-document publication is evidence OF (its binding is
1678
+ // honestly "unknown" per #1104), so it stays out of scope here.
911
1679
  if (report.relatedDocuments) {
912
1680
  for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
913
1681
  const relatedPath = uriToPath(relatedUri);
914
1682
  const relatedNormalized = normalizeMapKey(relatedPath);
1683
+ // #1667: a related document's report belongs to the SAME source as
1684
+ // the request that carried it, so it stores and inherits under that
1685
+ // source's key too.
915
1686
  if (related?.kind === "unchanged") {
916
- totalCount +=
917
- state.documentPullDiagnostics.get(relatedNormalized)?.length ?? 0;
1687
+ totalCount += pullSourceDiagnostics(state, relatedNormalized, identifier).length;
918
1688
  state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
919
1689
  }
920
1690
  else {
921
1691
  const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
922
- state.documentPullDiagnostics.set(relatedNormalized, relatedItems);
1692
+ storePullSourceDiagnostics(state, relatedNormalized, identifier, relatedItems);
923
1693
  state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
924
1694
  // #1104: a related document's diagnostics were NOT computed against
925
1695
  // content we independently sent/fingerprinted (we never requested
@@ -929,20 +1699,186 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
929
1699
  totalCount += relatedItems.length;
930
1700
  }
931
1701
  if (related?.resultId !== undefined) {
932
- state.pullResultIds.set(relatedNormalized, related.resultId);
1702
+ state.pullResultIds.set(pullSourceKey(relatedNormalized, identifier), related.resultId);
933
1703
  }
934
1704
  }
935
1705
  }
936
1706
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
937
1707
  return totalCount > 0
938
- ? { status: "found", count: totalCount }
1708
+ ? { status: "found", count: totalCount, primaryCount }
939
1709
  : { status: "clean" };
940
1710
  }
941
1711
  catch (err) {
1712
+ if (isPullTimeoutError(err, effectiveTimeoutMs)) {
1713
+ trackAbandonedPullRequest(state, sourceKey, requestPromise);
1714
+ pullAbort.abort();
1715
+ recordPullTimeoutTelemetry({
1716
+ scope: normalizedPath,
1717
+ identifier,
1718
+ effectiveBudgetMs: effectiveTimeoutMs,
1719
+ hadPreviousResultId: previousResultId !== undefined,
1720
+ elapsedMs: Date.now() - requestStartedAt,
1721
+ server: state.serverId,
1722
+ });
1723
+ armLateAnswerTelemetry({
1724
+ requestPromise,
1725
+ scope: normalizedPath,
1726
+ identifier,
1727
+ subject: sourceKey,
1728
+ requestStartedAt,
1729
+ server: state.serverId,
1730
+ settlement: pullSettlement,
1731
+ });
1732
+ }
942
1733
  recordPullFailure(state, "textDocument/diagnostic", err);
943
1734
  return { status: "unavailable" };
944
1735
  }
945
1736
  }
1737
+ function trackAbandonedPullRequest(state, sourceKey, requestPromise) {
1738
+ if (!state.abandonedPullRequests)
1739
+ state.abandonedPullRequests = new Map();
1740
+ state.abandonedPullRequests.set(sourceKey, requestPromise);
1741
+ const release = () => {
1742
+ if (state.abandonedPullRequests?.get(sourceKey) === requestPromise) {
1743
+ state.abandonedPullRequests.delete(sourceKey);
1744
+ }
1745
+ };
1746
+ requestPromise.then(release, release);
1747
+ }
1748
+ /**
1749
+ * #1713: `withTimeout` rejects with this EXACT message (`clients/deadline-utils.ts`)
1750
+ * only when its own timer wins the race — every other rejection is the request
1751
+ * itself failing (bad params, dead connection, thrown by the server). Only the
1752
+ * timer-won case is a "the answer might still be coming" situation worth a
1753
+ * late-answer watch; a request that already failed fast has nothing further to
1754
+ * observe.
1755
+ */
1756
+ function isPullTimeoutError(err, timeoutMs) {
1757
+ return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
1758
+ }
1759
+ /**
1760
+ * #1713: every pull timeout emits exactly one bounded latency.log record — the
1761
+ * observability gap the issue names (`withTimeout`'s throw previously skipped
1762
+ * ALL telemetry for this site). Never throws: telemetry must not perturb the
1763
+ * timeout path, which has already decided to return `unavailable` regardless.
1764
+ *
1765
+ * #1743: routed through `emitBounded`. This phase takes NO rising-edge gate
1766
+ * and NO per-turn cap, which is the pre-existing behavior kept exactly: a
1767
+ * pull timeout costs at least the full budget (seconds), so the call cadence
1768
+ * already bounds the volume, unlike `navRequest` below. The helper still
1769
+ * supplies the identity discipline and the registry membership the sweep
1770
+ * checks.
1771
+ */
1772
+ function recordPullTimeoutTelemetry(args) {
1773
+ // #1771: this record is written on a genuine failure path (a dispatched
1774
+ // pull that never answered), so per the bounded-telemetry rule
1775
+ // (`clients/bounded-telemetry.ts`) it needs a `ledgerKind`, not just the
1776
+ // detailed latency.log record. Before this it counted nothing in the
1777
+ // degradation ledger — every occurrence wrote a detail line, but nothing
1778
+ // tallied. No `risingEdgePer`: timeouts repeat and every one should count,
1779
+ // matching the pre-existing "no rising-edge gate" behavior for the
1780
+ // detailed record itself.
1781
+ emitBounded("lsp_pull_diagnostic_timeout", `${args.server}::${args.scope}::${args.identifier ?? "bare"}`, {
1782
+ type: "phase",
1783
+ filePath: args.scope,
1784
+ durationMs: args.elapsedMs,
1785
+ metadata: {
1786
+ identifier: args.identifier ?? "bare",
1787
+ effectiveBudgetMs: args.effectiveBudgetMs,
1788
+ hadPreviousResultId: args.hadPreviousResultId,
1789
+ server: args.server,
1790
+ },
1791
+ }, {
1792
+ ledgerKind: "lsp-pull-diagnostic-timeout",
1793
+ reason: `pull timeout on ${args.server} after ${args.elapsedMs}ms (budget ${args.effectiveBudgetMs}ms)`,
1794
+ });
1795
+ }
1796
+ /**
1797
+ * #1713: a telemetry-only continuation on the ALREADY-abandoned request
1798
+ * promise. `withTimeout` only raced it against a timer — the request is still
1799
+ * live server-side — so if it eventually resolves, this is the only place that
1800
+ * ever finds out. Purely observational: it runs after the caller has already
1801
+ * moved on with `unavailable`, and it never writes into `state`.
1802
+ *
1803
+ * Bounded via the degradation-ledger convention (`incrementDegradationCount`,
1804
+ * chosen over a hand-rolled per-identity rising-edge Set): the ledger already
1805
+ * caps entries per kind and re-arms at `session_start`
1806
+ * (`resetDegradationLedger`, wired into `handleSessionStart`), so reusing it
1807
+ * avoids a second latch this fix would otherwise have to register and reset
1808
+ * itself. `subject` is the same `pullSourceKey` (path + identifier) the pull
1809
+ * path already uses, so the discriminating identity survives aggregation.
1810
+ *
1811
+ * Attaching `.then` here creates no timer, socket, or other handle — it only
1812
+ * observes a promise the request already created — so it cannot keep the
1813
+ * process alive a moment longer than that pending request already does.
1814
+ */
1815
+ function armLateAnswerTelemetry(args) {
1816
+ args.requestPromise.then(() => {
1817
+ if (args.settlement.cancelled)
1818
+ return;
1819
+ try {
1820
+ const elapsedMs = Date.now() - args.requestStartedAt;
1821
+ // #1743: `emitBounded` counts every late answer in the ledger
1822
+ // under the same `pullSourceKey` subject as before, then writes
1823
+ // the record. No rising-edge gate, matching #1713's original
1824
+ // behavior: a late answer arrives at most once per abandoned
1825
+ // request, so the record count cannot exceed the timeout count.
1826
+ emitBounded("lsp_pull_late_answer_discarded", args.subject, {
1827
+ type: "phase",
1828
+ filePath: args.scope,
1829
+ durationMs: elapsedMs,
1830
+ metadata: { identifier: args.identifier ?? "bare" },
1831
+ }, {
1832
+ ledgerKind: "lsp-pull-late-answer",
1833
+ reason: `late pull answer discarded after ${elapsedMs}ms`,
1834
+ });
1835
+ }
1836
+ catch {
1837
+ // Telemetry must never break the observed path.
1838
+ }
1839
+ }, (err) => {
1840
+ // #1774: the abandoned request eventually REJECTED rather than
1841
+ // answering. Behavior is unchanged — the rejection is still
1842
+ // swallowed here, exactly as before — but "timeout then silence"
1843
+ // and "timeout then server rejection (e.g. ContentModified)" were
1844
+ // previously indistinguishable: `recordPullFailure` at request
1845
+ // time reports the FIRST failure only, and this rejection happens
1846
+ // strictly after that request already timed out, so it needs its
1847
+ // own record. Bounded via the degradation ledger, same shape as
1848
+ // the late-answer branch above, so this rejection cannot outpace
1849
+ // the timeout count that gates it.
1850
+ try {
1851
+ const elapsedMs = Date.now() - args.requestStartedAt;
1852
+ const candidate = err;
1853
+ const code = typeof candidate?.code === "number" ||
1854
+ typeof candidate?.code === "string"
1855
+ ? candidate.code
1856
+ : undefined;
1857
+ emitBounded("lsp_pull_late_rejection",
1858
+ // #1774 review: prefix the server, same reason the timeout kind
1859
+ // does (#1771). The workspace call site's `subject` is the bare
1860
+ // `WORKSPACE_PULL_SCOPE` constant — without the server prefix,
1861
+ // two servers' abandoned-workspace-pull rejections would collapse
1862
+ // into one ledger subject and hide which server is storming.
1863
+ `${args.server}::${args.subject}`, {
1864
+ type: "phase",
1865
+ filePath: args.scope,
1866
+ durationMs: elapsedMs,
1867
+ metadata: {
1868
+ identifier: args.identifier ?? "bare",
1869
+ server: args.server,
1870
+ ...(code !== undefined && { code }),
1871
+ },
1872
+ }, {
1873
+ ledgerKind: "lsp-pull-late-rejection",
1874
+ reason: `late pull rejection ${elapsedMs}ms after timeout${code !== undefined ? ` (code ${code})` : ""}`,
1875
+ });
1876
+ }
1877
+ catch {
1878
+ // Telemetry must never break the observed path.
1879
+ }
1880
+ });
1881
+ }
946
1882
  const PULL_FAILURE_HISTORY_LIMIT = 10;
947
1883
  function recordPullFailure(state, method, error) {
948
1884
  const candidate = error;
@@ -958,9 +1894,7 @@ function recordPullFailure(state, method, error) {
958
1894
  ...(typeof candidate.code === "number" || typeof candidate.code === "string"
959
1895
  ? { code: candidate.code }
960
1896
  : {}),
961
- message: typeof candidate.message === "string"
962
- ? candidate.message
963
- : String(error),
1897
+ message: typeof candidate.message === "string" ? candidate.message : String(error),
964
1898
  });
965
1899
  if (state.pullFailureHistory.length > PULL_FAILURE_HISTORY_LIMIT) {
966
1900
  state.pullFailureHistory.splice(0, state.pullFailureHistory.length - PULL_FAILURE_HISTORY_LIMIT);
@@ -978,15 +1912,58 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
978
1912
  return undefined;
979
1913
  if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
980
1914
  return undefined;
1915
+ if (state.abandonedPullRequests?.has(WORKSPACE_PULL_SCOPE))
1916
+ return undefined;
1917
+ // #1104: echo every resultId we hold from a PRIOR pull so the server can
1918
+ // answer `kind: "unchanged"` for files it hasn't recomputed, instead of
1919
+ // resending (and us re-hashing) every file on every sweep.
1920
+ const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
1921
+ // #1773: same floor as `pullDiagnosticSource` — an exhausted budget here is
1922
+ // the SAME clamp-then-dispatch defect via a second entry point. Observed
1923
+ // live in the 2026-08-20 plegma dogfood session via
1924
+ // `lsp_workspace_diagnostics_start` (not just the `lens_diagnostics_full`
1925
+ // fan-out `pullDiagnosticSource` covers), so this call site needs its own
1926
+ // skip, not just a shared helper the caller happens to hit.
1927
+ if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
1928
+ emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${WORKSPACE_PULL_SCOPE}`, {
1929
+ type: "phase",
1930
+ filePath: WORKSPACE_PULL_SCOPE,
1931
+ durationMs: 0,
1932
+ metadata: {
1933
+ identifier: "bare",
1934
+ remainingBudgetMs: budgetMs,
1935
+ server: state.serverId,
1936
+ },
1937
+ }, {
1938
+ ledgerKind: "lsp-pull-skipped-budget-exhausted",
1939
+ reason: `workspace pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
1940
+ });
1941
+ return undefined;
1942
+ }
1943
+ // #1713: declared OUTSIDE the try so the catch below can still reach them —
1944
+ // same shape as `pullDiagnosticSource` above. Captured outside `withTimeout`
1945
+ // so a timeout still holds a live handle on the request for the late-answer
1946
+ // watch.
1947
+ const workspacePullStartedAt = Date.now();
1948
+ const workspacePullTimeoutMs = Math.max(1, budgetMs);
1949
+ const workspacePullAbort = new AbortController();
1950
+ const workspacePullSettlement = { cancelled: false };
1951
+ const workspaceRequestPromise = safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }, workspacePullAbort.signal, workspacePullSettlement);
981
1952
  try {
982
- // #1104: echo every resultId we hold from a PRIOR pull so the server can
983
- // answer `kind: "unchanged"` for files it hasn't recomputed, instead of
984
- // resending (and us re-hashing) every file on every sweep.
985
- const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
986
- const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }), Math.max(1, budgetMs));
1953
+ const report = await withTimeout(workspaceRequestPromise, workspacePullTimeoutMs);
987
1954
  if (!report || !Array.isArray(report.items))
988
1955
  return undefined;
989
1956
  const out = [];
1957
+ // #1669 review N1: ONE deadline, shared across every "unchanged, no
1958
+ // cached basis" fallback pull this call makes — not a fresh `budgetMs`
1959
+ // grant handed to EACH one. A refresh clears `workspacePullResultCache`,
1960
+ // so the very next sweep after a refresh can route every item down this
1961
+ // path; granting each one the full budget serially turned a bounded call
1962
+ // unbounded (measured: 6 files x 300ms budget = 1844ms; worst case is
1963
+ // the normal case — 2000 files x 30s). Mirrors the shared-budget shape
1964
+ // `runWorkspaceDiagnosticsSwept`'s `perFileMs` bounds each file to,
1965
+ // scoped to fallback pulls made from within THIS one call.
1966
+ const fallbackPullDeadline = Date.now() + Math.max(1, budgetMs);
990
1967
  for (const item of report.items) {
991
1968
  if (!item?.uri)
992
1969
  continue;
@@ -998,8 +1975,53 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
998
1975
  // `items`, so without this a file the server confirmed unchanged
999
1976
  // would silently drop out of the sweep result entirely.
1000
1977
  const prior = state.workspacePullResultCache.get(normalizedPath);
1001
- if (!prior)
1002
- continue; // no earlier basis to inherit nothing to report
1978
+ if (!prior) {
1979
+ // #1669 review F4: no earlier basis to inherit (e.g. right after a
1980
+ // `workspace/diagnostic/refresh` cleared `workspacePullResultCache`)
1981
+ // — the server is answering "unchanged" against a resultId basis we
1982
+ // no longer hold, so there is nothing honest to report for this
1983
+ // file from THIS response. `continue`-ing here would make the file
1984
+ // silently absent from `out`, which the caller reads as "clean" —
1985
+ // a false clean for a file the server just told us it hasn't even
1986
+ // looked at freshly. Treat it as needs-full-pull: request the
1987
+ // per-file diagnostic directly instead.
1988
+ const remainingMs = fallbackPullDeadline - Date.now();
1989
+ if (remainingMs <= 0) {
1990
+ // #1669 review N1: the shared fallback-pull deadline is already
1991
+ // exhausted by earlier files in this same loop — bail WITHOUT
1992
+ // attempting a request (never fabricate clean; this file was
1993
+ // never actually asked about) and surface it as unavailable via
1994
+ // the same degradation ledger path as a real unavailable pull.
1995
+ incrementDegradationCount({
1996
+ kind: "lsp-pull-unconfirmed",
1997
+ subject: state.serverId,
1998
+ reason: "unchanged report with no workspacePullResultCache basis; the shared fallback-pull deadline was already exhausted by earlier files this sweep",
1999
+ });
2000
+ continue;
2001
+ }
2002
+ const outcome = await clientRequestPullDiagnostics(state, filePath, remainingMs);
2003
+ if (outcome.status === "found") {
2004
+ out.push({
2005
+ filePath,
2006
+ diagnostics: state.documentPullDiagnostics.get(normalizedPath) ?? [],
2007
+ contentHash: state.diagnosticBindings.get(normalizedPath)?.contentHash,
2008
+ });
2009
+ }
2010
+ else if (outcome.status === "unavailable") {
2011
+ // Genuinely unconfirmed — never fabricate a clean result. Recorded
2012
+ // via the shared degradation ledger (AGENTS.md: repeated
2013
+ // degradations use incrementDegradationCount, not a hand-rolled
2014
+ // counter) so a server that keeps racing refresh against sweeps
2015
+ // is visible in aggregate instead of silently absorbed per-file.
2016
+ incrementDegradationCount({
2017
+ kind: "lsp-pull-unconfirmed",
2018
+ subject: state.serverId,
2019
+ reason: "unchanged report with no workspacePullResultCache basis, and the per-file fallback pull was unavailable",
2020
+ });
2021
+ }
2022
+ // "clean": genuinely clean, correctly absent from `out`.
2023
+ continue;
2024
+ }
1003
2025
  if (item.resultId !== undefined) {
1004
2026
  state.workspacePullResultCache.set(normalizedPath, {
1005
2027
  ...prior,
@@ -1045,6 +2067,27 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
1045
2067
  return out;
1046
2068
  }
1047
2069
  catch (err) {
2070
+ if (isPullTimeoutError(err, workspacePullTimeoutMs)) {
2071
+ trackAbandonedPullRequest(state, WORKSPACE_PULL_SCOPE, workspaceRequestPromise);
2072
+ workspacePullAbort.abort();
2073
+ recordPullTimeoutTelemetry({
2074
+ scope: WORKSPACE_PULL_SCOPE,
2075
+ identifier: undefined,
2076
+ effectiveBudgetMs: workspacePullTimeoutMs,
2077
+ hadPreviousResultId: previousResultIds.length > 0,
2078
+ elapsedMs: Date.now() - workspacePullStartedAt,
2079
+ server: state.serverId,
2080
+ });
2081
+ armLateAnswerTelemetry({
2082
+ requestPromise: workspaceRequestPromise,
2083
+ scope: WORKSPACE_PULL_SCOPE,
2084
+ identifier: undefined,
2085
+ subject: WORKSPACE_PULL_SCOPE,
2086
+ requestStartedAt: workspacePullStartedAt,
2087
+ server: state.serverId,
2088
+ settlement: workspacePullSettlement,
2089
+ });
2090
+ }
1048
2091
  recordPullFailure(state, "workspace/diagnostic", err);
1049
2092
  return undefined;
1050
2093
  }
@@ -1052,7 +2095,17 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
1052
2095
  export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
1053
2096
  const normalizedPath = normalizeMapKey(filePath);
1054
2097
  const minVersion = options.minVersion;
1055
- const hasFreshDiagnostics = () => minVersion === undefined || state.diagnosticsVersion > minVersion;
2098
+ const pullSettleSource = options.pullSettleSource ?? "pull";
2099
+ // #1531: the freshness gate is PER PATH. `minVersion` is a reading of the
2100
+ // client-global counter, and `diagnosticsVersionsByPath` stores that same
2101
+ // counter's value at each store — the two are on one axis, so comparing them
2102
+ // asks "has a publication landed for THIS file since the baseline?" instead of
2103
+ // "has anything at all landed on this client?". Reading the global counter here
2104
+ // let a sibling file's publication end this file's wait before its own budget
2105
+ // lapsed, which then labelled the outcome row `silent` (reserved for "own
2106
+ // budget lapsed with nothing published") instead of `cut_off`.
2107
+ const hasFreshDiagnostics = () => minVersion === undefined ||
2108
+ diagnosticsVersionForPath(state, normalizedPath) > minVersion;
1056
2109
  // Version coherence: a cached push is "stale" only when the server reported
1057
2110
  // the document version it computed against AND that version lags the latest
1058
2111
  // didChange we sent. This prevents serving diagnostics from a superseded
@@ -1076,9 +2129,13 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1076
2129
  // `hasFreshDiagnostics()`, which is unconditionally true when there is no
1077
2130
  // version baseline (`minVersion === undefined`), so a failed pull returned
1078
2131
  // 0 and was read as a fresh clean.
2132
+ // #1639: the settle operation's OWN clock — durationMs on the eventual
2133
+ // log record measures from here, never from didOpen (that stays in
2134
+ // metadata.elapsedSinceDidOpenMs, computed separately below).
2135
+ const pullSettleStartedAt = Date.now();
1079
2136
  let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
1080
2137
  if (outcome.status === "found") {
1081
- logTypeScriptPullSettle(state, normalizedPath);
2138
+ logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
1082
2139
  return;
1083
2140
  }
1084
2141
  let sawClean = outcome.status === "clean";
@@ -1099,12 +2156,12 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1099
2156
  }
1100
2157
  if (options.pullOnly) {
1101
2158
  if (outcome.status === "found" || sawClean) {
1102
- logTypeScriptPullSettle(state, normalizedPath);
2159
+ logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
1103
2160
  }
1104
2161
  return;
1105
2162
  }
1106
2163
  if (outcome.status === "found" || sawClean) {
1107
- logTypeScriptPullSettle(state, normalizedPath);
2164
+ logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
1108
2165
  return;
1109
2166
  }
1110
2167
  }
@@ -1143,6 +2200,21 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1143
2200
  }, timeoutMs);
1144
2201
  });
1145
2202
  }
2203
+ /**
2204
+ * Queue a watched-files change for a file this client did not learn about
2205
+ * through textDocument/didOpen or didChange — an external bash write/delete,
2206
+ * or any other disk change outside the open-document sync path (#1668).
2207
+ * Shares `handleNotifyOpen`'s per-client debounced queue (#271), so a burst
2208
+ * of external changes coalesces into one notification per debounce window
2209
+ * instead of flooding the server with one per file.
2210
+ */
2211
+ export function handleNotifyExternalChange(state, filePath, type) {
2212
+ if (!isClientAlive(state))
2213
+ return;
2214
+ const normalizedPath = normalizeMapKey(filePath);
2215
+ const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2216
+ state.watchQueue.enqueue(uri, type);
2217
+ }
1146
2218
  export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
1147
2219
  if (!isClientAlive(state))
1148
2220
  return;
@@ -1187,19 +2259,21 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1187
2259
  state.diagnosticPublicationCounts.set(normalizedPath, 0);
1188
2260
  if (!isClientAlive(state))
1189
2261
  return;
1190
- await safeSendNotification(state.connection, "textDocument/didOpen", {
1191
- textDocument: { uri, languageId, version, text: content },
1192
- });
1193
- recordSentContent(state, normalizedPath, version, content);
2262
+ const reopenSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version, text: content } });
2263
+ // #1669 review F7: only mirror the send locally once it actually left
2264
+ // the process — see safeSendNotification's doc comment.
2265
+ if (reopenSent)
2266
+ recordSentContent(state, normalizedPath, version, content);
1194
2267
  state.openDocuments.add(normalizedPath);
1195
2268
  state.openDocumentUris?.set(normalizedPath, uri);
1196
2269
  return;
1197
2270
  }
1198
- await safeSendNotification(state.connection, "textDocument/didChange", {
2271
+ const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
1199
2272
  textDocument: { uri, version },
1200
- contentChanges: [{ text: content }],
2273
+ contentChanges: buildContentChanges(state, normalizedPath, content),
1201
2274
  });
1202
- recordSentContent(state, normalizedPath, version, content);
2275
+ if (changeSent)
2276
+ recordSentContent(state, normalizedPath, version, content);
1203
2277
  return;
1204
2278
  }
1205
2279
  state.pendingOpens.add(normalizedPath);
@@ -1232,10 +2306,9 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1232
2306
  }
1233
2307
  if (!isClientAlive(state))
1234
2308
  return;
1235
- await safeSendNotification(state.connection, "textDocument/didOpen", {
1236
- textDocument: { uri, languageId, version: 0, text: content },
1237
- });
1238
- recordSentContent(state, normalizedPath, 0, content);
2309
+ const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
2310
+ if (openSent)
2311
+ recordSentContent(state, normalizedPath, 0, content);
1239
2312
  state.pendingOpens.delete(normalizedPath);
1240
2313
  state.openDocuments.add(normalizedPath);
1241
2314
  state.closedDocuments?.delete(normalizedPath);
@@ -1267,13 +2340,19 @@ export async function handleNotifyChange(state, filePath, content) {
1267
2340
  if (!state.openDocuments.has(normalizedPath)) {
1268
2341
  // Safety fallback: keep protocol ordering valid even if caller sends
1269
2342
  // didChange before first didOpen for this document.
1270
- await safeSendNotification(state.connection, "textDocument/didOpen", {
1271
- textDocument: { uri, languageId: "plaintext", version: 0, text: content },
2343
+ const fallbackOpenSent = await safeSendNotification(state.connection, "textDocument/didOpen", {
2344
+ textDocument: {
2345
+ uri,
2346
+ languageId: "plaintext",
2347
+ version: 0,
2348
+ text: content,
2349
+ },
1272
2350
  });
1273
2351
  state.documentVersions.set(normalizedPath, 0);
1274
2352
  state.documentOpenedAt.set(normalizedPath, Date.now());
1275
2353
  state.diagnosticPublicationCounts.set(normalizedPath, 0);
1276
- recordSentContent(state, normalizedPath, 0, content);
2354
+ if (fallbackOpenSent)
2355
+ recordSentContent(state, normalizedPath, 0, content);
1277
2356
  state.openDocuments.add(normalizedPath);
1278
2357
  state.openDocumentUris?.set(normalizedPath, uri);
1279
2358
  return;
@@ -1283,11 +2362,12 @@ export async function handleNotifyChange(state, filePath, content) {
1283
2362
  // Clear stale diagnostics before sending new content so waitForDiagnostics
1284
2363
  // doesn't return immediately with the previous edit's results.
1285
2364
  clearDiagnosticsForPath(state, normalizedPath);
1286
- await safeSendNotification(state.connection, "textDocument/didChange", {
2365
+ const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
1287
2366
  textDocument: { uri, version },
1288
- contentChanges: [{ text: content }],
2367
+ contentChanges: buildContentChanges(state, normalizedPath, content),
1289
2368
  });
1290
- recordSentContent(state, normalizedPath, version, content);
2369
+ if (changeSent)
2370
+ recordSentContent(state, normalizedPath, version, content);
1291
2371
  }
1292
2372
  /** Close a document through the same lifecycle path exposed by the client. */
1293
2373
  export async function closeDocument(state, filePath) {
@@ -1298,7 +2378,8 @@ export async function closeDocument(state, filePath) {
1298
2378
  return;
1299
2379
  await safeSendNotification(state.connection, "textDocument/didClose", {
1300
2380
  textDocument: {
1301
- uri: state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href,
2381
+ uri: state.openDocumentUris?.get(normalizedPath) ??
2382
+ pathToFileURL(filePath).href,
1302
2383
  },
1303
2384
  });
1304
2385
  state.openDocuments.delete(normalizedPath);
@@ -1314,7 +2395,67 @@ export async function closeDocument(state, filePath) {
1314
2395
  state.projectIdentityProbedFiles?.delete(normalizedPath);
1315
2396
  clearDiagnosticsForPath(state, normalizedPath);
1316
2397
  }
1317
- export async function clientShutdown(state, options = {}) {
2398
+ /**
2399
+ * #1620 residual review F1: `fast` and `processExiting` only ever escalate —
2400
+ * a `true` demands strictly less work (or none) than a `false`, never more.
2401
+ * `existing` covers `requested` when it is at least as demanding on BOTH
2402
+ * axes, so deduping onto it can't silently downgrade what the new caller
2403
+ * needs (e.g. a `processExiting` caller must never inherit a teardown that
2404
+ * might still spawn `taskkill`).
2405
+ */
2406
+ function isAtLeastAsAggressiveShutdown(existing, requested) {
2407
+ return (!!existing.fast >= !!requested.fast &&
2408
+ !!existing.processExiting >= !!requested.processExiting);
2409
+ }
2410
+ export function clientShutdown(state, options = {}) {
2411
+ // #1620 residual 3: idempotent across racing callers — see the doc comment
2412
+ // on `LSPClientState.shutdownPromise`. The FIRST call starts the real
2413
+ // teardown and stores its promise; every subsequent call (any of the 8
2414
+ // call sites, on the same state) whose options are no MORE aggressive
2415
+ // awaits that same promise instead of running the RPC handshake and
2416
+ // emitting `lsp_client_shutdown` again.
2417
+ //
2418
+ // #1620 residual review F1: a call whose options ARE more aggressive does
2419
+ // NOT dedupe — it starts (and takes over as) its own teardown instead.
2420
+ // The concrete failure this closes: `resetLSPService`'s session-exit path
2421
+ // (`index.ts`) calls `shutdown({fast:true, processExiting:true})` — the
2422
+ // event loop is already closing, and `processExiting` exists precisely so
2423
+ // this teardown never spawns `taskkill` (a closing-loop `uv_async_send`
2424
+ // hard-aborts, `src\win\async.c`). If a GRACEFUL teardown (default
2425
+ // options — e.g. `client_ceiling_lru` eviction) is already in flight on
2426
+ // this same client when the exit path arrives, blindly deduping would
2427
+ // make the exit path inherit that graceful run's own `taskkill` spawn and
2428
+ // its close-event wait — the exact hazard `processExiting` forbids, on
2429
+ // top of blocking the closing loop for however long that takes. The
2430
+ // weaker in-flight teardown is left to finish on its own (every step
2431
+ // downstream — dispose, kill, deregister — is already idempotent).
2432
+ if (state.shutdownPromise &&
2433
+ state.shutdownOptions &&
2434
+ isAtLeastAsAggressiveShutdown(state.shutdownOptions, options)) {
2435
+ return state.shutdownPromise;
2436
+ }
2437
+ state.shutdownOptions = options;
2438
+ const attempt = clientShutdownOnce(state, options);
2439
+ state.shutdownPromise = attempt;
2440
+ // #1620 residual review F3: a REJECTED teardown (e.g. `killProcessTree`
2441
+ // throwing something its own internal catches don't cover) must not
2442
+ // latch a permanently-rejected promise here — every later call, even one
2443
+ // with equal or weaker options, would dedupe onto it forever and never
2444
+ // retry, so the child leaks silently with no further kill/dispose/
2445
+ // deregister attempt. Clear the latch on rejection (only if nothing more
2446
+ // aggressive has already superseded it) so the NEXT call starts a fresh
2447
+ // attempt instead of re-handing back the same dead promise. The rejection
2448
+ // itself still propagates to whoever is awaiting `attempt` right now —
2449
+ // this only affects callers that haven't asked yet.
2450
+ attempt.catch(() => {
2451
+ if (state.shutdownPromise === attempt) {
2452
+ state.shutdownPromise = undefined;
2453
+ state.shutdownOptions = undefined;
2454
+ }
2455
+ });
2456
+ return attempt;
2457
+ }
2458
+ async function clientShutdownOnce(state, options) {
1318
2459
  const shutdownStart = Date.now();
1319
2460
  state.shutdownRequested = true;
1320
2461
  state.isConnected = false;
@@ -1334,53 +2475,124 @@ export async function clientShutdown(state, options = {}) {
1334
2475
  state.watchQueue?.cancel();
1335
2476
  state.diagnosticEmitter.removeAllListeners();
1336
2477
  let shutdownRequestTimedOut = false;
1337
- if (!options.fast) {
1338
- try {
1339
- await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
1340
- }
1341
- catch {
1342
- /* ignore proceed to exit/kill so shutdown cannot hang the session */
1343
- shutdownRequestTimedOut = true;
1344
- }
1345
- try {
1346
- await safeSendNotification(state.connection, "exit", {});
1347
- }
1348
- catch {
1349
- /* ignore */
2478
+ let exitNotifyTimedOut = false;
2479
+ // #1620 residual 1 / residual-review F2: a failure that ISN'T the timer
2480
+ // winning was previously folded into the same "*TimedOut" flag as a real
2481
+ // timeout — honest for the rolled-up `shutdownOutcome` (still "forced"
2482
+ // either way), imprecise about WHICH failure happened. Two shapes land
2483
+ // here, both meaning "no confirmation this reached the server":
2484
+ // (a) a genuine promise rejection that isn't the timer (an immediate
2485
+ // protocol error) — reaches the `catch` below.
2486
+ // (b) `safeSendRequest`/`safeSendNotification` SWALLOW a stream error
2487
+ // (EPIPE, disposed connection — `isStreamError`) and RESOLVE
2488
+ // instead of rejecting, so the `catch` never runs at all. A real
2489
+ // `shutdown` reply is `null`, never `undefined`, and a real `exit`
2490
+ // notify send returns `true`; `undefined`/`false` is the swallow
2491
+ // case. Without this, a coded EPIPE reported `shutdownOutcome:
2492
+ // "graceful"` — nothing was delivered, but the log called it clean.
2493
+ let shutdownRequestUndelivered = false;
2494
+ let exitNotifyUndelivered = false;
2495
+ // #1620: the graceful handshake is BEST-EFFORT and the teardown is not. A
2496
+ // teardown path must not depend on the health of the thing it is tearing
2497
+ // down — the breaker fires exactly when the server is unresponsive. Keep the
2498
+ // handshake inside the `try` and every irreversible teardown step in the
2499
+ // `finally`, so disposal, the record, the deregistration, and the kill run no
2500
+ // matter how the two writes behave, and so a future added await here cannot
2501
+ // reintroduce the class.
2502
+ try {
2503
+ if (!options.fast) {
2504
+ try {
2505
+ const shutdownAck = await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
2506
+ if (shutdownAck === undefined)
2507
+ shutdownRequestUndelivered = true;
2508
+ }
2509
+ catch (err) {
2510
+ /* ignore — proceed to exit/kill so shutdown cannot hang the session */
2511
+ if (isShutdownTimeoutError(err, SHUTDOWN_REQUEST_TIMEOUT_MS)) {
2512
+ shutdownRequestTimedOut = true;
2513
+ }
2514
+ else {
2515
+ shutdownRequestUndelivered = true;
2516
+ }
2517
+ }
2518
+ try {
2519
+ const exitSent = await withTimeout(safeSendNotification(state.connection, "exit", {}), EXIT_NOTIFY_TIMEOUT_MS);
2520
+ if (exitSent === false)
2521
+ exitNotifyUndelivered = true;
2522
+ }
2523
+ catch (err) {
2524
+ /* ignore — same reason as the request above */
2525
+ if (isShutdownTimeoutError(err, EXIT_NOTIFY_TIMEOUT_MS)) {
2526
+ exitNotifyTimedOut = true;
2527
+ }
2528
+ else {
2529
+ exitNotifyUndelivered = true;
2530
+ }
2531
+ }
1350
2532
  }
1351
2533
  }
1352
- disposeClientConnection(state);
1353
- const pid = state.lspProcess.pid;
1354
- logLatency({
1355
- type: "phase",
1356
- phase: "lsp_client_shutdown",
1357
- filePath: state.root,
1358
- durationMs: Date.now() - shutdownStart,
1359
- metadata: {
1360
- serverId: state.serverId,
1361
- pid,
1362
- fast: !!options.fast,
1363
- processExiting: !!options.processExiting,
1364
- shutdownRequestTimedOut,
1365
- },
1366
- });
1367
- // #449/#472: deregister this LSP child from the instance registry. Fire-
1368
- // and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
1369
- // including the `processExiting` path where the event loop is closing
1370
- // (#234 forbids spawning here, but a plain fs write/rename is fine; even
1371
- // so, we don't await it to keep this teardown path as fast as before).
1372
- void removeLspChild(pid).catch((err) => {
2534
+ finally {
2535
+ disposeClientConnection(state);
2536
+ const pid = state.lspProcess.pid;
1373
2537
  logLatency({
1374
2538
  type: "phase",
1375
- phase: "lsp_registry_write_failed",
1376
- filePath: "",
1377
- durationMs: 0,
1378
- metadata: { op: "remove", pid, error: String(err) },
2539
+ phase: "lsp_client_shutdown",
2540
+ filePath: state.root,
2541
+ durationMs: Date.now() - shutdownStart,
2542
+ metadata: {
2543
+ serverId: state.serverId,
2544
+ pid,
2545
+ fast: !!options.fast,
2546
+ processExiting: !!options.processExiting,
2547
+ shutdownRequestTimedOut,
2548
+ // #1620: `shutdownRequestTimedOut` covered only the request half, so
2549
+ // a hung exit notify was indistinguishable from a clean exit — and
2550
+ // pre-fix no record was emitted at all. Report both halves plus one
2551
+ // rolled-up verdict, so a forced teardown is countable from the log.
2552
+ exitNotifyTimedOut,
2553
+ // #1620 residual 1 / residual-review F2: the *Undelivered pair covers
2554
+ // the failure the *TimedOut pair used to also claim — a rejection
2555
+ // that is NOT the timer winning, OR (the F2 gap) a swallowed stream
2556
+ // error that `safeSendRequest`/`safeSendNotification` resolved
2557
+ // instead of rejecting, so no exception ever reached the catches
2558
+ // above. Both still roll into shutdownOutcome "forced" below — an
2559
+ // undelivered write is never reported as "graceful".
2560
+ shutdownRequestUndelivered,
2561
+ exitNotifyUndelivered,
2562
+ shutdownOutcome: options.fast
2563
+ ? "fast"
2564
+ : shutdownRequestTimedOut ||
2565
+ exitNotifyTimedOut ||
2566
+ shutdownRequestUndelivered ||
2567
+ exitNotifyUndelivered
2568
+ ? "forced"
2569
+ : "graceful",
2570
+ },
1379
2571
  });
1380
- });
1381
- // On Windows, killing the direct child first can orphan grandchildren before
1382
- // taskkill can traverse the tree. Kill the full tree first and wait briefly.
1383
- await killProcessTree(state.lspProcess.process, pid, options);
2572
+ // #449/#472: deregister this LSP child from the instance registry. Fire-
2573
+ // and-forget (async fs, no spawn) must not add latency/risk to shutdown,
2574
+ // including the `processExiting` path where the event loop is closing
2575
+ // (#234 forbids spawning here, but a plain fs write/rename is fine; even
2576
+ // so, we don't await it to keep this teardown path as fast as before).
2577
+ // #1724: pass this spawn's marker so removeLspChild can guard against a
2578
+ // pid-recycling window (a NEW child claiming this exact pid before this
2579
+ // fire-and-forget write lands) rather than removing whichever child
2580
+ // currently sits at this pid.
2581
+ void removeLspChild(pid, extractSpawnMarker(state.lspProcess.args)).catch((err) => {
2582
+ logLatency({
2583
+ type: "phase",
2584
+ phase: "lsp_registry_write_failed",
2585
+ filePath: "",
2586
+ durationMs: 0,
2587
+ metadata: { op: "remove", pid, error: String(err) },
2588
+ });
2589
+ });
2590
+ // On Windows, killing the direct child first can orphan grandchildren before
2591
+ // taskkill can traverse the tree. Kill the full tree first and wait briefly.
2592
+ // Safe and idempotent on an already-exited child: killProcessTree returns
2593
+ // early on an observed exit rather than signalling a possibly-recycled pid.
2594
+ await killProcessTree(state.lspProcess.process, pid, options);
2595
+ }
1384
2596
  }
1385
2597
  /**
1386
2598
  * Translate a caller-supplied (UTF-16) `(line, character)` into the position the
@@ -1429,7 +2641,30 @@ signal = getAmbientAbortSignal()) {
1429
2641
  const requestVersion = normalizedPath !== undefined
1430
2642
  ? state.documentVersions.get(normalizedPath)
1431
2643
  : undefined;
1432
- const result = (await withTimeout(safeSendRequest(state.connection, method, params, signal), timeoutMs).catch((err) => {
2644
+ // #1716: captured OUTSIDE withTimeout, mirroring #1713's pull-diagnostic
2645
+ // fix (`pullDiagnosticSource`), so a timeout's catch below still holds a
2646
+ // live handle on the request for the late-answer watch. `withTimeout`
2647
+ // only races it against a timer — the request keeps running server-side
2648
+ // either way, and without this handle an abandoned answer would settle
2649
+ // into the void with no way to observe it.
2650
+ const requestStartedAt = Date.now();
2651
+ const requestPromise = safeSendRequest(state.connection, method, params, signal);
2652
+ const result = (await withTimeout(requestPromise, timeoutMs).catch((err) => {
2653
+ if (isNavTimeoutError(err, timeoutMs)) {
2654
+ recordNavTimeoutTelemetry({
2655
+ method,
2656
+ scope: normalizedPath,
2657
+ budgetMs: timeoutMs,
2658
+ elapsedMs: Date.now() - requestStartedAt,
2659
+ });
2660
+ armNavLateAnswerTelemetry({
2661
+ requestPromise,
2662
+ method,
2663
+ scope: normalizedPath,
2664
+ requestStartedAt,
2665
+ });
2666
+ return undefined;
2667
+ }
1433
2668
  if (err instanceof Error && err.message.startsWith("Timeout after")) {
1434
2669
  return undefined;
1435
2670
  }
@@ -1447,13 +2682,188 @@ signal = getAmbientAbortSignal()) {
1447
2682
  }
1448
2683
  return result;
1449
2684
  }
2685
+ /**
2686
+ * #1716: `withTimeout` rejects with this EXACT message (`clients/deadline-
2687
+ * utils.ts`) only when its own timer wins the race — mirrors #1713's
2688
+ * `isPullTimeoutError`. Only the timer-won case is a "the answer might still
2689
+ * be coming" situation worth a late-answer watch; a request that already
2690
+ * failed fast (or a stale-message coincidence) has nothing further to
2691
+ * observe. `navRequest`'s own behavior still falls back to the looser
2692
+ * `startsWith` check afterward, unchanged, so this stricter check only gates
2693
+ * telemetry — never the existing timeout-handling behavior.
2694
+ */
2695
+ function isNavTimeoutError(err, timeoutMs) {
2696
+ return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
2697
+ }
2698
+ /**
2699
+ * #1620 residual 1: `withTimeout` rejects with this EXACT message only when
2700
+ * its own timer wins the race — mirrors #1713's `isPullTimeoutError` and
2701
+ * #1716's `isNavTimeoutError`. `clientShutdown`'s two catches previously
2702
+ * treated ANY rejection (a real timeout OR an immediate protocol error) as
2703
+ * "timed out"; honest for the rolled-up `shutdownOutcome` (both are
2704
+ * "forced"), imprecise for the per-field flag a log reader keys on.
2705
+ */
2706
+ function isShutdownTimeoutError(err, timeoutMs) {
2707
+ return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
2708
+ }
2709
+ // #1716: subject/scope used when a nav request has no `staleCheckPath` (e.g.
2710
+ // workspaceSymbol, call-hierarchy follow-ups) — mirrors WORKSPACE_PULL_SCOPE
2711
+ // above for the same reason: telemetry identity needs a stable placeholder
2712
+ // where there is no real path.
2713
+ const NAV_REQUEST_NO_PATH_SCOPE = "*no-path*";
2714
+ /**
2715
+ * #1716: `navRequest` is the highest-volume LSP call site — hover,
2716
+ * definition, references, signatureHelp, documentSymbol, workspace/symbol,
2717
+ * call hierarchy all route through it, often several times per turn. A stuck
2718
+ * server can storm timeouts across many methods and files far faster than
2719
+ * the pull-diagnostic path #1713 fixed, so a per-event latency.log write
2720
+ * (that fix's shape) would itself become the flood. Every timeout is still
2721
+ * counted exactly, via the degradation ledger (bounded, in-memory, no I/O),
2722
+ * but only the RISING EDGE — the first occurrence per (method, file) this
2723
+ * session — also pays for a detailed log write. `incrementDegradationCount`'s
2724
+ * return value (`true` on the first occurrence for a kind/subject pair) is
2725
+ * reused as that rising-edge gate instead of a second hand-rolled latch: it
2726
+ * already re-arms at session_start via `resetDegradationLedger`, so this
2727
+ * adds zero new module state. Never throws: telemetry must not perturb a
2728
+ * path that has already decided to return `undefined` regardless.
2729
+ */
2730
+ function recordNavTimeoutTelemetry(args) {
2731
+ // #1743: `risingEdgePer: "identity"` is the same gate this site hand-rolled
2732
+ // off `incrementDegradationCount`'s return value, now expressed as an
2733
+ // option. The ledger still counts every timeout exactly, per (method, file).
2734
+ emitBounded("lsp_nav_request_timeout", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
2735
+ type: "phase",
2736
+ filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
2737
+ durationMs: args.elapsedMs,
2738
+ metadata: { method: args.method, effectiveBudgetMs: args.budgetMs },
2739
+ }, {
2740
+ ledgerKind: "lsp-nav-request-timeout",
2741
+ risingEdgePer: "identity",
2742
+ reason: `timeout budget=${args.budgetMs}ms elapsed=${args.elapsedMs}ms`,
2743
+ });
2744
+ }
2745
+ /**
2746
+ * #1716: nav-request sibling of #1713's `armLateAnswerTelemetry`. A
2747
+ * telemetry-only continuation on the ALREADY-abandoned request promise —
2748
+ * purely observational, never touches `state`, and attaching `.then` here
2749
+ * creates no new timer, socket, or other handle, so it cannot keep the
2750
+ * process alive a moment longer than the pending request already does.
2751
+ *
2752
+ * Bounded the same way as the timeout record above: every late answer is
2753
+ * counted exactly via the ledger, but only the rising edge per (method,
2754
+ * file) also writes an `lsp_nav_late_answer_discarded` record.
2755
+ *
2756
+ * Nav answers are read-once — unlike diagnostics' persistent pull cache,
2757
+ * there is no stale value this could poison — so this exists purely for
2758
+ * observability: telling a dogfood session "truly hung" apart from
2759
+ * "answered late", the same distinction #1713 made possible for pulls.
2760
+ */
2761
+ function armNavLateAnswerTelemetry(args) {
2762
+ args.requestPromise.then(() => {
2763
+ try {
2764
+ const elapsedMs = Date.now() - args.requestStartedAt;
2765
+ // #1743: same rising-edge gate as the timeout record above, now
2766
+ // carried by the helper instead of hand-rolled here.
2767
+ emitBounded("lsp_nav_late_answer_discarded", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
2768
+ type: "phase",
2769
+ filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
2770
+ durationMs: elapsedMs,
2771
+ metadata: { method: args.method },
2772
+ }, {
2773
+ ledgerKind: "lsp-nav-late-answer",
2774
+ risingEdgePer: "identity",
2775
+ reason: `late nav answer discarded after ${elapsedMs}ms`,
2776
+ });
2777
+ }
2778
+ catch {
2779
+ // Telemetry must never break the observed path.
2780
+ }
2781
+ }, () => {
2782
+ // The abandoned request eventually failed rather than answering —
2783
+ // not an "answer discarded" event. Nothing more to record here; this
2784
+ // handler exists so the request's eventual rejection never surfaces
2785
+ // as an unhandled rejection.
2786
+ });
2787
+ }
2788
+ /**
2789
+ * #1969: pick the request the liveness probe may send to THIS server.
2790
+ *
2791
+ * The probe only needs a round-trip, so any request works — but it must be one
2792
+ * the server advertised. #1277 originally hardcoded `workspace/symbol` because
2793
+ * it needs no open document, and treated `MethodNotFound` as proof of life.
2794
+ * That rationale is sound for the round-trip, and wrong as a default: a server
2795
+ * that does not implement the method logs an error for every probe. ast-grep's
2796
+ * tower_lsp backend wrote "got a 'workspace/symbol' request, but it is not
2797
+ * implemented" on each one, and its `code=1` deaths clustered after them
2798
+ * (#1969). pi-lens must not keep pushing an unimplemented method at a server
2799
+ * just to learn it is awake.
2800
+ *
2801
+ * The ladder, best first:
2802
+ * 1. `workspace/symbol` when `workspaceSymbolProvider` is advertised — the
2803
+ * #1277 probe, unchanged, and still needs no open document.
2804
+ * 2. `textDocument/documentSymbol` on an already-open document when
2805
+ * `documentSymbolProvider` is advertised. Read-only, scoped to a document
2806
+ * the server already has, and cheap.
2807
+ * 3. `textDocument/hover` at the start of an already-open document when
2808
+ * `hoverProvider` is advertised. This step is what keeps ast-grep covered:
2809
+ * `docs/servercapabilities.md:53` records its advertised set as
2810
+ * codeActionProvider, executeCommandProvider, hoverProvider and
2811
+ * textDocumentSync — no symbol provider of either kind. #1714's own
2812
+ * measurement (clients/lsp/index.ts, `paceAuxNotify`) timed hover against
2813
+ * the real ast-grep binary at 1 ms idle and 2086 ms behind 30 `didOpen`s,
2814
+ * the same ordering proof it recorded for `workspace/symbol`. So the
2815
+ * notify throttle keeps a real barrier here, not a hollow one.
2816
+ * 4. Nothing. The caller then reports liveness from process and connection
2817
+ * state alone, and records that it did.
2818
+ *
2819
+ * `MethodNotFound`-counts-as-alive still holds for every probe: dynamic
2820
+ * registration can retract a capability after `initialize`, and an error reply
2821
+ * is still a reply. What changes is that pi-lens no longer PROVOKES that error
2822
+ * on every probe.
2823
+ */
2824
+ function chooseLivenessProbe(state) {
2825
+ if (state.operationSupport?.workspaceSymbol === true) {
2826
+ return {
2827
+ method: "workspace/symbol",
2828
+ params: { query: LIVENESS_PING_QUERY },
2829
+ };
2830
+ }
2831
+ const openUri = firstOpenDocumentUri(state);
2832
+ if (openUri === undefined)
2833
+ return undefined;
2834
+ if (state.operationSupport?.documentSymbol === true) {
2835
+ return {
2836
+ method: "textDocument/documentSymbol",
2837
+ params: { textDocument: { uri: openUri } },
2838
+ };
2839
+ }
2840
+ if (state.operationSupport?.hover === true) {
2841
+ return {
2842
+ method: "textDocument/hover",
2843
+ params: {
2844
+ textDocument: { uri: openUri },
2845
+ position: { line: 0, character: 0 },
2846
+ },
2847
+ };
2848
+ }
2849
+ return undefined;
2850
+ }
2851
+ /** URI of any document this client currently holds open, if it holds one. */
2852
+ function firstOpenDocumentUri(state) {
2853
+ for (const normalizedPath of state.openDocuments) {
2854
+ const uri = state.openDocumentUris?.get(normalizedPath);
2855
+ if (uri !== undefined)
2856
+ return uri;
2857
+ }
2858
+ return undefined;
2859
+ }
1450
2860
  // #1277: cheap liveness round-trip used by the silent-clean gates in
1451
2861
  // `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
1452
2862
  // state — a server that accepted the notify write and then wedged (still
1453
2863
  // running, connection still open, just never replying) reads as "alive" by
1454
2864
  // those checks even though it will never answer anything again. This sends a
1455
- // real request (`workspace/symbol`, chosen because it needs no open document)
1456
- // and reports whether the connection round-tripped it — success, a genuine
2865
+ // real request (see `chooseLivenessProbe` for which, #1969) and reports
2866
+ // whether the connection round-tripped it — success, a genuine
1457
2867
  // protocol-level error (e.g. MethodNotFound), and a stream-destroyed/
1458
2868
  // cancelled response (safeSendRequest swallows those to `undefined`, so the
1459
2869
  // final `isClientAlive` re-check is what catches "died mid-flight") ALL count
@@ -1463,10 +2873,30 @@ signal = getAmbientAbortSignal()) {
1463
2873
  async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
1464
2874
  if (!isClientAlive(state))
1465
2875
  return false;
2876
+ const probe = chooseLivenessProbe(state);
2877
+ if (probe === undefined) {
2878
+ // #1969: no advertised method to probe with, so this answer comes from
2879
+ // process and connection state alone — exactly the checks the probe
2880
+ // exists to strengthen. Say so in the ledger rather than let a weaker
2881
+ // verdict pass as the strong one. Rising edge per server, via the
2882
+ // ledger's own tally; the count carries the repeats.
2883
+ try {
2884
+ incrementDegradationCount({
2885
+ kind: "lsp-liveness-probe-unsupported",
2886
+ subject: state.serverId,
2887
+ reason: "no advertised request method to probe with (no " +
2888
+ "workspaceSymbolProvider, and no open document with " +
2889
+ "documentSymbolProvider or hoverProvider); liveness from " +
2890
+ "process and connection state only",
2891
+ });
2892
+ }
2893
+ catch {
2894
+ // Telemetry must never break the observed path.
2895
+ }
2896
+ return isClientAlive(state);
2897
+ }
1466
2898
  try {
1467
- await withTimeout(safeSendRequest(state.connection, "workspace/symbol", {
1468
- query: LIVENESS_PING_QUERY,
1469
- }), timeoutMs);
2899
+ await withTimeout(safeSendRequest(state.connection, probe.method, probe.params), timeoutMs);
1470
2900
  }
1471
2901
  catch (err) {
1472
2902
  if (err instanceof Error && err.message.startsWith("Timeout after")) {
@@ -1569,10 +2999,14 @@ export async function runReadOnlyServerCommand(state, command, args, timeoutMs =
1569
2999
  }
1570
3000
  function validateWorkspaceEditVersions(state, edit) {
1571
3001
  for (const change of edit.documentChanges ?? []) {
1572
- if (typeof change !== "object" || change === null || !("textDocument" in change))
3002
+ if (typeof change !== "object" ||
3003
+ change === null ||
3004
+ !("textDocument" in change))
1573
3005
  continue;
1574
3006
  const textDocument = change.textDocument;
1575
- if (!textDocument || typeof textDocument.uri !== "string" || textDocument.version == null)
3007
+ if (!textDocument ||
3008
+ typeof textDocument.uri !== "string" ||
3009
+ textDocument.version == null)
1576
3010
  continue;
1577
3011
  const current = state.documentVersions.get(normalizeMapKey(uriToPath(textDocument.uri)));
1578
3012
  if (current === undefined || current !== textDocument.version) {
@@ -1639,7 +3073,10 @@ async function resolveCodeActionBestEffort(state, action) {
1639
3073
  return action;
1640
3074
  const merged = { ...action, ...resolved };
1641
3075
  return merged.edit
1642
- ? { ...merged, edit: await normalizeClientWorkspaceEdit(state, merged.edit) }
3076
+ ? {
3077
+ ...merged,
3078
+ edit: await normalizeClientWorkspaceEdit(state, merged.edit),
3079
+ }
1643
3080
  : merged;
1644
3081
  }
1645
3082
  // --- Client Factory ---
@@ -1736,10 +3173,21 @@ export async function createLSPClient(options) {
1736
3173
  // for concurrent waitForDiagnostics calls.
1737
3174
  const diagnosticEmitter = new EventEmitter();
1738
3175
  diagnosticEmitter.setMaxListeners(50);
3176
+ // SAFETY: three fields below are seeded `undefined as unknown as T` because
3177
+ // they cannot be built here. `workspaceDiagnosticsSupport` and
3178
+ // `operationSupport` are derived from the server's `initialize` result, and
3179
+ // `watchQueue`'s flush closure needs `state` itself. Each is assigned
3180
+ // in this same function before `state` reaches any caller:
3181
+ // `state.watchQueue` on the statement immediately after this literal, and
3182
+ // the two capability fields right after the `initialize` handshake
3183
+ // returns. Reorder that and the fields are genuinely `undefined` at their
3184
+ // declared non-optional types.
1739
3185
  const state = {
1740
3186
  isConnected: true,
1741
3187
  isDestroyed: false,
1742
3188
  shutdownRequested: false,
3189
+ shutdownPromise: undefined,
3190
+ shutdownOptions: undefined,
1743
3191
  exitedAt: undefined,
1744
3192
  connectionDisposed: false,
1745
3193
  lastError: undefined,
@@ -1754,11 +3202,15 @@ export async function createLSPClient(options) {
1754
3202
  documentOpenedAt: new Map(),
1755
3203
  diagnosticEmitter,
1756
3204
  diagnosticsVersion: 0,
3205
+ diagnosticsVersionsByPath: new Map(),
1757
3206
  documentVersions: new Map(),
1758
3207
  diagnosticDocVersions: new Map(),
1759
3208
  documentContentHashes: new Map(),
1760
3209
  diagnosticBindings: new Map(),
1761
3210
  pullResultIds: new Map(),
3211
+ documentPullDiagnosticsBySource: new Map(),
3212
+ pullGenerations: new Map(),
3213
+ pullRequestSequences: new Map(),
1762
3214
  workspacePullResultCache: new Map(),
1763
3215
  openDocuments: new Set(),
1764
3216
  closedDocuments: new Set(),
@@ -1766,10 +3218,14 @@ export async function createLSPClient(options) {
1766
3218
  pendingOpens: new Set(),
1767
3219
  projectIdentityProbedFiles: new Set(),
1768
3220
  // these are filled in after initialize — cast to avoid two-phase init
1769
- workspaceDiagnosticsSupport: undefined,
3221
+ workspaceDiagnosticsSupport:
3222
+ // SAFETY: initialize fills this capability before any workspace diagnostic request.
3223
+ undefined,
3224
+ // SAFETY: initialize fills this capability before operation dispatch.
1770
3225
  operationSupport: undefined,
1771
3226
  staticDiagnosticsMode: "push-only",
1772
3227
  positionEncoding: "utf-16",
3228
+ syncKind: TEXT_DOCUMENT_SYNC_KIND_FULL,
1773
3229
  dynamicRegistrations: new Map(),
1774
3230
  advertisedCommands: new Set(),
1775
3231
  serverEditsAllowed: 0,
@@ -1779,6 +3235,7 @@ export async function createLSPClient(options) {
1779
3235
  root,
1780
3236
  lspProcess,
1781
3237
  // two-phase: the flush closure needs `state` (below)
3238
+ // SAFETY: state construction completes before the flush closure can run.
1782
3239
  watchQueue: undefined,
1783
3240
  };
1784
3241
  // #271: batch per-file workspace/didChangeWatchedFiles into one notification
@@ -1804,6 +3261,18 @@ export async function createLSPClient(options) {
1804
3261
  }), initializeTimeoutMs);
1805
3262
  }
1806
3263
  catch (err) {
3264
+ // #1969: claim this kill BEFORE issuing it. `setupConnectionLifecycle`
3265
+ // ran above, so its `exit` and `close` handlers are already armed on
3266
+ // this child, and both read `state.shutdownRequested` to tell a crash
3267
+ // from a teardown we asked for. The kill below is one we asked for.
3268
+ // Without this line it reported as an unprompted death, fabricating a
3269
+ // `lsp-server-unexpected-close` entry reading
3270
+ // "code=1 signal=none stderr=empty" — character for character the
3271
+ // ast-grep signature this issue exists to make trustworthy — every time
3272
+ // a server merely failed to complete its handshake. It also silenced a
3273
+ // false `lsp_server_unexpected_exit` latency line that this path has
3274
+ // been writing since the #615 follow-up.
3275
+ state.shutdownRequested = true;
1807
3276
  // Hard-kill the hung process so it doesn't become a zombie.
1808
3277
  // SIGTERM alone is unreliable on Windows for cmd.exe/PowerShell trees.
1809
3278
  const pid = lspProcess.pid;
@@ -1811,7 +3280,7 @@ export async function createLSPClient(options) {
1811
3280
  // A child registered above (recordLspChild) but never reaching a healthy
1812
3281
  // createLSPClient return must still be deregistered here — otherwise the
1813
3282
  // registry keeps a stale entry for a process we just killed.
1814
- void removeLspChild(pid).catch((err) => {
3283
+ void removeLspChild(pid, extractSpawnMarker(lspProcess.args)).catch((err) => {
1815
3284
  // best-effort — a stale registry entry is harmless (the reaper's
1816
3285
  // liveness check will find it dead on the next sweep regardless)
1817
3286
  logLatency({
@@ -1872,6 +3341,7 @@ export async function createLSPClient(options) {
1872
3341
  detectWorkspaceDiagnosticsSupport(initResult);
1873
3342
  state.operationSupport = detectOperationSupport(initResult);
1874
3343
  state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
3344
+ state.syncKind = negotiateSyncKind(initResult?.capabilities);
1875
3345
  state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
1876
3346
  {}).sort((a, b) => a.localeCompare(b));
1877
3347
  for (const cmd of detectExecuteCommands(initResult)) {
@@ -1913,6 +3383,9 @@ export async function createLSPClient(options) {
1913
3383
  async change(filePath, content) {
1914
3384
  return handleNotifyChange(state, filePath, content);
1915
3385
  },
3386
+ watchedFileChange(filePath, type) {
3387
+ handleNotifyExternalChange(state, filePath, type);
3388
+ },
1916
3389
  },
1917
3390
  getDiagnostics(filePath) {
1918
3391
  return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
@@ -1920,6 +3393,9 @@ export async function createLSPClient(options) {
1920
3393
  getDiagnosticBinding(filePath) {
1921
3394
  return state.diagnosticBindings.get(normalizeMapKey(filePath));
1922
3395
  },
3396
+ getDiagnosticsVersionForPath(filePath) {
3397
+ return diagnosticsVersionForPath(state, normalizeMapKey(filePath));
3398
+ },
1923
3399
  getAllDiagnostics() {
1924
3400
  const result = new Map();
1925
3401
  const keys = new Set([
@@ -1936,10 +3412,12 @@ export async function createLSPClient(options) {
1936
3412
  return result;
1937
3413
  },
1938
3414
  getTrackedDiagnosticPaths() {
1939
- return [...new Set([
3415
+ return [
3416
+ ...new Set([
1940
3417
  ...state.pushDiagnostics.keys(),
1941
3418
  ...state.documentPullDiagnostics.keys(),
1942
- ])].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
3419
+ ]),
3420
+ ].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
1943
3421
  },
1944
3422
  pruneDiagnostics(predicate) {
1945
3423
  let removed = 0;
@@ -1978,6 +3456,9 @@ export async function createLSPClient(options) {
1978
3456
  async executeCommand(command, args, mutationContext) {
1979
3457
  return runServerCommand(state, command, args, EXECUTE_COMMAND_TIMEOUT_MS, mutationContext);
1980
3458
  },
3459
+ async executeReadOnlyCommand(command, args) {
3460
+ return runReadOnlyServerCommand(state, command, args);
3461
+ },
1981
3462
  get diagnosticsVersion() {
1982
3463
  return state.diagnosticsVersion;
1983
3464
  },
@@ -2143,14 +3624,25 @@ export async function createLSPClient(options) {
2143
3624
  };
2144
3625
  }
2145
3626
  // Helper to safely send notifications - catches stream destruction
3627
+ /**
3628
+ * Returns `true` once the notification was actually handed to the transport,
3629
+ * `false` when a stream error was swallowed (connection error handlers will
3630
+ * update state separately). #1669 review F7: callers that mirror what they
3631
+ * just told the server locally (`recordSentContent`) MUST gate on this
3632
+ * return value — recording a send that never left the process would desync
3633
+ * the mirror from what the server actually has, with nothing to self-heal
3634
+ * it (the next Incremental range would be computed against content the
3635
+ * server never saw).
3636
+ */
2146
3637
  async function safeSendNotification(connection, method, params) {
2147
3638
  try {
2148
3639
  await connection.sendNotification(method, params);
3640
+ return true;
2149
3641
  }
2150
3642
  catch (err) {
2151
3643
  if (isStreamError(err)) {
2152
3644
  // Silently ignore - stream was destroyed, connection error handlers will update state
2153
- return;
3645
+ return false;
2154
3646
  }
2155
3647
  throw err;
2156
3648
  }
@@ -2162,7 +3654,7 @@ async function safeSendRequest(connection, method, params,
2162
3654
  // vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
2163
3655
  // so a server stops computing a result the agent has already abandoned (#238
2164
3656
  // Item 1). The rejection that follows is swallowed (treated as `undefined`).
2165
- signal) {
3657
+ signal, settlement) {
2166
3658
  // Already abandoned before we even sent — don't bother the server.
2167
3659
  if (signal?.aborted)
2168
3660
  return undefined;
@@ -2189,9 +3681,13 @@ signal) {
2189
3681
  return (await send());
2190
3682
  }
2191
3683
  catch (err) {
2192
- if (isStreamError(err) || isCancellationError(err)) {
2193
- // Stream destroyed, or we cancelled the request on abort — either
2194
- // way there is no result to return.
3684
+ if (isCancellationError(err)) {
3685
+ if (settlement)
3686
+ settlement.cancelled = true;
3687
+ return undefined;
3688
+ }
3689
+ if (isStreamError(err)) {
3690
+ // Stream destroyed; connection handlers update client state separately.
2195
3691
  return undefined;
2196
3692
  }
2197
3693
  if (isContentModifiedError(err)) {