@herbertgao/pi-extensions 2026.9.0 → 2026.9.1

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 (293) 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/@pi-plugins/fast-mode/README.md +5 -3
  11. package/node_modules/@pi-plugins/fast-mode/dist/index.d.mts.map +1 -1
  12. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +20 -55
  13. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  14. package/node_modules/@pi-plugins/fast-mode/package.json +1 -1
  15. package/node_modules/pi-lens/CHANGELOG.md +988 -0
  16. package/node_modules/pi-lens/README.md +3 -0
  17. package/node_modules/pi-lens/config/biome/core.jsonc +11 -2
  18. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +13 -1
  19. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +2 -2
  20. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +927 -80
  21. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +13 -4
  22. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +123 -3
  23. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +60 -5
  24. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +2 -2
  25. package/node_modules/pi-lens/dist/clients/bash-file-access.js +1 -3
  26. package/node_modules/pi-lens/dist/clients/biome-client.js +9 -2
  27. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +14 -0
  28. package/node_modules/pi-lens/dist/clients/bootstrap.js +509 -73
  29. package/node_modules/pi-lens/dist/clients/bounded-cache.js +152 -12
  30. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +59 -4
  31. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +113 -0
  32. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +2 -2
  33. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +31 -4
  34. package/node_modules/pi-lens/dist/clients/cache-manager.js +105 -6
  35. package/node_modules/pi-lens/dist/clients/cache-observability.js +44 -18
  36. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +422 -0
  37. package/node_modules/pi-lens/dist/clients/cascade-logger.js +2 -2
  38. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +13 -3
  39. package/node_modules/pi-lens/dist/clients/complexity-client.js +16 -5
  40. package/node_modules/pi-lens/dist/clients/config-core/deny.js +221 -0
  41. package/node_modules/pi-lens/dist/clients/config-core/index.js +47 -0
  42. package/node_modules/pi-lens/dist/clients/config-core/merge.js +357 -0
  43. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +340 -0
  44. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +248 -0
  45. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +171 -0
  46. package/node_modules/pi-lens/dist/clients/config-core/records.js +218 -0
  47. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +125 -0
  48. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +78 -0
  49. package/node_modules/pi-lens/dist/clients/config-core/schema.js +167 -0
  50. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +281 -0
  51. package/node_modules/pi-lens/dist/clients/config-locations.js +160 -0
  52. package/node_modules/pi-lens/dist/clients/config-resolve.js +789 -0
  53. package/node_modules/pi-lens/dist/clients/config-schema.js +197 -0
  54. package/node_modules/pi-lens/dist/clients/config-warn.js +407 -0
  55. package/node_modules/pi-lens/dist/clients/dead-code-client.js +19 -14
  56. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +2 -2
  57. package/node_modules/pi-lens/dist/clients/deadline-utils.js +178 -0
  58. package/node_modules/pi-lens/dist/clients/debug-handles.js +2 -2
  59. package/node_modules/pi-lens/dist/clients/debug-heap.js +3 -3
  60. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +106 -0
  61. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +113 -7
  62. package/node_modules/pi-lens/dist/clients/dependency-checker.js +8 -19
  63. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +12 -9
  64. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +14 -4
  65. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +162 -18
  66. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +97 -0
  67. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +9 -7
  68. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -1
  69. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +380 -26
  70. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -1
  71. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +0 -1
  72. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -1
  73. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -1
  74. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -1
  75. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -1
  76. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -1
  77. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -1
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -1
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -1
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -1
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -1
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +1 -3
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -1
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -1
  85. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -1
  86. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +0 -1
  87. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -1
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -1
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -1
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -1
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -1
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +13 -18
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -1
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -1
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -1
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +7 -4
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -1
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +0 -1
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +1 -3
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +0 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -1
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +0 -1
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -1
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -1
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -1
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -1
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -1
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -1
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -1
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -1
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +3 -0
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +3 -4
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +194 -80
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +84 -15
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -1
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +0 -1
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -1
  121. package/node_modules/pi-lens/dist/clients/disposition-logger.js +3 -3
  122. package/node_modules/pi-lens/dist/clients/effective-config.js +403 -0
  123. package/node_modules/pi-lens/dist/clients/error-class.js +23 -0
  124. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +274 -0
  125. package/node_modules/pi-lens/dist/clients/extension-log.js +2 -2
  126. package/node_modules/pi-lens/dist/clients/feature-hints.js +2 -1
  127. package/node_modules/pi-lens/dist/clients/file-time.js +6 -1
  128. package/node_modules/pi-lens/dist/clients/file-utils.js +112 -9
  129. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +50 -13
  130. package/node_modules/pi-lens/dist/clients/format-service.js +6 -1
  131. package/node_modules/pi-lens/dist/clients/formatters.js +276 -102
  132. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +17 -0
  133. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +114 -19
  134. package/node_modules/pi-lens/dist/clients/generation-guard.js +4 -9
  135. package/node_modules/pi-lens/dist/clients/git-guard.js +1 -1
  136. package/node_modules/pi-lens/dist/clients/go-client.js +39 -0
  137. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +252 -0
  138. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +424 -0
  139. package/node_modules/pi-lens/dist/clients/hook-budgets.js +76 -0
  140. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +5 -2
  141. package/node_modules/pi-lens/dist/clients/host-ports.js +1 -1
  142. package/node_modules/pi-lens/dist/clients/installer/index.js +71 -52
  143. package/node_modules/pi-lens/dist/clients/instance-reaper.js +104 -230
  144. package/node_modules/pi-lens/dist/clients/instance-registry.js +6 -3
  145. package/node_modules/pi-lens/dist/clients/language-registry.js +595 -0
  146. package/node_modules/pi-lens/dist/clients/latency-logger.js +160 -2
  147. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +13 -0
  148. package/node_modules/pi-lens/dist/clients/lens-config.js +141 -30
  149. package/node_modules/pi-lens/dist/clients/lens-engine.js +30 -37
  150. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +59 -6
  151. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -2
  152. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +3 -1
  153. package/node_modules/pi-lens/dist/clients/lsp/client.js +168 -30
  154. package/node_modules/pi-lens/dist/clients/lsp/config.js +483 -158
  155. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +1 -1
  156. package/node_modules/pi-lens/dist/clients/lsp/edits.js +15 -2
  157. package/node_modules/pi-lens/dist/clients/lsp/index.js +676 -71
  158. package/node_modules/pi-lens/dist/clients/lsp/language.js +18 -169
  159. package/node_modules/pi-lens/dist/clients/lsp/launch.js +2 -1
  160. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +5 -16
  161. package/node_modules/pi-lens/dist/clients/lsp/server.js +284 -147
  162. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +116 -23
  163. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +2 -7
  164. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +9 -2
  165. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +1 -1
  166. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +163 -21
  167. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +36 -0
  168. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +33 -4
  169. package/node_modules/pi-lens/dist/clients/mcp/session.js +5 -16
  170. package/node_modules/pi-lens/dist/clients/memory-sampler.js +8 -3
  171. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +2 -4
  172. package/node_modules/pi-lens/dist/clients/module-report.js +20 -45
  173. package/node_modules/pi-lens/dist/clients/mutating-tool.js +651 -0
  174. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +368 -0
  175. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +240 -0
  176. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +247 -20
  177. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +101 -0
  178. package/node_modules/pi-lens/dist/clients/observed-mutation.js +1215 -0
  179. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +68 -22
  180. package/node_modules/pi-lens/dist/clients/opengrep-config.js +4 -0
  181. package/node_modules/pi-lens/dist/clients/package-manager.js +195 -26
  182. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +359 -80
  183. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +23 -7
  184. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +21 -2
  185. package/node_modules/pi-lens/dist/clients/path-utils.js +428 -13
  186. package/node_modules/pi-lens/dist/clients/persist-debounce.js +8 -1
  187. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +114 -0
  188. package/node_modules/pi-lens/dist/clients/pipeline.js +77 -16
  189. package/node_modules/pi-lens/dist/clients/probe-home-state.js +228 -0
  190. package/node_modules/pi-lens/dist/clients/process-bridge.js +66 -0
  191. package/node_modules/pi-lens/dist/clients/process-snapshot.js +68 -0
  192. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +4 -3
  193. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +27 -3
  194. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +51 -20
  195. package/node_modules/pi-lens/dist/clients/project-lens-config.js +393 -122
  196. package/node_modules/pi-lens/dist/clients/project-snapshot.js +52 -24
  197. package/node_modules/pi-lens/dist/clients/python-environment.js +217 -0
  198. package/node_modules/pi-lens/dist/clients/python-provenance.js +639 -0
  199. package/node_modules/pi-lens/dist/clients/quiet-window.js +6 -1
  200. package/node_modules/pi-lens/dist/clients/read-bridge.js +13 -21
  201. package/node_modules/pi-lens/dist/clients/read-expansion.js +9 -46
  202. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +15 -4
  203. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +364 -212
  204. package/node_modules/pi-lens/dist/clients/read-guard.js +21 -0
  205. package/node_modules/pi-lens/dist/clients/resource-sampler.js +195 -96
  206. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +152 -109
  207. package/node_modules/pi-lens/dist/clients/review-graph/service.js +55 -3
  208. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +7 -15
  209. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +10 -74
  210. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +9 -3
  211. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +18 -2
  212. package/node_modules/pi-lens/dist/clients/runtime-context.js +49 -16
  213. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +25 -6
  214. package/node_modules/pi-lens/dist/clients/runtime-session.js +139 -22
  215. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +168 -35
  216. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +797 -243
  217. package/node_modules/pi-lens/dist/clients/runtime-turn.js +1028 -53
  218. package/node_modules/pi-lens/dist/clients/rust-client.js +31 -0
  219. package/node_modules/pi-lens/dist/clients/safe-spawn.js +33 -10
  220. package/node_modules/pi-lens/dist/clients/sanitize.js +7 -1
  221. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +1 -3
  222. package/node_modules/pi-lens/dist/clients/security-scan-client.js +3 -0
  223. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +1 -1
  224. package/node_modules/pi-lens/dist/clients/session-start-observability.js +79 -0
  225. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +12 -3
  226. package/node_modules/pi-lens/dist/clients/sgconfig.js +6 -1
  227. package/node_modules/pi-lens/dist/clients/skills-resolver.js +105 -0
  228. package/node_modules/pi-lens/dist/clients/smells-rollup.js +2 -2
  229. package/node_modules/pi-lens/dist/clients/string-utils.js +13 -0
  230. package/node_modules/pi-lens/dist/clients/subagent-mode.js +17 -4
  231. package/node_modules/pi-lens/dist/clients/test-runner-client.js +176 -39
  232. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +239 -0
  233. package/node_modules/pi-lens/dist/clients/tool-definition.js +41 -1
  234. package/node_modules/pi-lens/dist/clients/tool-policy.js +91 -21
  235. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +40 -40
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +86 -48
  237. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +2 -2
  238. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +81 -1
  239. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +51 -46
  240. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +28 -0
  241. package/node_modules/pi-lens/dist/clients/typos-config.js +5 -0
  242. package/node_modules/pi-lens/dist/clients/user-notify.js +6 -2
  243. package/node_modules/pi-lens/dist/clients/widget-state.js +191 -26
  244. package/node_modules/pi-lens/dist/clients/word-index-logger.js +2 -2
  245. package/node_modules/pi-lens/dist/clients/word-index-store.js +20 -5
  246. package/node_modules/pi-lens/dist/clients/word-index.js +243 -31
  247. package/node_modules/pi-lens/dist/clients/workspace-topology.js +8 -1
  248. package/node_modules/pi-lens/dist/clients/zizmor-config.js +3 -0
  249. package/node_modules/pi-lens/dist/index.js +64335 -54300
  250. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +3 -1
  251. package/node_modules/pi-lens/dist/mcp/server.js +127 -5
  252. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +583 -0
  253. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +129 -0
  254. package/node_modules/pi-lens/dist/tools/effective-config.js +89 -0
  255. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +95 -24
  256. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +67 -80
  257. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +37 -16
  258. package/node_modules/pi-lens/dist/tools/shared.js +0 -1
  259. package/node_modules/pi-lens/docs/agent-guide.md +64 -1
  260. package/node_modules/pi-lens/docs/configuration.md +222 -0
  261. package/node_modules/pi-lens/docs/dependencies.md +3 -3
  262. package/node_modules/pi-lens/docs/features.md +44 -5
  263. package/node_modules/pi-lens/docs/language-coverage.md +2 -2
  264. package/node_modules/pi-lens/docs/pi-lens-fixer.md +25 -0
  265. package/node_modules/pi-lens/docs/pi-lens-investigator.md +25 -0
  266. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +27 -0
  267. package/node_modules/pi-lens/docs/pi-lens-subagent.md +38 -0
  268. package/node_modules/pi-lens/docs/pi-lens-warden.md +55 -0
  269. package/node_modules/pi-lens/docs/public-api-stability.md +359 -0
  270. package/node_modules/pi-lens/docs/release-qa-baseline.md +182 -0
  271. package/node_modules/pi-lens/docs/subagent-compat.md +110 -30
  272. package/node_modules/pi-lens/docs/tree-sitter_rules_catalog.md +2 -2
  273. package/node_modules/pi-lens/package.json +15 -23
  274. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml +3 -3
  275. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml +2 -2
  276. package/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml +12 -2
  277. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +192 -1
  278. package/node_modules/pi-lens/scripts/install-selftest.mjs +58 -2
  279. package/node_modules/pi-lens/scripts/lib/skills-predicate.mjs +129 -0
  280. package/node_modules/pi-lens/scripts/rpc-load-check.mjs +3 -1
  281. package/node_modules/pi-web-access/CHANGELOG.md +28 -0
  282. package/node_modules/pi-web-access/README.md +62 -15
  283. package/node_modules/pi-web-access/curator-page.ts +3 -1
  284. package/node_modules/pi-web-access/curator-server.ts +5 -1
  285. package/node_modules/pi-web-access/gemini-search.ts +8 -4
  286. package/node_modules/pi-web-access/github-extract.ts +242 -1
  287. package/node_modules/pi-web-access/index.ts +118 -64
  288. package/node_modules/pi-web-access/mistral-search.ts +281 -0
  289. package/node_modules/pi-web-access/package.json +2 -2
  290. package/node_modules/pi-web-access/perplexity.ts +14 -1
  291. package/node_modules/pi-web-access/utils.ts +23 -6
  292. package/node_modules/pi-web-access/xai-search.ts +96 -33
  293. package/package.json +5 -5
@@ -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,10 @@ 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 { getAmbientAbortSignal } from "../safe-spawn.js";
29
+ import { abortDeferredLspWork } from "../deferred-lsp-work.js";
26
30
  import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
27
31
  import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
28
32
  import { markPendingAuxiliaryCoverage, napiFallbackCoveredSince, } from "./pending-aux-coverage.js";
@@ -37,7 +41,7 @@ import { getServersForFileWithConfig, getServerInitOverride, } from "./config.js
37
41
  import { getSessionRootsForTelemetry, isOutsideAllSessionRoots, } from "./session-roots.js";
38
42
  import { getProcessSingleton } from "../process-singletons.js";
39
43
  import { getLanguageId } from "./language.js";
40
- import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
44
+ import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, resetLspLaunchAvailabilityGeneration, } from "./server.js";
41
45
  import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
42
46
  const WORKSPACE_ATTRIBUTION_CLIENT_CAP = 16;
43
47
  import { raceToCompletion } from "./aggregation.js";
@@ -468,6 +472,19 @@ function notifyWriteBudgetMs() {
468
472
  const raw = Number(process.env.PI_LENS_LSP_NOTIFY_BUDGET_MS);
469
473
  return Number.isFinite(raw) && raw > 0 ? raw : 2000;
470
474
  }
475
+ // #2239: the SAME effective per-server wait floor `getClientForFile` uses to
476
+ // size its own acquisition race — the caller's declared budget, raised to
477
+ // whatever the file's primary server(s) configure via `clientWaitTimeoutMs`
478
+ // (Ruby 30s, and the Bash/JSON/Vue/Svelte/Prisma overrides #2233 added). A
479
+ // cold primary spawn is allowed to run this long, so anything downstream that
480
+ // charges itself against `maxWaitMs` alone — rather than this floor — sees an
481
+ // already-elapsed time it never budgeted for and clamps to zero.
482
+ function primaryServerWaitFloorMs(filePath, maxWaitMs) {
483
+ const serverWaitOverrideMs = getServersForFileWithConfig(filePath)
484
+ .filter((s) => s.role !== "auxiliary")
485
+ .reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
486
+ return Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
487
+ }
471
488
  // #1459: how long ONE auxiliary notify write may stay outstanding before the
472
489
  // server counts as wedged rather than merely slow. A scanner whose per-file work
473
490
  // exceeds the write budget is normal (opengrep routinely needs >2s on a large
@@ -479,6 +496,30 @@ const NOTIFY_WEDGED_BUDGET_MULTIPLIER = 5;
479
496
  function notifyWedgedMs() {
480
497
  return notifyWriteBudgetMs() * NOTIFY_WEDGED_BUDGET_MULTIPLIER;
481
498
  }
499
+ // #2358: the wedge window's teardown decision gained a liveness discriminator.
500
+ // The fixed window above stays the FLOOR; a server whose drain history says it
501
+ // answers per-write in `ewmaMs` with `unacked` documents queued earns
502
+ // `k x ewmaMs x unacked` of patience instead — the issue's "a server that
503
+ // historically answers in 855 ms with 8 writes queued earns 15-20 s". The cap
504
+ // bounds the whole thing so a busy-but-never-draining server still respawns.
505
+ const NOTIFY_WEDGED_EWMA_MULTIPLIER = 2;
506
+ const NOTIFY_WEDGED_CAP_MS = 60_000;
507
+ function notifyWedgedCapMs() {
508
+ const raw = Number(process.env.PI_LENS_LSP_NOTIFY_WEDGED_CAP_MS);
509
+ return Number.isFinite(raw) && raw > 0 ? raw : NOTIFY_WEDGED_CAP_MS;
510
+ }
511
+ // #2358: how long the CPU-liveness discriminator watches the server between
512
+ // two process-CPU samples before deciding "busy" vs "flat". Only ever spent
513
+ // on a server whose write is already past its patience window — never on the
514
+ // per-edit hot path.
515
+ function notifyStallCpuSampleMs() {
516
+ const raw = Number(process.env.PI_LENS_LSP_NOTIFY_STALL_CPU_SAMPLE_MS);
517
+ return Number.isFinite(raw) && raw > 0 ? raw : 1200;
518
+ }
519
+ // A server burning more than this percent of one core across the sample
520
+ // window counts as BUSY (progressing), not flat (wedged). A single-threaded
521
+ // scanner under burst sits near 100; an idle or dead one near 0.
522
+ const NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT = 10;
482
523
  // #1714: how many document notifies one auxiliary may hold UNACKNOWLEDGED
483
524
  // before the next notify has to prove the server drained its input.
484
525
  //
@@ -712,6 +753,22 @@ export class LSPService {
712
753
  * successful write clears its entry.
713
754
  */
714
755
  notifyWriteBackpressureStreak = new Map();
756
+ /**
757
+ * #2358: EWMA of one auxiliary's per-document drain latency
758
+ * (ms per unacknowledged write), keyed by "serverId:normalizedRoot".
759
+ *
760
+ * Updated from DRAINED notify barriers only: the round-trip proves the
761
+ * server processed its `outstanding` backlog, and
762
+ * duration / outstanding is the per-write service time a single-threaded
763
+ * scanner actually achieves under load. The breaker grants a wedged write
764
+ * `k x ewma x unacked` of patience from it, so a slow-but-alive scanner is
765
+ * not killed at the fixed window by construction (#2358).
766
+ *
767
+ * Cleared by the service teardown (via `session_start`) like the rest of
768
+ * the breaker state; a stale throughput estimate from a previous session's
769
+ * client must not price the next one.
770
+ */
771
+ auxNotifyDrainLatencyEwma = new Map();
715
772
  /**
716
773
  * #1714: unacknowledged auxiliary document notifies per server key
717
774
  * ("serverId:normalizedRoot" — the same identity as every other gate here).
@@ -748,6 +805,15 @@ export class LSPService {
748
805
  * never lands is still demoted (see {@link demoteForNotifyStall}).
749
806
  */
750
807
  outstandingAuxNotifyWrites = new Map();
808
+ /**
809
+ * #2356: auxiliary clients removed by the notify-stall breaker are a
810
+ * temporary absence, not a missing scanner. Keep the demotion identity after
811
+ * deleting the client so the turn-end late-coverage probe can re-arm the
812
+ * pending pair until a replacement is published (or its existing bounded
813
+ * re-arm ceiling is reached). A successful replacement removes the marker.
814
+ */
815
+ static MAX_NOTIFY_STALL_DEMOTIONS = 50;
816
+ notifyStallDemotions = new BoundedFifoMap(LSPService.MAX_NOTIFY_STALL_DEMOTIONS);
751
817
  /** LRU clock for capacity eviction, keyed by the canonical server/root key. */
752
818
  clientLastUsedAt = new Map();
753
819
  /**
@@ -948,6 +1014,7 @@ export class LSPService {
948
1014
  return false;
949
1015
  }
950
1016
  const [victimKey, victimClient] = victim;
1017
+ this.releaseOutstandingAuxNotifyWrite(victimKey);
951
1018
  await victimClient.shutdown({ reason: "client_ceiling_lru" });
952
1019
  this.state.clients.delete(victimKey);
953
1020
  this.state.clientSpawnedAt.delete(victimKey);
@@ -964,6 +1031,15 @@ export class LSPService {
964
1031
  clearTimeout(timer);
965
1032
  this.typeScriptIdleTimers.delete(key);
966
1033
  }
1034
+ /** Release a notify token and its timer when its client generation retires. */
1035
+ releaseOutstandingAuxNotifyWrite(key, token) {
1036
+ const current = this.outstandingAuxNotifyWrites.get(key);
1037
+ if (!current || (token !== undefined && current !== token))
1038
+ return;
1039
+ clearTimeout(current.wedgeTimer);
1040
+ this.outstandingAuxNotifyWrites.delete(key);
1041
+ current.resolveSettled();
1042
+ }
967
1043
  scheduleTypeScriptIdleEviction(key) {
968
1044
  if (!key.startsWith("typescript:"))
969
1045
  return;
@@ -989,6 +1065,7 @@ export class LSPService {
989
1065
  // Publish the cold state synchronously before awaiting teardown. A request
990
1066
  // arriving while shutdown is in progress therefore waits on the spawn gate
991
1067
  // and creates a fresh client; it can never receive this retiring one.
1068
+ this.releaseOutstandingAuxNotifyWrite(key);
992
1069
  this.state.clients.delete(key);
993
1070
  this.state.clientSpawnedAt.delete(key);
994
1071
  this.state.demonstratedReady.delete(key);
@@ -1130,23 +1207,130 @@ export class LSPService {
1130
1207
  return;
1131
1208
  }
1132
1209
  this.notifyWriteBackpressureStreak.delete(key);
1133
- this.demoteForNotifyStall(key, entry, filePath, {
1210
+ // #2358: the consecutive-timeout ladder lands here too, and its verdict
1211
+ // must not kill a server that is demonstrably BUSY. The guarded demotion
1212
+ // samples the live process CPU and merely resets the ladder (the next
1213
+ // three timeouts re-try) when the server is progressing — the wedged-write
1214
+ // path's cap owns a busy-but-never-draining server, not this streak.
1215
+ void this.demoteNotifyStallCpuGuarded(key, entry, filePath, {
1134
1216
  consecutiveTimeouts: NOTIFY_BACKPRESSURE_BROKEN_AFTER,
1135
1217
  });
1136
1218
  }
1219
+ /**
1220
+ * #2358: liveness-guarded teardown for the STREAK ladder.
1221
+ *
1222
+ * The wedged-write path ({@link decideNotifyStallTeardown}) keeps its own
1223
+ * re-arm loop so a busy server's outstanding write stays un-torn-down; the
1224
+ * streak ladder has no write to hold — it simply does not demote, and the
1225
+ * next three timeouts re-attempt. Generation-guarded after the await so a
1226
+ * client replaced mid-sample is never demoted for a verdict about its
1227
+ * predecessor.
1228
+ */
1229
+ async demoteNotifyStallCpuGuarded(key, entry, filePath, reason) {
1230
+ if (this.state.clients.get(key) !== entry.client)
1231
+ return;
1232
+ // The CPU verdict is asynchronous, but the streak has already committed to
1233
+ // this client's teardown path. Release the TypeScript idle-timer ownership
1234
+ // before sampling so another idle callback cannot target the same client
1235
+ // while the verdict is in flight. A BUSY verdict below re-arms a fresh timer.
1236
+ this.clearTypeScriptIdleTimer(key);
1237
+ const verdict = await this.notifyStallCpuVerdict(entry);
1238
+ if (verdict.cpuVerdict === "busy") {
1239
+ if (this.state.clients.get(key) === entry.client) {
1240
+ this.scheduleTypeScriptIdleEviction(key);
1241
+ }
1242
+ this.logNotifyStallCpuBusy(key, entry, filePath, 0, verdict);
1243
+ return;
1244
+ }
1245
+ if (this.state.clients.get(key) !== entry.client)
1246
+ return;
1247
+ this.demoteForNotifyStall(key, entry, filePath, {
1248
+ ...reason,
1249
+ discriminator: "consecutive-timeouts",
1250
+ cpuVerdict: verdict.cpuVerdict,
1251
+ });
1252
+ }
1253
+ /**
1254
+ * #2358: classify a stalled server's live process CPU.
1255
+ *
1256
+ * A client with no process handle (a test/mock double, a legacy client)
1257
+ * reports "unmeasured" — the caller keeps the pre-#2358 demote-at-budget
1258
+ * behavior, because there is no liveness evidence to override it. A sampled
1259
+ * process that burns a core is BUSY; anything else is FLAT, including a
1260
+ * query that failed to resolve the pid — a server we cannot measure is not
1261
+ * proven busy, and the replacement self-heal is cheap.
1262
+ */
1263
+ async notifyStallCpuVerdict(entry) {
1264
+ const pid = entry.client.getProcessPid?.();
1265
+ if (pid === undefined || !(Number.isFinite(pid) && pid > 0)) {
1266
+ return { cpuVerdict: "unmeasured", cpuPercent: null };
1267
+ }
1268
+ const sample = await sampleProcessTreeCpuPercent(pid, notifyStallCpuSampleMs(), NOTIFY_STALL_CPU_BUSY_FLOOR_PERCENT);
1269
+ if (!sample.measured) {
1270
+ return { cpuVerdict: "unmeasured", cpuPercent: null };
1271
+ }
1272
+ if (sample.busy) {
1273
+ return { cpuVerdict: "busy", cpuPercent: sample.cpuPercent };
1274
+ }
1275
+ return { cpuVerdict: "flat", cpuPercent: sample.cpuPercent };
1276
+ }
1277
+ /**
1278
+ * #2358: one bounded row per busy defer. Not a failure record — the
1279
+ * discrimination succeeded — so it is a plain latency phase; its volume is
1280
+ * bounded by the re-arm cadence of one outstanding write (at most the wedge
1281
+ * cap divided by the budget, ~6 fires at defaults).
1282
+ */
1283
+ logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, extra = {}) {
1284
+ emitBounded("lsp_notify_stall_cpu_busy", `${key}:${normalizeMapKey(filePath)}`, {
1285
+ type: "phase",
1286
+ durationMs: outstandingMs,
1287
+ metadata: {
1288
+ serverId: entry.info.id,
1289
+ clientKey: key,
1290
+ outstandingMs,
1291
+ cpuPercent: verdict.cpuPercent ?? null,
1292
+ ...extra,
1293
+ },
1294
+ }, {
1295
+ ledgerKind: "lsp-notify-stall-cpu-busy",
1296
+ risingEdgePer: "identity",
1297
+ });
1298
+ }
1299
+ /**
1300
+ * #2358: the patience window a wedged auxiliary write earns.
1301
+ *
1302
+ * max(fixed floor, k x EWMA per-write latency x unacked depth), capped so a
1303
+ * busy-but-never-draining server still respawns within the cap. The EWMA
1304
+ * comes from DRAINED notify barriers (see {@link noteAuxNotifyDrainLatency});
1305
+ * with no history the fixed floor (the pre-#2358 window) stands.
1306
+ */
1307
+ auxNotifyWedgeBudgetMs(key) {
1308
+ let budgetMs = notifyWedgedMs();
1309
+ const ewma = this.auxNotifyDrainLatencyEwma.get(key);
1310
+ if (ewma !== undefined && ewma > 0) {
1311
+ const unacked = this.auxNotifyInflight.get(key)?.unacked ?? 0;
1312
+ budgetMs = Math.max(budgetMs, NOTIFY_WEDGED_EWMA_MULTIPLIER * ewma * unacked);
1313
+ }
1314
+ return Math.min(budgetMs, notifyWedgedCapMs());
1315
+ }
1137
1316
  /**
1138
1317
  * The demotion itself, shared by the #743 consecutive-timeout streak and the
1139
1318
  * #1459 wedged-write rule. Both mean the same thing — this client's input path
1140
1319
  * is not moving — and both need the same teardown.
1141
1320
  */
1142
1321
  demoteForNotifyStall(key, entry, filePath, reason) {
1322
+ // An async verdict belongs to one client generation. Never let a
1323
+ // predecessor's decision delete or cool down its replacement.
1324
+ if (this.state.clients.get(key) !== entry.client)
1325
+ return;
1143
1326
  this.notifyWriteBackpressureStreak.delete(key);
1144
- this.outstandingAuxNotifyWrites.delete(key);
1327
+ this.releaseOutstandingAuxNotifyWrite(key);
1145
1328
  // #1714: the demoted client is torn down, so its backlog count describes a
1146
1329
  // process that no longer exists. Leaving it would make the replacement start
1147
1330
  // at the ceiling and pay a barrier on its first file.
1148
1331
  this.auxNotifyInflight.delete(key);
1149
1332
  this.state.broken.set(key, Date.now() + BROKEN_BASE_COOLDOWN_MS);
1333
+ this.notifyStallDemotions.set(key, Date.now());
1150
1334
  void entry.client.shutdown().catch(() => { });
1151
1335
  this.state.clients.delete(key);
1152
1336
  this.state.clientSpawnedAt.delete(key);
@@ -1209,6 +1393,87 @@ export class LSPService {
1209
1393
  }
1210
1394
  this.auxNotifyInflight.set(key, { client, unacked: 1 });
1211
1395
  }
1396
+ /**
1397
+ * #2358: fold one DRAINED notify barrier's per-write latency into the
1398
+ * per-key EWMA. A drained round-trip proves the server processed its
1399
+ * `outstanding` backlog after the ping; duration / outstanding is the
1400
+ * per-document service time the single-threaded scanner actually achieves
1401
+ * under load, which is exactly what a busy-server patience window must be
1402
+ * priced from (#2358's evidence: an 855 ms per-answer server with an
1403
+ * 8-write backlog, able to drain in 10-20 s while healthy).
1404
+ */
1405
+ noteAuxNotifyDrainLatency(key, durationMs, outstanding, record) {
1406
+ if (!(outstanding > 0 && durationMs > 0))
1407
+ return;
1408
+ if (record && this.auxNotifyInflight.get(key) !== record)
1409
+ return;
1410
+ const perWriteMs = durationMs / outstanding;
1411
+ const prev = this.auxNotifyDrainLatencyEwma.get(key);
1412
+ this.auxNotifyDrainLatencyEwma.set(key, prev === undefined ? perWriteMs : 0.5 * perWriteMs + 0.5 * prev);
1413
+ }
1414
+ /**
1415
+ * #2358: decide whether a wedged notify write's teardown really fires.
1416
+ *
1417
+ * Before #2358 the breaker killed a server whose write stayed outstanding
1418
+ * past a FIXED window, conflating a dead input path with a busy scanner
1419
+ * draining a burst — the live opengrep kill at #2358's head. The decision
1420
+ * now has two guards, and the record names which one fired:
1421
+ *
1422
+ * - the window is ADAPTIVE — max(fixed floor, k x EWMA per-write latency x
1423
+ * unacked depth), capped at {@link notifyWedgedCapMs} — so a server that
1424
+ * historically answers in 855 ms with 8 writes queued earns 13.7 s (k=2),
1425
+ * not 10 s;
1426
+ * - past the window, the server's process CPU is sampled twice; a BUSY
1427
+ * server is left alone (the write stays outstanding and the caller re-arms
1428
+ * this timer), a FLAT or unmeasured one is torn down.
1429
+ *
1430
+ * The hard cap still kills: a busy server that cannot drain inside it is
1431
+ * damaged, and the replacement is the self-heal the breaker exists to
1432
+ * provide.
1433
+ */
1434
+ async decideNotifyStallTeardown(key, entry, filePath, token) {
1435
+ if (this.state.clients.get(key) !== entry.client ||
1436
+ this.outstandingAuxNotifyWrites.get(key) !== token) {
1437
+ throw new Error("stale notify generation");
1438
+ }
1439
+ const outstandingMs = Date.now() - token.startedAt;
1440
+ const armedBudgetMs = token.armedBudgetMs;
1441
+ if (outstandingMs >= notifyWedgedCapMs()) {
1442
+ return {
1443
+ action: "demote",
1444
+ reason: {
1445
+ outstandingMs,
1446
+ discriminator: "cap-exceeded",
1447
+ budgetMs: armedBudgetMs,
1448
+ },
1449
+ };
1450
+ }
1451
+ const verdict = await this.notifyStallCpuVerdict(entry);
1452
+ if (this.state.clients.get(key) !== entry.client ||
1453
+ this.outstandingAuxNotifyWrites.get(key) !== token) {
1454
+ throw new Error("stale notify generation");
1455
+ }
1456
+ if (verdict.cpuVerdict === "busy") {
1457
+ this.logNotifyStallCpuBusy(key, entry, filePath, outstandingMs, verdict, {
1458
+ budgetMs: armedBudgetMs,
1459
+ });
1460
+ return { action: "rearm", budgetMs: this.auxNotifyWedgeBudgetMs(key) };
1461
+ }
1462
+ return {
1463
+ action: "demote",
1464
+ reason: {
1465
+ outstandingMs,
1466
+ discriminator: verdict.cpuVerdict === "unmeasured"
1467
+ ? "budget-exceeded"
1468
+ : "budget-exceeded-cpu-flat",
1469
+ budgetMs: armedBudgetMs,
1470
+ ewmaInputMs: this.auxNotifyDrainLatencyEwma.get(key),
1471
+ unackedDepth: this.auxNotifyInflight.get(key)?.unacked ?? 0,
1472
+ cpuVerdict: verdict.cpuVerdict,
1473
+ cpuPercent: verdict.cpuPercent ?? null,
1474
+ },
1475
+ };
1476
+ }
1212
1477
  /**
1213
1478
  * #1714: is this auxiliary already holding as many documents as it may?
1214
1479
  *
@@ -1339,11 +1604,17 @@ export class LSPService {
1339
1604
  // and the waiter is what latches. A second latch here would be
1340
1605
  // unreachable, and no test could hold it honest.
1341
1606
  }
1607
+ // #2358: a DRAINED round-trip is a per-write latency measurement —
1608
+ // the server really processed its backlog — so it feeds the EWMA
1609
+ // the adaptive wedge window is priced from.
1610
+ const durationMs = Date.now() - startedAt;
1611
+ if (drained)
1612
+ this.noteAuxNotifyDrainLatency(key, durationMs, outstanding, record);
1342
1613
  this.noteDrainBarrierOutcome(key, entry, filePath, context, {
1343
1614
  unacked: outstanding,
1344
1615
  limit,
1345
1616
  waitMs,
1346
- durationMs: Date.now() - startedAt,
1617
+ durationMs,
1347
1618
  outcome: drained ? "drained" : "stalled",
1348
1619
  });
1349
1620
  return drained;
@@ -1466,7 +1737,7 @@ export class LSPService {
1466
1737
  // respawned) says nothing about this client's stdin — drop it, so a stale
1467
1738
  // entry can never starve a healthy server.
1468
1739
  if (outstanding && outstanding.client !== entry.client) {
1469
- this.outstandingAuxNotifyWrites.delete(clientKey);
1740
+ this.releaseOutstandingAuxNotifyWrite(clientKey, outstanding);
1470
1741
  }
1471
1742
  else if (outstanding) {
1472
1743
  const outstandingMs = Date.now() - outstanding.startedAt;
@@ -1503,30 +1774,70 @@ export class LSPService {
1503
1774
  startedAt: Date.now(),
1504
1775
  client: entry.client,
1505
1776
  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
- });
1777
+ armedBudgetMs: 0,
1778
+ // SAFETY: the handle is a torn-initialized placeholder. It is armed
1779
+ // a beat later in this same atomic claim (no await in between) and
1780
+ // thereafter only ever written by this token's own fire/release
1781
+ // closures, so the placeholder value is never read by anyone.
1782
+ wedgeTimer: undefined,
1783
+ resolveSettled: () => resolveSettled?.(),
1784
+ };
1785
+ // A write nothing accepts for the whole wedge window is a dead input
1786
+ // path, not a slow scan. Armed HERE rather than checked by the next
1787
+ // waiter: inside a burst every waiter arrives within one budget, so a
1788
+ // waiter-side check could never see the wedge window elapse and a
1789
+ // wedged scanner was never demoted. Unref'd so it cannot hold a
1790
+ // one-shot host alive, and cleared on release.
1791
+ //
1792
+ // #2358: the window itself is now ADAPTIVE (issue's `max(fixed,
1793
+ // k x EWMA per-answer latency x unacked depth)`, capped), and when it
1794
+ // fires the server's process CPU is sampled before the kill — a busy
1795
+ // server is left alone and the timer re-arms, so a scanner draining a
1796
+ // burst is never killed by construction. Every fire re-checks the
1797
+ // token identity after its awaits, so a release or a concurrent
1798
+ // demotion that lands mid-sample aborts the decision.
1799
+ const fireWedge = async () => {
1800
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
1801
+ this.state.clients.get(clientKey) !== entry.client) {
1802
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1803
+ return;
1804
+ }
1805
+ let decision;
1806
+ try {
1807
+ decision = await this.decideNotifyStallTeardown(clientKey, entry, filePath, token);
1808
+ }
1809
+ catch {
1810
+ // A failed decision must not strand the outstanding write or the
1811
+ // waiter: fall back to the pre-#2358 demote-at-budget teardown.
1812
+ decision = {
1813
+ action: "demote",
1814
+ reason: {
1815
+ outstandingMs: Date.now() - token.startedAt,
1816
+ discriminator: "budget-exceeded",
1817
+ budgetMs: token.armedBudgetMs,
1818
+ },
1819
+ };
1820
+ }
1821
+ if (this.outstandingAuxNotifyWrites.get(clientKey) !== token ||
1822
+ this.state.clients.get(clientKey) !== entry.client) {
1823
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1824
+ return;
1825
+ }
1826
+ if (decision.action === "demote") {
1827
+ this.demoteForNotifyStall(clientKey, entry, filePath, decision.reason);
1518
1828
  resolveSettled?.();
1519
- }, notifyWedgedMs()),
1829
+ return;
1830
+ }
1831
+ token.wedgeTimer = setTimeout(() => void fireWedge(), decision.budgetMs);
1832
+ token.wedgeTimer.unref?.();
1520
1833
  };
1834
+ token.armedBudgetMs = this.auxNotifyWedgeBudgetMs(clientKey);
1835
+ token.wedgeTimer = setTimeout(() => void fireWedge(), token.armedBudgetMs);
1521
1836
  token.wedgeTimer.unref?.();
1522
1837
  this.outstandingAuxNotifyWrites.set(clientKey, token);
1523
1838
  return {
1524
1839
  release: () => {
1525
- clearTimeout(token.wedgeTimer);
1526
- if (this.outstandingAuxNotifyWrites.get(clientKey) === token) {
1527
- this.outstandingAuxNotifyWrites.delete(clientKey);
1528
- }
1529
- resolveSettled?.();
1840
+ this.releaseOutstandingAuxNotifyWrite(clientKey, token);
1530
1841
  },
1531
1842
  };
1532
1843
  }
@@ -1607,6 +1918,30 @@ export class LSPService {
1607
1918
  return entries.sort((a, b) => (priority.get(a.serverId) ?? Number.MAX_SAFE_INTEGER) -
1608
1919
  (priority.get(b.serverId) ?? Number.MAX_SAFE_INTEGER));
1609
1920
  }
1921
+ /**
1922
+ * Documents currently open on any live client rooted at or under `cwd`
1923
+ * (#2430), as normalized path keys.
1924
+ *
1925
+ * The third contributor to the observational mutation net's tracked-file
1926
+ * set, alongside the read-guard's read/write set and the widget's diagnostic
1927
+ * files. It is pure in-memory enumeration — no spawn, no open, no stat —
1928
+ * and it is deliberately capped by the caller rather than here, because the
1929
+ * cap belongs to the sweep's budget, not to the pool's bookkeeping.
1930
+ */
1931
+ getOpenDocumentPaths(cwd) {
1932
+ const paths = new Set();
1933
+ for (const { client } of this.activeClientsForCwd(cwd)) {
1934
+ try {
1935
+ for (const filePath of client.openDocumentPaths?.() ?? [])
1936
+ paths.add(filePath);
1937
+ }
1938
+ catch {
1939
+ // A client torn down mid-enumeration contributes nothing; the sweep
1940
+ // is advisory and must never break on a dying server.
1941
+ }
1942
+ }
1943
+ return [...paths];
1944
+ }
1610
1945
  /**
1611
1946
  * Get or create LSP client for a file
1612
1947
  * Prevents duplicate client creation via in-flight promise tracking
@@ -1617,14 +1952,76 @@ export class LSPService {
1617
1952
  // Primary selection considers language servers only — auxiliary servers
1618
1953
  // (opengrep, …) attach alongside the primary and are never chosen as it.
1619
1954
  const servers = getServersForFileWithConfig(filePath).filter((s) => s.role !== "auxiliary");
1620
- const serverWaitOverrideMs = servers.reduce((max, server) => Math.max(max, server.clientWaitTimeoutMs ?? 0), 0);
1955
+ // #2525: candidate roots are resolved LAZILY, and at most once per call.
1956
+ // They are deliberately NOT hoisted: `NearestRoot` (clients/lsp/server.ts)
1957
+ // caches successful hits but never negatives, so a fallback that cannot
1958
+ // root here — DenoServer (`fallbackFor: "typescript"`, gated on
1959
+ // deno.json(c)) in a project with no deno.json — re-walks to the
1960
+ // filesystem root on EVERY call, by design, so that a deno.json
1961
+ // scaffolded mid-session is picked up without a restart. Hoisting would
1962
+ // charge that walk to every touch of every .ts file in every non-Deno
1963
+ // project. The sequential trial loop below needs no roots of its own
1964
+ // (`ensureClientForServer` resolves and bails at `!root` before any spawn
1965
+ // work), so a served touch — the hot path — resolves nothing extra at
1966
+ // all. Only the branches that EMIT a candidate list ask, and all of them
1967
+ // are cold paths.
1968
+ const rootMemo = new Map();
1969
+ /**
1970
+ * Split the candidates into the ones that could actually have been waited
1971
+ * on and the ones that never had a spawn slot. A record's subject must be
1972
+ * its producer (#1550) — the #2524 shape.
1973
+ *
1974
+ * #2525 round 3 (F3): no SHIPPED `server.root` rejects today — every one
1975
+ * bottoms out in `markerExists`, which only ever resolves `false` on a
1976
+ * miss — so this was reasoned "removed by construction". A reviewer probe
1977
+ * disproved that: a `root()` that rejects makes this `Promise.all` reject,
1978
+ * which throws out of `getClientForFile` itself (the caller never gets
1979
+ * `undefined` back, and `lsp_client_wait_timeout` is never emitted). A
1980
+ * per-server catch degrades that candidate to unrooted instead of taking
1981
+ * the whole partition down — the same "producer" the memo already knows
1982
+ * (a failed root resolution never got a client either), now WITH the
1983
+ * failure reason so the record says why, not just that.
1984
+ */
1985
+ const partitionCandidates = async () => {
1986
+ const resolved = await Promise.all(servers.map(async (server) => {
1987
+ try {
1988
+ return {
1989
+ server,
1990
+ root: await this.resolveServerRootOnce(server, filePath, rootMemo),
1991
+ reason: undefined,
1992
+ };
1993
+ }
1994
+ catch (error) {
1995
+ return {
1996
+ server,
1997
+ root: undefined,
1998
+ reason: error instanceof Error ? error.message : String(error),
1999
+ };
2000
+ }
2001
+ }));
2002
+ const unrootedReasons = {};
2003
+ for (const entry of resolved) {
2004
+ if (entry.root === undefined && entry.reason !== undefined) {
2005
+ unrootedReasons[entry.server.id] = entry.reason;
2006
+ }
2007
+ }
2008
+ return {
2009
+ rooted: resolved
2010
+ .filter((entry) => entry.root !== undefined)
2011
+ .map(({ server, root }) => ({ server, root })),
2012
+ unrootedIds: resolved
2013
+ .filter((entry) => entry.root === undefined)
2014
+ .map((entry) => entry.server.id),
2015
+ unrootedReasons,
2016
+ };
2017
+ };
1621
2018
  // hardCapMs is a caller-imposed ceiling (e.g. pipeline budget) that
1622
2019
  // prevents tool_result from blocking the TUI for the full LSP cold-start
1623
2020
  // window. When no server config sets a wait (serverWaitOverrideMs = 0),
1624
2021
  // hardCapMs is used directly — Math.min(0, cap) = 0 would otherwise
1625
2022
  // take the no-timeout branch and block indefinitely (e.g. pyright, which
1626
2023
  // has no clientWaitTimeoutMs but can take 30s to initialize on cold start).
1627
- const serverBaseMs = Math.max(maxWaitMs ?? 0, serverWaitOverrideMs);
2024
+ const serverBaseMs = primaryServerWaitFloorMs(filePath, maxWaitMs);
1628
2025
  const effectiveMaxWaitMs = hardCapMs !== undefined
1629
2026
  ? serverBaseMs > 0
1630
2027
  ? Math.min(serverBaseMs, hardCapMs)
@@ -1655,7 +2052,10 @@ export class LSPService {
1655
2052
  // joined it, instead of pinning a starter label on every joiner.
1656
2053
  let erroredServerId;
1657
2054
  let erroredOutcome;
1658
- // Try each matching server
2055
+ // Try each matching server in registration order.
2056
+ // `ensureClientForServer` resolves the root itself and returns before
2057
+ // any spawn work when there is none, so an unrooted fallback costs
2058
+ // this loop one already-memoized resolution and no spawn slot.
1659
2059
  for (const server of servers) {
1660
2060
  // A box, not a `let`: control-flow analysis cannot see the callback
1661
2061
  // write and would narrow a plain local to its initializer.
@@ -1664,7 +2064,7 @@ export class LSPService {
1664
2064
  };
1665
2065
  const spawned = await this.ensureClientForServer(filePath, server, resolvedRoots, noteSpawnInFlight, (reported) => {
1666
2066
  acquisition.outcome = reported;
1667
- });
2067
+ }, rootMemo);
1668
2068
  if (spawned) {
1669
2069
  logLatency({
1670
2070
  type: "phase",
@@ -1710,8 +2110,16 @@ export class LSPService {
1710
2110
  },
1711
2111
  });
1712
2112
  }
2113
+ // #2525: reuse the roots the trial loop above already resolved rather
2114
+ // than re-walking with the RAW `server.root`. Two reasons: the raw
2115
+ // call skips the ceiling/session-root gates, so this dedupe key could
2116
+ // disagree with the identity the acquisition path actually used; and
2117
+ // on the unserved path — the only path that reaches here — every
2118
+ // unrooted candidate would pay a second full walk to the filesystem
2119
+ // root, on every touch, before the `unavailableLogged` dedupe even
2120
+ // looked at it.
1713
2121
  const unavailable = (await Promise.all(servers.map(async (server) => {
1714
- const root = await server.root(filePath);
2122
+ const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
1715
2123
  return {
1716
2124
  server,
1717
2125
  key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
@@ -1767,11 +2175,13 @@ export class LSPService {
1767
2175
  // `inFlight` is cleared in ensureClientForServer's finally block, so a
1768
2176
  // settled acquisition can still be present here. Re-read the published
1769
2177
  // 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;
2178
+ // #2525: this is a cold path, so it is where the roots get resolved —
2179
+ // once, memoized with the trial loop's own resolutions. An unrooted
2180
+ // fallback can never have a client keyed under its (non-existent)
2181
+ // root, so it is neither probed here nor named in the record below.
2182
+ const { rooted, unrootedIds, unrootedReasons } = await partitionCandidates();
2183
+ for (const { server, root } of rooted) {
2184
+ const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
1775
2185
  if (client?.isAlive())
1776
2186
  return { client, info: server };
1777
2187
  }
@@ -1783,16 +2193,46 @@ export class LSPService {
1783
2193
  durationMs: 0,
1784
2194
  metadata: {
1785
2195
  maxWaitMs: effectiveMaxWaitMs,
1786
- serverIds: servers.map((server) => server.id),
2196
+ serverIds: rooted.map(({ server }) => server.id),
2197
+ unrootedCandidates: {
2198
+ count: unrootedIds.length,
2199
+ ids: unrootedIds,
2200
+ ...(Object.keys(unrootedReasons).length > 0
2201
+ ? { reasons: unrootedReasons }
2202
+ : {}),
2203
+ },
1787
2204
  reason: "budget_skipped_known_slow",
1788
2205
  },
1789
2206
  });
1790
2207
  return undefined;
1791
2208
  }
1792
2209
  if (waitResult === timeoutSentinel) {
2210
+ // #2525: likewise a cold path — resolve here, not up front.
2211
+ //
2212
+ // #2525 round 3 (L2): `partitionCandidates` does UNCACHED negative
2213
+ // root walks (`NearestRoot` never caches misses, by design — see the
2214
+ // comment above `rootMemo`) and this branch only reaches it AFTER
2215
+ // `effectiveMaxWaitMs` has already elapsed. Measured by the reviewer:
2216
+ // 2.06ms native, ~150ms on the #462 slow-FS profile — unbounded on top
2217
+ // of a budget whose entire contract is "return within the cap". Bound
2218
+ // it with a small ceiling of its own (a quarter of the caller's hard
2219
+ // cap when one was given, capped at 250ms) rather than leaving it the
2220
+ // one piece of async work in this function with no bound at all
2221
+ // (the "async work needs both bounds" shape — here there is no
2222
+ // per-call abort signal to race, only the timer bound applies).
2223
+ const partitionBudgetMs = hardCapMs !== undefined ? Math.min(250, hardCapMs / 4) : 250;
2224
+ const partitioned = await withDeadline(partitionCandidates(), {
2225
+ ms: partitionBudgetMs,
2226
+ onTimeout: "undefined",
2227
+ });
2228
+ const { rooted, unrootedIds, unrootedReasons } = partitioned ?? {
2229
+ rooted: [],
2230
+ unrootedIds: [],
2231
+ unrootedReasons: {},
2232
+ };
1793
2233
  // Snapshot known client health — scan by serverId prefix (no root needed)
1794
2234
  const knownHealth = [...this.state.clients.entries()]
1795
- .filter(([k]) => servers.some((s) => k.startsWith(`${s.id}:`)))
2235
+ .filter(([k]) => rooted.some(({ server }) => k.startsWith(`${server.id}:`)))
1796
2236
  .map(([k, c]) => ({
1797
2237
  serverId: k.split(":")[0],
1798
2238
  alive: c.isAlive(),
@@ -1805,9 +2245,23 @@ export class LSPService {
1805
2245
  durationMs: effectiveMaxWaitMs,
1806
2246
  metadata: {
1807
2247
  maxWaitMs: effectiveMaxWaitMs,
1808
- serverIds: servers.map((s) => s.id),
2248
+ // `serverIds` names the ROOTED pool the timeout record reports
2249
+ // against, not "servers this call actually waited on" — the
2250
+ // sequential trial loop can time out having reached only the
2251
+ // first of several rooted candidates (case 2 in the regression
2252
+ // test pins both `["typescript","deno"]` even though the loop
2253
+ // never got past `typescript`).
2254
+ serverIds: rooted.map(({ server }) => server.id),
2255
+ unrootedCandidates: {
2256
+ count: unrootedIds.length,
2257
+ ids: unrootedIds,
2258
+ ...(Object.keys(unrootedReasons).length > 0
2259
+ ? { reasons: unrootedReasons }
2260
+ : {}),
2261
+ },
1809
2262
  // servers absent from knownHealth were never spawned or are still spawning
1810
2263
  knownClientHealth: knownHealth,
2264
+ ...(partitioned === undefined ? { partition: "timed-out" } : {}),
1811
2265
  },
1812
2266
  });
1813
2267
  return undefined;
@@ -1815,8 +2269,10 @@ export class LSPService {
1815
2269
  return waitResult;
1816
2270
  }
1817
2271
  /**
1818
- * Get or create ALL LSP clients that can serve a file.
1819
- * Used for diagnostics aggregation across complementary servers.
2272
+ * Get or create all complementary LSP clients that can serve a file. Alternate
2273
+ * language servers marked with `fallbackFor` remain sequential fallbacks: an
2274
+ * aggregate diagnostics pass must not launch them beside a working preferred
2275
+ * server merely because it requested `clientScope: "all"`.
1820
2276
  */
1821
2277
  async getClientsForFile(filePath, excludeServerIds, resolvedRoots) {
1822
2278
  const allServers = getServersForFileWithConfig(filePath);
@@ -1825,28 +2281,59 @@ export class LSPService {
1825
2281
  : allServers;
1826
2282
  if (servers.length === 0)
1827
2283
  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,
2284
+ // Resolve once to keep the attempted count tied to servers with a real root.
2285
+ const rootedServers = (await Promise.all(servers.map(async (server) => ({
2286
+ server,
2287
+ root: await this.resolveServerRoot(server, filePath),
2288
+ })))).filter((entry) => entry.root !== undefined);
2289
+ let serverCountAttempted = 0;
2290
+ const acquisitions = new Map();
2291
+ const acquire = (server) => {
2292
+ serverCountAttempted += 1;
2293
+ return this.ensureClientForServer(filePath, server, resolvedRoots);
1836
2294
  };
2295
+ // Start complementary servers immediately. An alternate waits only for its
2296
+ // own preferred server, not for unrelated scanners, and starts if that server
2297
+ // declines. Registry order makes chained fallbacks deterministic.
2298
+ for (const { server } of rootedServers) {
2299
+ const preferred = server.fallbackFor
2300
+ ? acquisitions.get(server.fallbackFor)
2301
+ : undefined;
2302
+ const acquisition = preferred
2303
+ ? preferred.then((entry) => entry === undefined ? acquire(server) : undefined)
2304
+ : acquire(server);
2305
+ acquisitions.set(server.id, acquisition);
2306
+ }
2307
+ const results = await Promise.all(acquisitions.values());
2308
+ const clients = results.filter((entry) => entry !== undefined);
2309
+ return { clients, serverCountAttempted };
1837
2310
  }
1838
2311
  /**
1839
2312
  * Spawn/get the AUXILIARY clients for a file (role:"auxiliary") restricted to
1840
2313
  * the enabled set. These attach alongside the primary on the with-auxiliary
1841
2314
  * diagnostics path (cross-cutting scanners like opengrep).
1842
2315
  */
1843
- async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome) {
2316
+ async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome, maxWaitMs, signal, hook) {
1844
2317
  if (this.checkDestroyed() || enabledIds.size === 0)
1845
2318
  return [];
1846
2319
  const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
1847
2320
  if (servers.length === 0)
1848
2321
  return [];
1849
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported))));
2322
+ const rootMemo = new Map();
2323
+ const effectiveSignal = signal ?? getAmbientAbortSignal();
2324
+ const effectiveHook = hook ?? "tool_result_edit";
2325
+ const spawned = await Promise.all(servers.map(async (server) => {
2326
+ const acquisition = this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported), rootMemo);
2327
+ if (!maxWaitMs || maxWaitMs <= 0) {
2328
+ return acquisition;
2329
+ }
2330
+ return bounded(acquisition, {
2331
+ ms: maxWaitMs,
2332
+ signal: effectiveSignal,
2333
+ hook: effectiveHook,
2334
+ label: `auxiliary-spawn:${server.id}`,
2335
+ });
2336
+ }));
1850
2337
  return spawned.filter((entry) => Boolean(entry));
1851
2338
  }
1852
2339
  /**
@@ -1928,9 +2415,11 @@ export class LSPService {
1928
2415
  * the Gate-B readiness check, this NEVER creates or warms a client — it only
1929
2416
  * resolves each requested server's root and reads the already-connected
1930
2417
  * 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.
2418
+ * are absent unless notify-stall teardown marked that generation as
2419
+ * replaceable; that status carries the demotion timestamp so turn-end late
2420
+ * coverage can correlate each pair to the removed generation. A live
2421
+ * client is present even when its per-file cache entry is empty; its
2422
+ * timestamp distinguishes a published clean result from no publication.
1934
2423
  */
1935
2424
  async readCachedDiagnosticsForServers(filePath, serverIds) {
1936
2425
  const out = new Map();
@@ -1944,8 +2433,20 @@ export class LSPService {
1944
2433
  continue;
1945
2434
  const key = `${server.id}:${normalizeMapKey(root)}`;
1946
2435
  const client = this.state.clients.get(key);
1947
- if (!client?.isAlive())
2436
+ if (!client?.isAlive()) {
2437
+ const demotedAt = this.notifyStallDemotions.get(key);
2438
+ if (demotedAt !== undefined) {
2439
+ out.set(server.id, {
2440
+ diags: [],
2441
+ notifyStallDemoted: true,
2442
+ demotedAt,
2443
+ });
2444
+ }
1948
2445
  continue;
2446
+ }
2447
+ // A replacement is live again. The old generation's marker no longer
2448
+ // describes this client and must not make a later absence look transient.
2449
+ this.notifyStallDemotions.delete(key);
1949
2450
  const entry = client.getAllDiagnostics().get(normalizeMapKey(filePath));
1950
2451
  out.set(server.id, {
1951
2452
  diags: entry?.diags ?? [],
@@ -2156,7 +2657,29 @@ export class LSPService {
2156
2657
  noteSpawnVerdict(key, verdict) {
2157
2658
  this.lastSpawnVerdict.set(key, verdict);
2158
2659
  }
2159
- async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome) {
2660
+ /**
2661
+ * {@link resolveServerRoot}, memoized against a caller-owned map so one
2662
+ * acquisition asks a given server for its root at most once (#2525).
2663
+ *
2664
+ * Resolution is cheap exactly when it SUCCEEDS: `NearestRoot` caches hits,
2665
+ * but deliberately never caches negatives (so a marker scaffolded
2666
+ * mid-session is picked up without a restart). An unrooted candidate
2667
+ * therefore re-walks to the filesystem root on every call, which is why the
2668
+ * acquisition path and the records it emits must share one resolution
2669
+ * instead of each taking their own. Without a `memo` this is a plain
2670
+ * passthrough, for callers that have no acquisition to share with.
2671
+ */
2672
+ resolveServerRootOnce(server, filePath, memo) {
2673
+ if (!memo)
2674
+ return this.resolveServerRoot(server, filePath);
2675
+ const pending = memo.get(server.id);
2676
+ if (pending !== undefined)
2677
+ return pending;
2678
+ const started = this.resolveServerRoot(server, filePath);
2679
+ memo.set(server.id, started);
2680
+ return started;
2681
+ }
2682
+ async ensureClientForServer(filePath, server, resolvedRoots, onSpawnInFlight, onOutcome, rootMemo) {
2160
2683
  const handoff = this.generationHandoff;
2161
2684
  if (handoff) {
2162
2685
  await handoff;
@@ -2166,7 +2689,7 @@ export class LSPService {
2166
2689
  if (this.checkDestroyed())
2167
2690
  return undefined;
2168
2691
  }
2169
- const root = await this.resolveServerRoot(server, filePath);
2692
+ const root = await this.resolveServerRootOnce(server, filePath, rootMemo);
2170
2693
  if (!root || this.checkDestroyed())
2171
2694
  return undefined;
2172
2695
  if (server.role !== "auxiliary") {
@@ -2269,6 +2792,7 @@ export class LSPService {
2269
2792
  intentional: wasIntentional,
2270
2793
  },
2271
2794
  });
2795
+ this.releaseOutstandingAuxNotifyWrite(key);
2272
2796
  try {
2273
2797
  await existing.shutdown();
2274
2798
  }
@@ -2618,6 +3142,9 @@ export class LSPService {
2618
3142
  diagnosticProviderKind: "unavailable",
2619
3143
  };
2620
3144
  this.state.clients.set(key, client);
3145
+ // #2356: this generation is the replacement the late-coverage probe was
3146
+ // waiting for. Clear the retired-generation marker before any later probe.
3147
+ this.notifyStallDemotions.delete(key);
2621
3148
  this.unavailableLogged.delete(key);
2622
3149
  // #1934 review F1: a success retires the previous verdict, so the map
2623
3150
  // never outlives the attempts it describes.
@@ -2800,10 +3327,27 @@ export class LSPService {
2800
3327
  else if (clientScope === "with-auxiliary") {
2801
3328
  // Primary language server + the enabled cross-cutting auxiliaries
2802
3329
  // (opengrep, …). The aggregation layer merges/dedups their diagnostics.
3330
+ const auxStartedAt = Date.now();
2803
3331
  const [entry, aux] = await Promise.all([
2804
3332
  this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
2805
- this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary),
3333
+ this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary, options.maxClientWaitMs, undefined, options.hook),
2806
3334
  ]);
3335
+ const auxDurationMs = Date.now() - auxStartedAt;
3336
+ if (options.auxiliaryServerIds && options.auxiliaryServerIds.length > 0) {
3337
+ logLatency({
3338
+ type: "phase",
3339
+ phase: "auxiliary_readiness",
3340
+ filePath: normalizedPath,
3341
+ durationMs: auxDurationMs,
3342
+ metadata: {
3343
+ serverIds: aux.map((s) => s.info.id),
3344
+ attemptedCount: options.auxiliaryServerIds.length,
3345
+ readyCount: aux.length,
3346
+ coldServerIds: [...coldAuxiliaryServerIds],
3347
+ source,
3348
+ },
3349
+ });
3350
+ }
2807
3351
  spawned = entry ? [entry, ...aux] : aux;
2808
3352
  serverCountAttempted = spawned.length;
2809
3353
  }
@@ -3008,14 +3552,26 @@ export class LSPService {
3008
3552
  if (!notifySkipped) {
3009
3553
  const budget = notifyWriteBudgetMs();
3010
3554
  // #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.
3555
+ // by the write budget and by the effective primary wait floor, minus what
3556
+ // which includes the caller's `maxClientWaitMs` and any primary
3557
+ // server `clientWaitTimeoutMs` override. The elapsed client wait is
3558
+ // subtracted below.
3014
3559
  // A flat write-budget wait would tax a caller that asked for less than one
3015
3560
  // budget in total. Non-positive means "no time left to queue": the server
3016
3561
  // is reported as uncovered immediately.
3562
+ //
3563
+ // #2239: "what the caller already declared" is `primaryServerWaitFloorMs`,
3564
+ // not the raw `options.maxClientWaitMs` — the SAME floor `getClientForFile`
3565
+ // races the primary spawn against. A cold primary configured with its own
3566
+ // `clientWaitTimeoutMs` (Ruby 30s, and the #2233 Bash/JSON/Vue/Svelte/Prisma
3567
+ // overrides) is allowed to run past the caller's flat budget, and did just
3568
+ // that by the time this line runs — charging the subtraction against the
3569
+ // flat value alone always went negative and clamped to zero. The outer
3570
+ // `Math.min(budget, …)` is unchanged, so this raises what "already spent"
3571
+ // is measured against without widening the wait beyond one write budget.
3017
3572
  const queueWaitMs = options.maxClientWaitMs !== undefined
3018
- ? Math.min(budget, Math.max(0, options.maxClientWaitMs - (Date.now() - startedAt)))
3573
+ ? Math.min(budget, Math.max(0, primaryServerWaitFloorMs(filePath, options.maxClientWaitMs) -
3574
+ (Date.now() - startedAt)))
3019
3575
  : budget;
3020
3576
  await Promise.all(spawned.map(async (entry) => {
3021
3577
  // #743: this server already has this content from a recent touch
@@ -3103,10 +3659,13 @@ export class LSPService {
3103
3659
  // streak outright, so only the late case retracts. A landing
3104
3660
  // past the WEDGE window keeps its strike: at that point the
3105
3661
  // stall was long enough that #743's demotion is the honest
3106
- // verdict, not a latency artifact.
3662
+ // verdict, not a latency artifact. #2358: the wedge window
3663
+ // is the adaptive one the timer itself arms with, so a
3664
+ // write a BUSY server would have been left alone for also
3665
+ // retracts its strike instead of accruing a ladder charge.
3107
3666
  const outstandingMs = Date.now() - writeStartedAt;
3108
3667
  if (outstandingMs > budget &&
3109
- outstandingMs <= notifyWedgedMs()) {
3668
+ outstandingMs <= this.auxNotifyWedgeBudgetMs(clientKey)) {
3110
3669
  this.retractNotifyWriteBackpressure(clientKey, entry.info.id, filePath, outstandingMs, client);
3111
3670
  }
3112
3671
  }, release);
@@ -4398,9 +4957,7 @@ export class LSPService {
4398
4957
  reason: reasons.join(", ") || "scanner coverage gap",
4399
4958
  });
4400
4959
  }
4401
- logLatency({
4402
- type: "phase",
4403
- phase: "lsp_scanner_coverage_gap",
4960
+ emitBounded("lsp_scanner_coverage_gap", `${source}:${normalizedPath}`, {
4404
4961
  filePath: normalizedPath,
4405
4962
  durationMs: Date.now() - startedAt,
4406
4963
  metadata: {
@@ -4409,10 +4966,10 @@ export class LSPService {
4409
4966
  ...(brokenSkippedServerIds.length > 0 && {
4410
4967
  brokenSkippedServerIds,
4411
4968
  }),
4412
- // #1586: the deferrals this touch is actually uncovered for. The raw
4969
+ // #1586: the deferrals this touch is actually uncovered for. The
4413
4970
  // 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.
4971
+ // this row exists to prove a blackout, and a scanner already bound
4972
+ // to these bytes is not one.
4416
4973
  ...(uncoveredDeferredServerIds.length > 0 && {
4417
4974
  deferredResyncServerIds: uncoveredDeferredServerIds,
4418
4975
  }),
@@ -5841,6 +6398,23 @@ export class LSPService {
5841
6398
  // never proved it can answer diagnostics — that's a failed warm-up. A
5842
6399
  // server with an unresolvable key never spawns here, so it can't fail this
5843
6400
  // way and is excluded (never skipped by the caller for this file).
6401
+ // #2525: same split the readiness logic above already makes — `keys[i]`
6402
+ // is `undefined` exactly when the server resolves no key for this file,
6403
+ // i.e. it will never spawn here (DenoServer without a deno.json). The
6404
+ // `lsp_sweep_warmup_*` records must not claim to have warmed it. Free:
6405
+ // `keys` is already resolved, so this costs no filesystem work.
6406
+ const rootedServerIds = [];
6407
+ const unrootedServerIds = [];
6408
+ for (let i = 0; i < servers.length; i++) {
6409
+ if (keys[i] !== undefined)
6410
+ rootedServerIds.push(servers[i].id);
6411
+ else
6412
+ unrootedServerIds.push(servers[i].id);
6413
+ }
6414
+ const unrootedCandidates = {
6415
+ count: unrootedServerIds.length,
6416
+ ids: unrootedServerIds,
6417
+ };
5844
6418
  const stillColdServerIds = () => {
5845
6419
  const cold = [];
5846
6420
  for (let i = 0; i < servers.length; i++) {
@@ -5860,7 +6434,12 @@ export class LSPService {
5860
6434
  phase: "lsp_sweep_warmup_start",
5861
6435
  filePath: representativeFile,
5862
6436
  durationMs: 0,
5863
- metadata: { serverIds: servers.map((s) => s.id), timeoutMs, attempt },
6437
+ metadata: {
6438
+ serverIds: rootedServerIds,
6439
+ unrootedCandidates,
6440
+ timeoutMs,
6441
+ attempt,
6442
+ },
5864
6443
  });
5865
6444
  const warmupAttempt = this.touchFile(representativeFile, content, {
5866
6445
  diagnostics: "document",
@@ -5910,7 +6489,8 @@ export class LSPService {
5910
6489
  filePath: representativeFile,
5911
6490
  durationMs: Date.now() - startedAt,
5912
6491
  metadata: {
5913
- serverIds: servers.map((s) => s.id),
6492
+ serverIds: rootedServerIds,
6493
+ unrootedCandidates,
5914
6494
  timeoutMs,
5915
6495
  attempt,
5916
6496
  coldServerIds,
@@ -6935,6 +7515,9 @@ export class LSPService {
6935
7515
  if (this.checkDestroyed())
6936
7516
  return;
6937
7517
  this.isDestroyed = true;
7518
+ for (const [key, token] of this.outstandingAuxNotifyWrites) {
7519
+ this.releaseOutstandingAuxNotifyWrite(key, token);
7520
+ }
6938
7521
  for (const key of this.typeScriptIdleTimers.keys()) {
6939
7522
  this.clearTypeScriptIdleTimer(key);
6940
7523
  }
@@ -6987,7 +7570,15 @@ export class LSPService {
6987
7570
  // #1459: every gated client is gone, so no outstanding-write record can
6988
7571
  // describe a live one. The gate's identity check already neutralises a stale
6989
7572
  // entry; clearing keeps the map honest rather than relying on that.
6990
- this.outstandingAuxNotifyWrites.clear();
7573
+ for (const [key, token] of this.outstandingAuxNotifyWrites) {
7574
+ this.releaseOutstandingAuxNotifyWrite(key, token);
7575
+ }
7576
+ this.notifyStallDemotions.clear();
7577
+ // #2358: same reasoning — a per-write latency estimate belongs to a client
7578
+ // generation, and every client is gone. `session_start` reaches this
7579
+ // through the service reset, so the adaptive wedge window re-arms with
7580
+ // the session rather than being priced by a previous one's throughput.
7581
+ this.auxNotifyDrainLatencyEwma.clear();
6991
7582
  // #1714: same reasoning — a backlog count belongs to a client generation,
6992
7583
  // and every client is gone. `session_start` reaches this through the service
6993
7584
  // reset, so the pacing state re-arms with the session rather than living for
@@ -7189,6 +7780,20 @@ export async function notifyExternalFileChange(filePath, type) {
7189
7780
  return getLSPService().notifyExternalFileChange(filePath, type);
7190
7781
  }
7191
7782
  export function resetLSPService(options = {}) {
7783
+ // #2504 review round 2 (F3): whatever the retiring service was still being
7784
+ // asked for off-hook must stop HERE, before any teardown and before the
7785
+ // `!retiringService` early return below (a `session_start` reset with no
7786
+ // live service must still retire a loop the previous session armed). This
7787
+ // is the one choke point every lifecycle path goes through —
7788
+ // `session_shutdown`, `session_start`, and the idle reset — so the
7789
+ // deferred actionable-warnings pull cannot open another file against a
7790
+ // dying service, cannot re-spawn one after the idle reset, and cannot be
7791
+ // mid-`openFile` when the loop closes (#234). Aborting spawns nothing.
7792
+ abortDeferredLspWork(`lsp service reset: ${options.reason ?? "unspecified"}`);
7793
+ // Invalidate availability publication started by the retiring service before
7794
+ // any asynchronous teardown. The launch seam checks this generation after
7795
+ // every managed lookup, install, and process launch (#2351).
7796
+ resetLspLaunchAvailabilityGeneration();
7192
7797
  // A new session must get its own classic-tsserver-repair attempt: the
7193
7798
  // guard is a process-lifetime flag (see resetClassicTsRepairGuard), so a
7194
7799
  // repair that failed transiently in an earlier session must not stay