@herbertgao/pi-extensions 2026.8.4 → 2026.8.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (360) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +53 -14
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  14. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  15. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +18 -0
  16. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +20 -4
  21. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  22. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  26. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  31. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  32. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  33. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  36. package/node_modules/@narumitw/pi-btw/README.md +32 -24
  37. package/node_modules/@narumitw/pi-btw/package.json +6 -6
  38. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  39. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
  40. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  41. package/node_modules/pi-lens/CHANGELOG.md +2535 -954
  42. package/node_modules/pi-lens/README.md +13 -0
  43. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  44. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  45. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  46. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  47. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  48. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  50. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  51. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  52. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  53. package/node_modules/pi-lens/dist/clients/biome-client.js +136 -24
  54. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  55. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  56. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  57. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  58. package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
  59. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  60. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  61. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  62. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  63. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  64. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  65. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  66. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  67. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  68. package/node_modules/pi-lens/dist/clients/console-guard-install.js +26 -0
  69. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  70. package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
  71. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  72. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  73. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  74. package/node_modules/pi-lens/dist/clients/dependency-checker.js +361 -77
  75. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
  76. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
  77. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
  78. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  79. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  80. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  81. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  83. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1172 -683
  84. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  86. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  87. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +47 -6
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +473 -42
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  114. package/node_modules/pi-lens/dist/clients/disposition-publish.js +18 -4
  115. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  116. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  117. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  118. package/node_modules/pi-lens/dist/clients/extension-log.js +432 -0
  119. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  120. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  121. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  122. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  123. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  124. package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
  125. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  126. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  127. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  128. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  129. package/node_modules/pi-lens/dist/clients/git-guard.js +678 -11
  130. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  131. package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
  132. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
  133. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  134. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  135. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  136. package/node_modules/pi-lens/dist/clients/installer/index.js +395 -100
  137. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  138. package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
  139. package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
  140. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  141. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  142. package/node_modules/pi-lens/dist/clients/latency-logger.js +72 -1
  143. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  144. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  145. package/node_modules/pi-lens/dist/clients/lens-events.js +63 -9
  146. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  147. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  148. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
  149. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  150. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  151. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
  152. package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
  153. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  154. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
  155. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  156. package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
  157. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  158. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  159. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  160. package/node_modules/pi-lens/dist/clients/lsp/server.js +437 -86
  161. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  162. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  163. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  164. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  165. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  166. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  167. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +127 -32
  168. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  169. package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
  170. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  171. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  172. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  173. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  174. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  175. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  176. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  177. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  178. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  179. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  180. package/node_modules/pi-lens/dist/clients/pipeline.js +76 -15
  181. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  182. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  183. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  184. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  185. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  186. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  187. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  188. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  189. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  190. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  191. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  192. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  193. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  194. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  195. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  196. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  197. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  198. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  199. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  201. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  202. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  203. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  204. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  205. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  206. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  207. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  208. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +257 -77
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -117
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +342 -33
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  217. package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
  218. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  219. package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
  220. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  221. package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -0
  222. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  223. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  224. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  225. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  226. package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
  227. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  228. package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
  229. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  230. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  233. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  234. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  235. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  237. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  238. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  239. package/node_modules/pi-lens/dist/clients/warm-attach.js +50 -13
  240. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  241. package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
  242. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  243. package/node_modules/pi-lens/dist/index.js +51785 -36118
  244. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  245. package/node_modules/pi-lens/dist/mcp/server.js +219 -46
  246. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  247. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  248. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  249. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  250. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
  251. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
  252. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  253. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  254. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  255. package/node_modules/pi-lens/docs/agent-guide.md +40 -15
  256. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  257. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  258. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  259. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +14 -3
  260. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  261. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  262. package/node_modules/pi-lens/docs/fable.md +27 -0
  263. package/node_modules/pi-lens/docs/features.md +45 -3
  264. package/node_modules/pi-lens/docs/globalconfig.md +56 -1
  265. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  266. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  267. package/node_modules/pi-lens/docs/mcp.md +37 -4
  268. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  269. package/node_modules/pi-lens/docs/settings.md +18 -1
  270. package/node_modules/pi-lens/docs/tools.md +4 -1
  271. package/node_modules/pi-lens/docs/usage.md +41 -5
  272. package/node_modules/pi-lens/docs/word-index.md +35 -0
  273. package/node_modules/pi-lens/package.json +10 -10
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  300. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  301. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  302. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +134 -2
  303. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  304. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  305. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  306. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  307. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  308. package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
  309. package/node_modules/pi-mcp-adapter/README.md +38 -4
  310. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  311. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  312. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  313. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  314. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  315. package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
  316. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  317. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  318. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  319. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  320. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  321. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  322. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  323. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  324. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  325. package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
  326. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  327. package/node_modules/pi-mcp-adapter/package.json +4 -3
  328. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  329. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  330. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  331. package/node_modules/pi-mcp-adapter/server-manager.ts +41 -7
  332. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  333. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  334. package/node_modules/pi-mcp-adapter/tool-metadata.ts +62 -4
  335. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  336. package/node_modules/pi-mcp-adapter/types.ts +176 -27
  337. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  338. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  339. package/node_modules/pi-web-access/README.md +18 -12
  340. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  341. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  342. package/node_modules/pi-web-access/curator-page.ts +5 -3
  343. package/node_modules/pi-web-access/curator-server.ts +2 -1
  344. package/node_modules/pi-web-access/extract.ts +106 -34
  345. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  346. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  347. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  348. package/node_modules/pi-web-access/index.ts +120 -48
  349. package/node_modules/pi-web-access/package.json +2 -2
  350. package/node_modules/pi-web-access/summary-review.ts +11 -5
  351. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  352. package/package.json +12 -13
  353. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  354. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  355. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  356. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  357. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  358. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  359. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  360. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -7,6 +7,7 @@
7
7
  * - Diagnostics with debouncing
8
8
  * - Request/response handling
9
9
  */
10
+ import { logExtension } from "../extension-log.js";
10
11
  import { spawn as nodeSpawn } from "node:child_process";
11
12
  import { EventEmitter } from "node:events";
12
13
  import { access, readFile } from "node:fs/promises";
@@ -18,9 +19,12 @@ import { logLatency } from "../latency-logger.js";
18
19
  // `./node` subpath (no `.js`); the old `/node.js` file path no longer resolves.
19
20
  import { CancellationTokenSource, createMessageConnection, StreamMessageReader, StreamMessageWriter, } from "../deps/vscode-jsonrpc.js";
20
21
  import { getAmbientAbortSignal } from "../safe-spawn.js";
21
- import { applyWorkspaceEdit } from "./edits.js";
22
+ import { hashDiagnosticContent, } from "./diagnostic-binding.js";
23
+ import { newLspMutationCorrelationId, } from "../lsp-mutation.js";
24
+ import { applyWorkspaceEdit, normalizeWorkspaceEditToUtf16, } from "./edits.js";
22
25
  import { recordLspChild, removeLspChild } from "../instance-registry.js";
23
26
  import { normalizeMapKey, uriToPath } from "./path-utils.js";
27
+ import { probeTsserverProjectIdentity } from "./tsserver-sync.js";
24
28
  import { ADVERTISED_POSITION_ENCODINGS, convertCharacterOffset, lineTextAt, negotiatePositionEncoding, } from "./position-encoding.js";
25
29
  import { getStrategy } from "./wait-policy/index.js";
26
30
  import { WatchedFilesQueue } from "./watch-queue.js";
@@ -123,12 +127,35 @@ const PULL_DIAGNOSTICS_RETRY_INTERVAL_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_
123
127
  // (per #240) is NOT read as clean and falls through to the bounded push backstop.
124
128
  const PULL_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PULL_REQUEST_TIMEOUT_MS", 10_000);
125
129
  const SHUTDOWN_REQUEST_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_SHUTDOWN_TIMEOUT_MS", 1000);
130
+ // #1277: cheap liveness round-trip for the silent-clean gates (`index.ts`).
131
+ // Those gates convert a diagnostics-wait timeout into a confirmed-clean
132
+ // result from a STATIC capability classification (`silentOnClean`) alone —
133
+ // but a wedged server (accepted the notify write, then hung) satisfies that
134
+ // classification identically to a genuinely clean one. This is deliberately
135
+ // short relative to NAV_REQUEST_TIMEOUT_MS: it only needs to prove the
136
+ // connection round-trips SOMETHING before the touch reports clean, not
137
+ // complete a real navigation request.
138
+ const LIVENESS_PING_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_LIVENESS_PING_TIMEOUT_MS", 300);
139
+ // Distinctive, unlikely-to-collide query string — the response content is
140
+ // never inspected, only whether one arrived before the timeout.
141
+ const LIVENESS_PING_QUERY = "__pi_lens_liveness_ping__";
142
+ // #1104: bound on `state.workspacePullResultCache` — one entry per distinct
143
+ // file the server has ever returned a `resultId` for across this client's
144
+ // lifetime. A full clear on overflow (rather than an LRU) is fine, same
145
+ // reasoning as `DISK_BINDING_MEMO_MAX` in diagnostic-binding.ts: each entry is
146
+ // cheaply rebuilt by the next full pull, the worst case is just one extra full
147
+ // (non-`unchanged`) report per affected file.
148
+ const WORKSPACE_PULL_RESULT_CACHE_MAX = 4096;
126
149
  // Anti-deadlock backstop for workspace/executeCommand. Deliberately generous
127
150
  // (30s): the command is mutating and legitimately long-running (a real server
128
151
  // refactor / organize-imports), so this must not truncate valid work — it only
129
152
  // stops a hung server from blocking the caller forever. On timeout the command
130
153
  // may still be applying server-side; we surface that rather than pretend it ran.
131
154
  const EXECUTE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_EXECUTE_COMMAND_TIMEOUT_MS", 30_000);
155
+ // #1412 H1: short ceiling for the read-only tsserver project-identity probe.
156
+ // This is a telemetry sample, not a mutation — it must never hold the door
157
+ // open for anything close to EXECUTE_COMMAND_TIMEOUT_MS.
158
+ const PROBE_COMMAND_TIMEOUT_MS = positiveIntFromEnv("PI_LENS_LSP_PROJECT_IDENTITY_PROBE_TIMEOUT_MS", 2_500);
132
159
  const LSP_CRASH_CODES = new Set([
133
160
  "ERR_STREAM_DESTROYED",
134
161
  "ERR_STREAM_WRITE_AFTER_END",
@@ -282,12 +309,34 @@ export async function killProcessTree(proc, pid, options = {}) {
282
309
  }
283
310
  };
284
311
  try {
312
+ // #1114: gate the escalation on OBSERVED exit, not `proc.killed`. Node
313
+ // only sets `proc.killed = true` when `proc.kill()` (the ChildProcess
314
+ // method) successfully SENDS a signal — never when the process actually
315
+ // dies, and the primary SIGTERM path above goes through the raw
316
+ // `process.kill(-pid, …)` process-group call, which never touches
317
+ // `proc.killed` at all. Checking `!proc.killed` here was therefore
318
+ // either always-true (unconditional SIGKILL after the window,
319
+ // regardless of whether the group already died — group-kill path) or
320
+ // always-false/dead (direct-child fallback path, same shape as the
321
+ // safe-spawn escalation bug). An `exit` listener set once, up front,
322
+ // gives a real observed-death signal for both. Seeded from the same
323
+ // `exitCode`/`signalCode` pre-check the top-of-function early return
324
+ // uses (:689) — that early return is skipped when
325
+ // `options.processExiting` is set, so a process that was ALREADY dead
326
+ // on entry can still reach here; without seeding, `exited` would stay
327
+ // false (the "exit" event already fired before this listener was
328
+ // attached) and the fast/non-fast branches below would still fire a
329
+ // redundant group SIGKILL at the escalation window.
330
+ let exited = proc.exitCode != null || proc.signalCode != null;
331
+ proc.once?.("exit", () => {
332
+ exited = true;
333
+ });
285
334
  if (!killPosixProcessGroup("SIGTERM")) {
286
335
  killDirectChild("SIGTERM");
287
336
  }
288
337
  if (options.fast) {
289
338
  const timer = setTimeout(() => {
290
- if (!proc.killed) {
339
+ if (!exited) {
291
340
  logLatency({
292
341
  type: "phase",
293
342
  phase: "lsp_kill_escalation",
@@ -324,7 +373,7 @@ export async function killProcessTree(proc, pid, options = {}) {
324
373
  }, 1500);
325
374
  proc.once?.("exit", onExit);
326
375
  });
327
- if (!exitedInTime && !proc.killed) {
376
+ if (!exitedInTime && !exited) {
328
377
  logLatency({
329
378
  type: "phase",
330
379
  phase: "lsp_kill_escalation",
@@ -390,16 +439,73 @@ function getMergedDiagnosticsForPath(state, normalizedPath) {
390
439
  return mergeDiagnosticLists(state.pushDiagnostics?.get(normalizedPath) ??
391
440
  legacy.diagnostics?.get(normalizedPath), state.documentPullDiagnostics?.get(normalizedPath));
392
441
  }
393
- function clearDiagnosticsForPath(state, normalizedPath) {
442
+ /** Exported for tests: the quiet-window timer cancel on clear/resync is the
443
+ * headline #1412 safety property (a stale versionless publication must never
444
+ * land after the document content changed). */
445
+ export function clearDiagnosticsForPath(state, normalizedPath) {
394
446
  const legacy = state;
395
447
  state.pushDiagnostics?.delete(normalizedPath);
448
+ const pending = state.pendingDiagnostics?.get(normalizedPath);
449
+ if (pending)
450
+ clearTimeout(pending);
451
+ state.pendingDiagnostics?.delete(normalizedPath);
396
452
  state.pushDiagnosticTimestamps?.delete(normalizedPath);
397
453
  state.documentPullDiagnostics?.delete(normalizedPath);
398
454
  state.documentPullDiagnosticTimestamps?.delete(normalizedPath);
399
455
  state.diagnosticDocVersions?.delete(normalizedPath);
456
+ // #1095: a cleared path must never serve a stale content binding alongside a
457
+ // later publish — drop it with the diagnostics it described. (The last-sent
458
+ // `documentContentHashes` record is intentionally retained: it describes what
459
+ // we sent, which the NEXT publish for that version still needs to bind to.)
460
+ state.diagnosticBindings?.delete(normalizedPath);
461
+ // #1104: a resync invalidates any `unchanged`-report basis too — the next
462
+ // pull must not inherit a resultId/contentHash computed against the
463
+ // content this resync just replaced.
464
+ state.pullResultIds?.delete(normalizedPath);
465
+ state.workspacePullResultCache?.delete(normalizedPath);
400
466
  legacy.diagnostics?.delete(normalizedPath);
401
467
  legacy.diagnosticTimestamps?.delete(normalizedPath);
402
468
  }
469
+ function logTypeScriptPullSettle(state, normalizedPath) {
470
+ if (state.serverId !== "typescript")
471
+ return;
472
+ const diagnostics = state.documentPullDiagnostics.get(normalizedPath) ?? [];
473
+ const elapsedSinceDidOpenMs = Math.max(0, Date.now() - (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
474
+ const diagnosticCodes = [...new Set(diagnostics
475
+ .map((diagnostic) => diagnostic.code)
476
+ .filter((code) => code !== undefined)
477
+ .map(String))].slice(0, 8);
478
+ logLatency({
479
+ type: "phase",
480
+ phase: "lsp_typescript_diagnostic_sequence",
481
+ filePath: normalizedPath,
482
+ durationMs: elapsedSinceDidOpenMs,
483
+ metadata: {
484
+ launchVariant: state.launchVariant ?? "unknown",
485
+ publicationIndex: state.diagnosticPublicationCounts.get(normalizedPath) ?? 0,
486
+ version: null,
487
+ diagnosticCount: diagnostics.length,
488
+ diagnosticCodes,
489
+ elapsedSinceDidOpenMs,
490
+ settledReturn: true,
491
+ settleSource: "pull",
492
+ },
493
+ });
494
+ }
495
+ /**
496
+ * #1095: fingerprint the EXACT didOpen/didChange payload text at SEND time and
497
+ * tag it with the document version it was sent as, so a later
498
+ * `publishDiagnostics` echoing that version can bind its diagnostics to the
499
+ * content they were computed against. Runs on in-memory content — never a disk
500
+ * read on the notification path (I1). Bounded by the same file-size gates the
501
+ * caller already applies to the content it hands us.
502
+ */
503
+ function recordSentContent(state, normalizedPath, version, content) {
504
+ state.documentContentHashes.set(normalizedPath, {
505
+ version,
506
+ hash: hashDiagnosticContent(content),
507
+ });
508
+ }
403
509
  // Methods that can be registered dynamically and map to operationSupport keys
404
510
  const DYNAMIC_OPERATION_METHOD_MAP = {
405
511
  "textDocument/definition": "definition",
@@ -475,12 +581,60 @@ export function setupIncomingHandlers(state, initialization) {
475
581
  state.connection.onNotification("textDocument/publishDiagnostics", (params) => {
476
582
  const filePath = uriToPath(params.uri);
477
583
  const normalizedPath = normalizeMapKey(filePath);
584
+ // A server can flush a queued publish after didClose during teardown.
585
+ // Do not resurrect diagnostics or their content binding for a document
586
+ // that is no longer open on this client.
587
+ if (state.closedDocuments?.has(normalizedPath))
588
+ return;
478
589
  const newDiags = normalizeLspDiagnostics(params.diagnostics || []);
479
590
  const docVersion = params.version;
480
591
  if (PUB_DEBUG) {
481
- console.error(`[lsp-pub] server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`);
592
+ // #1333: PUB_DEBUG gate preserved; sink is extension.log.
593
+ logExtension({
594
+ subsystem: "lsp-pub",
595
+ level: "debug",
596
+ message: `server=${state.serverId} pubVersion=${docVersion} docVersion=${state.documentVersions?.get(normalizedPath)} diags=${newDiags.length}`,
597
+ });
598
+ }
599
+ const strategy = getStrategy(state.serverId, state.launchVariant);
600
+ // Publication counting and code extraction exist only for the
601
+ // TypeScript diagnostic-sequence telemetry; skip the bookkeeping for
602
+ // every other push server on this hot receive path.
603
+ const isTypeScriptTelemetry = state.serverId === "typescript";
604
+ const publicationIndex = isTypeScriptTelemetry
605
+ ? (state.diagnosticPublicationCounts.get(normalizedPath) ?? 0) + 1
606
+ : 0;
607
+ if (isTypeScriptTelemetry) {
608
+ state.diagnosticPublicationCounts.set(normalizedPath, publicationIndex);
482
609
  }
483
- const strategy = getStrategy(state.serverId);
610
+ const diagnosticCodes = isTypeScriptTelemetry
611
+ ? [...new Set(newDiags
612
+ .map((diagnostic) => diagnostic.code)
613
+ .filter((code) => code !== undefined)
614
+ .map(String))].slice(0, 8)
615
+ : [];
616
+ const logSequence = (settledReturn, settleSource) => {
617
+ if (state.serverId !== "typescript")
618
+ return;
619
+ const elapsedSinceDidOpenMs = Math.max(0, Date.now() -
620
+ (state.documentOpenedAt.get(normalizedPath) ?? Date.now()));
621
+ logLatency({
622
+ type: "phase",
623
+ phase: "lsp_typescript_diagnostic_sequence",
624
+ filePath: normalizedPath,
625
+ durationMs: elapsedSinceDidOpenMs,
626
+ metadata: {
627
+ launchVariant: state.launchVariant ?? "unknown",
628
+ publicationIndex,
629
+ version: docVersion ?? null,
630
+ diagnosticCount: newDiags.length,
631
+ diagnosticCodes,
632
+ elapsedSinceDidOpenMs,
633
+ settledReturn,
634
+ ...(settleSource && { settleSource }),
635
+ },
636
+ });
637
+ };
484
638
  // Record the document version these diagnostics were computed against
485
639
  // (when the server reports it) so waitForDiagnostics can reject results
486
640
  // that lag behind the latest didChange instead of serving them as fresh.
@@ -488,6 +642,26 @@ export function setupIncomingHandlers(state, initialization) {
488
642
  if (docVersion !== undefined) {
489
643
  state.diagnosticDocVersions.set(normalizedPath, docVersion);
490
644
  }
645
+ recordBinding();
646
+ };
647
+ // #1095: bind the just-stored diagnostics to the content they were
648
+ // computed against. Only when the server reported a version AND we still
649
+ // hold the sent-content fingerprint for exactly that version — otherwise
650
+ // no contentHash is recorded, so the binding reads "unknown" and a
651
+ // version-less server behaves exactly as before. Runs at the same
652
+ // write-time moment as `pushDiagnostics.set` (superseded pushes are
653
+ // dropped before this via `isSupersededPush`, so a binding never lags the
654
+ // latest sent version).
655
+ const recordBinding = () => {
656
+ if (docVersion === undefined) {
657
+ state.diagnosticBindings.delete(normalizedPath);
658
+ return;
659
+ }
660
+ const sent = state.documentContentHashes.get(normalizedPath);
661
+ state.diagnosticBindings.set(normalizedPath, {
662
+ version: docVersion,
663
+ contentHash: sent && sent.version === docVersion ? sent.hash : undefined,
664
+ });
491
665
  };
492
666
  // Late/superseded-push guard: if the server stamped this push with a
493
667
  // version and that version already lags the latest didChange we sent,
@@ -526,8 +700,10 @@ export function setupIncomingHandlers(state, initialization) {
526
700
  recordDocVersion();
527
701
  state.diagnosticsVersion += 1;
528
702
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
703
+ logSequence(true, "first-push");
529
704
  return;
530
705
  }
706
+ logSequence(false);
531
707
  const existingTimer = state.pendingDiagnostics.get(normalizedPath);
532
708
  if (existingTimer)
533
709
  clearTimeout(existingTimer);
@@ -540,6 +716,7 @@ export function setupIncomingHandlers(state, initialization) {
540
716
  recordDocVersion();
541
717
  state.diagnosticsVersion += 1;
542
718
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
719
+ logSequence(true, "quiet-window");
543
720
  }, strategy.debounceMs);
544
721
  state.pendingDiagnostics.set(normalizedPath, timer);
545
722
  });
@@ -580,8 +757,21 @@ export function setupIncomingHandlers(state, initialization) {
580
757
  if (state.serverEditsAllowed <= 0 || !params?.edit) {
581
758
  return { applied: false, failureReason: "edit not solicited" };
582
759
  }
760
+ const context = (state.activeMutationDepth ?? 0) === 1
761
+ ? state.activeMutationContext
762
+ : undefined;
763
+ const telemetryContext = context ?? {
764
+ cwd: state.root,
765
+ correlationId: newLspMutationCorrelationId(),
766
+ tool: "lsp-workspace-applyEdit",
767
+ source: "lsp-edit",
768
+ };
583
769
  try {
584
- await applyWorkspaceEdit(params.edit, state.root);
770
+ await applyWorkspaceEdit(params.edit, state.root, {
771
+ positionEncoding: state.positionEncoding,
772
+ documentVersions: state.documentVersions,
773
+ mutationContext: telemetryContext,
774
+ });
585
775
  return { applied: true };
586
776
  }
587
777
  catch (err) {
@@ -603,16 +793,30 @@ export function setupIncomingHandlers(state, initialization) {
603
793
  });
604
794
  state.connection.onRequest("window/workDoneProgress/create", async () => { });
605
795
  }
796
+ /**
797
+ * #1127: record the FIRST moment this client observed its own death. Detection
798
+ * of a dead client (the next `getClientForFile` attach in index.ts) can happen
799
+ * long after the process actually died — this timestamp is the only reliable
800
+ * "when did it die" signal, so it must be set here, at the earliest death
801
+ * signal, not derived later from detection time.
802
+ */
803
+ function markExitedIfUnset(state) {
804
+ if (state.exitedAt === undefined) {
805
+ state.exitedAt = Date.now();
806
+ }
807
+ }
606
808
  function setupConnectionLifecycle(state, recentStderr) {
607
809
  state.connection.onError(([error]) => {
608
810
  state.lastError = error instanceof Error ? error : new Error(String(error));
609
811
  state.isConnected = false;
610
812
  state.isDestroyed = true;
813
+ markExitedIfUnset(state);
611
814
  disposeClientConnection(state);
612
815
  });
613
816
  state.connection.onClose(() => {
614
817
  state.isConnected = false;
615
818
  state.isDestroyed = true;
819
+ markExitedIfUnset(state);
616
820
  disposeClientConnection(state);
617
821
  });
618
822
  state.lspProcess.process.on("exit", (code, signal) => {
@@ -627,6 +831,7 @@ function setupConnectionLifecycle(state, recentStderr) {
627
831
  const wasIntentional = state.shutdownRequested;
628
832
  state.isConnected = false;
629
833
  state.isDestroyed = true;
834
+ markExitedIfUnset(state);
630
835
  disposeClientConnection(state);
631
836
  if (!wasIntentional) {
632
837
  logLatency({
@@ -649,6 +854,12 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
649
854
  if (!isClientAlive(state))
650
855
  return { status: "unavailable" };
651
856
  const uri = pathToFileURL(filePath).href;
857
+ const normalizedPath = normalizeMapKey(filePath);
858
+ // #1104: echo the last resultId we hold for this document so a server that
859
+ // hasn't changed its view can answer `kind: "unchanged"` instead of
860
+ // recomputing — see the `kind === "unchanged"` branch below for how that's
861
+ // honored (inherit, never treat an omitted `items` as clean).
862
+ const previousResultId = state.pullResultIds.get(normalizedPath);
652
863
  try {
653
864
  // withTimeout is the backstop against a hung pull-mode server: without it
654
865
  // this await never settles unless the stream is destroyed. Bounded by the
@@ -657,23 +868,69 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
657
868
  // `clean`), so it falls through to the push-wait/timeout backstop.
658
869
  const report = await withTimeout(safeSendRequest(state.connection, "textDocument/diagnostic", {
659
870
  textDocument: { uri },
871
+ ...(previousResultId !== undefined && { previousResultId }),
660
872
  }), Math.max(1, Math.min(PULL_REQUEST_TIMEOUT_MS, budgetMs)));
661
- if (!report)
873
+ if (!report) {
874
+ recordPullFailure(state, "textDocument/diagnostic", new Error("empty response"));
662
875
  return { status: "unavailable" };
663
- const normalizedPath = normalizeMapKey(filePath);
664
- const primaryItems = normalizeLspDiagnostics(report.items ?? []);
876
+ }
665
877
  const now = Date.now();
666
- state.documentPullDiagnostics.set(normalizedPath, primaryItems);
667
- state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
668
- state.diagnosticsVersion += 1;
669
- let totalCount = primaryItems.length;
878
+ // #1104: the fingerprint of the content we last sent for this document —
879
+ // the SAME `documentContentHashes` entry the push binding path uses
880
+ // (`recordSentContent` runs unconditionally on every didOpen/didChange,
881
+ // regardless of push/pull mode), so this costs no extra read. A pull
882
+ // response describes whatever the server had when it answered, which for
883
+ // a pi-lens-opened document is exactly that last-sent payload.
884
+ const sentHash = state.documentContentHashes.get(normalizedPath)?.hash;
885
+ let totalCount;
886
+ if (report.kind === "unchanged") {
887
+ // #1104: same resultId basis as last time — an omitted `items` here
888
+ // means "no change", NOT "clean". Overwriting with `[]` would be the
889
+ // exact false-clean shape #570/#571 already fixed for the touch path;
890
+ // keep the previously stored diagnostics and binding as-is.
891
+ totalCount = state.documentPullDiagnostics.get(normalizedPath)?.length ?? 0;
892
+ // Still a fresh confirmation as of `now` even though the content is
893
+ // unchanged — bump the timestamp so `getAllDiagnostics()` doesn't read
894
+ // this entry as aging purely because the server had nothing new to say.
895
+ state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
896
+ }
897
+ else {
898
+ const primaryItems = normalizeLspDiagnostics(report.items ?? []);
899
+ state.documentPullDiagnostics.set(normalizedPath, primaryItems);
900
+ state.documentPullDiagnosticTimestamps.set(normalizedPath, now);
901
+ state.diagnosticsVersion += 1;
902
+ state.diagnosticBindings.set(normalizedPath, { contentHash: sentHash });
903
+ totalCount = primaryItems.length;
904
+ }
905
+ if (report.resultId !== undefined) {
906
+ state.pullResultIds.set(normalizedPath, report.resultId);
907
+ }
908
+ else {
909
+ state.pullResultIds.delete(normalizedPath);
910
+ }
670
911
  if (report.relatedDocuments) {
671
912
  for (const [relatedUri, related] of Object.entries(report.relatedDocuments)) {
672
913
  const relatedPath = uriToPath(relatedUri);
673
- const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
674
- state.documentPullDiagnostics.set(normalizeMapKey(relatedPath), relatedItems);
675
- state.documentPullDiagnosticTimestamps.set(normalizeMapKey(relatedPath), now);
676
- totalCount += relatedItems.length;
914
+ const relatedNormalized = normalizeMapKey(relatedPath);
915
+ if (related?.kind === "unchanged") {
916
+ totalCount +=
917
+ state.documentPullDiagnostics.get(relatedNormalized)?.length ?? 0;
918
+ state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
919
+ }
920
+ else {
921
+ const relatedItems = normalizeLspDiagnostics(related?.items ?? []);
922
+ state.documentPullDiagnostics.set(relatedNormalized, relatedItems);
923
+ state.documentPullDiagnosticTimestamps.set(relatedNormalized, now);
924
+ // #1104: a related document's diagnostics were NOT computed against
925
+ // content we independently sent/fingerprinted (we never requested
926
+ // it directly) — its binding stays honestly "unknown" rather than
927
+ // borrowing the primary document's hash.
928
+ state.diagnosticBindings.delete(relatedNormalized);
929
+ totalCount += relatedItems.length;
930
+ }
931
+ if (related?.resultId !== undefined) {
932
+ state.pullResultIds.set(relatedNormalized, related.resultId);
933
+ }
677
934
  }
678
935
  }
679
936
  state.diagnosticEmitter.emit("diagnostics", normalizedPath);
@@ -681,10 +938,34 @@ async function clientRequestPullDiagnostics(state, filePath, budgetMs = PULL_REQ
681
938
  ? { status: "found", count: totalCount }
682
939
  : { status: "clean" };
683
940
  }
684
- catch {
941
+ catch (err) {
942
+ recordPullFailure(state, "textDocument/diagnostic", err);
685
943
  return { status: "unavailable" };
686
944
  }
687
945
  }
946
+ const PULL_FAILURE_HISTORY_LIMIT = 10;
947
+ function recordPullFailure(state, method, error) {
948
+ const candidate = error;
949
+ const message = typeof candidate.message === "string" ? candidate.message : "";
950
+ const unsupportedMessage = /^(?:method not found|unknown method|unsupported method)(?::|$)/i;
951
+ if (candidate.code === -32601 ||
952
+ candidate.code === "-32601" ||
953
+ unsupportedMessage.test(message.trim()))
954
+ return;
955
+ state.pullFailureHistory.push({
956
+ timestamp: Date.now(),
957
+ method,
958
+ ...(typeof candidate.code === "number" || typeof candidate.code === "string"
959
+ ? { code: candidate.code }
960
+ : {}),
961
+ message: typeof candidate.message === "string"
962
+ ? candidate.message
963
+ : String(error),
964
+ });
965
+ if (state.pullFailureHistory.length > PULL_FAILURE_HISTORY_LIMIT) {
966
+ state.pullFailureHistory.splice(0, state.pullFailureHistory.length - PULL_FAILURE_HISTORY_LIMIT);
967
+ }
968
+ }
688
969
  /**
689
970
  * One project-wide `workspace/diagnostic` pull — a single request that returns
690
971
  * diagnostics for every document the server knows, instead of opening N files.
@@ -698,23 +979,73 @@ export async function clientRequestWorkspaceDiagnostics(state, budgetMs) {
698
979
  if (!state.workspaceDiagnosticsSupport.workspaceDiagnostics)
699
980
  return undefined;
700
981
  try {
701
- const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds: [] }), Math.max(1, budgetMs));
982
+ // #1104: echo every resultId we hold from a PRIOR pull so the server can
983
+ // answer `kind: "unchanged"` for files it hasn't recomputed, instead of
984
+ // resending (and us re-hashing) every file on every sweep.
985
+ const previousResultIds = Array.from(state.workspacePullResultCache.values()).map((entry) => ({ uri: entry.uri, value: entry.resultId }));
986
+ const report = await withTimeout(safeSendRequest(state.connection, "workspace/diagnostic", { previousResultIds }), Math.max(1, budgetMs));
702
987
  if (!report || !Array.isArray(report.items))
703
988
  return undefined;
704
989
  const out = [];
705
990
  for (const item of report.items) {
706
- // Only "full" reports carry items; "unchanged" means "same as last pull"
707
- // (none, since previousResultIds is empty on this one-shot request).
708
- if (!item?.uri || item.kind !== "full")
991
+ if (!item?.uri)
709
992
  continue;
710
- out.push({
711
- filePath: uriToPath(item.uri),
712
- diagnostics: normalizeLspDiagnostics(item.items ?? []),
713
- });
993
+ const filePath = uriToPath(item.uri);
994
+ const normalizedPath = normalizeMapKey(filePath);
995
+ if (item.kind === "unchanged") {
996
+ // #1104: inherit the prior pull's diagnostics + content binding for
997
+ // the SAME resultId basis — an "unchanged" report never carries
998
+ // `items`, so without this a file the server confirmed unchanged
999
+ // would silently drop out of the sweep result entirely.
1000
+ const prior = state.workspacePullResultCache.get(normalizedPath);
1001
+ if (!prior)
1002
+ continue; // no earlier basis to inherit — nothing to report
1003
+ if (item.resultId !== undefined) {
1004
+ state.workspacePullResultCache.set(normalizedPath, {
1005
+ ...prior,
1006
+ resultId: item.resultId,
1007
+ });
1008
+ }
1009
+ out.push({
1010
+ filePath,
1011
+ diagnostics: prior.diagnostics,
1012
+ contentHash: prior.contentHash,
1013
+ });
1014
+ continue;
1015
+ }
1016
+ // "full" (or a non-conforming server omitting `kind`, per the LSP
1017
+ // default) — recompute and re-fingerprint.
1018
+ const diagnostics = normalizeLspDiagnostics(item.items ?? []);
1019
+ // #1104: fingerprint the file bytes active AT REQUEST TIME. Best-effort —
1020
+ // a read failure (deleted/unreadable mid-sweep) just leaves contentHash
1021
+ // undefined, so the binding reads honestly "unknown", never fabricated.
1022
+ let contentHash;
1023
+ try {
1024
+ contentHash = hashDiagnosticContent(await readFile(filePath, "utf-8"));
1025
+ }
1026
+ catch {
1027
+ contentHash = undefined;
1028
+ }
1029
+ if (item.resultId !== undefined) {
1030
+ if (state.workspacePullResultCache.size >= WORKSPACE_PULL_RESULT_CACHE_MAX) {
1031
+ state.workspacePullResultCache.clear();
1032
+ }
1033
+ state.workspacePullResultCache.set(normalizedPath, {
1034
+ uri: item.uri,
1035
+ resultId: item.resultId,
1036
+ diagnostics,
1037
+ contentHash,
1038
+ });
1039
+ }
1040
+ else {
1041
+ state.workspacePullResultCache.delete(normalizedPath);
1042
+ }
1043
+ out.push({ filePath, diagnostics, contentHash });
714
1044
  }
715
1045
  return out;
716
1046
  }
717
- catch {
1047
+ catch (err) {
1048
+ recordPullFailure(state, "workspace/diagnostic", err);
718
1049
  return undefined;
719
1050
  }
720
1051
  }
@@ -746,10 +1077,12 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
746
1077
  // version baseline (`minVersion === undefined`), so a failed pull returned
747
1078
  // 0 and was read as a fresh clean.
748
1079
  let outcome = await clientRequestPullDiagnostics(state, filePath, timeoutMs);
749
- if (outcome.status === "found")
1080
+ if (outcome.status === "found") {
1081
+ logTypeScriptPullSettle(state, normalizedPath);
750
1082
  return;
1083
+ }
751
1084
  let sawClean = outcome.status === "clean";
752
- const strategy = getStrategy(state.serverId);
1085
+ const strategy = getStrategy(state.serverId, state.launchVariant);
753
1086
  const retryBudgetMs = strategy.pullRetryBudgetMs > 0
754
1087
  ? Math.min(timeoutMs, strategy.pullRetryBudgetMs)
755
1088
  : 0;
@@ -764,10 +1097,16 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
764
1097
  if (outcome.status === "clean")
765
1098
  sawClean = true;
766
1099
  }
767
- if (options.pullOnly)
1100
+ if (options.pullOnly) {
1101
+ if (outcome.status === "found" || sawClean) {
1102
+ logTypeScriptPullSettle(state, normalizedPath);
1103
+ }
768
1104
  return;
769
- if (outcome.status === "found" || sawClean)
1105
+ }
1106
+ if (outcome.status === "found" || sawClean) {
1107
+ logTypeScriptPullSettle(state, normalizedPath);
770
1108
  return;
1109
+ }
771
1110
  }
772
1111
  if (hasFreshDiagnostics() &&
773
1112
  !isVersionStale() &&
@@ -785,7 +1124,7 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
785
1124
  clearTimeout(debounceTimer);
786
1125
  // Adaptive debounce: use time since last push to compute remaining
787
1126
  // wait instead of always waiting the full debounce window.
788
- const strategy = getStrategy(state.serverId);
1127
+ const strategy = getStrategy(state.serverId, state.launchVariant);
789
1128
  const hit = state.pushDiagnosticTimestamps.get(normalizedPath);
790
1129
  const timeSincePush = hit ? Date.now() - hit : Infinity;
791
1130
  const remaining = Math.max(0, strategy.debounceMs - timeSincePush);
@@ -807,8 +1146,8 @@ export async function clientWaitForDiagnostics(state, filePath, timeoutMs, optio
807
1146
  export async function handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics = false, silent = false) {
808
1147
  if (!isClientAlive(state))
809
1148
  return;
810
- const uri = pathToFileURL(filePath).href;
811
1149
  const normalizedPath = normalizeMapKey(filePath);
1150
+ const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
812
1151
  if (state.openDocuments.has(normalizedPath) ||
813
1152
  state.pendingOpens.has(normalizedPath)) {
814
1153
  const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
@@ -816,34 +1155,57 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
816
1155
  // preserveDiagnostics: skip cache clear for format-only resyncs so
817
1156
  // waitForDiagnostics fast-paths instead of waiting up to 5s for TypeScript
818
1157
  // to re-publish what it already knows (formatting doesn't change semantics).
1158
+ // #1095 note: this also retains the prior content `binding`. Until the
1159
+ // server republishes for the new version, a binding read compares the OLD
1160
+ // content hash against the NEW disk bytes → boundToCurrentDisk `false` →
1161
+ // the consumer demotes to inconclusive. That's the SAFE (#533) direction (a
1162
+ // transient "unconfirmed", never a false-clean), self-healing on the next
1163
+ // publish — not a correctness hazard, unlike the reopen false-TRUE above.
819
1164
  if (!preserveDiagnostics) {
820
1165
  clearDiagnosticsForPath(state, normalizedPath);
821
1166
  }
822
1167
  // Scanners that only re-scan on a fresh open (opengrep ignores didChange):
823
1168
  // close + reopen so the re-edit actually triggers a re-scan instead of
824
1169
  // silently publishing nothing.
825
- if (getStrategy(state.serverId).reopenOnResync) {
1170
+ if (getStrategy(state.serverId, state.launchVariant).reopenOnResync) {
826
1171
  await safeSendNotification(state.connection, "textDocument/didClose", {
827
1172
  textDocument: { uri },
828
1173
  });
829
1174
  state.openDocuments.delete(normalizedPath);
830
- state.documentVersions.set(normalizedPath, 0);
1175
+ state.openDocumentUris?.delete(normalizedPath);
1176
+ // #1095 (P2-3): carry the version counter FORWARD across the
1177
+ // close+reopen instead of resetting to 0. LSP lets a didOpen use any
1178
+ // version, and reusing 0 for successive resyncs made the version
1179
+ // ambiguous — a late publish for an earlier resync's content echoed the
1180
+ // SAME 0 as the current send, so the superseded-push guard (0 < 0 is
1181
+ // false) accepted it and `recordBinding` bound STALE diagnostics to the
1182
+ // CURRENT content's fingerprint → an affirmative boundToCurrentDisk TRUE
1183
+ // for a stale view (worse than "unknown"). Monotonic versions make that
1184
+ // late echo strictly older → dropped by isSupersededPush → never bound.
1185
+ state.documentVersions.set(normalizedPath, version);
1186
+ state.documentOpenedAt.set(normalizedPath, Date.now());
1187
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
831
1188
  if (!isClientAlive(state))
832
1189
  return;
833
1190
  await safeSendNotification(state.connection, "textDocument/didOpen", {
834
- textDocument: { uri, languageId, version: 0, text: content },
1191
+ textDocument: { uri, languageId, version, text: content },
835
1192
  });
1193
+ recordSentContent(state, normalizedPath, version, content);
836
1194
  state.openDocuments.add(normalizedPath);
1195
+ state.openDocumentUris?.set(normalizedPath, uri);
837
1196
  return;
838
1197
  }
839
1198
  await safeSendNotification(state.connection, "textDocument/didChange", {
840
1199
  textDocument: { uri, version },
841
1200
  contentChanges: [{ text: content }],
842
1201
  });
1202
+ recordSentContent(state, normalizedPath, version, content);
843
1203
  return;
844
1204
  }
845
1205
  state.pendingOpens.add(normalizedPath);
846
1206
  state.documentVersions.set(normalizedPath, 0);
1207
+ state.documentOpenedAt.set(normalizedPath, Date.now());
1208
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
847
1209
  clearDiagnosticsForPath(state, normalizedPath); // always clear for initial open
848
1210
  // Send workspace notification first (like opencode does).
849
1211
  // Skipped in silent mode — cascade reads a file for diagnostics,
@@ -873,14 +1235,35 @@ export async function handleNotifyOpen(state, filePath, content, languageId, pre
873
1235
  await safeSendNotification(state.connection, "textDocument/didOpen", {
874
1236
  textDocument: { uri, languageId, version: 0, text: content },
875
1237
  });
1238
+ recordSentContent(state, normalizedPath, 0, content);
876
1239
  state.pendingOpens.delete(normalizedPath);
877
1240
  state.openDocuments.add(normalizedPath);
1241
+ state.closedDocuments?.delete(normalizedPath);
1242
+ state.openDocumentUris?.set(normalizedPath, uri);
1243
+ // Telemetry is deliberately detached after didOpen succeeds.
1244
+ // #1412 H1: routed through runReadOnlyServerCommand, NOT runServerCommand —
1245
+ // the probe must never open the serverEditsAllowed/activeMutationContext
1246
+ // mutation-acceptance window; it is a diagnostic sample, not a mutation, and
1247
+ // carries its own short PROBE_COMMAND_TIMEOUT_MS backstop. The probe itself
1248
+ // is classic-only and swallows every failure.
1249
+ void probeTsserverProjectIdentity({
1250
+ serverId: state.serverId,
1251
+ launchVariant: state.launchVariant,
1252
+ clientRoot: state.root,
1253
+ file: filePath,
1254
+ normalizedFile: normalizedPath,
1255
+ probedFiles: state.projectIdentityProbedFiles ??
1256
+ (state.projectIdentityProbedFiles = new Set()),
1257
+ commandChannel: {
1258
+ executeCommand: (command, args) => runReadOnlyServerCommand(state, command, args),
1259
+ },
1260
+ });
878
1261
  }
879
1262
  export async function handleNotifyChange(state, filePath, content) {
880
1263
  if (!isClientAlive(state))
881
1264
  return;
882
- const uri = pathToFileURL(filePath).href;
883
1265
  const normalizedPath = normalizeMapKey(filePath);
1266
+ const uri = state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href;
884
1267
  if (!state.openDocuments.has(normalizedPath)) {
885
1268
  // Safety fallback: keep protocol ordering valid even if caller sends
886
1269
  // didChange before first didOpen for this document.
@@ -888,7 +1271,11 @@ export async function handleNotifyChange(state, filePath, content) {
888
1271
  textDocument: { uri, languageId: "plaintext", version: 0, text: content },
889
1272
  });
890
1273
  state.documentVersions.set(normalizedPath, 0);
1274
+ state.documentOpenedAt.set(normalizedPath, Date.now());
1275
+ state.diagnosticPublicationCounts.set(normalizedPath, 0);
1276
+ recordSentContent(state, normalizedPath, 0, content);
891
1277
  state.openDocuments.add(normalizedPath);
1278
+ state.openDocumentUris?.set(normalizedPath, uri);
892
1279
  return;
893
1280
  }
894
1281
  const version = (state.documentVersions.get(normalizedPath) ?? 0) + 1;
@@ -900,6 +1287,32 @@ export async function handleNotifyChange(state, filePath, content) {
900
1287
  textDocument: { uri, version },
901
1288
  contentChanges: [{ text: content }],
902
1289
  });
1290
+ recordSentContent(state, normalizedPath, version, content);
1291
+ }
1292
+ /** Close a document through the same lifecycle path exposed by the client. */
1293
+ export async function closeDocument(state, filePath) {
1294
+ if (!isClientAlive(state))
1295
+ return;
1296
+ const normalizedPath = normalizeMapKey(filePath);
1297
+ if (!state.openDocuments.has(normalizedPath))
1298
+ return;
1299
+ await safeSendNotification(state.connection, "textDocument/didClose", {
1300
+ textDocument: {
1301
+ uri: state.openDocumentUris?.get(normalizedPath) ?? pathToFileURL(filePath).href,
1302
+ },
1303
+ });
1304
+ state.openDocuments.delete(normalizedPath);
1305
+ state.closedDocuments?.add(normalizedPath);
1306
+ state.openDocumentUris?.delete(normalizedPath);
1307
+ state.documentVersions.delete(normalizedPath);
1308
+ state.documentOpenedAt.delete(normalizedPath);
1309
+ state.diagnosticPublicationCounts.delete(normalizedPath);
1310
+ // #1412 L1: projectIdentityProbedFiles is a claim-once memo scoped to the
1311
+ // document's open lifetime (re-probing a closed-then-reopened file is
1312
+ // harmless and cheap) — mirror openDocuments' own per-close cleanup so it
1313
+ // doesn't grow unbounded across a long session's worth of open/close churn.
1314
+ state.projectIdentityProbedFiles?.delete(normalizedPath);
1315
+ clearDiagnosticsForPath(state, normalizedPath);
903
1316
  }
904
1317
  export async function clientShutdown(state, options = {}) {
905
1318
  const shutdownStart = Date.now();
@@ -912,6 +1325,10 @@ export async function clientShutdown(state, options = {}) {
912
1325
  state.pendingDiagnostics.clear();
913
1326
  state.pendingOpens.clear();
914
1327
  state.openDocuments.clear();
1328
+ state.openDocumentUris?.clear();
1329
+ // #1412 L1: mirror openDocuments' clear — a shut-down/evicted client's
1330
+ // probe memo is moot along with everything else document-scoped.
1331
+ state.projectIdentityProbedFiles?.clear();
915
1332
  // #271: drop any pending watched-files batch + its timer (a dying client's
916
1333
  // queued FS changes are moot, and the timer must not outlive the connection).
917
1334
  state.watchQueue?.cancel();
@@ -1030,13 +1447,44 @@ signal = getAmbientAbortSignal()) {
1030
1447
  }
1031
1448
  return result;
1032
1449
  }
1450
+ // #1277: cheap liveness round-trip used by the silent-clean gates in
1451
+ // `index.ts`. `isAlive()`/`checkAlive()` only look at process/connection
1452
+ // state — a server that accepted the notify write and then wedged (still
1453
+ // running, connection still open, just never replying) reads as "alive" by
1454
+ // those checks even though it will never answer anything again. This sends a
1455
+ // real request (`workspace/symbol`, chosen because it needs no open document)
1456
+ // and reports whether the connection round-tripped it — success, a genuine
1457
+ // protocol-level error (e.g. MethodNotFound), and a stream-destroyed/
1458
+ // cancelled response (safeSendRequest swallows those to `undefined`, so the
1459
+ // final `isClientAlive` re-check is what catches "died mid-flight") ALL count
1460
+ // as "alive"; only a real timeout, or the connection having gone down by the
1461
+ // time this resolves, reports dead. The response content itself is never
1462
+ // inspected — only whether one arrived in time.
1463
+ async function clientPingLiveness(state, timeoutMs = LIVENESS_PING_TIMEOUT_MS) {
1464
+ if (!isClientAlive(state))
1465
+ return false;
1466
+ try {
1467
+ await withTimeout(safeSendRequest(state.connection, "workspace/symbol", {
1468
+ query: LIVENESS_PING_QUERY,
1469
+ }), timeoutMs);
1470
+ }
1471
+ catch (err) {
1472
+ if (err instanceof Error && err.message.startsWith("Timeout after")) {
1473
+ return false;
1474
+ }
1475
+ // A real protocol-level error reply still proves the server round-
1476
+ // tripped the request — fall through to the alive re-check below
1477
+ // rather than treating an error response as "dead".
1478
+ }
1479
+ return isClientAlive(state);
1480
+ }
1033
1481
  // Run an advertised server command via workspace/executeCommand, with the
1034
1482
  // generous EXECUTE_COMMAND_TIMEOUT_MS anti-deadlock backstop. Preserves the
1035
1483
  // hardening invariants: allowlist-by-advertisement (only commands the server
1036
1484
  // declared) and the serverEditsAllowed window that gates server-driven
1037
1485
  // applyEdit to the duration of an explicit call. Exported with an overridable
1038
1486
  // `timeoutMs` for the #365 regression tests.
1039
- export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS) {
1487
+ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE_COMMAND_TIMEOUT_MS, mutationContext) {
1040
1488
  if (!isClientAlive(state)) {
1041
1489
  return { executed: false, reason: "lsp client not alive" };
1042
1490
  }
@@ -1047,6 +1495,11 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
1047
1495
  };
1048
1496
  }
1049
1497
  state.serverEditsAllowed += 1;
1498
+ state.activeMutationDepth = (state.activeMutationDepth ?? 0) + 1;
1499
+ if (state.activeMutationDepth === 1)
1500
+ state.activeMutationContext = mutationContext;
1501
+ else
1502
+ state.activeMutationContext = undefined;
1050
1503
  try {
1051
1504
  let result;
1052
1505
  try {
@@ -1072,22 +1525,122 @@ export async function runServerCommand(state, command, args, timeoutMs = EXECUTE
1072
1525
  }
1073
1526
  finally {
1074
1527
  state.serverEditsAllowed -= 1;
1528
+ state.activeMutationDepth = Math.max(0, (state.activeMutationDepth ?? 0) - 1);
1529
+ if (state.activeMutationDepth === 0)
1530
+ state.activeMutationContext = undefined;
1531
+ }
1532
+ }
1533
+ // #1412 H1/H2: read-only sibling of runServerCommand for telemetry/identity
1534
+ // probes that must NOT participate in the mutation-acceptance window. Unlike
1535
+ // runServerCommand this never touches serverEditsAllowed, activeMutationDepth,
1536
+ // or activeMutationContext — a probe firing mid-flight must leave a concurrent
1537
+ // real executeCommand's mutation context untouched, and must not itself open
1538
+ // the workspace/applyEdit acceptance window (client.ts's applyEdit handler
1539
+ // gates on serverEditsAllowed > 0). Preserves the allowlist-by-advertisement
1540
+ // invariant. Short PROBE_COMMAND_TIMEOUT_MS backstop — this is a diagnostic
1541
+ // sample, not a mutation, and must never hold anything up for anywhere near
1542
+ // EXECUTE_COMMAND_TIMEOUT_MS.
1543
+ export async function runReadOnlyServerCommand(state, command, args, timeoutMs = PROBE_COMMAND_TIMEOUT_MS) {
1544
+ if (!isClientAlive(state)) {
1545
+ return { executed: false, reason: "lsp client not alive" };
1546
+ }
1547
+ if (!state.advertisedCommands.has(command)) {
1548
+ return {
1549
+ executed: false,
1550
+ reason: `command "${command}" is not advertised by the ${state.serverId} server`,
1551
+ };
1075
1552
  }
1553
+ try {
1554
+ const result = await withTimeout(safeSendRequest(state.connection, "workspace/executeCommand", {
1555
+ command,
1556
+ arguments: args ?? [],
1557
+ }), timeoutMs);
1558
+ return { executed: true, result };
1559
+ }
1560
+ catch (err) {
1561
+ if (err instanceof Error && err.message.startsWith("Timeout after")) {
1562
+ return {
1563
+ executed: false,
1564
+ reason: `workspace/executeCommand timed out after ${timeoutMs}ms`,
1565
+ };
1566
+ }
1567
+ throw err;
1568
+ }
1569
+ }
1570
+ function validateWorkspaceEditVersions(state, edit) {
1571
+ for (const change of edit.documentChanges ?? []) {
1572
+ if (typeof change !== "object" || change === null || !("textDocument" in change))
1573
+ continue;
1574
+ const textDocument = change.textDocument;
1575
+ if (!textDocument || typeof textDocument.uri !== "string" || textDocument.version == null)
1576
+ continue;
1577
+ const current = state.documentVersions.get(normalizeMapKey(uriToPath(textDocument.uri)));
1578
+ if (current === undefined || current !== textDocument.version) {
1579
+ throw new Error(`stale workspace edit document version for ${textDocument.uri}`);
1580
+ }
1581
+ }
1582
+ }
1583
+ // Neutralize numeric `textDocument.version` stamps AFTER they have been
1584
+ // validated against the live document map. The tool apply paths (rename
1585
+ // apply:true in tools/lsp-navigation.ts, code-action autofix in
1586
+ // actionable-warnings.ts) call applyWorkspaceEdit without a documentVersions
1587
+ // map, so a preserved numeric version would fail preflight 100% of the time for
1588
+ // servers that stamp real versions (gopls stamps open documents). Setting the
1589
+ // version to null is the spec's "do not check" — the freshness guarantee has
1590
+ // already been provided here by validateWorkspaceEditVersions at the correct
1591
+ // moment. The server-initiated workspace/applyEdit handler does NOT route
1592
+ // through here (it applies params.edit directly with state.documentVersions),
1593
+ // so its real preflight version check is left fully intact.
1594
+ function stripDocumentVersions(edit) {
1595
+ if (!Array.isArray(edit.documentChanges))
1596
+ return edit;
1597
+ const documentChanges = edit.documentChanges.map((change) => {
1598
+ if (typeof change === "object" &&
1599
+ change !== null &&
1600
+ "textDocument" in change &&
1601
+ "edits" in change) {
1602
+ const textDocument = change
1603
+ .textDocument;
1604
+ if (textDocument && typeof textDocument.version === "number") {
1605
+ return {
1606
+ ...change,
1607
+ textDocument: { ...textDocument, version: null },
1608
+ };
1609
+ }
1610
+ }
1611
+ return change;
1612
+ });
1613
+ return { ...edit, documentChanges };
1614
+ }
1615
+ export async function normalizeClientWorkspaceEdit(state, edit) {
1616
+ validateWorkspaceEditVersions(state, edit);
1617
+ const normalized = (await normalizeWorkspaceEditToUtf16(edit, state.positionEncoding, state.root));
1618
+ return stripDocumentVersions(normalized);
1076
1619
  }
1077
1620
  async function resolveCodeActionBestEffort(state, action) {
1078
- if (!isClientAlive(state) || action.edit)
1621
+ if (!isClientAlive(state))
1079
1622
  return action;
1623
+ if (action.edit) {
1624
+ return {
1625
+ ...action,
1626
+ edit: await normalizeClientWorkspaceEdit(state, action.edit),
1627
+ };
1628
+ }
1629
+ let resolved;
1080
1630
  try {
1081
- const resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
1082
- if (!resolved || typeof resolved !== "object")
1083
- return action;
1084
- return { ...action, ...resolved };
1631
+ resolved = await withTimeout(safeSendRequest(state.connection, "codeAction/resolve", action), NAV_REQUEST_TIMEOUT_MS);
1085
1632
  }
1086
1633
  catch {
1087
1634
  // codeAction/resolve is optional. Keep the original lightweight action when
1088
1635
  // the server does not support resolve or fails to populate an edit.
1089
1636
  return action;
1090
1637
  }
1638
+ if (!resolved || typeof resolved !== "object")
1639
+ return action;
1640
+ const merged = { ...action, ...resolved };
1641
+ return merged.edit
1642
+ ? { ...merged, edit: await normalizeClientWorkspaceEdit(state, merged.edit) }
1643
+ : merged;
1091
1644
  }
1092
1645
  // --- Client Factory ---
1093
1646
  export async function createLSPClient(options) {
@@ -1187,6 +1740,7 @@ export async function createLSPClient(options) {
1187
1740
  isConnected: true,
1188
1741
  isDestroyed: false,
1189
1742
  shutdownRequested: false,
1743
+ exitedAt: undefined,
1190
1744
  connectionDisposed: false,
1191
1745
  lastError: undefined,
1192
1746
  connection,
@@ -1194,13 +1748,23 @@ export async function createLSPClient(options) {
1194
1748
  pushDiagnosticTimestamps: new Map(),
1195
1749
  documentPullDiagnostics: new Map(),
1196
1750
  documentPullDiagnosticTimestamps: new Map(),
1751
+ pullFailureHistory: [],
1197
1752
  pendingDiagnostics: new Map(),
1753
+ diagnosticPublicationCounts: new Map(),
1754
+ documentOpenedAt: new Map(),
1198
1755
  diagnosticEmitter,
1199
1756
  diagnosticsVersion: 0,
1200
1757
  documentVersions: new Map(),
1201
1758
  diagnosticDocVersions: new Map(),
1759
+ documentContentHashes: new Map(),
1760
+ diagnosticBindings: new Map(),
1761
+ pullResultIds: new Map(),
1762
+ workspacePullResultCache: new Map(),
1202
1763
  openDocuments: new Set(),
1764
+ closedDocuments: new Set(),
1765
+ openDocumentUris: new Map(),
1203
1766
  pendingOpens: new Set(),
1767
+ projectIdentityProbedFiles: new Set(),
1204
1768
  // these are filled in after initialize — cast to avoid two-phase init
1205
1769
  workspaceDiagnosticsSupport: undefined,
1206
1770
  operationSupport: undefined,
@@ -1209,6 +1773,7 @@ export async function createLSPClient(options) {
1209
1773
  dynamicRegistrations: new Map(),
1210
1774
  advertisedCommands: new Set(),
1211
1775
  serverEditsAllowed: 0,
1776
+ activeMutationDepth: 0,
1212
1777
  serverId,
1213
1778
  launchVariant,
1214
1779
  root,
@@ -1258,7 +1823,22 @@ export async function createLSPClient(options) {
1258
1823
  });
1259
1824
  });
1260
1825
  setTimeout(() => {
1261
- if (!lspProcess.process.killed && process.platform !== "win32") {
1826
+ // #1114: gate on the process's own observed `exitCode`/`signalCode`,
1827
+ // not `.killed` — `killProcessTree` above signals the POSIX process
1828
+ // GROUP via the raw `process.kill(-pid, …)`, which never touches
1829
+ // this `ChildProcess` instance's `.killed` flag, so `!…killed` here
1830
+ // was always true and this 2s backstop unconditionally re-sent
1831
+ // SIGKILL even when the group had already exited. `exitCode` alone
1832
+ // is insufficient too: a process that died FROM a signal (the
1833
+ // common case here — killProcessTree's own SIGTERM/SIGKILL) has
1834
+ // `exitCode === null` forever and only `signalCode` set, so
1835
+ // checking `exitCode === null` alone still re-SIGKILLs that corpse
1836
+ // on the common path (harmless — `kill()` on an already-exited pid
1837
+ // is a swallowed no-op — but not actually "observed still alive").
1838
+ // Require both null to mean "no exit observed by either signal".
1839
+ if (lspProcess.process.exitCode === null &&
1840
+ lspProcess.process.signalCode === null &&
1841
+ process.platform !== "win32") {
1262
1842
  lspProcess.process.kill("SIGKILL");
1263
1843
  }
1264
1844
  }, 2000);
@@ -1312,10 +1892,20 @@ export async function createLSPClient(options) {
1312
1892
  /** True if the server process has exited or been killed. */
1313
1893
  processExited: () => lspProcess.process.exitCode !== null ||
1314
1894
  lspProcess.process.killed === true,
1895
+ /** #1127: mirrors `state.shutdownRequested` — see interface doc. */
1896
+ wasShutdownIntentional: () => state.shutdownRequested,
1897
+ /** #1127: mirrors `state.exitedAt` — see interface doc. */
1898
+ getExitedAt: () => state.exitedAt,
1315
1899
  /** Last N lines of server stderr for diagnostics. */
1316
1900
  recentStderr: (lines) => recentStderr(lines),
1901
+ getPullFailureHistory: () => state.pullFailureHistory.map((entry) => ({
1902
+ ...entry,
1903
+ message: entry.message.slice(0, 200),
1904
+ })),
1317
1905
  /** Pre-request health check — returns error string if dead. */
1318
1906
  checkAlive: () => checkProcessAlive(),
1907
+ /** #1277: cheap request round-trip proving the server still responds. */
1908
+ pingLiveness: (timeoutMs) => clientPingLiveness(state, timeoutMs),
1319
1909
  notify: {
1320
1910
  async open(filePath, content, languageId, preserveDiagnostics, silent) {
1321
1911
  return handleNotifyOpen(state, filePath, content, languageId, preserveDiagnostics, silent);
@@ -1327,6 +1917,9 @@ export async function createLSPClient(options) {
1327
1917
  getDiagnostics(filePath) {
1328
1918
  return getMergedDiagnosticsForPath(state, normalizeMapKey(filePath));
1329
1919
  },
1920
+ getDiagnosticBinding(filePath) {
1921
+ return state.diagnosticBindings.get(normalizeMapKey(filePath));
1922
+ },
1330
1923
  getAllDiagnostics() {
1331
1924
  const result = new Map();
1332
1925
  const keys = new Set([
@@ -1337,17 +1930,16 @@ export async function createLSPClient(options) {
1337
1930
  result.set(key, {
1338
1931
  diags: getMergedDiagnosticsForPath(state, key),
1339
1932
  ts: Math.max(state.pushDiagnosticTimestamps.get(key) ?? 0, state.documentPullDiagnosticTimestamps.get(key) ?? 0),
1933
+ binding: state.diagnosticBindings.get(key),
1340
1934
  });
1341
1935
  }
1342
1936
  return result;
1343
1937
  },
1344
1938
  getTrackedDiagnosticPaths() {
1345
- return [
1346
- ...new Set([
1939
+ return [...new Set([
1347
1940
  ...state.pushDiagnostics.keys(),
1348
1941
  ...state.documentPullDiagnostics.keys(),
1349
- ]),
1350
- ];
1942
+ ])].map((filePath) => process.platform === "win32" ? filePath.replace(/\//g, "\\") : filePath);
1351
1943
  },
1352
1944
  pruneDiagnostics(predicate) {
1353
1945
  let removed = 0;
@@ -1383,8 +1975,8 @@ export async function createLSPClient(options) {
1383
1975
  getLaunchVariant() {
1384
1976
  return state.launchVariant;
1385
1977
  },
1386
- async executeCommand(command, args) {
1387
- return runServerCommand(state, command, args);
1978
+ async executeCommand(command, args, mutationContext) {
1979
+ return runServerCommand(state, command, args, EXECUTE_COMMAND_TIMEOUT_MS, mutationContext);
1388
1980
  },
1389
1981
  get diagnosticsVersion() {
1390
1982
  return state.diagnosticsVersion;
@@ -1448,6 +2040,12 @@ export async function createLSPClient(options) {
1448
2040
  isDocumentOpen(filePath) {
1449
2041
  return state.openDocuments.has(normalizeMapKey(filePath));
1450
2042
  },
2043
+ isBusy() {
2044
+ return (activeRequestsByConnection.get(connection) ?? 0) > 0;
2045
+ },
2046
+ getDocumentUri(filePath) {
2047
+ return state.openDocumentUris?.get(normalizeMapKey(filePath));
2048
+ },
1451
2049
  async workspaceSymbol(query) {
1452
2050
  if (!isClientAlive(state))
1453
2051
  return [];
@@ -1487,8 +2085,9 @@ export async function createLSPClient(options) {
1487
2085
  position: await toWirePosition(state, filePath, line, character),
1488
2086
  newName,
1489
2087
  }, filePath);
1490
- return result ?? null;
2088
+ return result ? await normalizeClientWorkspaceEdit(state, result) : null;
1491
2089
  },
2090
+ closeDocument: (filePath) => closeDocument(state, filePath),
1492
2091
  async willRenameFiles(oldFilePath, newFilePath) {
1493
2092
  const result = await navRequest(state, "workspace/willRenameFiles", {
1494
2093
  files: [
@@ -1498,16 +2097,16 @@ export async function createLSPClient(options) {
1498
2097
  },
1499
2098
  ],
1500
2099
  });
1501
- return result ?? null;
2100
+ return result ? await normalizeClientWorkspaceEdit(state, result) : null;
1502
2101
  },
1503
- async didRenameFiles(oldFilePath, newFilePath) {
2102
+ async didRenameFiles(oldFilePath, newFilePath, oldUri, newUri) {
1504
2103
  if (!isClientAlive(state))
1505
2104
  return;
1506
2105
  await safeSendNotification(state.connection, "workspace/didRenameFiles", {
1507
2106
  files: [
1508
2107
  {
1509
- oldUri: pathToFileURL(oldFilePath).href,
1510
- newUri: pathToFileURL(newFilePath).href,
2108
+ oldUri: oldUri ?? pathToFileURL(oldFilePath).href,
2109
+ newUri: newUri ?? pathToFileURL(newFilePath).href,
1511
2110
  },
1512
2111
  ],
1513
2112
  });
@@ -1556,6 +2155,7 @@ async function safeSendNotification(connection, method, params) {
1556
2155
  throw err;
1557
2156
  }
1558
2157
  }
2158
+ const activeRequestsByConnection = new WeakMap();
1559
2159
  // Helper to safely send requests - catches stream destruction
1560
2160
  async function safeSendRequest(connection, method, params,
1561
2161
  // When provided, aborting the signal cancels the in-flight request via
@@ -1578,6 +2178,7 @@ signal) {
1578
2178
  const send = () => tokenSource
1579
2179
  ? connection.sendRequest(method, params, tokenSource.token)
1580
2180
  : connection.sendRequest(method, params);
2181
+ activeRequestsByConnection.set(connection, (activeRequestsByConnection.get(connection) ?? 0) + 1);
1581
2182
  try {
1582
2183
  // One safe retry on ContentModified (-32801): the document changed under
1583
2184
  // us, so the server discarded the request. A single retry beats returning
@@ -1607,6 +2208,11 @@ signal) {
1607
2208
  }
1608
2209
  }
1609
2210
  finally {
2211
+ const remaining = (activeRequestsByConnection.get(connection) ?? 1) - 1;
2212
+ if (remaining > 0)
2213
+ activeRequestsByConnection.set(connection, remaining);
2214
+ else
2215
+ activeRequestsByConnection.delete(connection);
1610
2216
  if (signal && onAbort)
1611
2217
  signal.removeEventListener("abort", onAbort);
1612
2218
  tokenSource?.dispose();