@herbertgao/pi-extensions 2026.8.6 → 2026.8.8

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 (385) hide show
  1. package/README.md +8 -6
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +2 -2
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +2 -2
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +19 -7
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +30 -9
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +320 -22
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +79 -17
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +27 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +66 -17
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  16. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  17. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  18. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  19. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  26. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  27. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  29. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  30. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
  32. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  33. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +5 -4
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +174 -100
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +5 -1
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +10 -2
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +10 -6
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/tool-schema.md +11 -5
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +5 -2
  43. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
  44. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
  45. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
  46. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +140 -97
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +63 -28
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/state/selectors/projections.ts +22 -15
  49. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state-reducer.ts +11 -1
  50. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  51. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/response-envelope.ts +11 -0
  52. package/node_modules/@juicesharp/rpiv-ask-user-question/tool/types.ts +11 -0
  53. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/multi-select-view.ts +31 -17
  54. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/preview/preview-block-renderer.ts +15 -6
  55. package/node_modules/@juicesharp/rpiv-ask-user-question/view/components/wrapping-select.ts +15 -6
  56. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +86 -38
  57. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +76 -9
  58. package/node_modules/@narumitw/pi-btw/README.md +120 -111
  59. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  60. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  61. package/node_modules/@narumitw/pi-btw/package.json +10 -6
  62. package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
  63. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  64. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  65. package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
  66. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  67. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs +44 -1856
  68. package/node_modules/@pi-plugins/fast-mode/dist/index.mjs.map +1 -1
  69. package/node_modules/@pi-plugins/fast-mode/package.json +4 -6
  70. package/node_modules/pi-lens/CHANGELOG.md +999 -0
  71. package/node_modules/pi-lens/README.md +117 -3
  72. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  73. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  74. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
  75. package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
  76. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  77. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  78. package/node_modules/pi-lens/dist/clients/bash-file-access.js +381 -13
  79. package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
  80. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  81. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +129 -0
  82. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  83. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  84. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +206 -0
  85. package/node_modules/pi-lens/dist/clients/cache-observability.js +353 -76
  86. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  87. package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
  88. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  89. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  90. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  91. package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
  92. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  93. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  94. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +84 -14
  95. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +155 -0
  96. package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
  97. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +8 -2
  98. package/node_modules/pi-lens/dist/clients/deps/typebox.js +8 -3
  99. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
  100. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  101. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
  102. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
  103. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  104. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +9 -6
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +9 -7
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +8 -6
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -2
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +25 -2
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +20 -5
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +44 -11
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +17 -8
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +32 -4
  129. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +47 -3
  130. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +14 -4
  131. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +127 -28
  132. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +12 -7
  133. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  134. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
  135. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
  136. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  137. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
  138. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  139. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +188 -0
  140. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
  141. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +21 -10
  142. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -2
  143. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  144. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  145. package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
  146. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  147. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +316 -0
  148. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  149. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  150. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  151. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  152. package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
  153. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  154. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
  155. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  156. package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
  157. package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
  158. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  159. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  160. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  161. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  162. package/node_modules/pi-lens/dist/clients/jscpd-client.js +87 -8
  163. package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
  164. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  165. package/node_modules/pi-lens/dist/clients/language-profile.js +9 -2
  166. package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
  167. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  168. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  169. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  170. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +128 -8
  171. package/node_modules/pi-lens/dist/clients/lsp/client.js +1660 -164
  172. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
  173. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  174. package/node_modules/pi-lens/dist/clients/lsp/index.js +2423 -151
  175. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  176. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  177. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  178. package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
  179. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  180. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  181. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
  182. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
  183. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  184. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  185. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  186. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  187. package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
  188. package/node_modules/pi-lens/dist/clients/module-report.js +22 -1
  189. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +114 -23
  190. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  191. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  192. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  193. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  194. package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
  195. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  196. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  197. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  198. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
  199. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  200. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  201. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +23 -9
  202. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  203. package/node_modules/pi-lens/dist/clients/project-report.js +62 -27
  204. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  205. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  206. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  207. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +8 -3
  208. package/node_modules/pi-lens/dist/clients/read-guard.js +253 -10
  209. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  210. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  211. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +194 -31
  212. package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
  213. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +21 -0
  214. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +6 -3
  215. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
  216. package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
  217. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +352 -45
  218. package/node_modules/pi-lens/dist/clients/runtime-session.js +373 -9
  219. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  220. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
  221. package/node_modules/pi-lens/dist/clients/runtime-turn.js +620 -67
  222. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  223. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  224. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  225. package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
  226. package/node_modules/pi-lens/dist/clients/session-event-guard.js +184 -0
  227. package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
  228. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  229. package/node_modules/pi-lens/dist/clients/source-filter.js +6 -2
  230. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  231. package/node_modules/pi-lens/dist/clients/startup-scan.js +5 -2
  232. package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
  233. package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
  234. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  235. package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  237. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  238. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +1 -0
  239. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  240. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  241. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  242. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  243. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  244. package/node_modules/pi-lens/dist/index.js +61407 -48577
  245. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +476 -57
  246. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
  247. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  248. package/node_modules/pi-lens/docs/agent-guide.md +6 -1
  249. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
  250. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  251. package/node_modules/pi-lens/docs/features.md +19 -5
  252. package/node_modules/pi-lens/docs/globalconfig.md +3 -0
  253. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  254. package/node_modules/pi-lens/docs/mcp.md +2 -2
  255. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  256. package/node_modules/pi-lens/docs/settings.md +19 -1
  257. package/node_modules/pi-lens/docs/usage.md +1 -0
  258. package/node_modules/pi-lens/package.json +27 -10
  259. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
  260. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
  261. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
  262. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
  263. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
  264. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
  265. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
  300. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
  301. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
  302. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
  303. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  304. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  305. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  306. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  307. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  308. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  309. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
  310. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  311. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  312. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  313. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  314. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  315. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  316. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  317. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  318. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  319. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
  320. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  321. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  322. package/node_modules/pi-lens/scripts/install-selftest.mjs +99 -11
  323. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +75 -0
  324. package/node_modules/pi-lens/scripts/lib/warm-loader-cache.mjs +285 -0
  325. package/node_modules/pi-lens/scripts/warm-loader-cache.mjs +233 -0
  326. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
  327. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
  328. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
  329. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  330. package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
  331. package/node_modules/pi-mcp-adapter/README.md +47 -5
  332. package/node_modules/pi-mcp-adapter/cli.js +163 -1
  333. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  334. package/node_modules/pi-mcp-adapter/config.ts +25 -7
  335. package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
  336. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  337. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  338. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  339. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  340. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  341. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  342. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  343. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  344. package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
  345. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  346. package/node_modules/pi-mcp-adapter/package.json +3 -1
  347. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  348. package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
  349. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  350. package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
  351. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  352. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  353. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  354. package/node_modules/pi-mcp-adapter/types.ts +2 -0
  355. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  356. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  357. package/node_modules/pi-web-access/CHANGELOG.md +37 -0
  358. package/node_modules/pi-web-access/README.md +32 -13
  359. package/node_modules/pi-web-access/anysearch.ts +4 -2
  360. package/node_modules/pi-web-access/brave.ts +16 -4
  361. package/node_modules/pi-web-access/chrome-cookies.ts +86 -15
  362. package/node_modules/pi-web-access/curator-page.ts +6 -3
  363. package/node_modules/pi-web-access/curator-server.ts +4 -1
  364. package/node_modules/pi-web-access/data-uri-sanitize.ts +406 -0
  365. package/node_modules/pi-web-access/exa.ts +18 -7
  366. package/node_modules/pi-web-access/extract.ts +40 -5
  367. package/node_modules/pi-web-access/firecrawl.ts +18 -2
  368. package/node_modules/pi-web-access/gemini-search.ts +16 -4
  369. package/node_modules/pi-web-access/github-extract.ts +65 -36
  370. package/node_modules/pi-web-access/index.ts +60 -61
  371. package/node_modules/pi-web-access/openai-search.ts +17 -6
  372. package/node_modules/pi-web-access/package.json +1 -1
  373. package/node_modules/pi-web-access/page-query.ts +4 -2
  374. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  375. package/node_modules/pi-web-access/parallel.ts +2 -2
  376. package/node_modules/pi-web-access/pdf-extract.ts +18 -5
  377. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  378. package/node_modules/pi-web-access/serper.ts +211 -0
  379. package/node_modules/pi-web-access/ssrf-protection.ts +12 -2
  380. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  381. package/node_modules/pi-web-access/summary-review.ts +44 -22
  382. package/node_modules/pi-web-access/tavily.ts +16 -4
  383. package/node_modules/pi-web-access/utils.ts +87 -0
  384. package/node_modules/pi-web-access/valyu.ts +199 -0
  385. package/package.json +14 -14
@@ -11,15 +11,16 @@ import * as path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { logSessionStart } from "../../../sessionstart-logger.js";
13
13
  import { getGlobalPiLensDir } from "../../../file-utils.js";
14
+ import { createGenerationSource, } from "../../../generation-guard.js";
14
15
  import { PathKeyedMap } from "../../../path-keyed-map.js";
15
16
  import { normalizeEphemeralMapKey, normalizeMapKey, } from "../../../path-utils.js";
16
- import { ensureTool, isSpawnableCommand, resetPathWalkMemo, } from "../../../installer/index.js";
17
+ import { ensureTool, getInstallAttempt, getLastEnsureResolutionSource, getToolInstallStrategy, isSpawnableCommand, resetPathWalkMemo, } from "../../../installer/index.js";
17
18
  import { getServersForFileWithConfig, isServerDisabled, } from "../../../lsp/config.js";
18
19
  import { findGlobalBinary } from "../../../package-manager.js";
19
20
  import { safeSpawnAsync } from "../../../safe-spawn.js";
20
21
  import { getToolCommandSpec, shouldAutoInstallTool, } from "../../../tool-policy.js";
21
- import { classifyProbeFailure, createAvailabilityLatch, isLatchingOutcome, logAvailabilityDecision, startHostStallSampler, transientRetryDelayMs, } from "./availability-policy.js";
22
- export { createAvailabilityLatch, classifyProbeFailure, describeUnavailability, isTransientDecision, logAvailabilityDecision, startHostStallSampler, } from "./availability-policy.js";
22
+ import { classifyProbeFailure, createAvailabilityLatch, describeInstallAttempt, describeProbeEvidence, isLatchingOutcome, logAvailabilityDecision, startHostStallSampler, transientRetryDelayMs, } from "./availability-policy.js";
23
+ export { createAvailabilityLatch, classifyProbeFailure, describeProbeEvidence, describeUnavailability, isTransientDecision, logAvailabilityDecision, startHostStallSampler, } from "./availability-policy.js";
23
24
  /**
24
25
  * True when the LSP runner will cover `ctx.filePath` via the given PRIMARY server
25
26
  * id. Used by CLI runners that duplicate a linter a warm LSP already wraps
@@ -67,34 +68,176 @@ if (typeof __dirname !== "undefined") {
67
68
  // Managed tools directory (~/.pi-lens/tools) — where ensureTool() installs binaries
68
69
  const _managedToolsDir = path.join(getGlobalPiLensDir(), "tools");
69
70
  /**
70
- * The managed shim for a Node CLI tool (`~/.pi-lens/tools/node_modules/.bin/<tool>`),
71
- * or null when it is not on disk.
72
- *
73
- * When the shim exists the tool IS installed, so availability needs no spawn at
74
- * all — and a spawn that cannot happen cannot time out (#1467). knip and jscpd
75
- * each carried a line-for-line copy of this resolver; #1476 folds them into one
76
- * definition so the next managed tool inherits the fast path instead of a
77
- * fourth copy.
71
+ * Managed-shim candidates for a Node CLI tool, in the installer's own
72
+ * preference order (`getToolPath`'s managed-first branch: `.cmd`, then `.exe`,
73
+ * then extensionless on Windows; extensionless elsewhere).
78
74
  *
79
75
  * The pi-lens dir is read per call, never memoized at module load, so tests that
80
76
  * point `getGlobalPiLensDir` at a temp home still see their own tree.
81
77
  */
82
- export function findManagedNodeToolBinary(tool) {
78
+ function managedNodeToolCandidates(tool) {
83
79
  const base = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin", tool);
84
- const candidates = process.platform === "win32" ? [`${base}.cmd`, `${base}.exe`, base] : [base];
80
+ return process.platform === "win32"
81
+ ? [`${base}.cmd`, `${base}.exe`, base]
82
+ : [base];
83
+ }
84
+ /**
85
+ * Verification verdicts for managed shims, keyed by path + mtime + size, so a
86
+ * reinstall re-verifies and a session start re-arms.
87
+ *
88
+ * A plain `Map`, not a `PathKeyedMap`: `managedNodeToolCandidates` is the ONLY
89
+ * producer of these paths, so the write and read forms are the same string by
90
+ * construction and cannot diverge on case or separator.
91
+ */
92
+ const managedBinaryVerdicts = new Map();
93
+ /**
94
+ * Verifications in progress, keyed the same way. Concurrent first touches of
95
+ * one shim share the single probe instead of each spawning their own — the
96
+ * same in-flight-share shape `resolveInstallInFlightByCwd` uses for installs
97
+ * (#1674 review F2).
98
+ */
99
+ const managedVerifyInFlight = new Map();
100
+ /**
101
+ * Budget for the managed-shim verification spawn.
102
+ *
103
+ * 5s, not the checker's default probe budget: a cold npm shim on a cold file
104
+ * cache measured just over 2s in the #1674 review, so a tighter budget spends
105
+ * the whole wait and still learns nothing. This runs once per shim per session
106
+ * on the success path, and the installer's own verification keeps its more
107
+ * generous 10s.
108
+ */
109
+ const MANAGED_VERIFY_TIMEOUT_MS = 5000;
110
+ /** First cooldown after a verification that never got a fair run. */
111
+ const MANAGED_VERIFY_COOLDOWN_MS = 60_000;
112
+ /**
113
+ * Ceiling for the cooldown ladder — a shim that keeps stalling is still
114
+ * re-probed every 15 minutes.
115
+ */
116
+ const MANAGED_VERIFY_COOLDOWN_MAX_MS = 15 * 60_000;
117
+ function managedVerifyCooldownMs(attempts) {
118
+ return Math.min(MANAGED_VERIFY_COOLDOWN_MAX_MS, MANAGED_VERIFY_COOLDOWN_MS * 2 ** (attempts - 1));
119
+ }
120
+ async function runManagedVerification(candidate, stamp, priorAttempts, generation) {
121
+ let transient = false;
122
+ let ok;
85
123
  try {
86
- return candidates.find((candidate) => fs.existsSync(candidate)) ?? null;
124
+ const { verifyToolBinary } = await import("../../../installer/index.js");
125
+ ok = await verifyToolBinary(candidate, undefined, () => {
126
+ transient = true;
127
+ }, MANAGED_VERIFY_TIMEOUT_MS);
87
128
  }
88
129
  catch {
89
- return null;
130
+ // The verifier itself could not run — installer-isolated unit tests mock
131
+ // this module without it, and a throw here says nothing about the shim.
132
+ // "Cannot verify" is not "broken": keep the on-disk answer.
133
+ transient = true;
134
+ ok = false;
135
+ }
136
+ // An unspawnable prober is never a durable verdict (#1569): a timeout or a
137
+ // spawn-boundary failure says nothing about the shim, so it never demotes
138
+ // the candidate. It IS remembered, under a cooldown, so the wait is paid
139
+ // once per window rather than on every resolve (#1674 review F1).
140
+ // A verification that straddles a session boundary answers ITS caller, but
141
+ // its verdict belongs to the session that asked. Writing it into the fresh
142
+ // session would hand the new session the old one's cooldown — the exact
143
+ // re-arm this state clears at `session_start` (#1674 review F5). Same guard
144
+ // the install seam's in-flight share uses.
145
+ if (!ok && transient) {
146
+ const attempts = priorAttempts + 1;
147
+ generation.guardedWrite(stamp, () => {
148
+ managedBinaryVerdicts.set(stamp, {
149
+ verdict: "unverified",
150
+ attempts,
151
+ retryAtMs: Date.now() + managedVerifyCooldownMs(attempts),
152
+ });
153
+ });
154
+ return "unverified";
90
155
  }
156
+ generation.guardedWrite(stamp, () => {
157
+ managedBinaryVerdicts.set(stamp, { verdict: ok ? "ok" : "broken" });
158
+ });
159
+ if (!ok) {
160
+ // Once per shim per session, at the moment the verdict is reached — the
161
+ // memo answers every later call, so this cannot become per-dispatch spam.
162
+ logSessionStart(`dispatch availability: managed shim ${candidate} exists but does not run; falling through to PATH`);
163
+ }
164
+ return ok ? "ok" : "broken";
91
165
  }
166
+ async function verifyManagedCandidate(candidate) {
167
+ let stamp;
168
+ try {
169
+ const stat = fs.statSync(candidate);
170
+ stamp = `${candidate}:${stat.mtimeMs}:${stat.size}`;
171
+ }
172
+ catch {
173
+ return "absent";
174
+ }
175
+ const memo = managedBinaryVerdicts.get(stamp);
176
+ let priorAttempts = 0;
177
+ if (memo) {
178
+ if (memo.verdict !== "unverified")
179
+ return memo.verdict;
180
+ if (Date.now() < memo.retryAtMs)
181
+ return "unverified";
182
+ priorAttempts = memo.attempts;
183
+ }
184
+ const existing = managedVerifyInFlight.get(stamp);
185
+ if (existing)
186
+ return existing;
187
+ const generation = availabilityGeneration.capture();
188
+ const probe = runManagedVerification(candidate, stamp, priorAttempts, generation).finally(() => {
189
+ // A settling old-session probe must not evict the live entry a new
190
+ // session already started for the same shim (#1674 review F5).
191
+ generation.guardedWrite(stamp, () => {
192
+ if (managedVerifyInFlight.get(stamp) === probe) {
193
+ managedVerifyInFlight.delete(stamp);
194
+ }
195
+ });
196
+ });
197
+ managedVerifyInFlight.set(stamp, probe);
198
+ return probe;
199
+ }
200
+ /**
201
+ * The managed shim for a Node CLI tool (`~/.pi-lens/tools/node_modules/.bin/<tool>`),
202
+ * or null when no candidate on disk actually runs.
203
+ *
204
+ * Verification uses the installer's own `verifyToolBinary`, the same check its
205
+ * managed-first branch runs before returning a managed path. A bare
206
+ * `existsSync` let a broken shim shadow a working PATH binary — the on-disk
207
+ * file wins the race and then fails every spawn behind it (#1657). knip and
208
+ * jscpd each carried a line-for-line copy of this resolver; #1476 folds them
209
+ * into one definition so the next managed tool inherits the fast path instead
210
+ * of a fourth copy.
211
+ *
212
+ * A settled verdict is memoized per file identity for the session, so the fast
213
+ * path answers without a spawn after the first call (#1467). A probe that
214
+ * cannot run keeps the optimistic answer rather than turning a stall into a
215
+ * "missing tool", and is memoized under a bounded cooldown ladder so the wait
216
+ * is paid once per window instead of on every resolve — while a shim that
217
+ * stalls on a cold cache still gets re-probed later (#1674 review F1).
218
+ * Concurrent first touches share one probe (#1674 review F2).
219
+ */
220
+ export async function findManagedNodeToolBinary(tool) {
221
+ for (const candidate of managedNodeToolCandidates(tool)) {
222
+ const verdict = await verifyManagedCandidate(candidate);
223
+ if (verdict === "ok" || verdict === "unverified")
224
+ return candidate;
225
+ }
226
+ return null;
227
+ }
228
+ // =============================================================================
229
+ // VENV-AWARE COMMAND FINDER
230
+ // =============================================================================
92
231
  /**
93
232
  * Find a command in venv first, then fall back to global.
94
233
  * Checks common venv locations (.venv, venv) before trying global.
234
+ *
235
+ * The resolved path is returned verbatim: every spawn consumer runs with
236
+ * `shell: false` (safe-spawn, #817), so wrapping it in quotes would make it
237
+ * a literal filename that ENOENTs on every platform (#1508).
95
238
  */
96
- export function createVenvFinder(command, windowsExt = "", quoteWindows = false) {
97
- return (cwd) => {
239
+ export function createVenvFinder(command, windowsExt = "") {
240
+ return async (cwd) => {
98
241
  const venvPaths = [
99
242
  `.venv/bin/${command}`,
100
243
  `venv/bin/${command}`,
@@ -104,9 +247,22 @@ export function createVenvFinder(command, windowsExt = "", quoteWindows = false)
104
247
  for (const venvPath of venvPaths) {
105
248
  const fullPath = path.join(cwd, venvPath);
106
249
  if (fs.existsSync(fullPath)) {
107
- return quoteWindows && windowsExt ? `"${fullPath}"` : fullPath;
250
+ return fullPath;
108
251
  }
109
252
  }
253
+ // Managed-dir install (~/.pi-lens/tools/node_modules/.bin/<command>) — the
254
+ // same shim `ensureTool()` installs npm-strategy tools into. Checked BEFORE
255
+ // the bare-name PATH fallback (#1638): without this, a tool that only
256
+ // exists in the managed dir never resolves here, so `resolveAvailableOrInstall`
257
+ // clears the checker's cache on every install recovery for nothing — the
258
+ // very next probe misses PATH again, spawns a doomed `--version` process,
259
+ // and only THEN falls through to `ensureTool`'s own cache. One managed-dir
260
+ // check settles it without a spawn — after one verification per shim per
261
+ // session, so a shim that cannot run falls through to PATH instead of
262
+ // shadowing a working binary (#1657).
263
+ const managed = await findManagedNodeToolBinary(command);
264
+ if (managed)
265
+ return managed;
110
266
  // Fall back to global
111
267
  return command;
112
268
  };
@@ -171,10 +327,44 @@ export async function discoverManagedTool(toolId) {
171
327
  // install must not become an install attempt on every eligible file/turn.
172
328
  const installAttemptsByCwd = new PathKeyedMap(normalizeMapKey);
173
329
  const resolveInstallInFlightByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
330
+ // Which (cwd, toolId) pairs already got their ONE compensating `available`
331
+ // row this session for a GENUINE correction (#1612 review F2).
332
+ // `checker.reset()` clears the checker's own cache on every install recovery,
333
+ // so the NEXT call re-probes PATH from scratch — and that probe keeps missing
334
+ // for a managed-dir-only install (createVenvFinder never searches the managed
335
+ // tools dir; tracked separately as its own bug). Without this memo, every one
336
+ // of those repeat probe failures looks like a fresh correction and re-logs
337
+ // "available", even though only the first call actually ran an install.
338
+ //
339
+ // A correction is genuine only when a latched `unavailable` row for the same
340
+ // pair stood before it. `verifyOrInstallCommand` also emits through this seam
341
+ // with NO prior latch behind it (biome-check and oxlint reach it with no
342
+ // checker probe at all), and burning this memo on such a row silences the
343
+ // LATER genuine latch-then-recover for the same pair — the #1606 defect back
344
+ // through the #1612 seam (#1657). Those rows dedupe against
345
+ // `uncorrectedEmissionsByCwd` instead, so repeats stay suppressed without
346
+ // pre-empting the real correction.
347
+ const correctedAvailabilityByCwd = new PathKeyedMap(normalizeMapKey);
348
+ // Which (cwd, toolId) pairs already emitted an `available` row that corrected
349
+ // nothing (no latched `unavailable` stood before it). Same one-row-per-session
350
+ // bound as the memo above, kept in a SEPARATE scope so it cannot answer for a
351
+ // correction that has not happened yet (#1657).
352
+ const uncorrectedEmissionsByCwd = new PathKeyedMap(normalizeMapKey);
353
+ // Which (cwd, tool) pairs currently stand latched `unavailable` in the durable
354
+ // log. Recorded where such a row is actually written, so "was there anything
355
+ // to correct" is read from the log's own history rather than assumed by the
356
+ // caller. Session-scoped: `resetDispatchAvailabilityState` clears it, so a new
357
+ // session re-arms instead of inheriting the last one's latches (#1657).
358
+ const latchedUnavailableByCwd = new PathKeyedMap(normalizeMapKey);
174
359
  // Checkers are created by runner modules and may also be created dynamically.
175
360
  // Keep the session reset as a generation rather than retaining every checker
176
361
  // reset closure forever.
177
- let availabilityStateGeneration = 0;
362
+ //
363
+ // #1754: the counter itself is now the shared `GenerationSource` primitive, so
364
+ // the capture-before-await/check-after guards below are one implementation
365
+ // instead of five hand-rolled ones, and a dropped straddling write is visible
366
+ // in the degradation ledger instead of silent.
367
+ const availabilityGeneration = createGenerationSource("dispatch-availability");
178
368
  function installStateFor(cwd, toolId) {
179
369
  let states = installAttemptsByCwd.get(cwd);
180
370
  if (!states) {
@@ -200,12 +390,213 @@ function noteInstallSuccess(toolId, cwd) {
200
390
  if (states?.size === 0)
201
391
  installAttemptsByCwd.delete(cwd);
202
392
  }
393
+ function hasId(map, cwd, id) {
394
+ return map.get(cwd)?.has(id) ?? false;
395
+ }
396
+ function addId(map, cwd, id) {
397
+ let ids = map.get(cwd);
398
+ if (!ids) {
399
+ ids = new Set();
400
+ map.set(cwd, ids);
401
+ }
402
+ ids.add(id);
403
+ }
404
+ function deleteIds(map, cwd, ids) {
405
+ const existing = map.get(cwd);
406
+ if (!existing)
407
+ return;
408
+ for (const id of ids)
409
+ existing.delete(id);
410
+ if (existing.size === 0)
411
+ map.delete(cwd);
412
+ }
413
+ /**
414
+ * True once a compensating `available` row has already fired for this
415
+ * (cwd, toolId) pair this session; `noteAvailabilityCorrected` records it the
416
+ * first time. #1612 review F2's "once per correction" — see
417
+ * `correctedAvailabilityByCwd`'s comment for why repeats need suppressing.
418
+ */
419
+ function hasCorrectedAvailability(cwd, toolId) {
420
+ return hasId(correctedAvailabilityByCwd, cwd, toolId);
421
+ }
422
+ function noteAvailabilityCorrected(cwd, toolId) {
423
+ addId(correctedAvailabilityByCwd, cwd, toolId);
424
+ }
425
+ /**
426
+ * Record that a latched `unavailable` row now stands for this pair. Called
427
+ * from each seam that writes one (the availability checker and the shared
428
+ * probe latch), so a later compensating row can tell a genuine correction
429
+ * from a no-op emission (#1657).
430
+ */
431
+ function noteLatchedUnavailable(cwd, tool) {
432
+ addId(latchedUnavailableByCwd, cwd, tool);
433
+ }
434
+ /**
435
+ * Drop the latch record for a pair, because the row it described no longer
436
+ * stands. Called on every `available` verdict as well as on the compensating
437
+ * row itself, so a latch cannot outlive the state it describes and later back
438
+ * a "correction" that corrects nothing (#1674 review F3).
439
+ */
440
+ function clearLatchedUnavailable(cwd, ...tools) {
441
+ deleteIds(latchedUnavailableByCwd, cwd, tools);
442
+ }
443
+ /**
444
+ * Every name a latched row for this pair could have been written under. A
445
+ * checker logs its COMMAND (sometimes a resolved absolute path), the install
446
+ * seam knows a toolId, and the two are not always the same string. One
447
+ * derivation feeds BOTH the lookup and the clear, so the two can never
448
+ * disagree about which key the latch lives under (#1674 review F3).
449
+ */
450
+ function latchKeysFor(toolId, command) {
451
+ const keys = [toolId];
452
+ if (command)
453
+ keys.push(command, path.basename(command));
454
+ return keys;
455
+ }
456
+ /**
457
+ * True when a latched `unavailable` row stands for any of these keys. A false
458
+ * positive only restores the pre-#1657 behavior (the row emits and burns the
459
+ * correction memo); a false negative still emits the row, under the
460
+ * uncorrected scope. Neither can swallow a genuine correction.
461
+ */
462
+ function hasLatchedUnavailable(cwd, tools) {
463
+ return tools.some((tool) => hasId(latchedUnavailableByCwd, cwd, tool));
464
+ }
465
+ /**
466
+ * Which installer family resolved a tool, for the compensating row's
467
+ * `evidence.source` (#1612 review F1). Reads the registry's own
468
+ * `installStrategy` rather than hand-mapping per toolId — the two cannot
469
+ * drift apart because there is only one list.
470
+ */
471
+ function sourceTagForToolId(toolId) {
472
+ switch (getToolInstallStrategy(toolId)) {
473
+ case "npm":
474
+ return "managed-dir";
475
+ case "pip":
476
+ return "pip-user";
477
+ case "github":
478
+ return "github-release";
479
+ case "archive":
480
+ return "archive-dist";
481
+ case "maven":
482
+ return "maven-jar";
483
+ default:
484
+ // "gem" has no CLI runner behind this seam yet, and an unknown toolId
485
+ // means the registry lookup itself missed — either way, no tag beats
486
+ // a guessed one.
487
+ return undefined;
488
+ }
489
+ }
490
+ /**
491
+ * How to tag a not-fresh install resolution in the compensating row's
492
+ * evidence (#1636 review, carried over from #1615's verify). `getInstallAttempt`
493
+ * answers `undefined` for BOTH a genuine cache hit and a plain PATH/managed-dir
494
+ * discovery, and answers `declined`/`skipped` for a policy refusal that still
495
+ * hands back whatever discovery found — three different facts a single
496
+ * `"cache"` tag used to flatten into one. `declined`/`skipped` take priority
497
+ * over the source map: a project-trust decline overwrites whatever the
498
+ * discovery pass underneath it found (see `ensureTool`'s trust-gate branch in
499
+ * installer/index.ts), so by the time this reads it, "declined" is the honest
500
+ * answer regardless of how the binary was actually found.
501
+ */
502
+ function resolvedTagForAttempt(toolId, attempt) {
503
+ if (attempt?.outcome === "declined" || attempt?.outcome === "skipped") {
504
+ return "declined";
505
+ }
506
+ return getLastEnsureResolutionSource(toolId) === "path" ? "path" : "cache";
507
+ }
508
+ /**
509
+ * Emit the ONE compensating `available` row for a (cwd, toolId) correction,
510
+ * after a latched unavailable probe recovers via `ensureTool`. This is the
511
+ * SINGLE row constructor for every caller behind this seam —
512
+ * `resolveAvailableOrInstallUnshared` (#1612) and `verifyOrInstallCommand`
513
+ * (#1636) both call it rather than each hand-rolling their own, which is
514
+ * exactly the drift #1610's own review caught the first time this shape
515
+ * shipped twice.
516
+ *
517
+ * Gated on the once-per-correction memo (`correctedAvailabilityByCwd`,
518
+ * #1612 review F2): `checker.reset()` / a cleared probe cache means the NEXT
519
+ * call for the same (cwd, toolId) can resolve the same tool again with no
520
+ * fresh attempt behind it, and that must not re-log a "fresh correction"
521
+ * every time.
522
+ *
523
+ * `latchKeys` are every name a latched `unavailable` row for this pair could
524
+ * carry. A row that clears one of them is a genuine correction and burns the
525
+ * correction memo; a row with nothing to correct dedupes in its own scope, so
526
+ * it cannot silence the genuine latch-then-recover that follows it for the
527
+ * same pair (#1657).
528
+ */
529
+ function emitCompensatingAvailableRow(cwd, toolId, installStartedAt, installedPath, latchKeys) {
530
+ if (hasCorrectedAvailability(cwd, toolId))
531
+ return;
532
+ const correctsLatchedRow = hasLatchedUnavailable(cwd, latchKeys);
533
+ if (correctsLatchedRow) {
534
+ noteAvailabilityCorrected(cwd, toolId);
535
+ // Clear every key the latch could live under, not just the toolId: the
536
+ // checker records the row under its COMMAND, and a leftover entry would
537
+ // later back a "correction" with nothing behind it (#1674 review F3).
538
+ clearLatchedUnavailable(cwd, ...latchKeys);
539
+ }
540
+ else {
541
+ if (hasId(uncorrectedEmissionsByCwd, cwd, toolId))
542
+ return;
543
+ addId(uncorrectedEmissionsByCwd, cwd, toolId);
544
+ }
545
+ // `installed`/`installedPath` can come back truthy WITHOUT ensureTool
546
+ // running an install this call — its in-memory cache, its on-disk probe
547
+ // cache, getToolPath finding it already present, or a concurrent peer's
548
+ // install all return a path with no fresh attempt recorded. Deriving from
549
+ // `getInstallAttempt` (rather than asserting "succeeded" because the path
550
+ // is truthy, or hand-rolling a second mapping) is what #1610's OWN
551
+ // failure-path mirror bug was caught on review; read the REAL outcome
552
+ // through the one function that already knows how, don't assume it. Hard
553
+ // import, no `typeof` guard (#1636 review): a genuinely removed export
554
+ // must throw here, not silently read as "not attempted".
555
+ const attempt = getInstallAttempt(toolId);
556
+ const installEvidence = describeInstallAttempt(attempt);
557
+ const evidence = {
558
+ ...installEvidence,
559
+ binary: path.basename(installedPath),
560
+ source: sourceTagForToolId(toolId),
561
+ // Set alongside any non-"succeeded" outcome: the path is truthy but
562
+ // nothing ran this call, so it came from an already-known-good answer
563
+ // rather than a fresh attempt (#1612 review F2).
564
+ ...(installEvidence.install !== "succeeded" && {
565
+ resolved: resolvedTagForAttempt(toolId, attempt),
566
+ }),
567
+ // The whole point of the memo split, on the record: a reader can tell a
568
+ // row that cleared a latched `unavailable` from one that corrected
569
+ // nothing, without re-deriving it from the surrounding rows (#1674
570
+ // review F4).
571
+ correctsLatchedRow,
572
+ };
573
+ logAvailabilityDecision({
574
+ tool: toolId,
575
+ verdict: "available",
576
+ outcome: "success",
577
+ cause: "ok",
578
+ elapsedMs: Date.now() - installStartedAt,
579
+ // `checker.reset()`/a fresh probe just cleared whatever cache stood
580
+ // behind this call, so nothing is actually held pinned the instant
581
+ // this row is written — the very next probe starts fresh
582
+ // (#1612 review F3). Writing `true` here would claim a durability
583
+ // this call doesn't have.
584
+ latched: false,
585
+ classifiedBy: "caller",
586
+ evidence,
587
+ }, cwd);
588
+ }
203
589
  /** Reset availability/install suppression at the session boundary. */
204
590
  export function resetDispatchAvailabilityState() {
205
591
  installAttemptsByCwd.clear();
206
592
  resolveInstallInFlightByCwd.clear();
593
+ correctedAvailabilityByCwd.clear();
594
+ uncorrectedEmissionsByCwd.clear();
595
+ latchedUnavailableByCwd.clear();
596
+ managedBinaryVerdicts.clear();
597
+ managedVerifyInFlight.clear();
207
598
  resetPathWalkMemo();
208
- availabilityStateGeneration += 1;
599
+ availabilityGeneration.bump();
209
600
  }
210
601
  /**
211
602
  * Create a cached availability checker for a command.
@@ -227,19 +618,19 @@ export function resetDispatchAvailabilityState() {
227
618
  export function createAvailabilityChecker(command, windowsExt = "", versionArgs = ["--version"], options = {}) {
228
619
  const cacheByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
229
620
  const inFlightByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
230
- let checkerGeneration = availabilityStateGeneration;
231
- const findCommand = createVenvFinder(command, windowsExt, true);
621
+ let checkerGeneration = availabilityGeneration.current();
622
+ const findCommand = createVenvFinder(command, windowsExt);
232
623
  function ensureCurrentGeneration() {
233
- if (checkerGeneration === availabilityStateGeneration)
624
+ if (checkerGeneration === availabilityGeneration.current())
234
625
  return;
235
626
  cacheByCwd.clear();
236
627
  inFlightByCwd.clear();
237
- checkerGeneration = availabilityStateGeneration;
628
+ checkerGeneration = availabilityGeneration.current();
238
629
  }
239
630
  const reset = () => {
240
631
  cacheByCwd.clear();
241
632
  inFlightByCwd.clear();
242
- checkerGeneration = availabilityStateGeneration;
633
+ checkerGeneration = availabilityGeneration.current();
243
634
  };
244
635
  function getCache(cwd) {
245
636
  ensureCurrentGeneration();
@@ -279,13 +670,31 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
279
670
  retryAfterMs = transientRetryDelayMs(cache.transientAttempts, verdict.cause);
280
671
  cache.retryAtMs = Date.now() + retryAfterMs;
281
672
  }
673
+ const latched = verdict.available || isLatchingOutcome(verdict.outcome);
674
+ // Remember the pair whose durable row now says "unavailable and staying
675
+ // that way" — that is the row a later compensating `available` row
676
+ // corrects, and the only thing that makes such a row a correction
677
+ // (#1657).
678
+ if (!verdict.available && latched) {
679
+ noteLatchedUnavailable(resolvedCwd, command);
680
+ }
681
+ else if (verdict.available) {
682
+ // The latched row this pair carried no longer stands: THIS row says
683
+ // the tool is back. Dropping the record here keeps a stale latch from
684
+ // backing a later "correction" that corrects nothing (#1674 F3).
685
+ clearLatchedUnavailable(resolvedCwd, ...latchKeysFor(command));
686
+ }
282
687
  logAvailabilityDecision({
283
688
  tool: command,
284
689
  verdict: verdict.available ? "available" : "unavailable",
285
690
  outcome: verdict.outcome,
286
691
  cause: verdict.cause,
287
692
  elapsedMs: verdict.elapsedMs,
288
- latched: verdict.available || isLatchingOutcome(verdict.outcome),
693
+ latched,
694
+ ...(verdict.classifiedBy !== undefined && {
695
+ classifiedBy: verdict.classifiedBy,
696
+ }),
697
+ ...(verdict.evidence !== undefined && { evidence: verdict.evidence }),
289
698
  ...(verdict.hostStallMs !== undefined && {
290
699
  hostStallMs: verdict.hostStallMs,
291
700
  }),
@@ -325,7 +734,7 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
325
734
  const promiseGeneration = checkerGeneration;
326
735
  let promise;
327
736
  promise = (async () => {
328
- const fastPath = options.fastPath?.();
737
+ const fastPath = await options.fastPath?.();
329
738
  if (fastPath) {
330
739
  cache.command = fastPath;
331
740
  noteDecision(cache, resolvedCwd, {
@@ -333,6 +742,9 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
333
742
  outcome: "success",
334
743
  cause: "fast-path",
335
744
  elapsedMs: 0,
745
+ // Caller-asserted, and justifiably: an on-disk shim IS the tool, so
746
+ // there is no spawn to derive anything from (#1500).
747
+ classifiedBy: "caller",
336
748
  });
337
749
  return true;
338
750
  }
@@ -347,6 +759,9 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
347
759
  outcome: "non-installable",
348
760
  cause: "bad-cwd",
349
761
  elapsedMs: 0,
762
+ // The workspace is gone. Asserted from a stat, not a probe, and it
763
+ // must never read as "the tool is missing" (#1500).
764
+ classifiedBy: "caller",
350
765
  });
351
766
  return false;
352
767
  }
@@ -357,10 +772,11 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
357
772
  outcome: "non-installable",
358
773
  cause: "bad-cwd",
359
774
  elapsedMs: 0,
775
+ classifiedBy: "caller",
360
776
  });
361
777
  return false;
362
778
  }
363
- const cmd = findCommand(resolvedCwd);
779
+ const cmd = await findCommand(resolvedCwd);
364
780
  const env = await options.environment?.(resolvedCwd);
365
781
  // The probe budget is enforced by a HOST-side timer, so host event-loop
366
782
  // stalls are charged to the child. Measure the stall that overlapped the
@@ -388,10 +804,12 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
388
804
  cause: "ok",
389
805
  elapsedMs,
390
806
  hostStallMs,
807
+ classifiedBy: "probe",
808
+ evidence: describeProbeEvidence(result),
391
809
  });
392
810
  return true;
393
811
  }
394
- const { outcome, cause } = classifyProbeFailure(result, {
812
+ const { outcome, cause, evidence } = classifyProbeFailure(result, {
395
813
  hostStallMs,
396
814
  unclassifiedFailureOutcome: options.unclassifiedFailureOutcome,
397
815
  });
@@ -406,6 +824,8 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
406
824
  cause,
407
825
  elapsedMs,
408
826
  hostStallMs,
827
+ classifiedBy: "probe",
828
+ evidence,
409
829
  });
410
830
  return false;
411
831
  })().finally(() => {
@@ -444,32 +864,173 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
444
864
  }
445
865
  /**
446
866
  * Per-cwd cached availability probe for spawn signatures that don't fit
447
- * `createAvailabilityChecker` — multi-arg subcommands like `npx biome
448
- * --version`, `cargo clippy --version`, `mix credo --version`, or a
449
- * dynamically-resolved `<cmd> --version`. Each cwd is probed at most once;
450
- * concurrent first-time callers share the in-flight promise.
867
+ * `createAvailabilityChecker` — multi-arg subcommands like `cargo clippy
868
+ * --version` and `mix credo --version`, or a dynamically-resolved
869
+ * `<cmd> --version` such as eslint's local-then-global binary. Each cwd is
870
+ * probed at most once per verdict; concurrent first-time callers share the
871
+ * in-flight promise.
872
+ *
873
+ * Those three ARE the consumers. `npx biome --version` used to be named here as
874
+ * one and never was: biome resolves through `resolveManagedToolClient` and its
875
+ * own latch in `biome-client.ts`.
451
876
  *
452
- * The cache stores the boolean outcome forever (same shape as
453
- * `createAvailabilityChecker`): once known unavailable for a cwd, the
454
- * runner stays skipped for that cwd until the process restarts. Pass a
455
- * fresh probe for retry-after-install flows.
877
+ * ## Latch policy (#1494)
878
+ *
879
+ * This used to cache the boolean forever, so one stalled `eslint --version`
880
+ * disabled eslint for that cwd until the process restarted. The verdict now
881
+ * goes through `availability-policy.ts`: the probe returns the SPAWN RESULT,
882
+ * the policy classifies it, and only a durable outcome (`missing` /
883
+ * `non-installable`) latches. A timeout, abort or host stall expires on a
884
+ * bounded cooldown and the next caller re-probes.
456
885
  */
457
- export function createCwdCachedProbe(probe) {
458
- const cacheByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
459
- let probeGeneration = availabilityStateGeneration;
460
- return (cwd) => {
461
- if (probeGeneration !== availabilityStateGeneration) {
462
- cacheByCwd.clear();
463
- probeGeneration = availabilityStateGeneration;
886
+ export function createCwdCachedProbe(probe, options) {
887
+ const latchByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
888
+ const inFlightByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
889
+ let probeGeneration = availabilityGeneration.current();
890
+ function clear() {
891
+ latchByCwd.clear();
892
+ inFlightByCwd.clear();
893
+ }
894
+ function ensureCurrentGeneration() {
895
+ if (probeGeneration === availabilityGeneration.current())
896
+ return;
897
+ clear();
898
+ probeGeneration = availabilityGeneration.current();
899
+ }
900
+ function latchFor(key) {
901
+ const existing = latchByCwd.get(key);
902
+ if (existing)
903
+ return existing;
904
+ const created = createAvailabilityLatch();
905
+ latchByCwd.set(key, created);
906
+ return created;
907
+ }
908
+ function note(latch, key, verdict) {
909
+ let retryAfterMs;
910
+ if (verdict.available) {
911
+ latch.noteAvailable(verdict.cause);
912
+ }
913
+ else {
914
+ const delay = latch.noteUnavailable(verdict.outcome, verdict.cause);
915
+ if (delay > 0)
916
+ retryAfterMs = delay;
917
+ }
918
+ const latched = verdict.available || isLatchingOutcome(verdict.outcome);
919
+ // Same correction bookkeeping as the checker seam above (#1657).
920
+ if (!verdict.available && latched) {
921
+ noteLatchedUnavailable(key, options.tool);
464
922
  }
923
+ else if (verdict.available) {
924
+ clearLatchedUnavailable(key, ...latchKeysFor(options.tool));
925
+ }
926
+ logAvailabilityDecision({
927
+ tool: options.tool,
928
+ verdict: verdict.available ? "available" : "unavailable",
929
+ outcome: verdict.outcome,
930
+ cause: verdict.cause,
931
+ elapsedMs: verdict.elapsedMs,
932
+ latched,
933
+ hostStallMs: verdict.hostStallMs,
934
+ ...(retryAfterMs !== undefined && { retryAfterMs }),
935
+ ...(options.budgetMs !== undefined && { budgetMs: options.budgetMs }),
936
+ // Every verdict here is derived from the probe this seam just ran.
937
+ classifiedBy: "probe",
938
+ ...(verdict.evidence !== undefined && { evidence: verdict.evidence }),
939
+ }, key);
940
+ }
941
+ const run = (cwd) => {
942
+ ensureCurrentGeneration();
465
943
  const key = path.resolve(cwd || process.cwd());
466
- const existing = cacheByCwd.get(key);
944
+ const latch = latchFor(key);
945
+ const memo = latch.read();
946
+ if (memo !== null)
947
+ return Promise.resolve(memo);
948
+ const existing = inFlightByCwd.get(key);
467
949
  if (existing)
468
950
  return existing;
469
- const promise = probe(key).catch(() => false);
470
- cacheByCwd.set(key, promise);
951
+ const promiseGeneration = probeGeneration;
952
+ let promise;
953
+ promise = (async () => {
954
+ // The probe budget is enforced by a HOST-side timer, so a stalled event
955
+ // loop expires it while the child is still healthy. Measure the stall
956
+ // that overlapped the window and hand it to the classifier (#1467).
957
+ const stallSampler = startHostStallSampler();
958
+ const startedAt = Date.now();
959
+ let result;
960
+ let thrown;
961
+ try {
962
+ result = await probe(key);
963
+ }
964
+ catch (error) {
965
+ thrown = error;
966
+ }
967
+ const hostStallMs = stallSampler.stop();
968
+ const elapsedMs = Date.now() - startedAt;
969
+ // A probe that threw carries its errno in the Error, which is exactly
970
+ // what the taxonomy reads — so a thrown EAGAIN stays transient instead
971
+ // of collapsing into an untyped `false`.
972
+ const shape = result ?? {
973
+ error: thrown instanceof Error ? thrown : new Error(String(thrown)),
974
+ };
975
+ if (result && !result.error && result.status === 0) {
976
+ note(latch, key, {
977
+ available: true,
978
+ outcome: "success",
979
+ cause: "ok",
980
+ elapsedMs,
981
+ hostStallMs,
982
+ evidence: describeProbeEvidence(result, options.tool),
983
+ });
984
+ return true;
985
+ }
986
+ const { outcome, cause, evidence } = classifyProbeFailure(shape, {
987
+ hostStallMs,
988
+ command: options.tool,
989
+ unclassifiedFailureOutcome: options.unclassifiedFailureOutcome,
990
+ });
991
+ note(latch, key, {
992
+ available: false,
993
+ outcome,
994
+ cause,
995
+ elapsedMs,
996
+ hostStallMs,
997
+ evidence,
998
+ });
999
+ return false;
1000
+ })().finally(() => {
1001
+ // A session reset clears the map and a caller may immediately start a
1002
+ // replacement probe for the same cwd; the old promise must not delete
1003
+ // that newer-generation entry when it settles.
1004
+ if (probeGeneration === promiseGeneration &&
1005
+ inFlightByCwd.get(key) === promise) {
1006
+ inFlightByCwd.delete(key);
1007
+ }
1008
+ });
1009
+ inFlightByCwd.set(key, promise);
471
1010
  return promise;
472
1011
  };
1012
+ const cached = run;
1013
+ cached.getVerdict = (cwd) => {
1014
+ ensureCurrentGeneration();
1015
+ const latch = latchFor(path.resolve(cwd || process.cwd()));
1016
+ const outcome = latch.getOutcome();
1017
+ return {
1018
+ outcome,
1019
+ cause: latch.getCause(),
1020
+ elapsedMs: 0,
1021
+ // Read the OUTCOME, never `read()`: `read()` answers `null` both for a
1022
+ // cwd that was never probed and for a transient verdict whose cooldown
1023
+ // expired, and `!== false` turned both of those into `latched: true` —
1024
+ // the opposite of the truth in the expired case.
1025
+ latched: outcome !== null && isLatchingOutcome(outcome),
1026
+ retryAtMs: latch.getRetryAtMs(),
1027
+ };
1028
+ };
1029
+ cached.reset = () => {
1030
+ clear();
1031
+ probeGeneration = availabilityGeneration.current();
1032
+ };
1033
+ return cached;
473
1034
  }
474
1035
  export function resolveNodeToolCommand(cwd, toolName, windowsExt = ".cmd") {
475
1036
  const isWin = process.platform === "win32";
@@ -537,9 +1098,20 @@ async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--ve
537
1098
  const state = installStateFor(cwd, toolId);
538
1099
  if (state.suppressed)
539
1100
  return null;
1101
+ const installStartedAt = Date.now();
540
1102
  const installed = await ensureTool(toolId);
541
1103
  if (installed) {
542
1104
  noteInstallSuccess(toolId, cwd);
1105
+ // A latched `unavailable` row MAY stand here, written by whatever probe
1106
+ // the caller ran first — but not always. The on-disk pre-check above
1107
+ // logs nothing, and two runners reach this seam with no checker probe at
1108
+ // all (biome-check and oxlint call
1109
+ // `resolveToolCommandWithInstallFallback` directly, #1657). So the row is
1110
+ // emitted either way, and only a call that really clears a latched row is
1111
+ // recorded as a correction. Without the row, the durable log keeps saying
1112
+ // the tool is off after the installer just brought it back — the third
1113
+ // seam of #1606/#1610/#1612 (#1636).
1114
+ emitCompensatingAvailableRow(cwd, toolId, installStartedAt, installed, latchKeysFor(toolId, command));
543
1115
  return installed;
544
1116
  }
545
1117
  noteInstallFailure(toolId, cwd);
@@ -578,10 +1150,36 @@ async function resolveAvailableOrInstallUnshared(checker, toolId, cwd) {
578
1150
  if (state.suppressed) {
579
1151
  return null;
580
1152
  }
1153
+ const installStartedAt = Date.now();
581
1154
  const installed = await ensureTool(toolId);
582
1155
  if (installed) {
583
1156
  noteInstallSuccess(toolId, cwd);
584
1157
  checker.reset?.();
1158
+ // The PATH probe above already wrote a latched `unavailable` row via
1159
+ // `checker.isAvailableAsync`. Without a compensating row here, the durable
1160
+ // log keeps saying the tool is off after the installer just brought it
1161
+ // back — the shape #1606/PR #1610 fixed in `ensureViaInstaller`, shared
1162
+ // here by ~16 runners behind this helper (#1612), and by
1163
+ // `verifyOrInstallCommand` for the third seam (#1636).
1164
+ //
1165
+ // `checker.reset()` above clears the checker's cache, so the NEXT call
1166
+ // re-probes PATH from scratch — and for a managed-dir-only install that
1167
+ // probe misses again unless `createVenvFinder` finds it first (#1638).
1168
+ // Left unguarded, every one of those repeat misses would re-fire this
1169
+ // row, claiming a fresh install each time — `emitCompensatingAvailableRow`
1170
+ // gates on the once-per-correction memo to prevent that.
1171
+ //
1172
+ // The probe above returned the latching `missing` outcome, so a latched
1173
+ // `unavailable` row stands for this pair right now — recorded under the
1174
+ // checker's own command name. Record it under the toolId too, so the
1175
+ // emitter can READ that a correction is happening from the same ledger
1176
+ // every other caller reads, instead of being told so by an argument
1177
+ // (#1674 review F3).
1178
+ noteLatchedUnavailable(cwd, toolId);
1179
+ // This IS a genuine correction: the probe above returned the latching
1180
+ // `missing` outcome, so a latched `unavailable` row stands for this pair
1181
+ // right now (#1657).
1182
+ emitCompensatingAvailableRow(cwd, toolId, installStartedAt, installed, latchKeysFor(toolId, checker.getCommand(cwd) ?? undefined));
585
1183
  return installed;
586
1184
  }
587
1185
  noteInstallFailure(toolId, cwd);
@@ -598,16 +1196,19 @@ export function resolveAvailableOrInstall(checker, toolId, cwd) {
598
1196
  const existing = byTool.get(toolId);
599
1197
  if (existing)
600
1198
  return existing;
601
- const generation = availabilityStateGeneration;
1199
+ // Same capture-before-await guard as the managed-verify seam, via the same
1200
+ // primitive (#1754): a settling old-session transaction must not evict the
1201
+ // entry a NEW session already installed for this (cwd, tool).
1202
+ const generation = availabilityGeneration.capture();
602
1203
  const promise = resolveAvailableOrInstallUnshared(checker, toolId, cwd).finally(() => {
603
- if (generation !== availabilityStateGeneration)
604
- return;
605
- const current = resolveInstallInFlightByCwd.get(key);
606
- if (current?.get(toolId) === promise) {
607
- current.delete(toolId);
608
- if (current.size === 0)
609
- resolveInstallInFlightByCwd.delete(key);
610
- }
1204
+ generation.guardedWrite(`${toolId}@${key}`, () => {
1205
+ const current = resolveInstallInFlightByCwd.get(key);
1206
+ if (current?.get(toolId) === promise) {
1207
+ current.delete(toolId);
1208
+ if (current.size === 0)
1209
+ resolveInstallInFlightByCwd.delete(key);
1210
+ }
1211
+ });
611
1212
  });
612
1213
  byTool.set(toolId, promise);
613
1214
  return promise;
@@ -628,6 +1229,26 @@ let sgCmdArgs = [];
628
1229
  let sgSweepSawTransient = false;
629
1230
  let sgSweepTransientCause = "probe-timeout";
630
1231
  let sgSweepHostStallMs = 0;
1232
+ /**
1233
+ * Candidates that were UNREACHABLE, in ask order (#1568).
1234
+ *
1235
+ * The sweep stops at the first candidate that answers, so at the moment of a
1236
+ * win this list is exactly the set of candidates ahead of the winner that never
1237
+ * got a fair hearing — i.e. the preferred tiers the winner did not really beat.
1238
+ *
1239
+ * Basenames, because tier 1 is an absolute `node_modules/.bin` path and this
1240
+ * list is written to latency.log (#1568 review F3).
1241
+ */
1242
+ let sgSweepUnreachable = [];
1243
+ /**
1244
+ * Candidates that were probed and answered DURABLY missing this sweep — a
1245
+ * real ENOENT/non-installable verdict, not a stall (#1593). The retained-arm
1246
+ * fallback below only knows the sweep saw SOME transient candidate; without
1247
+ * this list it cannot tell "the memoized winner itself just proved absent"
1248
+ * from "an unrelated sibling merely stalled", and re-serves a command this
1249
+ * very sweep disproved.
1250
+ */
1251
+ let sgSweepDurablyMissing = [];
631
1252
  function isAstGrepVersionOutput(output) {
632
1253
  return /\bast[- ]grep\b/i.test(output);
633
1254
  }
@@ -653,6 +1274,12 @@ async function probeAstGrepCommandAsync(cmd, argsPrefix = []) {
653
1274
  if (outcome === "transient") {
654
1275
  sgSweepSawTransient = true;
655
1276
  sgSweepTransientCause = cause;
1277
+ const name = path.basename(cmd);
1278
+ if (!sgSweepUnreachable.includes(name))
1279
+ sgSweepUnreachable.push(name);
1280
+ }
1281
+ else if (!sgSweepDurablyMissing.includes(cmd)) {
1282
+ sgSweepDurablyMissing.push(cmd);
656
1283
  }
657
1284
  return false;
658
1285
  }
@@ -684,15 +1311,15 @@ function buildSgLocalBins() {
684
1311
  return bins;
685
1312
  }
686
1313
  let sgAvailableInFlight = null;
687
- let sgAvailabilityGeneration = availabilityStateGeneration;
1314
+ let sgAvailabilityGeneration = availabilityGeneration.current();
688
1315
  function ensureCurrentSgGeneration() {
689
- if (sgAvailabilityGeneration === availabilityStateGeneration)
1316
+ if (sgAvailabilityGeneration === availabilityGeneration.current())
690
1317
  return;
691
1318
  sgLatch.reset();
692
1319
  sgCmd = null;
693
1320
  sgCmdArgs = [];
694
1321
  sgAvailableInFlight = null;
695
- sgAvailabilityGeneration = availabilityStateGeneration;
1322
+ sgAvailabilityGeneration = availabilityGeneration.current();
696
1323
  }
697
1324
  export async function isSgAvailableAsync() {
698
1325
  ensureCurrentSgGeneration();
@@ -708,6 +1335,8 @@ export async function isSgAvailableAsync() {
708
1335
  sgSweepSawTransient = false;
709
1336
  sgSweepTransientCause = "probe-timeout";
710
1337
  sgSweepHostStallMs = 0;
1338
+ sgSweepUnreachable = [];
1339
+ sgSweepDurablyMissing = [];
711
1340
  // 1. Local node_modules/.bin
712
1341
  for (const localBin of buildSgLocalBins()) {
713
1342
  if (await probeAstGrepCommandAsync(localBin)) {
@@ -744,6 +1373,21 @@ export async function isSgAvailableAsync() {
744
1373
  noteSgAvailable(startedAt);
745
1374
  return true;
746
1375
  }
1376
+ // Nothing answered, and nothing answered TRANSIENTLY, while the verdict we
1377
+ // are about to overwrite is a provisional win whose command we still hold.
1378
+ // #1476's principle applies to the result as much as to the probe: a
1379
+ // timeout says nothing about the tool, so it cannot erase a command this
1380
+ // process proved working one cooldown ago. Keep serving it and re-arm
1381
+ // (#1568 review F1) — UNLESS this very sweep just proved the memoized
1382
+ // command durably missing (#1593): a sibling tier stalling is not license
1383
+ // to re-serve a winner that ENOENTed a moment ago in the same pass.
1384
+ if (sgSweepSawTransient &&
1385
+ sgLatch.isProvisional() &&
1386
+ sgCmd !== null &&
1387
+ !sgSweepDurablyMissing.includes(sgCmd)) {
1388
+ noteSgAvailable(startedAt, { retained: true });
1389
+ return true;
1390
+ }
747
1391
  // A timeout on ANY candidate is evidence about the host, not the tool.
748
1392
  noteSgUnavailable(startedAt, sgSweepSawTransient ? "transient" : "missing", sgSweepSawTransient ? sgSweepTransientCause : "not-found");
749
1393
  return false;
@@ -752,18 +1396,44 @@ export async function isSgAvailableAsync() {
752
1396
  });
753
1397
  return sgAvailableInFlight;
754
1398
  }
755
- /** Record a successful shared-ast-grep sweep, with one decision record. */
756
- function noteSgAvailable(startedAt) {
757
- sgLatch.noteAvailable();
1399
+ /**
1400
+ * Record a successful shared-ast-grep sweep, with one decision record.
1401
+ *
1402
+ * A win reached while an EARLIER candidate was unreachable is provisional
1403
+ * (#1568). The sweep stops at the first candidate that answers, so
1404
+ * `sgSweepSawTransient` at this point means precisely "a tier this one is
1405
+ * supposed to lose to never got a fair hearing" — the winner is used now, but
1406
+ * caching it for the session would pin a healthy PATH ast-grep behind `npx`
1407
+ * until the next restart.
1408
+ *
1409
+ * `retained` marks the other provisional case (#1568 review F1): no candidate
1410
+ * answered at all, so the winner being reported is the one the previous sweep
1411
+ * found, kept rather than discarded on a timeout.
1412
+ */
1413
+ function noteSgAvailable(startedAt, opts = {}) {
1414
+ const provisional = sgSweepSawTransient;
1415
+ let retryAfterMs = 0;
1416
+ if (provisional) {
1417
+ retryAfterMs = sgLatch.noteProvisionallyAvailable(sgSweepTransientCause);
1418
+ }
1419
+ else {
1420
+ sgLatch.noteAvailable();
1421
+ }
758
1422
  logAvailabilityDecision({
759
1423
  tool: "ast-grep",
760
1424
  verdict: "available",
761
1425
  outcome: "success",
762
- cause: "ok",
1426
+ cause: provisional ? sgSweepTransientCause : "ok",
763
1427
  elapsedMs: Date.now() - startedAt,
764
- latched: true,
1428
+ latched: !provisional,
765
1429
  hostStallMs: sgSweepHostStallMs,
766
1430
  budgetMs: 5000,
1431
+ ...(provisional && {
1432
+ provisional: true,
1433
+ unreachablePreferred: [...sgSweepUnreachable],
1434
+ ...(opts.retained === true && { retained: true }),
1435
+ ...(retryAfterMs > 0 && { retryAfterMs }),
1436
+ }),
767
1437
  });
768
1438
  }
769
1439
  /** Record a failed shared-ast-grep sweep; a transient verdict expires. */