@herbertgao/pi-extensions 2026.8.5 → 2026.8.7

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 (389) hide show
  1. package/README.md +5 -5
  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 +272 -22
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  17. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  18. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +16 -0
  19. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  20. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  21. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  24. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +22 -15
  25. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  26. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  27. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  28. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  29. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  30. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  32. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  33. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +34 -10
  34. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  35. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +29 -5
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +3 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +46 -10
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +8 -1
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -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 +3 -1
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  49. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
  50. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
  51. package/node_modules/@narumitw/pi-btw/README.md +119 -103
  52. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  53. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  54. package/node_modules/@narumitw/pi-btw/package.json +13 -9
  55. package/node_modules/@narumitw/pi-btw/src/btw.ts +40 -42
  56. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  57. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  58. package/node_modules/@narumitw/pi-btw/src/menu.ts +78 -15
  59. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  60. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  61. package/node_modules/pi-lens/CHANGELOG.md +1004 -0
  62. package/node_modules/pi-lens/README.md +11 -3
  63. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  64. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  65. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +427 -0
  66. package/node_modules/pi-lens/dist/clients/agent-nudge.js +79 -16
  67. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  68. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  69. package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
  70. package/node_modules/pi-lens/dist/clients/biome-client.js +176 -37
  71. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  72. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
  73. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  74. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  75. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
  76. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  77. package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
  78. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  79. package/node_modules/pi-lens/dist/clients/cascade-format.js +76 -2
  80. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  81. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  82. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  83. package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
  84. package/node_modules/pi-lens/dist/clients/dead-code-client.js +249 -52
  85. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  86. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  87. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
  88. package/node_modules/pi-lens/dist/clients/dependency-checker.js +43 -9
  89. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +153 -37
  90. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  91. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  92. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +165 -11
  93. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +325 -72
  94. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  95. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +81 -41
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +488 -0
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +84 -0
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +943 -93
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +106 -0
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  129. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  130. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  131. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  132. package/node_modules/pi-lens/dist/clients/extension-log.js +317 -3
  133. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  134. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
  135. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  136. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  137. package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
  138. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  139. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  140. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  141. package/node_modules/pi-lens/dist/clients/git-guard.js +49 -21
  142. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  143. package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
  144. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +219 -10
  145. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  146. package/node_modules/pi-lens/dist/clients/host-ports.js +9 -1
  147. package/node_modules/pi-lens/dist/clients/installer/index.js +1119 -101
  148. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  149. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  150. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  151. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  152. package/node_modules/pi-lens/dist/clients/jscpd-client.js +86 -15
  153. package/node_modules/pi-lens/dist/clients/knip-client.js +361 -24
  154. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  155. package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
  156. package/node_modules/pi-lens/dist/clients/latency-logger.js +487 -9
  157. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  158. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  159. package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
  160. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  161. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
  162. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  163. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +160 -20
  164. package/node_modules/pi-lens/dist/clients/lsp/client.js +1666 -148
  165. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +111 -0
  166. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  167. package/node_modules/pi-lens/dist/clients/lsp/index.js +2496 -171
  168. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  169. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  170. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  171. package/node_modules/pi-lens/dist/clients/lsp/server.js +517 -69
  172. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  173. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  174. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +172 -0
  175. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  176. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +61 -3
  177. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  178. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  179. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  180. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  181. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
  182. package/node_modules/pi-lens/dist/clients/mcp/session.js +39 -18
  183. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  184. package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
  185. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  186. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  187. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  188. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  189. package/node_modules/pi-lens/dist/clients/pipeline.js +178 -30
  190. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  191. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  192. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  193. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +85 -15
  194. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  195. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  196. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  197. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
  198. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  199. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  200. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  201. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  202. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
  203. package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
  204. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  205. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  206. package/node_modules/pi-lens/dist/clients/review-graph/query.js +76 -4
  207. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  208. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +248 -13
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +112 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +472 -61
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +359 -13
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +269 -22
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +948 -87
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  217. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  218. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  219. package/node_modules/pi-lens/dist/clients/security-scan-client.js +224 -8
  220. package/node_modules/pi-lens/dist/clients/sg-runner.js +249 -37
  221. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  222. package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
  223. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  224. package/node_modules/pi-lens/dist/clients/startup-timing.js +26 -2
  225. package/node_modules/pi-lens/dist/clients/test-runner-client.js +574 -32
  226. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  227. package/node_modules/pi-lens/dist/clients/tool-policy.js +127 -20
  228. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  229. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  230. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  233. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  234. package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
  235. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  236. package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
  237. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  238. package/node_modules/pi-lens/dist/index.js +64677 -49484
  239. package/node_modules/pi-lens/dist/mcp/server.js +8 -2
  240. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  241. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  242. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  243. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +469 -56
  244. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +190 -46
  245. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  246. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  247. package/node_modules/pi-lens/docs/agent-guide.md +44 -14
  248. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +518 -501
  249. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  250. package/node_modules/pi-lens/docs/features.md +33 -6
  251. package/node_modules/pi-lens/docs/globalconfig.md +14 -0
  252. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  253. package/node_modules/pi-lens/docs/mcp.md +2 -2
  254. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  255. package/node_modules/pi-lens/docs/settings.md +25 -1
  256. package/node_modules/pi-lens/docs/usage.md +24 -5
  257. package/node_modules/pi-lens/docs/word-index.md +35 -0
  258. package/node_modules/pi-lens/package.json +10 -6
  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 +22 -0
  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-conditional-empty-object-spread-js-test.yml +9 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +9 -0
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +9 -0
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +9 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +9 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +66 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  300. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  301. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  302. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  303. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  304. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +29 -0
  305. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +30 -0
  306. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +41 -0
  307. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +42 -0
  308. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  309. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  310. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  311. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  312. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  313. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  314. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +32 -0
  315. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  316. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  317. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  318. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  319. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  320. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  321. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  322. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  323. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  324. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +132 -1
  325. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  326. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  327. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +12 -11
  328. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  329. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +87 -116
  330. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +170 -0
  331. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  332. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  333. package/node_modules/pi-mcp-adapter/CHANGELOG.md +49 -0
  334. package/node_modules/pi-mcp-adapter/README.md +51 -6
  335. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  336. package/node_modules/pi-mcp-adapter/cli.js +188 -5
  337. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  338. package/node_modules/pi-mcp-adapter/config.ts +28 -10
  339. package/node_modules/pi-mcp-adapter/direct-tools.ts +23 -19
  340. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  341. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  342. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  343. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  344. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  345. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  346. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  347. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  348. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  349. package/node_modules/pi-mcp-adapter/metadata-cache.ts +46 -25
  350. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  351. package/node_modules/pi-mcp-adapter/package.json +4 -1
  352. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  353. package/node_modules/pi-mcp-adapter/request-headers-command.ts +330 -0
  354. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  355. package/node_modules/pi-mcp-adapter/server-manager.ts +145 -2
  356. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  357. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  358. package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
  359. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  360. package/node_modules/pi-mcp-adapter/types.ts +95 -10
  361. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  362. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  363. package/node_modules/pi-web-access/CHANGELOG.md +37 -0
  364. package/node_modules/pi-web-access/README.md +38 -14
  365. package/node_modules/pi-web-access/anysearch.ts +4 -2
  366. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  367. package/node_modules/pi-web-access/brave.ts +16 -4
  368. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  369. package/node_modules/pi-web-access/curator-page.ts +8 -3
  370. package/node_modules/pi-web-access/curator-server.ts +5 -1
  371. package/node_modules/pi-web-access/exa.ts +18 -7
  372. package/node_modules/pi-web-access/extract.ts +134 -37
  373. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  374. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  375. package/node_modules/pi-web-access/gemini-search.ts +33 -7
  376. package/node_modules/pi-web-access/index.ts +160 -104
  377. package/node_modules/pi-web-access/package.json +2 -2
  378. package/node_modules/pi-web-access/page-query.ts +4 -2
  379. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  380. package/node_modules/pi-web-access/parallel.ts +2 -2
  381. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  382. package/node_modules/pi-web-access/serper.ts +211 -0
  383. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  384. package/node_modules/pi-web-access/summary-review.ts +51 -23
  385. package/node_modules/pi-web-access/tavily.ts +16 -4
  386. package/node_modules/pi-web-access/utils.ts +87 -0
  387. package/node_modules/pi-web-access/valyu.ts +199 -0
  388. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  389. package/package.json +13 -13
@@ -7,26 +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
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";
28
33
  import { getStrategy } from "./wait-policy/index.js";
29
34
  import { WatchedFilesQueue } from "./watch-queue.js";
35
+ import { clearAllWorkspaceDiagnosticsCaches, clearWorkspaceDiagnosticsCacheAtAndAbove, } from "./workspace-diagnostics-cache.js";
30
36
  // Opt-in publishDiagnostics trace (PILENS_PUB_DEBUG=1) — read once, negligible
31
37
  // hot-path cost. Surfaces each server's publish behavior (version + count) to
32
38
  // diagnose the clean-file affirmative-signal question (#240): which servers
@@ -47,6 +53,8 @@ const PUB_DEBUG = Boolean(process.env.PILENS_PUB_DEBUG);
47
53
  * argument, without new server-specific code.
48
54
  */
49
55
  function extractSpawnMarker(args) {
56
+ if (!args)
57
+ return undefined;
50
58
  const tmpDir = os.tmpdir();
51
59
  for (let i = 0; i < args.length - 1; i++) {
52
60
  const flag = args[i];
@@ -125,7 +133,31 @@ const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_
125
133
  // the diagnostics flush. On timeout the request is treated as `unavailable`, which
126
134
  // (per #240) is NOT read as clean and falls through to the bounded push backstop.
127
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;
128
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);
129
161
  // #1277: cheap liveness round-trip for the silent-clean gates (`index.ts`).
130
162
  // Those gates convert a diagnostics-wait timeout into a confirmed-clean
131
163
  // result from a STATIC capability classification (`silentOnClean`) alone —
@@ -145,12 +177,46 @@ const LIVENESS_PING_QUERY = "__pi_lens_liveness_ping__";
145
177
  // cheaply rebuilt by the next full pull, the worst case is just one extra full
146
178
  // (non-`unchanged`) report per affected file.
147
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
+ }
148
210
  // Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
149
211
  // (30s): the command is mutating and legitimately long-running (a real server
150
212
  // refactor / organize-imports), so this must not truncate valid work — it only
151
213
  // stops a hung server from blocking the caller forever. On timeout the command
152
214
  // may still be applying server-side; we surface that rather than pretend it ran.
153
215
  const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
216
+ // #1412 H1: short ceiling for the read-only tsserver project-identity probe.
217
+ // This is a telemetry sample, not a mutation — it must never hold the door
218
+ // open for anything close to EXECUTE_COMMAND_TIMEOUT_MS.
219
+ const PROBE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PROJECT_IDENTITY_PROBE_TIMEOUT_MS", 2_500);
154
220
  const LSP_CRASH_CODES = new Set([
155
221
  "ERR_STREAM_DESTROYED",
156
222
  "ERR_STREAM_WRITE_AFTER_END",
@@ -409,10 +475,15 @@ function normalizeLspDiagnostic(diagnostic) {
409
475
  function normalizeLspDiagnostics(diagnostics) {
410
476
  return diagnostics.map(normalizeLspDiagnostic);
411
477
  }
412
- 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) {
413
484
  const merged = [];
414
485
  const seen = new Set();
415
- for (const diagnostic of [...(push ?? []), ...(pull ?? [])]) {
486
+ for (const diagnostic of lists.flatMap((list) => list ?? [])) {
416
487
  const key = [
417
488
  diagnostic.range.start.line,
418
489
  diagnostic.range.start.character,
@@ -430,13 +501,191 @@ function mergeDiagnosticLists(push, pull) {
430
501
  return merged;
431
502
  }
432
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.
433
510
  const legacy = state;
434
511
  return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
435
512
  legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
436
513
  }
437
- function clearDiagnosticsForPath(state, normalizedPath) {
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
+ }
675
+ /** Exported for tests: the quiet-window timer cancel on clear/resync is the
676
+ * headline #1412 safety property (a stale versionless publication must never
677
+ * land after the document content changed). */
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.
438
683
  const legacy = state;
439
684
  state.pushDiagnostics?.delete(normalizedPath);
685
+ const pending = state.pendingDiagnostics?.get(normalizedPath);
686
+ if (pending)
687
+ clearTimeout(pending);
688
+ state.pendingDiagnostics?.delete(normalizedPath);
440
689
  state.pushDiagnosticTimestamps?.delete(normalizedPath);
441
690
  state.documentPullDiagnostics?.delete(normalizedPath);
442
691
  state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
@@ -446,14 +695,99 @@ function clearDiagnosticsForPath(state, normalizedPath) {
446
695
  // `documentContentHashes` record is intentionally retained: it describes what
447
696
  // we sent, which the NEXT publish for that version still needs to bind to.)
448
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);
449
704
  // #1104: a resync invalidates any `unchanged`-report basis too — the next
450
705
  // pull must not inherit a resultId/contentHash computed against the
451
706
  // content this resync just replaced.
452
- 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);
453
725
  state.workspacePullResultCache?.delete(normalizedPath);
454
726
  legacy.diagnostics?.delete(normalizedPath);
455
727
  legacy.diagnosticTimestamps?.delete(normalizedPath);
456
728
  }
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) {
761
+ if (state.serverId !== "typescript")
762
+ return;
763
+ const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
764
+ const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
765
+ const diagnosticCodes = [
766
+ ...new Set(diagnostics
767
+ .map((diagnostic) => diagnostic.code)
768
+ .filter((code) => code !== undefined)
769
+ .map(String)),
770
+ ].slice(0, 8);
771
+ const trackedVersion = state.diagnosticsVersionsByPath?.get(normalizedPath);
772
+ logLatency({
773
+ type: "phase",
774
+ phase: "lsp_typescript_diagnostic_sequence",
775
+ filePath: normalizedPath,
776
+ durationMs,
777
+ metadata: {
778
+ serverId: state.serverId,
779
+ outcome: "settled",
780
+ launchVariant: state.launchVariant ?? "unknown",
781
+ publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
782
+ version: trackedVersion ?? "pull-unversioned",
783
+ diagnosticCount: diagnostics.length,
784
+ diagnosticCodes,
785
+ elapsedSinceDidOpenMs,
786
+ settledReturn: true,
787
+ settleSource: pullSettleSource,
788
+ },
789
+ });
790
+ }
457
791
  /**
458
792
  * #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
459
793
  * tag it with the document version it was sent as, so a later
@@ -466,8 +800,94 @@ function recordSentContent(state, normalizedPath, version, content) {
466
800
  state.documentContentHashes.set(normalizedPath, {
467
801
  version,
468
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
+ }),
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
+ },
469
839
  });
470
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
+ ];
890
+ }
471
891
  // Methods that can be registered dynamically and map to operationSupport keys
472
892
  const DYNAMIC_OPERATION_METHOD_MAP = {
473
893
  "textDocument/definition": "definition",
@@ -484,14 +904,23 @@ const DYNAMIC_OPERATION_METHOD_MAP = {
484
904
  "textDocument/prepareCallHierarchy": "callHierarchy",
485
905
  };
486
906
  export function applyDynamicCapabilities(state) {
487
- const registeredMethods = new Set(state.dynamicRegistrations.values());
907
+ const registrations = [...state.dynamicRegistrations.values()];
908
+ const registeredMethods = new Set(registrations.map((r) => r.method));
488
909
  const hasDynamicPull = registeredMethods.has("textDocument/diagnostic") ||
489
910
  registeredMethods.has("workspace/diagnostic");
490
911
  if (hasDynamicPull) {
491
912
  state.workspaceDiagnosticsSupport = {
492
913
  advertised: true,
493
914
  mode: "pull",
494
- 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"),
495
924
  diagnosticProviderKind: "dynamic",
496
925
  };
497
926
  }
@@ -511,15 +940,6 @@ export function applyDynamicCapabilities(state) {
511
940
  }
512
941
  }
513
942
  }
514
- /**
515
- * Resolve a `workspace/configuration` request item's `section` (a dot-path,
516
- * e.g. "scan.jobs") against the server's `initializationOptions` blob.
517
- * - No section (undefined/empty) → the whole blob, per spec ("if a scope
518
- * isn't asked for" the client returns the full settings for that scope).
519
- * - An unresolvable path → `null`, never the whole blob — a server asking
520
- * for a section it doesn't get must not silently receive unrelated config.
521
- * Exported for the #983 regression test.
522
- */
523
943
  export function resolveConfigurationSection(initialization, section) {
524
944
  if (!initialization)
525
945
  return section ? null : {};
@@ -527,9 +947,7 @@ export function resolveConfigurationSection(initialization, section) {
527
947
  return initialization;
528
948
  let cur = initialization;
529
949
  for (const part of section.split(".")) {
530
- if (typeof cur !== "object" ||
531
- cur === null ||
532
- !Object.prototype.hasOwnProperty.call(cur, part)) {
950
+ if (typeof cur !== "object" || cur === null || !Object.hasOwn(cur, part)) {
533
951
  return null;
534
952
  }
535
953
  cur = cur[part];
@@ -541,6 +959,12 @@ export function resolveConfigurationSection(initialization, section) {
541
959
  // a real language server. Not part of the public client API surface.
542
960
  export function setupIncomingHandlers(state, initialization) {
543
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();
544
968
  const filePath = uriToPath(params.uri);
545
969
  const normalizedPath = normalizeMapKey(filePath);
546
970
  // A server can flush a queued publish after didClose during teardown.
@@ -558,7 +982,73 @@ export function setupIncomingHandlers(state, initialization) {
558
982
  message: `server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`,
559
983
  });
560
984
  }
561
- const strategy = getStrategy(state.serverId);
985
+ const strategy = getStrategy(state.serverId, state.launchVariant);
986
+ // Publication counting and code extraction exist only for the
987
+ // TypeScript diagnostic-sequence telemetry; skip the bookkeeping for
988
+ // every other push server on this hot receive path.
989
+ const isTypeScriptTelemetry = state.serverId === "typescript";
990
+ const publicationIndex = isTypeScriptTelemetry
991
+ ? (state.diagnosticPublicationCounts.get(normalizedPath) ?? 0) + 1
992
+ : 0;
993
+ if (isTypeScriptTelemetry) {
994
+ state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
995
+ }
996
+ const diagnosticCodes = isTypeScriptTelemetry
997
+ ? [
998
+ ...new Set(newDiags
999
+ .map((diagnostic) => diagnostic.code)
1000
+ .filter((code) => code !== undefined)
1001
+ .map(String)),
1002
+ ].slice(0, 8)
1003
+ : [];
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) => {
1029
+ if (state.serverId !== "typescript")
1030
+ return;
1031
+ const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
1032
+ (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
1033
+ logLatency({
1034
+ type: "phase",
1035
+ phase: "lsp_typescript_diagnostic_sequence",
1036
+ filePath: normalizedPath,
1037
+ durationMs,
1038
+ metadata: {
1039
+ serverId: state.serverId,
1040
+ outcome: settledReturn ? "settled" : "published",
1041
+ launchVariant: state.launchVariant ?? "unknown",
1042
+ publicationIndex,
1043
+ version: docVersion ?? "push-unversioned",
1044
+ diagnosticCount: newDiags.length,
1045
+ diagnosticCodes,
1046
+ elapsedSinceDidOpenMs,
1047
+ settledReturn,
1048
+ settleSource,
1049
+ },
1050
+ });
1051
+ };
562
1052
  // Record the document version these diagnostics were computed against
563
1053
  // (when the server reports it) so waitForDiagnostics can reject results
564
1054
  // that lag behind the latest didChange instead of serving them as fresh.
@@ -603,10 +1093,12 @@ export function setupIncomingHandlers(state, initialization) {
603
1093
  // Known, deliberately out-of-scope gaps: the pull-diagnostics path
604
1094
  // (clientRequestPullDiagnostics/clientRequestWorkspaceDiagnostics) has no
605
1095
  // version stamp to compare against in this codebase's current handling,
606
- // so nothing analogous is applied there. And diagnosticsVersion is a
607
- // single global counter rather than per-path, so an unrelated path's
608
- // fresh push can still satisfy a wait for this path's version bump —
609
- // 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.
610
1102
  const isSupersededPush = () => {
611
1103
  if (docVersion === undefined)
612
1104
  return false;
@@ -622,10 +1114,14 @@ export function setupIncomingHandlers(state, initialization) {
622
1114
  state.pushDiagnostics.set(normalizedPath, newDiags);
623
1115
  state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
624
1116
  recordDocVersion();
625
- state.diagnosticsVersion += 1;
1117
+ bumpDiagnosticsVersion(state, normalizedPath);
626
1118
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
1119
+ // Immediate settle — no debounce wait to measure.
1120
+ logSequence(true, "first-push", 0);
627
1121
  return;
628
1122
  }
1123
+ // A raw per-publication receipt, not itself a settle.
1124
+ logSequence(false, "publication", 0);
629
1125
  const existingTimer = state.pendingDiagnostics.get(normalizedPath);
630
1126
  if (existingTimer)
631
1127
  clearTimeout(existingTimer);
@@ -636,8 +1132,10 @@ export function setupIncomingHandlers(state, initialization) {
636
1132
  state.pushDiagnostics.set(normalizedPath, newDiags);
637
1133
  state.pushDiagnosticTimestamps.set(normalizedPath, Date.now());
638
1134
  recordDocVersion();
639
- state.diagnosticsVersion += 1;
1135
+ bumpDiagnosticsVersion(state, normalizedPath);
640
1136
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
1137
+ // The real debounce wait, timed from THIS publish's own receipt.
1138
+ logSequence(true, "quiet-window", Date.now() - publishReceivedAt);
641
1139
  }, strategy.debounceMs);
642
1140
  state.pendingDiagnostics.set(normalizedPath, timer);
643
1141
  });
@@ -646,25 +1144,57 @@ export function setupIncomingHandlers(state, initialization) {
646
1144
  ]);
647
1145
  state.connection.onRequest("client/registerCapability", async (params) => {
648
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;
649
1151
  if (reg.id && reg.method) {
650
- 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
+ });
651
1166
  }
652
1167
  // executeCommand commands can arrive dynamically too — merge them
653
1168
  // into the allowlist so dynamically-registered commands are runnable.
654
- if (reg.method === "workspace/executeCommand" &&
655
- Array.isArray(reg.registerOptions?.commands)) {
656
- for (const cmd of reg.registerOptions.commands) {
657
- if (typeof cmd === "string")
658
- state.advertisedCommands.add(cmd);
659
- }
1169
+ if (reg.method === "workspace/executeCommand" && commands) {
1170
+ for (const cmd of commands)
1171
+ state.advertisedCommands.add(cmd);
660
1172
  }
661
1173
  }
662
1174
  applyDynamicCapabilities(state);
663
1175
  });
664
1176
  state.connection.onRequest("client/unregisterCapability", async (params) => {
1177
+ const retiredIdentifiers = new Set();
665
1178
  for (const unreg of params?.unregisterations ?? []) {
666
- if (unreg.id) {
667
- 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);
668
1198
  }
669
1199
  }
670
1200
  applyDynamicCapabilities(state);
@@ -713,6 +1243,128 @@ export function setupIncomingHandlers(state, initialization) {
713
1243
  return items.map((item) => resolveConfigurationSection(initialization, item?.section));
714
1244
  });
715
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
+ });
716
1368
  }
717
1369
  /**
718
1370
  * #1127: record the FIRST moment this client observed its own death. Detection
@@ -771,26 +1423,160 @@ function setupConnectionLifecycle(state, recentStderr) {
771
1423
  }
772
1424
  });
773
1425
  }
1426
+ /** Margin between each source's own request timeout and the race's backstop
1427
+ * deadline, so the natural "all sources settled" path wins over the backstop
1428
+ * and the race never reports `unavailable` for sources that did answer. */
1429
+ const PULL_RACE_BACKSTOP_MARGIN_MS = 25;
1430
+ /**
1431
+ * #1667: pull EVERY registered diagnostic source for a file, in parallel, and
1432
+ * answer as soon as the first source returns findings for that file.
1433
+ *
1434
+ * Roslyn, vtsls and some Java setups register `textDocument/diagnostic` once
1435
+ * per source (syntax, semantic, analyzers), each named by
1436
+ * `registerOptions.identifier`. One bare request asks only the server's default
1437
+ * source, so whole categories of findings were never requested. The fan-out
1438
+ * asks all of them plus the bare request.
1439
+ *
1440
+ * Timing is deliberate and load-bearing:
1441
+ * - PARALLEL, never one source after another. Sequencing would add every
1442
+ * slow source's latency to every touch.
1443
+ * - The race resolves on the FIRST source with findings for the file, with no
1444
+ * settle or debounce window after the match. A post-match wait is the exact
1445
+ * latency shape #1112/#1407 already cost this codebase once.
1446
+ * - Losing sources are NOT cancelled. Each source writes its own findings into
1447
+ * the pull cache before it resolves, so a late answer merges in the
1448
+ * background and the NEXT read of the file sees it. Late answers are not
1449
+ * dropped, they are just not waited for.
1450
+ *
1451
+ * The race runs inside the existing `raceToCompletion` primitive rather than a
1452
+ * second first-wins helper.
1453
+ */
774
1454
  async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQUEST_TIMEOUT_MS) {
775
1455
  if (!isClientAlive(state))
776
1456
  return { status: "unavailable" };
777
- const uri = pathToFileURL(filePath).href;
1457
+ const sources = pullDiagnosticSources(state);
1458
+ if (sources.length === 1) {
1459
+ // The overwhelmingly common case: no named sources registered. Skip the
1460
+ // race entirely so a single-source server pays nothing for this feature.
1461
+ return aggregatePullOutcomes(state, filePath, [
1462
+ await pullDiagnosticSource(state, filePath, budgetMs, undefined),
1463
+ ]);
1464
+ }
1465
+ const attempts = sources.map((identifier) => pullDiagnosticSource(state, filePath, budgetMs, identifier));
1466
+ const settled = await raceToCompletion(attempts, (results) => results.some((r) => r.status === "found" && r.primaryCount > 0), {
1467
+ // No graceMs: finalize the instant a source answers for this file.
1468
+ // The per-request `withTimeout` inside each attempt is the real bound;
1469
+ // this deadline is only a backstop, so it sits just past it.
1470
+ timeoutMs: Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)) +
1471
+ PULL_RACE_BACKSTOP_MARGIN_MS,
1472
+ });
1473
+ return aggregatePullOutcomes(state, filePath, settled);
1474
+ }
1475
+ /**
1476
+ * Collapse the sources that answered into one outcome for the caller.
1477
+ * `found` beats `clean` beats `unavailable`: a source with findings is an
1478
+ * answer, an authoritative empty report is an answer, and only "nobody
1479
+ * answered" is unavailable (#240 — an empty result must distinguish clean
1480
+ * from errored).
1481
+ */
1482
+ function aggregatePullOutcomes(state, filePath, outcomes) {
1483
+ const found = outcomes.filter((o) => o.status === "found");
1484
+ if (found.length > 0) {
1485
+ const normalizedPath = normalizeMapKey(filePath);
1486
+ // The union for the requested file, which is what a caller reading the
1487
+ // cache will see. A source that only reported `relatedDocuments` findings
1488
+ // contributes nothing there, so fall back to its own count.
1489
+ const unionCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
1490
+ return {
1491
+ status: "found",
1492
+ count: Math.max(unionCount, ...found.map((o) => o.count)),
1493
+ };
1494
+ }
1495
+ // #240, applied across sources: `clean` is an authoritative "this file has no
1496
+ // findings", and one source saying so proves nothing about a source that
1497
+ // FAILED to answer. A fan-out where any source errored is therefore
1498
+ // unavailable, not clean - the caller falls through to the push-wait/timeout
1499
+ // backstop instead of recording a confirmed-clean touch it cannot support.
1500
+ // (`raceToCompletion` drops still-pending promises from its result array, so
1501
+ // "every source settled, none errored" is the only shape that reads clean.)
1502
+ if (outcomes.some((o) => o.status === "unavailable")) {
1503
+ return { status: "unavailable" };
1504
+ }
1505
+ return outcomes.some((o) => o.status === "clean")
1506
+ ? { status: "clean" }
1507
+ : { status: "unavailable" };
1508
+ }
1509
+ async function pullDiagnosticSource(state, filePath, budgetMs, identifier) {
1510
+ if (!isClientAlive(state))
1511
+ return { status: "unavailable" };
778
1512
  const normalizedPath = normalizeMapKey(filePath);
1513
+ // #1773: a budget at or below the usable floor cannot complete a real
1514
+ // round trip. Skip the dispatch entirely — no `safeSendRequest` call, no
1515
+ // generation/sequence claim — and say so with a distinct record, so
1516
+ // `lsp_pull_diagnostic_timeout` only ever means a pull was genuinely
1517
+ // attempted. The caller sees the same `unavailable` outcome a timeout
1518
+ // would have produced (#240: unavailable, never a false clean), so
1519
+ // nothing downstream of this call changes shape.
1520
+ if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
1521
+ emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${pullSourceKey(normalizedPath, identifier)}`, {
1522
+ type: "phase",
1523
+ filePath: normalizedPath,
1524
+ durationMs: 0,
1525
+ metadata: {
1526
+ identifier: identifier ?? "bare",
1527
+ remainingBudgetMs: budgetMs,
1528
+ server: state.serverId,
1529
+ },
1530
+ }, {
1531
+ ledgerKind: "lsp-pull-skipped-budget-exhausted",
1532
+ reason: `pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
1533
+ });
1534
+ return { status: "unavailable" };
1535
+ }
1536
+ const uri = pathToFileURL(filePath).href;
779
1537
  // #1104: echo the last resultId we hold for this document so a server that
780
1538
  // hasn't changed its view can answer `kind: "unchanged"` instead of
781
1539
  // recomputing — see the `kind === "unchanged"` branch below for how that's
782
1540
  // honored (inherit, never treat an omitted `items` as clean).
783
- const previousResultId = state.pullResultIds.get(normalizedPath);
1541
+ // #1667: read THIS source's id, never another source's. Result ids are
1542
+ // issued per source, so echoing a syntax id on a semantic request asks the
1543
+ // server to compare against a basis it never handed out.
1544
+ const sourceKey = pullSourceKey(normalizedPath, identifier);
1545
+ if (state.abandonedPullRequests?.has(sourceKey)) {
1546
+ return { status: "unavailable" };
1547
+ }
1548
+ const previousResultId = state.pullResultIds.get(sourceKey);
1549
+ // #1667: the generation this pull was computed against. A resync landing
1550
+ // while the request is in flight bumps it, and every write below is dropped
1551
+ // rather than resurrecting state the resync cleared.
1552
+ const generation = pullGenerationFor(state, normalizedPath);
1553
+ // #1667: claim the newest-request slot for THIS source. Two fan-outs can
1554
+ // overlap for the same file with no resync between them (`ensureWarmForSweep`
1555
+ // touches, then the real touch follows ~60ms later), so the generation check
1556
+ // alone would let a slow answer from the earlier round land on top of a newer
1557
+ // one. Re-checked at write time below.
1558
+ const requestSequence = claimPullRequestSequence(state, sourceKey);
1559
+ // #1889: give the request a cancellation token. `withTimeout` only abandons
1560
+ // its await; aborting in the timeout branch below also sends `$/cancelRequest`
1561
+ // so repeated touches cannot leave an aging backlog inside the server.
1562
+ const requestStartedAt = Date.now();
1563
+ const effectiveTimeoutMs = Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs));
1564
+ const pullAbort = new AbortController();
1565
+ const pullSettlement = { cancelled: false };
1566
+ const requestPromise = safeSendRequest(state.connection, "textDocument/diagnostic", {
1567
+ textDocument: { uri },
1568
+ // #1667: name the source this request is for, so the server answers
1569
+ // from that source instead of its default one.
1570
+ ...(identifier !== undefined && { identifier }),
1571
+ ...(previousResultId !== undefined && { previousResultId }),
1572
+ }, pullAbort.signal, pullSettlement);
784
1573
  try {
785
1574
  // withTimeout is the backstop against a hung pull-mode server: without it
786
1575
  // this await never settles unless the stream is destroyed. Bounded by the
787
1576
  // smaller of the absolute ceiling and the caller's remaining wait budget.
788
1577
  // On timeout the caught error yields `unavailable` below (never a false
789
1578
  // `clean`), so it falls through to the push-wait/timeout backstop.
790
- const report = await withTimeout(safeSendRequest(state.connection, "textDocument/diagnostic", {
791
- textDocument: { uri },
792
- ...(previousResultId !== undefined && { previousResultId }),
793
- }), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
1579
+ const report = await withTimeout(requestPromise, effectiveTimeoutMs);
794
1580
  if (!report) {
795
1581
  recordPullFailure(state, "textDocument/diagnostic", new Error("empty response"));
796
1582
  return { status: "unavailable" };
@@ -803,13 +1589,28 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
803
1589
  // response describes whatever the server had when it answered, which for
804
1590
  // a pi-lens-opened document is exactly that last-sent payload.
805
1591
  const sentHash = state.documentContentHashes.get(normalizedPath)?.hash;
806
- let totalCount;
1592
+ // #1667: two ways this answer can already be obsolete, and neither may
1593
+ // write. Report the round trip as unavailable - a superseded answer is not
1594
+ // evidence the file is clean (#240).
1595
+ // - A resync landed while the request was in flight, so these findings
1596
+ // describe content that no longer exists.
1597
+ // - A LATER request for this same source has already been issued (an
1598
+ // overlapping fan-out), so this answer is the stale loser and would
1599
+ // clobber the newer result.
1600
+ if (pullGenerationFor(state, normalizedPath) !== generation ||
1601
+ !isNewestPullRequest(state, sourceKey, requestSequence)) {
1602
+ return { status: "unavailable" };
1603
+ }
1604
+ let primaryCount;
807
1605
  if (report.kind === "unchanged") {
808
1606
  // #1104: same resultId basis as last time — an omitted `items` here
809
1607
  // means "no change", NOT "clean". Overwriting with `[]` would be the
810
1608
  // exact false-clean shape #570/#571 already fixed for the touch path;
811
1609
  // keep the previously stored diagnostics and binding as-is.
812
- totalCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
1610
+ // #1667: "the previously stored diagnostics" means THIS SOURCE's, so
1611
+ // one source reporting unchanged neither inherits nor disturbs another
1612
+ // source's findings for the same file.
1613
+ primaryCount = pullSourceDiagnostics(state, normalizedPath, identifier).length;
813
1614
  // Still a fresh confirmation as of `now` even though the content is
814
1615
  // unchanged — bump the timestamp so `getAllDiagnostics()` doesn't read
815
1616
  // this entry as aging purely because the server had nothing new to say.
@@ -817,30 +1618,43 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
817
1618
  }
818
1619
  else {
819
1620
  const primaryItems = normalizeLspDiagnostics(report.items ?? []);
820
- state.documentPullDiagnostics.set(normalizedPath, primaryItems);
1621
+ // #1667: replace only this source's slice; `documentPullDiagnostics`
1622
+ // becomes the deduped union of every source that has reported.
1623
+ storePullSourceDiagnostics(state, normalizedPath, identifier, primaryItems);
821
1624
  state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
822
- state.diagnosticsVersion += 1;
1625
+ bumpDiagnosticsVersion(state, normalizedPath);
823
1626
  state.diagnosticBindings.set(normalizedPath, { contentHash: sentHash });
824
- totalCount = primaryItems.length;
1627
+ primaryCount = primaryItems.length;
825
1628
  }
1629
+ let totalCount = primaryCount;
826
1630
  if (report.resultId !== undefined) {
827
- state.pullResultIds.set(normalizedPath, report.resultId);
1631
+ state.pullResultIds.set(sourceKey, report.resultId);
828
1632
  }
829
1633
  else {
830
- state.pullResultIds.delete(normalizedPath);
1634
+ state.pullResultIds.delete(sourceKey);
831
1635
  }
1636
+ // #1531 note (pre-existing, unchanged): a related document's diagnostics are
1637
+ // STORED below but earn no version bump and therefore no per-path stamp —
1638
+ // exactly as before, since this loop never bumped the global counter either.
1639
+ // A wait on a path that only ever hears about itself through some other
1640
+ // document's `relatedDocuments` sees no freshness evidence and rides its
1641
+ // timeout. Direction is under-detection, and correcting it means deciding
1642
+ // what a related-document publication is evidence OF (its binding is
1643
+ // honestly "unknown" per #1104), so it stays out of scope here.
832
1644
  if (report.relatedDocuments) {
833
1645
  for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
834
1646
  const relatedPath = uriToPath(relatedUri);
835
1647
  const relatedNormalized = normalizeMapKey(relatedPath);
1648
+ // #1667: a related document's report belongs to the SAME source as
1649
+ // the request that carried it, so it stores and inherits under that
1650
+ // source's key too.
836
1651
  if (related?.kind === "unchanged") {
837
- totalCount +=
838
- state.documentPullDiagnostics.get(relatedNormalized)?.length ?? 0;
1652
+ totalCount += pullSourceDiagnostics(state, relatedNormalized, identifier).length;
839
1653
  state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
840
1654
  }
841
1655
  else {
842
1656
  const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
843
- state.documentPullDiagnostics.set(relatedNormalized, relatedItems);
1657
+ storePullSourceDiagnostics(state, relatedNormalized, identifier, relatedItems);
844
1658
  state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
845
1659
  // #1104: a related document's diagnostics were NOT computed against
846
1660
  // content we independently sent/fingerprinted (we never requested
@@ -850,20 +1664,186 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
850
1664
  totalCount += relatedItems.length;
851
1665
  }
852
1666
  if (related?.resultId !== undefined) {
853
- state.pullResultIds.set(relatedNormalized, related.resultId);
1667
+ state.pullResultIds.set(pullSourceKey(relatedNormalized, identifier), related.resultId);
854
1668
  }
855
1669
  }
856
1670
  }
857
1671
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
858
1672
  return totalCount > 0
859
- ? { status: "found", count: totalCount }
1673
+ ? { status: "found", count: totalCount, primaryCount }
860
1674
  : { status: "clean" };
861
1675
  }
862
1676
  catch (err) {
1677
+ if (isPullTimeoutError(err, effectiveTimeoutMs)) {
1678
+ trackAbandonedPullRequest(state, sourceKey, requestPromise);
1679
+ pullAbort.abort();
1680
+ recordPullTimeoutTelemetry({
1681
+ scope: normalizedPath,
1682
+ identifier,
1683
+ effectiveBudgetMs: effectiveTimeoutMs,
1684
+ hadPreviousResultId: previousResultId !== undefined,
1685
+ elapsedMs: Date.now() - requestStartedAt,
1686
+ server: state.serverId,
1687
+ });
1688
+ armLateAnswerTelemetry({
1689
+ requestPromise,
1690
+ scope: normalizedPath,
1691
+ identifier,
1692
+ subject: sourceKey,
1693
+ requestStartedAt,
1694
+ server: state.serverId,
1695
+ settlement: pullSettlement,
1696
+ });
1697
+ }
863
1698
  recordPullFailure(state, "textDocument/diagnostic", err);
864
1699
  return { status: "unavailable" };
865
1700
  }
866
1701
  }
1702
+ function trackAbandonedPullRequest(state, sourceKey, requestPromise) {
1703
+ if (!state.abandonedPullRequests)
1704
+ state.abandonedPullRequests = new Map();
1705
+ state.abandonedPullRequests.set(sourceKey, requestPromise);
1706
+ const release = () => {
1707
+ if (state.abandonedPullRequests?.get(sourceKey) === requestPromise) {
1708
+ state.abandonedPullRequests.delete(sourceKey);
1709
+ }
1710
+ };
1711
+ requestPromise.then(release, release);
1712
+ }
1713
+ /**
1714
+ * #1713: `withTimeout` rejects with this EXACT message (`clients/deadline-utils.ts`)
1715
+ * only when its own timer wins the race — every other rejection is the request
1716
+ * itself failing (bad params, dead connection, thrown by the server). Only the
1717
+ * timer-won case is a "the answer might still be coming" situation worth a
1718
+ * late-answer watch; a request that already failed fast has nothing further to
1719
+ * observe.
1720
+ */
1721
+ function isPullTimeoutError(err, timeoutMs) {
1722
+ return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
1723
+ }
1724
+ /**
1725
+ * #1713: every pull timeout emits exactly one bounded latency.log record — the
1726
+ * observability gap the issue names (`withTimeout`'s throw previously skipped
1727
+ * ALL telemetry for this site). Never throws: telemetry must not perturb the
1728
+ * timeout path, which has already decided to return `unavailable` regardless.
1729
+ *
1730
+ * #1743: routed through `emitBounded`. This phase takes NO rising-edge gate
1731
+ * and NO per-turn cap, which is the pre-existing behavior kept exactly: a
1732
+ * pull timeout costs at least the full budget (seconds), so the call cadence
1733
+ * already bounds the volume, unlike `navRequest` below. The helper still
1734
+ * supplies the identity discipline and the registry membership the sweep
1735
+ * checks.
1736
+ */
1737
+ function recordPullTimeoutTelemetry(args) {
1738
+ // #1771: this record is written on a genuine failure path (a dispatched
1739
+ // pull that never answered), so per the bounded-telemetry rule
1740
+ // (`clients/bounded-telemetry.ts`) it needs a `ledgerKind`, not just the
1741
+ // detailed latency.log record. Before this it counted nothing in the
1742
+ // degradation ledger — every occurrence wrote a detail line, but nothing
1743
+ // tallied. No `risingEdgePer`: timeouts repeat and every one should count,
1744
+ // matching the pre-existing "no rising-edge gate" behavior for the
1745
+ // detailed record itself.
1746
+ emitBounded("lsp_pull_diagnostic_timeout", `${args.server}::${args.scope}::${args.identifier ?? "bare"}`, {
1747
+ type: "phase",
1748
+ filePath: args.scope,
1749
+ durationMs: args.elapsedMs,
1750
+ metadata: {
1751
+ identifier: args.identifier ?? "bare",
1752
+ effectiveBudgetMs: args.effectiveBudgetMs,
1753
+ hadPreviousResultId: args.hadPreviousResultId,
1754
+ server: args.server,
1755
+ },
1756
+ }, {
1757
+ ledgerKind: "lsp-pull-diagnostic-timeout",
1758
+ reason: `pull timeout on ${args.server} after ${args.elapsedMs}ms (budget ${args.effectiveBudgetMs}ms)`,
1759
+ });
1760
+ }
1761
+ /**
1762
+ * #1713: a telemetry-only continuation on the ALREADY-abandoned request
1763
+ * promise. `withTimeout` only raced it against a timer — the request is still
1764
+ * live server-side — so if it eventually resolves, this is the only place that
1765
+ * ever finds out. Purely observational: it runs after the caller has already
1766
+ * moved on with `unavailable`, and it never writes into `state`.
1767
+ *
1768
+ * Bounded via the degradation-ledger convention (`incrementDegradationCount`,
1769
+ * chosen over a hand-rolled per-identity rising-edge Set): the ledger already
1770
+ * caps entries per kind and re-arms at `session_start`
1771
+ * (`resetDegradationLedger`, wired into `handleSessionStart`), so reusing it
1772
+ * avoids a second latch this fix would otherwise have to register and reset
1773
+ * itself. `subject` is the same `pullSourceKey` (path + identifier) the pull
1774
+ * path already uses, so the discriminating identity survives aggregation.
1775
+ *
1776
+ * Attaching `.then` here creates no timer, socket, or other handle — it only
1777
+ * observes a promise the request already created — so it cannot keep the
1778
+ * process alive a moment longer than that pending request already does.
1779
+ */
1780
+ function armLateAnswerTelemetry(args) {
1781
+ args.requestPromise.then(() => {
1782
+ if (args.settlement.cancelled)
1783
+ return;
1784
+ try {
1785
+ const elapsedMs = Date.now() - args.requestStartedAt;
1786
+ // #1743: `emitBounded` counts every late answer in the ledger
1787
+ // under the same `pullSourceKey` subject as before, then writes
1788
+ // the record. No rising-edge gate, matching #1713's original
1789
+ // behavior: a late answer arrives at most once per abandoned
1790
+ // request, so the record count cannot exceed the timeout count.
1791
+ emitBounded("lsp_pull_late_answer_discarded", args.subject, {
1792
+ type: "phase",
1793
+ filePath: args.scope,
1794
+ durationMs: elapsedMs,
1795
+ metadata: { identifier: args.identifier ?? "bare" },
1796
+ }, {
1797
+ ledgerKind: "lsp-pull-late-answer",
1798
+ reason: `late pull answer discarded after ${elapsedMs}ms`,
1799
+ });
1800
+ }
1801
+ catch {
1802
+ // Telemetry must never break the observed path.
1803
+ }
1804
+ }, (err) => {
1805
+ // #1774: the abandoned request eventually REJECTED rather than
1806
+ // answering. Behavior is unchanged — the rejection is still
1807
+ // swallowed here, exactly as before — but "timeout then silence"
1808
+ // and "timeout then server rejection (e.g. ContentModified)" were
1809
+ // previously indistinguishable: `recordPullFailure` at request
1810
+ // time reports the FIRST failure only, and this rejection happens
1811
+ // strictly after that request already timed out, so it needs its
1812
+ // own record. Bounded via the degradation ledger, same shape as
1813
+ // the late-answer branch above, so this rejection cannot outpace
1814
+ // the timeout count that gates it.
1815
+ try {
1816
+ const elapsedMs = Date.now() - args.requestStartedAt;
1817
+ const candidate = err;
1818
+ const code = typeof candidate?.code === "number" ||
1819
+ typeof candidate?.code === "string"
1820
+ ? candidate.code
1821
+ : undefined;
1822
+ emitBounded("lsp_pull_late_rejection",
1823
+ // #1774 review: prefix the server, same reason the timeout kind
1824
+ // does (#1771). The workspace call site's `subject` is the bare
1825
+ // `WORKSPACE_PULL_SCOPE` constant — without the server prefix,
1826
+ // two servers' abandoned-workspace-pull rejections would collapse
1827
+ // into one ledger subject and hide which server is storming.
1828
+ `${args.server}::${args.subject}`, {
1829
+ type: "phase",
1830
+ filePath: args.scope,
1831
+ durationMs: elapsedMs,
1832
+ metadata: {
1833
+ identifier: args.identifier ?? "bare",
1834
+ server: args.server,
1835
+ ...(code !== undefined && { code }),
1836
+ },
1837
+ }, {
1838
+ ledgerKind: "lsp-pull-late-rejection",
1839
+ reason: `late pull rejection ${elapsedMs}ms after timeout${code !== undefined ? ` (code ${code})` : ""}`,
1840
+ });
1841
+ }
1842
+ catch {
1843
+ // Telemetry must never break the observed path.
1844
+ }
1845
+ });
1846
+ }
867
1847
  const PULL_FAILURE_HISTORY_LIMIT = 10;
868
1848
  function recordPullFailure(state, method, error) {
869
1849
  const candidate = error;
@@ -879,9 +1859,7 @@ function recordPullFailure(state, method, error) {
879
1859
  ...(typeof candidate.code === "number" || typeof candidate.code === "string"
880
1860
  ? { code: candidate.code }
881
1861
  : {}),
882
- message: typeof candidate.message === "string"
883
- ? candidate.message
884
- : String(error),
1862
+ message: typeof candidate.message === "string" ? candidate.message : String(error),
885
1863
  });
886
1864
  if (state.pullFailureHistory.length > PULL_FAILURE_HISTORY_LIMIT) {
887
1865
  state.pullFailureHistory.splice(0, state.pullFailureHistory.length - PULL_FAILURE_HISTORY_LIMIT);
@@ -899,15 +1877,58 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
899
1877
  return undefined;
900
1878
  if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
901
1879
  return undefined;
1880
+ if (state.abandonedPullRequests?.has(WORKSPACE_PULL_SCOPE))
1881
+ return undefined;
1882
+ // #1104: echo every resultId we hold from a PRIOR pull so the server can
1883
+ // answer `kind: "unchanged"` for files it hasn't recomputed, instead of
1884
+ // resending (and us re-hashing) every file on every sweep.
1885
+ const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
1886
+ // #1773: same floor as `pullDiagnosticSource` — an exhausted budget here is
1887
+ // the SAME clamp-then-dispatch defect via a second entry point. Observed
1888
+ // live in the 2026-08-20 plegma dogfood session via
1889
+ // `lsp_workspace_diagnostics_start` (not just the `lens_diagnostics_full`
1890
+ // fan-out `pullDiagnosticSource` covers), so this call site needs its own
1891
+ // skip, not just a shared helper the caller happens to hit.
1892
+ if (budgetMs < PULL_MIN_USABLE_BUDGET_MS) {
1893
+ emitBounded("lsp_pull_skipped_budget_exhausted", `${state.serverId}::${WORKSPACE_PULL_SCOPE}`, {
1894
+ type: "phase",
1895
+ filePath: WORKSPACE_PULL_SCOPE,
1896
+ durationMs: 0,
1897
+ metadata: {
1898
+ identifier: "bare",
1899
+ remainingBudgetMs: budgetMs,
1900
+ server: state.serverId,
1901
+ },
1902
+ }, {
1903
+ ledgerKind: "lsp-pull-skipped-budget-exhausted",
1904
+ reason: `workspace pull skipped on ${state.serverId}: budget already exhausted (${budgetMs}ms remaining)`,
1905
+ });
1906
+ return undefined;
1907
+ }
1908
+ // #1713: declared OUTSIDE the try so the catch below can still reach them —
1909
+ // same shape as `pullDiagnosticSource` above. Captured outside `withTimeout`
1910
+ // so a timeout still holds a live handle on the request for the late-answer
1911
+ // watch.
1912
+ const workspacePullStartedAt = Date.now();
1913
+ const workspacePullTimeoutMs = Math.max(1, budgetMs);
1914
+ const workspacePullAbort = new AbortController();
1915
+ const workspacePullSettlement = { cancelled: false };
1916
+ const workspaceRequestPromise = safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }, workspacePullAbort.signal, workspacePullSettlement);
902
1917
  try {
903
- // #1104: echo every resultId we hold from a PRIOR pull so the server can
904
- // answer `kind: "unchanged"` for files it hasn't recomputed, instead of
905
- // resending (and us re-hashing) every file on every sweep.
906
- const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
907
- const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }), Math.max(1, budgetMs));
1918
+ const report = await withTimeout(workspaceRequestPromise, workspacePullTimeoutMs);
908
1919
  if (!report || !Array.isArray(report.items))
909
1920
  return undefined;
910
1921
  const out = [];
1922
+ // #1669 review N1: ONE deadline, shared across every "unchanged, no
1923
+ // cached basis" fallback pull this call makes — not a fresh `budgetMs`
1924
+ // grant handed to EACH one. A refresh clears `workspacePullResultCache`,
1925
+ // so the very next sweep after a refresh can route every item down this
1926
+ // path; granting each one the full budget serially turned a bounded call
1927
+ // unbounded (measured: 6 files x 300ms budget = 1844ms; worst case is
1928
+ // the normal case — 2000 files x 30s). Mirrors the shared-budget shape
1929
+ // `runWorkspaceDiagnosticsSwept`'s `perFileMs` bounds each file to,
1930
+ // scoped to fallback pulls made from within THIS one call.
1931
+ const fallbackPullDeadline = Date.now() + Math.max(1, budgetMs);
911
1932
  for (const item of report.items) {
912
1933
  if (!item?.uri)
913
1934
  continue;
@@ -919,8 +1940,53 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
919
1940
  // `items`, so without this a file the server confirmed unchanged
920
1941
  // would silently drop out of the sweep result entirely.
921
1942
  const prior = state.workspacePullResultCache.get(normalizedPath);
922
- if (!prior)
923
- continue; // no earlier basis to inherit — nothing to report
1943
+ if (!prior) {
1944
+ // #1669 review F4: no earlier basis to inherit (e.g. right after a
1945
+ // `workspace/diagnostic/refresh` cleared `workspacePullResultCache`)
1946
+ // — the server is answering "unchanged" against a resultId basis we
1947
+ // no longer hold, so there is nothing honest to report for this
1948
+ // file from THIS response. `continue`-ing here would make the file
1949
+ // silently absent from `out`, which the caller reads as "clean" —
1950
+ // a false clean for a file the server just told us it hasn't even
1951
+ // looked at freshly. Treat it as needs-full-pull: request the
1952
+ // per-file diagnostic directly instead.
1953
+ const remainingMs = fallbackPullDeadline - Date.now();
1954
+ if (remainingMs <= 0) {
1955
+ // #1669 review N1: the shared fallback-pull deadline is already
1956
+ // exhausted by earlier files in this same loop — bail WITHOUT
1957
+ // attempting a request (never fabricate clean; this file was
1958
+ // never actually asked about) and surface it as unavailable via
1959
+ // the same degradation ledger path as a real unavailable pull.
1960
+ incrementDegradationCount({
1961
+ kind: "lsp-pull-unconfirmed",
1962
+ subject: state.serverId,
1963
+ reason: "unchanged report with no workspacePullResultCache basis; the shared fallback-pull deadline was already exhausted by earlier files this sweep",
1964
+ });
1965
+ continue;
1966
+ }
1967
+ const outcome = await clientRequestPullDiagnostics(state, filePath, remainingMs);
1968
+ if (outcome.status === "found") {
1969
+ out.push({
1970
+ filePath,
1971
+ diagnostics: state.documentPullDiagnostics.get(normalizedPath) ?? [],
1972
+ contentHash: state.diagnosticBindings.get(normalizedPath)?.contentHash,
1973
+ });
1974
+ }
1975
+ else if (outcome.status === "unavailable") {
1976
+ // Genuinely unconfirmed — never fabricate a clean result. Recorded
1977
+ // via the shared degradation ledger (AGENTS.md: repeated
1978
+ // degradations use incrementDegradationCount, not a hand-rolled
1979
+ // counter) so a server that keeps racing refresh against sweeps
1980
+ // is visible in aggregate instead of silently absorbed per-file.
1981
+ incrementDegradationCount({
1982
+ kind: "lsp-pull-unconfirmed",
1983
+ subject: state.serverId,
1984
+ reason: "unchanged report with no workspacePullResultCache basis, and the per-file fallback pull was unavailable",
1985
+ });
1986
+ }
1987
+ // "clean": genuinely clean, correctly absent from `out`.
1988
+ continue;
1989
+ }
924
1990
  if (item.resultId !== undefined) {
925
1991
  state.workspacePullResultCache.set(normalizedPath, {
926
1992
  ...prior,
@@ -966,6 +2032,27 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
966
2032
  return out;
967
2033
  }
968
2034
  catch (err) {
2035
+ if (isPullTimeoutError(err, workspacePullTimeoutMs)) {
2036
+ trackAbandonedPullRequest(state, WORKSPACE_PULL_SCOPE, workspaceRequestPromise);
2037
+ workspacePullAbort.abort();
2038
+ recordPullTimeoutTelemetry({
2039
+ scope: WORKSPACE_PULL_SCOPE,
2040
+ identifier: undefined,
2041
+ effectiveBudgetMs: workspacePullTimeoutMs,
2042
+ hadPreviousResultId: previousResultIds.length > 0,
2043
+ elapsedMs: Date.now() - workspacePullStartedAt,
2044
+ server: state.serverId,
2045
+ });
2046
+ armLateAnswerTelemetry({
2047
+ requestPromise: workspaceRequestPromise,
2048
+ scope: WORKSPACE_PULL_SCOPE,
2049
+ identifier: undefined,
2050
+ subject: WORKSPACE_PULL_SCOPE,
2051
+ requestStartedAt: workspacePullStartedAt,
2052
+ server: state.serverId,
2053
+ settlement: workspacePullSettlement,
2054
+ });
2055
+ }
969
2056
  recordPullFailure(state, "workspace/diagnostic", err);
970
2057
  return undefined;
971
2058
  }
@@ -973,7 +2060,17 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
973
2060
  export async function clientWaitForDiagnostics(state, filePath, timeoutMs, options = {}) {
974
2061
  const normalizedPath = normalizeMapKey(filePath);
975
2062
  const minVersion = options.minVersion;
976
- const hasFreshDiagnostics = () => minVersion === undefined || state.diagnosticsVersion > minVersion;
2063
+ const pullSettleSource = options.pullSettleSource ?? "pull";
2064
+ // #1531: the freshness gate is PER PATH. `minVersion` is a reading of the
2065
+ // client-global counter, and `diagnosticsVersionsByPath` stores that same
2066
+ // counter's value at each store — the two are on one axis, so comparing them
2067
+ // asks "has a publication landed for THIS file since the baseline?" instead of
2068
+ // "has anything at all landed on this client?". Reading the global counter here
2069
+ // let a sibling file's publication end this file's wait before its own budget
2070
+ // lapsed, which then labelled the outcome row `silent` (reserved for "own
2071
+ // budget lapsed with nothing published") instead of `cut_off`.
2072
+ const hasFreshDiagnostics = () => minVersion === undefined ||
2073
+ diagnosticsVersionForPath(state, normalizedPath) > minVersion;
977
2074
  // Version coherence: a cached push is "stale" only when the server reported
978
2075
  // the document version it computed against AND that version lags the latest
979
2076
  // didChange we sent. This prevents serving diagnostics from a superseded
@@ -997,11 +2094,17 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
997
2094
  // `hasFreshDiagnostics()`, which is unconditionally true when there is no
998
2095
  // version baseline (`minVersion === undefined`), so a failed pull returned
999
2096
  // 0 and was read as a fresh clean.
2097
+ // #1639: the settle operation's OWN clock — durationMs on the eventual
2098
+ // log record measures from here, never from didOpen (that stays in
2099
+ // metadata.elapsedSinceDidOpenMs, computed separately below).
2100
+ const pullSettleStartedAt = Date.now();
1000
2101
  let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
1001
- if (outcome.status === "found")
2102
+ if (outcome.status === "found") {
2103
+ logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
1002
2104
  return;
2105
+ }
1003
2106
  let sawClean = outcome.status === "clean";
1004
- const strategy = getStrategy(state.serverId);
2107
+ const strategy = getStrategy(state.serverId, state.launchVariant);
1005
2108
  const retryBudgetMs = strategy.pullRetryBudgetMs > 0
1006
2109
  ? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
1007
2110
  : 0;
@@ -1016,10 +2119,16 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1016
2119
  if (outcome.status === "clean")
1017
2120
  sawClean = true;
1018
2121
  }
1019
- if (options.pullOnly)
2122
+ if (options.pullOnly) {
2123
+ if (outcome.status === "found" || sawClean) {
2124
+ logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
2125
+ }
1020
2126
  return;
1021
- if (outcome.status === "found" || sawClean)
2127
+ }
2128
+ if (outcome.status === "found" || sawClean) {
2129
+ logTypeScriptPullSettle(state, normalizedPath, Date.now() - pullSettleStartedAt, pullSettleSource);
1022
2130
  return;
2131
+ }
1023
2132
  }
1024
2133
  if (hasFreshDiagnostics() &&
1025
2134
  !isVersionStale() &&
@@ -1037,7 +2146,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1037
2146
  clearTimeout(debounceTimer);
1038
2147
  // Adaptive debounce: use time since last push to compute remaining
1039
2148
  // wait instead of always waiting the full debounce window.
1040
- const strategy = getStrategy(state.serverId);
2149
+ const strategy = getStrategy(state.serverId, state.launchVariant);
1041
2150
  const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
1042
2151
  const timeSincePush = hit ? Date.now() - hit : Infinity;
1043
2152
  const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
@@ -1056,6 +2165,21 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
1056
2165
  }, timeoutMs);
1057
2166
  });
1058
2167
  }
2168
+ /**
2169
+ * Queue a watched-files change for a file this client did not learn about
2170
+ * through textDocument/didOpen or didChange — an external bash write/delete,
2171
+ * or any other disk change outside the open-document sync path (#1668).
2172
+ * Shares `handleNotifyOpen`'s per-client debounced queue (#271), so a burst
2173
+ * of external changes coalesces into one notification per debounce window
2174
+ * instead of flooding the server with one per file.
2175
+ */
2176
+ export function handleNotifyExternalChange(state, filePath, type) {
2177
+ if (!isClientAlive(state))
2178
+ return;
2179
+ const normalizedPath = normalizeMapKey(filePath);
2180
+ const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2181
+ state.watchQueue.enqueue(uri, type);
2182
+ }
1059
2183
  export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
1060
2184
  if (!isClientAlive(state))
1061
2185
  return;
@@ -1080,7 +2204,7 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1080
2204
  // Scanners that only re-scan on a fresh open (opengrep ignores didChange):
1081
2205
  // close + reopen so the re-edit actually triggers a re-scan instead of
1082
2206
  // silently publishing nothing.
1083
- if (getStrategy(state.serverId).reopenOnResync) {
2207
+ if (getStrategy(state.serverId, state.launchVariant).reopenOnResync) {
1084
2208
  await safeSendNotification(state.connection, "textDocument/didClose", {
1085
2209
  textDocument: { uri },
1086
2210
  });
@@ -1096,25 +2220,31 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1096
2220
  // for a stale view (worse than "unknown"). Monotonic versions make that
1097
2221
  // late echo strictly older → dropped by isSupersededPush → never bound.
1098
2222
  state.documentVersions.set(normalizedPath, version);
2223
+ state.documentOpenedAt.set(normalizedPath, Date.now());
2224
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
1099
2225
  if (!isClientAlive(state))
1100
2226
  return;
1101
- await safeSendNotification(state.connection, "textDocument/didOpen", {
1102
- textDocument: { uri, languageId, version, text: content },
1103
- });
1104
- recordSentContent(state, normalizedPath, version, content);
2227
+ const reopenSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version, text: content } });
2228
+ // #1669 review F7: only mirror the send locally once it actually left
2229
+ // the process — see safeSendNotification's doc comment.
2230
+ if (reopenSent)
2231
+ recordSentContent(state, normalizedPath, version, content);
1105
2232
  state.openDocuments.add(normalizedPath);
1106
2233
  state.openDocumentUris?.set(normalizedPath, uri);
1107
2234
  return;
1108
2235
  }
1109
- await safeSendNotification(state.connection, "textDocument/didChange", {
2236
+ const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
1110
2237
  textDocument: { uri, version },
1111
- contentChanges: [{ text: content }],
2238
+ contentChanges: buildContentChanges(state, normalizedPath, content),
1112
2239
  });
1113
- recordSentContent(state, normalizedPath, version, content);
2240
+ if (changeSent)
2241
+ recordSentContent(state, normalizedPath, version, content);
1114
2242
  return;
1115
2243
  }
1116
2244
  state.pendingOpens.add(normalizedPath);
1117
2245
  state.documentVersions.set(normalizedPath, 0);
2246
+ state.documentOpenedAt.set(normalizedPath, Date.now());
2247
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
1118
2248
  clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
1119
2249
  // Send workspace notification first (like opencode does).
1120
2250
  // Skipped in silent mode — cascade reads a file for diagnostics,
@@ -1141,14 +2271,31 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
1141
2271
  }
1142
2272
  if (!isClientAlive(state))
1143
2273
  return;
1144
- await safeSendNotification(state.connection, "textDocument/didOpen", {
1145
- textDocument: { uri, languageId, version: 0, text: content },
1146
- });
1147
- recordSentContent(state, normalizedPath, 0, content);
2274
+ const openSent = await safeSendNotification(state.connection, "textDocument/didOpen", { textDocument: { uri, languageId, version: 0, text: content } });
2275
+ if (openSent)
2276
+ recordSentContent(state, normalizedPath, 0, content);
1148
2277
  state.pendingOpens.delete(normalizedPath);
1149
2278
  state.openDocuments.add(normalizedPath);
1150
2279
  state.closedDocuments?.delete(normalizedPath);
1151
2280
  state.openDocumentUris?.set(normalizedPath, uri);
2281
+ // Telemetry is deliberately detached after didOpen succeeds.
2282
+ // #1412 H1: routed through runReadOnlyServerCommand, NOT runServerCommand —
2283
+ // the probe must never open the serverEditsAllowed/activeMutationContext
2284
+ // mutation-acceptance window; it is a diagnostic sample, not a mutation, and
2285
+ // carries its own short PROBE_COMMAND_TIMEOUT_MS backstop. The probe itself
2286
+ // is classic-only and swallows every failure.
2287
+ void probeTsserverProjectIdentity({
2288
+ serverId: state.serverId,
2289
+ launchVariant: state.launchVariant,
2290
+ clientRoot: state.root,
2291
+ file: filePath,
2292
+ normalizedFile: normalizedPath,
2293
+ probedFiles: state.projectIdentityProbedFiles ??
2294
+ (state.projectIdentityProbedFiles = new Set()),
2295
+ commandChannel: {
2296
+ executeCommand: (command, args) => runReadOnlyServerCommand(state, command, args),
2297
+ },
2298
+ });
1152
2299
  }
1153
2300
  export async function handleNotifyChange(state, filePath, content) {
1154
2301
  if (!isClientAlive(state))
@@ -1158,11 +2305,19 @@ export async function handleNotifyChange(state, filePath, content) {
1158
2305
  if (!state.openDocuments.has(normalizedPath)) {
1159
2306
  // Safety fallback: keep protocol ordering valid even if caller sends
1160
2307
  // didChange before first didOpen for this document.
1161
- await safeSendNotification(state.connection, "textDocument/didOpen", {
1162
- textDocument: { uri, languageId: "plaintext", version: 0, text: content },
2308
+ const fallbackOpenSent = await safeSendNotification(state.connection, "textDocument/didOpen", {
2309
+ textDocument: {
2310
+ uri,
2311
+ languageId: "plaintext",
2312
+ version: 0,
2313
+ text: content,
2314
+ },
1163
2315
  });
1164
2316
  state.documentVersions.set(normalizedPath, 0);
1165
- recordSentContent(state, normalizedPath, 0, content);
2317
+ state.documentOpenedAt.set(normalizedPath, Date.now());
2318
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
2319
+ if (fallbackOpenSent)
2320
+ recordSentContent(state, normalizedPath, 0, content);
1166
2321
  state.openDocuments.add(normalizedPath);
1167
2322
  state.openDocumentUris?.set(normalizedPath, uri);
1168
2323
  return;
@@ -1172,11 +2327,12 @@ export async function handleNotifyChange(state, filePath, content) {
1172
2327
  // Clear stale diagnostics before sending new content so waitForDiagnostics
1173
2328
  // doesn't return immediately with the previous edit's results.
1174
2329
  clearDiagnosticsForPath(state, normalizedPath);
1175
- await safeSendNotification(state.connection, "textDocument/didChange", {
2330
+ const changeSent = await safeSendNotification(state.connection, "textDocument/didChange", {
1176
2331
  textDocument: { uri, version },
1177
- contentChanges: [{ text: content }],
2332
+ contentChanges: buildContentChanges(state, normalizedPath, content),
1178
2333
  });
1179
- recordSentContent(state, normalizedPath, version, content);
2334
+ if (changeSent)
2335
+ recordSentContent(state, normalizedPath, version, content);
1180
2336
  }
1181
2337
  /** Close a document through the same lifecycle path exposed by the client. */
1182
2338
  export async function closeDocument(state, filePath) {
@@ -1187,16 +2343,84 @@ export async function closeDocument(state, filePath) {
1187
2343
  return;
1188
2344
  await safeSendNotification(state.connection, "textDocument/didClose", {
1189
2345
  textDocument: {
1190
- uri: state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href,
2346
+ uri: state.openDocumentUris?.get(normalizedPath) ??
2347
+ pathToFileURL(filePath).href,
1191
2348
  },
1192
2349
  });
1193
2350
  state.openDocuments.delete(normalizedPath);
1194
2351
  state.closedDocuments?.add(normalizedPath);
1195
2352
  state.openDocumentUris?.delete(normalizedPath);
1196
2353
  state.documentVersions.delete(normalizedPath);
2354
+ state.documentOpenedAt.delete(normalizedPath);
2355
+ state.diagnosticPublicationCounts.delete(normalizedPath);
2356
+ // #1412 L1: projectIdentityProbedFiles is a claim-once memo scoped to the
2357
+ // document's open lifetime (re-probing a closed-then-reopened file is
2358
+ // harmless and cheap) — mirror openDocuments' own per-close cleanup so it
2359
+ // doesn't grow unbounded across a long session's worth of open/close churn.
2360
+ state.projectIdentityProbedFiles?.delete(normalizedPath);
1197
2361
  clearDiagnosticsForPath(state, normalizedPath);
1198
2362
  }
1199
- export async function clientShutdown(state, options = {}) {
2363
+ /**
2364
+ * #1620 residual review F1: `fast` and `processExiting` only ever escalate —
2365
+ * a `true` demands strictly less work (or none) than a `false`, never more.
2366
+ * `existing` covers `requested` when it is at least as demanding on BOTH
2367
+ * axes, so deduping onto it can't silently downgrade what the new caller
2368
+ * needs (e.g. a `processExiting` caller must never inherit a teardown that
2369
+ * might still spawn `taskkill`).
2370
+ */
2371
+ function isAtLeastAsAggressiveShutdown(existing, requested) {
2372
+ return (!!existing.fast >= !!requested.fast &&
2373
+ !!existing.processExiting >= !!requested.processExiting);
2374
+ }
2375
+ export function clientShutdown(state, options = {}) {
2376
+ // #1620 residual 3: idempotent across racing callers — see the doc comment
2377
+ // on `LSPClientState.shutdownPromise`. The FIRST call starts the real
2378
+ // teardown and stores its promise; every subsequent call (any of the 8
2379
+ // call sites, on the same state) whose options are no MORE aggressive
2380
+ // awaits that same promise instead of running the RPC handshake and
2381
+ // emitting `lsp_client_shutdown` again.
2382
+ //
2383
+ // #1620 residual review F1: a call whose options ARE more aggressive does
2384
+ // NOT dedupe — it starts (and takes over as) its own teardown instead.
2385
+ // The concrete failure this closes: `resetLSPService`'s session-exit path
2386
+ // (`index.ts`) calls `shutdown({fast:true, processExiting:true})` — the
2387
+ // event loop is already closing, and `processExiting` exists precisely so
2388
+ // this teardown never spawns `taskkill` (a closing-loop `uv_async_send`
2389
+ // hard-aborts, `src\win\async.c`). If a GRACEFUL teardown (default
2390
+ // options — e.g. `client_ceiling_lru` eviction) is already in flight on
2391
+ // this same client when the exit path arrives, blindly deduping would
2392
+ // make the exit path inherit that graceful run's own `taskkill` spawn and
2393
+ // its close-event wait — the exact hazard `processExiting` forbids, on
2394
+ // top of blocking the closing loop for however long that takes. The
2395
+ // weaker in-flight teardown is left to finish on its own (every step
2396
+ // downstream — dispose, kill, deregister — is already idempotent).
2397
+ if (state.shutdownPromise &&
2398
+ state.shutdownOptions &&
2399
+ isAtLeastAsAggressiveShutdown(state.shutdownOptions, options)) {
2400
+ return state.shutdownPromise;
2401
+ }
2402
+ state.shutdownOptions = options;
2403
+ const attempt = clientShutdownOnce(state, options);
2404
+ state.shutdownPromise = attempt;
2405
+ // #1620 residual review F3: a REJECTED teardown (e.g. `killProcessTree`
2406
+ // throwing something its own internal catches don't cover) must not
2407
+ // latch a permanently-rejected promise here — every later call, even one
2408
+ // with equal or weaker options, would dedupe onto it forever and never
2409
+ // retry, so the child leaks silently with no further kill/dispose/
2410
+ // deregister attempt. Clear the latch on rejection (only if nothing more
2411
+ // aggressive has already superseded it) so the NEXT call starts a fresh
2412
+ // attempt instead of re-handing back the same dead promise. The rejection
2413
+ // itself still propagates to whoever is awaiting `attempt` right now —
2414
+ // this only affects callers that haven't asked yet.
2415
+ attempt.catch(() => {
2416
+ if (state.shutdownPromise === attempt) {
2417
+ state.shutdownPromise = undefined;
2418
+ state.shutdownOptions = undefined;
2419
+ }
2420
+ });
2421
+ return attempt;
2422
+ }
2423
+ async function clientShutdownOnce(state, options) {
1200
2424
  const shutdownStart = Date.now();
1201
2425
  state.shutdownRequested = true;
1202
2426
  state.isConnected = false;
@@ -1208,58 +2432,132 @@ export async function clientShutdown(state, options = {}) {
1208
2432
  state.pendingOpens.clear();
1209
2433
  state.openDocuments.clear();
1210
2434
  state.openDocumentUris?.clear();
2435
+ // #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
2436
+ // probe memo is moot along with everything else document-scoped.
2437
+ state.projectIdentityProbedFiles?.clear();
1211
2438
  // #271: drop any pending watched-files batch + its timer (a dying client's
1212
2439
  // queued FS changes are moot, and the timer must not outlive the connection).
1213
2440
  state.watchQueue?.cancel();
1214
2441
  state.diagnosticEmitter.removeAllListeners();
1215
2442
  let shutdownRequestTimedOut = false;
1216
- if (!options.fast) {
1217
- try {
1218
- await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
1219
- }
1220
- catch {
1221
- /* ignore — proceed to exit/kill so shutdown cannot hang the session */
1222
- shutdownRequestTimedOut = true;
1223
- }
1224
- try {
1225
- await safeSendNotification(state.connection, "exit", {});
1226
- }
1227
- catch {
1228
- /* ignore */
2443
+ let exitNotifyTimedOut = false;
2444
+ // #1620 residual 1 / residual-review F2: a failure that ISN'T the timer
2445
+ // winning was previously folded into the same "*TimedOut" flag as a real
2446
+ // timeout — honest for the rolled-up `shutdownOutcome` (still "forced"
2447
+ // either way), imprecise about WHICH failure happened. Two shapes land
2448
+ // here, both meaning "no confirmation this reached the server":
2449
+ // (a) a genuine promise rejection that isn't the timer (an immediate
2450
+ // protocol error) — reaches the `catch` below.
2451
+ // (b) `safeSendRequest`/`safeSendNotification` SWALLOW a stream error
2452
+ // (EPIPE, disposed connection — `isStreamError`) and RESOLVE
2453
+ // instead of rejecting, so the `catch` never runs at all. A real
2454
+ // `shutdown` reply is `null`, never `undefined`, and a real `exit`
2455
+ // notify send returns `true`; `undefined`/`false` is the swallow
2456
+ // case. Without this, a coded EPIPE reported `shutdownOutcome:
2457
+ // "graceful"` — nothing was delivered, but the log called it clean.
2458
+ let shutdownRequestUndelivered = false;
2459
+ let exitNotifyUndelivered = false;
2460
+ // #1620: the graceful handshake is BEST-EFFORT and the teardown is not. A
2461
+ // teardown path must not depend on the health of the thing it is tearing
2462
+ // down — the breaker fires exactly when the server is unresponsive. Keep the
2463
+ // handshake inside the `try` and every irreversible teardown step in the
2464
+ // `finally`, so disposal, the record, the deregistration, and the kill run no
2465
+ // matter how the two writes behave, and so a future added await here cannot
2466
+ // reintroduce the class.
2467
+ try {
2468
+ if (!options.fast) {
2469
+ try {
2470
+ const shutdownAck = await withTimeout(safeSendRequest(state.connection, "shutdown", {}), SHUTDOWN_REQUEST_TIMEOUT_MS);
2471
+ if (shutdownAck === undefined)
2472
+ shutdownRequestUndelivered = true;
2473
+ }
2474
+ catch (err) {
2475
+ /* ignore — proceed to exit/kill so shutdown cannot hang the session */
2476
+ if (isShutdownTimeoutError(err, SHUTDOWN_REQUEST_TIMEOUT_MS)) {
2477
+ shutdownRequestTimedOut = true;
2478
+ }
2479
+ else {
2480
+ shutdownRequestUndelivered = true;
2481
+ }
2482
+ }
2483
+ try {
2484
+ const exitSent = await withTimeout(safeSendNotification(state.connection, "exit", {}), EXIT_NOTIFY_TIMEOUT_MS);
2485
+ if (exitSent === false)
2486
+ exitNotifyUndelivered = true;
2487
+ }
2488
+ catch (err) {
2489
+ /* ignore — same reason as the request above */
2490
+ if (isShutdownTimeoutError(err, EXIT_NOTIFY_TIMEOUT_MS)) {
2491
+ exitNotifyTimedOut = true;
2492
+ }
2493
+ else {
2494
+ exitNotifyUndelivered = true;
2495
+ }
2496
+ }
1229
2497
  }
1230
2498
  }
1231
- disposeClientConnection(state);
1232
- const pid = state.lspProcess.pid;
1233
- logLatency({
1234
- type: "phase",
1235
- phase: "lsp_client_shutdown",
1236
- filePath: state.root,
1237
- durationMs: Date.now() - shutdownStart,
1238
- metadata: {
1239
- serverId: state.serverId,
1240
- pid,
1241
- fast: !!options.fast,
1242
- processExiting: !!options.processExiting,
1243
- shutdownRequestTimedOut,
1244
- },
1245
- });
1246
- // #449/#472: deregister this LSP child from the instance registry. Fire-
1247
- // and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
1248
- // including the `processExiting` path where the event loop is closing
1249
- // (#234 forbids spawning here, but a plain fs write/rename is fine; even
1250
- // so, we don't await it to keep this teardown path as fast as before).
1251
- void removeLspChild(pid).catch((err) => {
2499
+ finally {
2500
+ disposeClientConnection(state);
2501
+ const pid = state.lspProcess.pid;
1252
2502
  logLatency({
1253
2503
  type: "phase",
1254
- phase: "lsp_registry_write_failed",
1255
- filePath: "",
1256
- durationMs: 0,
1257
- metadata: { op: "remove", pid, error: String(err) },
2504
+ phase: "lsp_client_shutdown",
2505
+ filePath: state.root,
2506
+ durationMs: Date.now() - shutdownStart,
2507
+ metadata: {
2508
+ serverId: state.serverId,
2509
+ pid,
2510
+ fast: !!options.fast,
2511
+ processExiting: !!options.processExiting,
2512
+ shutdownRequestTimedOut,
2513
+ // #1620: `shutdownRequestTimedOut` covered only the request half, so
2514
+ // a hung exit notify was indistinguishable from a clean exit — and
2515
+ // pre-fix no record was emitted at all. Report both halves plus one
2516
+ // rolled-up verdict, so a forced teardown is countable from the log.
2517
+ exitNotifyTimedOut,
2518
+ // #1620 residual 1 / residual-review F2: the *Undelivered pair covers
2519
+ // the failure the *TimedOut pair used to also claim — a rejection
2520
+ // that is NOT the timer winning, OR (the F2 gap) a swallowed stream
2521
+ // error that `safeSendRequest`/`safeSendNotification` resolved
2522
+ // instead of rejecting, so no exception ever reached the catches
2523
+ // above. Both still roll into shutdownOutcome "forced" below — an
2524
+ // undelivered write is never reported as "graceful".
2525
+ shutdownRequestUndelivered,
2526
+ exitNotifyUndelivered,
2527
+ shutdownOutcome: options.fast
2528
+ ? "fast"
2529
+ : shutdownRequestTimedOut ||
2530
+ exitNotifyTimedOut ||
2531
+ shutdownRequestUndelivered ||
2532
+ exitNotifyUndelivered
2533
+ ? "forced"
2534
+ : "graceful",
2535
+ },
1258
2536
  });
1259
- });
1260
- // On Windows, killing the direct child first can orphan grandchildren before
1261
- // taskkill can traverse the tree. Kill the full tree first and wait briefly.
1262
- await killProcessTree(state.lspProcess.process, pid, options);
2537
+ // #449/#472: deregister this LSP child from the instance registry. Fire-
2538
+ // and-forget (async fs, no spawn) — must not add latency/risk to shutdown,
2539
+ // including the `processExiting` path where the event loop is closing
2540
+ // (#234 forbids spawning here, but a plain fs write/rename is fine; even
2541
+ // so, we don't await it to keep this teardown path as fast as before).
2542
+ // #1724: pass this spawn's marker so removeLspChild can guard against a
2543
+ // pid-recycling window (a NEW child claiming this exact pid before this
2544
+ // fire-and-forget write lands) rather than removing whichever child
2545
+ // currently sits at this pid.
2546
+ void removeLspChild(pid, extractSpawnMarker(state.lspProcess.args)).catch((err) => {
2547
+ logLatency({
2548
+ type: "phase",
2549
+ phase: "lsp_registry_write_failed",
2550
+ filePath: "",
2551
+ durationMs: 0,
2552
+ metadata: { op: "remove", pid, error: String(err) },
2553
+ });
2554
+ });
2555
+ // On Windows, killing the direct child first can orphan grandchildren before
2556
+ // taskkill can traverse the tree. Kill the full tree first and wait briefly.
2557
+ // Safe and idempotent on an already-exited child: killProcessTree returns
2558
+ // early on an observed exit rather than signalling a possibly-recycled pid.
2559
+ await killProcessTree(state.lspProcess.process, pid, options);
2560
+ }
1263
2561
  }
1264
2562
  /**
1265
2563
  * Translate a caller-supplied (UTF-16) `(line, character)` into the position the
@@ -1308,7 +2606,30 @@ signal = getAmbientAbortSignal()) {
1308
2606
  const requestVersion = normalizedPath !== undefined
1309
2607
  ? state.documentVersions.get(normalizedPath)
1310
2608
  : undefined;
1311
- const result = (await withTimeout(safeSendRequest(state.connection, method, params, signal), timeoutMs).catch((err) => {
2609
+ // #1716: captured OUTSIDE withTimeout, mirroring #1713's pull-diagnostic
2610
+ // fix (`pullDiagnosticSource`), so a timeout's catch below still holds a
2611
+ // live handle on the request for the late-answer watch. `withTimeout`
2612
+ // only races it against a timer — the request keeps running server-side
2613
+ // either way, and without this handle an abandoned answer would settle
2614
+ // into the void with no way to observe it.
2615
+ const requestStartedAt = Date.now();
2616
+ const requestPromise = safeSendRequest(state.connection, method, params, signal);
2617
+ const result = (await withTimeout(requestPromise, timeoutMs).catch((err) => {
2618
+ if (isNavTimeoutError(err, timeoutMs)) {
2619
+ recordNavTimeoutTelemetry({
2620
+ method,
2621
+ scope: normalizedPath,
2622
+ budgetMs: timeoutMs,
2623
+ elapsedMs: Date.now() - requestStartedAt,
2624
+ });
2625
+ armNavLateAnswerTelemetry({
2626
+ requestPromise,
2627
+ method,
2628
+ scope: normalizedPath,
2629
+ requestStartedAt,
2630
+ });
2631
+ return undefined;
2632
+ }
1312
2633
  if (err instanceof Error && err.message.startsWith("Timeout after")) {
1313
2634
  return undefined;
1314
2635
  }
@@ -1326,6 +2647,109 @@ signal = getAmbientAbortSignal()) {
1326
2647
  }
1327
2648
  return result;
1328
2649
  }
2650
+ /**
2651
+ * #1716: `withTimeout` rejects with this EXACT message (`clients/deadline-
2652
+ * utils.ts`) only when its own timer wins the race — mirrors #1713's
2653
+ * `isPullTimeoutError`. Only the timer-won case is a "the answer might still
2654
+ * be coming" situation worth a late-answer watch; a request that already
2655
+ * failed fast (or a stale-message coincidence) has nothing further to
2656
+ * observe. `navRequest`'s own behavior still falls back to the looser
2657
+ * `startsWith` check afterward, unchanged, so this stricter check only gates
2658
+ * telemetry — never the existing timeout-handling behavior.
2659
+ */
2660
+ function isNavTimeoutError(err, timeoutMs) {
2661
+ return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
2662
+ }
2663
+ /**
2664
+ * #1620 residual 1: `withTimeout` rejects with this EXACT message only when
2665
+ * its own timer wins the race — mirrors #1713's `isPullTimeoutError` and
2666
+ * #1716's `isNavTimeoutError`. `clientShutdown`'s two catches previously
2667
+ * treated ANY rejection (a real timeout OR an immediate protocol error) as
2668
+ * "timed out"; honest for the rolled-up `shutdownOutcome` (both are
2669
+ * "forced"), imprecise for the per-field flag a log reader keys on.
2670
+ */
2671
+ function isShutdownTimeoutError(err, timeoutMs) {
2672
+ return err instanceof Error && err.message === `Timeout after ${timeoutMs}ms`;
2673
+ }
2674
+ // #1716: subject/scope used when a nav request has no `staleCheckPath` (e.g.
2675
+ // workspaceSymbol, call-hierarchy follow-ups) — mirrors WORKSPACE_PULL_SCOPE
2676
+ // above for the same reason: telemetry identity needs a stable placeholder
2677
+ // where there is no real path.
2678
+ const NAV_REQUEST_NO_PATH_SCOPE = "*no-path*";
2679
+ /**
2680
+ * #1716: `navRequest` is the highest-volume LSP call site — hover,
2681
+ * definition, references, signatureHelp, documentSymbol, workspace/symbol,
2682
+ * call hierarchy all route through it, often several times per turn. A stuck
2683
+ * server can storm timeouts across many methods and files far faster than
2684
+ * the pull-diagnostic path #1713 fixed, so a per-event latency.log write
2685
+ * (that fix's shape) would itself become the flood. Every timeout is still
2686
+ * counted exactly, via the degradation ledger (bounded, in-memory, no I/O),
2687
+ * but only the RISING EDGE — the first occurrence per (method, file) this
2688
+ * session — also pays for a detailed log write. `incrementDegradationCount`'s
2689
+ * return value (`true` on the first occurrence for a kind/subject pair) is
2690
+ * reused as that rising-edge gate instead of a second hand-rolled latch: it
2691
+ * already re-arms at session_start via `resetDegradationLedger`, so this
2692
+ * adds zero new module state. Never throws: telemetry must not perturb a
2693
+ * path that has already decided to return `undefined` regardless.
2694
+ */
2695
+ function recordNavTimeoutTelemetry(args) {
2696
+ // #1743: `risingEdgePer: "identity"` is the same gate this site hand-rolled
2697
+ // off `incrementDegradationCount`'s return value, now expressed as an
2698
+ // option. The ledger still counts every timeout exactly, per (method, file).
2699
+ emitBounded("lsp_nav_request_timeout", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
2700
+ type: "phase",
2701
+ filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
2702
+ durationMs: args.elapsedMs,
2703
+ metadata: { method: args.method, effectiveBudgetMs: args.budgetMs },
2704
+ }, {
2705
+ ledgerKind: "lsp-nav-request-timeout",
2706
+ risingEdgePer: "identity",
2707
+ reason: `timeout budget=${args.budgetMs}ms elapsed=${args.elapsedMs}ms`,
2708
+ });
2709
+ }
2710
+ /**
2711
+ * #1716: nav-request sibling of #1713's `armLateAnswerTelemetry`. A
2712
+ * telemetry-only continuation on the ALREADY-abandoned request promise —
2713
+ * purely observational, never touches `state`, and attaching `.then` here
2714
+ * creates no new timer, socket, or other handle, so it cannot keep the
2715
+ * process alive a moment longer than the pending request already does.
2716
+ *
2717
+ * Bounded the same way as the timeout record above: every late answer is
2718
+ * counted exactly via the ledger, but only the rising edge per (method,
2719
+ * file) also writes an `lsp_nav_late_answer_discarded` record.
2720
+ *
2721
+ * Nav answers are read-once — unlike diagnostics' persistent pull cache,
2722
+ * there is no stale value this could poison — so this exists purely for
2723
+ * observability: telling a dogfood session "truly hung" apart from
2724
+ * "answered late", the same distinction #1713 made possible for pulls.
2725
+ */
2726
+ function armNavLateAnswerTelemetry(args) {
2727
+ args.requestPromise.then(() => {
2728
+ try {
2729
+ const elapsedMs = Date.now() - args.requestStartedAt;
2730
+ // #1743: same rising-edge gate as the timeout record above, now
2731
+ // carried by the helper instead of hand-rolled here.
2732
+ emitBounded("lsp_nav_late_answer_discarded", `${args.method}:${args.scope ?? NAV_REQUEST_NO_PATH_SCOPE}`, {
2733
+ type: "phase",
2734
+ filePath: args.scope ?? NAV_REQUEST_NO_PATH_SCOPE,
2735
+ durationMs: elapsedMs,
2736
+ metadata: { method: args.method },
2737
+ }, {
2738
+ ledgerKind: "lsp-nav-late-answer",
2739
+ risingEdgePer: "identity",
2740
+ reason: `late nav answer discarded after ${elapsedMs}ms`,
2741
+ });
2742
+ }
2743
+ catch {
2744
+ // Telemetry must never break the observed path.
2745
+ }
2746
+ }, () => {
2747
+ // The abandoned request eventually failed rather than answering —
2748
+ // not an "answer discarded" event. Nothing more to record here; this
2749
+ // handler exists so the request's eventual rejection never surfaces
2750
+ // as an unhandled rejection.
2751
+ });
2752
+ }
1329
2753
  // #1277: cheap liveness round-trip used by the silent-clean gates in
1330
2754
  // `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
1331
2755
  // state — a server that accepted the notify write and then wedged (still
@@ -1409,12 +2833,53 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
1409
2833
  state.activeMutationContext = undefined;
1410
2834
  }
1411
2835
  }
2836
+ // #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
2837
+ // probes that must NOT participate in the mutation-acceptance window. Unlike
2838
+ // runServerCommand this never touches serverEditsAllowed, activeMutationDepth,
2839
+ // or activeMutationContext — a probe firing mid-flight must leave a concurrent
2840
+ // real executeCommand's mutation context untouched, and must not itself open
2841
+ // the workspace/applyEdit acceptance window (client.ts's applyEdit handler
2842
+ // gates on serverEditsAllowed > 0). Preserves the allowlist-by-advertisement
2843
+ // invariant. Short PROBE_COMMAND_TIMEOUT_MS backstop — this is a diagnostic
2844
+ // sample, not a mutation, and must never hold anything up for anywhere near
2845
+ // EXECUTE_COMMAND_TIMEOUT_MS.
2846
+ export async function runReadOnlyServerCommand(state, command, args, timeoutMs = PROBE_COMMAND_TIMEOUT_MS) {
2847
+ if (!isClientAlive(state)) {
2848
+ return { executed: false, reason: "lsp client not alive" };
2849
+ }
2850
+ if (!state.advertisedCommands.has(command)) {
2851
+ return {
2852
+ executed: false,
2853
+ reason: `command "${command}" is not advertised by the ${state.serverId} server`,
2854
+ };
2855
+ }
2856
+ try {
2857
+ const result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
2858
+ command,
2859
+ arguments: args ?? [],
2860
+ }), timeoutMs);
2861
+ return { executed: true, result };
2862
+ }
2863
+ catch (err) {
2864
+ if (err instanceof Error && err.message.startsWith("Timeout after")) {
2865
+ return {
2866
+ executed: false,
2867
+ reason: `workspace/executeCommand timed out after ${timeoutMs}ms`,
2868
+ };
2869
+ }
2870
+ throw err;
2871
+ }
2872
+ }
1412
2873
  function validateWorkspaceEditVersions(state, edit) {
1413
2874
  for (const change of edit.documentChanges ?? []) {
1414
- if (typeof change !== "object" || change === null || !("textDocument" in change))
2875
+ if (typeof change !== "object" ||
2876
+ change === null ||
2877
+ !("textDocument" in change))
1415
2878
  continue;
1416
2879
  const textDocument = change.textDocument;
1417
- if (!textDocument || typeof textDocument.uri !== "string" || textDocument.version == null)
2880
+ if (!textDocument ||
2881
+ typeof textDocument.uri !== "string" ||
2882
+ textDocument.version == null)
1418
2883
  continue;
1419
2884
  const current = state.documentVersions.get(normalizeMapKey(uriToPath(textDocument.uri)));
1420
2885
  if (current === undefined || current !== textDocument.version) {
@@ -1481,7 +2946,10 @@ async function resolveCodeActionBestEffort(state, action) {
1481
2946
  return action;
1482
2947
  const merged = { ...action, ...resolved };
1483
2948
  return merged.edit
1484
- ? { ...merged, edit: await normalizeClientWorkspaceEdit(state, merged.edit) }
2949
+ ? {
2950
+ ...merged,
2951
+ edit: await normalizeClientWorkspaceEdit(state, merged.edit),
2952
+ }
1485
2953
  : merged;
1486
2954
  }
1487
2955
  // --- Client Factory ---
@@ -1578,10 +3046,21 @@ export async function createLSPClient(options) {
1578
3046
  // for concurrent waitForDiagnostics calls.
1579
3047
  const diagnosticEmitter = new EventEmitter();
1580
3048
  diagnosticEmitter.setMaxListeners(50);
3049
+ // SAFETY: three fields below are seeded `undefined as unknown as T` because
3050
+ // they cannot be built here. `workspaceDiagnosticsSupport` and
3051
+ // `operationSupport` are derived from the server's `initialize` result, and
3052
+ // `watchQueue`'s flush closure needs `state` itself. Each is assigned
3053
+ // in this same function before `state` reaches any caller:
3054
+ // `state.watchQueue` on the statement immediately after this literal, and
3055
+ // the two capability fields right after the `initialize` handshake
3056
+ // returns. Reorder that and the fields are genuinely `undefined` at their
3057
+ // declared non-optional types.
1581
3058
  const state = {
1582
3059
  isConnected: true,
1583
3060
  isDestroyed: false,
1584
3061
  shutdownRequested: false,
3062
+ shutdownPromise: undefined,
3063
+ shutdownOptions: undefined,
1585
3064
  exitedAt: undefined,
1586
3065
  connectionDisposed: false,
1587
3066
  lastError: undefined,
@@ -1592,23 +3071,34 @@ export async function createLSPClient(options) {
1592
3071
  documentPullDiagnosticTimestamps: new Map(),
1593
3072
  pullFailureHistory: [],
1594
3073
  pendingDiagnostics: new Map(),
3074
+ diagnosticPublicationCounts: new Map(),
3075
+ documentOpenedAt: new Map(),
1595
3076
  diagnosticEmitter,
1596
3077
  diagnosticsVersion: 0,
3078
+ diagnosticsVersionsByPath: new Map(),
1597
3079
  documentVersions: new Map(),
1598
3080
  diagnosticDocVersions: new Map(),
1599
3081
  documentContentHashes: new Map(),
1600
3082
  diagnosticBindings: new Map(),
1601
3083
  pullResultIds: new Map(),
3084
+ documentPullDiagnosticsBySource: new Map(),
3085
+ pullGenerations: new Map(),
3086
+ pullRequestSequences: new Map(),
1602
3087
  workspacePullResultCache: new Map(),
1603
3088
  openDocuments: new Set(),
1604
3089
  closedDocuments: new Set(),
1605
3090
  openDocumentUris: new Map(),
1606
3091
  pendingOpens: new Set(),
3092
+ projectIdentityProbedFiles: new Set(),
1607
3093
  // these are filled in after initialize — cast to avoid two-phase init
1608
- workspaceDiagnosticsSupport: undefined,
3094
+ workspaceDiagnosticsSupport:
3095
+ // SAFETY: initialize fills this capability before any workspace diagnostic request.
3096
+ undefined,
3097
+ // SAFETY: initialize fills this capability before operation dispatch.
1609
3098
  operationSupport: undefined,
1610
3099
  staticDiagnosticsMode: "push-only",
1611
3100
  positionEncoding: "utf-16",
3101
+ syncKind: TEXT_DOCUMENT_SYNC_KIND_FULL,
1612
3102
  dynamicRegistrations: new Map(),
1613
3103
  advertisedCommands: new Set(),
1614
3104
  serverEditsAllowed: 0,
@@ -1618,6 +3108,7 @@ export async function createLSPClient(options) {
1618
3108
  root,
1619
3109
  lspProcess,
1620
3110
  // two-phase: the flush closure needs `state` (below)
3111
+ // SAFETY: state construction completes before the flush closure can run.
1621
3112
  watchQueue: undefined,
1622
3113
  };
1623
3114
  // #271: batch per-file workspace/didChangeWatchedFiles into one notification
@@ -1650,7 +3141,7 @@ export async function createLSPClient(options) {
1650
3141
  // A child registered above (recordLspChild) but never reaching a healthy
1651
3142
  // createLSPClient return must still be deregistered here — otherwise the
1652
3143
  // registry keeps a stale entry for a process we just killed.
1653
- void removeLspChild(pid).catch((err) => {
3144
+ void removeLspChild(pid, extractSpawnMarker(lspProcess.args)).catch((err) => {
1654
3145
  // best-effort — a stale registry entry is harmless (the reaper's
1655
3146
  // liveness check will find it dead on the next sweep regardless)
1656
3147
  logLatency({
@@ -1711,6 +3202,7 @@ export async function createLSPClient(options) {
1711
3202
  detectWorkspaceDiagnosticsSupport(initResult);
1712
3203
  state.operationSupport = detectOperationSupport(initResult);
1713
3204
  state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
3205
+ state.syncKind = negotiateSyncKind(initResult?.capabilities);
1714
3206
  state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
1715
3207
  {}).sort((a, b) => a.localeCompare(b));
1716
3208
  for (const cmd of detectExecuteCommands(initResult)) {
@@ -1752,6 +3244,9 @@ export async function createLSPClient(options) {
1752
3244
  async change(filePath, content) {
1753
3245
  return handleNotifyChange(state, filePath, content);
1754
3246
  },
3247
+ watchedFileChange(filePath, type) {
3248
+ handleNotifyExternalChange(state, filePath, type);
3249
+ },
1755
3250
  },
1756
3251
  getDiagnostics(filePath) {
1757
3252
  return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
@@ -1759,6 +3254,9 @@ export async function createLSPClient(options) {
1759
3254
  getDiagnosticBinding(filePath) {
1760
3255
  return state.diagnosticBindings.get(normalizeMapKey(filePath));
1761
3256
  },
3257
+ getDiagnosticsVersionForPath(filePath) {
3258
+ return diagnosticsVersionForPath(state, normalizeMapKey(filePath));
3259
+ },
1762
3260
  getAllDiagnostics() {
1763
3261
  const result = new Map();
1764
3262
  const keys = new Set([
@@ -1775,10 +3273,12 @@ export async function createLSPClient(options) {
1775
3273
  return result;
1776
3274
  },
1777
3275
  getTrackedDiagnosticPaths() {
1778
- return [...new Set([
3276
+ return [
3277
+ ...new Set([
1779
3278
  ...state.pushDiagnostics.keys(),
1780
3279
  ...state.documentPullDiagnostics.keys(),
1781
- ])].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
3280
+ ]),
3281
+ ].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
1782
3282
  },
1783
3283
  pruneDiagnostics(predicate) {
1784
3284
  let removed = 0;
@@ -1817,6 +3317,9 @@ export async function createLSPClient(options) {
1817
3317
  async executeCommand(command, args, mutationContext) {
1818
3318
  return runServerCommand(state, command, args, EXECUTE_COMMAND_TIMEOUT_MS, mutationContext);
1819
3319
  },
3320
+ async executeReadOnlyCommand(command, args) {
3321
+ return runReadOnlyServerCommand(state, command, args);
3322
+ },
1820
3323
  get diagnosticsVersion() {
1821
3324
  return state.diagnosticsVersion;
1822
3325
  },
@@ -1982,14 +3485,25 @@ export async function createLSPClient(options) {
1982
3485
  };
1983
3486
  }
1984
3487
  // Helper to safely send notifications - catches stream destruction
3488
+ /**
3489
+ * Returns `true` once the notification was actually handed to the transport,
3490
+ * `false` when a stream error was swallowed (connection error handlers will
3491
+ * update state separately). #1669 review F7: callers that mirror what they
3492
+ * just told the server locally (`recordSentContent`) MUST gate on this
3493
+ * return value — recording a send that never left the process would desync
3494
+ * the mirror from what the server actually has, with nothing to self-heal
3495
+ * it (the next Incremental range would be computed against content the
3496
+ * server never saw).
3497
+ */
1985
3498
  async function safeSendNotification(connection, method, params) {
1986
3499
  try {
1987
3500
  await connection.sendNotification(method, params);
3501
+ return true;
1988
3502
  }
1989
3503
  catch (err) {
1990
3504
  if (isStreamError(err)) {
1991
3505
  // Silently ignore - stream was destroyed, connection error handlers will update state
1992
- return;
3506
+ return false;
1993
3507
  }
1994
3508
  throw err;
1995
3509
  }
@@ -2001,7 +3515,7 @@ async function safeSendRequest(connection, method, params,
2001
3515
  // vscode-jsonrpc's CancellationToken → an LSP `$/cancelRequest` notification,
2002
3516
  // so a server stops computing a result the agent has already abandoned (#238
2003
3517
  // Item 1). The rejection that follows is swallowed (treated as `undefined`).
2004
- signal) {
3518
+ signal, settlement) {
2005
3519
  // Already abandoned before we even sent — don't bother the server.
2006
3520
  if (signal?.aborted)
2007
3521
  return undefined;
@@ -2028,9 +3542,13 @@ signal) {
2028
3542
  return (await send());
2029
3543
  }
2030
3544
  catch (err) {
2031
- if (isStreamError(err) || isCancellationError(err)) {
2032
- // Stream destroyed, or we cancelled the request on abort — either
2033
- // way there is no result to return.
3545
+ if (isCancellationError(err)) {
3546
+ if (settlement)
3547
+ settlement.cancelled = true;
3548
+ return undefined;
3549
+ }
3550
+ if (isStreamError(err)) {
3551
+ // Stream destroyed; connection handlers update client state separately.
2034
3552
  return undefined;
2035
3553
  }
2036
3554
  if (isContentModifiedError(err)) {