@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
@@ -9,19 +9,22 @@
9
9
  */
10
10
  import { spawn as nodeSpawn } from "node:child_process";
11
11
  import { EventEmitter } from "node:events";
12
- import { access, readFile } from "node:fs/promises";
12
+ import { access, lstat, readFile } from "node:fs/promises";
13
13
  import * as os from "node:os";
14
14
  import { pathToFileURL } from "node:url";
15
15
  import { emitBounded } from "../bounded-telemetry.js";
16
16
  import { withTimeout } from "../deadline-utils.js";
17
- import { incrementDegradationCount } from "../degradation-ledger.js";
17
+ import { minimatch } from "../deps/minimatch.js";
18
+ import { incrementDegradationCount, recordDegradationOnce, } from "../degradation-ledger.js";
18
19
  // vscode-jsonrpc v9 ships an `exports` map exposing the Node entry as the
19
20
  // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
20
21
  import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
21
22
  import { logExtension } from "../extension-log.js";
22
23
  import { recordLspChild, removeLspChild } from "../instance-registry.js";
24
+ import { workspaceDiagnosticsCacheSessionStart } from "./workspace-diagnostics-session.js";
23
25
  import { logLatency } from "../latency-logger.js";
24
26
  import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
27
+ import { getProcessSingleton } from "../process-singletons.js";
25
28
  import { getAmbientAbortSignal } from "../safe-spawn.js";
26
29
  import { raceToCompletion } from "./aggregation.js";
27
30
  import { hashDiagnosticContent, } from "./diagnostic-binding.js";
@@ -67,7 +70,7 @@ function extractSpawnMarker(args) {
67
70
  return undefined;
68
71
  }
69
72
  // --- Constants ---
70
- const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
73
+ export const INITIALIZE_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_INIT_TIMEOUT_MS", 15_000); // 15s — npx downloads are handled by ensureTool, not here
71
74
  /**
72
75
  * The client capabilities advertised in every `initialize`. The textDocument set
73
76
  * is intentionally COMPLETE and spec-compliant: servers built on
@@ -96,6 +99,11 @@ export const CLIENT_CAPABILITIES = {
96
99
  workspaceFolders: true,
97
100
  configuration: true,
98
101
  didChangeWatchedFiles: { dynamicRegistration: true },
102
+ fileOperations: {
103
+ dynamicRegistration: false,
104
+ willRename: true,
105
+ didRename: true,
106
+ },
99
107
  },
100
108
  textDocument: {
101
109
  synchronization: {
@@ -115,7 +123,27 @@ export const CLIENT_CAPABILITIES = {
115
123
  implementation: { dynamicRegistration: false },
116
124
  references: { dynamicRegistration: false },
117
125
  documentSymbol: { dynamicRegistration: false },
118
- codeAction: { dynamicRegistration: false },
126
+ codeAction: {
127
+ dynamicRegistration: false,
128
+ dataSupport: true,
129
+ resolveSupport: { properties: ["edit", "command"] },
130
+ // #1971 review: without literal support, LSP 3.17 constrains
131
+ // textDocument/codeAction responses to Command[] — resolve of an edit
132
+ // would then be protocol-invalid. We parse CodeAction objects.
133
+ codeActionLiteralSupport: {
134
+ codeActionKind: {
135
+ valueSet: [
136
+ "quickfix",
137
+ "refactor",
138
+ "refactor.extract",
139
+ "refactor.inline",
140
+ "refactor.rewrite",
141
+ "source",
142
+ "source.organizeImports",
143
+ ],
144
+ },
145
+ },
146
+ },
119
147
  rename: { dynamicRegistration: false },
120
148
  publishDiagnostics: {
121
149
  relatedInformation: true,
@@ -253,6 +281,78 @@ function installCrashGuard() {
253
281
  throw reason instanceof Error ? reason : new Error(String(reason));
254
282
  });
255
283
  }
284
+ /** #2065: bound both the path count and UTF-16 heap represented by retained
285
+ * Incremental-sync text. The map keeps the newest sent paths when evicting. */
286
+ export const MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES = 128;
287
+ export const MAX_INCREMENTAL_TEXT_RETAINED_BYTES = 64 * 1024 * 1024;
288
+ /**
289
+ * #2065 fix round 1: a PROJECTION registry, not a second source of truth for
290
+ * "which clients are live" — its only job is letting `memory-sampler.ts`
291
+ * total retained-text bytes across clients without importing `LSPService`.
292
+ * Considered projecting straight off `LSPService.state.clients` (the
293
+ * manager's own client map, `clients/lsp/index.ts`) instead of keeping this
294
+ * Set at all, and rejected it for two reasons:
295
+ * 1. Import direction: `clients/lsp/index.ts` imports THIS file, not the
296
+ * reverse, so reading `LSPService.state.clients` from here (or from
297
+ * `memory-sampler.ts`, which every other snapshot in that module reaches
298
+ * by importing the owning subsystem directly, e.g.
299
+ * `getReviewGraphWorkspaceCacheSnapshot`, `getDispatchCascadeCacheStats`)
300
+ * means importing the higher-level manager from the lower-level client
301
+ * module — a layering inversion, not a cycle fix.
302
+ * 2. `LSPService.state.clients` is ITSELF only lazily reconciled against
303
+ * real client death — a crashed client's slot is noticed and evicted on
304
+ * the next `getClientForFile` attach, not at crash time (see
305
+ * `markExitedIfUnset`'s docstring above). Projecting off it would not
306
+ * give memory-sampler any more real-time accuracy than this Set; it
307
+ * would just move the same "who deregisters, and when" question onto a
308
+ * larger, higher-risk surface (the manager's routing map) for no gain.
309
+ *
310
+ * What makes this safe to keep as a second collection: it has exactly ONE
311
+ * lifecycle seam. `spawnClient` is the only writer that adds (below), and
312
+ * `disposeClientConnection` is the only writer that removes — and that
313
+ * function is itself the single convergence point every permanent-death path
314
+ * (`connection.onError`, `connection.onClose`, the process `exit` handler,
315
+ * and `clientShutdownOnce`'s `finally`) already funnels through, guarded by
316
+ * the same `connectionDisposed` idempotency flag that gates the connection
317
+ * teardown itself. Membership can therefore never drift from "has this
318
+ * client's connection been torn down" — there is nowhere else in the file
319
+ * that can flip a client from alive to permanently dead without going
320
+ * through `disposeClientConnection`.
321
+ *
322
+ * PROCESS-SCOPED, not module-scoped (#2130 criterion 2). This Set is what
323
+ * `memory_sample.subsystems.lsp.clients` counts, and a module-scope Set counted
324
+ * only the clients this MODULE EVALUATION spawned. pi evaluates the pi-lens
325
+ * graph up to nine times per process (#2146, measured `host_boot` = 9) and each
326
+ * evaluation builds its own `LSPService` and fleet (#2157 item 1), so a
327
+ * secondary root's servers — spawned by a later evaluation — were invisible to
328
+ * the sampler running in the first. That is the reported symptom exactly:
329
+ * `clients: 1` in `memory_sample` beside `lspChildCount: 2` in `instances.json`,
330
+ * with two tsservers alive. Sharing the Set through `getProcessSingleton` makes
331
+ * both the count and `roots` span every root the process serves.
332
+ *
333
+ * Both seams share it, which is the whole requirement: sharing only the add
334
+ * site would build a Set that never shrinks, a worse leak than the undercount.
335
+ */
336
+ const ACTIVE_CLIENTS_FAMILY = "lsp-client.active-clients";
337
+ /** Bump when the cell's shape changes. */
338
+ const ACTIVE_CLIENTS_VERSION = 1;
339
+ function activeLspClientSet() {
340
+ return getProcessSingleton(ACTIVE_CLIENTS_FAMILY, ACTIVE_CLIENTS_VERSION, () => new Set());
341
+ }
342
+ export function getLspDocumentTextRetentionSnapshot() {
343
+ let entries = 0;
344
+ let bytes = 0;
345
+ const roots = new Set();
346
+ const active = activeLspClientSet();
347
+ for (const state of active) {
348
+ entries += state.incrementalTextRetainedEntries ?? 0;
349
+ bytes += state.incrementalTextRetainedBytes ?? 0;
350
+ if (typeof state.root === "string" && state.root.length > 0) {
351
+ roots.add(state.root);
352
+ }
353
+ }
354
+ return { clients: active.size, entries, bytes, roots: roots.size };
355
+ }
256
356
  function isClientAlive(state) {
257
357
  return (state.isConnected && !state.isDestroyed && !state.lspProcess.process.killed);
258
358
  }
@@ -260,6 +360,13 @@ function disposeClientConnection(state) {
260
360
  if (state.connectionDisposed)
261
361
  return;
262
362
  state.connectionDisposed = true;
363
+ // #2065 fix round 1: this is the ONE place every permanent-death path
364
+ // converges (onError, onClose, the process 'exit' handler, and
365
+ // clientShutdownOnce's finally) — see setupConnectionLifecycle below and
366
+ // clientShutdownOnce. Deregistering here, guarded by the idempotency flag
367
+ // above, means a crash that never re-attaches still frees its retained
368
+ // text instead of pinning it for the rest of the process lifetime.
369
+ activeLspClientSet().delete(state);
263
370
  try {
264
371
  state.connection.dispose();
265
372
  }
@@ -788,6 +895,50 @@ function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSo
788
895
  },
789
896
  });
790
897
  }
898
+ /**
899
+ * #2066: the ONE full-document walk a send needs, replacing the two that used
900
+ * to run independently — a `charCodeAt` loop counting `\n` for the
901
+ * `lsp_document_send` line count, and a `.split(/\r\n|\r|\n/)` in
902
+ * `buildContentChanges` that materialized one substring per line to read the
903
+ * last one. The #1095 sha256 is a third walk and stays; it is native, and it
904
+ * is load-bearing.
905
+ *
906
+ * The two counts here are deliberately NOT the same number and must not be
907
+ * collapsed into one. `lfNewlineCount` counts `\n` only, because its consumer
908
+ * pairs against `diagnostic_past_eof`, whose gate counts LF BYTES
909
+ * (`clients/diagnostic-line-freshness.ts`); merging the conventions would put
910
+ * the two records one apart on exactly the mixed-ending documents the pairing
911
+ * exists to debug. `lastLine` uses the full LSP terminator set (`\r\n`, lone
912
+ * `\r`, `\n` — #1669 review F6), because a CRLF or classic-Mac document whose
913
+ * terminators went uncounted computes a range ending mid-document.
914
+ */
915
+ function scanSentContent(content) {
916
+ let lfNewlineCount = 0;
917
+ let index = 0;
918
+ let start = 0;
919
+ // `charCodeAt`, not `codePointAt` (typescript:S7758, accepted): both are
920
+ // compared against 10/13, which no surrogate unit or astral code point can
921
+ // equal, so they are interchangeable here — but `codePointAt` measured 8%
922
+ // slower on a 400 KiB LF document (0.716 vs 0.663 ms/scan), because every
923
+ // ordinary character pays a surrogate-pair check this loop never uses.
924
+ for (let i = 0; i < content.length; i++) {
925
+ const code = content.charCodeAt(i);
926
+ if (code !== 10 && code !== 13)
927
+ continue;
928
+ if (code === 10) {
929
+ lfNewlineCount++;
930
+ }
931
+ else if (content.charCodeAt(i + 1) === 10) {
932
+ // `\r\n` is ONE line terminator, and still carries the `\n` the
933
+ // LF-only count wants.
934
+ lfNewlineCount++;
935
+ i++;
936
+ }
937
+ index++;
938
+ start = i + 1;
939
+ }
940
+ return { lfNewlineCount, lastLine: { index, start } };
941
+ }
791
942
  /**
792
943
  * #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
793
944
  * tag it with the document version it was sent as, so a later
@@ -797,16 +948,84 @@ function logTypeScriptPullSettle(state, normalizedPath, durationMs, pullSettleSo
797
948
  * caller already applies to the content it hands us.
798
949
  */
799
950
  function recordSentContent(state, normalizedPath, version, content) {
951
+ const scan = scanSentContent(content);
952
+ const previous = state.documentContentHashes.get(normalizedPath);
953
+ if (previous && previous.version > version) {
954
+ incrementDegradationCount({
955
+ kind: "lsp-document-send-order",
956
+ subject: `${state.serverId}:${normalizedPath}`,
957
+ reason: `recorded version ${version} after newer version ${previous.version}`,
958
+ });
959
+ return;
960
+ }
961
+ if (previous?.text !== undefined) {
962
+ state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
963
+ state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? previous.text.length * 2) -
964
+ previous.text.length * 2);
965
+ // #2065 fix round 1 F5: the path is about to be rewritten below (and
966
+ // possibly re-marked text-bearing) — drop the stale membership first so
967
+ // the reinsert further down lands at the END of the recency order
968
+ // instead of leaving a duplicate-free but stale position.
969
+ state.incrementalTextBearingPaths?.delete(normalizedPath);
970
+ }
971
+ // #2065 fix round 1 F5: eviction recency now lives in
972
+ // `incrementalTextBearingPaths` (above/below), not this map's own
973
+ // insertion order — no remaining reader iterates `documentContentHashes`
974
+ // for order (every other site below is a keyed `.get`/`.delete`). The
975
+ // reinsert is kept anyway so a log or future debug dump that DOES walk
976
+ // this map in insertion order still reads "most recently sent last",
977
+ // matching the aux set it mirrors, rather than a stale mixed order.
978
+ state.documentContentHashes.delete(normalizedPath);
800
979
  state.documentContentHashes.set(normalizedPath, {
801
980
  version,
802
981
  hash: hashDiagnosticContent(content),
803
982
  // #1669: retain the full text only for Incremental — the sole reader
804
983
  // (`buildContentChanges`) needs it to compute the NEXT change against
805
984
  // what the server last saw; Full/None never read this field.
985
+ // #2066: `lastLine` is that reader's answer, already computed by the
986
+ // scan above — it rides along with the text it describes.
806
987
  ...(state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL && {
807
988
  text: content,
989
+ lastLine: scan.lastLine,
808
990
  }),
809
991
  });
992
+ if (state.syncKind === TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
993
+ state.incrementalTextRetainedEntries =
994
+ (state.incrementalTextRetainedEntries ?? 0) + 1;
995
+ state.incrementalTextRetainedBytes =
996
+ (state.incrementalTextRetainedBytes ?? 0) + content.length * 2;
997
+ // #2065 fix round 1 F5: track membership in the same insertion (=
998
+ // recency) order as `documentContentHashes` itself, so eviction below
999
+ // never needs to spread and scan the full content-binding map —
1000
+ // including paths whose text was already stripped — just to find the
1001
+ // oldest text-bearing one.
1002
+ if (!state.incrementalTextBearingPaths) {
1003
+ state.incrementalTextBearingPaths = new Set();
1004
+ }
1005
+ state.incrementalTextBearingPaths.add(normalizedPath);
1006
+ while ((state.incrementalTextRetainedEntries ?? 0) >
1007
+ MAX_INCREMENTAL_TEXT_RETAINED_ENTRIES ||
1008
+ (state.incrementalTextRetainedBytes ?? 0) >
1009
+ MAX_INCREMENTAL_TEXT_RETAINED_BYTES) {
1010
+ const oldestPath = state.incrementalTextBearingPaths
1011
+ ?.values()
1012
+ .next().value;
1013
+ if (oldestPath === undefined)
1014
+ break;
1015
+ const binding = state.documentContentHashes.get(oldestPath);
1016
+ state.incrementalTextBearingPaths.delete(oldestPath);
1017
+ if (!binding || binding.text === undefined)
1018
+ continue;
1019
+ // #2066: `lastLine` is dropped here with the text it describes. The
1020
+ // #1095 version/hash binding is what must survive the strip.
1021
+ state.documentContentHashes.set(oldestPath, {
1022
+ version: binding.version,
1023
+ hash: binding.hash,
1024
+ });
1025
+ state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
1026
+ state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? 0) - binding.text.length * 2);
1027
+ }
1028
+ }
810
1029
  // #1641 criterion 3: the in-memory document's version + content length AT
811
1030
  // SEND TIME, so a later "diagnostic cited a line past current disk EOF"
812
1031
  // record (`diagnostic_past_eof`, clients/diagnostic-line-freshness.ts) can be
@@ -818,14 +1037,9 @@ function recordSentContent(state, normalizedPath, version, content) {
818
1037
  // convention as the gate itself (newline count + 1 — a trailing `\n` adds
819
1038
  // one more, empty, addressable line; an empty document is still 1 line),
820
1039
  // or the two records disagree by one at exactly the boundary this counter
821
- // exists to help debug. Counted directly (no `.split("\n")`, which
822
- // allocates one substring per line — measured at 13.3ms/200k allocations
823
- // on a 7MB send) since only the COUNT is needed here, not the lines.
824
- let newlineCount = 0;
825
- for (let i = 0; i < content.length; i++) {
826
- if (content.charCodeAt(i) === 10)
827
- newlineCount++;
828
- }
1040
+ // exists to help debug. #2066: the count comes from `scanSentContent`
1041
+ // above, which folded this loop into the walk `buildContentChanges` was
1042
+ // already paying for; the LF-only convention is unchanged.
829
1043
  logLatency({
830
1044
  type: "phase",
831
1045
  phase: "lsp_document_send",
@@ -834,7 +1048,7 @@ function recordSentContent(state, normalizedPath, version, content) {
834
1048
  metadata: {
835
1049
  version,
836
1050
  contentLength: content.length,
837
- contentLineCount: newlineCount + 1,
1051
+ contentLineCount: scan.lfNewlineCount + 1,
838
1052
  },
839
1053
  });
840
1054
  }
@@ -864,23 +1078,24 @@ function buildContentChanges(state, normalizedPath, content) {
864
1078
  if (state.syncKind !== TEXT_DOCUMENT_SYNC_KIND_INCREMENTAL) {
865
1079
  return [{ text: content }];
866
1080
  }
867
- const previousText = state.documentContentHashes.get(normalizedPath)?.text;
868
- if (previousText === undefined) {
1081
+ const previous = state.documentContentHashes.get(normalizedPath);
1082
+ if (previous?.text === undefined) {
869
1083
  return [{ text: content }];
870
1084
  }
871
- // #1669 review F6: split on every LSP line terminator (\r\n, lone \r, or
872
- // \n), not just \n — a document using CRLF or lone-CR line endings would
873
- // otherwise be undercounted, computing a range that ends mid-document
874
- // instead of at the real last line.
875
- const previousLines = previousText.split(/\r\n|\r|\n/);
876
- const lastLine = previousLines.length - 1;
877
- const lastLineText = previousLines[lastLine] ?? "";
1085
+ const previousText = previous.text;
1086
+ // #2066: `recordSentContent` scanned this exact text when it sent it, so
1087
+ // read where it left the last line instead of walking (and splitting) the
1088
+ // document a second time. An entry carrying text without that position was
1089
+ // written by something else — resolve it through the SAME scanner, never a
1090
+ // second convention that can drift from it.
1091
+ const lastLine = previous.lastLine ?? scanSentContent(previousText).lastLine;
1092
+ const lastLineText = previousText.slice(lastLine.start);
878
1093
  return [
879
1094
  {
880
1095
  range: {
881
1096
  start: { line: 0, character: 0 },
882
1097
  end: {
883
- line: lastLine,
1098
+ line: lastLine.index,
884
1099
  character: convertCharacterOffset(state.positionEncoding, lastLineText, lastLineText.length),
885
1100
  },
886
1101
  },
@@ -2332,10 +2547,9 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
2332
2547
  },
2333
2548
  });
2334
2549
  }
2335
- export async function handleNotifyChange(state, filePath, content) {
2550
+ async function handleNotifyChangeOnce(state, filePath, content, normalizedPath) {
2336
2551
  if (!isClientAlive(state))
2337
2552
  return;
2338
- const normalizedPath = normalizeMapKey(filePath);
2339
2553
  const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
2340
2554
  if (!state.openDocuments.has(normalizedPath)) {
2341
2555
  // Safety fallback: keep protocol ordering valid even if caller sends
@@ -2369,6 +2583,27 @@ export async function handleNotifyChange(state, filePath, content) {
2369
2583
  if (changeSent)
2370
2584
  recordSentContent(state, normalizedPath, version, content);
2371
2585
  }
2586
+ /**
2587
+ * #2113: serialize the read/build/send/record transaction per document. The
2588
+ * content-change payload must be built against the content recorded by the
2589
+ * preceding send for this path; unrelated paths retain parallel sends.
2590
+ */
2591
+ export function handleNotifyChange(state, filePath, content) {
2592
+ if (!isClientAlive(state))
2593
+ return Promise.resolve();
2594
+ const normalizedPath = normalizeMapKey(filePath);
2595
+ const previous = state.notifyChangeQueues.get(normalizedPath) ?? Promise.resolve();
2596
+ const queued = previous
2597
+ .catch(() => undefined)
2598
+ .then(() => handleNotifyChangeOnce(state, filePath, content, normalizedPath));
2599
+ const settled = queued.finally(() => {
2600
+ if (state.notifyChangeQueues.get(normalizedPath) === settled) {
2601
+ state.notifyChangeQueues.delete(normalizedPath);
2602
+ }
2603
+ });
2604
+ state.notifyChangeQueues.set(normalizedPath, settled);
2605
+ return settled;
2606
+ }
2372
2607
  /** Close a document through the same lifecycle path exposed by the client. */
2373
2608
  export async function closeDocument(state, filePath) {
2374
2609
  if (!isClientAlive(state))
@@ -2393,7 +2628,44 @@ export async function closeDocument(state, filePath) {
2393
2628
  // harmless and cheap) — mirror openDocuments' own per-close cleanup so it
2394
2629
  // doesn't grow unbounded across a long session's worth of open/close churn.
2395
2630
  state.projectIdentityProbedFiles?.delete(normalizedPath);
2631
+ const retained = state.documentContentHashes.get(normalizedPath);
2632
+ if (retained?.text !== undefined) {
2633
+ state.incrementalTextRetainedEntries = Math.max(0, (state.incrementalTextRetainedEntries ?? 1) - 1);
2634
+ state.incrementalTextRetainedBytes = Math.max(0, (state.incrementalTextRetainedBytes ?? retained.text.length * 2) -
2635
+ retained.text.length * 2);
2636
+ // #2065 fix round 2 N1: `recordSentContent` and the eviction loop both
2637
+ // delete from `incrementalTextBearingPaths` when they strip a path's
2638
+ // text, but neither runs on close — this was the missing third writer.
2639
+ // Without it, a closed path's membership in the aux set survives
2640
+ // forever: below the 128-entry/64 MiB cap the eviction loop that would
2641
+ // otherwise clean it up never runs at all, so the set grows by one
2642
+ // stale entry per open/close cycle with nothing ever draining it — the
2643
+ // exact unbounded-per-path-store class #2065 exists to close, just
2644
+ // moved into the aux index instead of the byte-retention count itself.
2645
+ state.incrementalTextBearingPaths?.delete(normalizedPath);
2646
+ }
2647
+ // Keep the hash/version binding only while the document is open; the full
2648
+ // text is needed for the next edit, never after didClose (#2065).
2649
+ state.documentContentHashes.delete(normalizedPath);
2396
2650
  clearDiagnosticsForPath(state, normalizedPath);
2651
+ // #2065 fix round 2 (reverts fix round 1 F4): `pullGenerations` is the
2652
+ // eighth per-path map in this family, but it is deliberately NOT cleared
2653
+ // here — the same reasoning `clearDiagnosticsForPath` already documents
2654
+ // above for `pullRequestSequences` applies to this counter too, and fix
2655
+ // round 1's F4 missed it. `pullGenerationFor` defaults a missing entry to
2656
+ // `0`, so deleting the entry on close resets the counter. A pull already
2657
+ // in flight before the close captured its generation BEFORE this close's
2658
+ // `clearDiagnosticsForPath` bump above ran. If the document is closed and
2659
+ // reopened before that in-flight pull's write lands, deleting the entry
2660
+ // here would let the counter read back as `0` again — the exact value the
2661
+ // stale write's stale captured generation equals — so the write-time
2662
+ // `!== generation` check at its call site would wrongly accept a stale
2663
+ // answer computed against the pre-close content. Leaving the counter
2664
+ // monotonic and un-reset keeps every pre-close capture permanently
2665
+ // distinguishable from whatever the counter reads afterward, no matter how
2666
+ // many closes or reopens happen in between. The cost is one integer per
2667
+ // path that outlives the document's open lifetime — negligible next to
2668
+ // correctness here.
2397
2669
  }
2398
2670
  /**
2399
2671
  * #1620 residual review F1: `fast` and `processExiting` only ever escalate —
@@ -2532,6 +2804,9 @@ async function clientShutdownOnce(state, options) {
2532
2804
  }
2533
2805
  }
2534
2806
  finally {
2807
+ // #2065 fix round 1: deregistration now lives in disposeClientConnection
2808
+ // itself (the convergence point for every crash/dispose path, not just
2809
+ // this graceful one) — see its comment.
2535
2810
  disposeClientConnection(state);
2536
2811
  const pid = state.lspProcess.pid;
2537
2812
  logLatency({
@@ -3060,6 +3335,14 @@ async function resolveCodeActionBestEffort(state, action) {
3060
3335
  edit: await normalizeClientWorkspaceEdit(state, action.edit),
3061
3336
  };
3062
3337
  }
3338
+ if (!state.operationSupport.codeActionResolve) {
3339
+ recordDegradationOnce({
3340
+ kind: "lsp-capability-skip",
3341
+ subject: `${state.serverId}:codeAction/resolve`,
3342
+ reason: "server did not advertise codeActionProvider.resolveProvider",
3343
+ });
3344
+ return action;
3345
+ }
3063
3346
  let resolved;
3064
3347
  try {
3065
3348
  resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
@@ -3082,7 +3365,7 @@ async function resolveCodeActionBestEffort(state, action) {
3082
3365
  // --- Client Factory ---
3083
3366
  export async function createLSPClient(options) {
3084
3367
  installCrashGuard();
3085
- const { serverId, process: lspProcess, root, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
3368
+ const { serverId, process: lspProcess, root, sessionCwd, initialization, initializeTimeoutMs = INITIALIZE_TIMEOUT_MS, launchVariant, } = options;
3086
3369
  // #449/#472: register this LSP child in the cross-process instance registry
3087
3370
  // as soon as we have a live pid — BEFORE `initialize` completes, not after.
3088
3371
  // Registering early means a child that dies/hangs during initialize (the
@@ -3095,6 +3378,11 @@ export async function createLSPClient(options) {
3095
3378
  serverId,
3096
3379
  command: lspProcess.command,
3097
3380
  marker: extractSpawnMarker(lspProcess.args),
3381
+ sessionIdentity: {
3382
+ projectRoot: sessionCwd ?? process.cwd(),
3383
+ rootSource: sessionCwd ? "service-cwd" : "lsp-fallback",
3384
+ startedAt: new Date(workspaceDiagnosticsCacheSessionStart()).toISOString(),
3385
+ },
3098
3386
  }).catch((err) => {
3099
3387
  // best-effort observability — never fail LSP startup over this
3100
3388
  logLatency({
@@ -3204,8 +3492,12 @@ export async function createLSPClient(options) {
3204
3492
  diagnosticsVersion: 0,
3205
3493
  diagnosticsVersionsByPath: new Map(),
3206
3494
  documentVersions: new Map(),
3495
+ notifyChangeQueues: new Map(),
3207
3496
  diagnosticDocVersions: new Map(),
3208
3497
  documentContentHashes: new Map(),
3498
+ incrementalTextRetainedEntries: 0,
3499
+ incrementalTextBearingPaths: new Set(),
3500
+ incrementalTextRetainedBytes: 0,
3209
3501
  diagnosticBindings: new Map(),
3210
3502
  pullResultIds: new Map(),
3211
3503
  documentPullDiagnosticsBySource: new Map(),
@@ -3238,6 +3530,7 @@ export async function createLSPClient(options) {
3238
3530
  // SAFETY: state construction completes before the flush closure can run.
3239
3531
  watchQueue: undefined,
3240
3532
  };
3533
+ activeLspClientSet().add(state);
3241
3534
  // #271: batch per-file workspace/didChangeWatchedFiles into one notification
3242
3535
  // per debounce window, so an N-file turn re-indexes the server once, not N×.
3243
3536
  state.watchQueue = new WatchedFilesQueue((changes) => {
@@ -3340,6 +3633,31 @@ export async function createLSPClient(options) {
3340
3633
  state.workspaceDiagnosticsSupport =
3341
3634
  detectWorkspaceDiagnosticsSupport(initResult);
3342
3635
  state.operationSupport = detectOperationSupport(initResult);
3636
+ const capabilities = (initResult
3637
+ ?.capabilities ?? {});
3638
+ const workspace = isCapabilityRecord(capabilities.workspace)
3639
+ ? capabilities.workspace
3640
+ : undefined;
3641
+ const fileOperations = isCapabilityRecord(workspace)
3642
+ ? workspace.fileOperations
3643
+ : undefined;
3644
+ const malformedFileOperationRegistrations = new Set();
3645
+ if (isCapabilityRecord(fileOperations)) {
3646
+ for (const operation of ["willRename", "didRename"]) {
3647
+ if (fileOperations[operation] !== undefined &&
3648
+ parseFileOperationFilters(fileOperations[operation]) === undefined) {
3649
+ malformedFileOperationRegistrations.add(operation);
3650
+ }
3651
+ }
3652
+ }
3653
+ state.fileOperationMalformedRegistrations =
3654
+ malformedFileOperationRegistrations;
3655
+ if (isCapabilityRecord(fileOperations)) {
3656
+ state.fileOperationFilters = {
3657
+ willRename: parseFileOperationFilters(fileOperations.willRename),
3658
+ didRename: parseFileOperationFilters(fileOperations.didRename),
3659
+ };
3660
+ }
3343
3661
  state.positionEncoding = negotiatePositionEncoding(initResult?.capabilities);
3344
3662
  state.syncKind = negotiateSyncKind(initResult?.capabilities);
3345
3663
  state.rawCapabilityKeys = Object.keys(initResult?.capabilities ??
@@ -3444,6 +3762,9 @@ export async function createLSPClient(options) {
3444
3762
  getOperationSupport() {
3445
3763
  return state.operationSupport;
3446
3764
  },
3765
+ getMalformedFileOperationRegistrations() {
3766
+ return state.fileOperationMalformedRegistrations ?? new Set();
3767
+ },
3447
3768
  getAdvertisedCommands() {
3448
3769
  return [...state.advertisedCommands];
3449
3770
  },
@@ -3570,11 +3891,26 @@ export async function createLSPClient(options) {
3570
3891
  },
3571
3892
  closeDocument: (filePath) => closeDocument(state, filePath),
3572
3893
  async willRenameFiles(oldFilePath, newFilePath) {
3894
+ if (!isClientAlive(state))
3895
+ return null;
3896
+ // #1971 review: the server registered WHICH paths it cares about.
3897
+ // Sending outside those filters asks a question the server never
3898
+ // signed up to answer.
3899
+ const oldUri = pathToFileURL(oldFilePath).href;
3900
+ const newUri = pathToFileURL(newFilePath).href;
3901
+ if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.willRename, oldUri, newUri, oldFilePath, newFilePath))) {
3902
+ recordDegradationOnce({
3903
+ kind: "lsp-capability-skip",
3904
+ subject: `${state.serverId}:workspace/willRenameFiles:filter`,
3905
+ reason: "filter-mismatch",
3906
+ });
3907
+ return null;
3908
+ }
3573
3909
  const result = await navRequest(state, "workspace/willRenameFiles", {
3574
3910
  files: [
3575
3911
  {
3576
- oldUri: pathToFileURL(oldFilePath).href,
3577
- newUri: pathToFileURL(newFilePath).href,
3912
+ oldUri,
3913
+ newUri,
3578
3914
  },
3579
3915
  ],
3580
3916
  });
@@ -3583,11 +3919,28 @@ export async function createLSPClient(options) {
3583
3919
  async didRenameFiles(oldFilePath, newFilePath, oldUri, newUri) {
3584
3920
  if (!isClientAlive(state))
3585
3921
  return;
3922
+ // `fileOperationFilters.didRename` is parsed by the same predicate that
3923
+ // derives operationSupport.didRenameFiles (see
3924
+ // isFileOperationRegistrationOptions). Thus filters undefined iff the
3925
+ // capability is absent; the service-level gate remains the enforcement
3926
+ // point for aggregated dispatch.
3927
+ // #1971 review: same registration-filter discipline as willRename —
3928
+ // the server only signed up to hear about matching paths.
3929
+ const wireOldUri = oldUri ?? pathToFileURL(oldFilePath).href;
3930
+ const wireNewUri = newUri ?? pathToFileURL(newFilePath).href;
3931
+ if (!(await fileOperationFiltersMatch(state.fileOperationFilters?.didRename, wireOldUri, wireNewUri, oldFilePath, newFilePath))) {
3932
+ recordDegradationOnce({
3933
+ kind: "lsp-capability-skip",
3934
+ subject: `${state.serverId}:workspace/didRenameFiles:filter`,
3935
+ reason: "filter-mismatch",
3936
+ });
3937
+ return;
3938
+ }
3586
3939
  await safeSendNotification(state.connection, "workspace/didRenameFiles", {
3587
3940
  files: [
3588
3941
  {
3589
- oldUri: oldUri ?? pathToFileURL(oldFilePath).href,
3590
- newUri: newUri ?? pathToFileURL(newFilePath).href,
3942
+ oldUri: wireOldUri,
3943
+ newUri: wireNewUri,
3591
3944
  },
3592
3945
  ],
3593
3946
  });
@@ -3818,8 +4171,13 @@ function detectOperationSupport(initResult) {
3818
4171
  return false;
3819
4172
  if (typeof value === "boolean")
3820
4173
  return value;
3821
- return true;
4174
+ return isCapabilityRecord(value);
3822
4175
  };
4176
+ const codeActionProvider = capabilities?.codeActionProvider;
4177
+ const workspace = capabilities?.workspace;
4178
+ const fileOperations = isCapabilityRecord(workspace)
4179
+ ? workspace.fileOperations
4180
+ : undefined;
3823
4181
  return {
3824
4182
  definition: hasProvider("definitionProvider"),
3825
4183
  typeDefinition: hasProvider("typeDefinitionProvider"),
@@ -3830,8 +4188,135 @@ function detectOperationSupport(initResult) {
3830
4188
  documentSymbol: hasProvider("documentSymbolProvider"),
3831
4189
  workspaceSymbol: hasProvider("workspaceSymbolProvider"),
3832
4190
  codeAction: hasProvider("codeActionProvider"),
4191
+ codeActionResolve: isCapabilityRecord(codeActionProvider) &&
4192
+ codeActionProvider.resolveProvider === true,
3833
4193
  rename: hasProvider("renameProvider"),
4194
+ willRenameFiles: isCapabilityRecord(fileOperations) &&
4195
+ isFileOperationRegistrationOptions(fileOperations.willRename),
4196
+ didRenameFiles: isCapabilityRecord(fileOperations) &&
4197
+ isFileOperationRegistrationOptions(fileOperations.didRename),
3834
4198
  implementation: hasProvider("implementationProvider"),
3835
4199
  callHierarchy: hasProvider("callHierarchyProvider"),
3836
4200
  };
3837
4201
  }
4202
+ function isCapabilityRecord(value) {
4203
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4204
+ }
4205
+ function isFileOperationRegistrationOptions(value) {
4206
+ return parseFileOperationFilters(value) !== undefined;
4207
+ }
4208
+ /**
4209
+ * Parse the filters of a `FileOperationRegistrationOptions` object. Returns
4210
+ * `undefined` when the options are absent or malformed — callers treat that as
4211
+ * "no interest declared" and fail closed, never sending the operation.
4212
+ */
4213
+ function parseFileOperationFilters(value) {
4214
+ if (!isCapabilityRecord(value) || !Array.isArray(value.filters)) {
4215
+ return undefined;
4216
+ }
4217
+ const filters = [];
4218
+ for (const entry of value.filters) {
4219
+ if (!isCapabilityRecord(entry))
4220
+ return undefined;
4221
+ const pattern = entry.pattern;
4222
+ if (!isCapabilityRecord(pattern) ||
4223
+ typeof pattern.glob !== "string" ||
4224
+ pattern.glob.trim() === "") {
4225
+ return undefined;
4226
+ }
4227
+ if (entry.scheme !== undefined &&
4228
+ (typeof entry.scheme !== "string" ||
4229
+ entry.scheme.trim() === "" ||
4230
+ !/^[A-Za-z][A-Za-z0-9+.-]*$/.test(entry.scheme))) {
4231
+ return undefined;
4232
+ }
4233
+ const matches = pattern.matches;
4234
+ if (matches !== undefined && matches !== "file" && matches !== "folder") {
4235
+ return undefined;
4236
+ }
4237
+ if (pattern.options !== undefined && !isCapabilityRecord(pattern.options)) {
4238
+ return undefined;
4239
+ }
4240
+ const options = isCapabilityRecord(pattern.options)
4241
+ ? pattern.options
4242
+ : undefined;
4243
+ if (options?.ignoreCase !== undefined &&
4244
+ typeof options.ignoreCase !== "boolean") {
4245
+ return undefined;
4246
+ }
4247
+ filters.push({
4248
+ scheme: entry.scheme?.toLowerCase(),
4249
+ glob: pattern.glob,
4250
+ matches: matches === "file" || matches === "folder" ? matches : undefined,
4251
+ ignoreCase: options?.ignoreCase === true,
4252
+ });
4253
+ }
4254
+ return filters;
4255
+ }
4256
+ /**
4257
+ * Whether ANY parsed filter expresses interest in either path of a rename.
4258
+ * Conservative by design: unsupported URI schemes, an unresolved explicit
4259
+ * file/folder kind, malformed URIs, and zero filters all match nothing.
4260
+ */
4261
+ async function fileOperationFiltersMatch(filters, oldUri, newUri, oldFilePath, newFilePath) {
4262
+ if (!filters || filters.length === 0)
4263
+ return false;
4264
+ const candidates = [oldUri, newUri].flatMap(fileOperationUriCandidates);
4265
+ const entityKind = filters.some((filter) => filter.matches !== undefined)
4266
+ ? await probeRenameEntityKind(oldFilePath, newFilePath)
4267
+ : undefined;
4268
+ for (const filter of filters) {
4269
+ if (filter.scheme !== undefined && filter.scheme !== "file")
4270
+ continue;
4271
+ if (filter.matches !== undefined && filter.matches !== entityKind)
4272
+ continue;
4273
+ for (const candidate of candidates) {
4274
+ try {
4275
+ if (minimatch(candidate, filter.glob, {
4276
+ nocase: filter.ignoreCase,
4277
+ dot: true,
4278
+ })) {
4279
+ return true;
4280
+ }
4281
+ }
4282
+ catch {
4283
+ // Malformed glob implementations must never widen server fan-out.
4284
+ }
4285
+ }
4286
+ }
4287
+ return false;
4288
+ }
4289
+ function fileOperationUriCandidates(uri) {
4290
+ let parsed;
4291
+ try {
4292
+ parsed = new URL(uri);
4293
+ }
4294
+ catch {
4295
+ return [];
4296
+ }
4297
+ // pi-lens performs local filesystem renames only. Even when a caller supplies
4298
+ // preserved URI spelling, another scheme cannot describe that operation.
4299
+ if (parsed.protocol.toLowerCase() !== "file:")
4300
+ return [];
4301
+ let uriPath;
4302
+ try {
4303
+ uriPath = decodeURIComponent(parsed.pathname).replace(/\\/g, "/");
4304
+ }
4305
+ catch {
4306
+ return [];
4307
+ }
4308
+ return [uriPath];
4309
+ }
4310
+ async function probeRenameEntityKind(oldFilePath, newFilePath) {
4311
+ for (const filePath of [oldFilePath, newFilePath]) {
4312
+ try {
4313
+ const info = await lstat(filePath);
4314
+ return info.isDirectory() ? "folder" : "file";
4315
+ }
4316
+ catch {
4317
+ // Before the rename the old path normally exists; after it, the new path
4318
+ // does. Probe both instead of deriving behavior from the host OS.
4319
+ }
4320
+ }
4321
+ return undefined;
4322
+ }