@herbertgao/pi-extensions 2026.8.3 → 2026.8.5

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 (408) hide show
  1. package/README.md +45 -4
  2. package/examples/pi-footer.json +275 -0
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +10 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/README.md +10 -2
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  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 +5 -8
  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 +8 -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 +15 -2
  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/schedule-menu.ts +9 -8
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  26. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  27. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  31. package/node_modules/@narumitw/pi-btw/README.md +8 -7
  32. package/node_modules/@narumitw/pi-btw/package.json +2 -2
  33. package/node_modules/@narumitw/pi-btw/src/menu.ts +1 -0
  34. package/node_modules/pi-footer/CHANGELOG.md +145 -0
  35. package/node_modules/pi-footer/LICENSE +21 -0
  36. package/node_modules/pi-footer/README.md +354 -0
  37. package/node_modules/pi-footer/package.json +68 -0
  38. package/node_modules/pi-footer/src/cache.ts +106 -0
  39. package/node_modules/pi-footer/src/colors.ts +237 -0
  40. package/node_modules/pi-footer/src/config.ts +202 -0
  41. package/node_modules/pi-footer/src/event-widgets.ts +41 -0
  42. package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
  43. package/node_modules/pi-footer/src/git.ts +135 -0
  44. package/node_modules/pi-footer/src/index.ts +268 -0
  45. package/node_modules/pi-footer/src/metrics.ts +141 -0
  46. package/node_modules/pi-footer/src/presets.ts +383 -0
  47. package/node_modules/pi-footer/src/render.ts +114 -0
  48. package/node_modules/pi-footer/src/separators.ts +52 -0
  49. package/node_modules/pi-footer/src/types.ts +122 -0
  50. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
  51. package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
  52. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
  53. package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
  54. package/node_modules/pi-footer/src/ui/events.ts +35 -0
  55. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
  56. package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
  57. package/node_modules/pi-footer/src/ui/fields.ts +252 -0
  58. package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
  59. package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
  60. package/node_modules/pi-footer/src/ui/layout.ts +17 -0
  61. package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
  62. package/node_modules/pi-footer/src/ui/model.ts +64 -0
  63. package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
  64. package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
  65. package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
  66. package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
  67. package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
  68. package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
  69. package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
  70. package/node_modules/pi-footer/src/ui/screen.ts +243 -0
  71. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
  72. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
  73. package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
  74. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
  75. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
  76. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
  77. package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
  78. package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
  79. package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
  80. package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
  81. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
  82. package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
  83. package/node_modules/pi-footer/src/ui/theme.ts +48 -0
  84. package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
  85. package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
  86. package/node_modules/pi-footer/src/ui.ts +66 -0
  87. package/node_modules/pi-footer/src/widgets/context.ts +28 -0
  88. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
  89. package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
  90. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
  91. package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
  92. package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
  93. package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
  94. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
  95. package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
  96. package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
  97. package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
  98. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
  99. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
  100. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
  101. package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
  102. package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
  103. package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
  104. package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
  105. package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
  106. package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
  107. package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
  108. package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
  109. package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
  110. package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
  111. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
  112. package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
  113. package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
  114. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
  115. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
  116. package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
  117. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
  118. package/node_modules/pi-footer/src/widgets/options.ts +122 -0
  119. package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
  120. package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
  121. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
  122. package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
  123. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
  124. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
  125. package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
  126. package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
  127. package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
  128. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
  129. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
  130. package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
  131. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
  132. package/node_modules/pi-footer/src/widgets/store.ts +26 -0
  133. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
  134. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
  135. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
  136. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
  137. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
  138. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
  139. package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
  140. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
  141. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
  142. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
  143. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
  144. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
  145. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
  146. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
  147. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
  148. package/node_modules/pi-footer/src/widgets/types.ts +181 -0
  149. package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
  150. package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
  151. package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
  152. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
  153. package/node_modules/pi-lens/CHANGELOG.md +2440 -954
  154. package/node_modules/pi-lens/README.md +13 -0
  155. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  156. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  157. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  158. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  159. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  160. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  161. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  162. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  163. package/node_modules/pi-lens/dist/clients/biome-client.js +29 -25
  164. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  165. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  166. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  167. package/node_modules/pi-lens/dist/clients/bus-publish.js +10 -3
  168. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  169. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  170. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  171. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  172. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  173. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  174. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  175. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  176. package/node_modules/pi-lens/dist/clients/console-guard-install.js +14 -0
  177. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  178. package/node_modules/pi-lens/dist/clients/dead-code-client.js +2 -1
  179. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  180. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  181. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  182. package/node_modules/pi-lens/dist/clients/dependency-checker.js +347 -75
  183. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +61 -11
  184. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  185. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +63 -20
  186. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  187. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  188. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  189. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  190. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  191. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1085 -681
  192. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  193. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  194. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  195. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  196. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  197. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  198. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  199. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  201. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  202. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  203. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +14 -6
  204. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  205. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  206. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  207. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  208. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  209. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  210. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  211. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  212. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  213. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  214. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  215. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  216. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +277 -26
  217. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  218. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  219. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  220. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  221. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  222. package/node_modules/pi-lens/dist/clients/extension-log.js +139 -0
  223. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  224. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  225. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  226. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  227. package/node_modules/pi-lens/dist/clients/format-events-publish.js +16 -3
  228. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  229. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  230. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  231. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  232. package/node_modules/pi-lens/dist/clients/git-guard.js +681 -11
  233. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  234. package/node_modules/pi-lens/dist/clients/go-client.js +2 -1
  235. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +8 -0
  236. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  237. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  238. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  239. package/node_modules/pi-lens/dist/clients/installer/index.js +390 -99
  240. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  241. package/node_modules/pi-lens/dist/clients/jscpd-client.js +31 -54
  242. package/node_modules/pi-lens/dist/clients/knip-client.js +15 -36
  243. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  244. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  245. package/node_modules/pi-lens/dist/clients/latency-logger.js +23 -1
  246. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  247. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  248. package/node_modules/pi-lens/dist/clients/lens-events.js +27 -4
  249. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  250. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  251. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +30 -0
  252. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  253. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +1 -1
  254. package/node_modules/pi-lens/dist/clients/lsp/client.js +493 -48
  255. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  256. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +86 -0
  257. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  258. package/node_modules/pi-lens/dist/clients/lsp/index.js +1807 -780
  259. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  260. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  261. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  262. package/node_modules/pi-lens/dist/clients/lsp/server.js +196 -32
  263. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  264. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  265. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  266. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +123 -32
  267. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  268. package/node_modules/pi-lens/dist/clients/mcp/session.js +216 -8
  269. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  270. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  271. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  272. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  273. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  274. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  275. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  276. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  277. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  278. package/node_modules/pi-lens/dist/clients/pipeline.js +47 -13
  279. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  280. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  281. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  282. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  283. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  284. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  285. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  286. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  287. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  288. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  289. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  290. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  291. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  292. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  293. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  294. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  295. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  296. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  297. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  298. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  299. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  300. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  301. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  302. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +107 -73
  303. package/node_modules/pi-lens/dist/clients/runtime-context.js +36 -12
  304. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +74 -6
  305. package/node_modules/pi-lens/dist/clients/runtime-session.js +405 -117
  306. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  307. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +218 -23
  308. package/node_modules/pi-lens/dist/clients/runtime-turn.js +237 -64
  309. package/node_modules/pi-lens/dist/clients/rust-client.js +2 -1
  310. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  311. package/node_modules/pi-lens/dist/clients/security-scan-client.js +2 -1
  312. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  313. package/node_modules/pi-lens/dist/clients/sg-runner.js +284 -174
  314. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  315. package/node_modules/pi-lens/dist/clients/smells-rollup.js +224 -0
  316. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  317. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  318. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  319. package/node_modules/pi-lens/dist/clients/startup-timing.js +1 -1
  320. package/node_modules/pi-lens/dist/clients/test-runner-client.js +38 -8
  321. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  322. package/node_modules/pi-lens/dist/clients/tool-policy.js +388 -39
  323. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  324. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  325. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  326. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  327. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  328. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  329. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  330. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  331. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  332. package/node_modules/pi-lens/dist/clients/warm-attach.js +33 -13
  333. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  334. package/node_modules/pi-lens/dist/clients/word-index.js +391 -64
  335. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  336. package/node_modules/pi-lens/dist/index.js +49361 -36888
  337. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  338. package/node_modules/pi-lens/dist/mcp/server.js +211 -44
  339. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  340. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +200 -36
  341. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +153 -40
  342. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  343. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  344. package/node_modules/pi-lens/docs/agent-guide.md +2 -2
  345. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  346. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  347. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  348. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +5 -2
  349. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  350. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  351. package/node_modules/pi-lens/docs/fable.md +27 -0
  352. package/node_modules/pi-lens/docs/features.md +31 -2
  353. package/node_modules/pi-lens/docs/globalconfig.md +45 -1
  354. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  355. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  356. package/node_modules/pi-lens/docs/mcp.md +37 -4
  357. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  358. package/node_modules/pi-lens/docs/settings.md +12 -1
  359. package/node_modules/pi-lens/docs/tools.md +4 -1
  360. package/node_modules/pi-lens/docs/usage.md +18 -0
  361. package/node_modules/pi-lens/package.json +10 -10
  362. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  363. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  364. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  365. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  366. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  367. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  368. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  369. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  370. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  371. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  372. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  373. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  374. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +55 -2
  375. package/node_modules/pi-mcp-adapter/CHANGELOG.md +25 -0
  376. package/node_modules/pi-mcp-adapter/README.md +34 -3
  377. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  378. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  379. package/node_modules/pi-mcp-adapter/direct-tools.ts +54 -7
  380. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  381. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  382. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  383. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  384. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  385. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  386. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  387. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  388. package/node_modules/pi-mcp-adapter/metadata-cache.ts +27 -4
  389. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  390. package/node_modules/pi-mcp-adapter/package.json +3 -3
  391. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  392. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  393. package/node_modules/pi-mcp-adapter/server-manager.ts +36 -7
  394. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  395. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  396. package/node_modules/pi-mcp-adapter/tool-metadata.ts +42 -4
  397. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  398. package/node_modules/pi-mcp-adapter/types.ts +93 -27
  399. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  400. package/package.json +17 -13
  401. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  402. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  403. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  404. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  405. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  406. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  407. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  408. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -11,25 +11,69 @@ import { createHash } from "node:crypto";
11
11
  import * as nodeFs from "node:fs";
12
12
  import fs from "node:fs/promises";
13
13
  import path from "node:path";
14
+ import { pathToFileURL, URL } from "node:url";
14
15
  import { getProjectIgnoreMatcher, isExcludedDirName, } from "../file-utils.js";
15
16
  import { recordLsp } from "../widget-state.js";
16
17
  import { applyAuxiliarySuppressions } from "../dispatch/auxiliary-lsp.js";
17
18
  import { detectFileRole } from "../file-role.js";
18
19
  import { logLatency } from "../latency-logger.js";
19
20
  import { logSessionStart } from "../sessionstart-logger.js";
21
+ import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "../degradation-ledger.js";
22
+ import { isLspSpawnAllowedByTrust, assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
20
23
  import { shouldPreferPullOnlyDiagnostics } from "../lsp-budget.js";
21
24
  import { withDeadline } from "../deadline-utils.js";
22
25
  import { isAtOrAboveHomeDir, normalizeMapKey, uriToPath, } from "../path-utils.js";
26
+ import { recordLspMutation, } from "../lsp-mutation.js";
23
27
  import { createLSPClient } from "./client.js";
28
+ import { bindingStateLabel, composeBoundToCurrentDisk, createDiskBindingCache, } from "./diagnostic-binding.js";
24
29
  import { getServersForFileWithConfig, getServerInitOverride } from "./config.js";
25
30
  import { getLanguageId } from "./language.js";
26
- import { LSP_SERVERS, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
31
+ import { LSP_SERVERS, enforceLspRootCeiling, hasProjectBoundaryMarker, isDirectLspCommandTemporarilyUnavailable, } from "./server.js";
27
32
  import { classifyCascadeWaitTier, classifyServerWaitTier, getStrategy, } from "./wait-policy/index.js";
28
33
  import { raceToCompletion } from "./aggregation.js";
29
- import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, } from "./edits.js";
34
+ import { applyWorkspaceEdit, mergeWorkspaceTextEditsByPriority, summarizeWorkspaceEdit, validateWorkspaceEdit, } from "./edits.js";
30
35
  import { buildScopeKey, createWorkspaceDiagnosticsCacheContext, } from "./workspace-diagnostics-cache.js";
31
36
  import { attemptTsserverSyncDiagnostics, } from "./tsserver-sync.js";
32
37
  import { isWarmAttached, tryWarmAttachedDiagnostics, } from "../warm-attach.js";
38
+ function destinationUriPreservingSpelling(oldUri, oldFilePath, newFilePath) {
39
+ const canonical = pathToFileURL(newFilePath);
40
+ try {
41
+ const original = new URL(oldUri);
42
+ const authority = /^file:\/\/([^/]*)/i.exec(oldUri)?.[1] ?? "";
43
+ if (original.protocol !== "file:" ||
44
+ (original.host !== "" && original.hostname !== "localhost")) {
45
+ return canonical.href;
46
+ }
47
+ const canonicalDestination = () => `${original.protocol}//${authority}${canonical.pathname}`;
48
+ const oldParent = path.resolve(path.dirname(oldFilePath));
49
+ const newParent = path.resolve(path.dirname(newFilePath));
50
+ if (normalizeMapKey(oldParent) !== normalizeMapKey(newParent)) {
51
+ return canonicalDestination();
52
+ }
53
+ const slash = original.pathname.lastIndexOf("/");
54
+ if (slash < 0)
55
+ return canonicalDestination();
56
+ let rawName = canonical.pathname.slice(canonical.pathname.lastIndexOf("/") + 1);
57
+ const oldRawName = original.pathname.slice(slash + 1);
58
+ const oldName = decodeURIComponent(oldRawName);
59
+ const expectedOldName = path.basename(oldFilePath);
60
+ if (oldName.toLowerCase() !== expectedOldName.toLowerCase()) {
61
+ return canonicalDestination();
62
+ }
63
+ // Keep non-canonical percent-encoding choices from the URI that was
64
+ // opened (for example `%2E` instead of `.`) while using the new path's
65
+ // exact basename. The authority and directory spelling are preserved too.
66
+ for (const match of oldRawName.matchAll(/%([0-9a-f]{2})/gi)) {
67
+ const encoded = match[0];
68
+ const decoded = String.fromCharCode(Number.parseInt(match[1], 16));
69
+ rawName = rawName.split(decoded).join(encoded);
70
+ }
71
+ return `${original.protocol}//${authority}${original.pathname.slice(0, slash + 1)}${rawName}`;
72
+ }
73
+ catch {
74
+ return canonical.href;
75
+ }
76
+ }
33
77
  // --- Init override helpers ---
34
78
  /**
35
79
  * Recursively merges `override` onto `base`. Override wins on leaf conflicts
@@ -69,6 +113,41 @@ export function mergeInitializationOptions(base, override) {
69
113
  const BROKEN_BASE_COOLDOWN_MS = 15_000;
70
114
  const BROKEN_MAX_COOLDOWN_MS = 5 * 60_000; // cap at 5 minutes
71
115
  const BROKEN_PERMANENT_AFTER = 5; // disable for session after N consecutive failures
116
+ // #1127: a client that dies THIS soon after a successful spawn/initialize is
117
+ // treated as a crash-loop symptom (opengrep's post-init "Unhandled message"
118
+ // exit), not a legitimate long-running server that happened to restart once.
119
+ // 60s comfortably covers normal serve-a-few-files-then-idle sessions while
120
+ // still catching "spawns fine, dies within seconds every time" servers.
121
+ const RUNTIME_EXIT_UPTIME_THRESHOLD_MS = 60_000;
122
+ // #1142: the fast path above only trips on CONSECUTIVE exits UNDER the 60s
123
+ // threshold, so a server that reliably dies just PAST it (~65-90s after every
124
+ // spawn) resets that streak on every death and churns forever — a persistent
125
+ // low-frequency crash loop the hard-cutoff design structurally cannot see. The
126
+ // windowed-rate trip COMPOSES with (does not replace) the fast path: N
127
+ // non-intentional deaths within a rolling M-minute window trip the breaker
128
+ // regardless of each death's individual lifetime.
129
+ // N = BROKEN_PERMANENT_AFTER (5): one uniform "5 strikes" give-up count
130
+ // across both streams, and high enough that the benign over-threshold
131
+ // deaths that reach this path (a one-off crash after a long healthy run;
132
+ // a single sleep/resume connection drop) — at most one per key per event
133
+ // — cannot approach it.
134
+ // M = 15 min: wide enough for a genuine slow loop (each life ~65-90s, so 5
135
+ // death→respawn cycles land in ~6-15 min of active churn) to accumulate
136
+ // 5, yet short enough that sparse benign crashes (e.g. "twice last hour,
137
+ // now healthy") age out of the rolling window before five ever coincide.
138
+ const RUNTIME_EXIT_WINDOW_MS = 15 * 60_000;
139
+ const RUNTIME_EXIT_WINDOW_TRIP_COUNT = BROKEN_PERMANENT_AFTER;
140
+ // A death whose measured lifetime (`exitedAt - spawnedAt`) exceeds this ceiling
141
+ // is NOT recorded toward the window: it is either a genuinely long healthy run
142
+ // that crashed once, or a lifetime computed ACROSS a machine-sleep / Modern-
143
+ // Standby suspend (the #1122/#1139 death-timestamp lesson — a suspend gap makes
144
+ // a benign exit look like a huge "uptime"), and neither is crash-loop churn.
145
+ // The slow-loop residual this fix targets dies well within the ceiling
146
+ // (~65-90s), so it is unaffected. This is a belt-and-suspenders guard on top of
147
+ // the structural defense that the window is per-KEY: a single suspend kills at
148
+ // most one live client per server, contributing at most one death to any one
149
+ // key's window, so it cannot by itself reach the trip count regardless.
150
+ const RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS = 10 * 60_000;
72
151
  // #743: a server whose per-server notify write (didOpen/didChange) times out
73
152
  // this many times in a row is a persistently backpressured server; it is demoted
74
153
  // into the `broken` cooldown map (evicted + cooled down) so subsequent sweeps
@@ -81,6 +160,20 @@ const NAV_CLIENT_WAIT_TIMEOUT_MS = Math.max(0, Number.parseInt(process.env.PI_LE
81
160
  1500);
82
161
  const TOUCH_DEBOUNCE_MS = Math.max(0, Number.parseInt(process.env.PI_LENS_LSP_TOUCH_DEBOUNCE_MS ?? "1500", 10) ||
83
162
  1500);
163
+ const DEFAULT_LSP_CLIENT_CEILING = 24;
164
+ const DEFAULT_TS_IDLE_EVICT_MS = 20 * 60_000;
165
+ export function getTypeScriptIdleEvictMs() {
166
+ const parsed = Number.parseInt(process.env.PI_LENS_TS_IDLE_EVICT_MS ?? "", 10);
167
+ return Number.isSafeInteger(parsed) && parsed > 0
168
+ ? parsed
169
+ : DEFAULT_TS_IDLE_EVICT_MS;
170
+ }
171
+ export function getLspClientCeiling() {
172
+ const parsed = Number.parseInt(process.env.PI_LENS_LSP_CLIENT_CEILING ?? "", 10);
173
+ return Number.isSafeInteger(parsed) && parsed > 0
174
+ ? parsed
175
+ : DEFAULT_LSP_CLIENT_CEILING;
176
+ }
84
177
  // #667: the sweep warm-up round trip's OWN generous, one-time budget —
85
178
  // deliberately larger than any single per-file sweep budget (`perFileMs` in
86
179
  // `runWorkspaceDiagnostics`, or the batch tool's per-file wait) because this
@@ -279,7 +372,7 @@ const WORKSPACE_SWEEP_PREOPEN_CHUNK_SIZE = (() => {
279
372
  return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 8;
280
373
  })();
281
374
  // #584: opengrep has no `workspace/diagnostic` pull support (push-only,
282
- // docs/servercapabilities.md) and `reopenOnResync: true` (server-strategies.ts)
375
+ // docs/servercapabilities.md) and `reopenOnResync: true` (wait-policy/strategies.ts)
283
376
  // means every per-file LSP touch already forces a full re-scan anyway — there's
284
377
  // no incremental win from routing it through the sweep's per-file loop. On a
285
378
  // full workspace sweep it instead dominates the per-file wait (its own
@@ -390,11 +483,50 @@ async function collectWorkspaceDiagnosticFiles(root, maxFiles = getMaxWorkspaceD
390
483
  export class LSPService {
391
484
  state;
392
485
  workspaceProbeLogged = new Set();
486
+ /** Per-service immutable root-boundary verdicts; root detectors cache hits too. */
487
+ projectBoundaryCache = new Map();
393
488
  warmStartLogged = new Set();
394
489
  optionalFailureLogged = new Set();
490
+ /** Server/root pairs that already emitted unavailable for the current occurrence. */
491
+ unavailableLogged = new Set();
395
492
  optionalDisabled = new Set();
396
493
  /** Consecutive failure counts for exponential backoff circuit breaker */
397
494
  failureCounts = new Map();
495
+ /**
496
+ * #1127: consecutive EARLY post-init runtime exits — a client that closed
497
+ * unexpectedly (not via our own `shutdown()`) with an uptime under
498
+ * {@link RUNTIME_EXIT_UPTIME_THRESHOLD_MS}. Tracked separately from
499
+ * {@link failureCounts} because a successful spawn/initialize clears that
500
+ * map (correct for the spawn/init failure class it was built for — see
501
+ * `spawnClient`'s success path) but is exactly the event a crash-loop
502
+ * server keeps producing right before it dies again; reusing the same map
503
+ * would make every respawn erase the streak the moment it re-spawned,
504
+ * which is the #1127 bug. This counter shares the SAME cooldown formula,
505
+ * the SAME `state.broken`/`permanentlyBroken` maps, and the SAME
506
+ * give-up-after-N-failures threshold as the spawn/init breaker — it is a
507
+ * parallel counter feeding one circuit, not a second mechanism.
508
+ */
509
+ runtimeExitCounts = new Map();
510
+ /**
511
+ * #1142: rolling window of recent non-intentional death timestamps
512
+ * (`exitedAt`) per "serverId:root" key (the SAME identity as
513
+ * {@link runtimeExitCounts} and {@link LSPState.broken} — defect shape 1) —
514
+ * the SECOND, independent breaker trip condition that COMPOSES with the
515
+ * {@link runtimeExitCounts} fast path. That counter only sees CONSECUTIVE
516
+ * exits under the 60s threshold; a server that dies just PAST it every spawn
517
+ * resets that streak forever (#1142). This window trips when
518
+ * {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths fall within
519
+ * {@link RUNTIME_EXIT_WINDOW_MS}, regardless of each death's lifetime.
520
+ *
521
+ * Bounded on BOTH axes (defect shape 9): entries older than the window are
522
+ * pruned on every record, AND the array is hard-capped at the trip count
523
+ * (drop-oldest) — a rate trip needs only the count within the window, never
524
+ * an unbounded history of timestamps. No timer backs it (defect shape 4): it
525
+ * ages purely by prune-on-check, so there is nothing to `unref()` or
526
+ * print-mode-gate. Like {@link runtimeExitCounts} it is a per-instance field,
527
+ * so a fresh `LSPService` (via `resetLSPService`) starts empty.
528
+ */
529
+ runtimeExitWindow = new Map();
398
530
  /** Server/root keys disabled for the rest of this session after repeated failures. */
399
531
  permanentlyBroken = new Set();
400
532
  /**
@@ -413,7 +545,26 @@ export class LSPService {
413
545
  * those falls through to a fresh check rather than serving a stale result.
414
546
  */
415
547
  lastKnownContentHash = new Map();
548
+ /**
549
+ * #1095: lazily verifies a stored {@link DiagnosticBinding} against current
550
+ * disk bytes, memoizing the disk fingerprint per (file, mtime) so repeated
551
+ * binding reads across `touchFile`/`getAllDiagnostics` within a session don't
552
+ * re-hash unchanged files. Owned by the service so the memo is shared.
553
+ */
554
+ diskBindingCache = createDiskBindingCache();
416
555
  lastDiagnosticsHealth = new Map();
556
+ /**
557
+ * Touch-debounce record of "this server already has this content", keyed
558
+ * "normalizedFilePath:clientScope:serverId".
559
+ *
560
+ * #743/#1253: the serverId component is load-bearing. #1253 requires that a
561
+ * server whose notify write never landed does NOT get an entry (otherwise the
562
+ * next touch debounces the re-push and the silent-clean gates read its silence
563
+ * as a confirmed clean). #743 requires that one wedged server must not degrade
564
+ * its healthy siblings. A file-level key can only satisfy one of those at a
565
+ * time — per-server, both hold: the stalled server simply has no entry and is
566
+ * re-pushed, while every sibling whose write landed keeps its own debounce.
567
+ */
417
568
  recentTouches = new Map();
418
569
  /**
419
570
  * #743: consecutive per-server notify-write timeout count, keyed by
@@ -424,6 +575,23 @@ export class LSPService {
424
575
  * successful write clears its entry.
425
576
  */
426
577
  notifyWriteBackpressureStreak = new Map();
578
+ /** LRU clock for capacity eviction, keyed by the canonical server/root key. */
579
+ clientLastUsedAt = new Map();
580
+ /**
581
+ * Manager-owned use leases close the acquisition/use gap that `isBusy()`
582
+ * cannot see: a caller may hold a selected client before its first request has
583
+ * entered the client. Eviction skips any key with an outstanding lease.
584
+ */
585
+ clientLeases = new Map();
586
+ /**
587
+ * Per-root idle eviction for TypeScript's large, rebuildable program graph.
588
+ * Timers are unref'd so an idle language service cannot keep a one-shot host
589
+ * alive, and are removed before shutdown so a concurrent request rebuilds
590
+ * instead of receiving the retiring client.
591
+ */
592
+ typeScriptIdleTimers = new Map();
593
+ /** Serializes capacity decisions with publication into `state.inFlight`. */
594
+ clientSpawnGate = Promise.resolve();
427
595
  /** True after shutdown() has been called; blocks new operations */
428
596
  isDestroyed = false;
429
597
  /**
@@ -446,10 +614,215 @@ export class LSPService {
446
614
  demonstratedCold: new Set(),
447
615
  };
448
616
  }
617
+ /**
618
+ * Resolve one server's client identity root. Root selection is hard-bounded
619
+ * by the session cwd, then config-only nested roots reuse an already-hosted
620
+ * same-server ancestor. Real nested projects retain independent clients.
621
+ */
622
+ async resolveServerRoot(server, filePath) {
623
+ const candidate = await server.root(filePath);
624
+ if (!candidate)
625
+ return undefined;
626
+ const root = enforceLspRootCeiling(candidate, process.cwd(), filePath);
627
+ if (normalizeMapKey(root) === normalizeMapKey(process.cwd()))
628
+ return root;
629
+ const rootKey = normalizeMapKey(root);
630
+ const prefix = `${server.id}:`;
631
+ let nearestAncestor;
632
+ for (const key of new Set([
633
+ ...this.state.clients.keys(),
634
+ ...this.state.inFlight.keys(),
635
+ ])) {
636
+ if (!key.startsWith(prefix))
637
+ continue;
638
+ const ancestorKey = key.slice(prefix.length);
639
+ const relative = path.relative(ancestorKey, rootKey);
640
+ if (relative === "" ||
641
+ relative.startsWith("..") ||
642
+ path.isAbsolute(relative)) {
643
+ continue;
644
+ }
645
+ if (!nearestAncestor || ancestorKey.length > nearestAncestor.length) {
646
+ nearestAncestor = ancestorKey;
647
+ }
648
+ }
649
+ if (!nearestAncestor)
650
+ return root;
651
+ let boundary = this.projectBoundaryCache.get(rootKey);
652
+ if (!boundary) {
653
+ boundary = hasProjectBoundaryMarker(root);
654
+ this.projectBoundaryCache.set(rootKey, boundary);
655
+ }
656
+ if (await boundary)
657
+ return root;
658
+ return (this.state.clients.get(`${server.id}:${nearestAncestor}`)?.root ??
659
+ nearestAncestor);
660
+ }
449
661
  /** Guard: return true if service is shutting down or shut down */
450
662
  checkDestroyed() {
451
663
  return this.isDestroyed;
452
664
  }
665
+ async withClientSpawnGate(operation) {
666
+ const previous = this.clientSpawnGate;
667
+ let release;
668
+ this.clientSpawnGate = new Promise((resolve) => {
669
+ release = resolve;
670
+ });
671
+ await previous;
672
+ try {
673
+ return await operation();
674
+ }
675
+ finally {
676
+ release();
677
+ }
678
+ }
679
+ async clientKeyFor(entry, filePath) {
680
+ const root = await this.resolveServerRoot(entry.info, filePath);
681
+ return root ? `${entry.info.id}:${normalizeMapKey(root)}` : undefined;
682
+ }
683
+ async acquireClientLease(entry, filePath) {
684
+ const key = await this.clientKeyFor(entry, filePath);
685
+ if (!key)
686
+ return undefined;
687
+ return this.withClientSpawnGate(async () => {
688
+ if (this.isDestroyed || this.state.clients.get(key) !== entry.client) {
689
+ return undefined;
690
+ }
691
+ this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
692
+ return key;
693
+ });
694
+ }
695
+ async acquireClientLeases(entries, filePath) {
696
+ const keys = await Promise.all(entries.map((entry) => this.clientKeyFor(entry, filePath)));
697
+ if (keys.some((key) => key === undefined))
698
+ return undefined;
699
+ const keyed = entries.map((entry, index) => [keys[index], entry]);
700
+ return this.withClientSpawnGate(async () => {
701
+ if (this.isDestroyed ||
702
+ keyed.some(([key, entry]) => this.state.clients.get(key) !== entry.client)) {
703
+ return undefined;
704
+ }
705
+ for (const [key] of keyed) {
706
+ this.clientLeases.set(key, (this.clientLeases.get(key) ?? 0) + 1);
707
+ }
708
+ return keyed.map(([key]) => key);
709
+ });
710
+ }
711
+ releaseClientLease(key) {
712
+ const remaining = (this.clientLeases.get(key) ?? 1) - 1;
713
+ if (remaining > 0) {
714
+ this.clientLeases.set(key, remaining);
715
+ return;
716
+ }
717
+ this.clientLeases.delete(key);
718
+ if (this.state.clients.has(key)) {
719
+ this.clientLastUsedAt.set(key, Date.now());
720
+ this.scheduleTypeScriptIdleEviction(key);
721
+ }
722
+ }
723
+ async withClientForFileUse(filePath, maxWaitMs, hardCapMs, use) {
724
+ while (!this.isDestroyed) {
725
+ const entry = await this.getClientForFile(filePath, maxWaitMs, hardCapMs);
726
+ if (!entry)
727
+ return undefined;
728
+ const leaseKey = await this.acquireClientLease(entry, filePath);
729
+ if (!leaseKey)
730
+ continue;
731
+ try {
732
+ return await use(entry);
733
+ }
734
+ finally {
735
+ this.releaseClientLease(leaseKey);
736
+ }
737
+ }
738
+ return undefined;
739
+ }
740
+ async makeCapacityForClient(key) {
741
+ const occupiedKeys = new Set(this.state.inFlight.keys());
742
+ for (const [clientKey, client] of this.state.clients) {
743
+ if (client.isAlive())
744
+ occupiedKeys.add(clientKey);
745
+ }
746
+ const occupied = occupiedKeys.size;
747
+ if (occupied < getLspClientCeiling())
748
+ return true;
749
+ const idle = [...this.state.clients.entries()]
750
+ .filter(([candidateKey, client]) => candidateKey !== key &&
751
+ client.isAlive() &&
752
+ client.isBusy?.() !== true &&
753
+ (this.clientLeases.get(candidateKey) ?? 0) === 0)
754
+ .sort(([a], [b]) => (this.clientLastUsedAt.get(a) ?? this.state.clientSpawnedAt.get(a) ?? 0) -
755
+ (this.clientLastUsedAt.get(b) ?? this.state.clientSpawnedAt.get(b) ?? 0));
756
+ const victim = idle[0];
757
+ if (!victim) {
758
+ logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: spawn declined; all clients are in use`);
759
+ return false;
760
+ }
761
+ const [victimKey, victimClient] = victim;
762
+ await victimClient.shutdown({ reason: "client_ceiling_lru" });
763
+ this.state.clients.delete(victimKey);
764
+ this.state.clientSpawnedAt.delete(victimKey);
765
+ this.state.demonstratedReady.delete(victimKey);
766
+ this.state.demonstratedCold.delete(victimKey);
767
+ this.clientLastUsedAt.delete(victimKey);
768
+ this.clearTypeScriptIdleTimer(victimKey);
769
+ logSessionStart(`lsp client ceiling ${getLspClientCeiling()}: evicted idle LRU ${victimKey}`);
770
+ return true;
771
+ }
772
+ clearTypeScriptIdleTimer(key) {
773
+ const timer = this.typeScriptIdleTimers.get(key);
774
+ if (timer)
775
+ clearTimeout(timer);
776
+ this.typeScriptIdleTimers.delete(key);
777
+ }
778
+ scheduleTypeScriptIdleEviction(key) {
779
+ if (!key.startsWith("typescript:"))
780
+ return;
781
+ // Pressure-gating these timers would require a separate reconciliation pass
782
+ // when the manager crosses the threshold; keep ownership simple and use the
783
+ // warm-LSP-friendly 20-minute default instead.
784
+ this.clearTypeScriptIdleTimer(key);
785
+ const lastUsedAt = this.clientLastUsedAt.get(key) ?? Date.now();
786
+ const timer = setTimeout(() => {
787
+ this.typeScriptIdleTimers.delete(key);
788
+ void this.withClientSpawnGate(async () => {
789
+ if (this.isDestroyed)
790
+ return;
791
+ const client = this.state.clients.get(key);
792
+ if (!client?.isAlive())
793
+ return;
794
+ if (client.isBusy?.() === true ||
795
+ (this.clientLeases.get(key) ?? 0) > 0 ||
796
+ (this.clientLastUsedAt.get(key) ?? 0) !== lastUsedAt) {
797
+ this.scheduleTypeScriptIdleEviction(key);
798
+ return;
799
+ }
800
+ // Publish the cold state synchronously before awaiting teardown. A request
801
+ // arriving while shutdown is in progress therefore waits on the spawn gate
802
+ // and creates a fresh client; it can never receive this retiring one.
803
+ this.state.clients.delete(key);
804
+ this.state.clientSpawnedAt.delete(key);
805
+ this.state.demonstratedReady.delete(key);
806
+ this.state.demonstratedCold.delete(key);
807
+ this.clientLastUsedAt.delete(key);
808
+ try {
809
+ await client.shutdown({ reason: "typescript_idle_eviction" });
810
+ }
811
+ catch {
812
+ // The strong manager reference is already gone; shutdown is best-effort
813
+ // like the other eviction paths and must not reject from a timer callback.
814
+ }
815
+ logSessionStart(`lsp typescript idle eviction: released ${key}`);
816
+ recordDegradation({
817
+ kind: "ts-idle-eviction",
818
+ subject: key,
819
+ reason: "idle TypeScript client released to bound memory",
820
+ });
821
+ }).catch(() => { });
822
+ }, getTypeScriptIdleEvictMs());
823
+ timer.unref?.();
824
+ this.typeScriptIdleTimers.set(key, timer);
825
+ }
453
826
  fingerprintContent(content) {
454
827
  if (content.length <= 96) {
455
828
  return `${content.length}:${content}`;
@@ -461,10 +834,15 @@ export class LSPService {
461
834
  * NOT need diagnostics — those callers still need to wait for the LSP to
462
835
  * publish, even if the notify itself is a no-op.
463
836
  */
464
- shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics) {
837
+ shouldSkipTouch(filePath, content, clientScope, waitForDiagnostics, serverIds) {
465
838
  if (waitForDiagnostics)
466
839
  return false;
467
- return this.shouldSkipNotify(filePath, content, clientScope);
840
+ // #743: only short-circuit the whole call when EVERY spawned server already
841
+ // has this content. If even one still needs the push, fall through — the
842
+ // write loop skips the servers that are covered and pushes only the rest.
843
+ if (serverIds.length === 0)
844
+ return false;
845
+ return serverIds.every((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId));
468
846
  }
469
847
  /**
470
848
  * Should the didOpen/didChange notify be skipped while keeping the
@@ -478,12 +856,14 @@ export class LSPService {
478
856
  * with diagnosticsMode="document" moments later. Without this check the
479
857
  * second call's notify clears in-progress diagnostics and the LSP has to
480
858
  * start over — observed as multi-second waits on slow TS projects.
859
+ *
860
+ * Decided PER SERVER (#743): a sibling's stalled write must not cost this
861
+ * server its debounce. See {@link recentTouches}.
481
862
  */
482
- shouldSkipNotify(filePath, content, clientScope) {
863
+ shouldSkipNotify(filePath, content, clientScope, serverId) {
483
864
  if (TOUCH_DEBOUNCE_MS <= 0)
484
865
  return false;
485
- const key = `${normalizeMapKey(filePath)}:${clientScope}`;
486
- const previous = this.recentTouches.get(key);
866
+ const previous = this.recentTouches.get(this.recentTouchKey(filePath, clientScope, serverId));
487
867
  if (!previous)
488
868
  return false;
489
869
  const now = Date.now();
@@ -491,8 +871,11 @@ export class LSPService {
491
871
  return false;
492
872
  return previous.fingerprint === this.fingerprintContent(content);
493
873
  }
494
- markTouched(filePath, content, clientScope) {
495
- const key = `${normalizeMapKey(filePath)}:${clientScope}`;
874
+ recentTouchKey(filePath, clientScope, serverId) {
875
+ return `${normalizeMapKey(filePath)}:${clientScope}:${serverId}`;
876
+ }
877
+ markTouched(filePath, content, clientScope, serverId) {
878
+ const key = this.recentTouchKey(filePath, clientScope, serverId);
496
879
  const now = Date.now();
497
880
  this.recentTouches.set(key, {
498
881
  fingerprint: this.fingerprintContent(content),
@@ -501,8 +884,10 @@ export class LSPService {
501
884
  });
502
885
  // Trim entries that are already past the debounce window — shouldSkipTouch
503
886
  // ignores them anyway, so they serve no purpose. Only sweep when the map
504
- // exceeds the threshold to avoid iterating on every call.
505
- if (this.recentTouches.size > 200) {
887
+ // exceeds the threshold to avoid iterating on every call. The threshold is
888
+ // per-ENTRY and entries are now per-server, so it is scaled up to keep the
889
+ // same effective file capacity a file-level key gave a multi-server scope.
890
+ if (this.recentTouches.size > 800) {
506
891
  for (const [k, v] of this.recentTouches) {
507
892
  if (now - v.touchedAt > TOUCH_DEBOUNCE_MS) {
508
893
  this.recentTouches.delete(k);
@@ -522,7 +907,7 @@ export class LSPService {
522
907
  * implementation that doesn't independently stamp `.root`).
523
908
  */
524
909
  async demonstratedReadyKeyFor(server, filePath) {
525
- const root = await server.root(filePath);
910
+ const root = await this.resolveServerRoot(server, filePath);
526
911
  if (!root)
527
912
  return undefined;
528
913
  return `${server.id}:${normalizeMapKey(root)}`;
@@ -534,6 +919,9 @@ export class LSPService {
534
919
  // the negative cache.
535
920
  this.state.demonstratedCold.delete(key);
536
921
  }
922
+ recordBreaker(key, reason) {
923
+ recordDegradationOnce({ kind: "lsp-breaker", subject: key, reason });
924
+ }
537
925
  /**
538
926
  * #743: record one notify-write timeout for a server and, once it has stalled
539
927
  * {@link NOTIFY_BACKPRESSURE_BROKEN_AFTER} times in a row, demote it through
@@ -558,6 +946,8 @@ export class LSPService {
558
946
  this.state.clients.delete(key);
559
947
  this.state.clientSpawnedAt.delete(key);
560
948
  this.state.demonstratedReady.delete(key);
949
+ this.clientLastUsedAt.delete(key);
950
+ this.clearTypeScriptIdleTimer(key);
561
951
  logLatency({
562
952
  type: "phase",
563
953
  phase: "lsp_notify_backpressure_broken",
@@ -633,14 +1023,25 @@ export class LSPService {
633
1023
  return spawned;
634
1024
  }
635
1025
  }
1026
+ const unavailable = (await Promise.all(servers.map(async (server) => {
1027
+ const root = await server.root(filePath);
1028
+ return {
1029
+ server,
1030
+ key: `${server.id}:${root ? normalizeMapKey(root) : "<unresolved>"}`,
1031
+ };
1032
+ }))).filter(({ key }) => !this.unavailableLogged.has(key));
1033
+ for (const { key } of unavailable)
1034
+ this.unavailableLogged.add(key);
1035
+ if (unavailable.length === 0)
1036
+ return undefined;
636
1037
  logLatency({
637
1038
  type: "phase",
638
1039
  phase: "lsp_client_unavailable",
639
1040
  filePath,
640
1041
  durationMs: 0,
641
1042
  metadata: {
642
- candidateCount: servers.length,
643
- servers: servers.map((server) => server.id),
1043
+ candidateCount: unavailable.length,
1044
+ servers: unavailable.map(({ server }) => server.id),
644
1045
  },
645
1046
  });
646
1047
  return undefined;
@@ -649,10 +1050,31 @@ export class LSPService {
649
1050
  return withBudget();
650
1051
  }
651
1052
  const timeoutSentinel = Symbol("lsp-client-wait-timeout");
652
- const waitResult = await Promise.race([
653
- withBudget(),
654
- new Promise((resolve) => setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs)),
655
- ]);
1053
+ // #1097: store the timer and clear it once the race settles. Without this,
1054
+ // when `withBudget()` wins (the common case: the client is ready well before
1055
+ // the budget), the losing `setTimeout` stays a REF'D pending timer for the
1056
+ // full remaining `effectiveMaxWaitMs`. In a long-lived interactive session
1057
+ // that is invisible (it fires later, resolves an orphan promise, is GC'd).
1058
+ // In a one-shot `pi --print --no-session` process it is fatal: the timer
1059
+ // keeps the event loop alive for up to `effectiveMaxWaitMs` after
1060
+ // `agent_settled`/`session_shutdown`, so the completed process never exits
1061
+ // (issue #1097 — a recurrence of the #22 symptom via a different handle, and
1062
+ // a member of the uncleared-race-timeout class the shared `withDeadline`
1063
+ // helper already guards against elsewhere).
1064
+ let waitTimer;
1065
+ let waitResult;
1066
+ try {
1067
+ waitResult = await Promise.race([
1068
+ withBudget(),
1069
+ new Promise((resolve) => {
1070
+ waitTimer = setTimeout(() => resolve(timeoutSentinel), effectiveMaxWaitMs);
1071
+ }),
1072
+ ]);
1073
+ }
1074
+ finally {
1075
+ if (waitTimer)
1076
+ clearTimeout(waitTimer);
1077
+ }
656
1078
  if (waitResult === timeoutSentinel) {
657
1079
  // Snapshot known client health — scan by serverId prefix (no root needed)
658
1080
  const knownHealth = [...this.state.clients.entries()]
@@ -691,7 +1113,7 @@ export class LSPService {
691
1113
  return { clients: [], serverCountAttempted: 0 };
692
1114
  // Count servers with a valid root as "attempted" — extension-only matches
693
1115
  // that fail the root check are not real spawn attempts.
694
- const roots = await Promise.all(servers.map((s) => s.root(filePath)));
1116
+ const roots = await Promise.all(servers.map((s) => this.resolveServerRoot(s, filePath)));
695
1117
  const serverCountAttempted = roots.filter(Boolean).length;
696
1118
  const spawned = await Promise.all(servers.map((server) => this.ensureClientForServer(filePath, server)));
697
1119
  return {
@@ -722,7 +1144,7 @@ export class LSPService {
722
1144
  return undefined;
723
1145
  const servers = getServersForFileWithConfig(filePath);
724
1146
  for (const server of servers) {
725
- const root = await server.root(filePath);
1147
+ const root = await this.resolveServerRoot(server, filePath);
726
1148
  if (!root)
727
1149
  continue;
728
1150
  const key = `${server.id}:${normalizeMapKey(root)}`;
@@ -744,7 +1166,7 @@ export class LSPService {
744
1166
  for (const server of getServersForFileWithConfig(filePath)) {
745
1167
  if (server.id !== serverId)
746
1168
  continue;
747
- const root = await server.root(filePath);
1169
+ const root = await this.resolveServerRoot(server, filePath);
748
1170
  if (!root)
749
1171
  continue;
750
1172
  const key = `${server.id}:${normalizeMapKey(root)}`;
@@ -763,10 +1185,10 @@ export class LSPService {
763
1185
  if (this.checkDestroyed())
764
1186
  return undefined;
765
1187
  }
766
- const root = await server.root(filePath);
1188
+ const root = await this.resolveServerRoot(server, filePath);
767
1189
  if (!root || this.checkDestroyed())
768
1190
  return undefined;
769
- const allowInstall = this.shouldAllowInstall(filePath, root);
1191
+ const allowInstall = this.shouldAllowInstall(server.id);
770
1192
  const normalizedRoot = normalizeMapKey(root);
771
1193
  const key = `${server.id}:${normalizedRoot}`;
772
1194
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
@@ -803,6 +1225,9 @@ export class LSPService {
803
1225
  const existing = this.state.clients.get(key);
804
1226
  if (existing) {
805
1227
  if (existing.isAlive()) {
1228
+ this.unavailableLogged.delete(key);
1229
+ this.clientLastUsedAt.set(key, Date.now());
1230
+ this.scheduleTypeScriptIdleEviction(key);
806
1231
  if (!this.warmStartLogged.has(key)) {
807
1232
  logSessionStart(`lsp warm-start ${server.id}: reused root=${root} file=${filePath}`);
808
1233
  this.warmStartLogged.add(key);
@@ -811,6 +1236,28 @@ export class LSPService {
811
1236
  }
812
1237
  // Dead client — was previously alive, now needs respawn
813
1238
  const spawnedAt = this.state.clientSpawnedAt.get(key);
1239
+ // #1127: capture BEFORE calling existing.shutdown() below — both
1240
+ // wasShutdownIntentional() and getExitedAt() read state that
1241
+ // existing.shutdown() itself mutates as a side effect of our own
1242
+ // cleanup (shutdownRequested flips true, and the process exit it
1243
+ // triggers would stamp exitedAt at CLEANUP time rather than at the
1244
+ // real death), so both must be read before that call.
1245
+ const wasIntentional = existing.wasShutdownIntentional();
1246
+ const exitedAt = existing.getExitedAt();
1247
+ // #1127: lifetime MUST be measured from the client's own recorded
1248
+ // death (exitedAt), not from "now" (this detection). Detection is
1249
+ // lazy — the next file attach — and #1127's real-world pattern is
1250
+ // attach-triggered respawns minutes to HOURS apart: a server that
1251
+ // died 5s after spawning but wasn't detected until an hour later
1252
+ // must still read as an early, breaker-worthy exit. Fall back to the
1253
+ // detection-time delta only when exitedAt is somehow unset (the
1254
+ // client's own exit handlers always set it before isAlive() can go
1255
+ // false, so this is a defensive fallback, not the expected path).
1256
+ const uptimeMs = exitedAt != null && spawnedAt != null
1257
+ ? exitedAt - spawnedAt
1258
+ : spawnedAt != null
1259
+ ? Date.now() - spawnedAt
1260
+ : null;
814
1261
  logLatency({
815
1262
  type: "phase",
816
1263
  phase: "lsp_server_respawn",
@@ -819,7 +1266,8 @@ export class LSPService {
819
1266
  metadata: {
820
1267
  serverId: server.id,
821
1268
  root,
822
- uptimeMs: spawnedAt != null ? Date.now() - spawnedAt : null,
1269
+ uptimeMs,
1270
+ intentional: wasIntentional,
823
1271
  },
824
1272
  });
825
1273
  try {
@@ -830,7 +1278,127 @@ export class LSPService {
830
1278
  }
831
1279
  this.state.clients.delete(key);
832
1280
  this.state.clientSpawnedAt.delete(key);
1281
+ this.clientLastUsedAt.delete(key);
1282
+ this.clearTypeScriptIdleTimer(key);
833
1283
  this.state.broken.delete(key);
1284
+ // #1127: count EARLY, non-intentional runtime exits toward the circuit
1285
+ // breaker. Spawn/initialize itself succeeded here (this client made it
1286
+ // into state.clients), so `failureCounts` — the spawn/init breaker —
1287
+ // never saw this failure and was cleared to 0 on that earlier success.
1288
+ // Without this, a server that spawns fine and then dies shortly after
1289
+ // serving (opengrep's post-init "Unhandled message" crash, #1122/#1127)
1290
+ // respawns forever: cooldown/permanent-disable never engage.
1291
+ //
1292
+ // Deliberate teardowns (session reset, #743 notify-backpressure
1293
+ // eviction, a generation handoff) call `shutdown()` themselves and set
1294
+ // `shutdownRequested` before the process exits — those must never count,
1295
+ // or a legitimate restart would wrongly march the server toward
1296
+ // permanent disablement.
1297
+ if (wasIntentional) {
1298
+ // Not evidence of a bad server — leave any existing runtime-exit
1299
+ // streak alone (a deliberate restart is not a recovery signal
1300
+ // either; only clear the streak once the server proves itself by
1301
+ // staying up past the threshold, handled in the `else` below). The
1302
+ // windowed-rate trip below is likewise gated behind this
1303
+ // `!wasIntentional` guard, so user-initiated restarts, config/
1304
+ // workspace reloads, session changes, #743 eviction and generation
1305
+ // handoffs (all shutdown()-driven) never feed it.
1306
+ }
1307
+ else {
1308
+ // #1142: windowed-rate trip — the SECOND, independent breaker
1309
+ // condition, composed with the fast path below. Record this
1310
+ // non-intentional death and, if RUNTIME_EXIT_WINDOW_TRIP_COUNT
1311
+ // deaths fell within the rolling window, give up NOW regardless of
1312
+ // each death's individual lifetime. This catches the slow loop
1313
+ // (dies just past the 60s threshold every spawn) that the
1314
+ // consecutive-early-exit fast path structurally cannot see.
1315
+ if (this.recordRuntimeExitWindow(key, exitedAt, uptimeMs)) {
1316
+ const rate = `${RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths within ${Math.round(RUNTIME_EXIT_WINDOW_MS / 60_000)}m`;
1317
+ // Set a cooldown so THIS detection also stops respawning (the
1318
+ // broken check below at the end of this method re-reads it),
1319
+ // symmetric with the fast path which cools down on every count.
1320
+ // `max` is defensive, not load-bearing under the actual call
1321
+ // order: `state.broken` for this key is `delete`d at the top of
1322
+ // this method (see the "detect dead client" branch above), so
1323
+ // this `.get(key) ?? 0` always reads 0 here — it's the fast
1324
+ // path below that runs AFTER this block and can raise the
1325
+ // cooldown for the same death. Harmless either way: the
1326
+ // non-optional branch latches `permanentlyBroken` (making the
1327
+ // cooldown moot), and the optional branch here is currently
1328
+ // unreachable (`OPTIONAL_LSP_SERVER_IDS` is empty).
1329
+ this.state.broken.set(key, Math.max(this.state.broken.get(key) ?? 0, Date.now() +
1330
+ (isOptionalServer
1331
+ ? OPTIONAL_LSP_RETRY_COOLDOWN_MS
1332
+ : BROKEN_MAX_COOLDOWN_MS)));
1333
+ if (isOptionalServer) {
1334
+ // Optional servers never latch permanently (mirrors the fast
1335
+ // path): cool down + disable for a retry interval, and reset
1336
+ // the window so the trip must be re-earned afterward.
1337
+ this.optionalDisabled.add(key);
1338
+ this.runtimeExitWindow.delete(key);
1339
+ logSessionStart(`lsp respawn ${server.id}: optional server cooled down (windowed-rate trip: ${rate})`);
1340
+ }
1341
+ else {
1342
+ this.permanentlyBroken.add(key);
1343
+ this.recordBreaker(key, `windowed runtime-exit trip: ${rate}`);
1344
+ logSessionStart(`lsp respawn ${server.id}: permanently disabled (windowed-rate trip: ${rate}, uptimeMs=${uptimeMs})`);
1345
+ }
1346
+ }
1347
+ // #1127/#1139 fast path — CONSECUTIVE exits UNDER the 60s
1348
+ // threshold. UNCHANGED: its own counter, its own trip point. A
1349
+ // single death may feed BOTH accumulators (the window above AND
1350
+ // runtimeExitCounts here), but neither alters the other's threshold,
1351
+ // so the fast path's trip point is exactly what it was.
1352
+ if (uptimeMs != null &&
1353
+ uptimeMs < RUNTIME_EXIT_UPTIME_THRESHOLD_MS) {
1354
+ const rCount = (this.runtimeExitCounts.get(key) ?? 0) + 1;
1355
+ this.runtimeExitCounts.set(key, rCount);
1356
+ const rCooldown = Math.min(BROKEN_BASE_COOLDOWN_MS * 2 ** (rCount - 1), BROKEN_MAX_COOLDOWN_MS);
1357
+ this.state.broken.set(key, Date.now() + rCooldown);
1358
+ if (!isOptionalServer && rCount >= BROKEN_PERMANENT_AFTER) {
1359
+ this.permanentlyBroken.add(key);
1360
+ this.recordBreaker(key, `permanently disabled after ${rCount} early post-init exits`);
1361
+ logSessionStart(`lsp respawn ${server.id}: permanently disabled after ${rCount} early post-init exits (uptimeMs=${uptimeMs})`);
1362
+ }
1363
+ else {
1364
+ logSessionStart(`lsp respawn ${server.id}: early post-init exit ${rCount}/${BROKEN_PERMANENT_AFTER} (uptimeMs=${uptimeMs}), cooldown=${rCooldown}ms`);
1365
+ }
1366
+ }
1367
+ else {
1368
+ // Survived past the threshold (or uptime unknown) before dying —
1369
+ // this was a functioning server, not a HOT crash loop. Reset the
1370
+ // consecutive-early streak so an occasional post-longrun crash
1371
+ // doesn't creep it toward permanent disablement. The windowed
1372
+ // counter above is deliberately NOT reset here: a ~65-90s death
1373
+ // lands in this branch yet IS the churn #1142 targets — the
1374
+ // window ages those out by time instead.
1375
+ this.runtimeExitCounts.delete(key);
1376
+ // Outer-map hygiene (#1183): runtimeExitWindow itself is only
1377
+ // ever `delete`d on the optional-trip branch above (currently
1378
+ // unreachable — OPTIONAL_LSP_SERVER_IDS is empty), so a key
1379
+ // that crashed once then recovered would otherwise retain a
1380
+ // stale timestamp array forever, even once every entry in it
1381
+ // has aged out of the window. Drop it here once nothing in it
1382
+ // is still in-window — purely map hygiene: a later death
1383
+ // rebuilds the array from scratch via recordRuntimeExitWindow,
1384
+ // so this cannot change trip behavior. Anchor "now" on this
1385
+ // death's own `exitedAt` (falling back to real time only if
1386
+ // unset), NOT `Date.now()` directly — this death's timestamp
1387
+ // is what `recordRuntimeExitWindow` itself anchors pruning on
1388
+ // (see its `windowRef` comment), so a death that WAS just
1389
+ // recorded is guaranteed to still be in-window here and this
1390
+ // can only ever fire for the "declined to record" cases
1391
+ // (over the sleep-gap ceiling, or a missing `exitedAt`) where
1392
+ // nothing new was added and the array is genuinely stale.
1393
+ const windowDeaths = this.runtimeExitWindow.get(key);
1394
+ if (windowDeaths) {
1395
+ const windowNow = exitedAt ?? Date.now();
1396
+ if (!windowDeaths.some((t) => t >= windowNow - RUNTIME_EXIT_WINDOW_MS)) {
1397
+ this.runtimeExitWindow.delete(key);
1398
+ }
1399
+ }
1400
+ }
1401
+ }
834
1402
  }
835
1403
  const brokenUntil = this.state.broken.get(key);
836
1404
  if (typeof brokenUntil === "number" && brokenUntil > Date.now()) {
@@ -851,31 +1419,53 @@ export class LSPService {
851
1419
  if (isOptionalServer)
852
1420
  this.optionalDisabled.delete(key);
853
1421
  }
854
- const inFlight = this.state.inFlight.get(key);
855
- if (inFlight) {
856
- return inFlight;
1422
+ let spawnPromise = this.state.inFlight.get(key);
1423
+ if (!spawnPromise) {
1424
+ const started = await this.withClientSpawnGate(async () => {
1425
+ const raced = this.state.inFlight.get(key);
1426
+ if (raced)
1427
+ return { promise: raced };
1428
+ // `server.root()` and dead-client cleanup above are async. A reset during
1429
+ // either gap must not let this retired generation start a late spawn.
1430
+ if (this.checkDestroyed())
1431
+ return undefined;
1432
+ if (!(await this.makeCapacityForClient(key)))
1433
+ return undefined;
1434
+ const promise = this.spawnClient(server, root, key, filePath, allowInstall);
1435
+ this.state.inFlight.set(key, promise);
1436
+ return { promise };
1437
+ });
1438
+ if (!started)
1439
+ return undefined;
1440
+ spawnPromise = started.promise;
857
1441
  }
858
- // `server.root()` and a dead client's shutdown above are both async. A
859
- // reset during either gap may have completed without seeing this request in
860
- // state.inFlight; never let that retired generation start a late spawn.
861
- if (this.checkDestroyed())
862
- return undefined;
863
- const spawnPromise = this.spawnClient(server, root, key, filePath, allowInstall);
864
- this.state.inFlight.set(key, spawnPromise);
865
1442
  try {
866
1443
  return await spawnPromise;
867
1444
  }
868
1445
  finally {
869
- this.state.inFlight.delete(key);
1446
+ if (this.state.inFlight.get(key) === spawnPromise) {
1447
+ this.state.inFlight.delete(key);
1448
+ }
870
1449
  }
871
1450
  }
872
- shouldAllowInstall(_filePath, _root) {
1451
+ shouldAllowInstall(serverId) {
1452
+ if (!assertInstallAllowed(`lsp install: ${serverId}`))
1453
+ return false;
873
1454
  return process.env.PI_LENS_DISABLE_LSP_INSTALL !== "1";
874
1455
  }
875
1456
  /**
876
1457
  * Internal: spawn a client for a server/root combination
877
1458
  */
878
1459
  async spawnClient(server, root, key, filePath, allowInstall) {
1460
+ // #1334 S5: honor the host project-trust decision before executing any
1461
+ // project-resolved binary. Only an explicit host "not trusted" blocks —
1462
+ // a host with no trust surface (`"unknown"`) spawns exactly as before.
1463
+ // Deliberately NOT marked broken: trust is a policy outcome, not a server
1464
+ // failure, and the user may grant trust later in the same session.
1465
+ if (!isLspSpawnAllowedByTrust()) {
1466
+ logSessionStart(`lsp spawn ${server.id}: refused — ${projectTrustDenialReason()}`);
1467
+ return undefined;
1468
+ }
879
1469
  const isOptionalServer = OPTIONAL_LSP_SERVER_IDS.has(server.id); // NOSONAR: set intentionally empty — no optional servers configured yet
880
1470
  const startedAt = Date.now();
881
1471
  logSessionStart(`lsp spawn ${server.id}: start root=${root} install=${allowInstall ? "enabled" : "disabled"} file=${filePath}`);
@@ -914,6 +1504,7 @@ export class LSPService {
914
1504
  this.state.broken.set(key, Date.now() + uCooldown);
915
1505
  if (uCount >= BROKEN_PERMANENT_AFTER) {
916
1506
  this.permanentlyBroken.add(key);
1507
+ this.recordBreaker(key, `permanently disabled after ${uCount} unavailable spawns`);
917
1508
  logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${uCount} failures`);
918
1509
  }
919
1510
  return undefined;
@@ -943,7 +1534,10 @@ export class LSPService {
943
1534
  diagnosticProviderKind: "unavailable",
944
1535
  };
945
1536
  this.state.clients.set(key, client);
1537
+ this.unavailableLogged.delete(key);
946
1538
  this.state.clientSpawnedAt.set(key, Date.now());
1539
+ this.clientLastUsedAt.set(key, Date.now());
1540
+ this.scheduleTypeScriptIdleEviction(key);
947
1541
  this.failureCounts.delete(key);
948
1542
  if (isOptionalServer) {
949
1543
  this.optionalDisabled.delete(key);
@@ -973,6 +1567,7 @@ export class LSPService {
973
1567
  this.state.broken.set(key, Date.now() + eCooldown);
974
1568
  if (!isOptionalServer && eCount >= BROKEN_PERMANENT_AFTER) {
975
1569
  this.permanentlyBroken.add(key);
1570
+ this.recordBreaker(key, `permanently disabled after ${eCount} spawn/initialize failures`);
976
1571
  logSessionStart(`lsp spawn ${server.id}: permanently disabled after ${eCount} failures`);
977
1572
  }
978
1573
  if (isOptionalServer) {
@@ -987,11 +1582,10 @@ export class LSPService {
987
1582
  async openFile(filePath, content, options) {
988
1583
  if (this.checkDestroyed())
989
1584
  return;
990
- const spawned = await this.getClientForFile(filePath, undefined, options?.spawnBudgetMs);
991
- if (!spawned)
992
- return;
993
- const languageId = getLanguageId(filePath) ?? "plaintext";
994
- await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
1585
+ await this.withClientForFileUse(filePath, undefined, options?.spawnBudgetMs, async (spawned) => {
1586
+ const languageId = getLanguageId(filePath) ?? "plaintext";
1587
+ await spawned.client.notify.open(filePath, content, languageId, options?.preserveDiagnostics);
1588
+ });
995
1589
  }
996
1590
  /**
997
1591
  * Update file content (sends textDocument/didChange)
@@ -999,10 +1593,7 @@ export class LSPService {
999
1593
  async updateFile(filePath, content) {
1000
1594
  if (this.checkDestroyed())
1001
1595
  return;
1002
- const spawned = await this.getClientForFile(filePath);
1003
- if (!spawned)
1004
- return;
1005
- await spawned.client.notify.change(filePath, content);
1596
+ await this.withClientForFileUse(filePath, undefined, undefined, (spawned) => spawned.client.notify.change(filePath, content));
1006
1597
  }
1007
1598
  /**
1008
1599
  * Touch a file like OpenCode's LSP flow: ensure document is open/synced,
@@ -1064,533 +1655,733 @@ export class LSPService {
1064
1655
  });
1065
1656
  return;
1066
1657
  }
1067
- if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none")) {
1068
- logLatency({
1069
- type: "phase",
1070
- phase: "lsp_touch_file",
1071
- filePath: normalizedPath,
1072
- durationMs: Date.now() - startedAt,
1073
- metadata: {
1074
- serverCountReady: spawned.length,
1075
- clientScope,
1076
- diagnosticsMode,
1077
- source,
1078
- failureKind: "success",
1079
- skipped: true,
1080
- reason: "debounced_unchanged_content",
1081
- },
1082
- });
1083
- return [];
1658
+ const leaseKeys = await this.acquireClientLeases(spawned, filePath);
1659
+ if (!leaseKeys) {
1660
+ // An idle eviction won between selection and lease admission. Resolve the
1661
+ // now-current client set and replay; no notification targets the retiree.
1662
+ return this.touchFile(filePath, content, options);
1084
1663
  }
1085
- const languageId = getLanguageId(filePath) ?? "plaintext";
1086
- const silent = options.silent ?? false;
1087
- // When the same content was already pushed to the LSP within the touch
1088
- // debounce window, skip the notify — pushing again clears the LSP's
1089
- // diagnostic cache (via notify.open) and forces it to restart work it
1090
- // already did. This is what makes the post-write touch + dispatch-lsp-
1091
- // runner touch sequence expensive on slow TS projects.
1092
- const notifySkipped = this.shouldSkipNotify(filePath, content, clientScope);
1093
- const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
1094
- // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1095
- // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1096
- // deadline over a single Promise.all — otherwise one backpressured server
1097
- // (stalled stdin) times out the write for the ENTIRE file, flipping every
1098
- // co-touched healthy server to inconclusive and zeroing its diagnostics.
1099
- // Bounded so a backpressured write can't hang the caller; on timeout we
1100
- // proceed — the diagnostics wait below is separately bounded and simply
1101
- // returns no fresh diagnostics for the server(s) that stalled.
1102
- //
1103
- // Holds the serverId of every server whose write did NOT land in time.
1104
- // The file-level `notifyWriteTimedOut` (logged below) means "at least one
1105
- // server timed out"; this list carries the per-server detail the
1106
- // demonstratedReady gate reads so a healthy sibling stays eligible.
1107
- const notifyWriteTimedOutServerIds = [];
1108
- if (!notifySkipped) {
1109
- const budget = notifyWriteBudgetMs();
1110
- await Promise.all(spawned.map(async (entry) => {
1111
- // Same identity as the broken/demonstratedReady maps.
1112
- const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
1113
- let wrote;
1114
- let rejected = false;
1115
- try {
1116
- wrote = await withDeadline(entry.client.notify
1117
- .open(filePath, content, languageId, undefined, silent)
1118
- .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
1119
- }
1120
- catch {
1121
- // The write itself rejected (not backpressure): the content did
1122
- // not land, so this server is inconclusive for the touch, but a
1123
- // rejection is not a stdin-backpressure signal and must not count
1124
- // toward the backpressure demotion streak.
1125
- rejected = true;
1126
- }
1127
- if (wrote === true) {
1128
- // A clean write clears any accrued backpressure streak (#743).
1129
- if (clientKey)
1130
- this.notifyWriteBackpressureStreak.delete(clientKey);
1131
- }
1132
- else {
1133
- notifyWriteTimedOutServerIds.push(entry.info.id);
1134
- if (!rejected) {
1135
- this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
1136
- }
1137
- }
1138
- return true;
1139
- }));
1140
- if (notifyWriteTimedOutServerIds.length > 0) {
1664
+ try {
1665
+ const spawnedServerIds = spawned.map((entry) => entry.info.id);
1666
+ if (this.shouldSkipTouch(filePath, content, clientScope, diagnosticsMode !== "none", spawnedServerIds)) {
1141
1667
  logLatency({
1142
1668
  type: "phase",
1143
- phase: "lsp_notify_timeout",
1669
+ phase: "lsp_touch_file",
1144
1670
  filePath: normalizedPath,
1145
1671
  durationMs: Date.now() - startedAt,
1146
1672
  metadata: {
1147
- source,
1673
+ serverCountReady: spawned.length,
1148
1674
  clientScope,
1149
- serverCount: spawned.length,
1150
- timedOutServerIds: notifyWriteTimedOutServerIds,
1675
+ diagnosticsMode,
1676
+ source,
1677
+ failureKind: "success",
1678
+ skipped: true,
1679
+ reason: "debounced_unchanged_content",
1151
1680
  },
1152
1681
  });
1682
+ // #1179: a debounced skip collected nothing — resolve the wrapper's
1683
+ // `.diags` as empty with neither flag (exactly the pre-wrapper `[]`).
1684
+ return { diags: [] };
1153
1685
  }
1154
- }
1155
- // File-level flag: at least one server's write timed out (kept for the
1156
- // conservative touch-wide `inconclusive` merge semantics — see below).
1157
- const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
1158
- let diagnosticsTimedOut = false;
1159
- // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1160
- // the aux grace window. Undefined when no aux was cut off (primary-only
1161
- // paths never set this). Logged in lsp_touch_file metadata.
1162
- let auxCutOffServerIds;
1163
- // #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
1164
- // full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
1165
- // holds the sync commands' answer when the racing confirm won the wait
1166
- // (undefined = the race didn't produce an answer; the end-of-wait
1167
- // fallback below may still fill it in on a timed-out empty result).
1168
- let tsserverSyncEligible = false;
1169
- let tsserverSyncConfirmed;
1170
- if (diagnosticsMode !== "none") {
1171
- // Resolution: env wins so users can tune the cap without rebuilding.
1172
- // Otherwise, on the single-server hot path (primary scope), use that
1173
- // server's own strategy budget (server-strategies.ts) so a fast server
1174
- // (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
1175
- // one (rust-analyzer 3s) gets the time it needs — bounded by any caller
1176
- // ceiling that exists to protect the per-edit pipeline budget (#203).
1177
- // #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
1178
- // gets the same per-server treatment as "with-auxiliary" — each spawned
1179
- // server (primary + any auxiliaries) is bounded by ITS OWN strategy
1180
- // budget instead of one flat number shared by every server. This was
1181
- // never a deliberate "all means wait for the group ceiling" semantic:
1182
- // #203 introduced perServerTimeout only for the single-server primary
1183
- // path and left "full"/"all" on the pre-existing flat resolution
1184
- // ("full/cascade path unchanged"); #242 later added "with-auxiliary"
1185
- // without revisiting "all". The one property "all" genuinely needs —
1186
- // the touch's overall detection deadline is the SLOWEST spawned
1187
- // server's budget, not the fastest — is unaffected: `timeoutMs` below
1188
- // is always `Math.max(...spawned.map(timeoutFor))` regardless of which
1189
- // timeoutFor is selected, so a slow auxiliary still gets to run to its
1190
- // own budget before the touch is logged as timed out. What changes is
1191
- // only that a fast server's *individual* `waitForDiagnostics` call
1192
- // (further below) now resolves/times out against its own budget
1193
- // instead of blocking to the flat multi-server number.
1194
- const envWait = readEnvDiagnosticsWaitMs();
1195
- const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
1196
- const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
1197
- // #645: resolve each spawned server's "is this the first same-sweep
1198
- // touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
1199
- // is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
1200
- // (it marks the server seen), and `perServerTimeout` below is invoked
1201
- // twice per server in this call (once to compute the overall
1202
- // `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
1203
- // the gate directly from inside `perServerTimeout` would consume the
1204
- // "first touch" slot on the first of those two calls and read as
1205
- // already-warm on the second, silently shortchanging the very touch
1206
- // that was supposed to get the full budget.
1207
- const sweepFirstTouch = new Map();
1208
- if (options.sweepIndexGate) {
1209
- for (const entry of spawned) {
1210
- const strategy = getStrategy(entry.client.serverId);
1211
- if (strategy.workspaceIndexing) {
1212
- sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
1213
- }
1214
- }
1215
- }
1216
- // #832: workspace-indexing servers that are classified as silent on
1217
- // clean do not benefit from the generic cold-indexing floor. Their
1218
- // configured strategy already gives the first sweep touch a bounded
1219
- // workspace-index budget (marksman: 1500ms), while the capability
1220
- // classification proves that a clean push has no affirmative signal to
1221
- // wait for. Keep this restricted to the workspace-indexing strategy:
1222
- // TypeScript is also a silent-on-clean push server, but its cold project
1223
- // load still needs the longer 20s floor.
1686
+ const languageId = getLanguageId(filePath) ?? "plaintext";
1687
+ const silent = options.silent ?? false;
1688
+ // When the same content was already pushed to the LSP within the touch
1689
+ // debounce window, skip the notify — pushing again clears the LSP's
1690
+ // diagnostic cache (via notify.open) and forces it to restart work it
1691
+ // already did. This is what makes the post-write touch + dispatch-lsp-
1692
+ // runner touch sequence expensive on slow TS projects.
1224
1693
  //
1225
- // Build this from the live spawned client's capabilities rather than
1226
- // server id alone. Missing/throwing capability data fails closed, so a
1227
- // new or ambiguous server keeps the existing generous warm-up budget.
1228
- const silentCleanWarmupServers = new Set();
1229
- if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1230
- for (const entry of spawned) {
1231
- const strategy = getStrategy(entry.client.serverId);
1232
- if (strategy.workspaceIndexing !== true)
1233
- continue;
1694
+ // #743: resolved PER SERVER. A server whose sibling's write stalled last
1695
+ // touch still holds its own debounce entry, so it is skipped here while the
1696
+ // stalled server (which has no entry) gets re-pushed. `notifySkipped` stays
1697
+ // as the file-level "every server was skipped" summary for the logs and the
1698
+ // no-new-version baseline below.
1699
+ const notifySkippedServerIds = new Set(spawnedServerIds.filter((serverId) => this.shouldSkipNotify(filePath, content, clientScope, serverId)));
1700
+ const notifySkipped = spawned.length > 0 && notifySkippedServerIds.size === spawned.length;
1701
+ const diagnosticBaselines = new Map(spawned.map((entry) => [entry.client, entry.client.diagnosticsVersion]));
1702
+ // #743: PER-SERVER notify-write deadlines. Each server's didOpen/didChange
1703
+ // write gets its OWN notifyWriteBudgetMs budget rather than one shared
1704
+ // deadline over a single Promise.all — otherwise one backpressured server
1705
+ // (stalled stdin) times out the write for the ENTIRE file, flipping every
1706
+ // co-touched healthy server to inconclusive and zeroing its diagnostics.
1707
+ // Bounded so a backpressured write can't hang the caller; on timeout we
1708
+ // proceed — the diagnostics wait below is separately bounded and simply
1709
+ // returns no fresh diagnostics for the server(s) that stalled.
1710
+ //
1711
+ // Holds the serverId of every server whose write did NOT land in time.
1712
+ // The file-level `notifyWriteTimedOut` (logged below) means "at least one
1713
+ // server timed out"; this list carries the per-server detail the
1714
+ // demonstratedReady gate reads so a healthy sibling stays eligible.
1715
+ const notifyWriteTimedOutServerIds = [];
1716
+ if (!notifySkipped) {
1717
+ const budget = notifyWriteBudgetMs();
1718
+ await Promise.all(spawned.map(async (entry) => {
1719
+ // #743: this server already has this content from a recent touch
1720
+ // that landed. Pushing again would clear its diagnostic cache for
1721
+ // nothing — leave its debounce entry (and its original timestamp)
1722
+ // alone so the window still expires naturally.
1723
+ if (notifySkippedServerIds.has(entry.info.id))
1724
+ return;
1725
+ // Same identity as the broken/demonstratedReady maps.
1726
+ const clientKey = await this.demonstratedReadyKeyFor(entry.info, filePath);
1727
+ let wrote;
1728
+ let rejected = false;
1234
1729
  try {
1235
- const snapshot = {
1236
- serverId: entry.client.serverId,
1237
- root: entry.client.root,
1238
- operationSupport: entry.client.getOperationSupport(),
1239
- workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
1240
- advertisedCommands: entry.client.getAdvertisedCommands(),
1241
- rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
1242
- launchVariant: entry.client.getLaunchVariant?.(),
1243
- };
1244
- if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
1245
- "tier3-silent") {
1246
- silentCleanWarmupServers.add(entry.client.serverId);
1247
- }
1730
+ wrote = await withDeadline(entry.client.notify
1731
+ .open(filePath, content, languageId, undefined, silent)
1732
+ .then(() => true), { ms: budget, onTimeout: "undefined", onReject: "propagate" });
1248
1733
  }
1249
1734
  catch {
1250
- // Fail closed: capability uncertainty must retain the cold floor.
1735
+ // The write itself rejected (not backpressure): the content did
1736
+ // not land, so this server is inconclusive for the touch, but a
1737
+ // rejection is not a stdin-backpressure signal and must not count
1738
+ // toward the backpressure demotion streak.
1739
+ rejected = true;
1740
+ }
1741
+ if (wrote === true) {
1742
+ // A clean write clears any accrued backpressure streak (#743).
1743
+ if (clientKey)
1744
+ this.notifyWriteBackpressureStreak.delete(clientKey);
1745
+ // #1253: record the debounce entry for THIS server only, and only
1746
+ // because its own write landed. A server whose write timed out or
1747
+ // rejected falls through without an entry, so the next touch
1748
+ // re-pushes it instead of laundering the failure into a later
1749
+ // touch that looks fully delivered (which the silent-clean gates
1750
+ // would then read as a confirmed clean).
1751
+ this.markTouched(filePath, content, clientScope, entry.info.id);
1752
+ }
1753
+ else {
1754
+ notifyWriteTimedOutServerIds.push(entry.info.id);
1755
+ if (!rejected) {
1756
+ this.recordNotifyWriteBackpressure(clientKey, entry, filePath);
1757
+ }
1251
1758
  }
1759
+ }));
1760
+ if (notifyWriteTimedOutServerIds.length > 0) {
1761
+ logLatency({
1762
+ type: "phase",
1763
+ phase: "lsp_notify_timeout",
1764
+ filePath: normalizedPath,
1765
+ durationMs: Date.now() - startedAt,
1766
+ metadata: {
1767
+ source,
1768
+ clientScope,
1769
+ serverCount: spawned.length,
1770
+ timedOutServerIds: notifyWriteTimedOutServerIds,
1771
+ },
1772
+ });
1252
1773
  }
1253
1774
  }
1254
- // Each server gets its OWN deadline, bounded by the caller cap as a
1255
- // CEILING (never a floor) — so a clean push-silent primary (typescript
1256
- // ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
1257
- // slow aux (opengrep) can't override the per-edit cap. Resolves as soon
1258
- // as a server publishes; this is just its individual deadline. (#242)
1259
- const perServerTimeout = (serverId) => {
1260
- const strategy = getStrategy(serverId);
1261
- let strategyWait = strategy.aggregateWaitMs;
1262
- // #645: a `workspaceIndexing` server (marksman) only needs the
1263
- // full budget for the FIRST same-sweep touch to it — every
1264
- // subsequent touch in this sweep uses the much shorter warm-wait
1265
- // instead, since the one-time index build only needs to finish
1266
- // once. `sweepFirstTouch` only has entries when a sweep gate was
1267
- // passed in AND the strategy is marked, so a per-edit touch
1268
- // (no gate) or an unmarked server is completely unaffected.
1269
- const isFirstTouch = sweepFirstTouch.get(serverId);
1270
- if (isFirstTouch === false && strategy.workspaceIndexing) {
1271
- strategyWait =
1272
- strategy.workspaceIndexingWarmWaitMs ??
1273
- Math.min(300, strategyWait);
1775
+ // File-level flag: at least one server's write timed out (kept for the
1776
+ // conservative touch-wide `inconclusive` merge semantics — see below).
1777
+ const notifyWriteTimedOut = notifyWriteTimedOutServerIds.length > 0;
1778
+ let diagnosticsTimedOut = false;
1779
+ // R8 (#714): server ids of aux-role servers whose push wait was cut off by
1780
+ // the aux grace window. Undefined when no aux was cut off (primary-only
1781
+ // paths never set this). Logged in lsp_touch_file metadata.
1782
+ let auxCutOffServerIds;
1783
+ // #707: tsserver sync clean-confirm state. `tsserverSyncEligible` is the
1784
+ // full gate (evaluated once, before the wait); `tsserverSyncConfirmed`
1785
+ // holds the sync commands' answer when the racing confirm won the wait
1786
+ // (undefined = the race didn't produce an answer; the end-of-wait
1787
+ // fallback below may still fill it in on a timed-out empty result).
1788
+ let tsserverSyncEligible = false;
1789
+ let tsserverSyncConfirmed;
1790
+ if (diagnosticsMode !== "none") {
1791
+ // Resolution: env wins so users can tune the cap without rebuilding.
1792
+ // Otherwise, on the single-server hot path (primary scope), use that
1793
+ // server's own strategy budget (wait-policy/strategies.ts) so a fast server
1794
+ // (TypeScript ~1s) isn't held to a flat multi-second wait while a slow
1795
+ // one (rust-analyzer 3s) gets the time it needs — bounded by any caller
1796
+ // ceiling that exists to protect the per-edit pipeline budget (#203).
1797
+ // #573: clientScope "all" (lsp_diagnostics, lens_diagnostics_full) now
1798
+ // gets the same per-server treatment as "with-auxiliary" — each spawned
1799
+ // server (primary + any auxiliaries) is bounded by ITS OWN strategy
1800
+ // budget instead of one flat number shared by every server. This was
1801
+ // never a deliberate "all means wait for the group ceiling" semantic:
1802
+ // #203 introduced perServerTimeout only for the single-server primary
1803
+ // path and left "full"/"all" on the pre-existing flat resolution
1804
+ // ("full/cascade path unchanged"); #242 later added "with-auxiliary"
1805
+ // without revisiting "all". The one property "all" genuinely needs —
1806
+ // the touch's overall detection deadline is the SLOWEST spawned
1807
+ // server's budget, not the fastest — is unaffected: `timeoutMs` below
1808
+ // is always `Math.max(...spawned.map(timeoutFor))` regardless of which
1809
+ // timeoutFor is selected, so a slow auxiliary still gets to run to its
1810
+ // own budget before the touch is logged as timed out. What changes is
1811
+ // only that a fast server's *individual* `waitForDiagnostics` call
1812
+ // (further below) now resolves/times out against its own budget
1813
+ // instead of blocking to the flat multi-server number.
1814
+ const envWait = readEnvDiagnosticsWaitMs();
1815
+ const callerCap = options.maxDiagnosticsWaitMs ?? options.maxClientWaitMs;
1816
+ const modeFloor = diagnosticsMode === "full" ? 3000 : 1200;
1817
+ // #645: resolve each spawned server's "is this the first same-sweep
1818
+ // touch for it" verdict EXACTLY ONCE up front, before `perServerTimeout`
1819
+ // is defined. `SweepIndexGate.consumeFirstTouch` is side-effecting
1820
+ // (it marks the server seen), and `perServerTimeout` below is invoked
1821
+ // twice per server in this call (once to compute the overall
1822
+ // `timeoutMs` deadline, again inside the wait `Promise.all`) — calling
1823
+ // the gate directly from inside `perServerTimeout` would consume the
1824
+ // "first touch" slot on the first of those two calls and read as
1825
+ // already-warm on the second, silently shortchanging the very touch
1826
+ // that was supposed to get the full budget.
1827
+ const sweepFirstTouch = new Map();
1828
+ if (options.sweepIndexGate) {
1829
+ for (const entry of spawned) {
1830
+ const strategy = getStrategy(entry.client.serverId);
1831
+ if (strategy.workspaceIndexing) {
1832
+ sweepFirstTouch.set(entry.client.serverId, options.sweepIndexGate.consumeFirstTouch(entry.client.serverId));
1833
+ }
1834
+ }
1274
1835
  }
1275
- if (callerCap !== undefined) {
1276
- // #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
1277
- // to act as a FLOOR (give it at least this much, possibly more
1278
- // if the strategy already wants more) rather than the normal
1279
- // ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
1280
- if (options.warmupOverride) {
1281
- // #832: a workspace-indexing server already classified as
1282
- // silent-on-clean uses its strategy's bounded wait on the first
1283
- // attempt; the generic cold floor is for servers whose cold work
1284
- // can eventually produce a push answer (notably TypeScript).
1285
- if (silentCleanWarmupServers.has(serverId)) {
1286
- return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1836
+ // #832: workspace-indexing servers that are classified as silent on
1837
+ // clean do not benefit from the generic cold-indexing floor. Their
1838
+ // configured strategy already gives the first sweep touch a bounded
1839
+ // workspace-index budget (marksman: 1500ms), while the capability
1840
+ // classification proves that a clean push has no affirmative signal to
1841
+ // wait for. Keep this restricted to the workspace-indexing strategy:
1842
+ // TypeScript is also a silent-on-clean push server, but its cold project
1843
+ // load still needs the longer 20s floor.
1844
+ //
1845
+ // Build this from the live spawned client's capabilities rather than
1846
+ // server id alone. Missing/throwing capability data fails closed, so a
1847
+ // new or ambiguous server keeps the existing generous warm-up budget.
1848
+ const silentCleanWarmupServers = new Set();
1849
+ if (options.warmupOverride && (options.warmupAttempt ?? 1) <= 1) {
1850
+ for (const entry of spawned) {
1851
+ const strategy = getStrategy(entry.client.serverId);
1852
+ if (strategy.workspaceIndexing !== true)
1853
+ continue;
1854
+ try {
1855
+ const snapshot = {
1856
+ serverId: entry.client.serverId,
1857
+ root: entry.client.root,
1858
+ operationSupport: entry.client.getOperationSupport(),
1859
+ workspaceDiagnosticsSupport: entry.client.getWorkspaceDiagnosticsSupport(),
1860
+ advertisedCommands: entry.client.getAdvertisedCommands(),
1861
+ rawCapabilityKeys: entry.client.getRawCapabilityKeys?.() ?? [],
1862
+ launchVariant: entry.client.getLaunchVariant?.(),
1863
+ };
1864
+ if (classifyServerWaitTier(entry.client.serverId, snapshot) ===
1865
+ "tier3-silent") {
1866
+ silentCleanWarmupServers.add(entry.client.serverId);
1867
+ }
1287
1868
  }
1288
- // #799: only the FIRST warm-up attempt for a cold server gets the
1289
- // floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
1290
- if ((options.warmupAttempt ?? 1) > 1) {
1291
- return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1869
+ catch {
1870
+ // Fail closed: capability uncertainty must retain the cold floor.
1292
1871
  }
1293
- return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
1294
1872
  }
1295
- return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1296
1873
  }
1297
- return strategyWait > 0 ? strategyWait : modeFloor;
1298
- };
1299
- let timeoutFor;
1300
- if (envWait !== undefined) {
1301
- // Env override is a single flat cap so users can tune without rebuilding.
1302
- timeoutFor = () => envWait;
1303
- }
1304
- else if ((!useAllClients && spawned.length === 1) ||
1305
- clientScope === "with-auxiliary" ||
1306
- clientScope === "all") {
1307
- timeoutFor = perServerTimeout;
1308
- }
1309
- else {
1310
- // Fail-safe for any future clientScope this branch hasn't been
1311
- // taught about yet — keep the old flat resolution rather than
1312
- // silently mis-budgeting an unrecognized scope.
1313
- timeoutFor = () => callerCap ?? modeFloor;
1314
- }
1315
- // Detection deadline = the slowest individual server's budget.
1316
- const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
1317
- // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1318
- // starts. Cheap synchronous gates first (notify succeeded, collecting,
1319
- // primary scope, `serverId === "typescript"` — the sync commands this
1320
- // races are tsserver-specific protocol extensions, not a generic
1321
- // push-only capability, so #799 giving other servers (marksman) the
1322
- // SAME `silentOnClean` marker must not route them into a sync attempt
1323
- // that can never succeed for them), then the live capability-snapshot
1324
- // tier classification (`classifyCascadeWaitTier`, which also excludes
1325
- // native-ts7 via `launchVariant`). Every other server fails this
1326
- // synchronous gate and pays ZERO extra work — not even the snapshot
1327
- // read; a non-typescript `silentOnClean` server instead gets the
1328
- // generic (non-racing) clean-confirm fallback further below.
1329
- if (!notifyWriteTimedOut &&
1330
- options.collectDiagnostics === true &&
1331
- clientScope === "primary" &&
1332
- spawned.length === 1 &&
1333
- spawned[0].client.serverId === "typescript" &&
1334
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1335
- try {
1336
- const snapshots = await this.getCapabilitySnapshots(filePath);
1337
- tsserverSyncEligible =
1338
- classifyCascadeWaitTier(this, filePath, snapshots) ===
1339
- "tier3-silent";
1874
+ // Each server gets its OWN deadline, bounded by the caller cap as a
1875
+ // CEILING (never a floor) — so a clean push-silent primary (typescript
1876
+ // ~1s) can't hold the whole touch to a slow auxiliary's budget, and a
1877
+ // slow aux (opengrep) can't override the per-edit cap. Resolves as soon
1878
+ // as a server publishes; this is just its individual deadline. (#242)
1879
+ const perServerTimeout = (serverId) => {
1880
+ const strategy = getStrategy(serverId);
1881
+ let strategyWait = strategy.aggregateWaitMs;
1882
+ // #645: a `workspaceIndexing` server (marksman) only needs the
1883
+ // full budget for the FIRST same-sweep touch to it — every
1884
+ // subsequent touch in this sweep uses the much shorter warm-wait
1885
+ // instead, since the one-time index build only needs to finish
1886
+ // once. `sweepFirstTouch` only has entries when a sweep gate was
1887
+ // passed in AND the strategy is marked, so a per-edit touch
1888
+ // (no gate) or an unmarked server is completely unaffected.
1889
+ const isFirstTouch = sweepFirstTouch.get(serverId);
1890
+ if (isFirstTouch === false && strategy.workspaceIndexing) {
1891
+ strategyWait =
1892
+ strategy.workspaceIndexingWarmWaitMs ??
1893
+ Math.min(300, strategyWait);
1894
+ }
1895
+ if (callerCap !== undefined) {
1896
+ // #669: `ensureWarmForSweep`'s cold-server warm-up wants its cap
1897
+ // to act as a FLOOR (give it at least this much, possibly more
1898
+ // if the strategy already wants more) rather than the normal
1899
+ // ceiling — see `warmupOverride` doc on `LSPTouchFileOptions`.
1900
+ if (options.warmupOverride) {
1901
+ // #832: a workspace-indexing server already classified as
1902
+ // silent-on-clean uses its strategy's bounded wait on the first
1903
+ // attempt; the generic cold floor is for servers whose cold work
1904
+ // can eventually produce a push answer (notably TypeScript).
1905
+ if (silentCleanWarmupServers.has(serverId)) {
1906
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1907
+ }
1908
+ // #799: only the FIRST warm-up attempt for a cold server gets the
1909
+ // floor — see the `warmupAttempt` doc on `LSPTouchFileOptions`.
1910
+ if ((options.warmupAttempt ?? 1) > 1) {
1911
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1912
+ }
1913
+ return Math.max(callerCap, strategyWait > 0 ? strategyWait : 0);
1914
+ }
1915
+ return Math.min(callerCap, strategyWait > 0 ? strategyWait : callerCap);
1916
+ }
1917
+ return strategyWait > 0 ? strategyWait : modeFloor;
1918
+ };
1919
+ let timeoutFor;
1920
+ if (envWait !== undefined) {
1921
+ // Env override is a single flat cap so users can tune without rebuilding.
1922
+ timeoutFor = () => envWait;
1340
1923
  }
1341
- catch {
1342
- // Fail-safe: ineligible — today's full wait, no sync attempt.
1924
+ else if ((!useAllClients && spawned.length === 1) ||
1925
+ clientScope === "with-auxiliary" ||
1926
+ clientScope === "all") {
1927
+ timeoutFor = perServerTimeout;
1343
1928
  }
1344
- }
1345
- const waitStartedAt = Date.now();
1346
- // R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
1347
- // slow auxiliary no longer holds the push wait to its own deadline.
1348
- // Primary waits resolve on their own per-server budget; once ALL primaries
1349
- // have settled the auxiliaries get at most auxGraceMs before we proceed.
1350
- // Primary-only and "all"/"primary" scopes are completely unaffected —
1351
- // they fall through to the original Promise.all path below.
1352
- //
1353
- // "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
1354
- // In the with-auxiliary spawn list, `getClientForFile` returns the
1355
- // language-primary entry first and `getAuxiliaryClientsForFile` appends
1356
- // the rest — but we use info.role rather than position so the logic is
1357
- // correct even if ordering shifts in the future.
1358
- //
1359
- // The #707 tsserver sync race operates exclusively on single-server
1360
- // primary-scope touches (guarded by `clientScope === "primary" &&
1361
- // spawned.length === 1`), so there is NO interaction with this path.
1362
- const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
1363
- spawned.some((e) => e.info.role === "auxiliary");
1364
- // Per-server wait promises (each already bounded by its own
1365
- // perServerTimeout — unchanged from before R8).
1366
- let pressureSnapshots = [];
1367
- if (shouldPreferPullOnlyDiagnostics()) {
1368
- try {
1369
- pressureSnapshots = await this.getCapabilitySnapshots(filePath);
1929
+ else {
1930
+ // Fail-safe for any future clientScope this branch hasn't been
1931
+ // taught about yet — keep the old flat resolution rather than
1932
+ // silently mis-budgeting an unrecognized scope.
1933
+ timeoutFor = () => callerCap ?? modeFloor;
1370
1934
  }
1371
- catch {
1372
- // Fail-open: missing capability state keeps today's push fallback.
1935
+ // Detection deadline = the slowest individual server's budget.
1936
+ const timeoutMs = Math.max(0, ...spawned.map((e) => timeoutFor(e.client.serverId)));
1937
+ // #707: evaluate the tsserver sync clean-confirm gate BEFORE the wait
1938
+ // starts. Cheap synchronous gates first (notify succeeded, collecting,
1939
+ // primary scope, `serverId === "typescript"` — the sync commands this
1940
+ // races are tsserver-specific protocol extensions, not a generic
1941
+ // push-only capability, so #799 giving other servers (marksman) the
1942
+ // SAME `silentOnClean` marker must not route them into a sync attempt
1943
+ // that can never succeed for them), then the live capability-snapshot
1944
+ // tier classification (`classifyCascadeWaitTier`, which also excludes
1945
+ // native-ts7 via `launchVariant`). Every other server fails this
1946
+ // synchronous gate and pays ZERO extra work — not even the snapshot
1947
+ // read; a non-typescript `silentOnClean` server instead gets the
1948
+ // generic (non-racing) clean-confirm fallback further below.
1949
+ if (!notifyWriteTimedOut &&
1950
+ options.collectDiagnostics === true &&
1951
+ clientScope === "primary" &&
1952
+ spawned.length === 1 &&
1953
+ spawned[0].client.serverId === "typescript" &&
1954
+ getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1955
+ try {
1956
+ const snapshots = await this.getCapabilitySnapshots(filePath);
1957
+ tsserverSyncEligible =
1958
+ classifyCascadeWaitTier(this, filePath, snapshots) ===
1959
+ "tier3-silent";
1960
+ }
1961
+ catch {
1962
+ // Fail-safe: ineligible — today's full wait, no sync attempt.
1963
+ }
1373
1964
  }
1374
- }
1375
- const perServerWaits = spawned.map((entry) => {
1376
- const serverTimeout = timeoutFor(entry.client.serverId);
1377
- const baseline = diagnosticBaselines.get(entry.client);
1378
- const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
1379
- const wait = !notifySkipped && Number.isFinite(baseline)
1380
- ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1381
- minVersion: baseline,
1382
- ...(pullOnly && { pullOnly: true }),
1383
- })
1384
- : pullOnly
1965
+ const waitStartedAt = Date.now();
1966
+ // R8 (#714): on the with-auxiliary path, apply a bounded aux grace so a
1967
+ // slow auxiliary no longer holds the push wait to its own deadline.
1968
+ // Primary waits resolve on their own per-server budget; once ALL primaries
1969
+ // have settled the auxiliaries get at most auxGraceMs before we proceed.
1970
+ // Primary-only and "all"/"primary" scopes are completely unaffected —
1971
+ // they fall through to the original Promise.all path below.
1972
+ //
1973
+ // "Primary" here = a server whose LSPServerInfo.role is not "auxiliary".
1974
+ // In the with-auxiliary spawn list, `getClientForFile` returns the
1975
+ // language-primary entry first and `getAuxiliaryClientsForFile` appends
1976
+ // the rest — but we use info.role rather than position so the logic is
1977
+ // correct even if ordering shifts in the future.
1978
+ //
1979
+ // The #707 tsserver sync race operates exclusively on single-server
1980
+ // primary-scope touches (guarded by `clientScope === "primary" &&
1981
+ // spawned.length === 1`), so there is NO interaction with this path.
1982
+ const hasTouchAuxiliaries = clientScope === "with-auxiliary" &&
1983
+ spawned.some((e) => e.info.role === "auxiliary");
1984
+ // Per-server wait promises (each already bounded by its own
1985
+ // perServerTimeout — unchanged from before R8).
1986
+ let pressureSnapshots = [];
1987
+ if (shouldPreferPullOnlyDiagnostics()) {
1988
+ try {
1989
+ pressureSnapshots = await this.getCapabilitySnapshots(filePath);
1990
+ }
1991
+ catch {
1992
+ // Fail-open: missing capability state keeps today's push fallback.
1993
+ }
1994
+ }
1995
+ const perServerWaits = spawned.map((entry) => {
1996
+ const serverTimeout = timeoutFor(entry.client.serverId);
1997
+ const baseline = diagnosticBaselines.get(entry.client);
1998
+ const pullOnly = classifyServerWaitTier(entry.client.serverId, pressureSnapshots.find((snapshot) => snapshot.serverId === entry.client.serverId)) === "pull-capable";
1999
+ // #743: per-server — a server we DID push to still gets the
2000
+ // version-baseline wait even when a sibling was debounced away.
2001
+ const wait = !notifySkippedServerIds.has(entry.info.id) && Number.isFinite(baseline)
1385
2002
  ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
1386
- pullOnly: true,
2003
+ minVersion: baseline,
2004
+ ...(pullOnly && { pullOnly: true }),
1387
2005
  })
1388
- : entry.client.waitForDiagnostics(filePath, serverTimeout);
1389
- return wait.catch(() => undefined);
1390
- });
1391
- // The push wait — same per-server budget composition as before #707;
1392
- // only the awaiting changed (assigned so it can be raced below).
1393
- let pushWaitSettled = false;
1394
- const pushWait = hasTouchAuxiliaries
1395
- ? (() => {
1396
- // Primary waits: all non-auxiliary servers.
1397
- const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
1398
- // Aux waits: auxiliary servers (advisory).
1399
- const auxWaits = perServerWaits
1400
- .map((p, i) => spawned[i].info.role === "auxiliary"
1401
- ? { promise: p, serverId: spawned[i].info.id }
1402
- : null)
1403
- .filter((x) => x !== null);
1404
- const auxGraceMs = readEnvAuxGraceMs() ?? 500;
1405
- // After all primaries settle, give auxiliaries at most auxGraceMs.
1406
- // Late aux results are dropped from the wait (advisory only — they
1407
- // land in the client cache and surface on the next edit); aux servers
1408
- // that did answer within the grace are included automatically since
1409
- // their waitForDiagnostics already resolved. The cut-off server ids
1410
- // are logged in the latency metadata (lsp_touch_file phase, field
1411
- // `auxCutOffServerIds`).
1412
- return Promise.all(primaryWaits).then(async () => {
1413
- if (auxWaits.length === 0)
1414
- return;
1415
- const auxTimeout = new Promise((resolve) => {
1416
- const t = setTimeout(() => resolve("timeout"), auxGraceMs);
1417
- if (typeof t === "object" && "unref" in t)
1418
- t.unref?.();
2006
+ : pullOnly
2007
+ ? entry.client.waitForDiagnostics(filePath, serverTimeout, {
2008
+ pullOnly: true,
2009
+ })
2010
+ : entry.client.waitForDiagnostics(filePath, serverTimeout);
2011
+ return wait.catch(() => undefined);
2012
+ });
2013
+ // The push wait — same per-server budget composition as before #707;
2014
+ // only the awaiting changed (assigned so it can be raced below).
2015
+ let pushWaitSettled = false;
2016
+ const pushWait = hasTouchAuxiliaries
2017
+ ? (() => {
2018
+ // Primary waits: all non-auxiliary servers.
2019
+ const primaryWaits = perServerWaits.filter((_, i) => spawned[i].info.role !== "auxiliary");
2020
+ // Aux waits: auxiliary servers (advisory).
2021
+ const auxWaits = perServerWaits
2022
+ .map((p, i) => spawned[i].info.role === "auxiliary"
2023
+ ? { promise: p, serverId: spawned[i].info.id }
2024
+ : null)
2025
+ .filter((x) => x !== null);
2026
+ const auxGraceMs = readEnvAuxGraceMs() ?? 500;
2027
+ // After all primaries settle, give auxiliaries at most auxGraceMs.
2028
+ // Late aux results are dropped from the wait (advisory only — they
2029
+ // land in the client cache and surface on the next edit); aux servers
2030
+ // that did answer within the grace are included automatically since
2031
+ // their waitForDiagnostics already resolved. The cut-off server ids
2032
+ // are logged in the latency metadata (lsp_touch_file phase, field
2033
+ // `auxCutOffServerIds`).
2034
+ return Promise.all(primaryWaits).then(async () => {
2035
+ if (auxWaits.length === 0)
2036
+ return;
2037
+ const auxTimeout = new Promise((resolve) => {
2038
+ const t = setTimeout(() => resolve("timeout"), auxGraceMs);
2039
+ if (typeof t === "object" && "unref" in t)
2040
+ t.unref?.();
2041
+ });
2042
+ const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
2043
+ const outcome = await Promise.race([auxAll, auxTimeout]);
2044
+ if (outcome === "timeout") {
2045
+ // Record which auxiliaries did NOT finish in time.
2046
+ const unfinished = [];
2047
+ for (const a of auxWaits) {
2048
+ let done = false;
2049
+ // Check synchronously if already resolved by racing against
2050
+ // a resolved promise.
2051
+ await Promise.race([
2052
+ a.promise.then(() => {
2053
+ done = true;
2054
+ }),
2055
+ Promise.resolve(),
2056
+ ]);
2057
+ if (!done)
2058
+ unfinished.push(a.serverId);
2059
+ }
2060
+ if (unfinished.length > 0)
2061
+ auxCutOffServerIds = unfinished;
2062
+ }
1419
2063
  });
1420
- const auxAll = Promise.all(auxWaits.map((a) => a.promise)).then(() => "done");
1421
- const outcome = await Promise.race([auxAll, auxTimeout]);
1422
- if (outcome === "timeout") {
1423
- // Record which auxiliaries did NOT finish in time.
1424
- const unfinished = [];
1425
- for (const a of auxWaits) {
1426
- let done = false;
1427
- // Check synchronously if already resolved by racing against
1428
- // a resolved promise.
1429
- await Promise.race([
1430
- a.promise.then(() => {
1431
- done = true;
1432
- }),
1433
- Promise.resolve(),
1434
- ]);
1435
- if (!done)
1436
- unfinished.push(a.serverId);
2064
+ })()
2065
+ : Promise.all(perServerWaits).then(() => { });
2066
+ pushWait.then(() => {
2067
+ pushWaitSettled = true;
2068
+ });
2069
+ if (tsserverSyncEligible) {
2070
+ // #707 racing variant: rather than burning the full push-wait budget
2071
+ // on a silent-on-clean server (which by definition never answers on a
2072
+ // clean file), race the push wait against a grace-delayed sync
2073
+ // confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
2074
+ // gives a genuinely dirty file's push a head start: if diagnostics
2075
+ // arrive before the grace elapses, the sync request never goes out —
2076
+ // zero new latency or requests on the push-answers path.
2077
+ //
2078
+ // Race semantics:
2079
+ // - sync answers first → that's the confirmed result (clean OR
2080
+ // dirty — the sync commands return the file's real syntactic +
2081
+ // semantic state, so a dirty-file win is still correct and its
2082
+ // findings are surfaced, never discarded).
2083
+ // - push settles first → push wins; a still-in-flight sync outcome
2084
+ // is discarded (the racer checks `pushWaitSettled` after the
2085
+ // call returns and drops its own result).
2086
+ // - sync unavailable/fails → the racer parks on a never-resolving
2087
+ // promise so the race is decided by the push wait's own budget,
2088
+ // exactly today's behavior (the end-of-wait fallback below still
2089
+ // gets its shot on a timed-out empty result).
2090
+ // The racer never rejects (every failure path is caught), so the
2091
+ // losing promise can never surface as an unhandled rejection.
2092
+ const graceMs = readTsserverSyncGraceMs();
2093
+ const primaryClient = spawned[0].client;
2094
+ // Resolves with the sync commands' diagnostics when the confirm
2095
+ // succeeds; parks on a never-resolving promise on EVERY other path
2096
+ // (push already answered, sync unavailable/failed, push won while
2097
+ // in flight) so the race is then decided by the push wait's own
2098
+ // budget — exactly today's behavior.
2099
+ const syncRacer = (async () => {
2100
+ await new Promise((resolve) => {
2101
+ const timer = setTimeout(resolve, graceMs);
2102
+ timer.unref?.();
2103
+ });
2104
+ // Push already answered (settled, or published diagnostics that
2105
+ // its wait is about to settle on) — nothing to confirm, no sync
2106
+ // request goes out.
2107
+ if (pushWaitSettled ||
2108
+ primaryClient.getDiagnostics(filePath).length > 0) {
2109
+ return new Promise(() => { });
2110
+ }
2111
+ try {
2112
+ const result = await attemptTsserverSyncDiagnostics(filePath, this);
2113
+ if (result === undefined || pushWaitSettled) {
2114
+ // Sync unavailable/failed, or push won while the sync call
2115
+ // was in flight — drop the sync outcome and let the push
2116
+ // wait decide the race.
2117
+ return new Promise(() => { });
1437
2118
  }
1438
- if (unfinished.length > 0)
1439
- auxCutOffServerIds = unfinished;
2119
+ return result;
1440
2120
  }
2121
+ catch {
2122
+ return new Promise(() => { });
2123
+ }
2124
+ })();
2125
+ const raceOutcome = await Promise.race([
2126
+ pushWait.then(() => undefined),
2127
+ syncRacer,
2128
+ ]);
2129
+ if (raceOutcome !== undefined) {
2130
+ tsserverSyncConfirmed = raceOutcome;
2131
+ }
2132
+ }
2133
+ else {
2134
+ await pushWait;
2135
+ }
2136
+ const waitedMs = Date.now() - waitStartedAt;
2137
+ if (tsserverSyncConfirmed !== undefined) {
2138
+ // #707: the racing sync confirm won — a definitive answer well under
2139
+ // the push-wait budget. Not a timeout, not inconclusive.
2140
+ logLatency({
2141
+ type: "phase",
2142
+ phase: "lsp_tsserver_sync_confirm",
2143
+ filePath: normalizedPath,
2144
+ durationMs: waitedMs,
2145
+ metadata: {
2146
+ source,
2147
+ serverId: spawned[0]?.client.serverId,
2148
+ clientScope,
2149
+ diagnosticsMode,
2150
+ mode: "race",
2151
+ confirmedDiagnosticCount: tsserverSyncConfirmed.length,
2152
+ budgetMs: timeoutMs,
2153
+ savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
2154
+ },
1441
2155
  });
1442
- })()
1443
- : Promise.all(perServerWaits).then(() => { });
1444
- pushWait.then(() => {
1445
- pushWaitSettled = true;
1446
- });
1447
- if (tsserverSyncEligible) {
1448
- // #707 racing variant: rather than burning the full push-wait budget
1449
- // on a silent-on-clean server (which by definition never answers on a
1450
- // clean file), race the push wait against a grace-delayed sync
1451
- // confirm. The grace (default 300ms, PI_LENS_TSSERVER_SYNC_GRACE_MS)
1452
- // gives a genuinely dirty file's push a head start: if diagnostics
1453
- // arrive before the grace elapses, the sync request never goes out —
1454
- // zero new latency or requests on the push-answers path.
1455
- //
1456
- // Race semantics:
1457
- // - sync answers first → that's the confirmed result (clean OR
1458
- // dirty — the sync commands return the file's real syntactic +
1459
- // semantic state, so a dirty-file win is still correct and its
1460
- // findings are surfaced, never discarded).
1461
- // - push settles first → push wins; a still-in-flight sync outcome
1462
- // is discarded (the racer checks `pushWaitSettled` after the
1463
- // call returns and drops its own result).
1464
- // - sync unavailable/fails → the racer parks on a never-resolving
1465
- // promise so the race is decided by the push wait's own budget,
1466
- // exactly today's behavior (the end-of-wait fallback below still
1467
- // gets its shot on a timed-out empty result).
1468
- // The racer never rejects (every failure path is caught), so the
1469
- // losing promise can never surface as an unhandled rejection.
1470
- const graceMs = readTsserverSyncGraceMs();
1471
- const primaryClient = spawned[0].client;
1472
- // Resolves with the sync commands' diagnostics when the confirm
1473
- // succeeds; parks on a never-resolving promise on EVERY other path
1474
- // (push already answered, sync unavailable/failed, push won while
1475
- // in flight) so the race is then decided by the push wait's own
1476
- // budget — exactly today's behavior.
1477
- const syncRacer = (async () => {
1478
- await new Promise((resolve) => {
1479
- const timer = setTimeout(resolve, graceMs);
1480
- timer.unref?.();
1481
- });
1482
- // Push already answered (settled, or published diagnostics that
1483
- // its wait is about to settle on) — nothing to confirm, no sync
1484
- // request goes out.
1485
- if (pushWaitSettled ||
1486
- primaryClient.getDiagnostics(filePath).length > 0) {
1487
- return new Promise(() => { });
2156
+ }
2157
+ else if (waitedMs + 20 >= timeoutMs) {
2158
+ // Within ~20 ms of the configured budget we treat it as a timeout;
2159
+ // the LSP didn't beat the cap. Diagnostics that arrive late still
2160
+ // land in the client's cache and surface on the next edit.
2161
+ diagnosticsTimedOut = true;
2162
+ for (const entry of spawned) {
2163
+ incrementDegradationCount({
2164
+ kind: "lsp-diagnostics-timeout",
2165
+ // `info.id` is the authoritative server identity carried by
2166
+ // every spawned entry. The client test doubles (and some
2167
+ // lightweight clients) need not expose a serverId property;
2168
+ // ledger recording must never abort the touch or alter #570's
2169
+ // timeout-preserves-last-known-diagnostics semantics.
2170
+ subject: entry.info.id,
2171
+ reason: "diagnostics wait timed out",
2172
+ });
1488
2173
  }
2174
+ logLatency({
2175
+ type: "phase",
2176
+ phase: "lsp_diagnostics_timeout",
2177
+ filePath: normalizedPath,
2178
+ durationMs: waitedMs,
2179
+ metadata: {
2180
+ source,
2181
+ clientScope,
2182
+ diagnosticsMode,
2183
+ timeoutMs,
2184
+ },
2185
+ });
2186
+ }
2187
+ // #814: capability-aware AGGREGATE wait — generalize #799's
2188
+ // single-server (`clientScope === "primary" && spawned.length === 1`)
2189
+ // silent-clean confirm to multi-server `clientScope: "all"` touches
2190
+ // (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
2191
+ // `serverScope: "all"`). #799's gate never fires here (it's scoped to
2192
+ // the primary hot path), so a scope-"all" touch where every OTHER
2193
+ // spawned server already answered but one push-only `silentOnClean`
2194
+ // server (marksman on a clean markdown file) never publishes still
2195
+ // reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
2196
+ // though the "silence" is exactly what that server's own known
2197
+ // clean-behavior predicts — not an unresolved question.
2198
+ //
2199
+ // A spawned server counts as "still outstanding" when nothing landed
2200
+ // in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
2201
+ // is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
2202
+ // that file's entry as part of the didOpen/didChange this touch just
2203
+ // sent, so a present entry can only be a FRESH answer (found or a real
2204
+ // confirmed-empty push/pull), never a stale one bleeding through from
2205
+ // an earlier touch. This is the same "did anything publish for this
2206
+ // file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
2207
+ // already trusts (#240 doctrine) — reused here, not reinvented.
2208
+ //
2209
+ // The touch stays inconclusive unless EVERY still-outstanding server
2210
+ // is classified `tier3-silent` (push-only + `silentOnClean`, the same
2211
+ // `classifyServerWaitTier` rule the single-server gate below and the
2212
+ // cascade lane use) — one ordinary push-only straggler (still
2213
+ // genuinely analyzing) or a pull-capable server that never answered
2214
+ // keeps the touch cautious, matching #799's "err toward caution"
2215
+ // posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
2216
+ // plus the per-server re-check below are the same "the notify write
2217
+ // must have actually landed" conservatism #799 established — a
2218
+ // server's silence is only evidence of "clean" when we know it saw
2219
+ // the new content.
2220
+ if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
1489
2221
  try {
1490
- const result = await attemptTsserverSyncDiagnostics(filePath, this);
1491
- if (result === undefined || pushWaitSettled) {
1492
- // Sync unavailable/failed, or push won while the sync call
1493
- // was in flight — drop the sync outcome and let the push
1494
- // wait decide the race.
1495
- return new Promise(() => { });
2222
+ const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
2223
+ !entry.client.getAllDiagnostics().has(normalizedPath));
2224
+ if (outstanding.length > 0) {
2225
+ const snapshots = await this.getCapabilitySnapshots(filePath);
2226
+ const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
2227
+ if (allSilent) {
2228
+ // #1277: the static tier3-silent classification alone can't
2229
+ // tell a genuinely clean server from one that accepted the
2230
+ // notify write and then wedged — both look identical to
2231
+ // `classifyServerWaitTier`, which only reads the capability
2232
+ // snapshot, never the server's actual current responsiveness.
2233
+ // Require every still-outstanding server to answer a cheap
2234
+ // bounded round-trip before trusting the silence as clean;
2235
+ // any server that doesn't respond in time keeps the touch
2236
+ // inconclusive rather than confirming a possibly-dead server
2237
+ // clean.
2238
+ const liveness = await Promise.all(outstanding.map((entry) => (entry.client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false)));
2239
+ if (liveness.every(Boolean)) {
2240
+ diagnosticsTimedOut = false;
2241
+ logLatency({
2242
+ type: "phase",
2243
+ phase: "lsp_silent_clean_confirm",
2244
+ filePath: normalizedPath,
2245
+ durationMs: Date.now() - startedAt,
2246
+ metadata: {
2247
+ source,
2248
+ clientScope,
2249
+ diagnosticsMode,
2250
+ aggregate: true,
2251
+ serverIds: outstanding.map((entry) => entry.client.serverId),
2252
+ },
2253
+ });
2254
+ }
2255
+ }
1496
2256
  }
1497
- return result;
1498
2257
  }
1499
2258
  catch {
1500
- return new Promise(() => { });
2259
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's
2260
+ // inconclusive behavior, exactly like the single-server gate.
1501
2261
  }
1502
- })();
1503
- const raceOutcome = await Promise.race([
1504
- pushWait.then(() => undefined),
1505
- syncRacer,
1506
- ]);
1507
- if (raceOutcome !== undefined) {
1508
- tsserverSyncConfirmed = raceOutcome;
1509
2262
  }
1510
2263
  }
1511
- else {
1512
- await pushWait;
1513
- }
1514
- const waitedMs = Date.now() - waitStartedAt;
1515
- if (tsserverSyncConfirmed !== undefined) {
1516
- // #707: the racing sync confirm won — a definitive answer well under
1517
- // the push-wait budget. Not a timeout, not inconclusive.
1518
- logLatency({
1519
- type: "phase",
1520
- phase: "lsp_tsserver_sync_confirm",
1521
- filePath: normalizedPath,
1522
- durationMs: waitedMs,
1523
- metadata: {
1524
- source,
1525
- clientScope,
1526
- diagnosticsMode,
1527
- mode: "race",
1528
- confirmedDiagnosticCount: tsserverSyncConfirmed.length,
1529
- budgetMs: timeoutMs,
1530
- savedVsBudgetMs: Math.max(0, timeoutMs - waitedMs),
1531
- },
1532
- });
1533
- }
1534
- else if (waitedMs + 20 >= timeoutMs) {
1535
- // Within ~20 ms of the configured budget we treat it as a timeout;
1536
- // the LSP didn't beat the cap. Diagnostics that arrive late still
1537
- // land in the client's cache and surface on the next edit.
1538
- diagnosticsTimedOut = true;
1539
- logLatency({
1540
- type: "phase",
1541
- phase: "lsp_diagnostics_timeout",
1542
- filePath: normalizedPath,
1543
- durationMs: waitedMs,
1544
- metadata: {
1545
- source,
1546
- clientScope,
1547
- diagnosticsMode,
1548
- timeoutMs,
1549
- },
1550
- });
2264
+ // #707: when the racing sync confirm won the wait, its answer IS the
2265
+ // collected result — the file's real syntactic + semantic state straight
2266
+ // from tsserver (clean = [], dirty = real findings that a silentOnClean
2267
+ // server had computed but never published). Otherwise merge the push
2268
+ // diagnostics from the client cache as always.
2269
+ let collected = options.collectDiagnostics
2270
+ ? tsserverSyncConfirmed !== undefined
2271
+ ? mergeLspDiagnostics(tsserverSyncConfirmed)
2272
+ : mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
2273
+ : undefined;
2274
+ // #1095 (P3-b): whether `collected` came from a tsserver sync confirm
2275
+ // (`tsserverSyncRequest`) rather than the publish cache. A sync-confirmed
2276
+ // result is authoritative for the CURRENT buffer but is NOT tied to the
2277
+ // publish-path content binding (`diagnosticBindings`, set on publish), so
2278
+ // composing that binding here could let a STALE publish fingerprint demote a
2279
+ // genuinely-fresh sync answer to `false`. The end-of-wait fallback below can
2280
+ // also set this. When true, the binding is surfaced as "unknown" (honest,
2281
+ // non-demoting) rather than the stale publish binding.
2282
+ let syncConfirmed = tsserverSyncConfirmed !== undefined;
2283
+ // #707 end-of-wait fallback: when the racing confirm did NOT decide the
2284
+ // wait (sync unavailable/failed mid-race, or push resolved as a bare
2285
+ // timeout) and the wait timed out with an empty result on an eligible
2286
+ // touch, give the sync clean-confirm one last shot before reporting
2287
+ // inconclusive. `tsserverSyncEligible` already encodes every gate (notify
2288
+ // succeeded, collecting, primary scope, tier3-silent classic typescript —
2289
+ // native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
2290
+ // answers (even with an empty body, which is a confirmed clean), we use
2291
+ // those diagnostics as the confirmed result and clear the
2292
+ // `diagnosticsTimedOut` flag so the touch is no longer treated as
2293
+ // inconclusive. Sync diagnostics on a dirty file are surfaced, not
2294
+ // discarded. If the sync call fails or is unavailable, we fall through to
2295
+ // today's behavior: `inconclusive` = true, `collected` unchanged. This
2296
+ // turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
2297
+ // even when the race path couldn't answer.
2298
+ if (diagnosticsTimedOut &&
2299
+ tsserverSyncEligible &&
2300
+ collected !== undefined &&
2301
+ collected.length === 0) {
2302
+ try {
2303
+ const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
2304
+ if (syncResult !== undefined) {
2305
+ // Sync answered — confirmed result (clean or with diagnostics).
2306
+ // Clear the timed-out flag so the touch is no longer inconclusive.
2307
+ diagnosticsTimedOut = false;
2308
+ syncConfirmed = true;
2309
+ collected = syncResult.length > 0
2310
+ ? mergeLspDiagnostics(syncResult)
2311
+ : [];
2312
+ logLatency({
2313
+ type: "phase",
2314
+ phase: "lsp_tsserver_sync_confirm",
2315
+ filePath: normalizedPath,
2316
+ durationMs: Date.now() - startedAt,
2317
+ metadata: {
2318
+ source,
2319
+ clientScope,
2320
+ diagnosticsMode,
2321
+ mode: "end-of-wait",
2322
+ confirmedDiagnosticCount: collected.length,
2323
+ },
2324
+ });
2325
+ }
2326
+ }
2327
+ catch {
2328
+ // Any failure here falls through to today's inconclusive behavior.
2329
+ }
1551
2330
  }
1552
- // #814: capability-aware AGGREGATE wait — generalize #799's
1553
- // single-server (`clientScope === "primary" && spawned.length === 1`)
1554
- // silent-clean confirm to multi-server `clientScope: "all"` touches
1555
- // (`lens_diagnostics` mode=full per-file sweep, `lsp_diagnostics`
1556
- // `serverScope: "all"`). #799's gate never fires here (it's scoped to
1557
- // the primary hot path), so a scope-"all" touch where every OTHER
1558
- // spawned server already answered but one push-only `silentOnClean`
1559
- // server (marksman on a clean markdown file) never publishes still
1560
- // reported the WHOLE touch `inconclusive`/`diagnosticsTimedOut` even
1561
- // though the "silence" is exactly what that server's own known
1562
- // clean-behavior predicts — not an unresolved question.
1563
- //
1564
- // A spawned server counts as "still outstanding" when nothing landed
1565
- // in its per-file diagnostics cache for THIS touch — `getAllDiagnostics`
1566
- // is keyed by file and `clearDiagnosticsForPath` (`client.ts`) deletes
1567
- // that file's entry as part of the didOpen/didChange this touch just
1568
- // sent, so a present entry can only be a FRESH answer (found or a real
1569
- // confirmed-empty push/pull), never a stale one bleeding through from
1570
- // an earlier touch. This is the same "did anything publish for this
1571
- // file since we asked" signal `cascade-tier.ts`'s Tier-3 reconcile
1572
- // already trusts (#240 doctrine) — reused here, not reinvented.
2331
+ // #799: generalize the "silent-clean push-only" confirm beyond
2332
+ // typescript's active sync-command race above. That mechanism is
2333
+ // TS-specific (`attemptTsserverSyncDiagnostics` races an actual
2334
+ // `typescript.tsserverRequest` — no equivalent protocol exists for
2335
+ // e.g. marksman) and is now scoped to `serverId === "typescript"`
2336
+ // only (see the gate above), so it never fires for another
2337
+ // `silentOnClean` server. This is the generic fallback for those
2338
+ // servers: if the wait ran its full budget with a successful notify
2339
+ // write and nothing published, and the live capability snapshot
2340
+ // classifies this touch as tier3-silent (push-only + `silentOnClean`,
2341
+ // #458's `classifyCascadeWaitTier`), that is not "still working" —
2342
+ // `silentOnClean` means by definition this server publishes NOTHING
2343
+ // on a clean transition, so a timeout under those conditions IS the
2344
+ // confirmed-clean answer. `!tsserverSyncEligible` keeps this from
2345
+ // ever double-deciding typescript's own touches — when the sync race
2346
+ // was attempted and failed/was unavailable, typescript's existing
2347
+ // "falls through to inconclusive, unchanged" contract (#707) is
2348
+ // preserved exactly; typescript touches that never enter that gate
2349
+ // (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
2350
+ // warm-up call) are still eligible here as a genuine bonus fix. Scoped
2351
+ // to `clientScope === "primary"`/`spawned.length === 1` exactly like
2352
+ // the sync-eligible gate above (and like `ensureWarmForSweep`'s own
2353
+ // `clientScope: "primary"` warm-up touch) so a multi-server
2354
+ // with-auxiliary/all touch — where a partial timeout must stay
2355
+ // cautious per the doc below — is never affected.
1573
2356
  //
1574
- // The touch stays inconclusive unless EVERY still-outstanding server
1575
- // is classified `tier3-silent` (push-only + `silentOnClean`, the same
1576
- // `classifyServerWaitTier` rule the single-server gate below and the
1577
- // cascade lane use) — one ordinary push-only straggler (still
1578
- // genuinely analyzing) or a pull-capable server that never answered
1579
- // keeps the touch cautious, matching #799's "err toward caution"
1580
- // posture for partial timeouts. `!notifyWriteTimedOut` (touch-wide)
1581
- // plus the per-server re-check below are the same "the notify write
1582
- // must have actually landed" conservatism #799 established — a
1583
- // server's silence is only evidence of "clean" when we know it saw
1584
- // the new content.
1585
- if (diagnosticsTimedOut && !notifyWriteTimedOut && clientScope === "all") {
2357
+ // #814: this is now a SPECIAL CASE of the more general per-server gate
2358
+ // above (the `clientScope === "all"` block right before the diagnostics-
2359
+ // wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
2360
+ // server is tier3-silent" collapses to exactly this single-server check.
2361
+ // Left in place unchanged (rather than deleted/rewritten to delegate to
2362
+ // the new gate) per #814's scope: a future cleanup could fold this block
2363
+ // into the general one once both have soaked, but that's a separate,
2364
+ // lower-risk follow-up, not bundled into this fix.
2365
+ if (diagnosticsTimedOut &&
2366
+ !notifyWriteTimedOut &&
2367
+ !tsserverSyncEligible &&
2368
+ clientScope === "primary" &&
2369
+ spawned.length === 1 &&
2370
+ getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1586
2371
  try {
1587
- const outstanding = spawned.filter((entry) => !notifyWriteTimedOutServerIds.includes(entry.info.id) &&
1588
- !entry.client.getAllDiagnostics().has(normalizedPath));
1589
- if (outstanding.length > 0) {
1590
- const snapshots = await this.getCapabilitySnapshots(filePath);
1591
- const allSilent = outstanding.every((entry) => classifyServerWaitTier(entry.client.serverId, snapshots.find((s) => s.serverId === entry.client.serverId)) === "tier3-silent");
1592
- if (allSilent) {
2372
+ const snapshots = await this.getCapabilitySnapshots(filePath);
2373
+ if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
2374
+ // #1277: same liveness precondition as the aggregate gate above —
2375
+ // `tier3-silent` is a static capability classification and can't
2376
+ // distinguish "silent because clean" from "silent because wedged".
2377
+ // A cheap bounded round-trip proves the server is still actually
2378
+ // responding before its silence is trusted as a clean confirm; a
2379
+ // server that doesn't answer in time leaves the touch inconclusive.
2380
+ const alive = await (spawned[0].client.pingLiveness?.() ?? Promise.resolve(true)).catch(() => false);
2381
+ if (alive) {
1593
2382
  diagnosticsTimedOut = false;
2383
+ if (collected !== undefined)
2384
+ collected = mergeLspDiagnostics([]);
1594
2385
  logLatency({
1595
2386
  type: "phase",
1596
2387
  phase: "lsp_silent_clean_confirm",
@@ -1600,238 +2391,155 @@ export class LSPService {
1600
2391
  source,
1601
2392
  clientScope,
1602
2393
  diagnosticsMode,
1603
- aggregate: true,
1604
- serverIds: outstanding.map((entry) => entry.client.serverId),
2394
+ serverId: spawned[0].client.serverId,
1605
2395
  },
1606
2396
  });
1607
2397
  }
1608
2398
  }
1609
2399
  }
1610
2400
  catch {
1611
- // Fail-safe: leave `diagnosticsTimedOut` as-is — today's
1612
- // inconclusive behavior, exactly like the single-server gate.
2401
+ // Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
2402
+ // behavior.
1613
2403
  }
1614
2404
  }
1615
- }
1616
- // #707: when the racing sync confirm won the wait, its answer IS the
1617
- // collected result — the file's real syntactic + semantic state straight
1618
- // from tsserver (clean = [], dirty = real findings that a silentOnClean
1619
- // server had computed but never published). Otherwise merge the push
1620
- // diagnostics from the client cache as always.
1621
- let collected = options.collectDiagnostics
1622
- ? tsserverSyncConfirmed !== undefined
1623
- ? mergeLspDiagnostics(tsserverSyncConfirmed)
1624
- : mergeLspDiagnostics(spawned.flatMap((entry) => entry.client.getDiagnostics(filePath)))
1625
- : undefined;
1626
- // #707 end-of-wait fallback: when the racing confirm did NOT decide the
1627
- // wait (sync unavailable/failed mid-race, or push resolved as a bare
1628
- // timeout) and the wait timed out with an empty result on an eligible
1629
- // touch, give the sync clean-confirm one last shot before reporting
1630
- // inconclusive. `tsserverSyncEligible` already encodes every gate (notify
1631
- // succeeded, collecting, primary scope, tier3-silent classic typescript —
1632
- // native-ts7 excluded by `classifyCascadeWaitTier`). If the sync call
1633
- // answers (even with an empty body, which is a confirmed clean), we use
1634
- // those diagnostics as the confirmed result and clear the
1635
- // `diagnosticsTimedOut` flag so the touch is no longer treated as
1636
- // inconclusive. Sync diagnostics on a dirty file are surfaced, not
1637
- // discarded. If the sync call fails or is unavailable, we fall through to
1638
- // today's behavior: `inconclusive` = true, `collected` unchanged. This
1639
- // turns "unconfirmed after ~1000ms" into "confirmed at ~wait+sync-RTT"
1640
- // even when the race path couldn't answer.
1641
- if (diagnosticsTimedOut &&
1642
- tsserverSyncEligible &&
1643
- collected !== undefined &&
1644
- collected.length === 0) {
1645
- try {
1646
- const syncResult = await attemptTsserverSyncDiagnostics(filePath, this);
1647
- if (syncResult !== undefined) {
1648
- // Sync answered — confirmed result (clean or with diagnostics).
1649
- // Clear the timed-out flag so the touch is no longer inconclusive.
1650
- diagnosticsTimedOut = false;
1651
- collected = syncResult.length > 0
1652
- ? mergeLspDiagnostics(syncResult)
1653
- : [];
1654
- logLatency({
1655
- type: "phase",
1656
- phase: "lsp_tsserver_sync_confirm",
1657
- filePath: normalizedPath,
1658
- durationMs: Date.now() - startedAt,
1659
- metadata: {
1660
- source,
1661
- clientScope,
1662
- diagnosticsMode,
1663
- mode: "end-of-wait",
1664
- confirmedDiagnosticCount: collected.length,
1665
- },
1666
- });
2405
+ // A touch is inconclusive when EITHER the notify write or the
2406
+ // diagnostics wait hit their deadline for ANY of the spawned servers
2407
+ // (these flags are touch-wide, covering the whole `Promise.all` over
2408
+ // `spawned` — see the field doc on the return type). We deliberately
2409
+ // err toward caution here: `collected` merges diagnostics across every
2410
+ // spawned server, so even a partial timeout (e.g. a slow auxiliary
2411
+ // while the primary answered) means the merged result may be missing
2412
+ // findings that just hadn't arrived yet — it must not be trusted as a
2413
+ // confirmed answer.
2414
+ const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
2415
+ // #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
2416
+ // "actually warm" signal `ensureWarmForSweep` waits for — mark every
2417
+ // spawned server so a later sweep in this session sees the check as a
2418
+ // no-op instead of paying the warm-up round trip again.
2419
+ //
2420
+ // #743: the diagnostics wait is a blanket (touch-wide) gate, but the
2421
+ // notify-write timeout is now PER-SERVER — a healthy server whose sibling's
2422
+ // write stalled must still be eligible, so only servers whose OWN write
2423
+ // timed out are skipped here (rather than gating the whole loop on the
2424
+ // file-level `inconclusive`).
2425
+ const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
2426
+ if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
2427
+ for (const entry of spawned) {
2428
+ if (notifyTimedOutServerIds.has(entry.info.id))
2429
+ continue;
2430
+ const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
2431
+ if (key)
2432
+ this.markDemonstratedReadyKey(key);
1667
2433
  }
1668
2434
  }
1669
- catch {
1670
- // Any failure here falls through to today's inconclusive behavior.
1671
- }
1672
- }
1673
- // #799: generalize the "silent-clean push-only" confirm beyond
1674
- // typescript's active sync-command race above. That mechanism is
1675
- // TS-specific (`attemptTsserverSyncDiagnostics` races an actual
1676
- // `typescript.tsserverRequest` — no equivalent protocol exists for
1677
- // e.g. marksman) and is now scoped to `serverId === "typescript"`
1678
- // only (see the gate above), so it never fires for another
1679
- // `silentOnClean` server. This is the generic fallback for those
1680
- // servers: if the wait ran its full budget with a successful notify
1681
- // write and nothing published, and the live capability snapshot
1682
- // classifies this touch as tier3-silent (push-only + `silentOnClean`,
1683
- // #458's `classifyCascadeWaitTier`), that is not "still working" —
1684
- // `silentOnClean` means by definition this server publishes NOTHING
1685
- // on a clean transition, so a timeout under those conditions IS the
1686
- // confirmed-clean answer. `!tsserverSyncEligible` keeps this from
1687
- // ever double-deciding typescript's own touches — when the sync race
1688
- // was attempted and failed/was unavailable, typescript's existing
1689
- // "falls through to inconclusive, unchanged" contract (#707) is
1690
- // preserved exactly; typescript touches that never enter that gate
1691
- // (e.g. `collectDiagnostics: false`, like `ensureWarmForSweep`'s own
1692
- // warm-up call) are still eligible here as a genuine bonus fix. Scoped
1693
- // to `clientScope === "primary"`/`spawned.length === 1` exactly like
1694
- // the sync-eligible gate above (and like `ensureWarmForSweep`'s own
1695
- // `clientScope: "primary"` warm-up touch) so a multi-server
1696
- // with-auxiliary/all touch — where a partial timeout must stay
1697
- // cautious per the doc below — is never affected.
1698
- //
1699
- // #814: this is now a SPECIAL CASE of the more general per-server gate
1700
- // above (the `clientScope === "all"` block right before the diagnostics-
1701
- // wait `if` closes) — for `spawned.length === 1`, "every still-outstanding
1702
- // server is tier3-silent" collapses to exactly this single-server check.
1703
- // Left in place unchanged (rather than deleted/rewritten to delegate to
1704
- // the new gate) per #814's scope: a future cleanup could fold this block
1705
- // into the general one once both have soaked, but that's a separate,
1706
- // lower-risk follow-up, not bundled into this fix.
1707
- if (diagnosticsTimedOut &&
1708
- !notifyWriteTimedOut &&
1709
- !tsserverSyncEligible &&
1710
- clientScope === "primary" &&
1711
- spawned.length === 1 &&
1712
- getStrategy(spawned[0].client.serverId).silentOnClean === true) {
1713
- try {
1714
- const snapshots = await this.getCapabilitySnapshots(filePath);
1715
- if (classifyCascadeWaitTier(this, filePath, snapshots) === "tier3-silent") {
1716
- diagnosticsTimedOut = false;
1717
- if (collected !== undefined)
1718
- collected = mergeLspDiagnostics([]);
1719
- logLatency({
1720
- type: "phase",
1721
- phase: "lsp_silent_clean_confirm",
1722
- filePath: normalizedPath,
1723
- durationMs: Date.now() - startedAt,
1724
- metadata: {
1725
- source,
1726
- clientScope,
1727
- diagnosticsMode,
1728
- serverId: spawned[0].client.serverId,
1729
- },
1730
- });
2435
+ // Prime the last-known cache WITH the hash of the content we just synced,
2436
+ // so a hot-path consumer (actionable-warnings at turn_end) can verify the
2437
+ // cached diagnostics are for the current bytes before reusing them instead
2438
+ // of paying for a second open+wait. Only when we actually collected — a
2439
+ // non-collecting touch (didChange-only) leaves the prior entry intact.
2440
+ // Skip this entirely when the touch was inconclusive: an unconfirmed
2441
+ // empty `collected` must never erase a previously-confirmed non-empty
2442
+ // record (that's the #570 bug — a timeout silently reporting as clean
2443
+ // and wiping out known-good diagnostic state).
2444
+ if (collected !== undefined && !inconclusive) {
2445
+ const normalizedKey = normalizeMapKey(filePath);
2446
+ if (collected.length > 0) {
2447
+ this.lastKnownDiagnostics.set(normalizedKey, collected);
2448
+ this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
2449
+ }
2450
+ else {
2451
+ this.lastKnownDiagnostics.delete(normalizedKey);
2452
+ this.lastKnownContentHash.delete(normalizedKey);
1731
2453
  }
1732
2454
  }
1733
- catch {
1734
- // Fail-safe: leave `diagnosticsTimedOut` as-is — today's inconclusive
1735
- // behavior.
1736
- }
1737
- }
1738
- // A touch is inconclusive when EITHER the notify write or the
1739
- // diagnostics wait hit their deadline for ANY of the spawned servers
1740
- // (these flags are touch-wide, covering the whole `Promise.all` over
1741
- // `spawned` — see the field doc on the return type). We deliberately
1742
- // err toward caution here: `collected` merges diagnostics across every
1743
- // spawned server, so even a partial timeout (e.g. a slow auxiliary
1744
- // while the primary answered) means the merged result may be missing
1745
- // findings that just hadn't arrived yet — it must not be trusted as a
1746
- // confirmed answer.
1747
- const inconclusive = notifyWriteTimedOut || diagnosticsTimedOut;
1748
- // #667: a confirmed (non-inconclusive) diagnostics-mode touch is the
1749
- // "actually warm" signal `ensureWarmForSweep` waits for — mark every
1750
- // spawned server so a later sweep in this session sees the check as a
1751
- // no-op instead of paying the warm-up round trip again.
1752
- //
1753
- // #743: the diagnostics wait is a blanket (touch-wide) gate, but the
1754
- // notify-write timeout is now PER-SERVER — a healthy server whose sibling's
1755
- // write stalled must still be eligible, so only servers whose OWN write
1756
- // timed out are skipped here (rather than gating the whole loop on the
1757
- // file-level `inconclusive`).
1758
- const notifyTimedOutServerIds = new Set(notifyWriteTimedOutServerIds);
1759
- if (diagnosticsMode !== "none" && !diagnosticsTimedOut) {
1760
- for (const entry of spawned) {
1761
- if (notifyTimedOutServerIds.has(entry.info.id))
1762
- continue;
1763
- const key = await this.demonstratedReadyKeyFor(entry.info, filePath);
1764
- if (key)
1765
- this.markDemonstratedReadyKey(key);
1766
- }
1767
- }
1768
- // Prime the last-known cache WITH the hash of the content we just synced,
1769
- // so a hot-path consumer (actionable-warnings at turn_end) can verify the
1770
- // cached diagnostics are for the current bytes before reusing them instead
1771
- // of paying for a second open+wait. Only when we actually collected — a
1772
- // non-collecting touch (didChange-only) leaves the prior entry intact.
1773
- // Skip this entirely when the touch was inconclusive: an unconfirmed
1774
- // empty `collected` must never erase a previously-confirmed non-empty
1775
- // record (that's the #570 bug — a timeout silently reporting as clean
1776
- // and wiping out known-good diagnostic state).
1777
- if (collected !== undefined && !inconclusive) {
1778
- const normalizedKey = normalizeMapKey(filePath);
1779
- if (collected.length > 0) {
1780
- this.lastKnownDiagnostics.set(normalizedKey, collected);
1781
- this.lastKnownContentHash.set(normalizedKey, this.hashContent(content));
2455
+ // #1179 (shape-5 structural fix): build the result WRAPPER. The two flags
2456
+ // that used to ride the returned array as NON-enumerable side-channels
2457
+ // (`Object.defineProperty(collected, ...)`, dropped by any `[...]`/`.filter`/
2458
+ // `JSON` copy — the #1094/#1096 loss class) are now EXPLICIT ENUMERABLE
2459
+ // fields on this wrapper. `.diags` holds the array a copy operates on, so the
2460
+ // flags survive by construction. Field presence mirrors the old attachment
2461
+ // conditions EXACTLY: `inconclusive` only for a confirmed-inconclusive
2462
+ // collect, `binding` only for a collecting touch — a non-collecting touch
2463
+ // keeps resolving `{ diags: [] }`, no flags.
2464
+ const result = { diags: collected ?? [] };
2465
+ if (collected !== undefined && inconclusive) {
2466
+ result.inconclusive = true;
1782
2467
  }
1783
- else {
1784
- this.lastKnownDiagnostics.delete(normalizedKey);
1785
- this.lastKnownContentHash.delete(normalizedKey);
2468
+ else if (collected !== undefined) {
2469
+ // Preserve the lower-level affirmative result across consumers. In
2470
+ // particular, the silent-clean gates above clear diagnosticsTimedOut only
2471
+ // after a successful notify and capability-confirmed wait; reclassifying
2472
+ // that empty array later would discard the evidence that made it clean.
2473
+ result.confirmation = "confirmed";
1786
2474
  }
1787
- }
1788
- if (collected !== undefined && inconclusive) {
1789
- // Non-enumerable so JSON.stringify / spread / logging of the
1790
- // diagnostics array is unaffected — this is a query-only bonus
1791
- // field, not a shape change existing array consumers need to know
1792
- // about.
1793
- Object.defineProperty(collected, "inconclusive", {
1794
- value: true,
1795
- enumerable: false,
1796
- configurable: true,
2475
+ // #1095: attach the merged content binding so a consumer can ask whether
2476
+ // these diagnostics were computed against current disk. Composed across every
2477
+ // spawned client so a single client whose view diverged from disk marks the
2478
+ // whole merged result mismatched. Disk verify is lazy + memoized per
2479
+ // (file, mtime).
2480
+ let binding;
2481
+ if (collected !== undefined) {
2482
+ binding = syncConfirmed
2483
+ ? // #1095 (P3-b): a tsserver sync-confirmed result is authoritative for
2484
+ // the current buffer but not tied to the publish-path fingerprint —
2485
+ // surface "unknown" so a stale publish binding can't demote it.
2486
+ { boundToCurrentDisk: "unknown" }
2487
+ : this.mergeBinding(filePath,
2488
+ // Optional-chain so a client without the getter (test doubles, a
2489
+ // partially-mocked client) yields "unknown" rather than throwing —
2490
+ // unknown preserves pre-#1095 behavior for that contributor.
2491
+ spawned.map((entry) => entry.client.getDiagnosticBinding?.(filePath)));
2492
+ result.binding = binding;
2493
+ }
2494
+ // The recent-touches entries are recorded per server inside the notify-write
2495
+ // loop above, at the moment each server's own write lands (#1253) — a server
2496
+ // whose write timed out or rejected deliberately gets none, so the next touch
2497
+ // re-pushes it rather than debouncing a failure into a later touch that looks
2498
+ // fully delivered. Nothing to record here: a skipped server keeps its original
2499
+ // entry (and timestamp) so its window still expires naturally instead of being
2500
+ // extended by every reuse.
2501
+ logLatency({
2502
+ type: "phase",
2503
+ phase: "lsp_touch_file",
2504
+ filePath: normalizedPath,
2505
+ durationMs: Date.now() - startedAt,
2506
+ metadata: {
2507
+ serverCountReady: spawned.length,
2508
+ clientScope,
2509
+ diagnosticsMode,
2510
+ source,
2511
+ failureKind: "success",
2512
+ collectedDiagnostics: collected?.length,
2513
+ // #1095: observability so an unbinding server is diagnosable —
2514
+ // "bound" (matches disk) / "mismatch" (diverged) / "unknown"
2515
+ // (version-less server or disk unreadable). Absent for non-collecting
2516
+ // touches (no merged result to bind).
2517
+ ...(binding !== undefined && {
2518
+ bindingState: bindingStateLabel(binding.boundToCurrentDisk),
2519
+ }),
2520
+ notifySkipped,
2521
+ notifyWriteTimedOut,
2522
+ // #743: per-server detail — which servers' writes actually timed out.
2523
+ // Absent when none did. `notifyWriteTimedOut` is the file-level "at
2524
+ // least one" summary.
2525
+ ...(notifyWriteTimedOutServerIds.length > 0 && {
2526
+ notifyWriteTimedOutServerIds,
2527
+ }),
2528
+ diagnosticsTimedOut,
2529
+ inconclusive,
2530
+ // R8 (#714): server ids of auxiliaries whose push wait was cut off by
2531
+ // the aux grace window (primary settled clean + aux timed out in grace).
2532
+ // Absent when no aux was cut off. These servers' diagnostics are
2533
+ // advisory-only and will surface on the next edit from their cache.
2534
+ ...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
2535
+ },
1797
2536
  });
2537
+ return result;
1798
2538
  }
1799
- // Only refresh the recent-touches entry when we actually pushed. Skipping
1800
- // here keeps the original push timestamp intact so the debounce window
1801
- // expires naturally instead of being extended by every reuse.
1802
- if (!notifySkipped) {
1803
- this.markTouched(filePath, content, clientScope);
2539
+ finally {
2540
+ for (const key of leaseKeys)
2541
+ this.releaseClientLease(key);
1804
2542
  }
1805
- logLatency({
1806
- type: "phase",
1807
- phase: "lsp_touch_file",
1808
- filePath: normalizedPath,
1809
- durationMs: Date.now() - startedAt,
1810
- metadata: {
1811
- serverCountReady: spawned.length,
1812
- clientScope,
1813
- diagnosticsMode,
1814
- source,
1815
- failureKind: "success",
1816
- collectedDiagnostics: collected?.length,
1817
- notifySkipped,
1818
- notifyWriteTimedOut,
1819
- // #743: per-server detail — which servers' writes actually timed out.
1820
- // Absent when none did. `notifyWriteTimedOut` is the file-level "at
1821
- // least one" summary.
1822
- ...(notifyWriteTimedOutServerIds.length > 0 && {
1823
- notifyWriteTimedOutServerIds,
1824
- }),
1825
- diagnosticsTimedOut,
1826
- inconclusive,
1827
- // R8 (#714): server ids of auxiliaries whose push wait was cut off by
1828
- // the aux grace window (primary settled clean + aux timed out in grace).
1829
- // Absent when no aux was cut off. These servers' diagnostics are
1830
- // advisory-only and will surface on the next edit from their cache.
1831
- ...(auxCutOffServerIds !== undefined && { auxCutOffServerIds }),
1832
- },
1833
- });
1834
- return collected ?? [];
1835
2543
  }
1836
2544
  /**
1837
2545
  * Get diagnostics for a file
@@ -2036,6 +2744,40 @@ export class LSPService {
2036
2744
  hashContent(content) {
2037
2745
  return createHash("sha256").update(content).digest("hex");
2038
2746
  }
2747
+ /**
2748
+ * #1095: compose the content `binding` for a merged diagnostics result across
2749
+ * the clients that contributed to it (primary + any auxiliaries). Verifies
2750
+ * each contributor's stored binding against current disk lazily (memoized per
2751
+ * file+mtime by {@link diskBindingCache}) and composes per
2752
+ * {@link composeBoundToCurrentDisk}: ANY contributor mismatches disk → false;
2753
+ * else all "unknown" (or no contributors) → "unknown"; else true. The
2754
+ * surfaced version/contentHash come from the first contributor that carries
2755
+ * them — informational only; the load-bearing field is `boundToCurrentDisk`.
2756
+ * #1104: `version` and `contentHash` are tracked INDEPENDENTLY (not gated on
2757
+ * the same contributor carrying both) — a pull-sourced binding deliberately
2758
+ * has no `version` (the pull protocol has no version-echo concept) but does
2759
+ * carry a real `contentHash`; gating the latter on the former would silently
2760
+ * drop it here even though `boundToCurrentDisk` above already used it.
2761
+ */
2762
+ mergeBinding(filePath, stored) {
2763
+ const verdicts = [];
2764
+ let version;
2765
+ let contentHash;
2766
+ for (const entry of stored) {
2767
+ verdicts.push(this.diskBindingCache.boundToCurrentDisk(filePath, entry ?? {}));
2768
+ if (version === undefined && entry?.version !== undefined) {
2769
+ version = entry.version;
2770
+ }
2771
+ if (contentHash === undefined && entry?.contentHash !== undefined) {
2772
+ contentHash = entry.contentHash;
2773
+ }
2774
+ }
2775
+ return {
2776
+ version,
2777
+ contentHash,
2778
+ boundToCurrentDisk: composeBoundToCurrentDisk(verdicts),
2779
+ };
2780
+ }
2039
2781
  /**
2040
2782
  * Navigation: go to definition
2041
2783
  */
@@ -2134,18 +2876,18 @@ export class LSPService {
2134
2876
  * advertisement in the client). If filePath is given, target that file's
2135
2877
  * server; otherwise the first active client.
2136
2878
  */
2137
- async executeCommand(filePath, command, args) {
2879
+ async executeCommand(filePath, command, args, mutationContext) {
2138
2880
  if (filePath) {
2139
2881
  const spawned = await this.getClientForFile(filePath, NAV_CLIENT_WAIT_TIMEOUT_MS);
2140
2882
  if (!spawned) {
2141
2883
  return { executed: false, reason: "no LSP server for file" };
2142
2884
  }
2143
- return spawned.client.executeCommand(command, args);
2885
+ return spawned.client.executeCommand(command, args, mutationContext);
2144
2886
  }
2145
2887
  const first = this.state.clients.values().next().value;
2146
2888
  if (!first)
2147
2889
  return { executed: false, reason: "no active LSP server" };
2148
- return first.executeCommand(command, args);
2890
+ return first.executeCommand(command, args, mutationContext);
2149
2891
  }
2150
2892
  /**
2151
2893
  * Capability snapshot for LSP operations.
@@ -2180,7 +2922,7 @@ export class LSPService {
2180
2922
  if (filePath) {
2181
2923
  const servers = getServersForFileWithConfig(filePath);
2182
2924
  for (const server of servers) {
2183
- const root = await server.root(filePath);
2925
+ const root = await this.resolveServerRoot(server, filePath);
2184
2926
  if (!root)
2185
2927
  continue;
2186
2928
  const client = this.state.clients.get(`${server.id}:${normalizeMapKey(root)}`);
@@ -2254,6 +2996,20 @@ export class LSPService {
2254
2996
  async renameFile(oldFilePath, newFilePath, options) {
2255
2997
  const cwd = options.cwd;
2256
2998
  const apply = options.apply ?? false;
2999
+ // Validate the complete resource operation before asking any server for
3000
+ // willRenameFiles edits. This is a read-only preflight, but it reuses the
3001
+ // same confinement, realpath/symlink, existence, and destination checks as
3002
+ // the eventual apply path, so an invalid rename cannot first mutate an
3003
+ // in-workspace file through returned text edits (including previews).
3004
+ await validateWorkspaceEdit({
3005
+ documentChanges: [
3006
+ {
3007
+ kind: "rename",
3008
+ oldUri: pathToFileURL(oldFilePath).href,
3009
+ newUri: pathToFileURL(newFilePath).href,
3010
+ },
3011
+ ],
3012
+ }, cwd);
2257
3013
  const priorityServerIds = getServersForFileWithConfig(oldFilePath).map((server) => server.id);
2258
3014
  const activeClients = this.activeClientsForCwd(cwd, priorityServerIds);
2259
3015
  const willRenameFailures = [];
@@ -2290,25 +3046,142 @@ export class LSPService {
2290
3046
  summary,
2291
3047
  };
2292
3048
  }
2293
- const applied = await applyWorkspaceEdit(merged.edit, cwd);
2294
- await fs.mkdir(path.dirname(newFilePath), { recursive: true });
2295
- await fs.rename(oldFilePath, newFilePath);
3049
+ let applied;
3050
+ try {
3051
+ applied = await applyWorkspaceEdit(merged.edit, cwd, {
3052
+ mutationContext: options.mutationContext,
3053
+ observe: false,
3054
+ });
3055
+ }
3056
+ catch (err) {
3057
+ if (options.mutationContext) {
3058
+ const partial = err
3059
+ .appliedWorkspaceEdit;
3060
+ if (partial) {
3061
+ recordLspMutation(options.mutationContext, {
3062
+ results: [partial],
3063
+ status: "failed",
3064
+ });
3065
+ }
3066
+ }
3067
+ throw err;
3068
+ }
3069
+ const openDocuments = activeClients
3070
+ .filter(({ client }) => client.isDocumentOpen(oldFilePath))
3071
+ .map(({ serverId, client }) => ({
3072
+ serverId,
3073
+ client,
3074
+ oldUri: client.getDocumentUri(oldFilePath),
3075
+ }));
3076
+ const closeFailures = [];
3077
+ await Promise.all(openDocuments.map(async ({ serverId, client }) => {
3078
+ try {
3079
+ await client.closeDocument(oldFilePath);
3080
+ return undefined;
3081
+ }
3082
+ catch (err) {
3083
+ closeFailures.push({
3084
+ serverId,
3085
+ error: err instanceof Error ? err.message : String(err),
3086
+ });
3087
+ return undefined;
3088
+ }
3089
+ }));
3090
+ if (closeFailures.length > 0) {
3091
+ if (options.mutationContext) {
3092
+ recordLspMutation(options.mutationContext, {
3093
+ results: [applied],
3094
+ status: "failed",
3095
+ });
3096
+ }
3097
+ // Do not rename or send didRenameFiles while any server still has the
3098
+ // old document open. Re-open/resync every affected client so a partial
3099
+ // close cannot leave an in-memory document behind the disk contents.
3100
+ // Reopen with the document's ACTUAL language ID (the same resolver every
3101
+ // genuine open uses) rather than a hardcoded "plaintext" fallback — a
3102
+ // wrong languageId here degrades that server's diagnostics until the
3103
+ // next genuine open (#1147 P3-7).
3104
+ const content = await fs.readFile(oldFilePath, "utf-8");
3105
+ const languageId = getLanguageId(oldFilePath) ?? "plaintext";
3106
+ await Promise.all(openDocuments.map(({ client }) => client.notify.open(oldFilePath, content, languageId, true, true)));
3107
+ throw new Error(`workspace/didClose failed; rename aborted: ${closeFailures.map((failure) => `${failure.serverId}: ${failure.error}`).join("; ")}`);
3108
+ }
3109
+ let renameApplied;
3110
+ try {
3111
+ // Route the resource mutation through the same preflight confinement,
3112
+ // realpath and symlink policy as all other workspace edits. Do not
3113
+ // duplicate that security boundary with a direct mkdir/rename pair.
3114
+ renameApplied = await applyWorkspaceEdit({
3115
+ documentChanges: [
3116
+ {
3117
+ kind: "rename",
3118
+ oldUri: pathToFileURL(oldFilePath).href,
3119
+ newUri: pathToFileURL(newFilePath).href,
3120
+ },
3121
+ ],
3122
+ }, cwd, { observe: false });
3123
+ }
3124
+ catch (err) {
3125
+ if (options.mutationContext) {
3126
+ recordLspMutation(options.mutationContext, {
3127
+ results: [applied],
3128
+ status: "failed",
3129
+ });
3130
+ }
3131
+ throw err;
3132
+ }
2296
3133
  const relOld = path.relative(cwd, oldFilePath).replace(/\\/g, "/") ||
2297
3134
  path.basename(oldFilePath);
2298
3135
  const relNew = path.relative(cwd, newFilePath).replace(/\\/g, "/") ||
2299
3136
  path.basename(newFilePath);
2300
3137
  const renameDescription = `Renamed ${relOld} → ${relNew}`;
2301
3138
  await Promise.all(activeClients.map(async ({ serverId, client }) => {
3139
+ const opened = openDocuments.find((entry) => entry.serverId === serverId);
2302
3140
  try {
2303
- await client.didRenameFiles(oldFilePath, newFilePath);
3141
+ if (opened?.oldUri) {
3142
+ await client.didRenameFiles(oldFilePath, newFilePath, opened.oldUri, destinationUriPreservingSpelling(opened.oldUri, oldFilePath, newFilePath));
3143
+ }
3144
+ else {
3145
+ await client.didRenameFiles(oldFilePath, newFilePath);
3146
+ }
3147
+ return undefined;
2304
3148
  }
2305
3149
  catch (err) {
2306
3150
  didRenameFailures.push({
2307
3151
  serverId,
2308
3152
  error: err instanceof Error ? err.message : String(err),
2309
3153
  });
3154
+ return undefined;
2310
3155
  }
2311
3156
  }));
3157
+ const files = [...new Set([...applied.files, oldFilePath, newFilePath])];
3158
+ if (options.mutationContext) {
3159
+ recordLspMutation(options.mutationContext, {
3160
+ results: [
3161
+ {
3162
+ ...applied,
3163
+ files,
3164
+ operationTotal: applied.operationTotal + renameApplied.operationTotal,
3165
+ appliedOperationTotal: applied.appliedOperationTotal + renameApplied.appliedOperationTotal,
3166
+ appliedOperationIndexes: [
3167
+ ...applied.appliedOperationIndexes,
3168
+ ...renameApplied.appliedOperationIndexes.map((index) => applied.operationTotal + index),
3169
+ ],
3170
+ operationCounts: {
3171
+ textEdits: applied.operationCounts.textEdits + renameApplied.operationCounts.textEdits,
3172
+ create: applied.operationCounts.create + renameApplied.operationCounts.create,
3173
+ rename: applied.operationCounts.rename + renameApplied.operationCounts.rename,
3174
+ delete: applied.operationCounts.delete + renameApplied.operationCounts.delete,
3175
+ },
3176
+ fileDetails: [
3177
+ ...applied.fileDetails,
3178
+ ...renameApplied.fileDetails,
3179
+ ],
3180
+ },
3181
+ ],
3182
+ status: "success",
3183
+ });
3184
+ }
2312
3185
  return {
2313
3186
  applied: true,
2314
3187
  serverIds: activeClients.map((entry) => entry.serverId),
@@ -2318,7 +3191,7 @@ export class LSPService {
2318
3191
  inputEditCount: merged.inputEditCount,
2319
3192
  summary,
2320
3193
  descriptions: [...applied.descriptions, renameDescription],
2321
- files: [...new Set([...applied.files, oldFilePath, newFilePath])],
3194
+ files,
2322
3195
  };
2323
3196
  }
2324
3197
  /**
@@ -2556,16 +3429,21 @@ export class LSPService {
2556
3429
  ms: timeoutMs,
2557
3430
  onTimeout: "undefined",
2558
3431
  }));
3432
+ const coldServerIds = stillColdServerIds();
2559
3433
  logLatency({
2560
3434
  type: "phase",
2561
- phase: "lsp_sweep_warmup_done",
3435
+ phase: options.signal?.aborted
3436
+ ? "lsp_sweep_warmup_aborted"
3437
+ : coldServerIds.length > 0
3438
+ ? "lsp_sweep_warmup_failed"
3439
+ : "lsp_sweep_warmup_done",
2562
3440
  filePath: representativeFile,
2563
3441
  durationMs: Date.now() - startedAt,
2564
3442
  metadata: {
2565
3443
  serverIds: servers.map((s) => s.id),
2566
3444
  timeoutMs,
2567
3445
  attempt,
2568
- coldServerIds: stillColdServerIds(),
3446
+ coldServerIds,
2569
3447
  },
2570
3448
  });
2571
3449
  };
@@ -2591,13 +3469,6 @@ export class LSPService {
2591
3469
  }
2592
3470
  }
2593
3471
  if (failedServerIds.length > 0) {
2594
- logLatency({
2595
- type: "phase",
2596
- phase: "lsp_sweep_warmup_failed",
2597
- filePath: representativeFile,
2598
- durationMs: 0,
2599
- metadata: { failedServerIds, timeoutMs },
2600
- });
2601
3472
  // #799: record the negative cache so a LATER sweep this session
2602
3473
  // skips straight past re-paying this warm-up (initial + retry).
2603
3474
  // Cleared automatically the moment the server demonstrates
@@ -2659,13 +3530,31 @@ export class LSPService {
2659
3530
  ]);
2660
3531
  const cachedResults = [];
2661
3532
  const filesToTouch = [];
3533
+ const writeIndexByPath = new Map();
2662
3534
  for (const filePath of files) {
3535
+ // Reserve order before cache lookup/touch admission. A result that settles
3536
+ // later is still ordered by when this scan began observing the file (#1198).
3537
+ writeIndexByPath.set(normalizeMapKey(filePath), options.nextWriteIndex?.());
2663
3538
  const cached = workspaceDiagnosticsCacheCtx.lookup(filePath, workspaceSweepScopeKey);
2664
- if (cached) {
3539
+ // #1095 (P2-1 bug-class sweep): both sweeps share cache entries under the
3540
+ // same scopeKey, so this service sweep must apply the SAME content-binding
3541
+ // gate the tools/lsp-diagnostics.ts sibling site does — an entry whose
3542
+ // recorded fingerprint no longer matches disk (bytes changed WITHOUT an
3543
+ // mtime bump the freshness check would catch, exactly what contentHash
3544
+ // exists to detect) must NOT be replayed and reconciled as confirmed via
3545
+ // mode=full. On a mismatch, fall through to a fresh touch.
3546
+ if (cached && cached.binding.boundToCurrentDisk !== false) {
2665
3547
  cachedResults.push({
2666
3548
  filePath,
2667
3549
  diagnostics: cached.diagnostics,
2668
3550
  count: cached.count,
3551
+ // #1093: a cache hit replays an older observation — carry its
3552
+ // scan time so mode=full's footer reconcile stamps `touchedAt`
3553
+ // with when the truth was seen, not now().
3554
+ observedAt: cached.scannedAt,
3555
+ contentHash: cached.binding.contentHash,
3556
+ boundToCurrentDisk: cached.binding.boundToCurrentDisk,
3557
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
2669
3558
  });
2670
3559
  }
2671
3560
  else {
@@ -2847,8 +3736,16 @@ export class LSPService {
2847
3736
  if (attached && !attached.available) {
2848
3737
  await this.ensureWarmForSweep(filePath, { signal });
2849
3738
  }
2850
- const diagnostics = attached?.available
2851
- ? attached.response.diagnostics
3739
+ // #1179 (shape-5 structural fix): normalize both sources into the
3740
+ // `TouchFileResult` wrapper shape so the flag reads below come off
3741
+ // EXPLICIT fields, not a non-enumerable side-channel a copy would drop.
3742
+ // The warm-attach IPC branch resolves a plain diagnostics array with
3743
+ // `inconclusive` re-surfaced as an enumerable DTO field (the socket can
3744
+ // carry no binding, and the IPC client already rejects an inconclusive
3745
+ // answer, so `available` implies confirmed) — wrap it as `{ diags }`;
3746
+ // the incumbent branch already returns the wrapper.
3747
+ const touchResult = attached?.available
3748
+ ? { diags: attached.response.diagnostics }
2852
3749
  : await withDeadline(this.touchFile(filePath, content, {
2853
3750
  diagnostics: "document",
2854
3751
  collectDiagnostics: true,
@@ -2863,17 +3760,23 @@ export class LSPService {
2863
3760
  // full wait budget only once across this whole sweep.
2864
3761
  sweepIndexGate,
2865
3762
  }), { ms: perFileMs, onTimeout: "undefined" });
3763
+ const diagnostics = touchResult?.diags;
2866
3764
  // #571: prefer #570's real per-touch inconclusive signal
2867
- // (`touchFile`'s non-enumerable `.inconclusive` flag — set when the
2868
- // notify write or the diagnostics wait itself timed out) over this
2869
- // sweep's own OUTER `perFileMs` deadline, which only catches a touch
2870
- // that never returned at all within budget. Either one means the
2871
- // result wasn't confirmed.
2872
- const inconclusive = diagnostics
2873
- ?.inconclusive === true;
2874
- const timedOut = diagnostics === undefined || inconclusive;
3765
+ // (`touchFile`'s `.inconclusive` flag — set when the notify write or the
3766
+ // diagnostics wait itself timed out) over this sweep's own OUTER
3767
+ // `perFileMs` deadline, which only catches a touch that never returned at
3768
+ // all within budget. Either one means the result wasn't confirmed.
3769
+ const inconclusive = touchResult?.inconclusive === true;
3770
+ const timedOut = touchResult === undefined || inconclusive;
2875
3771
  if (timedOut)
2876
3772
  timedOutFiles += 1;
3773
+ // #1104 (shape 5 — AGENTS.md): the touch's content binding is an
3774
+ // EXPLICIT enumerable field on the wrapper now (#1179), so it survives
3775
+ // `applyAuxiliarySuppressions` below rebuilding `.diags` via `.filter()`
3776
+ // — read it off `touchResult`, not off the derived array. A version-
3777
+ // less/pull-less server composed no binding, so `contentHash` stays
3778
+ // undefined here (honest "unknown" downstream), matching prior behavior.
3779
+ const rawBinding = touchResult?.binding;
2877
3780
  // #586: honor each auxiliary profile's native inline-suppression
2878
3781
  // comment (e.g. opengrep's `// nosemgrep`, #441) — computed from the
2879
3782
  // raw `diagnostics` (before this drops its non-enumerable
@@ -2897,6 +3800,9 @@ export class LSPService {
2897
3800
  diagnostics: filteredDiagnostics ?? [],
2898
3801
  count: filteredDiagnostics?.length ?? 0,
2899
3802
  timedOut,
3803
+ contentHash: rawBinding?.contentHash,
3804
+ boundToCurrentDisk: rawBinding?.boundToCurrentDisk,
3805
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
2900
3806
  });
2901
3807
  }
2902
3808
  catch (err) {
@@ -2909,6 +3815,7 @@ export class LSPService {
2909
3815
  // no confirmed result was obtained, so reconciliation (#571) must
2910
3816
  // skip it the same way.
2911
3817
  timedOut: true,
3818
+ writeIndex: writeIndexByPath.get(normalizeMapKey(filePath)),
2912
3819
  });
2913
3820
  }
2914
3821
  completed += 1;
@@ -2948,7 +3855,10 @@ export class LSPService {
2948
3855
  const pulled = await this.tryWorkspacePull(group.files, perFileMs);
2949
3856
  if (pulled) {
2950
3857
  for (const result of pulled) {
2951
- results.push(result);
3858
+ results.push({
3859
+ ...result,
3860
+ writeIndex: writeIndexByPath.get(normalizeMapKey(result.filePath)),
3861
+ });
2952
3862
  // #671: a pull result is always confirmed (see
2953
3863
  // `tryWorkspacePull`'s doc comment), so it's cache-eligible
2954
3864
  // too — best-effort stat since the pull already resolved the
@@ -3072,7 +3982,15 @@ export class LSPService {
3072
3982
  const scannedAt = scannedMtimeByFile.get(result.filePath);
3073
3983
  if (result.error || result.timedOut || scannedAt === undefined)
3074
3984
  continue;
3075
- workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt);
3985
+ // #1104: thread the per-result `contentHash` (from either the
3986
+ // `tryWorkspacePull` fast path or a per-file touch's own #1095 binding)
3987
+ // into the cache entry — previously this call never passed one, so
3988
+ // every pull-served entry read binding "unknown" forever and the #1095
3989
+ // P2-1 service-sweep binding gate above (`cached.binding.
3990
+ // boundToCurrentDisk !== false`) could never demote a pull-cached
3991
+ // entry. Absent `contentHash` (no binding was available) behaves
3992
+ // exactly as before.
3993
+ workspaceDiagnosticsCacheCtx.record(result.filePath, workspaceSweepScopeKey, result.diagnostics, scannedAt, result.contentHash);
3076
3994
  }
3077
3995
  workspaceDiagnosticsCacheCtx.persist();
3078
3996
  return [...cachedResults, ...results].filter(Boolean);
@@ -3099,14 +4017,24 @@ export class LSPService {
3099
4017
  return undefined;
3100
4018
  const byPath = new Map();
3101
4019
  for (const entry of report) {
3102
- byPath.set(normalizeMapKey(entry.filePath), entry.diagnostics);
4020
+ byPath.set(normalizeMapKey(entry.filePath), entry);
3103
4021
  }
3104
4022
  return groupFiles.map((filePath) => {
3105
- const diagnostics = byPath.get(normalizeMapKey(filePath)) ?? [];
4023
+ const entry = byPath.get(normalizeMapKey(filePath));
4024
+ const diagnostics = entry?.diagnostics ?? [];
3106
4025
  // A pull that got here returned a real workspace/diagnostic report
3107
4026
  // (see the `!report` guard above) — always confirmed, unlike a
3108
4027
  // per-file touchFile default-empty on timeout.
3109
- return { filePath, diagnostics, count: diagnostics.length, timedOut: false };
4028
+ // #1104: `contentHash` comes straight from the client's pull layer —
4029
+ // a file absent from the report (reported "clean" here) carries none,
4030
+ // same honest "unknown" the record site already applies for it.
4031
+ return {
4032
+ filePath,
4033
+ diagnostics,
4034
+ count: diagnostics.length,
4035
+ timedOut: false,
4036
+ contentHash: entry?.contentHash,
4037
+ };
3110
4038
  });
3111
4039
  }
3112
4040
  catch {
@@ -3118,6 +4046,9 @@ export class LSPService {
3118
4046
  */
3119
4047
  async getAllDiagnostics() {
3120
4048
  const all = new Map();
4049
+ // #1095: per-file stored bindings across every contributing client, merged
4050
+ // into one `boundToCurrentDisk` verdict after the client loop.
4051
+ const bindingsByPath = new Map();
3121
4052
  const now = Date.now();
3122
4053
  for (const [_key, client] of this.state.clients) {
3123
4054
  // Resolve existence asynchronously (was a blocking existsSync per tracked
@@ -3150,8 +4081,35 @@ export class LSPService {
3150
4081
  else {
3151
4082
  all.set(filePath, { diags: [...entry.diags], ts: entry.ts });
3152
4083
  }
4084
+ const list = bindingsByPath.get(filePath) ?? [];
4085
+ list.push(entry.binding);
4086
+ bindingsByPath.set(filePath, list);
3153
4087
  }
3154
4088
  }
4089
+ // #1095 (P2-2): expose the composed content binding per file LAZILY — the
4090
+ // disk stat+hash verify only runs when a consumer actually reads `.binding`.
4091
+ // The current caller (the cascade, once per edit turn) does NOT read it yet
4092
+ // (binding adoption in cascade/integration.ts is deferred to the second PR),
4093
+ // so this leaves ZERO eager disk cost while still surfacing the field for the
4094
+ // consumer that arrives next. Non-enumerable so incidental spread/serialize
4095
+ // (e.g. logging) can't trigger a stat storm; memoized per entry so repeated
4096
+ // reads verify once.
4097
+ // #1179: DELIBERATELY NOT migrated to an enumerable wrapper field (unlike the
4098
+ // `touchFile` flags). Enumerable would make an incidental `{...entry}`/serialize
4099
+ // eagerly fire this getter and stat every file — the exact stat storm the
4100
+ // laziness above exists to prevent. It therefore stays on the read-off-original
4101
+ // carriage contract (see `DiagnosticBinding`): every consumer reads `.binding`
4102
+ // off the ORIGINAL Map entry (`clients/dispatch/integration.ts` reads it off
4103
+ // `entry`, never a copy), and the cascade's TTL gate reads it only when fresh.
4104
+ for (const [filePath, entry] of all) {
4105
+ const stored = bindingsByPath.get(filePath) ?? [];
4106
+ let memo;
4107
+ Object.defineProperty(entry, "binding", {
4108
+ enumerable: false,
4109
+ configurable: true,
4110
+ get: () => (memo ??= this.mergeBinding(filePath, stored)),
4111
+ });
4112
+ }
3155
4113
  return all;
3156
4114
  }
3157
4115
  /**
@@ -3186,6 +4144,9 @@ export class LSPService {
3186
4144
  if (this.checkDestroyed())
3187
4145
  return;
3188
4146
  this.isDestroyed = true;
4147
+ for (const key of this.typeScriptIdleTimers.keys()) {
4148
+ this.clearTypeScriptIdleTimer(key);
4149
+ }
3189
4150
  // Belt-and-braces: wait for any in-flight spawns so that Guard 1/2 in
3190
4151
  // spawnClient can observe isDestroyed and clean up. Skip on the
3191
4152
  // process-exiting path — the event loop is closing and we must not block.
@@ -3237,12 +4198,69 @@ export class LSPService {
3237
4198
  getStatus() {
3238
4199
  return Array.from(this.state.clients.entries()).map(([key, client]) => {
3239
4200
  const [serverId, root] = key.split(":");
3240
- return { serverId, root, connected: client.isAlive() };
4201
+ return {
4202
+ serverId,
4203
+ root,
4204
+ connected: client.isAlive(),
4205
+ pullFailureHistory: (client.getPullFailureHistory?.() ?? []).map((entry) => ({
4206
+ ...entry,
4207
+ message: entry.message.slice(0, 200),
4208
+ })),
4209
+ };
3241
4210
  });
3242
4211
  }
4212
+ /**
4213
+ * #1142 windowed-rate breaker (composes with the {@link runtimeExitCounts}
4214
+ * fast path — see that field and {@link RUNTIME_EXIT_WINDOW_MS}).
4215
+ *
4216
+ * Records one non-intentional death and returns true iff this death pushes
4217
+ * the rolling window to {@link RUNTIME_EXIT_WINDOW_TRIP_COUNT} deaths, i.e.
4218
+ * the breaker should give up now. The caller has ALREADY excluded intentional
4219
+ * teardowns (shutdown()-driven restarts, #743 eviction, session resets,
4220
+ * generation handoffs) — only genuine crash-respawns reach here.
4221
+ *
4222
+ * Sleep-gap / long-healthy-run guard: a death whose measured lifetime exceeds
4223
+ * {@link RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS} (or is unknown) is NOT
4224
+ * recorded — a lifetime spanning a Modern-Standby suspend, or a genuinely
4225
+ * long run that crashed once, is not churn. See that constant for the
4226
+ * per-KEY structural defense this backs up.
4227
+ *
4228
+ * Bounded on both axes (defect shape 9): prune entries outside the window,
4229
+ * then hard-cap the retained array at the trip count (drop-oldest — a rate
4230
+ * trip needs only the in-window count, so earliest-evidence order is
4231
+ * irrelevant here).
4232
+ */
4233
+ recordRuntimeExitWindow(key, exitedAt, uptimeMs) {
4234
+ if (exitedAt == null ||
4235
+ uptimeMs == null ||
4236
+ uptimeMs > RUNTIME_EXIT_WINDOW_UPTIME_CEILING_MS) {
4237
+ return false;
4238
+ }
4239
+ const deaths = this.runtimeExitWindow.get(key) ?? [];
4240
+ deaths.push(exitedAt);
4241
+ // Roll the window around the most recent death. `exitedAt` is the client's
4242
+ // own recorded death time (#1127), which can arrive out of DETECTION order
4243
+ // — a death detected hours later still carries its true, older timestamp —
4244
+ // so anchor on the max, not on push order.
4245
+ const windowRef = Math.max(...deaths);
4246
+ const windowStart = windowRef - RUNTIME_EXIT_WINDOW_MS;
4247
+ let pruned = deaths.filter((t) => t >= windowStart);
4248
+ if (pruned.length > RUNTIME_EXIT_WINDOW_TRIP_COUNT) {
4249
+ pruned = pruned.slice(pruned.length - RUNTIME_EXIT_WINDOW_TRIP_COUNT);
4250
+ }
4251
+ this.runtimeExitWindow.set(key, pruned);
4252
+ return pruned.length >= RUNTIME_EXIT_WINDOW_TRIP_COUNT;
4253
+ }
3243
4254
  /**
3244
4255
  * Read-only circuit-breaker status, including server/root pairs that have no
3245
4256
  * live client and would therefore be absent from getStatus().
4257
+ *
4258
+ * #1127: `failureCounts` (spawn/init failures) and `runtimeExitCounts`
4259
+ * (early post-init runtime exits) are two INDEPENDENT streams feeding one
4260
+ * circuit — either can trip its own cooldown/permanent-disable on its own
4261
+ * threshold, without the other. `failures` below is their SUM, purely for
4262
+ * display: it is the total churn behind whatever cooldown/permanent state
4263
+ * is showing, not a claim that both streams are at that count.
3246
4264
  */
3247
4265
  getBrokenStatus() {
3248
4266
  const keys = new Set([
@@ -3254,7 +4272,16 @@ export class LSPService {
3254
4272
  return {
3255
4273
  serverId: separator >= 0 ? key.slice(0, separator) : key,
3256
4274
  root: separator >= 0 ? key.slice(separator + 1) : "",
3257
- failures: this.failureCounts.get(key) ?? 0,
4275
+ // #1127/#1142: spawn/init failures, consecutive early post-init
4276
+ // exits, and the windowed-rate stream all feed the same breaker
4277
+ // (see `runtimeExitCounts`/`runtimeExitWindow` docs). Sum spawn/init
4278
+ // failures with the LARGER of the two runtime-exit streams — a
4279
+ // single <60s death feeds BOTH runtimeExitCounts and the window, so
4280
+ // `max` avoids double-counting them, while still surfacing a
4281
+ // windowed-only trip (whose runtimeExitCounts may be 0) instead of
4282
+ // misreporting `failures: 0` behind a permanent-disable.
4283
+ failures: (this.failureCounts.get(key) ?? 0) +
4284
+ Math.max(this.runtimeExitCounts.get(key) ?? 0, this.runtimeExitWindow.get(key)?.length ?? 0),
3258
4285
  permanentlyBroken: this.permanentlyBroken.has(key),
3259
4286
  cooldownUntil: this.state.broken.get(key) ?? 0,
3260
4287
  };