@herbertgao/pi-extensions 2026.8.5 → 2026.8.7

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 (389) hide show
  1. package/README.md +5 -5
  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 +272 -22
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +94 -77
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +48 -6
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  15. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  16. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  17. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  18. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +16 -0
  19. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  20. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  21. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  24. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +22 -15
  25. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  26. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  27. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  28. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  29. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  30. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  32. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  33. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +34 -10
  34. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  35. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +29 -5
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +3 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +46 -10
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +8 -1
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -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 +3 -1
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  49. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
  50. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
  51. package/node_modules/@narumitw/pi-btw/README.md +119 -103
  52. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  53. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  54. package/node_modules/@narumitw/pi-btw/package.json +13 -9
  55. package/node_modules/@narumitw/pi-btw/src/btw.ts +40 -42
  56. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  57. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  58. package/node_modules/@narumitw/pi-btw/src/menu.ts +78 -15
  59. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  60. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  61. package/node_modules/pi-lens/CHANGELOG.md +1004 -0
  62. package/node_modules/pi-lens/README.md +11 -3
  63. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  64. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  65. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +427 -0
  66. package/node_modules/pi-lens/dist/clients/agent-nudge.js +79 -16
  67. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  68. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  69. package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
  70. package/node_modules/pi-lens/dist/clients/biome-client.js +176 -37
  71. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  72. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
  73. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  74. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  75. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
  76. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  77. package/node_modules/pi-lens/dist/clients/bus-publish.js +11 -3
  78. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  79. package/node_modules/pi-lens/dist/clients/cascade-format.js +76 -2
  80. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  81. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  82. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  83. package/node_modules/pi-lens/dist/clients/console-guard-install.js +16 -4
  84. package/node_modules/pi-lens/dist/clients/dead-code-client.js +249 -52
  85. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  86. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  87. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
  88. package/node_modules/pi-lens/dist/clients/dependency-checker.js +43 -9
  89. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +153 -37
  90. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  91. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +10 -3
  92. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +165 -11
  93. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +325 -72
  94. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  95. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +81 -41
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +488 -0
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +84 -0
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  122. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +943 -93
  123. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  124. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
  125. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +106 -0
  126. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
  127. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
  128. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  129. package/node_modules/pi-lens/dist/clients/disposition-publish.js +10 -3
  130. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  131. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  132. package/node_modules/pi-lens/dist/clients/extension-log.js +317 -3
  133. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  134. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
  135. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  136. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  137. package/node_modules/pi-lens/dist/clients/format-events-publish.js +39 -8
  138. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  139. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  140. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  141. package/node_modules/pi-lens/dist/clients/git-guard.js +49 -21
  142. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  143. package/node_modules/pi-lens/dist/clients/go-client.js +21 -39
  144. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +219 -10
  145. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  146. package/node_modules/pi-lens/dist/clients/host-ports.js +9 -1
  147. package/node_modules/pi-lens/dist/clients/installer/index.js +1119 -101
  148. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  149. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  150. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  151. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  152. package/node_modules/pi-lens/dist/clients/jscpd-client.js +86 -15
  153. package/node_modules/pi-lens/dist/clients/knip-client.js +361 -24
  154. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  155. package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
  156. package/node_modules/pi-lens/dist/clients/latency-logger.js +487 -9
  157. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  158. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  159. package/node_modules/pi-lens/dist/clients/lens-events.js +56 -25
  160. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  161. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +45 -2
  162. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  163. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +160 -20
  164. package/node_modules/pi-lens/dist/clients/lsp/client.js +1666 -148
  165. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +111 -0
  166. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  167. package/node_modules/pi-lens/dist/clients/lsp/index.js +2496 -171
  168. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  169. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  170. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  171. package/node_modules/pi-lens/dist/clients/lsp/server.js +517 -69
  172. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  173. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  174. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +172 -0
  175. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  176. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +61 -3
  177. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  178. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  179. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  180. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  181. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +4 -0
  182. package/node_modules/pi-lens/dist/clients/mcp/session.js +39 -18
  183. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  184. package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
  185. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  186. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  187. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  188. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  189. package/node_modules/pi-lens/dist/clients/pipeline.js +178 -30
  190. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  191. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  192. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  193. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +85 -15
  194. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  195. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  196. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  197. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
  198. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  199. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  200. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  201. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  202. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
  203. package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
  204. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  205. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  206. package/node_modules/pi-lens/dist/clients/review-graph/query.js +76 -4
  207. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  208. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +248 -13
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +112 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +472 -61
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +359 -13
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +269 -22
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +948 -87
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +21 -37
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  217. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  218. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  219. package/node_modules/pi-lens/dist/clients/security-scan-client.js +224 -8
  220. package/node_modules/pi-lens/dist/clients/sg-runner.js +249 -37
  221. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  222. package/node_modules/pi-lens/dist/clients/smells-rollup.js +18 -11
  223. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  224. package/node_modules/pi-lens/dist/clients/startup-timing.js +26 -2
  225. package/node_modules/pi-lens/dist/clients/test-runner-client.js +574 -32
  226. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  227. package/node_modules/pi-lens/dist/clients/tool-policy.js +127 -20
  228. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  229. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  230. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  233. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  234. package/node_modules/pi-lens/dist/clients/warm-attach.js +17 -0
  235. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  236. package/node_modules/pi-lens/dist/clients/word-index.js +305 -33
  237. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  238. package/node_modules/pi-lens/dist/index.js +64677 -49484
  239. package/node_modules/pi-lens/dist/mcp/server.js +8 -2
  240. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  241. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  242. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  243. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +469 -56
  244. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +190 -46
  245. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  246. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  247. package/node_modules/pi-lens/docs/agent-guide.md +44 -14
  248. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +518 -501
  249. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  250. package/node_modules/pi-lens/docs/features.md +33 -6
  251. package/node_modules/pi-lens/docs/globalconfig.md +14 -0
  252. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  253. package/node_modules/pi-lens/docs/mcp.md +2 -2
  254. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  255. package/node_modules/pi-lens/docs/settings.md +25 -1
  256. package/node_modules/pi-lens/docs/usage.md +24 -5
  257. package/node_modules/pi-lens/docs/word-index.md +35 -0
  258. package/node_modules/pi-lens/package.json +10 -6
  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 +22 -0
  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-conditional-empty-object-spread-js-test.yml +9 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +9 -0
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +9 -0
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +9 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +9 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +66 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  300. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  301. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  302. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  303. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  304. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +29 -0
  305. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +30 -0
  306. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +41 -0
  307. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +42 -0
  308. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  309. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  310. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  311. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  312. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  313. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  314. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +32 -0
  315. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  316. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  317. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  318. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  319. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  320. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  321. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  322. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  323. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  324. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +132 -1
  325. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  326. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  327. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +12 -11
  328. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  329. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +87 -116
  330. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +170 -0
  331. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  332. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  333. package/node_modules/pi-mcp-adapter/CHANGELOG.md +49 -0
  334. package/node_modules/pi-mcp-adapter/README.md +51 -6
  335. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  336. package/node_modules/pi-mcp-adapter/cli.js +188 -5
  337. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  338. package/node_modules/pi-mcp-adapter/config.ts +28 -10
  339. package/node_modules/pi-mcp-adapter/direct-tools.ts +23 -19
  340. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  341. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  342. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  343. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  344. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  345. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  346. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  347. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  348. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  349. package/node_modules/pi-mcp-adapter/metadata-cache.ts +46 -25
  350. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  351. package/node_modules/pi-mcp-adapter/package.json +4 -1
  352. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  353. package/node_modules/pi-mcp-adapter/request-headers-command.ts +330 -0
  354. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  355. package/node_modules/pi-mcp-adapter/server-manager.ts +145 -2
  356. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  357. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  358. package/node_modules/pi-mcp-adapter/tool-metadata.ts +38 -18
  359. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  360. package/node_modules/pi-mcp-adapter/types.ts +95 -10
  361. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  362. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  363. package/node_modules/pi-web-access/CHANGELOG.md +37 -0
  364. package/node_modules/pi-web-access/README.md +38 -14
  365. package/node_modules/pi-web-access/anysearch.ts +4 -2
  366. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  367. package/node_modules/pi-web-access/brave.ts +16 -4
  368. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  369. package/node_modules/pi-web-access/curator-page.ts +8 -3
  370. package/node_modules/pi-web-access/curator-server.ts +5 -1
  371. package/node_modules/pi-web-access/exa.ts +18 -7
  372. package/node_modules/pi-web-access/extract.ts +134 -37
  373. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  374. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  375. package/node_modules/pi-web-access/gemini-search.ts +33 -7
  376. package/node_modules/pi-web-access/index.ts +160 -104
  377. package/node_modules/pi-web-access/package.json +2 -2
  378. package/node_modules/pi-web-access/page-query.ts +4 -2
  379. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  380. package/node_modules/pi-web-access/parallel.ts +2 -2
  381. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  382. package/node_modules/pi-web-access/serper.ts +211 -0
  383. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  384. package/node_modules/pi-web-access/summary-review.ts +51 -23
  385. package/node_modules/pi-web-access/tavily.ts +16 -4
  386. package/node_modules/pi-web-access/utils.ts +87 -0
  387. package/node_modules/pi-web-access/valyu.ts +199 -0
  388. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  389. package/package.json +13 -13
@@ -11,13 +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";
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";
21
24
  /**
22
25
  * True when the LSP runner will cover `ctx.filePath` via the given PRIMARY server
23
26
  * id. Used by CLI runners that duplicate a linter a warm LSP already wraps
@@ -64,12 +67,177 @@ if (typeof __dirname !== "undefined") {
64
67
  }
65
68
  // Managed tools directory (~/.pi-lens/tools) — where ensureTool() installs binaries
66
69
  const _managedToolsDir = path.join(getGlobalPiLensDir(), "tools");
70
+ /**
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).
74
+ *
75
+ * The pi-lens dir is read per call, never memoized at module load, so tests that
76
+ * point `getGlobalPiLensDir` at a temp home still see their own tree.
77
+ */
78
+ function managedNodeToolCandidates(tool) {
79
+ const base = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin", tool);
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;
123
+ try {
124
+ const { verifyToolBinary } = await import("../../../installer/index.js");
125
+ ok = await verifyToolBinary(candidate, undefined, () => {
126
+ transient = true;
127
+ }, MANAGED_VERIFY_TIMEOUT_MS);
128
+ }
129
+ catch {
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";
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";
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
+ // =============================================================================
67
231
  /**
68
232
  * Find a command in venv first, then fall back to global.
69
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).
70
238
  */
71
- export function createVenvFinder(command, windowsExt = "", quoteWindows = false) {
72
- return (cwd) => {
239
+ export function createVenvFinder(command, windowsExt = "") {
240
+ return async (cwd) => {
73
241
  const venvPaths = [
74
242
  `.venv/bin/${command}`,
75
243
  `venv/bin/${command}`,
@@ -79,9 +247,22 @@ export function createVenvFinder(command, windowsExt = "", quoteWindows = false)
79
247
  for (const venvPath of venvPaths) {
80
248
  const fullPath = path.join(cwd, venvPath);
81
249
  if (fs.existsSync(fullPath)) {
82
- return quoteWindows && windowsExt ? `"${fullPath}"` : fullPath;
250
+ return fullPath;
83
251
  }
84
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;
85
266
  // Fall back to global
86
267
  return command;
87
268
  };
@@ -146,10 +327,44 @@ export async function discoverManagedTool(toolId) {
146
327
  // install must not become an install attempt on every eligible file/turn.
147
328
  const installAttemptsByCwd = new PathKeyedMap(normalizeMapKey);
148
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);
149
359
  // Checkers are created by runner modules and may also be created dynamically.
150
360
  // Keep the session reset as a generation rather than retaining every checker
151
361
  // reset closure forever.
152
- 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");
153
368
  function installStateFor(cwd, toolId) {
154
369
  let states = installAttemptsByCwd.get(cwd);
155
370
  if (!states) {
@@ -175,12 +390,213 @@ function noteInstallSuccess(toolId, cwd) {
175
390
  if (states?.size === 0)
176
391
  installAttemptsByCwd.delete(cwd);
177
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
+ }
178
589
  /** Reset availability/install suppression at the session boundary. */
179
590
  export function resetDispatchAvailabilityState() {
180
591
  installAttemptsByCwd.clear();
181
592
  resolveInstallInFlightByCwd.clear();
593
+ correctedAvailabilityByCwd.clear();
594
+ uncorrectedEmissionsByCwd.clear();
595
+ latchedUnavailableByCwd.clear();
596
+ managedBinaryVerdicts.clear();
597
+ managedVerifyInFlight.clear();
182
598
  resetPathWalkMemo();
183
- availabilityStateGeneration += 1;
599
+ availabilityGeneration.bump();
184
600
  }
185
601
  /**
186
602
  * Create a cached availability checker for a command.
@@ -191,23 +607,30 @@ export function resetDispatchAvailabilityState() {
191
607
  * `zig --version`). Passing the wrong probe makes the runner silently skip on
192
608
  * every machine, so toolchains with a non-standard version command must override
193
609
  * this.
610
+ *
611
+ * ## Latch policy (#1467)
612
+ *
613
+ * A `missing` / `non-installable` verdict is durable and is cached for the
614
+ * session. A `transient` verdict — timeout, abort, EAGAIN — is NOT: it is
615
+ * cached only for a bounded cooldown, after which the next caller re-probes.
616
+ * An installed tool therefore recovers on its own, without a host restart.
194
617
  */
195
618
  export function createAvailabilityChecker(command, windowsExt = "", versionArgs = ["--version"], options = {}) {
196
619
  const cacheByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
197
620
  const inFlightByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
198
- let checkerGeneration = availabilityStateGeneration;
199
- const findCommand = createVenvFinder(command, windowsExt, true);
621
+ let checkerGeneration = availabilityGeneration.current();
622
+ const findCommand = createVenvFinder(command, windowsExt);
200
623
  function ensureCurrentGeneration() {
201
- if (checkerGeneration === availabilityStateGeneration)
624
+ if (checkerGeneration === availabilityGeneration.current())
202
625
  return;
203
626
  cacheByCwd.clear();
204
627
  inFlightByCwd.clear();
205
- checkerGeneration = availabilityStateGeneration;
628
+ checkerGeneration = availabilityGeneration.current();
206
629
  }
207
630
  const reset = () => {
208
631
  cacheByCwd.clear();
209
632
  inFlightByCwd.clear();
210
- checkerGeneration = availabilityStateGeneration;
633
+ checkerGeneration = availabilityGeneration.current();
211
634
  };
212
635
  function getCache(cwd) {
213
636
  ensureCurrentGeneration();
@@ -219,16 +642,81 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
219
642
  available: null,
220
643
  command: null,
221
644
  outcome: null,
645
+ cause: null,
646
+ elapsedMs: 0,
647
+ retryAtMs: 0,
648
+ transientAttempts: 0,
222
649
  };
223
650
  cacheByCwd.set(key, created);
224
651
  return created;
225
652
  }
653
+ /** Record a verdict on the cache and emit exactly one decision record. */
654
+ function noteDecision(cache, resolvedCwd, verdict) {
655
+ cache.available = verdict.available;
656
+ cache.outcome = verdict.outcome;
657
+ cache.cause = verdict.cause;
658
+ cache.elapsedMs = verdict.elapsedMs;
659
+ let retryAfterMs;
660
+ if (verdict.available) {
661
+ cache.retryAtMs = 0;
662
+ cache.transientAttempts = 0;
663
+ }
664
+ else if (isLatchingOutcome(verdict.outcome)) {
665
+ cache.retryAtMs = 0;
666
+ cache.transientAttempts = 0;
667
+ }
668
+ else {
669
+ cache.transientAttempts += 1;
670
+ retryAfterMs = transientRetryDelayMs(cache.transientAttempts, verdict.cause);
671
+ cache.retryAtMs = Date.now() + retryAfterMs;
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
+ }
687
+ logAvailabilityDecision({
688
+ tool: command,
689
+ verdict: verdict.available ? "available" : "unavailable",
690
+ outcome: verdict.outcome,
691
+ cause: verdict.cause,
692
+ elapsedMs: verdict.elapsedMs,
693
+ latched,
694
+ ...(verdict.classifiedBy !== undefined && {
695
+ classifiedBy: verdict.classifiedBy,
696
+ }),
697
+ ...(verdict.evidence !== undefined && { evidence: verdict.evidence }),
698
+ ...(verdict.hostStallMs !== undefined && {
699
+ hostStallMs: verdict.hostStallMs,
700
+ }),
701
+ ...(retryAfterMs !== undefined && { retryAfterMs }),
702
+ budgetMs: options.probeTimeout ?? 5000,
703
+ }, resolvedCwd);
704
+ }
226
705
  async function isAvailableAsync(cwd) {
227
706
  ensureCurrentGeneration();
228
707
  const resolvedCwd = cwd || process.cwd();
229
708
  const cache = getCache(resolvedCwd);
230
- if (cache.available === false)
231
- return false;
709
+ if (cache.available === false) {
710
+ // A durable "this machine does not have the tool" stays cached; a
711
+ // transient probe failure only holds until its cooldown expires, so an
712
+ // installed tool cannot be disabled for the life of the process by one
713
+ // slow second at warm-up (#1467).
714
+ if (cache.outcome !== "transient")
715
+ return false;
716
+ if (Date.now() < cache.retryAtMs)
717
+ return false;
718
+ cache.available = null;
719
+ }
232
720
  if (cache.available === true && cache.command) {
233
721
  if (await isSpawnableCommand(cache.command))
234
722
  return true;
@@ -237,6 +725,7 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
237
725
  cache.available = null;
238
726
  cache.command = null;
239
727
  cache.outcome = null;
728
+ cache.cause = null;
240
729
  }
241
730
  const key = path.resolve(resolvedCwd);
242
731
  const existing = inFlightByCwd.get(key);
@@ -245,11 +734,18 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
245
734
  const promiseGeneration = checkerGeneration;
246
735
  let promise;
247
736
  promise = (async () => {
248
- const fastPath = options.fastPath?.();
737
+ const fastPath = await options.fastPath?.();
249
738
  if (fastPath) {
250
- cache.available = true;
251
- cache.outcome = "success";
252
739
  cache.command = fastPath;
740
+ noteDecision(cache, resolvedCwd, {
741
+ available: true,
742
+ outcome: "success",
743
+ cause: "fast-path",
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",
748
+ });
253
749
  return true;
254
750
  }
255
751
  // A bad/removed workspace must not be mistaken for a missing tool and
@@ -258,49 +754,79 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
258
754
  try {
259
755
  const cwdStat = await fs.promises.stat(resolvedCwd);
260
756
  if (!cwdStat.isDirectory()) {
261
- cache.available = false;
262
- cache.outcome = "non-installable";
757
+ noteDecision(cache, resolvedCwd, {
758
+ available: false,
759
+ outcome: "non-installable",
760
+ cause: "bad-cwd",
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",
765
+ });
263
766
  return false;
264
767
  }
265
768
  }
266
769
  catch {
267
- cache.available = false;
268
- cache.outcome = "non-installable";
770
+ noteDecision(cache, resolvedCwd, {
771
+ available: false,
772
+ outcome: "non-installable",
773
+ cause: "bad-cwd",
774
+ elapsedMs: 0,
775
+ classifiedBy: "caller",
776
+ });
269
777
  return false;
270
778
  }
271
- const cmd = findCommand(resolvedCwd);
779
+ const cmd = await findCommand(resolvedCwd);
272
780
  const env = await options.environment?.(resolvedCwd);
273
- const result = await safeSpawnAsync(cmd, versionArgs, {
274
- timeout: options.probeTimeout ?? 5000,
275
- cwd: resolvedCwd,
276
- env,
277
- });
781
+ // The probe budget is enforced by a HOST-side timer, so host event-loop
782
+ // stalls are charged to the child. Measure the stall that overlapped the
783
+ // window and hand it to the classifier (#1467).
784
+ const stallSampler = startHostStallSampler();
785
+ const startedAt = Date.now();
786
+ let result;
787
+ let hostStallMs;
788
+ try {
789
+ result = await safeSpawnAsync(cmd, versionArgs, {
790
+ timeout: options.probeTimeout ?? 5000,
791
+ cwd: resolvedCwd,
792
+ env,
793
+ });
794
+ }
795
+ finally {
796
+ hostStallMs = stallSampler.stop();
797
+ }
798
+ const elapsedMs = Date.now() - startedAt;
278
799
  if (!result.error && result.status === 0) {
279
- cache.available = true;
280
- cache.outcome = "success";
281
800
  cache.command = cmd;
801
+ noteDecision(cache, resolvedCwd, {
802
+ available: true,
803
+ outcome: "success",
804
+ cause: "ok",
805
+ elapsedMs,
806
+ hostStallMs,
807
+ classifiedBy: "probe",
808
+ evidence: describeProbeEvidence(result),
809
+ });
282
810
  return true;
283
811
  }
284
- cache.available = false;
285
- const errorCode = result.error?.code;
286
- if (result.spawnFailure?.kind === "tool-not-found") {
812
+ const { outcome, cause, evidence } = classifyProbeFailure(result, {
813
+ hostStallMs,
814
+ unclassifiedFailureOutcome: options.unclassifiedFailureOutcome,
815
+ });
816
+ // Only a TYPED tool-not-found invalidates the PATH walk memo; an
817
+ // `unclassifiedFailureOutcome: "missing"` compatibility verdict is a
818
+ // guess, not evidence that PATH changed.
819
+ if (result.spawnFailure?.kind === "tool-not-found")
287
820
  resetPathWalkMemo();
288
- cache.outcome = "missing";
289
- }
290
- else if (result.failure === "timeout" ||
291
- result.failure === "aborted" ||
292
- result.failure === "signal" ||
293
- result.spawnFailure?.kind === "killed" ||
294
- errorCode === "EAGAIN" ||
295
- errorCode === "EBUSY") {
296
- cache.outcome = "transient";
297
- }
298
- else {
299
- // A present command that rejects its version probe (or an EACCES/
300
- // EINVAL/UNKNOWN failure) is not repaired by reinstalling it.
301
- cache.outcome =
302
- options.unclassifiedFailureOutcome ?? "non-installable";
303
- }
821
+ noteDecision(cache, resolvedCwd, {
822
+ available: false,
823
+ outcome,
824
+ cause,
825
+ elapsedMs,
826
+ hostStallMs,
827
+ classifiedBy: "probe",
828
+ evidence,
829
+ });
304
830
  return false;
305
831
  })().finally(() => {
306
832
  // A session reset clears this map and a caller may immediately start a
@@ -323,36 +849,188 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
323
849
  ensureCurrentGeneration();
324
850
  return getCache(cwd || process.cwd()).outcome;
325
851
  }
326
- return { isAvailableAsync, getCommand, getOutcome, reset };
852
+ function getVerdict(cwd) {
853
+ ensureCurrentGeneration();
854
+ const cache = getCache(cwd || process.cwd());
855
+ return {
856
+ outcome: cache.outcome,
857
+ cause: cache.cause,
858
+ elapsedMs: cache.elapsedMs,
859
+ latched: cache.available !== false || isLatchingOutcome(cache.outcome ?? "missing"),
860
+ retryAtMs: cache.retryAtMs,
861
+ };
862
+ }
863
+ return { isAvailableAsync, getCommand, getOutcome, getVerdict, reset };
327
864
  }
328
865
  /**
329
866
  * Per-cwd cached availability probe for spawn signatures that don't fit
330
- * `createAvailabilityChecker` — multi-arg subcommands like `npx biome
331
- * --version`, `cargo clippy --version`, `mix credo --version`, or a
332
- * dynamically-resolved `<cmd> --version`. Each cwd is probed at most once;
333
- * 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`.
876
+ *
877
+ * ## Latch policy (#1494)
334
878
  *
335
- * The cache stores the boolean outcome forever (same shape as
336
- * `createAvailabilityChecker`): once known unavailable for a cwd, the
337
- * runner stays skipped for that cwd until the process restarts. Pass a
338
- * fresh probe for retry-after-install flows.
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.
339
885
  */
340
- export function createCwdCachedProbe(probe) {
341
- const cacheByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
342
- let probeGeneration = availabilityStateGeneration;
343
- return (cwd) => {
344
- if (probeGeneration !== availabilityStateGeneration) {
345
- cacheByCwd.clear();
346
- 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);
922
+ }
923
+ else if (verdict.available) {
924
+ clearLatchedUnavailable(key, ...latchKeysFor(options.tool));
347
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();
348
943
  const key = path.resolve(cwd || process.cwd());
349
- 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);
350
949
  if (existing)
351
950
  return existing;
352
- const promise = probe(key).catch(() => false);
353
- 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);
354
1010
  return promise;
355
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;
356
1034
  }
357
1035
  export function resolveNodeToolCommand(cwd, toolName, windowsExt = ".cmd") {
358
1036
  const isWin = process.platform === "win32";
@@ -420,9 +1098,20 @@ async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--ve
420
1098
  const state = installStateFor(cwd, toolId);
421
1099
  if (state.suppressed)
422
1100
  return null;
1101
+ const installStartedAt = Date.now();
423
1102
  const installed = await ensureTool(toolId);
424
1103
  if (installed) {
425
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));
426
1115
  return installed;
427
1116
  }
428
1117
  noteInstallFailure(toolId, cwd);
@@ -461,10 +1150,36 @@ async function resolveAvailableOrInstallUnshared(checker, toolId, cwd) {
461
1150
  if (state.suppressed) {
462
1151
  return null;
463
1152
  }
1153
+ const installStartedAt = Date.now();
464
1154
  const installed = await ensureTool(toolId);
465
1155
  if (installed) {
466
1156
  noteInstallSuccess(toolId, cwd);
467
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));
468
1183
  return installed;
469
1184
  }
470
1185
  noteInstallFailure(toolId, cwd);
@@ -481,16 +1196,19 @@ export function resolveAvailableOrInstall(checker, toolId, cwd) {
481
1196
  const existing = byTool.get(toolId);
482
1197
  if (existing)
483
1198
  return existing;
484
- 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();
485
1203
  const promise = resolveAvailableOrInstallUnshared(checker, toolId, cwd).finally(() => {
486
- if (generation !== availabilityStateGeneration)
487
- return;
488
- const current = resolveInstallInFlightByCwd.get(key);
489
- if (current?.get(toolId) === promise) {
490
- current.delete(toolId);
491
- if (current.size === 0)
492
- resolveInstallInFlightByCwd.delete(key);
493
- }
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
+ });
494
1212
  });
495
1213
  byTool.set(toolId, promise);
496
1214
  return promise;
@@ -498,20 +1216,72 @@ export function resolveAvailableOrInstall(checker, toolId, cwd) {
498
1216
  // =============================================================================
499
1217
  // SHARED AST-GREP AVAILABILITY
500
1218
  // =============================================================================
501
- // Shared ast-grep availability cache across all slop runners
502
- let sgAvailable = null;
1219
+ /**
1220
+ * Shared ast-grep availability across all slop runners, behind the transient-
1221
+ * aware latch (#1476). This module-level memo carried the same shape `SgRunner`
1222
+ * did — one failed sweep, including a timeout, disabled ast-grep for every slop
1223
+ * runner for the life of the process.
1224
+ */
1225
+ const sgLatch = createAvailabilityLatch();
503
1226
  let sgCmd = null;
504
1227
  let sgCmdArgs = [];
1228
+ /** Classification of the current sweep, accumulated across candidates. */
1229
+ let sgSweepSawTransient = false;
1230
+ let sgSweepTransientCause = "probe-timeout";
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 = [];
505
1252
  function isAstGrepVersionOutput(output) {
506
1253
  return /\bast[- ]grep\b/i.test(output);
507
1254
  }
508
1255
  async function probeAstGrepCommandAsync(cmd, argsPrefix = []) {
509
- const check = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], {
510
- timeout: 5000,
511
- });
512
- return (!check.error &&
1256
+ const sampler = startHostStallSampler();
1257
+ let check;
1258
+ let hostStallMs;
1259
+ try {
1260
+ check = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], {
1261
+ timeout: 5000,
1262
+ });
1263
+ }
1264
+ finally {
1265
+ hostStallMs = sampler.stop();
1266
+ sgSweepHostStallMs += hostStallMs;
1267
+ }
1268
+ if (!check.error &&
513
1269
  check.status === 0 &&
514
- isAstGrepVersionOutput(`${check.stdout}\n${check.stderr}`));
1270
+ isAstGrepVersionOutput(`${check.stdout}\n${check.stderr}`)) {
1271
+ return true;
1272
+ }
1273
+ const { outcome, cause } = classifyProbeFailure(check, { hostStallMs });
1274
+ if (outcome === "transient") {
1275
+ sgSweepSawTransient = true;
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);
1283
+ }
1284
+ return false;
515
1285
  }
516
1286
  /** Pre-filter local node_modules/.bin candidates that actually exist on disk. */
517
1287
  function buildSgLocalBins() {
@@ -541,29 +1311,38 @@ function buildSgLocalBins() {
541
1311
  return bins;
542
1312
  }
543
1313
  let sgAvailableInFlight = null;
544
- let sgAvailabilityGeneration = availabilityStateGeneration;
1314
+ let sgAvailabilityGeneration = availabilityGeneration.current();
545
1315
  function ensureCurrentSgGeneration() {
546
- if (sgAvailabilityGeneration === availabilityStateGeneration)
1316
+ if (sgAvailabilityGeneration === availabilityGeneration.current())
547
1317
  return;
548
- sgAvailable = null;
1318
+ sgLatch.reset();
549
1319
  sgCmd = null;
550
1320
  sgCmdArgs = [];
551
1321
  sgAvailableInFlight = null;
552
- sgAvailabilityGeneration = availabilityStateGeneration;
1322
+ sgAvailabilityGeneration = availabilityGeneration.current();
553
1323
  }
554
1324
  export async function isSgAvailableAsync() {
555
1325
  ensureCurrentSgGeneration();
556
- if (sgAvailable !== null)
557
- return sgAvailable;
1326
+ // `read()` returns null when the last verdict was transient and its cooldown
1327
+ // expired: re-probe rather than stay dead for the session (#1476).
1328
+ const memo = sgLatch.read();
1329
+ if (memo !== null)
1330
+ return memo;
558
1331
  if (sgAvailableInFlight)
559
1332
  return sgAvailableInFlight;
560
1333
  sgAvailableInFlight = (async () => {
1334
+ const startedAt = Date.now();
1335
+ sgSweepSawTransient = false;
1336
+ sgSweepTransientCause = "probe-timeout";
1337
+ sgSweepHostStallMs = 0;
1338
+ sgSweepUnreachable = [];
1339
+ sgSweepDurablyMissing = [];
561
1340
  // 1. Local node_modules/.bin
562
1341
  for (const localBin of buildSgLocalBins()) {
563
1342
  if (await probeAstGrepCommandAsync(localBin)) {
564
1343
  sgCmd = localBin;
565
1344
  sgCmdArgs = [];
566
- sgAvailable = true;
1345
+ noteSgAvailable(startedAt);
567
1346
  return true;
568
1347
  }
569
1348
  }
@@ -572,7 +1351,7 @@ export async function isSgAvailableAsync() {
572
1351
  if (await probeAstGrepCommandAsync(cmd)) {
573
1352
  sgCmd = cmd;
574
1353
  sgCmdArgs = [];
575
- sgAvailable = true;
1354
+ noteSgAvailable(startedAt);
576
1355
  return true;
577
1356
  }
578
1357
  }
@@ -583,7 +1362,7 @@ export async function isSgAvailableAsync() {
583
1362
  if (globalBin && (await probeAstGrepCommandAsync(globalBin))) {
584
1363
  sgCmd = globalBin;
585
1364
  sgCmdArgs = [];
586
- sgAvailable = true;
1365
+ noteSgAvailable(startedAt);
587
1366
  return true;
588
1367
  }
589
1368
  }
@@ -591,16 +1370,87 @@ export async function isSgAvailableAsync() {
591
1370
  if (await probeAstGrepCommandAsync("npx", ["--no", "--", "ast-grep"])) {
592
1371
  sgCmd = "npx";
593
1372
  sgCmdArgs = ["--no", "--", "ast-grep"];
594
- sgAvailable = true;
1373
+ noteSgAvailable(startedAt);
595
1374
  return true;
596
1375
  }
597
- sgAvailable = false;
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
+ }
1391
+ // A timeout on ANY candidate is evidence about the host, not the tool.
1392
+ noteSgUnavailable(startedAt, sgSweepSawTransient ? "transient" : "missing", sgSweepSawTransient ? sgSweepTransientCause : "not-found");
598
1393
  return false;
599
1394
  })().finally(() => {
600
1395
  sgAvailableInFlight = null;
601
1396
  });
602
1397
  return sgAvailableInFlight;
603
1398
  }
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
+ }
1422
+ logAvailabilityDecision({
1423
+ tool: "ast-grep",
1424
+ verdict: "available",
1425
+ outcome: "success",
1426
+ cause: provisional ? sgSweepTransientCause : "ok",
1427
+ elapsedMs: Date.now() - startedAt,
1428
+ latched: !provisional,
1429
+ hostStallMs: sgSweepHostStallMs,
1430
+ budgetMs: 5000,
1431
+ ...(provisional && {
1432
+ provisional: true,
1433
+ unreachablePreferred: [...sgSweepUnreachable],
1434
+ ...(opts.retained === true && { retained: true }),
1435
+ ...(retryAfterMs > 0 && { retryAfterMs }),
1436
+ }),
1437
+ });
1438
+ }
1439
+ /** Record a failed shared-ast-grep sweep; a transient verdict expires. */
1440
+ function noteSgUnavailable(startedAt, outcome, cause) {
1441
+ const retryAfterMs = sgLatch.noteUnavailable(outcome, cause);
1442
+ logAvailabilityDecision({
1443
+ tool: "ast-grep",
1444
+ verdict: "unavailable",
1445
+ outcome,
1446
+ cause,
1447
+ elapsedMs: Date.now() - startedAt,
1448
+ latched: outcome !== "transient",
1449
+ hostStallMs: sgSweepHostStallMs,
1450
+ ...(retryAfterMs > 0 && { retryAfterMs }),
1451
+ budgetMs: 5000,
1452
+ });
1453
+ }
604
1454
  export function getSgCommand() {
605
1455
  ensureCurrentSgGeneration();
606
1456
  return {