@herbertgao/pi-extensions 2026.8.13 → 2026.8.15

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 (263) hide show
  1. package/README.md +19 -14
  2. package/THIRD_PARTY_NOTICES.md +0 -1
  3. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +19 -0
  4. package/node_modules/@czottmann/pi-automode/README.md +5 -1
  5. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +126 -13
  6. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
  9. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  10. package/node_modules/@herbertgao/{pi-stash → pi-bark}/LICENSE +4 -5
  11. package/node_modules/@herbertgao/pi-bark/README.md +44 -0
  12. package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
  13. package/node_modules/@herbertgao/pi-bark/package.json +53 -0
  14. package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
  15. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  16. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  17. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  18. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  19. package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
  20. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  22. package/node_modules/@narumitw/pi-btw/README.md +125 -88
  23. package/node_modules/@narumitw/pi-btw/dist/index.ts +140 -45
  24. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
  25. package/node_modules/@narumitw/pi-btw/package.json +5 -8
  26. package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
  27. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +106 -17
  28. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  29. package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
  30. package/node_modules/@tifan/pi-handoff/README.md +36 -13
  31. package/node_modules/@tifan/pi-handoff/package.json +2 -2
  32. package/node_modules/@tifan/pi-handoff/src/index.ts +160 -4
  33. package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
  34. package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
  35. package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
  36. package/node_modules/@tifan/pi-recap/README.md +1 -1
  37. package/node_modules/@tifan/pi-recap/package.json +2 -2
  38. package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
  39. package/node_modules/@tifan/pi-rename/README.md +42 -21
  40. package/node_modules/@tifan/pi-rename/package.json +1 -1
  41. package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
  42. package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
  43. package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
  44. package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
  45. package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
  46. package/node_modules/pi-lens/CHANGELOG.md +320 -0
  47. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
  48. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
  50. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
  51. package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
  52. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
  53. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
  54. package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
  55. package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
  56. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
  57. package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
  58. package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
  59. package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
  60. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
  61. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
  62. package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
  63. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
  64. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
  65. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
  66. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
  67. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
  68. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
  69. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
  70. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
  71. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
  72. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
  73. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
  74. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
  75. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
  76. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
  77. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  85. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
  86. package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
  87. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
  88. package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
  89. package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
  90. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
  91. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
  92. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
  93. package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
  94. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
  95. package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
  96. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
  97. package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
  98. package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
  99. package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
  100. package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
  101. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  102. package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
  103. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
  104. package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
  105. package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
  106. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
  107. package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
  108. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
  109. package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
  110. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
  111. package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
  112. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
  113. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
  114. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  115. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
  116. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
  117. package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
  118. package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
  119. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
  120. package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
  121. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
  122. package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
  123. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
  124. package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
  125. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
  126. package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
  127. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  128. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
  129. package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
  130. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
  131. package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
  132. package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
  133. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
  134. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
  135. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
  136. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
  137. package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
  138. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
  139. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
  140. package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
  141. package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
  142. package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
  143. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
  144. package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
  145. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
  146. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
  147. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
  148. package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
  149. package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
  150. package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
  151. package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
  152. package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
  153. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
  154. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
  155. package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
  156. package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
  157. package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
  158. package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
  159. package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
  160. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
  161. package/node_modules/pi-lens/dist/index.js +11243 -6651
  162. package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
  163. package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
  164. package/node_modules/pi-lens/dist/mcp/server.js +6 -1
  165. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
  166. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
  167. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
  168. package/node_modules/pi-lens/docs/agent-guide.md +4 -2
  169. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
  170. package/node_modules/pi-lens/docs/astplayground.md +26 -17
  171. package/node_modules/pi-lens/package.json +5 -1
  172. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  173. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  174. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  175. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  176. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  177. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  178. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  179. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  180. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  181. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  182. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  183. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  184. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  185. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  186. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  187. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  188. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  189. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  190. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  191. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  192. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  193. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  194. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  195. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  196. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  197. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  198. package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  199. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  200. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  201. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  202. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  203. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  204. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  205. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  206. package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  207. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  208. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  209. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  210. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  211. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  212. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  213. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  214. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  215. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
  216. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  217. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  218. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  219. package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  220. package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  221. package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  222. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  223. package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  224. package/node_modules/pi-mcp-adapter/CHANGELOG.md +35 -0
  225. package/node_modules/pi-mcp-adapter/README.md +5 -2
  226. package/node_modules/pi-mcp-adapter/cli.js +4 -4
  227. package/node_modules/pi-mcp-adapter/commands.ts +1 -1
  228. package/node_modules/pi-mcp-adapter/config.ts +1 -0
  229. package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
  230. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  231. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
  232. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
  233. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
  234. package/node_modules/pi-mcp-adapter/dist/types.d.ts +3 -1
  235. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  236. package/node_modules/pi-mcp-adapter/index.ts +90 -6
  237. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +124 -32
  238. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
  239. package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
  240. package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
  241. package/node_modules/pi-mcp-adapter/package.json +1 -1
  242. package/node_modules/pi-mcp-adapter/proxy-modes.ts +62 -22
  243. package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
  244. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -0
  245. package/node_modules/pi-mcp-adapter/types.ts +3 -1
  246. package/node_modules/pi-web-access/CHANGELOG.md +42 -0
  247. package/node_modules/pi-web-access/README.md +26 -10
  248. package/node_modules/pi-web-access/curator-page.ts +12 -3
  249. package/node_modules/pi-web-access/curator-server.ts +3 -1
  250. package/node_modules/pi-web-access/extract.ts +88 -13
  251. package/node_modules/pi-web-access/gemini-search.ts +10 -5
  252. package/node_modules/pi-web-access/github-extract.ts +47 -3
  253. package/node_modules/pi-web-access/index.ts +40 -40
  254. package/node_modules/pi-web-access/package.json +1 -1
  255. package/node_modules/pi-web-access/page-query.ts +61 -8
  256. package/node_modules/pi-web-access/xcrawl.ts +264 -0
  257. package/package.json +19 -22
  258. package/node_modules/@herbertgao/pi-stash/README.md +0 -34
  259. package/node_modules/@herbertgao/pi-stash/package.json +0 -51
  260. package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
  261. package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
  262. package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
  263. package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
@@ -17,7 +17,7 @@ import { recordLsp } from "../widget-state.js";
17
17
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
18
18
  import { detectFileRole } from "../file-role.js";
19
19
  import { emitBounded } from "../bounded-telemetry.js";
20
- import { logLatency } from "../latency-logger.js";
20
+ import { logLatency, phaseFinished, phaseStarted } from "../latency-logger.js";
21
21
  import { logSessionStart } from "../sessionstart-logger.js";
22
22
  import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
23
23
  import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
@@ -25,12 +25,17 @@ import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
25
25
  import { withDeadline, withTimeout } from "../deadline-utils.js";
26
26
  import { acquireWorkspaceSweepHold, clearWorkspaceSweepHoldForSessionStart, } from "./workspace-sweep-hold.js";
27
27
  import { DocumentDriftTracker, fingerprintDocumentContent, } from "./document-drift.js";
28
- import { markPendingAuxiliaryCoverage } from "./pending-aux-coverage.js";
28
+ import { markPendingAuxiliaryCoverage, napiFallbackCoveredSince, } from "./pending-aux-coverage.js";
29
29
  import { isAtOrAboveHomeDir, isWindowsPath, normalizeMapKey, uriToPath, } from "../path-utils.js";
30
30
  import { recordLspMutation } from "../lsp-mutation.js";
31
31
  import { createLSPClient } from "./client.js";
32
32
  import { auxiliaryCoverageGap, bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, hashDiagnosticContent, resolveTouchVerdict, touchCoverageGap, } from "./diagnostic-binding.js";
33
33
  import { getServersForFileWithConfig, getServerInitOverride, } from "./config.js";
34
+ // #2052: deliberately NOT taken from `config.js`. This module's import surface
35
+ // from `config.js` is mirrored by explicit `vi.mock` factories in ~58 test
36
+ // files, so every new symbol taken from there breaks all of them.
37
+ import { getSessionRootsForTelemetry, isOutsideAllSessionRoots, } from "./session-roots.js";
38
+ import { getProcessSingleton } from "../process-singletons.js";
34
39
  import { getLanguageId } from "./language.js";
35
40
  import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, resetClassicTsRepairGuard, } from "./server.js";
36
41
  import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
@@ -301,6 +306,19 @@ function readEnvAuxGraceMs() {
301
306
  return undefined;
302
307
  return parsed;
303
308
  }
309
+ const DEFAULT_AUX_GRACE_CEILING_MS = 2000;
310
+ const MAX_ADAPTIVE_AUX_GRACE_CEILING_MS = 8000;
311
+ const ADAPTIVE_AUX_GRACE_MARGIN_MS = 500;
312
+ export function auxWaitBudgetMs(serverId, isCold, configuredCeilingMs, declaredWaitMs) {
313
+ if (configuredCeilingMs !== undefined || !isCold) {
314
+ return Math.min(declaredWaitMs, configuredCeilingMs ?? DEFAULT_AUX_GRACE_CEILING_MS);
315
+ }
316
+ const observedSpawnMs = getSuccessfulLspSpawnDurationMs(serverId);
317
+ if (observedSpawnMs === undefined || observedSpawnMs <= 0) {
318
+ return Math.min(declaredWaitMs, DEFAULT_AUX_GRACE_CEILING_MS);
319
+ }
320
+ return Math.min(MAX_ADAPTIVE_AUX_GRACE_CEILING_MS, Math.max(declaredWaitMs, observedSpawnMs + ADAPTIVE_AUX_GRACE_MARGIN_MS));
321
+ }
304
322
  const DIAGNOSTICS_SEMANTIC_SETTLE_THRESHOLD_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_THRESHOLD_MS ?? "250", 10) || 250);
305
323
  const DIAGNOSTICS_SEMANTIC_SETTLE_WAIT_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_DIAGNOSTICS_SEMANTIC_SETTLE_MS ?? "400", 10) || 400);
306
324
  // Once the fastest client has diagnostics, remaining clients get this window before
@@ -576,10 +594,12 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
576
594
  }
577
595
  // --- Service ---
578
596
  export class LSPService {
597
+ sessionCwd;
579
598
  state;
580
599
  workspaceProbeLogged = new Set();
581
600
  /** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
582
601
  projectBoundaryCache = new Map();
602
+ /** Foreign roots are recorded once per normalized root for this service/session. */
583
603
  warmStartLogged = new Set();
584
604
  optionalFailureLogged = new Set();
585
605
  /** Server/root pairs that already emitted unavailable for the current occurrence. */
@@ -755,8 +775,9 @@ export class LSPService {
755
775
  * a permanent promise/microtask tax.
756
776
  */
757
777
  generationHandoff;
758
- constructor(generationHandoff) {
778
+ constructor(generationHandoff, sessionCwd) {
759
779
  this.generationHandoff = generationHandoff;
780
+ this.sessionCwd = sessionCwd;
760
781
  this.state = {
761
782
  clients: new Map(),
762
783
  servers: new Map(),
@@ -776,6 +797,14 @@ export class LSPService {
776
797
  const candidate = await server.root(filePath);
777
798
  if (!candidate)
778
799
  return undefined;
800
+ // #2052: a file outside EVERY initialized session cwd gets no client at
801
+ // all. The ceiling below is unchanged (`process.cwd()`, as before this
802
+ // issue) and still only clamps files that are actually inside it —
803
+ // `enforceLspRootCeiling` returns the root untouched for an out-of-ceiling
804
+ // file. So this gate is the ONLY new refusal, and it consults the
805
+ // registry, not a single cwd.
806
+ if (isOutsideAllSessionRoots(filePath))
807
+ return undefined;
779
808
  const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
780
809
  if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
781
810
  return root;
@@ -1621,7 +1650,11 @@ export class LSPService {
1621
1650
  // #1934: the first server whose acquisition ERRORED, as opposed to
1622
1651
  // cleanly declining. Kept so a selection that served nobody still
1623
1652
  // says which server the pool actually tried and failed to spawn.
1653
+ // #2064 carries the errored outcome VALUE with it, so the record
1654
+ // below reports whether this caller started the failed spawn or
1655
+ // joined it, instead of pinning a starter label on every joiner.
1624
1656
  let erroredServerId;
1657
+ let erroredOutcome;
1625
1658
  // Try each matching server
1626
1659
  for (const server of servers) {
1627
1660
  // A box, not a `let`: control-flow analysis cannot see the callback
@@ -1650,8 +1683,12 @@ export class LSPService {
1650
1683
  });
1651
1684
  return spawned;
1652
1685
  }
1653
- if (acquisition.outcome === "spawn-failure") {
1654
- erroredServerId ??= server.id;
1686
+ if (acquisition.outcome === "spawn-failure" ||
1687
+ acquisition.outcome === "spawn-failure-joined") {
1688
+ if (erroredServerId === undefined) {
1689
+ erroredServerId = server.id;
1690
+ erroredOutcome = acquisition.outcome;
1691
+ }
1655
1692
  }
1656
1693
  }
1657
1694
  if (erroredServerId !== undefined) {
@@ -1668,7 +1705,8 @@ export class LSPService {
1668
1705
  metadata: {
1669
1706
  serverId: erroredServerId,
1670
1707
  candidateCount: servers.length,
1671
- outcome: "spawn-failure",
1708
+ outcome: (erroredOutcome ??
1709
+ "spawn-failure"),
1672
1710
  },
1673
1711
  });
1674
1712
  }
@@ -1802,13 +1840,13 @@ export class LSPService {
1802
1840
  * the enabled set. These attach alongside the primary on the with-auxiliary
1803
1841
  * diagnostics path (cross-cutting scanners like opengrep).
1804
1842
  */
1805
- async getAuxiliaryClientsForFile(filePath, enabledIds) {
1843
+ async getAuxiliaryClientsForFile(filePath, enabledIds, onOutcome) {
1806
1844
  if (this.checkDestroyed() || enabledIds.size === 0)
1807
1845
  return [];
1808
1846
  const servers = getServersForFileWithConfig(filePath).filter((s) => s.role === "auxiliary" && enabledIds.has(s.id));
1809
1847
  if (servers.length === 0)
1810
1848
  return [];
1811
- const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
1849
+ const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server, undefined, undefined, (reported) => onOutcome?.(server.id, reported))));
1812
1850
  return spawned.filter((entry) => Boolean(entry));
1813
1851
  }
1814
1852
  /**
@@ -1857,11 +1895,11 @@ export class LSPService {
1857
1895
  return undefined;
1858
1896
  }
1859
1897
  /**
1860
- * Read-only liveness check for one server/file pair. Unlike
1861
- * `getClientForFile`, this never creates or warms a client; it only resolves
1862
- * the server's root and checks the already-connected client map.
1898
+ * Read-only Gate-B readiness check for one auxiliary server/root. A connected
1899
+ * process is not yet a diagnostic producer: only its first publication proves
1900
+ * that this root's client can supersede an in-process fallback runner.
1863
1901
  */
1864
- async isServerAliveForFile(serverId, filePath) {
1902
+ async hasServerPublishedForFileRoot(serverId, filePath) {
1865
1903
  if (this.checkDestroyed())
1866
1904
  return false;
1867
1905
  for (const server of getServersForFileWithConfig(filePath)) {
@@ -1871,20 +1909,28 @@ export class LSPService {
1871
1909
  if (!root)
1872
1910
  continue;
1873
1911
  const key = `${server.id}:${normalizeMapKey(root)}`;
1874
- if (this.state.clients.get(key)?.isAlive())
1912
+ const client = this.state.clients.get(key);
1913
+ // #2324 F1: per-file grain. `diagnosticsVersion` is client-global —
1914
+ // any sibling path's publication bumps it, so it cannot answer "did
1915
+ // THIS server publish for THIS file?" (client.ts:339-341). A file
1916
+ // this server never touched would otherwise read as gated-open the
1917
+ // moment any other file on the same client got a publication.
1918
+ if (client?.isAlive() &&
1919
+ client.getDiagnosticsVersionForPath(filePath) > 0) {
1875
1920
  return true;
1921
+ }
1876
1922
  }
1877
1923
  return false;
1878
1924
  }
1879
1925
  /**
1880
1926
  * #2001/#2002 collect-later: read-only cached-diagnostics probe for the
1881
1927
  * turn-end late-auxiliary delivery (`clients/runtime-turn.ts`). Like
1882
- * `isServerAliveForFile`, this NEVER creates or warms a client — it only
1928
+ * the Gate-B readiness check, this NEVER creates or warms a client — it only
1883
1929
  * resolves each requested server's root and reads the already-connected
1884
1930
  * client's cached diagnostics for `filePath`. Servers with no live client
1885
- * are simply absent from the returned map (the caller drops those pairs
1886
- * silently); a present server maps to its cache contents, possibly empty
1887
- * (still scanning).
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.
1888
1934
  */
1889
1935
  async readCachedDiagnosticsForServers(filePath, serverIds) {
1890
1936
  const out = new Map();
@@ -1900,7 +1946,11 @@ export class LSPService {
1900
1946
  const client = this.state.clients.get(key);
1901
1947
  if (!client?.isAlive())
1902
1948
  continue;
1903
- out.set(server.id, client.getDiagnostics(filePath));
1949
+ const entry = client.getAllDiagnostics().get(normalizeMapKey(filePath));
1950
+ out.set(server.id, {
1951
+ diags: entry?.diags ?? [],
1952
+ publishedAt: entry?.ts,
1953
+ });
1904
1954
  }
1905
1955
  return out;
1906
1956
  }
@@ -2372,12 +2422,21 @@ export class LSPService {
2372
2422
  if (isOptionalServer)
2373
2423
  this.optionalDisabled.delete(key);
2374
2424
  }
2425
+ // #2064: did THIS caller start the language-server process, or did it
2426
+ // join a spawn another caller already had in flight? The answer is only
2427
+ // knowable here, before the await — downstream of `await spawnPromise`
2428
+ // the two are indistinguishable, which is exactly how the 3.0x
2429
+ // over-count happened. There are two join sites and both count as
2430
+ // joins: the unguarded read below, and the `raced` re-read inside the
2431
+ // spawn gate, which catches a caller that reached the gate before the
2432
+ // starter published its promise.
2433
+ let startedSpawn = false;
2375
2434
  let spawnPromise = this.state.inFlight.get(key);
2376
2435
  if (!spawnPromise) {
2377
2436
  const started = await this.withClientSpawnGate(async () => {
2378
2437
  const raced = this.state.inFlight.get(key);
2379
2438
  if (raced)
2380
- return { promise: raced };
2439
+ return { promise: raced, startedSpawn: false };
2381
2440
  // `server.root()` and dead-client cleanup above are async. A reset during
2382
2441
  // either gap must not let this retired generation start a late spawn.
2383
2442
  if (this.checkDestroyed())
@@ -2386,11 +2445,12 @@ export class LSPService {
2386
2445
  return undefined;
2387
2446
  const promise = this.spawnClient(server, root, key, filePath, allowInstall);
2388
2447
  this.state.inFlight.set(key, promise);
2389
- return { promise };
2448
+ return { promise, startedSpawn: true };
2390
2449
  });
2391
2450
  if (!started)
2392
2451
  return undefined;
2393
2452
  spawnPromise = started.promise;
2453
+ startedSpawn = started.startedSpawn;
2394
2454
  }
2395
2455
  // Announce the in-flight spawn so the caller can skip a doomed touch
2396
2456
  // wait. The announcement never returns a client and never
@@ -2410,18 +2470,41 @@ export class LSPService {
2410
2470
  // #1934: a client here cost a process WAIT, whether this caller
2411
2471
  // started the spawn or joined another caller's in-flight promise.
2412
2472
  // Either way the selection was not served from the warm pool.
2473
+ // #2064: which of the two it was is now named, so the record can
2474
+ // answer "how many processes started" as well as "how many
2475
+ // selections paid a wait". `startedSpawn` is captured before the
2476
+ // await, because after it the two are indistinguishable.
2413
2477
  //
2414
2478
  // The verdict read is synchronous and sits in the same microtask as
2415
2479
  // the await above, so it can only see the attempt just settled.
2416
2480
  onOutcome?.(spawned
2417
- ? "cold-spawn"
2481
+ ? startedSpawn
2482
+ ? "cold-spawn"
2483
+ : "cold-spawn-joined"
2418
2484
  : this.lastSpawnVerdict.get(key) === "failed"
2419
- ? "spawn-failure"
2485
+ ? startedSpawn
2486
+ ? "spawn-failure"
2487
+ : "spawn-failure-joined"
2420
2488
  : "declined");
2421
2489
  return spawned;
2422
2490
  }
2423
2491
  catch (err) {
2424
2492
  // A throwing spawn promise is an errored acquisition by definition.
2493
+ //
2494
+ // #2064 review F2: deliberately NOT split into a starter/joiner pair
2495
+ // like the resolve path above, because nothing could observe the
2496
+ // split. `spawnClient` never rethrows; it catches its own spawn and
2497
+ // initialize failures and resolves `undefined`. This catch is
2498
+ // therefore reachable only when `spawnClient` throws BEFORE its own
2499
+ // `try` (the trust probe, `logSessionStart`, `recordLsp`). A joiner
2500
+ // CAN read the already-rejected promise out of `inFlight` (the #2106
2501
+ // verify measured 2 invocations for 3 callers), but it does not
2502
+ // matter: the rethrow
2503
+ // below unwinds past every `lsp_client_selected` emit site, so this
2504
+ // value is written to no record: a probe drove a throwing trust
2505
+ // check through two concurrent callers and got two rejections and
2506
+ // zero selection records. An unobservable discriminator is a
2507
+ // vacuous guard, so this path keeps the single #1934 value.
2425
2508
  onOutcome?.("spawn-failure");
2426
2509
  throw err;
2427
2510
  }
@@ -2514,6 +2597,7 @@ export class LSPService {
2514
2597
  serverId: server.id,
2515
2598
  process: spawned.process,
2516
2599
  root,
2600
+ sessionCwd: this.sessionCwd,
2517
2601
  initialization: mergedInit,
2518
2602
  initializeTimeoutMs: server.initializeTimeoutMs,
2519
2603
  launchVariant: spawned.launchVariant,
@@ -2550,6 +2634,36 @@ export class LSPService {
2550
2634
  const spawnDurationMs = Date.now() - startedAt;
2551
2635
  recordLsp(server.id, root, "spawn_success", spawnDurationMs);
2552
2636
  recordSuccessfulLspSpawn(server.id, spawnDurationMs);
2637
+ // #2064: the only latency record that a language-server PROCESS
2638
+ // started. `lsp_launch_candidate_success` covers the servers that
2639
+ // launch through `resolveAndLaunch` and never fired for TypeScript,
2640
+ // which served 913 of 941 selections in the field window — so
2641
+ // nothing in `latency.log` counted the 29.3s TypeScript spawn at
2642
+ // all. This sits at `spawnClient`'s single success path, so every
2643
+ // server reports through one record instead of a per-server
2644
+ // launcher, and `count(serverId=typescript)` is answerable from
2645
+ // `latency.log` alone. Volume is bounded by process starts: one
2646
+ // record per spawn, and a spawn is single-flighted per
2647
+ // `serverId:root` by `state.inFlight`.
2648
+ //
2649
+ // The two path fields are deliberate and are not duplicates.
2650
+ // `filePath` carries the ROOT, because the root plus `serverId` is
2651
+ // the client's identity and the unit a spawn is single-flighted on.
2652
+ // `triggerFilePath` carries the file whose touch paid for the
2653
+ // spawn, which answers a different question and is the one that
2654
+ // varies across records for the same client.
2655
+ logLatency({
2656
+ type: "phase",
2657
+ phase: "lsp_server_spawned",
2658
+ filePath: root,
2659
+ durationMs: spawnDurationMs,
2660
+ metadata: {
2661
+ serverId: server.id,
2662
+ source: spawned.source ?? "unknown",
2663
+ launchVariant: spawned.launchVariant ?? "default",
2664
+ triggerFilePath: filePath,
2665
+ },
2666
+ });
2553
2667
  if (!this.workspaceProbeLogged.has(key)) {
2554
2668
  logSessionStart(`lsp workspace-diag probe ${server.id}: advertised=${wsDiag.advertised} mode=${wsDiag.mode} provider=${wsDiag.diagnosticProviderKind}`);
2555
2669
  this.workspaceProbeLogged.add(key);
@@ -2646,6 +2760,11 @@ export class LSPService {
2646
2760
  }
2647
2761
  const startedAt = Date.now();
2648
2762
  const normalizedPath = normalizeMapKey(filePath);
2763
+ const outsideRoot = await this.findOutsideProjectRoot(filePath);
2764
+ if (outsideRoot) {
2765
+ this.recordOutsideRootDecline(filePath, outsideRoot, "touchFile");
2766
+ return { diags: [], skipReason: "outside-project-root" };
2767
+ }
2649
2768
  // #1783: every path that asks a language server anything comes through
2650
2769
  // here, so this is where the disk-drift backstop gets its heartbeat.
2651
2770
  // Deliberately NOT awaited: the sweep is rate-limited to one pass per
@@ -2665,6 +2784,12 @@ export class LSPService {
2665
2784
  const useAllClients = clientScope === "all";
2666
2785
  const resolvedPrimaryRoots = new Map();
2667
2786
  const waitSkipReasons = new Set();
2787
+ const coldAuxiliaryServerIds = new Set();
2788
+ const noteColdAuxiliary = (serverId, outcome) => {
2789
+ if (outcome === "cold-spawn" || outcome === "cold-spawn-joined") {
2790
+ coldAuxiliaryServerIds.add(serverId);
2791
+ }
2792
+ };
2668
2793
  let spawned;
2669
2794
  let serverCountAttempted;
2670
2795
  if (useAllClients) {
@@ -2677,7 +2802,7 @@ export class LSPService {
2677
2802
  // (opengrep, …). The aggregation layer merges/dedups their diagnostics.
2678
2803
  const [entry, aux] = await Promise.all([
2679
2804
  this.getClientForFile(filePath, options.maxClientWaitMs, undefined, resolvedPrimaryRoots, waitSkipReasons),
2680
- this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? [])),
2805
+ this.getAuxiliaryClientsForFile(filePath, new Set(options.auxiliaryServerIds ?? []), noteColdAuxiliary),
2681
2806
  ]);
2682
2807
  spawned = entry ? [entry, ...aux] : aux;
2683
2808
  serverCountAttempted = spawned.length;
@@ -2780,6 +2905,10 @@ export class LSPService {
2780
2905
  // quietly reverting to the global counter, which is the defect itself.
2781
2906
  const readPathVersion = (client) => client.getDiagnosticsVersionForPath?.(filePath);
2782
2907
  const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, readPathVersion(entry.client)]));
2908
+ // #2161: anchor publication evidence before this touch's notify. A
2909
+ // later per-file cache entry, including an empty one, proves that the
2910
+ // primary answered; an empty diagnostics result alone cannot.
2911
+ const markedAtMs = Date.now();
2783
2912
  // #1458: read a late auxiliary publication BEFORE the ordinary resync
2784
2913
  // clears its client cache. Carry it only when the publication's exact
2785
2914
  // sent-content fingerprint matches this touch's content. A changed edit,
@@ -3327,7 +3456,25 @@ export class LSPService {
3327
3456
  // Fail-open: missing capability state keeps today's push fallback.
3328
3457
  }
3329
3458
  }
3330
- const perServerWaits = spawned.map((entry) => {
3459
+ const configuredAuxCeilingMs = readEnvAuxGraceMs();
3460
+ const perServerDeclaredTimeouts = spawned.map((entry) => timeoutFor(entry.client.serverId));
3461
+ const perServerWaitTimeouts = spawned.map((entry, entryIndex) => {
3462
+ const declaredServerTimeout = perServerDeclaredTimeouts[entryIndex];
3463
+ const observedSpawnMs = getSuccessfulLspSpawnDurationMs(entry.client.serverId);
3464
+ return hasTouchAuxiliaries &&
3465
+ entry.info.role === "auxiliary" &&
3466
+ coldAuxiliaryServerIds.has(entry.client.serverId) &&
3467
+ (configuredAuxCeilingMs !== undefined ||
3468
+ (observedSpawnMs !== undefined && observedSpawnMs > 0))
3469
+ ? auxWaitBudgetMs(entry.client.serverId, true, configuredAuxCeilingMs, declaredServerTimeout)
3470
+ : perServerDeclaredTimeouts[entryIndex];
3471
+ });
3472
+ const perServerRaceBudgets = spawned.map((entry, entryIndex) => {
3473
+ return hasTouchAuxiliaries && entry.info.role === "auxiliary"
3474
+ ? auxWaitBudgetMs(entry.client.serverId, coldAuxiliaryServerIds.has(entry.client.serverId), configuredAuxCeilingMs, perServerDeclaredTimeouts[entryIndex])
3475
+ : perServerDeclaredTimeouts[entryIndex];
3476
+ });
3477
+ const perServerWaits = spawned.map((entry, entryIndex) => {
3331
3478
  // #1459: a DEFERRED server never received this content, so its version
3332
3479
  // can never advance past the baseline — waiting on it burns its whole
3333
3480
  // budget and would flip the touch to `inconclusive`, discarding a
@@ -3336,7 +3483,7 @@ export class LSPService {
3336
3483
  if (deferredResyncServerIds.has(entry.info.id)) {
3337
3484
  return Promise.resolve(undefined);
3338
3485
  }
3339
- const serverTimeout = timeoutFor(entry.client.serverId);
3486
+ const serverTimeout = perServerWaitTimeouts[entryIndex];
3340
3487
  // #1531: a per-path baseline. `clientWaitForDiagnostics` compares it
3341
3488
  // against this path's own publication stamp, so a sibling file's
3342
3489
  // publication on a shared client can no longer end this wait before the
@@ -3395,14 +3542,13 @@ export class LSPService {
3395
3542
  serverId: spawned[i].info.id,
3396
3543
  client: spawned[i].client,
3397
3544
  baseline: diagnosticBaselines.get(spawned[i].client),
3545
+ budgetMs: perServerRaceBudgets[i],
3398
3546
  }
3399
3547
  : null)
3400
3548
  .filter((x) => x !== null);
3401
- const auxCeilingMs = readEnvAuxGraceMs() ?? 2000;
3402
- // After all primaries settle, give each auxiliary the smaller of
3403
- // its declared wait budget and the global auxiliary ceiling. The
3404
- // 2000ms default admits measured ~1.3s warm scanner runs without
3405
- // making every edit pay opengrep's 3500ms cold-start allowance.
3549
+ // After all primaries settle, use the same per-auxiliary budget
3550
+ // that bounded its own diagnostic wait. Warm acquisitions retain
3551
+ // the 2000ms ceiling; cold acquisitions include observed startup.
3406
3552
  // Late aux results are dropped from this wait. A later unchanged-
3407
3553
  // content read may carry a SHA-256-bound cache publication before its
3408
3554
  // resync clears the cache; changed or unknown content never replays.
@@ -3415,7 +3561,7 @@ export class LSPService {
3415
3561
  return;
3416
3562
  const auxWaitStartedAt = Date.now();
3417
3563
  const outcomes = await Promise.all(auxWaits.map(async (aux) => {
3418
- const budgetMs = Math.min(timeoutFor(aux.serverId), auxCeilingMs);
3564
+ const { budgetMs } = aux;
3419
3565
  let timer;
3420
3566
  const timeout = new Promise((resolve) => {
3421
3567
  timer = setTimeout(() => resolve(false), budgetMs);
@@ -3515,7 +3661,24 @@ export class LSPService {
3515
3661
  // revision and probing it would replay stale findings.
3516
3662
  const collectLaterServerIds = outcomes
3517
3663
  .filter((o) => !o.publishedThisContent &&
3518
- (o.outcome === "cut_off" || o.outcome === "silent"))
3664
+ (o.outcome === "cut_off" || o.outcome === "silent") &&
3665
+ // #2324 R2-A/R3-A: ast-grep's napi fallback is a
3666
+ // SECOND producer of coverage for this exact pair,
3667
+ // dispatched CONCURRENTLY with this whole touch
3668
+ // (dispatcher.ts's Promise.all groups) — not just
3669
+ // concurrently with this wait. On a COLD touch,
3670
+ // getClientsForFile's spawn+handshake (above, before
3671
+ // waitStartedAt is ever captured) can itself take
3672
+ // long enough that napi's near-instant Gate-B check
3673
+ // records coverage BEFORE waitStartedAt — the
3674
+ // issue's own 68ms race shape. Baseline on startedAt
3675
+ // instead: stamped at touchFile's own entry, before
3676
+ // ANY spawn work, so it is the earliest instant this
3677
+ // touch's own napi run could possibly predate. Still
3678
+ // excludes a stale record from an EARLIER touch,
3679
+ // which is all the staleness guard needs.
3680
+ !(o.serverId === "ast-grep" &&
3681
+ napiFallbackCoveredSince(filePath, startedAt)))
3519
3682
  .map((o) => o.serverId);
3520
3683
  if (collectLaterServerIds.length > 0) {
3521
3684
  markPendingAuxiliaryCoverage(filePath, collectLaterServerIds);
@@ -3576,8 +3739,11 @@ export class LSPService {
3576
3739
  // Push already answered (settled, or published diagnostics that
3577
3740
  // its wait is about to settle on) — nothing to confirm, no sync
3578
3741
  // request goes out.
3742
+ const publishedAt = primaryClient
3743
+ .getAllDiagnostics?.()
3744
+ .get(normalizedPath)?.ts;
3579
3745
  if (pushWaitSettled ||
3580
- primaryClient.getDiagnostics(filePath).length > 0) {
3746
+ (publishedAt !== undefined && publishedAt > markedAtMs)) {
3581
3747
  return new Promise(() => { });
3582
3748
  }
3583
3749
  try {
@@ -4492,6 +4658,49 @@ export class LSPService {
4492
4658
  this.releaseClientLease(key);
4493
4659
  }
4494
4660
  }
4661
+ /**
4662
+ * #2052: the file's nearest marker root, but ONLY when the file is outside
4663
+ * every initialized session cwd. Returns undefined (no decline) otherwise.
4664
+ *
4665
+ * The cheap registry test runs FIRST so an in-session file — the hot path —
4666
+ * pays one bounded map walk and never the `server.root()` filesystem walks.
4667
+ */
4668
+ async findOutsideProjectRoot(filePath) {
4669
+ if (!isOutsideAllSessionRoots(filePath))
4670
+ return undefined;
4671
+ const candidates = await Promise.all(getServersForFileWithConfig(filePath).map((server) => server.root(filePath)));
4672
+ return candidates.find((candidate) => Boolean(candidate));
4673
+ }
4674
+ /**
4675
+ * #2052: one bounded record per normalized foreign root per session.
4676
+ *
4677
+ * `operation` is a PARAMETER, not a constant: `touchFile` and
4678
+ * `getDiagnostics` both decline here, and a record that always claimed
4679
+ * "touchFile" would erase which call site actually refused — the
4680
+ * discriminating identity a bounded record has to preserve. The ledger's
4681
+ * rising edge is keyed on the root, so many files under one foreign root
4682
+ * still collapse to a single detailed row.
4683
+ */
4684
+ recordOutsideRootDecline(filePath, nearestRoot, operation) {
4685
+ const root = normalizeMapKey(nearestRoot);
4686
+ const sessionRoots = getSessionRootsForTelemetry()
4687
+ .map((sessionRoot) => normalizeMapKey(sessionRoot))
4688
+ .join(", ");
4689
+ emitBounded("lsp_capability_skip", root, {
4690
+ filePath: normalizeMapKey(filePath),
4691
+ durationMs: 0,
4692
+ metadata: {
4693
+ operation,
4694
+ nearestMarkerRoot: root,
4695
+ sessionRoots,
4696
+ identity: root,
4697
+ },
4698
+ }, {
4699
+ ledgerKind: "lsp-capability-skip",
4700
+ risingEdgePer: "identity",
4701
+ reason: `declined LSP root outside session cwd: ${root}`,
4702
+ });
4703
+ }
4495
4704
  /**
4496
4705
  * Get diagnostics for a file
4497
4706
  */
@@ -4527,6 +4736,11 @@ export class LSPService {
4527
4736
  }
4528
4737
  async getDiagnostics(filePath, diagnosticsMode = "full") {
4529
4738
  const normalizedPath = normalizeMapKey(filePath);
4739
+ const outsideRoot = await this.findOutsideProjectRoot(filePath);
4740
+ if (outsideRoot) {
4741
+ this.recordOutsideRootDecline(filePath, outsideRoot, "getDiagnostics");
4742
+ return [];
4743
+ }
4530
4744
  if (this.checkDestroyed()) {
4531
4745
  this.lastDiagnosticsHealth.set(normalizedPath, {
4532
4746
  health: "destroyed",
@@ -4972,7 +5186,7 @@ export class LSPService {
4972
5186
  * `executeCommand` above cannot give a render-path probe:
4973
5187
  *
4974
5188
  * - **Never spawns.** It resolves the already-connected client map the way
4975
- * `isServerAliveForFile` does, instead of routing through
5189
+ * other read-only client-map lookups do, instead of routing through
4976
5190
  * `getClientForFile` → `ensureClientForServer`. A probe that spawns a
4977
5191
  * language-server fleet to answer a question about how to RENDER a
4978
5192
  * diagnostic is a cost the caller never asked for — and under warm attach
@@ -5180,7 +5394,21 @@ export class LSPService {
5180
5394
  const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
5181
5395
  const willRenameFailures = [];
5182
5396
  const didRenameFailures = [];
5183
- const willResults = await Promise.all(activeClients.map(async ({ serverId, client }) => {
5397
+ const willRenameClients = activeClients.filter(({ serverId, client }) => {
5398
+ if (client.getOperationSupport().willRenameFiles === true)
5399
+ return true;
5400
+ recordDegradationOnce({
5401
+ kind: "lsp-capability-skip",
5402
+ subject: `${serverId}:workspace/willRenameFiles`,
5403
+ reason: client
5404
+ .getMalformedFileOperationRegistrations()
5405
+ .has("willRename")
5406
+ ? "malformed-registration"
5407
+ : "no-registration",
5408
+ });
5409
+ return false;
5410
+ });
5411
+ const willResults = await Promise.all(willRenameClients.map(async ({ serverId, client }) => {
5184
5412
  try {
5185
5413
  return {
5186
5414
  serverId,
@@ -5196,7 +5424,7 @@ export class LSPService {
5196
5424
  }
5197
5425
  }));
5198
5426
  const successfulWillResults = willResults.filter((result) => !willRenameFailures.some((failure) => failure.serverId === result.serverId));
5199
- if (activeClients.length > 0 && successfulWillResults.length === 0) {
5427
+ if (willRenameClients.length > 0 && successfulWillResults.length === 0) {
5200
5428
  throw new Error(`workspace/willRenameFiles failed for all active LSP servers: ${willRenameFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
5201
5429
  }
5202
5430
  const merged = mergeWorkspaceTextEditsByPriority(successfulWillResults);
@@ -5343,6 +5571,21 @@ export class LSPService {
5343
5571
  path.basename(newFilePath);
5344
5572
  const renameDescription = `Renamed ${relOld} → ${relNew}`;
5345
5573
  await Promise.all(activeClients.map(async ({ serverId, client }) => {
5574
+ // #1971 review: didRename has its own registration. A server that
5575
+ // never asked for didRenameFiles notifications must not receive
5576
+ // them — same chokepoint discipline as the willRename preflight.
5577
+ if (client.getOperationSupport().didRenameFiles !== true) {
5578
+ recordDegradationOnce({
5579
+ kind: "lsp-capability-skip",
5580
+ subject: `${serverId}:workspace/didRenameFiles`,
5581
+ reason: client
5582
+ .getMalformedFileOperationRegistrations()
5583
+ .has("didRename")
5584
+ ? "malformed-registration"
5585
+ : "no-registration",
5586
+ });
5587
+ return;
5588
+ }
5346
5589
  const opened = openDocuments.find((entry) => entry.serverId === serverId);
5347
5590
  // #1621: bounded for the same reason as the didClose notify above —
5348
5591
  // rename propagation is best-effort advice to servers, not a
@@ -5828,6 +6071,10 @@ export class LSPService {
5828
6071
  // mtime it was ACTUALLY scanned at (not re-stat'd after the fact, which
5829
6072
  // could race a concurrent edit).
5830
6073
  const scannedMtimeByFile = new Map();
6074
+ // #2300: size from the SAME stat call as the mtime above — zero extra
6075
+ // syscalls — so the cache write below can give `isEntryFresh` a size
6076
+ // axis alongside the mtime.
6077
+ const scannedSizeByFile = new Map();
5831
6078
  // Group files by their primary language server (#387, extracted as
5832
6079
  // `groupFilesByPrimaryServer` for #631). tsserver — and most servers — is
5833
6080
  // single-threaded per project: N concurrent touches to ONE server don't
@@ -6018,7 +6265,9 @@ export class LSPService {
6018
6265
  // microseconds with no extra event-loop tick, where an awaited
6019
6266
  // promise would insert one.
6020
6267
  try {
6021
- scannedMtimeByFile.set(filePath, nodeFs.statSync(filePath).mtimeMs);
6268
+ const scanStat = nodeFs.statSync(filePath);
6269
+ scannedMtimeByFile.set(filePath, scanStat.mtimeMs);
6270
+ scannedSizeByFile.set(filePath, scanStat.size);
6022
6271
  }
6023
6272
  catch {
6024
6273
  // Best-effort: a failed stat here just means this file won't be
@@ -6091,20 +6340,35 @@ export class LSPService {
6091
6340
  // instead of aggregating as a confirmed clean. Every route is gated here.
6092
6341
  const unconfirmedServerIds = touchCoverageGap(touchResult);
6093
6342
  const coverageGap = unconfirmedServerIds.length > 0;
6343
+ // #2052: the touch DECLINED this file — it is outside every
6344
+ // initialized session cwd, so nothing was asked and `diags` is an
6345
+ // empty placeholder, not an observation. Before this, the sweep
6346
+ // never read `skipReason`, so a declined foreign file fell through
6347
+ // as `timedOut: false` with zero diagnostics: reported CONFIRMED
6348
+ // CLEAN, and then written into the workspace cache by the record
6349
+ // loop below, which replays that false clean on every later sweep.
6350
+ const declinedOutsideRoot = touchResult?.skipReason === "outside-project-root";
6094
6351
  // #1549: the sweep verdict is per answering lane. An auxiliary gap
6095
6352
  // narrows coverage, but a primary answer remains usable; only absence of
6096
6353
  // the touch result or a primary-scoped inconclusive verdict poisons it.
6097
- const timedOut = touchResult === undefined || inconclusive;
6354
+ const timedOut = touchResult === undefined || inconclusive || declinedOutsideRoot;
6098
6355
  if (timedOut)
6099
6356
  timedOutFiles += 1;
6100
6357
  // #1618: WHY, in priority order — the outer deadline (nothing came
6101
6358
  // back at all) outranks an inner inconclusive signal, which outranks
6102
6359
  // a narrower auxiliary coverage gap.
6360
+ // #2052: `outside_project_root` is checked FIRST among the reasons a
6361
+ // touch produced no observation. A declined file cannot be
6362
+ // `inconclusive` (nothing was asked, so nothing timed out), and
6363
+ // rendering it as "didn't complete within budget" would tell the
6364
+ // reader to retry a request that will always be refused.
6103
6365
  const unconfirmedReason = touchResult === undefined
6104
6366
  ? "budget"
6105
- : inconclusive
6106
- ? "inconclusive"
6107
- : undefined;
6367
+ : declinedOutsideRoot
6368
+ ? "outside_project_root"
6369
+ : inconclusive
6370
+ ? "inconclusive"
6371
+ : undefined;
6108
6372
  // #1104 (shape 5 — AGENTS.md): the touch's content binding is an
6109
6373
  // EXPLICIT enumerable field on the wrapper now (#1179), so it survives
6110
6374
  // `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
@@ -6187,140 +6451,177 @@ export class LSPService {
6187
6451
  // is the same primitive `tools/lsp-diagnostics.ts`'s batch/directory scan
6188
6452
  // now uses for its own file list.
6189
6453
  const groupWorkers = Math.min(WORKSPACE_DIAGNOSTICS_CONCURRENCY, groups.length);
6190
- await runPerServerGroups(groups, groupWorkers, async (group) => {
6191
- if (signal?.aborted)
6192
- return;
6193
- // #1618: checked before any per-group work (pull attempt, warm-up,
6194
- // per-file loop) so a service already destroyed when this group
6195
- // starts never pays for a language-server round trip it cannot get.
6196
- if (this.checkDestroyed()) {
6197
- markServiceDestroyed(group.files);
6198
- return;
6199
- }
6200
- // Fast path: one project-wide pull for the whole group (opt-in).
6201
- if (!isWarmAttached() && workspacePullEnabled && !group.multiServer) {
6202
- const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
6203
- if (pulled) {
6204
- // #1782: an explicit zero-diagnostic answer for a file this
6205
- // sweep served from cache SUPERSEDES that cached replay. Route
6206
- // it through the same result list every other answer uses, so
6207
- // the cache write below overwrites the stale entry and the
6208
- // footer reconcile in `tools/lens-diagnostics.ts` clears the
6209
- // widget rows — no second eviction path to keep in step.
6210
- for (const clean of pulled.extraClean) {
6211
- supersededCacheKeys.add(normalizeMapKey(clean.filePath));
6212
- }
6213
- for (const result of [...pulled.results, ...pulled.extraClean]) {
6214
- results.push({
6215
- ...result,
6216
- writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
6217
- });
6218
- // #671: a pull result is always confirmed (see
6219
- // `tryWorkspacePull`'s doc comment), so it's cache-eligible
6220
- // too — best-effort stat since the pull already resolved the
6221
- // diagnostics for this file some time ago.
6222
- try {
6223
- scannedMtimeByFile.set(result.filePath, nodeFs.statSync(result.filePath).mtimeMs);
6454
+ // #1723 residual, named as a deferred gap on PR #1805 and closed here:
6455
+ // the SCAN-side twin of the runner chokepoint #1805 wired in
6456
+ // `dispatcher.ts`'s `runRunner`. This sweep is the site whose COMPLETION
6457
+ // (`lsp_workspace_diagnostics`) is the `lastPhase` on #1723's own
6458
+ // 18 270 ms reproduction, so until this bracket existed the record could
6459
+ // only ever name the phase that finished BEFORE the block, never the one
6460
+ // burning the time during it.
6461
+ //
6462
+ // ONE bracket for the whole fan-out, not one per file (#2272 review F1).
6463
+ // Per-file bracketing was the first shape and it was inert for exactly
6464
+ // the case above: `phaseFinished` pushes onto a ring capped at
6465
+ // `CLOSED_BRACKET_CAP` (5, clients/latency-logger.ts), while `turn_end`
6466
+ // reads `getPhaseForWindow` AFTER the sweep returns. A 225-file sweep
6467
+ // therefore evicted the blocking file's own bracket unless the block
6468
+ // landed in the last five files, and the survivors were then rejected by
6469
+ // `MIN_PLAUSIBLE_ELAPSED_FRACTION` for being far shorter than the block
6470
+ // window. The phase string is a constant carrying no per-file identity,
6471
+ // so 225 brackets never held more information than one — they only cost
6472
+ // 225 map inserts and destroyed the ring. One bracket keeps the same
6473
+ // discriminating identity, survives to the read point, and spans the
6474
+ // block by construction.
6475
+ //
6476
+ // Paired in `finally`, per `phaseFinished`'s contract: an abandoned
6477
+ // bracket would misattribute every LATER loop_block to this sweep. The
6478
+ // leak guard is what the tests pin: emptying this `finally` reds three
6479
+ // of them. `finally` rather than a trailing call covers a throw that
6480
+ // escapes the fan-out; abort and service-destroyed both RETURN from the
6481
+ // worker callback rather than throwing, so those two paths alone would
6482
+ // not distinguish the two spellings.
6483
+ const sweepPhase = phaseStarted("lsp_workspace_diagnostics_touch");
6484
+ try {
6485
+ await runPerServerGroups(groups, groupWorkers, async (group) => {
6486
+ if (signal?.aborted)
6487
+ return;
6488
+ // #1618: checked before any per-group work (pull attempt, warm-up,
6489
+ // per-file loop) so a service already destroyed when this group
6490
+ // starts never pays for a language-server round trip it cannot get.
6491
+ if (this.checkDestroyed()) {
6492
+ markServiceDestroyed(group.files);
6493
+ return;
6494
+ }
6495
+ // Fast path: one project-wide pull for the whole group (opt-in).
6496
+ if (!isWarmAttached() && workspacePullEnabled && !group.multiServer) {
6497
+ const pulled = await this.tryWorkspacePull(group.files, perFileMs, cacheServedKeys);
6498
+ if (pulled) {
6499
+ // #1782: an explicit zero-diagnostic answer for a file this
6500
+ // sweep served from cache SUPERSEDES that cached replay. Route
6501
+ // it through the same result list every other answer uses, so
6502
+ // the cache write below overwrites the stale entry and the
6503
+ // footer reconcile in `tools/lens-diagnostics.ts` clears the
6504
+ // widget rows — no second eviction path to keep in step.
6505
+ for (const clean of pulled.extraClean) {
6506
+ supersededCacheKeys.add(normalizeMapKey(clean.filePath));
6224
6507
  }
6225
- catch {
6226
- // Not cache-eligible without a confirmed mtime.
6508
+ for (const result of [...pulled.results, ...pulled.extraClean]) {
6509
+ results.push({
6510
+ ...result,
6511
+ writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
6512
+ });
6513
+ // #671: a pull result is always confirmed (see
6514
+ // `tryWorkspacePull`'s doc comment), so it's cache-eligible
6515
+ // too — best-effort stat since the pull already resolved the
6516
+ // diagnostics for this file some time ago.
6517
+ try {
6518
+ const pullStat = nodeFs.statSync(result.filePath);
6519
+ scannedMtimeByFile.set(result.filePath, pullStat.mtimeMs);
6520
+ scannedSizeByFile.set(result.filePath, pullStat.size);
6521
+ }
6522
+ catch {
6523
+ // Not cache-eligible without a confirmed mtime.
6524
+ }
6227
6525
  }
6526
+ completed += group.files.length;
6527
+ options.onProgress?.(completed, files.length);
6528
+ return;
6228
6529
  }
6229
- completed += group.files.length;
6230
- options.onProgress?.(completed, files.length);
6231
- return;
6232
6530
  }
6233
- }
6234
- // #667: warm-check before this group's own per-file loop starts —
6235
- // cheap/no-op when the group's primary server already demonstrated
6236
- // readiness (from an earlier sweep, or an earlier group sharing the
6237
- // same server root, this session); pays one deliberate warm-up round
6238
- // trip against the group's first file only when genuinely cold. Not
6239
- // needed above the pull fast path: a `workspace/diagnostic` pull
6240
- // already covers the WHOLE group with its own generous per-server
6241
- // budget in one shot — the per-file "first N files eat individual
6242
- // timeouts" failure mode this fixes doesn't apply there.
6243
- const first = group.files[0];
6244
- if (first && !isWarmAttached()) {
6245
- const warmup = await this.ensureWarmForSweep(first, { signal });
6246
- if (signal?.aborted)
6247
- return;
6248
- // #744: the group's primary server failed warm-up (initial round
6249
- // trip + one retry both left it cold). Every per-file touch to it
6250
- // would re-pay its full timeout and time out again, dragging the
6251
- // whole sweep — the exact wedged-marksman failure mode this closes.
6252
- // So skip this group's files and record each as UNCONFIRMED
6253
- // (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
6254
- // the group is keyed by its primary server, so a non-empty
6255
- // `failedServerIds` means that primary is the one that couldn't warm.
6256
- if (warmup.failedServerIds.length > 0) {
6257
- logLatency({
6258
- type: "phase",
6259
- phase: "lsp_sweep_group_skipped_warmup",
6260
- filePath: first,
6261
- durationMs: 0,
6262
- metadata: {
6263
- failedServerIds: warmup.failedServerIds,
6264
- // #799: distinguishes a fresh warm-up failure from a
6265
- // negative-cache hit (this sweep never re-attempted warm-up
6266
- // at all — it was already known cold from earlier this
6267
- // session).
6268
- skippedFromCache: warmup.skippedFromCache ?? false,
6269
- skippedFiles: group.files.length,
6270
- },
6271
- });
6272
- for (const filePath of group.files) {
6273
- results.push({
6274
- filePath,
6275
- diagnostics: [],
6276
- count: 0,
6277
- timedOut: true,
6278
- unconfirmedReason: "inconclusive",
6279
- skippedWarmupFailure: true,
6531
+ // #667: warm-check before this group's own per-file loop starts —
6532
+ // cheap/no-op when the group's primary server already demonstrated
6533
+ // readiness (from an earlier sweep, or an earlier group sharing the
6534
+ // same server root, this session); pays one deliberate warm-up round
6535
+ // trip against the group's first file only when genuinely cold. Not
6536
+ // needed above the pull fast path: a `workspace/diagnostic` pull
6537
+ // already covers the WHOLE group with its own generous per-server
6538
+ // budget in one shot — the per-file "first N files eat individual
6539
+ // timeouts" failure mode this fixes doesn't apply there.
6540
+ const first = group.files[0];
6541
+ if (first && !isWarmAttached()) {
6542
+ const warmup = await this.ensureWarmForSweep(first, { signal });
6543
+ if (signal?.aborted)
6544
+ return;
6545
+ // #744: the group's primary server failed warm-up (initial round
6546
+ // trip + one retry both left it cold). Every per-file touch to it
6547
+ // would re-pay its full timeout and time out again, dragging the
6548
+ // whole sweep — the exact wedged-marksman failure mode this closes.
6549
+ // So skip this group's files and record each as UNCONFIRMED
6550
+ // (timedOut + skippedWarmupFailure), never as confirmed-clean `[]`:
6551
+ // the group is keyed by its primary server, so a non-empty
6552
+ // `failedServerIds` means that primary is the one that couldn't warm.
6553
+ if (warmup.failedServerIds.length > 0) {
6554
+ logLatency({
6555
+ type: "phase",
6556
+ phase: "lsp_sweep_group_skipped_warmup",
6557
+ filePath: first,
6558
+ durationMs: 0,
6559
+ metadata: {
6560
+ failedServerIds: warmup.failedServerIds,
6561
+ // #799: distinguishes a fresh warm-up failure from a
6562
+ // negative-cache hit (this sweep never re-attempted warm-up
6563
+ // at all — it was already known cold from earlier this
6564
+ // session).
6565
+ skippedFromCache: warmup.skippedFromCache ?? false,
6566
+ skippedFiles: group.files.length,
6567
+ },
6280
6568
  });
6281
- timedOutFiles += 1;
6282
- completed += 1;
6569
+ for (const filePath of group.files) {
6570
+ results.push({
6571
+ filePath,
6572
+ diagnostics: [],
6573
+ count: 0,
6574
+ timedOut: true,
6575
+ unconfirmedReason: "inconclusive",
6576
+ skippedWarmupFailure: true,
6577
+ });
6578
+ timedOutFiles += 1;
6579
+ completed += 1;
6580
+ }
6581
+ options.onProgress?.(completed, files.length);
6582
+ return;
6283
6583
  }
6284
- options.onProgress?.(completed, files.length);
6285
- return;
6584
+ if (warmup.performedWarmup)
6585
+ options.onServerReady?.();
6286
6586
  }
6287
- if (warmup.performedWarmup)
6288
- options.onServerReady?.();
6289
- }
6290
- // #608/#621: batch-open a CHUNK of this group's files before
6291
- // waiting on diagnostics for any of them individually — see
6292
- // `preOpenGroupFiles` above. Chunking (rather than the whole
6293
- // group at once) bounds how much a single burst can dump on
6294
- // the server's request queue at real project scale, while each
6295
- // chunk's opens still land inside the debounce window and
6296
- // coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
6297
- for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
6298
- if (signal?.aborted)
6299
- return;
6300
- // #1618: a service destroyed WHILE this group's chunk loop was
6301
- // already running (the group-start check above can't see a
6302
- // destruction that lands mid-loop) — stop here and mark every
6303
- // file from this chunk onward, rather than letting the remaining
6304
- // chunks pay for pre-opens/touches against a torn-down service.
6305
- if (this.checkDestroyed()) {
6306
- markServiceDestroyed(group.files.slice(chunkStart));
6307
- return;
6308
- }
6309
- const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
6310
- await preOpenGroupFiles(chunk);
6311
- for (const filePath of chunk) {
6312
- // Honor cancellation between files (#341); already-collected
6313
- // results are returned as a partial.
6587
+ // #608/#621: batch-open a CHUNK of this group's files before
6588
+ // waiting on diagnostics for any of them individually — see
6589
+ // `preOpenGroupFiles` above. Chunking (rather than the whole
6590
+ // group at once) bounds how much a single burst can dump on
6591
+ // the server's request queue at real project scale, while each
6592
+ // chunk's opens still land inside the debounce window and
6593
+ // coalesce into one flush — see `WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE`.
6594
+ for (let chunkStart = 0; chunkStart < group.files.length; chunkStart += WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE) {
6314
6595
  if (signal?.aborted)
6315
6596
  return;
6597
+ // #1618: a service destroyed WHILE this group's chunk loop was
6598
+ // already running (the group-start check above can't see a
6599
+ // destruction that lands mid-loop) — stop here and mark every
6600
+ // file from this chunk onward, rather than letting the remaining
6601
+ // chunks pay for pre-opens/touches against a torn-down service.
6316
6602
  if (this.checkDestroyed()) {
6317
- markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
6603
+ markServiceDestroyed(group.files.slice(chunkStart));
6318
6604
  return;
6319
6605
  }
6320
- await processFile(filePath);
6606
+ const chunk = group.files.slice(chunkStart, chunkStart + WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE);
6607
+ await preOpenGroupFiles(chunk);
6608
+ for (const filePath of chunk) {
6609
+ // Honor cancellation between files (#341); already-collected
6610
+ // results are returned as a partial.
6611
+ if (signal?.aborted)
6612
+ return;
6613
+ if (this.checkDestroyed()) {
6614
+ markServiceDestroyed(group.files.slice(group.files.indexOf(filePath)));
6615
+ return;
6616
+ }
6617
+ await processFile(filePath);
6618
+ }
6321
6619
  }
6322
- }
6323
- }, signal);
6620
+ }, signal);
6621
+ }
6622
+ finally {
6623
+ phaseFinished(sweepPhase);
6624
+ }
6324
6625
  // #1618: per-reason tally alongside the flat `timedOutFiles` count (kept
6325
6626
  // for the existing `scripts/analyze-pi-lens-logs.mjs` consumer) — a
6326
6627
  // dashboard reading only the flat count can no longer mistake 81
@@ -6386,7 +6687,7 @@ export class LSPService {
6386
6687
  // boundToCurrentDisk !== false`) could never demote a pull-cached
6387
6688
  // entry. Absent `contentHash` (no binding was available) behaves
6388
6689
  // exactly as before.
6389
- workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
6690
+ workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash, scannedSizeByFile.get(result.filePath));
6390
6691
  }
6391
6692
  workspaceDiagnosticsCacheCtx.persist();
6392
6693
  // #1782: drop every cached replay a pull answered clean — returning both
@@ -6831,27 +7132,52 @@ export class LSPService {
6831
7132
  return [...primary, ...aux];
6832
7133
  }
6833
7134
  }
6834
- // --- Singleton Instance ---
6835
- let globalLSPService = null;
7135
+ const LSP_PROCESS_FAMILY = "lsp.service";
7136
+ const LSP_PROCESS_VERSION = 1;
7137
+ function lspProcessState() {
7138
+ let incompatibleHandoff;
7139
+ return getProcessSingleton(LSP_PROCESS_FAMILY, LSP_PROCESS_VERSION, () => ({ service: null, generationHandoff: incompatibleHandoff }), (value) => {
7140
+ // Shut down a live incompatible service before replacing its cell.
7141
+ if (!value || typeof value !== "object")
7142
+ return;
7143
+ const candidate = value;
7144
+ const previousHandoff = candidate.generationHandoff;
7145
+ const teardown = candidate.service?.shutdown
7146
+ ? candidate.service
7147
+ .shutdown({ fast: true, reason: "process_singleton_reset" })
7148
+ .catch(() => undefined)
7149
+ : undefined;
7150
+ if (previousHandoff && teardown) {
7151
+ incompatibleHandoff = Promise.allSettled([
7152
+ previousHandoff,
7153
+ teardown,
7154
+ ]).then(() => undefined);
7155
+ }
7156
+ else {
7157
+ incompatibleHandoff = previousHandoff ?? teardown;
7158
+ }
7159
+ });
7160
+ }
7161
+ function processService() {
7162
+ const state = lspProcessState();
7163
+ if (!state.service)
7164
+ state.service = new LSPService(state.generationHandoff, process.cwd());
7165
+ return state.service;
7166
+ }
6836
7167
  /**
6837
7168
  * #850: all singleton generations whose teardown is still pending. A new
6838
7169
  * generation may be allocated synchronously, but its first spawn waits on this
6839
7170
  * handoff so two generations can never own the same server/root concurrently.
6840
7171
  */
6841
- let globalLSPGenerationHandoff;
6842
7172
  export function getLSPService() {
6843
- if (!globalLSPService) {
6844
- globalLSPService = new LSPService(globalLSPGenerationHandoff);
6845
- }
6846
- return globalLSPService;
7173
+ return processService();
6847
7174
  }
6848
7175
  /**
6849
- * Cross-layer liveness seam for dispatch-side auxiliary gates. This is a
6850
- * liveness read only: it does not spawn, wait for initialization, or probe a
6851
- * binary.
7176
+ * Gate-B readiness seam. It reads only the live client map and never spawns,
7177
+ * waits for initialization, or probes a binary.
6852
7178
  */
6853
- export async function isAuxiliaryLspAlive(serverId, filePath) {
6854
- return getLSPService().isServerAliveForFile(serverId, filePath);
7179
+ export async function hasAuxiliaryLspPublishedForRoot(serverId, filePath) {
7180
+ return getLSPService().hasServerPublishedForFileRoot(serverId, filePath);
6855
7181
  }
6856
7182
  /**
6857
7183
  * Cross-layer seam (#1668) for callers outside `lsp/` (bash/write tool-result
@@ -6874,8 +7200,9 @@ export function resetLSPService(options = {}) {
6874
7200
  // hide behind a leaked/stuck guard from the generation before it.
6875
7201
  clearWorkspaceSweepHoldForSessionStart();
6876
7202
  }
6877
- const retiringService = globalLSPService;
6878
- globalLSPService = null;
7203
+ const state = lspProcessState();
7204
+ const retiringService = state.service;
7205
+ state.service = null;
6879
7206
  if (!retiringService)
6880
7207
  return;
6881
7208
  // shutdown() marks the service destroyed synchronously before its first
@@ -6884,14 +7211,14 @@ export function resetLSPService(options = {}) {
6884
7211
  // its predecessor. allSettled keeps teardown best-effort without ever
6885
7212
  // rejecting (and therefore permanently poisoning) the next generation.
6886
7213
  const teardown = retiringService.shutdown(options);
6887
- const pending = globalLSPGenerationHandoff
6888
- ? [globalLSPGenerationHandoff, teardown]
7214
+ const pending = state.generationHandoff
7215
+ ? [state.generationHandoff, teardown]
6889
7216
  : [teardown];
6890
7217
  const handoff = Promise.allSettled(pending).then(() => undefined);
6891
- globalLSPGenerationHandoff = handoff;
7218
+ state.generationHandoff = handoff;
6892
7219
  void handoff.then(() => {
6893
- if (globalLSPGenerationHandoff === handoff) {
6894
- globalLSPGenerationHandoff = undefined;
7220
+ if (state.generationHandoff === handoff) {
7221
+ state.generationHandoff = undefined;
6895
7222
  }
6896
7223
  });
6897
7224
  }