@herbertgao/pi-extensions 2026.9.0 → 2026.9.2

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 (360) hide show
  1. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +13 -1
  2. package/node_modules/@czottmann/pi-automode/README.md +20 -0
  3. package/node_modules/@czottmann/pi-automode/docs/GLOSSARY.md +1 -1
  4. package/node_modules/@czottmann/pi-automode/docs/automode-classifier-flow.md +2 -1
  5. package/node_modules/@czottmann/pi-automode/docs/defaults.md +3 -2
  6. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +38 -2
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +5 -3
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/extension.ts +11 -3
  9. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  10. package/node_modules/@narumitw/pi-btw/README.md +57 -141
  11. package/node_modules/@narumitw/pi-btw/dist/index.ts +466 -41
  12. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +4 -4
  13. package/node_modules/@narumitw/pi-btw/docs/workflows.md +74 -0
  14. package/node_modules/@narumitw/pi-btw/package.json +2 -1
  15. package/node_modules/@narumitw/pi-btw/src/btw.ts +15 -1
  16. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +32 -2
  17. package/node_modules/@narumitw/pi-btw/src/keybindings.ts +330 -0
  18. package/node_modules/@narumitw/pi-btw/src/menu.ts +136 -19
  19. package/node_modules/@narumitw/pi-btw/src/settings.ts +32 -0
  20. package/node_modules/@narumitw/pi-btw/src/side-thread.ts +57 -5
  21. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +52 -30
  22. package/node_modules/@pi-plugins/fast-mode/README.md +5 -3
  23. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
  24. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
  25. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  26. package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
  27. package/node_modules/pi-lens/CHANGELOG.md +1072 -0
  28. package/node_modules/pi-lens/README.md +3 -0
  29. package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
  30. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
  31. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -9
  32. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
  33. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +1 -1
  34. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
  35. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
  36. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
  37. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -9
  38. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +2 -0
  39. package/node_modules/pi-lens/dist/clients/bash-file-access.js +133 -3
  40. package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
  41. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -6
  42. package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
  43. package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
  44. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +1 -1
  45. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -9
  46. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
  47. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +3 -11
  48. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
  49. package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
  50. package/node_modules/pi-lens/dist/clients/cache-observability.js +40 -19
  51. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
  52. package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -9
  53. package/node_modules/pi-lens/dist/clients/child-unref.js +1 -19
  54. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
  55. package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
  56. package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
  57. package/node_modules/pi-lens/dist/clients/config-core/index.js +50 -0
  58. package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
  59. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
  60. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
  61. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +164 -0
  62. package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
  63. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
  64. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
  65. package/node_modules/pi-lens/dist/clients/config-core/schema.js +165 -0
  66. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
  67. package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
  68. package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
  69. package/node_modules/pi-lens/dist/clients/config-schema.js +234 -0
  70. package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
  71. package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
  72. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -6
  73. package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
  74. package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
  75. package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
  76. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
  77. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
  78. package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
  79. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +1 -1
  80. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
  81. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +15 -5
  82. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -4
  83. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -44
  84. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +27 -8
  86. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -6
  87. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +1 -1
  88. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -3
  89. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +2 -2
  90. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +1 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +383 -27
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +4 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +1 -2
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +14 -18
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +6 -3
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +1 -2
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +1 -2
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +4 -3
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -67
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -21
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
  143. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +2 -20
  144. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +1 -1
  145. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
  146. package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +2 -2
  147. package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -10
  148. package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
  149. package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
  150. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
  151. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +2 -2
  152. package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
  153. package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
  154. package/node_modules/pi-lens/dist/clients/file-kinds.js +1 -38
  155. package/node_modules/pi-lens/dist/clients/file-time.js +7 -2
  156. package/node_modules/pi-lens/dist/clients/file-utils.js +117 -10
  157. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
  158. package/node_modules/pi-lens/dist/clients/format-service.js +6 -2
  159. package/node_modules/pi-lens/dist/clients/formatters.js +385 -104
  160. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
  161. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +116 -22
  162. package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
  163. package/node_modules/pi-lens/dist/clients/git-guard.js +6 -1
  164. package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
  165. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
  166. package/node_modules/pi-lens/dist/clients/grammar-source.js +3 -3
  167. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
  168. package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
  169. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
  170. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  171. package/node_modules/pi-lens/dist/clients/installer/index.js +289 -59
  172. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +84 -18
  173. package/node_modules/pi-lens/dist/clients/instance-reaper.js +110 -236
  174. package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
  175. package/node_modules/pi-lens/dist/clients/language-profile.js +0 -9
  176. package/node_modules/pi-lens/dist/clients/language-registry.js +599 -0
  177. package/node_modules/pi-lens/dist/clients/latency-logger.js +165 -9
  178. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +34 -1
  179. package/node_modules/pi-lens/dist/clients/lens-config.js +160 -30
  180. package/node_modules/pi-lens/dist/clients/lens-engine.js +33 -41
  181. package/node_modules/pi-lens/dist/clients/lens-events.js +1 -1
  182. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +77 -6
  183. package/node_modules/pi-lens/dist/clients/lens-map.js +1 -1
  184. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -38
  185. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
  186. package/node_modules/pi-lens/dist/clients/lsp/client.js +172 -33
  187. package/node_modules/pi-lens/dist/clients/lsp/config.js +484 -158
  188. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
  189. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +1 -1
  190. package/node_modules/pi-lens/dist/clients/lsp/edits.js +18 -5
  191. package/node_modules/pi-lens/dist/clients/lsp/index.js +892 -96
  192. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +1 -1
  193. package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -177
  194. package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
  195. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  196. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -18
  197. package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
  198. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
  199. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
  200. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -1
  201. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +14 -7
  202. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +6 -0
  203. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +2 -1
  204. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
  205. package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +1 -1
  206. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
  207. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
  208. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
  209. package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
  210. package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
  211. package/node_modules/pi-lens/dist/clients/metrics-history.js +28 -107
  212. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +3 -5
  213. package/node_modules/pi-lens/dist/clients/module-report.js +21 -46
  214. package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
  215. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
  216. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
  217. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -24
  218. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
  219. package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
  220. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +70 -28
  221. package/node_modules/pi-lens/dist/clients/opengrep-config.js +5 -1
  222. package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
  223. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
  224. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
  225. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
  226. package/node_modules/pi-lens/dist/clients/path-utils.js +451 -15
  227. package/node_modules/pi-lens/dist/clients/performance-report.js +2 -2
  228. package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
  229. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
  230. package/node_modules/pi-lens/dist/clients/pipeline.js +79 -18
  231. package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
  232. package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
  233. package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
  234. package/node_modules/pi-lens/dist/clients/project-changes.js +1 -1
  235. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +1 -1
  236. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  237. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +1 -1
  238. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +1 -1
  239. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +1 -1
  240. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +1 -1
  241. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +28 -4
  242. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
  243. package/node_modules/pi-lens/dist/clients/project-lens-config.js +438 -122
  244. package/node_modules/pi-lens/dist/clients/project-scan-policy.js +3 -51
  245. package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
  246. package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
  247. package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
  248. package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
  249. package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
  250. package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
  251. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +16 -5
  252. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
  253. package/node_modules/pi-lens/dist/clients/read-guard.js +26 -2
  254. package/node_modules/pi-lens/dist/clients/recent-touches.js +2 -2
  255. package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
  256. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +155 -114
  257. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +1 -1
  258. package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
  259. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  260. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
  261. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
  262. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +19 -3
  263. package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
  264. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
  265. package/node_modules/pi-lens/dist/clients/runtime-session.js +184 -32
  266. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +179 -36
  267. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
  268. package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
  269. package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
  270. package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
  271. package/node_modules/pi-lens/dist/clients/sanitize.js +7 -12
  272. package/node_modules/pi-lens/dist/clients/scan-utils.js +1 -56
  273. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
  274. package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
  275. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
  276. package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
  277. package/node_modules/pi-lens/dist/clients/session-summary.js +0 -24
  278. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
  279. package/node_modules/pi-lens/dist/clients/sgconfig.js +7 -6
  280. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +1 -1
  281. package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
  282. package/node_modules/pi-lens/dist/clients/smells-rollup.js +4 -4
  283. package/node_modules/pi-lens/dist/clients/startup-scan.js +2 -10
  284. package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
  285. package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
  286. package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
  287. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +244 -0
  288. package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
  289. package/node_modules/pi-lens/dist/clients/tool-policy.js +92 -22
  290. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
  291. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +87 -100
  292. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
  293. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
  294. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
  295. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  296. package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -4
  297. package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
  298. package/node_modules/pi-lens/dist/clients/user-notify.js +6 -6
  299. package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
  300. package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -9
  301. package/node_modules/pi-lens/dist/clients/word-index-store.js +23 -12
  302. package/node_modules/pi-lens/dist/clients/word-index.js +252 -47
  303. package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
  304. package/node_modules/pi-lens/dist/clients/zizmor-config.js +4 -1
  305. package/node_modules/pi-lens/dist/index.js +65899 -55340
  306. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
  307. package/node_modules/pi-lens/dist/mcp/build-staleness.js +1 -1
  308. package/node_modules/pi-lens/dist/mcp/server.js +127 -5
  309. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
  310. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
  311. package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
  312. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +96 -25
  313. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +104 -86
  314. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
  315. package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +3 -3
  316. package/node_modules/pi-lens/dist/tools/render-compact.js +1 -1
  317. package/node_modules/pi-lens/dist/tools/shared.js +0 -1
  318. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -2
  319. package/node_modules/pi-lens/docs/agent-guide.md +64 -1
  320. package/node_modules/pi-lens/docs/configuration.md +222 -0
  321. package/node_modules/pi-lens/docs/dependencies.md +3 -3
  322. package/node_modules/pi-lens/docs/features.md +44 -5
  323. package/node_modules/pi-lens/docs/globalconfig.md +20 -0
  324. package/node_modules/pi-lens/docs/language-coverage.md +2 -2
  325. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +9 -1
  326. package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
  327. package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
  328. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +33 -0
  329. package/node_modules/pi-lens/docs/pi-lens-subagent.md +49 -0
  330. package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
  331. package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
  332. package/node_modules/pi-lens/docs/release-qa-baseline.md +190 -0
  333. package/node_modules/pi-lens/docs/servercapabilities.md +7 -3
  334. package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
  335. package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
  336. package/node_modules/pi-lens/docs/word-index.md +1 -1
  337. package/node_modules/pi-lens/package.json +18 -35
  338. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  339. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  340. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  341. package/node_modules/pi-lens/rules/typos/_typos.toml +4 -2
  342. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
  343. package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
  344. package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
  345. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +1 -1
  346. package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
  347. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +4 -2
  348. package/node_modules/pi-web-access/CHANGELOG.md +28 -0
  349. package/node_modules/pi-web-access/README.md +62 -15
  350. package/node_modules/pi-web-access/curator-page.ts +3 -1
  351. package/node_modules/pi-web-access/curator-server.ts +5 -1
  352. package/node_modules/pi-web-access/gemini-search.ts +8 -4
  353. package/node_modules/pi-web-access/github-extract.ts +242 -1
  354. package/node_modules/pi-web-access/index.ts +118 -64
  355. package/node_modules/pi-web-access/mistral-search.ts +281 -0
  356. package/node_modules/pi-web-access/package.json +2 -2
  357. package/node_modules/pi-web-access/perplexity.ts +14 -1
  358. package/node_modules/pi-web-access/utils.ts +23 -6
  359. package/node_modules/pi-web-access/xai-search.ts +96 -33
  360. package/package.json +6 -6
@@ -12,6 +12,7 @@ import * as nodeFs from "node:fs";
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
14
  import { pathToFileURL, URL } from "node:url";
15
+ import { BoundedFifoMap } from "../bounded-cache.js";
15
16
  import { getProjectIgnoreMatcher, isExcludedDirName } from "../file-utils.js";
16
17
  import { recordLsp } from "../widget-state.js";
17
18
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
@@ -22,7 +23,11 @@ import { logSessionStart } from "../sessionstart-logger.js";
22
23
  import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
23
24
  import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
24
25
  import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
25
- import { withDeadline, withTimeout } from "../deadline-utils.js";
26
+ import { sampleProcessTreeCpuPercent } from "../resource-sampler.js";
27
+ import { bounded, withDeadline, withTimeout } from "../deadline-utils.js";
28
+ import { HOOK_WALL_BUDGET_MS } from "../hook-budgets.js";
29
+ import { getAmbientAbortSignal } from "../safe-spawn.js";
30
+ import { abortDeferredLspWork } from "../deferred-lsp-work.js";
26
31
  import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
27
32
  import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
28
33
  import { markPendingAuxiliaryCoverage, napiFallbackCoveredSince, } from "./pending-aux-coverage.js";
@@ -37,7 +42,7 @@ import { getServersForFileWithConfig, getServerInitOverride, } from "./config.js
37
42
  import { getSessionRootsForTelemetry, isOutsideAllSessionRoots, } from "./session-roots.js";
38
43
  import { getProcessSingleton } from "../process-singletons.js";
39
44
  import { getLanguageId } from "./language.js";
40
- import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
45
+ import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, resetLspLaunchAvailabilityGeneration, } from "./server.js";
41
46
  import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
42
47
  const WORKSPACE_ATTRIBUTION_CLIENT_CAP = 16;
43
48
  import { raceToCompletion } from "./aggregation.js";
@@ -468,6 +473,19 @@ function notifyWriteBudgetMs() {
468
473
  const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
469
474
  return Number.isFinite(raw) && raw > 0 ? raw : 2000;
470
475
  }
476
+ // #2239: the SAME effective per-server wait floor `getClientForFile` uses to
477
+ // size its own acquisition race — the caller's declared budget, raised to
478
+ // whatever the file's primary server(s) configure via `clientWaitTimeoutMs`
479
+ // (Ruby 30s, and the Bash/JSON/Vue/Svelte/Prisma overrides #2233 added). A
480
+ // cold primary spawn is allowed to run this long, so anything downstream that
481
+ // charges itself against `maxWaitMs` alone — rather than this floor — sees an
482
+ // already-elapsed time it never budgeted for and clamps to zero.
483
+ function primaryServerWaitFloorMs(filePath, maxWaitMs) {
484
+ const serverWaitOverrideMs = getServersForFileWithConfig(filePath)
485
+ .filter((s) => s.role !== "auxiliary")
486
+ .reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
487
+ return Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
488
+ }
471
489
  // #1459: how long ONE auxiliary notify write may stay outstanding before the
472
490
  // server counts as wedged rather than merely slow. A scanner whose per-file work
473
491
  // exceeds the write budget is normal (opengrep routinely needs >2s on a large
@@ -479,6 +497,30 @@ const NOTIFY_WEDGED_BUDGET_MULTIPLIER = 5;
479
497
  function notifyWedgedMs() {
480
498
  return notifyWriteBudgetMs() * NOTIFY_WEDGED_BUDGET_MULTIPLIER;
481
499
  }
500
+ // #2358: the wedge window's teardown decision gained a liveness discriminator.
501
+ // The fixed window above stays the FLOOR; a server whose drain history says it
502
+ // answers per-write in `ewmaMs` with `unacked` documents queued earns
503
+ // `k x ewmaMs x unacked` of patience instead — the issue's "a server that
504
+ // historically answers in 855 ms with 8 writes queued earns 15-20 s". The cap
505
+ // bounds the whole thing so a busy-but-never-draining server still respawns.
506
+ const NOTIFY_WEDGED_EWMA_MULTIPLIER = 2;
507
+ const NOTIFY_WEDGED_CAP_MS = 60_000;
508
+ function notifyWedgedCapMs() {
509
+ const raw = Number(process.env.PI_LENS_LSP_NOTIFY_WEDGED_CAP_MS);
510
+ return Number.isFinite(raw) && raw > 0 ? raw : NOTIFY_WEDGED_CAP_MS;
511
+ }
512
+ // #2358: how long the CPU-liveness discriminator watches the server between
513
+ // two process-CPU samples before deciding "busy" vs "flat". Only ever spent
514
+ // on a server whose write is already past its patience window — never on the
515
+ // per-edit hot path.
516
+ function notifyStallCpuSampleMs() {
517
+ const raw = Number(process.env.PI_LENS_LSP_NOTIFY_STALL_CPU_SAMPLE_MS);
518
+ return Number.isFinite(raw) && raw > 0 ? raw : 1200;
519
+ }
520
+ // A server burning more than this percent of one core across the sample
521
+ // window counts as BUSY (progressing), not flat (wedged). A single-threaded
522
+ // scanner under burst sits near 100; an idle or dead one near 0.
523
+ const NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT = 10;
482
524
  // #1714: how many document notifies one auxiliary may hold UNACKNOWLEDGED
483
525
  // before the next notify has to prove the server drained its input.
484
526
  //
@@ -605,6 +647,12 @@ export class LSPService {
605
647
  /** Server/root pairs that already emitted unavailable for the current occurrence. */
606
648
  unavailableLogged = new Set();
607
649
  optionalDisabled = new Set();
650
+ /**
651
+ * One first-contact diagnostic wait per custom server in this service
652
+ * generation. Concurrent touches share the probe; a hook cutoff never arms
653
+ * the navigation-only latch, so the next touch can try again.
654
+ */
655
+ firstContactWaits = new Map();
608
656
  /**
609
657
  * #1934 review F1: what the last COMPLETED `spawnClient` call for a
610
658
  * (server, root) key decided, written by that call at every point it
@@ -712,6 +760,22 @@ export class LSPService {
712
760
  * successful write clears its entry.
713
761
  */
714
762
  notifyWriteBackpressureStreak = new Map();
763
+ /**
764
+ * #2358: EWMA of one auxiliary's per-document drain latency
765
+ * (ms per unacknowledged write), keyed by "serverId:normalizedRoot".
766
+ *
767
+ * Updated from DRAINED notify barriers only: the round-trip proves the
768
+ * server processed its `outstanding` backlog, and
769
+ * duration / outstanding is the per-write service time a single-threaded
770
+ * scanner actually achieves under load. The breaker grants a wedged write
771
+ * `k x ewma x unacked` of patience from it, so a slow-but-alive scanner is
772
+ * not killed at the fixed window by construction (#2358).
773
+ *
774
+ * Cleared by the service teardown (via `session_start`) like the rest of
775
+ * the breaker state; a stale throughput estimate from a previous session's
776
+ * client must not price the next one.
777
+ */
778
+ auxNotifyDrainLatencyEwma = new Map();
715
779
  /**
716
780
  * #1714: unacknowledged auxiliary document notifies per server key
717
781
  * ("serverId:normalizedRoot" — the same identity as every other gate here).
@@ -748,6 +812,15 @@ export class LSPService {
748
812
  * never lands is still demoted (see {@link demoteForNotifyStall}).
749
813
  */
750
814
  outstandingAuxNotifyWrites = new Map();
815
+ /**
816
+ * #2356: auxiliary clients removed by the notify-stall breaker are a
817
+ * temporary absence, not a missing scanner. Keep the demotion identity after
818
+ * deleting the client so the turn-end late-coverage probe can re-arm the
819
+ * pending pair until a replacement is published (or its existing bounded
820
+ * re-arm ceiling is reached). A successful replacement removes the marker.
821
+ */
822
+ static MAX_NOTIFY_STALL_DEMOTIONS = 50;
823
+ notifyStallDemotions = new BoundedFifoMap(LSPService.MAX_NOTIFY_STALL_DEMOTIONS);
751
824
  /** LRU clock for capacity eviction, keyed by the canonical server/root key. */
752
825
  clientLastUsedAt = new Map();
753
826
  /**
@@ -767,6 +840,8 @@ export class LSPService {
767
840
  clientSpawnGate = Promise.resolve();
768
841
  /** True after shutdown() has been called; blocks new operations */
769
842
  isDestroyed = false;
843
+ /** Aborts bounded in-flight service work when this generation is retired. */
844
+ shutdownController = new AbortController();
770
845
  /**
771
846
  * #850: teardown completion for every singleton generation retired before
772
847
  * this service was published. Only replacement services receive one; direct
@@ -786,6 +861,8 @@ export class LSPService {
786
861
  clientSpawnedAt: new Map(),
787
862
  demonstratedReady: new Set(),
788
863
  demonstratedCold: new Set(),
864
+ diagnosticsPublished: new Set(),
865
+ diagnosticsUnsupported: new Set(),
789
866
  };
790
867
  }
791
868
  /**
@@ -948,6 +1025,7 @@ export class LSPService {
948
1025
  return false;
949
1026
  }
950
1027
  const [victimKey, victimClient] = victim;
1028
+ this.releaseOutstandingAuxNotifyWrite(victimKey);
951
1029
  await victimClient.shutdown({ reason: "client_ceiling_lru" });
952
1030
  this.state.clients.delete(victimKey);
953
1031
  this.state.clientSpawnedAt.delete(victimKey);
@@ -964,6 +1042,15 @@ export class LSPService {
964
1042
  clearTimeout(timer);
965
1043
  this.typeScriptIdleTimers.delete(key);
966
1044
  }
1045
+ /** Release a notify token and its timer when its client generation retires. */
1046
+ releaseOutstandingAuxNotifyWrite(key, token) {
1047
+ const current = this.outstandingAuxNotifyWrites.get(key);
1048
+ if (!current || (token !== undefined && current !== token))
1049
+ return;
1050
+ clearTimeout(current.wedgeTimer);
1051
+ this.outstandingAuxNotifyWrites.delete(key);
1052
+ current.resolveSettled();
1053
+ }
967
1054
  scheduleTypeScriptIdleEviction(key) {
968
1055
  if (!key.startsWith("typescript:"))
969
1056
  return;
@@ -989,6 +1076,7 @@ export class LSPService {
989
1076
  // Publish the cold state synchronously before awaiting teardown. A request
990
1077
  // arriving while shutdown is in progress therefore waits on the spawn gate
991
1078
  // and creates a fresh client; it can never receive this retiring one.
1079
+ this.releaseOutstandingAuxNotifyWrite(key);
992
1080
  this.state.clients.delete(key);
993
1081
  this.state.clientSpawnedAt.delete(key);
994
1082
  this.state.demonstratedReady.delete(key);
@@ -1130,23 +1218,130 @@ export class LSPService {
1130
1218
  return;
1131
1219
  }
1132
1220
  this.notifyWriteBackpressureStreak.delete(key);
1133
- this.demoteForNotifyStall(key, entry, filePath, {
1221
+ // #2358: the consecutive-timeout ladder lands here too, and its verdict
1222
+ // must not kill a server that is demonstrably BUSY. The guarded demotion
1223
+ // samples the live process CPU and merely resets the ladder (the next
1224
+ // three timeouts re-try) when the server is progressing — the wedged-write
1225
+ // path's cap owns a busy-but-never-draining server, not this streak.
1226
+ void this.demoteNotifyStallCpuGuarded(key, entry, filePath, {
1134
1227
  consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
1135
1228
  });
1136
1229
  }
1230
+ /**
1231
+ * #2358: liveness-guarded teardown for the STREAK ladder.
1232
+ *
1233
+ * The wedged-write path ({@link decideNotifyStallTeardown}) keeps its own
1234
+ * re-arm loop so a busy server's outstanding write stays un-torn-down; the
1235
+ * streak ladder has no write to hold — it simply does not demote, and the
1236
+ * next three timeouts re-attempt. Generation-guarded after the await so a
1237
+ * client replaced mid-sample is never demoted for a verdict about its
1238
+ * predecessor.
1239
+ */
1240
+ async demoteNotifyStallCpuGuarded(key, entry, filePath, reason) {
1241
+ if (this.state.clients.get(key) !== entry.client)
1242
+ return;
1243
+ // The CPU verdict is asynchronous, but the streak has already committed to
1244
+ // this client's teardown path. Release the TypeScript idle-timer ownership
1245
+ // before sampling so another idle callback cannot target the same client
1246
+ // while the verdict is in flight. A BUSY verdict below re-arms a fresh timer.
1247
+ this.clearTypeScriptIdleTimer(key);
1248
+ const verdict = await this.notifyStallCpuVerdict(entry);
1249
+ if (verdict.cpuVerdict === "busy") {
1250
+ if (this.state.clients.get(key) === entry.client) {
1251
+ this.scheduleTypeScriptIdleEviction(key);
1252
+ }
1253
+ this.logNotifyStallCpuBusy(key, entry, filePath, 0, verdict);
1254
+ return;
1255
+ }
1256
+ if (this.state.clients.get(key) !== entry.client)
1257
+ return;
1258
+ this.demoteForNotifyStall(key, entry, filePath, {
1259
+ ...reason,
1260
+ discriminator: "consecutive-timeouts",
1261
+ cpuVerdict: verdict.cpuVerdict,
1262
+ });
1263
+ }
1264
+ /**
1265
+ * #2358: classify a stalled server's live process CPU.
1266
+ *
1267
+ * A client with no process handle (a test/mock double, a legacy client)
1268
+ * reports "unmeasured" — the caller keeps the pre-#2358 demote-at-budget
1269
+ * behavior, because there is no liveness evidence to override it. A sampled
1270
+ * process that burns a core is BUSY; anything else is FLAT, including a
1271
+ * query that failed to resolve the pid — a server we cannot measure is not
1272
+ * proven busy, and the replacement self-heal is cheap.
1273
+ */
1274
+ async notifyStallCpuVerdict(entry) {
1275
+ const pid = entry.client.getProcessPid?.();
1276
+ if (pid === undefined || !(Number.isFinite(pid) && pid > 0)) {
1277
+ return { cpuVerdict: "unmeasured", cpuPercent: null };
1278
+ }
1279
+ const sample = await sampleProcessTreeCpuPercent(pid, notifyStallCpuSampleMs(), NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT);
1280
+ if (!sample.measured) {
1281
+ return { cpuVerdict: "unmeasured", cpuPercent: null };
1282
+ }
1283
+ if (sample.busy) {
1284
+ return { cpuVerdict: "busy", cpuPercent: sample.cpuPercent };
1285
+ }
1286
+ return { cpuVerdict: "flat", cpuPercent: sample.cpuPercent };
1287
+ }
1288
+ /**
1289
+ * #2358: one bounded row per busy defer. Not a failure record — the
1290
+ * discrimination succeeded — so it is a plain latency phase; its volume is
1291
+ * bounded by the re-arm cadence of one outstanding write (at most the wedge
1292
+ * cap divided by the budget, ~6 fires at defaults).
1293
+ */
1294
+ logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, extra = {}) {
1295
+ emitBounded("lsp_notify_stall_cpu_busy", `${key}:${normalizeMapKey(filePath)}`, {
1296
+ type: "phase",
1297
+ durationMs: outstandingMs,
1298
+ metadata: {
1299
+ serverId: entry.info.id,
1300
+ clientKey: key,
1301
+ outstandingMs,
1302
+ cpuPercent: verdict.cpuPercent ?? null,
1303
+ ...extra,
1304
+ },
1305
+ }, {
1306
+ ledgerKind: "lsp-notify-stall-cpu-busy",
1307
+ risingEdgePer: "identity",
1308
+ });
1309
+ }
1310
+ /**
1311
+ * #2358: the patience window a wedged auxiliary write earns.
1312
+ *
1313
+ * max(fixed floor, k x EWMA per-write latency x unacked depth), capped so a
1314
+ * busy-but-never-draining server still respawns within the cap. The EWMA
1315
+ * comes from DRAINED notify barriers (see {@link noteAuxNotifyDrainLatency});
1316
+ * with no history the fixed floor (the pre-#2358 window) stands.
1317
+ */
1318
+ auxNotifyWedgeBudgetMs(key) {
1319
+ let budgetMs = notifyWedgedMs();
1320
+ const ewma = this.auxNotifyDrainLatencyEwma.get(key);
1321
+ if (ewma !== undefined && ewma > 0) {
1322
+ const unacked = this.auxNotifyInflight.get(key)?.unacked ?? 0;
1323
+ budgetMs = Math.max(budgetMs, NOTIFY_WEDGED_EWMA_MULTIPLIER * ewma * unacked);
1324
+ }
1325
+ return Math.min(budgetMs, notifyWedgedCapMs());
1326
+ }
1137
1327
  /**
1138
1328
  * The demotion itself, shared by the #743 consecutive-timeout streak and the
1139
1329
  * #1459 wedged-write rule. Both mean the same thing — this client's input path
1140
1330
  * is not moving — and both need the same teardown.
1141
1331
  */
1142
1332
  demoteForNotifyStall(key, entry, filePath, reason) {
1333
+ // An async verdict belongs to one client generation. Never let a
1334
+ // predecessor's decision delete or cool down its replacement.
1335
+ if (this.state.clients.get(key) !== entry.client)
1336
+ return;
1143
1337
  this.notifyWriteBackpressureStreak.delete(key);
1144
- this.outstandingAuxNotifyWrites.delete(key);
1338
+ this.releaseOutstandingAuxNotifyWrite(key);
1145
1339
  // #1714: the demoted client is torn down, so its backlog count describes a
1146
1340
  // process that no longer exists. Leaving it would make the replacement start
1147
1341
  // at the ceiling and pay a barrier on its first file.
1148
1342
  this.auxNotifyInflight.delete(key);
1149
1343
  this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
1344
+ this.notifyStallDemotions.set(key, Date.now());
1150
1345
  void entry.client.shutdown().catch(() => { });
1151
1346
  this.state.clients.delete(key);
1152
1347
  this.state.clientSpawnedAt.delete(key);
@@ -1209,6 +1404,87 @@ export class LSPService {
1209
1404
  }
1210
1405
  this.auxNotifyInflight.set(key, { client, unacked: 1 });
1211
1406
  }
1407
+ /**
1408
+ * #2358: fold one DRAINED notify barrier's per-write latency into the
1409
+ * per-key EWMA. A drained round-trip proves the server processed its
1410
+ * `outstanding` backlog after the ping; duration / outstanding is the
1411
+ * per-document service time the single-threaded scanner actually achieves
1412
+ * under load, which is exactly what a busy-server patience window must be
1413
+ * priced from (#2358's evidence: an 855 ms per-answer server with an
1414
+ * 8-write backlog, able to drain in 10-20 s while healthy).
1415
+ */
1416
+ noteAuxNotifyDrainLatency(key, durationMs, outstanding, record) {
1417
+ if (!(outstanding > 0 && durationMs > 0))
1418
+ return;
1419
+ if (record && this.auxNotifyInflight.get(key) !== record)
1420
+ return;
1421
+ const perWriteMs = durationMs / outstanding;
1422
+ const prev = this.auxNotifyDrainLatencyEwma.get(key);
1423
+ this.auxNotifyDrainLatencyEwma.set(key, prev === undefined ? perWriteMs : 0.5 * perWriteMs + 0.5 * prev);
1424
+ }
1425
+ /**
1426
+ * #2358: decide whether a wedged notify write's teardown really fires.
1427
+ *
1428
+ * Before #2358 the breaker killed a server whose write stayed outstanding
1429
+ * past a FIXED window, conflating a dead input path with a busy scanner
1430
+ * draining a burst — the live opengrep kill at #2358's head. The decision
1431
+ * now has two guards, and the record names which one fired:
1432
+ *
1433
+ * - the window is ADAPTIVE — max(fixed floor, k x EWMA per-write latency x
1434
+ * unacked depth), capped at {@link notifyWedgedCapMs} — so a server that
1435
+ * historically answers in 855 ms with 8 writes queued earns 13.7 s (k=2),
1436
+ * not 10 s;
1437
+ * - past the window, the server's process CPU is sampled twice; a BUSY
1438
+ * server is left alone (the write stays outstanding and the caller re-arms
1439
+ * this timer), a FLAT or unmeasured one is torn down.
1440
+ *
1441
+ * The hard cap still kills: a busy server that cannot drain inside it is
1442
+ * damaged, and the replacement is the self-heal the breaker exists to
1443
+ * provide.
1444
+ */
1445
+ async decideNotifyStallTeardown(key, entry, filePath, token) {
1446
+ if (this.state.clients.get(key) !== entry.client ||
1447
+ this.outstandingAuxNotifyWrites.get(key) !== token) {
1448
+ throw new Error("stale notify generation");
1449
+ }
1450
+ const outstandingMs = Date.now() - token.startedAt;
1451
+ const armedBudgetMs = token.armedBudgetMs;
1452
+ if (outstandingMs >= notifyWedgedCapMs()) {
1453
+ return {
1454
+ action: "demote",
1455
+ reason: {
1456
+ outstandingMs,
1457
+ discriminator: "cap-exceeded",
1458
+ budgetMs: armedBudgetMs,
1459
+ },
1460
+ };
1461
+ }
1462
+ const verdict = await this.notifyStallCpuVerdict(entry);
1463
+ if (this.state.clients.get(key) !== entry.client ||
1464
+ this.outstandingAuxNotifyWrites.get(key) !== token) {
1465
+ throw new Error("stale notify generation");
1466
+ }
1467
+ if (verdict.cpuVerdict === "busy") {
1468
+ this.logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, {
1469
+ budgetMs: armedBudgetMs,
1470
+ });
1471
+ return { action: "rearm", budgetMs: this.auxNotifyWedgeBudgetMs(key) };
1472
+ }
1473
+ return {
1474
+ action: "demote",
1475
+ reason: {
1476
+ outstandingMs,
1477
+ discriminator: verdict.cpuVerdict === "unmeasured"
1478
+ ? "budget-exceeded"
1479
+ : "budget-exceeded-cpu-flat",
1480
+ budgetMs: armedBudgetMs,
1481
+ ewmaInputMs: this.auxNotifyDrainLatencyEwma.get(key),
1482
+ unackedDepth: this.auxNotifyInflight.get(key)?.unacked ?? 0,
1483
+ cpuVerdict: verdict.cpuVerdict,
1484
+ cpuPercent: verdict.cpuPercent ?? null,
1485
+ },
1486
+ };
1487
+ }
1212
1488
  /**
1213
1489
  * #1714: is this auxiliary already holding as many documents as it may?
1214
1490
  *
@@ -1339,11 +1615,17 @@ export class LSPService {
1339
1615
  // and the waiter is what latches. A second latch here would be
1340
1616
  // unreachable, and no test could hold it honest.
1341
1617
  }
1618
+ // #2358: a DRAINED round-trip is a per-write latency measurement —
1619
+ // the server really processed its backlog — so it feeds the EWMA
1620
+ // the adaptive wedge window is priced from.
1621
+ const durationMs = Date.now() - startedAt;
1622
+ if (drained)
1623
+ this.noteAuxNotifyDrainLatency(key, durationMs, outstanding, record);
1342
1624
  this.noteDrainBarrierOutcome(key, entry, filePath, context, {
1343
1625
  unacked: outstanding,
1344
1626
  limit,
1345
1627
  waitMs,
1346
- durationMs: Date.now() - startedAt,
1628
+ durationMs,
1347
1629
  outcome: drained ? "drained" : "stalled",
1348
1630
  });
1349
1631
  return drained;
@@ -1466,7 +1748,7 @@ export class LSPService {
1466
1748
  // respawned) says nothing about this client's stdin — drop it, so a stale
1467
1749
  // entry can never starve a healthy server.
1468
1750
  if (outstanding && outstanding.client !== entry.client) {
1469
- this.outstandingAuxNotifyWrites.delete(clientKey);
1751
+ this.releaseOutstandingAuxNotifyWrite(clientKey, outstanding);
1470
1752
  }
1471
1753
  else if (outstanding) {
1472
1754
  const outstandingMs = Date.now() - outstanding.startedAt;
@@ -1503,30 +1785,70 @@ export class LSPService {
1503
1785
  startedAt: Date.now(),
1504
1786
  client: entry.client,
1505
1787
  settled,
1506
- // A write nothing accepts for the whole wedge window is a dead input
1507
- // path, not a slow scan. Armed HERE rather than checked by the next
1508
- // waiter: inside a burst every waiter arrives within one budget, so a
1509
- // waiter-side check could never see the wedge window elapse and a
1510
- // wedged scanner was never demoted. Unref'd so it cannot hold a
1511
- // one-shot host alive, and cleared on release.
1512
- wedgeTimer: setTimeout(() => {
1513
- if (this.outstandingAuxNotifyWrites.get(clientKey) !== token)
1514
- return;
1515
- this.demoteForNotifyStall(clientKey, entry, filePath, {
1516
- outstandingMs: Date.now() - token.startedAt,
1517
- });
1788
+ armedBudgetMs: 0,
1789
+ // SAFETY: the handle is a torn-initialized placeholder. It is armed
1790
+ // a beat later in this same atomic claim (no await in between) and
1791
+ // thereafter only ever written by this token's own fire/release
1792
+ // closures, so the placeholder value is never read by anyone.
1793
+ wedgeTimer: undefined,
1794
+ resolveSettled: () => resolveSettled?.(),
1795
+ };
1796
+ // A write nothing accepts for the whole wedge window is a dead input
1797
+ // path, not a slow scan. Armed HERE rather than checked by the next
1798
+ // waiter: inside a burst every waiter arrives within one budget, so a
1799
+ // waiter-side check could never see the wedge window elapse and a
1800
+ // wedged scanner was never demoted. Unref'd so it cannot hold a
1801
+ // one-shot host alive, and cleared on release.
1802
+ //
1803
+ // #2358: the window itself is now ADAPTIVE (issue's `max(fixed,
1804
+ // k x EWMA per-answer latency x unacked depth)`, capped), and when it
1805
+ // fires the server's process CPU is sampled before the kill — a busy
1806
+ // server is left alone and the timer re-arms, so a scanner draining a
1807
+ // burst is never killed by construction. Every fire re-checks the
1808
+ // token identity after its awaits, so a release or a concurrent
1809
+ // demotion that lands mid-sample aborts the decision.
1810
+ const fireWedge = async () => {
1811
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
1812
+ this.state.clients.get(clientKey) !== entry.client) {
1813
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1814
+ return;
1815
+ }
1816
+ let decision;
1817
+ try {
1818
+ decision = await this.decideNotifyStallTeardown(clientKey, entry, filePath, token);
1819
+ }
1820
+ catch {
1821
+ // A failed decision must not strand the outstanding write or the
1822
+ // waiter: fall back to the pre-#2358 demote-at-budget teardown.
1823
+ decision = {
1824
+ action: "demote",
1825
+ reason: {
1826
+ outstandingMs: Date.now() - token.startedAt,
1827
+ discriminator: "budget-exceeded",
1828
+ budgetMs: token.armedBudgetMs,
1829
+ },
1830
+ };
1831
+ }
1832
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
1833
+ this.state.clients.get(clientKey) !== entry.client) {
1834
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1835
+ return;
1836
+ }
1837
+ if (decision.action === "demote") {
1838
+ this.demoteForNotifyStall(clientKey, entry, filePath, decision.reason);
1518
1839
  resolveSettled?.();
1519
- }, notifyWedgedMs()),
1840
+ return;
1841
+ }
1842
+ token.wedgeTimer = setTimeout(() => void fireWedge(), decision.budgetMs);
1843
+ token.wedgeTimer.unref?.();
1520
1844
  };
1845
+ token.armedBudgetMs = this.auxNotifyWedgeBudgetMs(clientKey);
1846
+ token.wedgeTimer = setTimeout(() => void fireWedge(), token.armedBudgetMs);
1521
1847
  token.wedgeTimer.unref?.();
1522
1848
  this.outstandingAuxNotifyWrites.set(clientKey, token);
1523
1849
  return {
1524
1850
  release: () => {
1525
- clearTimeout(token.wedgeTimer);
1526
- if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
1527
- this.outstandingAuxNotifyWrites.delete(clientKey);
1528
- }
1529
- resolveSettled?.();
1851
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1530
1852
  },
1531
1853
  };
1532
1854
  }
@@ -1607,6 +1929,30 @@ export class LSPService {
1607
1929
  return entries.sort((a, b) => (priority.get(a.serverId) ?? Number.MAX_SAFE_INTEGER) -
1608
1930
  (priority.get(b.serverId) ?? Number.MAX_SAFE_INTEGER));
1609
1931
  }
1932
+ /**
1933
+ * Documents currently open on any live client rooted at or under `cwd`
1934
+ * (#2430), as normalized path keys.
1935
+ *
1936
+ * The third contributor to the observational mutation net's tracked-file
1937
+ * set, alongside the read-guard's read/write set and the widget's diagnostic
1938
+ * files. It is pure in-memory enumeration — no spawn, no open, no stat —
1939
+ * and it is deliberately capped by the caller rather than here, because the
1940
+ * cap belongs to the sweep's budget, not to the pool's bookkeeping.
1941
+ */
1942
+ getOpenDocumentPaths(cwd) {
1943
+ const paths = new Set();
1944
+ for (const { client } of this.activeClientsForCwd(cwd)) {
1945
+ try {
1946
+ for (const filePath of client.openDocumentPaths?.() ?? [])
1947
+ paths.add(filePath);
1948
+ }
1949
+ catch {
1950
+ // A client torn down mid-enumeration contributes nothing; the sweep
1951
+ // is advisory and must never break on a dying server.
1952
+ }
1953
+ }
1954
+ return [...paths];
1955
+ }
1610
1956
  /**
1611
1957
  * Get or create LSP client for a file
1612
1958
  * Prevents duplicate client creation via in-flight promise tracking
@@ -1617,14 +1963,76 @@ export class LSPService {
1617
1963
  // Primary selection considers language servers only — auxiliary servers
1618
1964
  // (opengrep, …) attach alongside the primary and are never chosen as it.
1619
1965
  const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
1620
- const serverWaitOverrideMs = servers.reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
1966
+ // #2525: candidate roots are resolved LAZILY, and at most once per call.
1967
+ // They are deliberately NOT hoisted: `NearestRoot` (clients/lsp/server.ts)
1968
+ // caches successful hits but never negatives, so a fallback that cannot
1969
+ // root here — DenoServer (`fallbackFor: "typescript"`, gated on
1970
+ // deno.json(c)) in a project with no deno.json — re-walks to the
1971
+ // filesystem root on EVERY call, by design, so that a deno.json
1972
+ // scaffolded mid-session is picked up without a restart. Hoisting would
1973
+ // charge that walk to every touch of every .ts file in every non-Deno
1974
+ // project. The sequential trial loop below needs no roots of its own
1975
+ // (`ensureClientForServer` resolves and bails at `!root` before any spawn
1976
+ // work), so a served touch — the hot path — resolves nothing extra at
1977
+ // all. Only the branches that EMIT a candidate list ask, and all of them
1978
+ // are cold paths.
1979
+ const rootMemo = new Map();
1980
+ /**
1981
+ * Split the candidates into the ones that could actually have been waited
1982
+ * on and the ones that never had a spawn slot. A record's subject must be
1983
+ * its producer (#1550) — the #2524 shape.
1984
+ *
1985
+ * #2525 round 3 (F3): no SHIPPED `server.root` rejects today — every one
1986
+ * bottoms out in `markerExists`, which only ever resolves `false` on a
1987
+ * miss — so this was reasoned "removed by construction". A reviewer probe
1988
+ * disproved that: a `root()` that rejects makes this `Promise.all` reject,
1989
+ * which throws out of `getClientForFile` itself (the caller never gets
1990
+ * `undefined` back, and `lsp_client_wait_timeout` is never emitted). A
1991
+ * per-server catch degrades that candidate to unrooted instead of taking
1992
+ * the whole partition down — the same "producer" the memo already knows
1993
+ * (a failed root resolution never got a client either), now WITH the
1994
+ * failure reason so the record says why, not just that.
1995
+ */
1996
+ const partitionCandidates = async () => {
1997
+ const resolved = await Promise.all(servers.map(async (server) => {
1998
+ try {
1999
+ return {
2000
+ server,
2001
+ root: await this.resolveServerRootOnce(server, filePath, rootMemo),
2002
+ reason: undefined,
2003
+ };
2004
+ }
2005
+ catch (error) {
2006
+ return {
2007
+ server,
2008
+ root: undefined,
2009
+ reason: error instanceof Error ? error.message : String(error),
2010
+ };
2011
+ }
2012
+ }));
2013
+ const unrootedReasons = {};
2014
+ for (const entry of resolved) {
2015
+ if (entry.root === undefined && entry.reason !== undefined) {
2016
+ unrootedReasons[entry.server.id] = entry.reason;
2017
+ }
2018
+ }
2019
+ return {
2020
+ rooted: resolved
2021
+ .filter((entry) => entry.root !== undefined)
2022
+ .map(({ server, root }) => ({ server, root })),
2023
+ unrootedIds: resolved
2024
+ .filter((entry) => entry.root === undefined)
2025
+ .map((entry) => entry.server.id),
2026
+ unrootedReasons,
2027
+ };
2028
+ };
1621
2029
  // hardCapMs is a caller-imposed ceiling (e.g. pipeline budget) that
1622
2030
  // prevents tool_result from blocking the TUI for the full LSP cold-start
1623
2031
  // window. When no server config sets a wait (serverWaitOverrideMs = 0),
1624
2032
  // hardCapMs is used directly — Math.min(0, cap) = 0 would otherwise
1625
2033
  // take the no-timeout branch and block indefinitely (e.g. pyright, which
1626
2034
  // has no clientWaitTimeoutMs but can take 30s to initialize on cold start).
1627
- const serverBaseMs = Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
2035
+ const serverBaseMs = primaryServerWaitFloorMs(filePath, maxWaitMs);
1628
2036
  const effectiveMaxWaitMs = hardCapMs !== undefined
1629
2037
  ? serverBaseMs > 0
1630
2038
  ? Math.min(serverBaseMs, hardCapMs)
@@ -1655,7 +2063,10 @@ export class LSPService {
1655
2063
  // joined it, instead of pinning a starter label on every joiner.
1656
2064
  let erroredServerId;
1657
2065
  let erroredOutcome;
1658
- // Try each matching server
2066
+ // Try each matching server in registration order.
2067
+ // `ensureClientForServer` resolves the root itself and returns before
2068
+ // any spawn work when there is none, so an unrooted fallback costs
2069
+ // this loop one already-memoized resolution and no spawn slot.
1659
2070
  for (const server of servers) {
1660
2071
  // A box, not a `let`: control-flow analysis cannot see the callback
1661
2072
  // write and would narrow a plain local to its initializer.
@@ -1664,7 +2075,7 @@ export class LSPService {
1664
2075
  };
1665
2076
  const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight, (reported) => {
1666
2077
  acquisition.outcome = reported;
1667
- });
2078
+ }, rootMemo);
1668
2079
  if (spawned) {
1669
2080
  logLatency({
1670
2081
  type: "phase",
@@ -1710,8 +2121,16 @@ export class LSPService {
1710
2121
  },
1711
2122
  });
1712
2123
  }
2124
+ // #2525: reuse the roots the trial loop above already resolved rather
2125
+ // than re-walking with the RAW `server.root`. Two reasons: the raw
2126
+ // call skips the ceiling/session-root gates, so this dedupe key could
2127
+ // disagree with the identity the acquisition path actually used; and
2128
+ // on the unserved path — the only path that reaches here — every
2129
+ // unrooted candidate would pay a second full walk to the filesystem
2130
+ // root, on every touch, before the `unavailableLogged` dedupe even
2131
+ // looked at it.
1713
2132
  const unavailable = (await Promise.all(servers.map(async (server) => {
1714
- const root = await server.root(filePath);
2133
+ const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
1715
2134
  return {
1716
2135
  server,
1717
2136
  key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
@@ -1767,11 +2186,13 @@ export class LSPService {
1767
2186
  // `inFlight` is cleared in ensureClientForServer's finally block, so a
1768
2187
  // settled acquisition can still be present here. Re-read the published
1769
2188
  // clients at the decision point; a usable client outranks the sentinel.
1770
- for (const server of servers) {
1771
- const root = await this.resolveServerRoot(server, filePath);
1772
- const client = root
1773
- ? this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`)
1774
- : undefined;
2189
+ // #2525: this is a cold path, so it is where the roots get resolved —
2190
+ // once, memoized with the trial loop's own resolutions. An unrooted
2191
+ // fallback can never have a client keyed under its (non-existent)
2192
+ // root, so it is neither probed here nor named in the record below.
2193
+ const { rooted, unrootedIds, unrootedReasons } = await partitionCandidates();
2194
+ for (const { server, root } of rooted) {
2195
+ const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
1775
2196
  if (client?.isAlive())
1776
2197
  return { client, info: server };
1777
2198
  }
@@ -1783,16 +2204,46 @@ export class LSPService {
1783
2204
  durationMs: 0,
1784
2205
  metadata: {
1785
2206
  maxWaitMs: effectiveMaxWaitMs,
1786
- serverIds: servers.map((server) => server.id),
2207
+ serverIds: rooted.map(({ server }) => server.id),
2208
+ unrootedCandidates: {
2209
+ count: unrootedIds.length,
2210
+ ids: unrootedIds,
2211
+ ...(Object.keys(unrootedReasons).length > 0
2212
+ ? { reasons: unrootedReasons }
2213
+ : {}),
2214
+ },
1787
2215
  reason: "budget_skipped_known_slow",
1788
2216
  },
1789
2217
  });
1790
2218
  return undefined;
1791
2219
  }
1792
2220
  if (waitResult === timeoutSentinel) {
2221
+ // #2525: likewise a cold path — resolve here, not up front.
2222
+ //
2223
+ // #2525 round 3 (L2): `partitionCandidates` does UNCACHED negative
2224
+ // root walks (`NearestRoot` never caches misses, by design — see the
2225
+ // comment above `rootMemo`) and this branch only reaches it AFTER
2226
+ // `effectiveMaxWaitMs` has already elapsed. Measured by the reviewer:
2227
+ // 2.06ms native, ~150ms on the #462 slow-FS profile — unbounded on top
2228
+ // of a budget whose entire contract is "return within the cap". Bound
2229
+ // it with a small ceiling of its own (a quarter of the caller's hard
2230
+ // cap when one was given, capped at 250ms) rather than leaving it the
2231
+ // one piece of async work in this function with no bound at all
2232
+ // (the "async work needs both bounds" shape — here there is no
2233
+ // per-call abort signal to race, only the timer bound applies).
2234
+ const partitionBudgetMs = hardCapMs !== undefined ? Math.min(250, hardCapMs / 4) : 250;
2235
+ const partitioned = await withDeadline(partitionCandidates(), {
2236
+ ms: partitionBudgetMs,
2237
+ onTimeout: "undefined",
2238
+ });
2239
+ const { rooted, unrootedIds, unrootedReasons } = partitioned ?? {
2240
+ rooted: [],
2241
+ unrootedIds: [],
2242
+ unrootedReasons: {},
2243
+ };
1793
2244
  // Snapshot known client health — scan by serverId prefix (no root needed)
1794
2245
  const knownHealth = [...this.state.clients.entries()]
1795
- .filter(([k]) => servers.some((s) => k.startsWith(`${s.id}:`)))
2246
+ .filter(([k]) => rooted.some(({ server }) => k.startsWith(`${server.id}:`)))
1796
2247
  .map(([k, c]) => ({
1797
2248
  serverId: k.split(":")[0],
1798
2249
  alive: c.isAlive(),
@@ -1805,9 +2256,23 @@ export class LSPService {
1805
2256
  durationMs: effectiveMaxWaitMs,
1806
2257
  metadata: {
1807
2258
  maxWaitMs: effectiveMaxWaitMs,
1808
- serverIds: servers.map((s) => s.id),
2259
+ // `serverIds` names the ROOTED pool the timeout record reports
2260
+ // against, not "servers this call actually waited on" — the
2261
+ // sequential trial loop can time out having reached only the
2262
+ // first of several rooted candidates (case 2 in the regression
2263
+ // test pins both `["typescript","deno"]` even though the loop
2264
+ // never got past `typescript`).
2265
+ serverIds: rooted.map(({ server }) => server.id),
2266
+ unrootedCandidates: {
2267
+ count: unrootedIds.length,
2268
+ ids: unrootedIds,
2269
+ ...(Object.keys(unrootedReasons).length > 0
2270
+ ? { reasons: unrootedReasons }
2271
+ : {}),
2272
+ },
1809
2273
  // servers absent from knownHealth were never spawned or are still spawning
1810
2274
  knownClientHealth: knownHealth,
2275
+ ...(partitioned === undefined ? { partition: "timed-out" } : {}),
1811
2276
  },
1812
2277
  });
1813
2278
  return undefined;
@@ -1815,8 +2280,10 @@ export class LSPService {
1815
2280
  return waitResult;
1816
2281
  }
1817
2282
  /**
1818
- * Get or create ALL LSP clients that can serve a file.
1819
- * Used for diagnostics aggregation across complementary servers.
2283
+ * Get or create all complementary LSP clients that can serve a file. Alternate
2284
+ * language servers marked with `fallbackFor` remain sequential fallbacks: an
2285
+ * aggregate diagnostics pass must not launch them beside a working preferred
2286
+ * server merely because it requested `clientScope: "all"`.
1820
2287
  */
1821
2288
  async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
1822
2289
  const allServers = getServersForFileWithConfig(filePath);
@@ -1825,28 +2292,59 @@ export class LSPService {
1825
2292
  : allServers;
1826
2293
  if (servers.length === 0)
1827
2294
  return { clients: [], serverCountAttempted: 0 };
1828
- // Count servers with a valid root as "attempted" — extension-only matches
1829
- // that fail the root check are not real spawn attempts.
1830
- const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
1831
- const serverCountAttempted = roots.filter(Boolean).length;
1832
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, resolvedRoots)));
1833
- return {
1834
- clients: spawned.filter((entry) => Boolean(entry)),
1835
- serverCountAttempted,
2295
+ // Resolve once to keep the attempted count tied to servers with a real root.
2296
+ const rootedServers = (await Promise.all(servers.map(async (server) => ({
2297
+ server,
2298
+ root: await this.resolveServerRoot(server, filePath),
2299
+ })))).filter((entry) => entry.root !== undefined);
2300
+ let serverCountAttempted = 0;
2301
+ const acquisitions = new Map();
2302
+ const acquire = (server) => {
2303
+ serverCountAttempted += 1;
2304
+ return this.ensureClientForServer(filePath, server, resolvedRoots);
1836
2305
  };
2306
+ // Start complementary servers immediately. An alternate waits only for its
2307
+ // own preferred server, not for unrelated scanners, and starts if that server
2308
+ // declines. Registry order makes chained fallbacks deterministic.
2309
+ for (const { server } of rootedServers) {
2310
+ const preferred = server.fallbackFor
2311
+ ? acquisitions.get(server.fallbackFor)
2312
+ : undefined;
2313
+ const acquisition = preferred
2314
+ ? preferred.then((entry) => entry === undefined ? acquire(server) : undefined)
2315
+ : acquire(server);
2316
+ acquisitions.set(server.id, acquisition);
2317
+ }
2318
+ const results = await Promise.all(acquisitions.values());
2319
+ const clients = results.filter((entry) => entry !== undefined);
2320
+ return { clients, serverCountAttempted };
1837
2321
  }
1838
2322
  /**
1839
2323
  * Spawn/get the AUXILIARY clients for a file (role:"auxiliary") restricted to
1840
2324
  * the enabled set. These attach alongside the primary on the with-auxiliary
1841
2325
  * diagnostics path (cross-cutting scanners like opengrep).
1842
2326
  */
1843
- async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome) {
2327
+ async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome, maxWaitMs, signal, hook) {
1844
2328
  if (this.checkDestroyed() || enabledIds.size === 0)
1845
2329
  return [];
1846
2330
  const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
1847
2331
  if (servers.length === 0)
1848
2332
  return [];
1849
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported))));
2333
+ const rootMemo = new Map();
2334
+ const effectiveSignal = signal ?? getAmbientAbortSignal();
2335
+ const effectiveHook = hook ?? "tool_result_edit";
2336
+ const spawned = await Promise.all(servers.map(async (server) => {
2337
+ const acquisition = this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported), rootMemo);
2338
+ if (!maxWaitMs || maxWaitMs <= 0) {
2339
+ return acquisition;
2340
+ }
2341
+ return bounded(acquisition, {
2342
+ ms: maxWaitMs,
2343
+ signal: effectiveSignal,
2344
+ hook: effectiveHook,
2345
+ label: `auxiliary-spawn:${server.id}`,
2346
+ });
2347
+ }));
1850
2348
  return spawned.filter((entry) => Boolean(entry));
1851
2349
  }
1852
2350
  /**
@@ -1928,9 +2426,11 @@ export class LSPService {
1928
2426
  * the Gate-B readiness check, this NEVER creates or warms a client — it only
1929
2427
  * resolves each requested server's root and reads the already-connected
1930
2428
  * client's cached diagnostics for `filePath`. Servers with no live client
1931
- * are simply absent from the returned map. A live client is present only
1932
- * when its per-file cache entry exists; its timestamp distinguishes a
1933
- * published clean result from no publication.
2429
+ * are absent unless notify-stall teardown marked that generation as
2430
+ * replaceable; that status carries the demotion timestamp so turn-end late
2431
+ * coverage can correlate each pair to the removed generation. A live
2432
+ * client is present even when its per-file cache entry is empty; its
2433
+ * timestamp distinguishes a published clean result from no publication.
1934
2434
  */
1935
2435
  async readCachedDiagnosticsForServers(filePath, serverIds) {
1936
2436
  const out = new Map();
@@ -1944,8 +2444,20 @@ export class LSPService {
1944
2444
  continue;
1945
2445
  const key = `${server.id}:${normalizeMapKey(root)}`;
1946
2446
  const client = this.state.clients.get(key);
1947
- if (!client?.isAlive())
2447
+ if (!client?.isAlive()) {
2448
+ const demotedAt = this.notifyStallDemotions.get(key);
2449
+ if (demotedAt !== undefined) {
2450
+ out.set(server.id, {
2451
+ diags: [],
2452
+ notifyStallDemoted: true,
2453
+ demotedAt,
2454
+ });
2455
+ }
1948
2456
  continue;
2457
+ }
2458
+ // A replacement is live again. The old generation's marker no longer
2459
+ // describes this client and must not make a later absence look transient.
2460
+ this.notifyStallDemotions.delete(key);
1949
2461
  const entry = client.getAllDiagnostics().get(normalizeMapKey(filePath));
1950
2462
  out.set(server.id, {
1951
2463
  diags: entry?.diags ?? [],
@@ -2156,7 +2668,29 @@ export class LSPService {
2156
2668
  noteSpawnVerdict(key, verdict) {
2157
2669
  this.lastSpawnVerdict.set(key, verdict);
2158
2670
  }
2159
- async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome) {
2671
+ /**
2672
+ * {@link resolveServerRoot}, memoized against a caller-owned map so one
2673
+ * acquisition asks a given server for its root at most once (#2525).
2674
+ *
2675
+ * Resolution is cheap exactly when it SUCCEEDS: `NearestRoot` caches hits,
2676
+ * but deliberately never caches negatives (so a marker scaffolded
2677
+ * mid-session is picked up without a restart). An unrooted candidate
2678
+ * therefore re-walks to the filesystem root on every call, which is why the
2679
+ * acquisition path and the records it emits must share one resolution
2680
+ * instead of each taking their own. Without a `memo` this is a plain
2681
+ * passthrough, for callers that have no acquisition to share with.
2682
+ */
2683
+ resolveServerRootOnce(server, filePath, memo) {
2684
+ if (!memo)
2685
+ return this.resolveServerRoot(server, filePath);
2686
+ const pending = memo.get(server.id);
2687
+ if (pending !== undefined)
2688
+ return pending;
2689
+ const started = this.resolveServerRoot(server, filePath);
2690
+ memo.set(server.id, started);
2691
+ return started;
2692
+ }
2693
+ async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome, rootMemo) {
2160
2694
  const handoff = this.generationHandoff;
2161
2695
  if (handoff) {
2162
2696
  await handoff;
@@ -2166,7 +2700,7 @@ export class LSPService {
2166
2700
  if (this.checkDestroyed())
2167
2701
  return undefined;
2168
2702
  }
2169
- const root = await this.resolveServerRoot(server, filePath);
2703
+ const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
2170
2704
  if (!root || this.checkDestroyed())
2171
2705
  return undefined;
2172
2706
  if (server.role !== "auxiliary") {
@@ -2269,6 +2803,7 @@ export class LSPService {
2269
2803
  intentional: wasIntentional,
2270
2804
  },
2271
2805
  });
2806
+ this.releaseOutstandingAuxNotifyWrite(key);
2272
2807
  try {
2273
2808
  await existing.shutdown();
2274
2809
  }
@@ -2593,6 +3128,12 @@ export class LSPService {
2593
3128
  }
2594
3129
  const override = getServerInitOverride(server.id, filePath);
2595
3130
  const mergedInit = mergeInitializationOptions(spawned.initialization, override?.initializationOptions);
3131
+ // A replacement process is a new first-contact identity even when the
3132
+ // configured server id is unchanged. Re-arm both capability latches before
3133
+ // the replacement can publish a snapshot.
3134
+ this.state.diagnosticsPublished.delete(server.id);
3135
+ this.state.diagnosticsUnsupported.delete(server.id);
3136
+ this.firstContactWaits.delete(server.id);
2596
3137
  const client = await createLSPClient({
2597
3138
  serverId: server.id,
2598
3139
  process: spawned.process,
@@ -2601,6 +3142,9 @@ export class LSPService {
2601
3142
  initialization: mergedInit,
2602
3143
  initializeTimeoutMs: server.initializeTimeoutMs,
2603
3144
  launchVariant: spawned.launchVariant,
3145
+ onDiagnosticsPublished: (serverId) => {
3146
+ this.state.diagnosticsPublished.add(serverId);
3147
+ },
2604
3148
  });
2605
3149
  // Guard 2: service was shut down while we were completing the initialize
2606
3150
  // handshake. Shut down the live client best-effort and do not register it.
@@ -2618,6 +3162,9 @@ export class LSPService {
2618
3162
  diagnosticProviderKind: "unavailable",
2619
3163
  };
2620
3164
  this.state.clients.set(key, client);
3165
+ // #2356: this generation is the replacement the late-coverage probe was
3166
+ // waiting for. Clear the retired-generation marker before any later probe.
3167
+ this.notifyStallDemotions.delete(key);
2621
3168
  this.unavailableLogged.delete(key);
2622
3169
  // #1934 review F1: a success retires the previous verdict, so the map
2623
3170
  // never outlives the attempts it describes.
@@ -2759,6 +3306,11 @@ export class LSPService {
2759
3306
  return;
2760
3307
  }
2761
3308
  const startedAt = Date.now();
3309
+ const hookDeadlineAt = options.hook !== undefined &&
3310
+ Object.hasOwn(HOOK_WALL_BUDGET_MS, options.hook)
3311
+ ? startedAt +
3312
+ HOOK_WALL_BUDGET_MS[options.hook]
3313
+ : undefined;
2762
3314
  const normalizedPath = normalizeMapKey(filePath);
2763
3315
  const outsideRoot = await this.findOutsideProjectRoot(filePath);
2764
3316
  if (outsideRoot) {
@@ -2800,10 +3352,27 @@ export class LSPService {
2800
3352
  else if (clientScope === "with-auxiliary") {
2801
3353
  // Primary language server + the enabled cross-cutting auxiliaries
2802
3354
  // (opengrep, …). The aggregation layer merges/dedups their diagnostics.
3355
+ const auxStartedAt = Date.now();
2803
3356
  const [entry, aux] = await Promise.all([
2804
3357
  this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
2805
- this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary),
3358
+ this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary, options.maxClientWaitMs, undefined, options.hook),
2806
3359
  ]);
3360
+ const auxDurationMs = Date.now() - auxStartedAt;
3361
+ if (options.auxiliaryServerIds && options.auxiliaryServerIds.length > 0) {
3362
+ logLatency({
3363
+ type: "phase",
3364
+ phase: "auxiliary_readiness",
3365
+ filePath: normalizedPath,
3366
+ durationMs: auxDurationMs,
3367
+ metadata: {
3368
+ serverIds: aux.map((s) => s.info.id),
3369
+ attemptedCount: options.auxiliaryServerIds.length,
3370
+ readyCount: aux.length,
3371
+ coldServerIds: [...coldAuxiliaryServerIds],
3372
+ source,
3373
+ },
3374
+ });
3375
+ }
2807
3376
  spawned = entry ? [entry, ...aux] : aux;
2808
3377
  serverCountAttempted = spawned.length;
2809
3378
  }
@@ -2850,6 +3419,19 @@ export class LSPService {
2850
3419
  if (!leaseKeys) {
2851
3420
  // An idle eviction won between selection and lease admission. Resolve the
2852
3421
  // now-current client set and replay; no notification targets the retiree.
3422
+ if (this.checkDestroyed()) {
3423
+ const primaryServerIds = spawned
3424
+ .filter((entry) => entry.info.role !== "auxiliary")
3425
+ .map((entry) => entry.info.id);
3426
+ return {
3427
+ diags: [],
3428
+ inconclusive: true,
3429
+ ...(primaryServerIds.length > 0 && {
3430
+ inconclusiveServerIds: primaryServerIds,
3431
+ }),
3432
+ inconclusiveReason: "diagnostics-wait",
3433
+ };
3434
+ }
2853
3435
  return this.touchFile(filePath, content, options);
2854
3436
  }
2855
3437
  try {
@@ -3008,14 +3590,26 @@ export class LSPService {
3008
3590
  if (!notifySkipped) {
3009
3591
  const budget = notifyWriteBudgetMs();
3010
3592
  // #1459: how long a queued auxiliary may wait for its resync slot. Bounded
3011
- // by the write budget AND by whatever the caller already declared it is
3012
- // willing to spend on this touch (`maxClientWaitMs` — cascade's cold
3013
- // snapshot passes 1000ms), minus what the client wait above already spent.
3593
+ // by the write budget and by the effective primary wait floor, minus what
3594
+ // which includes the caller's `maxClientWaitMs` and any primary
3595
+ // server `clientWaitTimeoutMs` override. The elapsed client wait is
3596
+ // subtracted below.
3014
3597
  // A flat write-budget wait would tax a caller that asked for less than one
3015
3598
  // budget in total. Non-positive means "no time left to queue": the server
3016
3599
  // is reported as uncovered immediately.
3600
+ //
3601
+ // #2239: "what the caller already declared" is `primaryServerWaitFloorMs`,
3602
+ // not the raw `options.maxClientWaitMs` — the SAME floor `getClientForFile`
3603
+ // races the primary spawn against. A cold primary configured with its own
3604
+ // `clientWaitTimeoutMs` (Ruby 30s, and the #2233 Bash/JSON/Vue/Svelte/Prisma
3605
+ // overrides) is allowed to run past the caller's flat budget, and did just
3606
+ // that by the time this line runs — charging the subtraction against the
3607
+ // flat value alone always went negative and clamped to zero. The outer
3608
+ // `Math.min(budget, …)` is unchanged, so this raises what "already spent"
3609
+ // is measured against without widening the wait beyond one write budget.
3017
3610
  const queueWaitMs = options.maxClientWaitMs !== undefined
3018
- ? Math.min(budget, Math.max(0, options.maxClientWaitMs - (Date.now() - startedAt)))
3611
+ ? Math.min(budget, Math.max(0, primaryServerWaitFloorMs(filePath, options.maxClientWaitMs) -
3612
+ (Date.now() - startedAt)))
3019
3613
  : budget;
3020
3614
  await Promise.all(spawned.map(async (entry) => {
3021
3615
  // #743: this server already has this content from a recent touch
@@ -3103,10 +3697,13 @@ export class LSPService {
3103
3697
  // streak outright, so only the late case retracts. A landing
3104
3698
  // past the WEDGE window keeps its strike: at that point the
3105
3699
  // stall was long enough that #743's demotion is the honest
3106
- // verdict, not a latency artifact.
3700
+ // verdict, not a latency artifact. #2358: the wedge window
3701
+ // is the adaptive one the timer itself arms with, so a
3702
+ // write a BUSY server would have been left alone for also
3703
+ // retracts its strike instead of accruing a ladder charge.
3107
3704
  const outstandingMs = Date.now() - writeStartedAt;
3108
3705
  if (outstandingMs > budget &&
3109
- outstandingMs <= notifyWedgedMs()) {
3706
+ outstandingMs <= this.auxNotifyWedgeBudgetMs(clientKey)) {
3110
3707
  this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
3111
3708
  }
3112
3709
  }, release);
@@ -3232,6 +3829,7 @@ export class LSPService {
3232
3829
  // fallback below may still fill it in on a timed-out empty result).
3233
3830
  let tsserverSyncEligible = false;
3234
3831
  let tsserverSyncConfirmed;
3832
+ let diagnosticsUnsupportedServerIds = [];
3235
3833
  if (diagnosticsMode !== "none") {
3236
3834
  // Resolution: env wins so users can tune the cap without rebuilding.
3237
3835
  // Otherwise, on the single-server hot path (primary scope), use that
@@ -3302,6 +3900,9 @@ export class LSPService {
3302
3900
  root: entry.client.root,
3303
3901
  operationSupport: entry.client.getOperationSupport(),
3304
3902
  workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
3903
+ customServer: entry.info.custom,
3904
+ diagnosticsPublished: this.state.diagnosticsPublished.has(entry.info.id),
3905
+ diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(entry.info.id),
3305
3906
  advertisedCommands: entry.client.getAdvertisedCommands(),
3306
3907
  rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
3307
3908
  launchVariant: entry.client.getLaunchVariant?.(),
@@ -3445,6 +4046,29 @@ export class LSPService {
3445
4046
  const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
3446
4047
  options.collectDiagnostics === true &&
3447
4048
  spawned.some((e) => e.info.role === "auxiliary");
4049
+ if (spawned.some((entry) => entry.info.custom === true)) {
4050
+ try {
4051
+ const snapshotHook = options.hook ?? "tool_result_edit";
4052
+ const snapshotBudget = hookDeadlineAt === undefined
4053
+ ? HOOK_WALL_BUDGET_MS.tool_result_edit
4054
+ : Math.max(0, hookDeadlineAt - Date.now());
4055
+ const snapshots = await bounded(this.getCapabilitySnapshots(filePath), {
4056
+ ms: snapshotBudget,
4057
+ signal: getAmbientAbortSignal(),
4058
+ shutdownSignal: this.shutdownController.signal,
4059
+ hook: snapshotHook,
4060
+ label: `getCapabilitySnapshots:${filePath}`,
4061
+ });
4062
+ if (snapshots !== undefined) {
4063
+ diagnosticsUnsupportedServerIds = spawned
4064
+ .filter((entry) => classifyServerWaitTier(entry.info.id, snapshots.find((s) => s.serverId === entry.info.id)) === "diagnostics-unsupported")
4065
+ .map((entry) => entry.info.id);
4066
+ }
4067
+ }
4068
+ catch {
4069
+ // Capability uncertainty fails closed and retains the wait.
4070
+ }
4071
+ }
3448
4072
  // Per-server wait promises (each already bounded by its own
3449
4073
  // perServerTimeout — unchanged from before R8).
3450
4074
  let pressureSnapshots = [];
@@ -3469,12 +4093,33 @@ export class LSPService {
3469
4093
  ? auxWaitBudgetMs(entry.client.serverId, true, configuredAuxCeilingMs, declaredServerTimeout)
3470
4094
  : perServerDeclaredTimeouts[entryIndex];
3471
4095
  });
4096
+ const firstContactCutOffServerIds = new Set();
4097
+ const firstContactErroredServerIds = new Set();
4098
+ const answeredForThisTouch = (entry) => {
4099
+ const baseline = diagnosticBaselines.get(entry.client);
4100
+ const currentPathVersion = readPathVersion(entry.client);
4101
+ if (Number.isFinite(baseline) &&
4102
+ currentPathVersion !== undefined &&
4103
+ currentPathVersion > baseline) {
4104
+ return true;
4105
+ }
4106
+ try {
4107
+ return (entry.client.getAllDiagnostics?.().has(normalizedPath) === true);
4108
+ }
4109
+ catch {
4110
+ return false;
4111
+ }
4112
+ };
3472
4113
  const perServerRaceBudgets = spawned.map((entry, entryIndex) => {
3473
4114
  return hasTouchAuxiliaries && entry.info.role === "auxiliary"
3474
4115
  ? auxWaitBudgetMs(entry.client.serverId, coldAuxiliaryServerIds.has(entry.client.serverId), configuredAuxCeilingMs, perServerDeclaredTimeouts[entryIndex])
3475
4116
  : perServerDeclaredTimeouts[entryIndex];
3476
4117
  });
3477
4118
  const perServerWaits = spawned.map((entry, entryIndex) => {
4119
+ if (this.state.diagnosticsUnsupported.has(entry.info.id) ||
4120
+ diagnosticsUnsupportedServerIds.includes(entry.info.id)) {
4121
+ return Promise.resolve(undefined);
4122
+ }
3478
4123
  // #1459: a DEFERRED server never received this content, so its version
3479
4124
  // can never advance past the baseline — waiting on it burns its whole
3480
4125
  // budget and would flip the touch to `inconclusive`, discarding a
@@ -3504,7 +4149,7 @@ export class LSPService {
3504
4149
  const isWarmupTouch = source === "lsp_sweep_warmup";
3505
4150
  // #743: per-server — a server we DID push to still gets the
3506
4151
  // version-baseline wait even when a sibling was debounced away.
3507
- const wait = !notifySkippedServerIds.has(entry.info.id) &&
4152
+ const waitForDiagnostics = () => !notifySkippedServerIds.has(entry.info.id) &&
3508
4153
  Number.isFinite(baseline)
3509
4154
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
3510
4155
  minVersion: baseline,
@@ -3521,7 +4166,73 @@ export class LSPService {
3521
4166
  pullSettleSource: "pull-warmup",
3522
4167
  })
3523
4168
  : entry.client.waitForDiagnostics(filePath, serverTimeout);
3524
- return wait.catch(() => undefined);
4169
+ const firstContact = entry.info.custom === true &&
4170
+ !this.state.diagnosticsPublished.has(entry.info.id) &&
4171
+ !this.state.diagnosticsUnsupported.has(entry.info.id) &&
4172
+ entry.client.getWorkspaceDiagnosticsSupport().mode !== "pull";
4173
+ if (!firstContact)
4174
+ return waitForDiagnostics().catch(() => undefined);
4175
+ let shared = this.firstContactWaits.get(entry.info.id);
4176
+ if (!shared) {
4177
+ let removeShutdownListener;
4178
+ const shutdown = new Promise((resolve) => {
4179
+ const onShutdown = () => resolve("shutdown");
4180
+ if (this.shutdownController.signal.aborted) {
4181
+ onShutdown();
4182
+ return;
4183
+ }
4184
+ this.shutdownController.signal.addEventListener("abort", onShutdown, {
4185
+ once: true,
4186
+ });
4187
+ removeShutdownListener = () => this.shutdownController.signal.removeEventListener("abort", onShutdown);
4188
+ });
4189
+ const probe = Promise.race([
4190
+ waitForDiagnostics().then(() => "completed", () => "errored"),
4191
+ shutdown,
4192
+ ]);
4193
+ const completed = withDeadline(probe, {
4194
+ ms: serverTimeout,
4195
+ onTimeout: "undefined",
4196
+ });
4197
+ shared = completed
4198
+ .then((outcome) => {
4199
+ if (outcome === "errored" || outcome === "shutdown") {
4200
+ return "errored";
4201
+ }
4202
+ if (answeredForThisTouch(entry))
4203
+ return "answered";
4204
+ this.state.diagnosticsUnsupported.add(entry.info.id);
4205
+ recordDegradationOnce({
4206
+ kind: "lsp-diagnostics-unsupported",
4207
+ subject: entry.info.id,
4208
+ reason: "custom server has no pull provider and no publish evidence after first contact wait",
4209
+ });
4210
+ return "silent";
4211
+ })
4212
+ .catch(() => "errored")
4213
+ .finally(() => {
4214
+ removeShutdownListener?.();
4215
+ if (this.firstContactWaits.get(entry.info.id) === shared) {
4216
+ this.firstContactWaits.delete(entry.info.id);
4217
+ }
4218
+ });
4219
+ this.firstContactWaits.set(entry.info.id, shared);
4220
+ }
4221
+ const callerWait = hookDeadlineAt === undefined
4222
+ ? shared
4223
+ : withDeadline(shared, {
4224
+ ms: Math.max(0, hookDeadlineAt - Date.now()),
4225
+ onTimeout: "undefined",
4226
+ });
4227
+ return callerWait.then((outcome) => {
4228
+ if (outcome === undefined) {
4229
+ firstContactCutOffServerIds.add(entry.info.id);
4230
+ }
4231
+ else if (outcome === "errored") {
4232
+ firstContactErroredServerIds.add(entry.info.id);
4233
+ }
4234
+ return undefined;
4235
+ });
3525
4236
  });
3526
4237
  // The push wait — same per-server budget composition as before #707;
3527
4238
  // only the awaiting changed (assigned so it can be raced below).
@@ -3698,9 +4409,14 @@ export class LSPService {
3698
4409
  });
3699
4410
  })()
3700
4411
  : Promise.all(perServerWaits).then(() => { });
3701
- pushWait.then(() => {
4412
+ // Mark on BOTH paths: a rejected `pushWait` is settled too, and a
4413
+ // resolve-only `.then` left a derived promise that rejected unhandled
4414
+ // whenever a per-server wait failed (oxlint no-floating-promises,
4415
+ // 2026-09-07). The rejection itself still reaches the awaiters below.
4416
+ const markPushWaitSettled = () => {
3702
4417
  pushWaitSettled = true;
3703
- });
4418
+ };
4419
+ void pushWait.then(markPushWaitSettled, markPushWaitSettled);
3704
4420
  if (tsserverSyncEligible) {
3705
4421
  // #707 racing variant: rather than burning the full push-wait budget
3706
4422
  // on a silent-on-clean server (which by definition never answers on a
@@ -3772,6 +4488,35 @@ export class LSPService {
3772
4488
  await pushWait;
3773
4489
  }
3774
4490
  const waitedMs = Date.now() - waitStartedAt;
4491
+ const firstContactCustomServerIds = spawned
4492
+ .filter((entry) => entry.info.custom === true &&
4493
+ !this.state.diagnosticsPublished.has(entry.info.id) &&
4494
+ !this.state.diagnosticsUnsupported.has(entry.info.id) &&
4495
+ !firstContactCutOffServerIds.has(entry.info.id) &&
4496
+ !firstContactErroredServerIds.has(entry.info.id) &&
4497
+ entry.client.getWorkspaceDiagnosticsSupport().mode !== "pull")
4498
+ .map((entry) => entry.info.id);
4499
+ for (const serverId of firstContactCustomServerIds) {
4500
+ const entry = spawned.find((candidate) => candidate.info.id === serverId);
4501
+ if (!entry || answeredForThisTouch(entry))
4502
+ continue;
4503
+ this.state.diagnosticsUnsupported.add(serverId);
4504
+ diagnosticsUnsupportedServerIds.push(serverId);
4505
+ recordDegradationOnce({
4506
+ kind: "lsp-diagnostics-unsupported",
4507
+ subject: serverId,
4508
+ reason: "custom server has no pull provider and no publish evidence after first contact wait",
4509
+ });
4510
+ }
4511
+ for (const entry of spawned) {
4512
+ if (entry.info.custom === true &&
4513
+ this.state.diagnosticsUnsupported.has(entry.info.id)) {
4514
+ diagnosticsUnsupportedServerIds.push(entry.info.id);
4515
+ }
4516
+ }
4517
+ diagnosticsUnsupportedServerIds = [
4518
+ ...new Set(diagnosticsUnsupportedServerIds),
4519
+ ];
3775
4520
  // #1533: the same auxiliary coverage evidence for a collecting touch that
3776
4521
  // did NOT enter the aux-grace wait — in practice `clientScope: "all"`, the
3777
4522
  // batch/directory scan surface. Auxiliaries ARE spawned on that scope
@@ -3895,7 +4640,8 @@ export class LSPService {
3895
4640
  },
3896
4641
  });
3897
4642
  }
3898
- else if (waitedMs + 20 >= timeoutMs) {
4643
+ else if (waitedMs + 20 >= timeoutMs ||
4644
+ firstContactErroredServerIds.size > 0) {
3899
4645
  // Within ~20 ms of the configured budget we treat it as a timeout;
3900
4646
  // the LSP didn't beat the cap. Diagnostics that arrive late still
3901
4647
  // land in the client's cache and surface on the next edit.
@@ -3920,25 +4666,10 @@ export class LSPService {
3920
4666
  // reads as unanswered, which for a primary is exactly the pre-#1549
3921
4667
  // verdict. This block can therefore only ever NARROW an inconclusive
3922
4668
  // touch, never create one.
3923
- const answeredForThisTouch = (entry) => {
3924
- const baseline = diagnosticBaselines.get(entry.client);
3925
- const currentPathVersion = readPathVersion(entry.client);
3926
- if (Number.isFinite(baseline) &&
3927
- currentPathVersion !== undefined &&
3928
- currentPathVersion > baseline) {
3929
- return true;
3930
- }
3931
- try {
3932
- return (entry.client.getAllDiagnostics?.().has(normalizedPath) === true);
3933
- }
3934
- catch {
3935
- // Fail closed: an unreadable cache is not evidence of an answer.
3936
- return false;
3937
- }
3938
- };
3939
4669
  // A deferred server was never sent this content and is not waited on, so
3940
4670
  // it cannot have "timed out" — it is already reported as a coverage gap.
3941
- const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id));
4671
+ const waited = spawned.filter((entry) => !deferredResyncServerIds.has(entry.info.id) &&
4672
+ !diagnosticsUnsupportedServerIds.includes(entry.info.id));
3942
4673
  const unanswered = waited.filter((entry) => !answeredForThisTouch(entry));
3943
4674
  diagnosticsUnansweredServerIds = unanswered.map((e) => e.info.id);
3944
4675
  diagnosticsUnansweredPrimaryServerIds = unanswered
@@ -3948,8 +4679,9 @@ export class LSPService {
3948
4679
  // preserve, so it keeps the pre-#1549 touch-wide verdict rather than
3949
4680
  // absolving itself on an auxiliary's evidence.
3950
4681
  const hasWaitedPrimary = waited.some((entry) => entry.info.role !== "auxiliary");
4682
+ const hasUnsupportedPrimary = diagnosticsUnsupportedServerIds.some((serverId) => spawned.some((entry) => entry.info.id === serverId && entry.info.role !== "auxiliary"));
3951
4683
  diagnosticsTimedOut =
3952
- !hasWaitedPrimary ||
4684
+ (!hasWaitedPrimary && !hasUnsupportedPrimary) ||
3953
4685
  diagnosticsUnansweredPrimaryServerIds.length > 0;
3954
4686
  for (const entry of unanswered) {
3955
4687
  incrementDegradationCount({
@@ -4398,9 +5130,7 @@ export class LSPService {
4398
5130
  reason: reasons.join(", ") || "scanner coverage gap",
4399
5131
  });
4400
5132
  }
4401
- logLatency({
4402
- type: "phase",
4403
- phase: "lsp_scanner_coverage_gap",
5133
+ emitBounded("lsp_scanner_coverage_gap", `${source}:${normalizedPath}`, {
4404
5134
  filePath: normalizedPath,
4405
5135
  durationMs: Date.now() - startedAt,
4406
5136
  metadata: {
@@ -4409,10 +5139,10 @@ export class LSPService {
4409
5139
  ...(brokenSkippedServerIds.length > 0 && {
4410
5140
  brokenSkippedServerIds,
4411
5141
  }),
4412
- // #1586: the deferrals this touch is actually uncovered for. The raw
5142
+ // #1586: the deferrals this touch is actually uncovered for. The
4413
5143
  // gate action keeps its own record in `lsp_notify_resync_deferred`;
4414
- // this row exists to prove a blackout, and a scanner already bound to
4415
- // these bytes is not one.
5144
+ // this row exists to prove a blackout, and a scanner already bound
5145
+ // to these bytes is not one.
4416
5146
  ...(uncoveredDeferredServerIds.length > 0 && {
4417
5147
  deferredResyncServerIds: uncoveredDeferredServerIds,
4418
5148
  }),
@@ -4504,6 +5234,11 @@ export class LSPService {
4504
5234
  // collect, `binding` only for a collecting touch — a non-collecting touch
4505
5235
  // keeps resolving `{ diags: [] }`, no flags.
4506
5236
  const result = { diags: collected ?? [] };
5237
+ const primaryDiagnosticsUnsupported = diagnosticsUnsupportedServerIds.some((serverId) => spawned.some((entry) => entry.info.id === serverId && entry.info.role !== "auxiliary"));
5238
+ if (diagnosticsUnsupportedServerIds.length > 0) {
5239
+ result.diagnosticsUnsupportedServerIds =
5240
+ diagnosticsUnsupportedServerIds;
5241
+ }
4507
5242
  if (collected !== undefined && inconclusive) {
4508
5243
  result.inconclusive = true;
4509
5244
  // #1549: name the primary that produced the verdict and which deadline it
@@ -4516,6 +5251,11 @@ export class LSPService {
4516
5251
  }
4517
5252
  result.inconclusiveReason = verdict.inconclusiveReason;
4518
5253
  }
5254
+ else if (collected !== undefined && primaryDiagnosticsUnsupported) {
5255
+ // A navigation-only primary has no diagnostic confirmation to report.
5256
+ // Auxiliary coverage cannot turn that capability boundary into a clean
5257
+ // or partial diagnostic verdict.
5258
+ }
4519
5259
  else if (collected !== undefined && coverageGap) {
4520
5260
  // #1470/#1493: narrowed, not collapsed. Reached for EITHER no-answer
4521
5261
  // shape — a cut-off auxiliary or a silent one with nothing published for
@@ -4526,7 +5266,7 @@ export class LSPService {
4526
5266
  result.confirmation = "partial";
4527
5267
  result.unconfirmedServerIds = [...unconfirmedServerIds];
4528
5268
  }
4529
- else if (collected !== undefined) {
5269
+ else if (collected !== undefined && !primaryDiagnosticsUnsupported) {
4530
5270
  // Preserve the lower-level affirmative result across consumers. In
4531
5271
  // particular, the silent-clean gates above clear diagnosticsTimedOut only
4532
5272
  // after a successful notify and capability-confirmed wait; reclassifying
@@ -5297,6 +6037,9 @@ export class LSPService {
5297
6037
  root,
5298
6038
  operationSupport: client.getOperationSupport(),
5299
6039
  workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
6040
+ customServer: server.custom,
6041
+ diagnosticsPublished: this.state.diagnosticsPublished.has(server.id),
6042
+ diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(server.id),
5300
6043
  advertisedCommands: client.getAdvertisedCommands(),
5301
6044
  rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
5302
6045
  launchVariant: client.getLaunchVariant?.(),
@@ -5314,6 +6057,9 @@ export class LSPService {
5314
6057
  root: client.root,
5315
6058
  operationSupport: client.getOperationSupport(),
5316
6059
  workspaceDiagnosticsSupport: client.getWorkspaceDiagnosticsSupport(),
6060
+ customServer: this.state.servers.get(serverId)?.custom,
6061
+ diagnosticsPublished: this.state.diagnosticsPublished.has(serverId),
6062
+ diagnosticsUnsupported: this.state.diagnosticsUnsupported.has(serverId),
5317
6063
  advertisedCommands: client.getAdvertisedCommands(),
5318
6064
  rawCapabilityKeys: client.getRawCapabilityKeys?.() ?? [],
5319
6065
  launchVariant: client.getLaunchVariant?.(),
@@ -5841,6 +6587,23 @@ export class LSPService {
5841
6587
  // never proved it can answer diagnostics — that's a failed warm-up. A
5842
6588
  // server with an unresolvable key never spawns here, so it can't fail this
5843
6589
  // way and is excluded (never skipped by the caller for this file).
6590
+ // #2525: same split the readiness logic above already makes — `keys[i]`
6591
+ // is `undefined` exactly when the server resolves no key for this file,
6592
+ // i.e. it will never spawn here (DenoServer without a deno.json). The
6593
+ // `lsp_sweep_warmup_*` records must not claim to have warmed it. Free:
6594
+ // `keys` is already resolved, so this costs no filesystem work.
6595
+ const rootedServerIds = [];
6596
+ const unrootedServerIds = [];
6597
+ for (let i = 0; i < servers.length; i++) {
6598
+ if (keys[i] !== undefined)
6599
+ rootedServerIds.push(servers[i].id);
6600
+ else
6601
+ unrootedServerIds.push(servers[i].id);
6602
+ }
6603
+ const unrootedCandidates = {
6604
+ count: unrootedServerIds.length,
6605
+ ids: unrootedServerIds,
6606
+ };
5844
6607
  const stillColdServerIds = () => {
5845
6608
  const cold = [];
5846
6609
  for (let i = 0; i < servers.length; i++) {
@@ -5860,7 +6623,12 @@ export class LSPService {
5860
6623
  phase: "lsp_sweep_warmup_start",
5861
6624
  filePath: representativeFile,
5862
6625
  durationMs: 0,
5863
- metadata: { serverIds: servers.map((s) => s.id), timeoutMs, attempt },
6626
+ metadata: {
6627
+ serverIds: rootedServerIds,
6628
+ unrootedCandidates,
6629
+ timeoutMs,
6630
+ attempt,
6631
+ },
5864
6632
  });
5865
6633
  const warmupAttempt = this.touchFile(representativeFile, content, {
5866
6634
  diagnostics: "document",
@@ -5910,7 +6678,8 @@ export class LSPService {
5910
6678
  filePath: representativeFile,
5911
6679
  durationMs: Date.now() - startedAt,
5912
6680
  metadata: {
5913
- serverIds: servers.map((s) => s.id),
6681
+ serverIds: rootedServerIds,
6682
+ unrootedCandidates,
5914
6683
  timeoutMs,
5915
6684
  attempt,
5916
6685
  coldServerIds,
@@ -6462,7 +7231,7 @@ export class LSPService {
6462
7231
  // ONE bracket for the whole fan-out, not one per file (#2272 review F1).
6463
7232
  // Per-file bracketing was the first shape and it was inert for exactly
6464
7233
  // the case above: `phaseFinished` pushes onto a ring capped at
6465
- // `CLOSED_BRACKET_CAP` (5, clients/latency-logger.ts), while `turn_end`
7234
+ // `RECENT_PHASE_CAP` (5, clients/latency-logger.ts), while `turn_end`
6466
7235
  // reads `getPhaseForWindow` AFTER the sweep returns. A 225-file sweep
6467
7236
  // therefore evicted the blocking file's own bracket unless the block
6468
7237
  // landed in the last five files, and the survivors were then rejected by
@@ -6935,6 +7704,10 @@ export class LSPService {
6935
7704
  if (this.checkDestroyed())
6936
7705
  return;
6937
7706
  this.isDestroyed = true;
7707
+ this.shutdownController.abort();
7708
+ for (const [key, token] of this.outstandingAuxNotifyWrites) {
7709
+ this.releaseOutstandingAuxNotifyWrite(key, token);
7710
+ }
6938
7711
  for (const key of this.typeScriptIdleTimers.keys()) {
6939
7712
  this.clearTypeScriptIdleTimer(key);
6940
7713
  }
@@ -6980,6 +7753,7 @@ export class LSPService {
6980
7753
  });
6981
7754
  this.state.clients.clear();
6982
7755
  this.state.broken.clear();
7756
+ this.firstContactWaits.clear();
6983
7757
  // #1934 review F1: map hygiene alongside the breaker it sits next to.
6984
7758
  // Not load-bearing — every read follows its own attempt's write — but a
6985
7759
  // verdict for a client generation that no longer exists is dead weight.
@@ -6987,7 +7761,15 @@ export class LSPService {
6987
7761
  // #1459: every gated client is gone, so no outstanding-write record can
6988
7762
  // describe a live one. The gate's identity check already neutralises a stale
6989
7763
  // entry; clearing keeps the map honest rather than relying on that.
6990
- this.outstandingAuxNotifyWrites.clear();
7764
+ for (const [key, token] of this.outstandingAuxNotifyWrites) {
7765
+ this.releaseOutstandingAuxNotifyWrite(key, token);
7766
+ }
7767
+ this.notifyStallDemotions.clear();
7768
+ // #2358: same reasoning — a per-write latency estimate belongs to a client
7769
+ // generation, and every client is gone. `session_start` reaches this
7770
+ // through the service reset, so the adaptive wedge window re-arms with
7771
+ // the session rather than being priced by a previous one's throughput.
7772
+ this.auxNotifyDrainLatencyEwma.clear();
6991
7773
  // #1714: same reasoning — a backlog count belongs to a client generation,
6992
7774
  // and every client is gone. `session_start` reaches this through the service
6993
7775
  // reset, so the pacing state re-arms with the session rather than living for
@@ -7189,6 +7971,20 @@ export async function notifyExternalFileChange(filePath, type) {
7189
7971
  return getLSPService().notifyExternalFileChange(filePath, type);
7190
7972
  }
7191
7973
  export function resetLSPService(options = {}) {
7974
+ // #2504 review round 2 (F3): whatever the retiring service was still being
7975
+ // asked for off-hook must stop HERE, before any teardown and before the
7976
+ // `!retiringService` early return below (a `session_start` reset with no
7977
+ // live service must still retire a loop the previous session armed). This
7978
+ // is the one choke point every lifecycle path goes through —
7979
+ // `session_shutdown`, `session_start`, and the idle reset — so the
7980
+ // deferred actionable-warnings pull cannot open another file against a
7981
+ // dying service, cannot re-spawn one after the idle reset, and cannot be
7982
+ // mid-`openFile` when the loop closes (#234). Aborting spawns nothing.
7983
+ abortDeferredLspWork(`lsp service reset: ${options.reason ?? "unspecified"}`);
7984
+ // Invalidate availability publication started by the retiring service before
7985
+ // any asynchronous teardown. The launch seam checks this generation after
7986
+ // every managed lookup, install, and process launch (#2351).
7987
+ resetLspLaunchAvailabilityGeneration();
7192
7988
  // A new session must get its own classic-tsserver-repair attempt: the
7193
7989
  // guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
7194
7990
  // repair that failed transiently in an earlier session must not stay