@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
@@ -45,7 +45,6 @@
45
45
  * - pip packages
46
46
  * - GitHub releases (platform-specific binaries → ~/.pi-lens/bin/)
47
47
  */
48
- import { logExtension } from "../extension-log.js";
49
48
  import { spawn } from "node:child_process";
50
49
  import { existsSync, statSync, unlinkSync } from "node:fs";
51
50
  import fs from "node:fs/promises";
@@ -53,16 +52,20 @@ import https from "node:https";
53
52
  import { createRequire } from "node:module";
54
53
  import os from "node:os";
55
54
  import path from "node:path";
55
+ import { writeFileAtomicAsync } from "../atomic-write.js";
56
56
  import { BoundedLruCache } from "../bounded-cache.js";
57
+ import { logExtension } from "../extension-log.js";
57
58
  import { isFullyQualified } from "../path-utils.js";
58
59
  import { assertInstallAllowed, projectTrustDenialReason, } from "../project-trust.js";
59
60
  const _installerRequire = createRequire(import.meta.url);
60
61
  import { createGunzip } from "node:zlib";
61
- import { logSessionStart } from "../sessionstart-logger.js";
62
+ import { TRANSIENT_MAX_COOLDOWN_MS } from "../dispatch/runners/utils/availability-policy.js";
63
+ import { recordDegradationOnce } from "../degradation-ledger.js";
62
64
  import { commitDurableStoreAsync } from "../durable-store.js";
63
65
  import { getGlobalPiLensDir } from "../file-utils.js";
64
66
  import { allAvailableGlobalBinDirs, installArgs, pmBinary, resolveNodePackageManager, } from "../package-manager.js";
65
67
  import { resetSafeSpawnWindowsCommandCache, safeSpawnAsync, } from "../safe-spawn.js";
68
+ import { logSessionStart } from "../sessionstart-logger.js";
66
69
  // Global installation directory for pi-lens tools
67
70
  const TOOLS_DIR = path.join(getGlobalPiLensDir(), "tools");
68
71
  const INSTALL_LOCK_PATH = path.join(TOOLS_DIR, ".install.lock");
@@ -135,8 +138,7 @@ async function acquireInstallLock() {
135
138
  // every future install with a full-timeout wait. A lock older
136
139
  // than any legitimate install (owner install bound + slack) is
137
140
  // stale regardless of what the PID now points at.
138
- const maxAgeMs = (Number(process.env.PI_LENS_INSTALL_TIMEOUT_MS) || 120_000) +
139
- 60_000;
141
+ const maxAgeMs = (Number(process.env.PI_LENS_INSTALL_TIMEOUT_MS) || 120_000) + 60_000;
140
142
  const expired = Number.isFinite(owner.createdAt) &&
141
143
  Date.now() - owner.createdAt > maxAgeMs;
142
144
  if (expired ||
@@ -241,7 +243,11 @@ export const TOOLS = [
241
243
  checkCommand: "tsc",
242
244
  checkArgs: ["--version"],
243
245
  installStrategy: "npm",
244
- packageName: "typescript",
246
+ // The managed compiler serves the classic typescript-language-server
247
+ // fallback. TypeScript 7 removed lib/tsserver.js and is selected only from
248
+ // project-local installs through the native `tsc --lsp --stdio` path.
249
+ // Revisit when typescript-language-server supports TS 7 — refs #1436.
250
+ packageName: "typescript@5.9.3",
245
251
  binaryName: "tsc",
246
252
  },
247
253
  {
@@ -799,7 +805,11 @@ export const TOOLS = [
799
805
  id: "powershell-editor-services",
800
806
  name: "PowerShell Editor Services",
801
807
  checkCommand: "pwsh",
802
- checkArgs: ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
808
+ checkArgs: [
809
+ "-NoProfile",
810
+ "-Command",
811
+ "$PSVersionTable.PSVersion.ToString()",
812
+ ],
803
813
  installStrategy: "archive",
804
814
  binaryName: "powershell-editor-services",
805
815
  archive: {
@@ -830,7 +840,9 @@ export const TOOLS = [
830
840
  const version = "22.1.0";
831
841
  const base = `https://github.com/clangd/clangd/releases/download/${version}`;
832
842
  if (platform === "linux")
833
- return arch === "x64" ? `${base}/clangd-linux-${version}.zip` : undefined;
843
+ return arch === "x64"
844
+ ? `${base}/clangd-linux-${version}.zip`
845
+ : undefined;
834
846
  if (platform === "darwin")
835
847
  return `${base}/clangd-mac-${version}.zip`;
836
848
  if (platform === "win32")
@@ -1198,6 +1210,31 @@ export const TOOLS = [
1198
1210
  binaryInArchive: "clojure-lsp",
1199
1211
  },
1200
1212
  },
1213
+ {
1214
+ // CUE ships a single native binary per platform on GitHub releases;
1215
+ // the LSP runs via `cue lsp serve`. Used as managedToolId by CueServer.
1216
+ id: "cue",
1217
+ name: "CUE",
1218
+ checkCommand: "cue",
1219
+ checkArgs: ["version"],
1220
+ installStrategy: "github",
1221
+ binaryName: "cue",
1222
+ github: {
1223
+ repo: "cue-lang/cue",
1224
+ assetMatch: (platform, arch) => {
1225
+ if (platform === "linux")
1226
+ return arch === "arm64" ? "linux_arm64.tar.gz" : "linux_amd64.tar.gz";
1227
+ if (platform === "darwin")
1228
+ return arch === "arm64"
1229
+ ? "darwin_arm64.tar.gz"
1230
+ : "darwin_amd64.tar.gz";
1231
+ if (platform === "win32")
1232
+ return arch === "arm64" ? "windows_arm64.zip" : "windows_amd64.zip";
1233
+ return undefined;
1234
+ },
1235
+ binaryInArchive: "cue",
1236
+ },
1237
+ },
1201
1238
  {
1202
1239
  // gleam ships a single static binary per platform on GitHub releases; the
1203
1240
  // LSP runs via `gleam lsp`. Used as managedToolId by GleamServer. The linux
@@ -1274,9 +1311,7 @@ export const TOOLS = [
1274
1311
  if (arch !== "x64" && arch !== "arm64")
1275
1312
  return undefined;
1276
1313
  if (platform === "linux")
1277
- return arch === "arm64"
1278
- ? "expert_linux_arm64"
1279
- : "expert_linux_amd64";
1314
+ return arch === "arm64" ? "expert_linux_arm64" : "expert_linux_amd64";
1280
1315
  if (platform === "darwin")
1281
1316
  return arch === "arm64"
1282
1317
  ? "expert_darwin_arm64"
@@ -1295,11 +1330,45 @@ const installFailureReasons = new Map();
1295
1330
  export function getInstallFailureReason(toolId) {
1296
1331
  return installFailureReasons.get(toolId);
1297
1332
  }
1333
+ const installAttempts = new Map();
1334
+ function noteInstallAttempt(toolId, outcome, reason) {
1335
+ installAttempts.set(toolId, { outcome, reason, at: Date.now() });
1336
+ }
1337
+ /** Record an outcome only if this attempt has not already recorded its own. */
1338
+ function noteInstallAttemptIfUnrecorded(toolId, outcome, reason) {
1339
+ if (installAttempts.has(toolId))
1340
+ return;
1341
+ noteInstallAttempt(toolId, outcome, reason);
1342
+ }
1343
+ /**
1344
+ * What the last install attempt for `toolId` did, or `undefined` when no attempt
1345
+ * has been recorded in this session. Consumers map this onto the
1346
+ * `availability_decision` record's install evidence (#1500).
1347
+ */
1348
+ export function getInstallAttempt(toolId) {
1349
+ return installAttempts.get(toolId);
1350
+ }
1351
+ const lastEnsureResolutionSource = new Map();
1352
+ export function getLastEnsureResolutionSource(toolId) {
1353
+ return lastEnsureResolutionSource.get(toolId);
1354
+ }
1298
1355
  // Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
1299
1356
  const resolvedPathCache = new BoundedLruCache(256);
1357
+ /** Re-arm resolved tool paths when a new session may have changed PATH. */
1358
+ export function resetResolvedPathCache() {
1359
+ resolvedPathCache.clear();
1360
+ }
1300
1361
  const PROBE_CACHE_PATH = path.join(getGlobalPiLensDir(), "probe-cache.json");
1301
1362
  const PROBE_CACHE_LOCK_STALE_MS = 180_000;
1302
1363
  const PROBE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
1364
+ /**
1365
+ * How long a transient-tainted entry (see `ProbeCacheEntry.transient`) is
1366
+ * served before `getToolPath` is asked again, instead of the full 24h TTL
1367
+ * (#1569). Shares the shared probe policy's transient ceiling rather than
1368
+ * inventing a new number for the same "how long is a stall worth trusting"
1369
+ * question.
1370
+ */
1371
+ const PROBE_CACHE_TRANSIENT_COOLDOWN_MS = TRANSIENT_MAX_COOLDOWN_MS;
1303
1372
  const PROBE_CACHE_FLUSH_LOCK_WAIT_MS = 250;
1304
1373
  const PROBE_CACHE_FLUSH_RETRY_DELAY_MS = 300;
1305
1374
  const PROBE_CACHE_FLUSH_RETRY_MAX_DELAY_MS = 30_000;
@@ -1349,7 +1418,8 @@ function scheduleProbeFlush(delayMs = PROBE_CACHE_FLUSH_RETRY_DELAY_MS) {
1349
1418
  _probeCacheFlushTimer.unref?.();
1350
1419
  }
1351
1420
  function scheduleProbeFlushRetry() {
1352
- const delay = Math.min(PROBE_CACHE_FLUSH_RETRY_MAX_DELAY_MS, PROBE_CACHE_FLUSH_RETRY_DELAY_MS * 2 ** Math.min(_probeCacheRetryAttempt, 6));
1421
+ const delay = Math.min(PROBE_CACHE_FLUSH_RETRY_MAX_DELAY_MS, PROBE_CACHE_FLUSH_RETRY_DELAY_MS *
1422
+ 2 ** Math.min(_probeCacheRetryAttempt, 6));
1353
1423
  _probeCacheRetryAttempt += 1;
1354
1424
  scheduleProbeFlush(delay);
1355
1425
  }
@@ -1359,14 +1429,32 @@ function snapshotProbeCacheChanges() {
1359
1429
  versions: new Map(_probeCacheChangeVersions),
1360
1430
  };
1361
1431
  }
1432
+ /**
1433
+ * Deserialize the on-disk probe-cache for the LOCKED write-side merge
1434
+ * (`writeProbeCache`'s `commitDurableStoreAsync` call). Unlike `readProbeCache`
1435
+ * (the ordinary session-lookup path, which already degrades a parse/shape
1436
+ * failure to `{}`), this used to THROW on a torn/corrupt file — which does
1437
+ * not crash the caller (`writeProbeCache` wraps the whole commit in try/catch
1438
+ * and retries), but it also means the corrupt file on disk is never repaired:
1439
+ * every retry re-reads the same torn bytes, re-throws, and gives up again,
1440
+ * forever (#1609 layer b). Degrading here too, exactly like `readProbeCache`,
1441
+ * lets the next successful flush's `merge` step overwrite the torn file with
1442
+ * a valid one instead of looping on it indefinitely.
1443
+ */
1362
1444
  function deserializeProbeCache(contents) {
1363
1445
  if (contents === undefined)
1364
1446
  return {};
1365
- const parsed = JSON.parse(contents);
1366
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1367
- throw new Error("probe-cache root is not an object");
1447
+ try {
1448
+ const parsed = JSON.parse(contents);
1449
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1450
+ throw new Error("probe-cache root is not an object");
1451
+ }
1452
+ return parsed;
1453
+ }
1454
+ catch (err) {
1455
+ logSessionStart(`auto-install probe-cache: write-side read was corrupt (${err.message}); recovering as empty`);
1456
+ return {};
1368
1457
  }
1369
- return parsed;
1370
1458
  }
1371
1459
  function applyProbeCacheChanges(disk, changes) {
1372
1460
  for (const [toolId, entry] of changes) {
@@ -1383,9 +1471,12 @@ function applyProbeCacheChanges(disk, changes) {
1383
1471
  * supplies the probe cache's former quarantine/stale-owner recovery.
1384
1472
  */
1385
1473
  function ageProbeCache(disk) {
1386
- const cutoff = Date.now() - PROBE_CACHE_TTL_MS;
1474
+ const now = Date.now();
1387
1475
  for (const [toolId, entry] of Object.entries(disk)) {
1388
- if (!Number.isFinite(entry.cachedAt) || entry.cachedAt < cutoff) {
1476
+ const ttl = entry.transient
1477
+ ? PROBE_CACHE_TRANSIENT_COOLDOWN_MS
1478
+ : PROBE_CACHE_TTL_MS;
1479
+ if (!Number.isFinite(entry.cachedAt) || entry.cachedAt < now - ttl) {
1389
1480
  delete disk[toolId];
1390
1481
  }
1391
1482
  }
@@ -1514,8 +1605,15 @@ export async function checkProbeCache(toolId) {
1514
1605
  const entry = cache[toolId];
1515
1606
  if (!entry)
1516
1607
  return undefined;
1517
- if (Date.now() - entry.cachedAt > PROBE_CACHE_TTL_MS) {
1518
- logSessionStart(`auto-install probe-cache ${toolId}: miss (ttl expired)`);
1608
+ // A transient-tainted entry (#1569) is not trusted past the shorter
1609
+ // transient cooldown, even inside the 24h TTL — the selection it recorded
1610
+ // may be a degraded fallback that a since-recovered preferred tier should
1611
+ // now beat.
1612
+ const ttl = entry.transient
1613
+ ? PROBE_CACHE_TRANSIENT_COOLDOWN_MS
1614
+ : PROBE_CACHE_TTL_MS;
1615
+ if (Date.now() - entry.cachedAt > ttl) {
1616
+ logSessionStart(`auto-install probe-cache ${toolId}: miss (${entry.transient ? "transient cooldown" : "ttl"} expired)`);
1519
1617
  delete cache[toolId];
1520
1618
  markProbeCacheChange(toolId, null);
1521
1619
  return undefined;
@@ -1545,7 +1643,7 @@ export async function checkProbeCache(toolId) {
1545
1643
  }
1546
1644
  }
1547
1645
  // Exported for testing only.
1548
- export async function updateProbeCache(toolId, resolvedPath) {
1646
+ export async function updateProbeCache(toolId, resolvedPath, transient = false) {
1549
1647
  try {
1550
1648
  const stat = await fs.stat(resolvedPath);
1551
1649
  const cache = await readProbeCache();
@@ -1553,6 +1651,7 @@ export async function updateProbeCache(toolId, resolvedPath) {
1553
1651
  path: resolvedPath,
1554
1652
  mtimeMs: stat.mtimeMs,
1555
1653
  cachedAt: Date.now(),
1654
+ ...(transient && { transient: true }),
1556
1655
  };
1557
1656
  cache[toolId] = entry;
1558
1657
  markProbeCacheChange(toolId, entry);
@@ -1569,10 +1668,13 @@ export function resetProbeCacheStateForTesting() {
1569
1668
  _probeCacheChangeVersions.clear();
1570
1669
  _probeCacheChangeGeneration = 0;
1571
1670
  _probeCacheRetryAttempt = 0;
1572
- resolvedPathCache.clear();
1671
+ resetResolvedPathCache();
1573
1672
  ensureInFlight.clear();
1574
1673
  installFailureReasons.clear();
1674
+ installAttempts.clear();
1675
+ lastEnsureResolutionSource.clear();
1575
1676
  lastManagedInstallVersion.clear();
1677
+ lastResolveTransient.clear();
1576
1678
  resetPathWalkMemo();
1577
1679
  if (_probeCacheFlushTimer !== null) {
1578
1680
  clearTimeout(_probeCacheFlushTimer);
@@ -1699,8 +1801,28 @@ const lastManagedInstallVersion = new Map();
1699
1801
  * `onVersionOutput`, when provided, receives the raw stdout on a successful
1700
1802
  * (exit 0) probe — used to piggyback version-pin drift detection onto this
1701
1803
  * already-happening spawn instead of adding a new one (#589).
1804
+ *
1805
+ * Exported so every managed-binary check runs THIS verification rather than a
1806
+ * bare `existsSync` of its own: an on-disk shim that cannot run must not
1807
+ * shadow a working PATH binary (#1657).
1702
1808
  */
1703
- async function verifyToolBinary(binPath, onVersionOutput) {
1809
+ export async function verifyToolBinary(binPath, onVersionOutput,
1810
+ /**
1811
+ * Called when a `false` verdict came from a probe that never got a fair
1812
+ * run — a spawn timeout/signal or a spawn-boundary EAGAIN/EBUSY/sync throw
1813
+ * — rather than the binary actually rejecting `--version` (#1569). An
1814
+ * unspawnable prober is never a durable verdict: a caller that falls
1815
+ * through to a lower-priority candidate on one of these must not let the
1816
+ * fallback's selection be trusted as if the preferred candidate were
1817
+ * genuinely broken.
1818
+ */
1819
+ onTransient,
1820
+ /**
1821
+ * Spawn budget, ms. Install paths keep the generous default; a latency-
1822
+ * sensitive caller on the dispatch hot path passes a shorter one and treats
1823
+ * the expiry as transient rather than as a verdict (#1657).
1824
+ */
1825
+ timeoutMs = 10000) {
1704
1826
  return new Promise((resolve) => {
1705
1827
  const isWindows = installerPlatform() === "win32";
1706
1828
  const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
@@ -1726,7 +1848,7 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1726
1848
  let proc;
1727
1849
  try {
1728
1850
  proc = spawn(spawnCmd, useShell ? [] : ["--version"], {
1729
- timeout: 10000,
1851
+ timeout: timeoutMs,
1730
1852
  stdio: ["ignore", "pipe", "pipe"],
1731
1853
  shell: useShell,
1732
1854
  });
@@ -1734,7 +1856,9 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1734
1856
  catch (err) {
1735
1857
  // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the pidusage
1736
1858
  // bug class, #533) — best-effort verify, resolve rather than reject.
1859
+ // The prober itself never ran, so this says nothing about the binary.
1737
1860
  logSessionStart(`auto-install verify: spawn threw for ${binPath} (${err instanceof Error ? err.message : String(err)})`);
1861
+ onTransient?.();
1738
1862
  resolve(false);
1739
1863
  return;
1740
1864
  }
@@ -1742,7 +1866,7 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1742
1866
  let stderr = "";
1743
1867
  proc.stdout?.on("data", (data) => (stdout += data));
1744
1868
  proc.stderr?.on("data", (data) => (stderr += data));
1745
- proc.on("exit", (code) => {
1869
+ proc.on("exit", (code, signal) => {
1746
1870
  if (code === 0) {
1747
1871
  debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
1748
1872
  onVersionOutput?.(stdout);
@@ -1755,11 +1879,20 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1755
1879
  resolve(true);
1756
1880
  }
1757
1881
  else {
1758
- logSessionStart(`auto-install verify: failed for ${binPath} (exit=${code})`);
1882
+ // `code === null` (usually paired with a `signal`) means the
1883
+ // spawn timeout fired and the process was killed before it could
1884
+ // exit on its own — a stall, not a verdict from the binary.
1885
+ if (code === null || signal)
1886
+ onTransient?.();
1887
+ logSessionStart(`auto-install verify: failed for ${binPath} (exit=${code}${signal ? `, signal=${signal}` : ""})`);
1759
1888
  resolve(false);
1760
1889
  }
1761
1890
  });
1762
1891
  proc.on("error", (err) => {
1892
+ const errno = err.code;
1893
+ if (errno === "EAGAIN" || errno === "EBUSY" || errno === "ETIMEDOUT") {
1894
+ onTransient?.();
1895
+ }
1763
1896
  logSessionStart(`auto-install verify: error for ${binPath}: ${err.message}`);
1764
1897
  resolve(false);
1765
1898
  });
@@ -1958,7 +2091,47 @@ export function resolvePlatformPackageBinary(tool) {
1958
2091
  }
1959
2092
  return undefined;
1960
2093
  }
2094
+ /**
2095
+ * Whether the most recent {@link getToolPath} resolution for a tool saw a
2096
+ * transient probe failure on some candidate before landing on its answer
2097
+ * (#1569). A side channel, in the same shape as `lastManagedInstallVersion`
2098
+ * above: `getToolPath` keeps its `string | undefined` return so its many
2099
+ * callers are untouched, and `ensureToolResolved` reads this immediately
2100
+ * after awaiting it to decide how long the persisted probe-cache entry may
2101
+ * be trusted for.
2102
+ */
2103
+ const lastResolveTransient = new Map();
2104
+ /**
2105
+ * The resolution identity the most recent install of each tool used — a GitHub
2106
+ * release tag, a pinned archive URL, a Maven GAV (#1747).
2107
+ *
2108
+ * A side channel in the same shape as `lastManagedInstallVersion`: the install
2109
+ * functions keep their `string | undefined` return, and `finishInstallAttempt`
2110
+ * reads this to stamp the refresh state at install time. Without that stamp a
2111
+ * freshly installed tool would look "never refreshed" and pay a redundant
2112
+ * re-resolution the first time the cadence came round.
2113
+ */
2114
+ const lastInstallResolutionId = new Map();
2115
+ /**
2116
+ * True when the tool path `getToolPath` most recently returned for `toolId`
2117
+ * was selected after a candidate tier failed transiently — a stalled or
2118
+ * unspawnable probe, never a clean "not found". Such a selection may be a
2119
+ * degraded fallback masking a preferred tier that is actually fine, so a
2120
+ * caller persisting it must not trust it for the full 24h TTL (#1569).
2121
+ */
2122
+ export function wasLastResolveTransient(toolId) {
2123
+ return lastResolveTransient.get(toolId) ?? false;
2124
+ }
1961
2125
  export async function getToolPath(toolId) {
2126
+ let sawTransient = false;
2127
+ const markTransient = () => {
2128
+ sawTransient = true;
2129
+ };
2130
+ const result = await getToolPathResolved(toolId, markTransient);
2131
+ lastResolveTransient.set(toolId, sawTransient);
2132
+ return result;
2133
+ }
2134
+ async function getToolPathResolved(toolId, onTransient) {
1962
2135
  const tool = TOOLS.find((t) => t.id === toolId);
1963
2136
  if (!tool)
1964
2137
  return undefined;
@@ -1995,7 +2168,7 @@ export async function getToolPath(toolId) {
1995
2168
  const cmdPath = `${localBase}.cmd`;
1996
2169
  try {
1997
2170
  await fs.access(cmdPath);
1998
- if (await verifyToolBinary(cmdPath, recordVersion)) {
2171
+ if (await verifyToolBinary(cmdPath, recordVersion, onTransient)) {
1999
2172
  return cmdPath;
2000
2173
  }
2001
2174
  logSessionStart(`auto-install verify: ${cmdPath} exists but is broken, will reinstall`);
@@ -2008,7 +2181,7 @@ export async function getToolPath(toolId) {
2008
2181
  const exePath = `${localBase}.exe`;
2009
2182
  try {
2010
2183
  await fs.access(exePath);
2011
- if (await verifyToolBinary(exePath, recordVersion)) {
2184
+ if (await verifyToolBinary(exePath, recordVersion, onTransient)) {
2012
2185
  return exePath;
2013
2186
  }
2014
2187
  logSessionStart(`auto-install verify: ${exePath} exists but is broken, will reinstall`);
@@ -2020,7 +2193,7 @@ export async function getToolPath(toolId) {
2020
2193
  if (installerPlatform() !== "win32") {
2021
2194
  try {
2022
2195
  await fs.access(localBase);
2023
- if (await verifyToolBinary(localBase, recordVersion)) {
2196
+ if (await verifyToolBinary(localBase, recordVersion, onTransient)) {
2024
2197
  return localBase;
2025
2198
  }
2026
2199
  logSessionStart(`auto-install verify: ${localBase} exists but is broken, will reinstall`);
@@ -2035,7 +2208,8 @@ export async function getToolPath(toolId) {
2035
2208
  // before falling back to PATH or a (re)install.
2036
2209
  if (tool.platformPackage) {
2037
2210
  const platformBin = resolvePlatformPackageBinary(tool);
2038
- if (platformBin && (await verifyToolBinary(platformBin))) {
2211
+ if (platformBin &&
2212
+ (await verifyToolBinary(platformBin, undefined, onTransient))) {
2039
2213
  logSessionStart(`auto-install ${toolId}: resolved platform-package binary at ${platformBin}`);
2040
2214
  return platformBin;
2041
2215
  }
@@ -2057,14 +2231,14 @@ export async function getToolPath(toolId) {
2057
2231
  return tool.checkCommand;
2058
2232
  }
2059
2233
  if (tool.installStrategy === "npm") {
2060
- const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
2234
+ const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id, onTransient);
2061
2235
  if (npmPath) {
2062
2236
  return npmPath;
2063
2237
  }
2064
2238
  }
2065
2239
  // For pip tools, also probe user-level script locations
2066
2240
  if (tool.installStrategy === "pip") {
2067
- const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
2241
+ const pipPath = await findPipUserToolPath(tool.binaryName || tool.id, onTransient);
2068
2242
  if (pipPath) {
2069
2243
  return pipPath;
2070
2244
  }
@@ -2122,9 +2296,9 @@ function getArchiveBinaryCandidates(binaryName, platform, assetName) {
2122
2296
  candidates.add(`${binaryName}.cmd`);
2123
2297
  return [...candidates];
2124
2298
  }
2125
- async function findNpmGlobalToolPath(binaryName) {
2299
+ async function findNpmGlobalToolPath(binaryName, onTransient) {
2126
2300
  const isWindows = process.platform === "win32";
2127
- const binDirs = await getNpmGlobalBinCandidates();
2301
+ const binDirs = await getNpmGlobalBinCandidates(onTransient);
2128
2302
  for (const dir of binDirs) {
2129
2303
  const candidates = isWindows
2130
2304
  ? [
@@ -2135,7 +2309,7 @@ async function findNpmGlobalToolPath(binaryName) {
2135
2309
  for (const candidate of candidates) {
2136
2310
  try {
2137
2311
  await fs.access(candidate);
2138
- if (await verifyToolBinary(candidate)) {
2312
+ if (await verifyToolBinary(candidate, undefined, onTransient)) {
2139
2313
  return candidate;
2140
2314
  }
2141
2315
  }
@@ -2146,7 +2320,7 @@ async function findNpmGlobalToolPath(binaryName) {
2146
2320
  }
2147
2321
  return undefined;
2148
2322
  }
2149
- async function getNpmGlobalBinCandidates() {
2323
+ async function getNpmGlobalBinCandidates(onTransient) {
2150
2324
  const dirs = [];
2151
2325
  const seen = new Set();
2152
2326
  const add = (value) => {
@@ -2167,13 +2341,16 @@ async function getNpmGlobalBinCandidates() {
2167
2341
  add(path.join(os.homedir(), ".npm-global", "bin"));
2168
2342
  }
2169
2343
  // Global bin dirs for every installed manager (npm/pnpm/yarn/bun) — a tool
2170
- // may have been installed globally via any of them.
2171
- for (const dir of await allAvailableGlobalBinDirs()) {
2344
+ // may have been installed globally via any of them. `onTransient` surfaces
2345
+ // a manager whose availability probe stalled rather than genuinely failed,
2346
+ // so its bin dir may be missing from `dirs` for a reason other than "not
2347
+ // installed" (#1585).
2348
+ for (const dir of await allAvailableGlobalBinDirs(onTransient)) {
2172
2349
  add(dir);
2173
2350
  }
2174
2351
  return dirs;
2175
2352
  }
2176
- async function findPipUserToolPath(binaryName) {
2353
+ async function findPipUserToolPath(binaryName, onTransient) {
2177
2354
  const isWindows = process.platform === "win32";
2178
2355
  const userBaseCandidates = await getPythonUserBaseCandidates();
2179
2356
  for (const userBase of userBaseCandidates) {
@@ -2206,7 +2383,7 @@ async function findPipUserToolPath(binaryName) {
2206
2383
  for (const candidate of candidates) {
2207
2384
  try {
2208
2385
  await fs.access(candidate);
2209
- if (await verifyToolBinary(candidate)) {
2386
+ if (await verifyToolBinary(candidate, undefined, onTransient)) {
2210
2387
  return candidate;
2211
2388
  }
2212
2389
  }
@@ -2295,12 +2472,20 @@ function sameHost(a, b) {
2295
2472
  }
2296
2473
  }
2297
2474
  /**
2298
- * Fetch a URL, following up to `maxRedirects` redirects.
2299
- * Returns the raw Buffer of the response body. Any caller-supplied headers are
2300
- * dropped when a redirect crosses to a different host, so an Authorization
2301
- * header can never leak to a redirect target (e.g. a release CDN).
2475
+ * Fetch a URL, following up to `maxRedirects` redirects, and surface the status
2476
+ * line and `ETag` alongside the body.
2477
+ *
2478
+ * `304 Not Modified` resolves rather than rejects: the periodic GitHub
2479
+ * re-resolution (#1747) sends `If-None-Match` and a 304 is its cheapest and
2480
+ * most desirable answer — the release has not moved, no asset is downloaded,
2481
+ * and GitHub does not charge the request against the caller's rate limit. Every
2482
+ * other non-2xx status is still an error.
2483
+ *
2484
+ * Any caller-supplied headers are dropped when a redirect crosses to a
2485
+ * different host, so an Authorization header can never leak to a redirect
2486
+ * target (e.g. a release CDN).
2302
2487
  */
2303
- function httpsGet(url, maxRedirects = 5, headers = {}) {
2488
+ function httpsGetWithMeta(url, maxRedirects = 5, headers = {}) {
2304
2489
  return new Promise((resolve, reject) => {
2305
2490
  https
2306
2491
  .get(url, { headers: { "User-Agent": "pi-lens/1.0", ...headers } }, (res) => {
@@ -2317,7 +2502,12 @@ function httpsGet(url, maxRedirects = 5, headers = {}) {
2317
2502
  const { Authorization: _drop, ...rest } = headers;
2318
2503
  return rest;
2319
2504
  })();
2320
- return resolve(httpsGet(location, maxRedirects - 1, nextHeaders));
2505
+ return resolve(httpsGetWithMeta(location, maxRedirects - 1, nextHeaders));
2506
+ }
2507
+ const etag = typeof res.headers.etag === "string" ? res.headers.etag : undefined;
2508
+ if (res.statusCode === 304) {
2509
+ res.resume();
2510
+ return resolve({ statusCode: 304, body: Buffer.alloc(0), etag });
2321
2511
  }
2322
2512
  if (res.statusCode !== 200) {
2323
2513
  res.resume();
@@ -2325,12 +2515,24 @@ function httpsGet(url, maxRedirects = 5, headers = {}) {
2325
2515
  }
2326
2516
  const chunks = [];
2327
2517
  res.on("data", (chunk) => chunks.push(chunk));
2328
- res.on("end", () => resolve(Buffer.concat(chunks)));
2518
+ res.on("end", () => resolve({ statusCode: 200, body: Buffer.concat(chunks), etag }));
2329
2519
  res.on("error", reject);
2330
2520
  })
2331
2521
  .on("error", reject);
2332
2522
  });
2333
2523
  }
2524
+ /**
2525
+ * Fetch a URL and return the raw Buffer of the response body. Rejects on any
2526
+ * non-200 status (a 304 is impossible here — no caller of this form sends a
2527
+ * validator header).
2528
+ */
2529
+ async function httpsGet(url, maxRedirects = 5, headers = {}) {
2530
+ const response = await httpsGetWithMeta(url, maxRedirects, headers);
2531
+ if (response.statusCode !== 200) {
2532
+ throw new Error(`HTTP ${response.statusCode} for ${url}`);
2533
+ }
2534
+ return response.body;
2535
+ }
2334
2536
  /**
2335
2537
  * Run a shell command and return true on exit code 0.
2336
2538
  */
@@ -2358,7 +2560,14 @@ function runCommand(command, args, cwd) {
2358
2560
  * Download and install a tool from a GitHub release.
2359
2561
  * Returns the path to the installed binary, or undefined on failure.
2360
2562
  */
2361
- async function installGitHubTool(tool) {
2563
+ async function installGitHubTool(tool,
2564
+ /**
2565
+ * Release metadata the caller already fetched. The periodic refresh (#1747)
2566
+ * resolves `releases/latest` to decide whether the tag moved at all, and
2567
+ * hands the SAME response here rather than paying a second GitHub API call
2568
+ * for an answer it already has.
2569
+ */
2570
+ prefetchedRelease) {
2362
2571
  const spec = tool.github;
2363
2572
  if (!spec)
2364
2573
  return undefined;
@@ -2369,15 +2578,35 @@ async function installGitHubTool(tool) {
2369
2578
  logSessionStart(`github-install ${tool.id}: unsupported platform=${platform} arch=${arch}`);
2370
2579
  return undefined;
2371
2580
  }
2372
- // Fetch latest release metadata from GitHub API
2373
- logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
2581
+ // Fetch latest release metadata from GitHub API, unless the caller already did.
2374
2582
  let releaseJson;
2375
- try {
2376
- const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, githubApiAuthHeaders());
2377
- releaseJson = JSON.parse(body.toString("utf8"));
2583
+ if (prefetchedRelease) {
2584
+ releaseJson = prefetchedRelease;
2585
+ logSessionStart(`github-install ${tool.id}: using caller-supplied release metadata for ${spec.repo} (${prefetchedRelease.tag_name ?? "untagged"})`);
2378
2586
  }
2379
- catch (err) {
2380
- logSessionStart(`github-install ${tool.id}: release fetch failed: ${err.message}`);
2587
+ else {
2588
+ logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
2589
+ try {
2590
+ const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, githubApiAuthHeaders());
2591
+ releaseJson = JSON.parse(body.toString("utf8"));
2592
+ }
2593
+ catch (err) {
2594
+ logSessionStart(`github-install ${tool.id}: release fetch failed: ${err.message}`);
2595
+ return undefined;
2596
+ }
2597
+ }
2598
+ if (releaseJson.tag_name) {
2599
+ lastInstallResolutionId.set(tool.id, releaseJson.tag_name);
2600
+ }
2601
+ // #1759 review F3: a 200 whose body has no `assets` array (an unexpected
2602
+ // GitHub API shape, not a real release) used to reach `pickReleaseAsset`
2603
+ // and throw inside `.find`, uncaught by anything in this function. For the
2604
+ // REFRESH caller that throw skipped every stamp write, so the tool never
2605
+ // got a failure stamp and re-took the one-per-session slot forever. Failing
2606
+ // here, like a missing asset match, keeps this an ordinary "no install"
2607
+ // outcome both callers already handle.
2608
+ if (!Array.isArray(releaseJson.assets)) {
2609
+ logSessionStart(`github-install ${tool.id}: release metadata has no assets array`);
2381
2610
  return undefined;
2382
2611
  }
2383
2612
  const asset = pickReleaseAsset(releaseJson.assets, assetSubstring) ??
@@ -2416,7 +2645,10 @@ async function installGitHubTool(tool) {
2416
2645
  gunzip.on("error", reject);
2417
2646
  gunzip.end(assetBuffer);
2418
2647
  });
2419
- await fs.writeFile(destPath, decompressed, { mode: 0o750 });
2648
+ await writeFileAtomicAsync(destPath, decompressed, {
2649
+ bestEffort: false,
2650
+ mode: 0o750,
2651
+ });
2420
2652
  }
2421
2653
  else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tar.xz")) {
2422
2654
  // Write archive to temp file, extract with system tar
@@ -2483,7 +2715,10 @@ async function installGitHubTool(tool) {
2483
2715
  }
2484
2716
  else {
2485
2717
  // Bare binary (e.g. shfmt_*_linux_amd64)
2486
- await fs.writeFile(destPath, assetBuffer, { mode: 0o750 });
2718
+ await writeFileAtomicAsync(destPath, assetBuffer, {
2719
+ bestEffort: false,
2720
+ mode: 0o750,
2721
+ });
2487
2722
  }
2488
2723
  }
2489
2724
  catch (err) {
@@ -2501,9 +2736,7 @@ async function installGitHubTool(tool) {
2501
2736
  }
2502
2737
  try {
2503
2738
  const extraBuffer = await httpsGet(extraAsset.browser_download_url);
2504
- await fs.writeFile(path.join(GITHUB_BIN_DIR, extraName), extraBuffer, {
2505
- mode: 0o750,
2506
- });
2739
+ await writeFileAtomicAsync(path.join(GITHUB_BIN_DIR, extraName), extraBuffer, { bestEffort: false, mode: 0o750 });
2507
2740
  logSessionStart(`github-install ${tool.id}: installed extra asset ${extraName} (${extraBuffer.length} bytes)`);
2508
2741
  }
2509
2742
  catch (err) {
@@ -2546,6 +2779,581 @@ const NEEDS_POSTINSTALL = new Set([
2546
2779
  "esbuild",
2547
2780
  "intelephense", // postinstall fetches platform binary; --ignore-scripts breaks install
2548
2781
  ]);
2782
+ /**
2783
+ * Does this npm package need its lifecycle scripts to install correctly?
2784
+ *
2785
+ * Exported so the periodic refresh (#1730) re-runs each package under the SAME
2786
+ * script policy its original install used. A refresh that drops
2787
+ * `--ignore-scripts` for biome or `@ast-grep/cli` leaves the JS launcher
2788
+ * updated and its native binary on the old version.
2789
+ */
2790
+ export function npmToolNeedsPostinstall(packageName) {
2791
+ return NEEDS_POSTINSTALL.has(packageName);
2792
+ }
2793
+ /**
2794
+ * The managed npm tools whose installed version is free to move — every
2795
+ * registry entry with `installStrategy: "npm"` and an UNPINNED `packageName`.
2796
+ *
2797
+ * Derived from `TOOLS` on every call rather than listed by hand: a hand-kept
2798
+ * copy of a registry is the single-source-of-truth defect this repo keeps
2799
+ * finding, and a new npm tool added to `TOOLS` must be refreshed without anyone
2800
+ * remembering a second list.
2801
+ *
2802
+ * Packages carrying an explicit `name@1.2.3` pin are excluded. The pin IS the
2803
+ * intended version, and #589's drift check already reinstalls a managed copy
2804
+ * that wanders off it.
2805
+ */
2806
+ export function getRefreshableManagedNpmTools() {
2807
+ return getRefreshableManagedTools()
2808
+ .filter((candidate) => candidate.strategy === "npm")
2809
+ .map((candidate) => ({
2810
+ toolId: candidate.toolId,
2811
+ // The npm branch of getRefreshableManagedTools only admits entries that
2812
+ // have both, so these are total.
2813
+ packageName: candidate.packageName,
2814
+ binaryName: candidate.binaryName,
2815
+ }));
2816
+ }
2817
+ /** The Maven GAV a `maven` entry pins, as a stable identity string. */
2818
+ function mavenCoordinate(spec) {
2819
+ return [
2820
+ spec.repoBaseUrl ?? MAVEN_CENTRAL_BASE,
2821
+ spec.groupId,
2822
+ spec.artifactId,
2823
+ spec.version,
2824
+ spec.classifier ?? "",
2825
+ ].join(":");
2826
+ }
2827
+ /**
2828
+ * Every managed tool whose installed copy is allowed to move, across ALL six
2829
+ * install strategies — the single source of truth the periodic refresh selects
2830
+ * from (#1730, #1747).
2831
+ *
2832
+ * Derived from `TOOLS` on every call rather than listed by hand. A hand-kept
2833
+ * copy of a registry is the defect shape this repo keeps re-finding, and a tool
2834
+ * added to `TOOLS` must become refreshable without anyone remembering a second
2835
+ * list.
2836
+ *
2837
+ * Excluded: npm packages carrying an explicit `name@1.2.3` pin (the pin IS the
2838
+ * intended version, and #589's drift check already repairs those), and any
2839
+ * entry with no coordinate to re-resolve at all.
2840
+ */
2841
+ export function getRefreshableManagedTools() {
2842
+ const refreshable = [];
2843
+ for (const tool of TOOLS) {
2844
+ switch (tool.installStrategy) {
2845
+ case "npm": {
2846
+ if (!tool.packageName)
2847
+ continue;
2848
+ if (parsePinnedVersion(tool.packageName) !== undefined)
2849
+ continue;
2850
+ // `binaryName` is what `installNpmTool` verifies after an install, and
2851
+ // the refresh has to verify the SAME path after an update. An npm
2852
+ // entry without one cannot be verified, so it is not refreshed either.
2853
+ if (!tool.binaryName)
2854
+ continue;
2855
+ refreshable.push({
2856
+ toolId: tool.id,
2857
+ strategy: "npm",
2858
+ packageName: tool.packageName,
2859
+ binaryName: tool.binaryName,
2860
+ });
2861
+ break;
2862
+ }
2863
+ case "pip":
2864
+ case "gem": {
2865
+ if (!tool.packageName)
2866
+ continue;
2867
+ refreshable.push({
2868
+ toolId: tool.id,
2869
+ strategy: tool.installStrategy,
2870
+ packageName: tool.packageName,
2871
+ });
2872
+ break;
2873
+ }
2874
+ case "github": {
2875
+ if (!tool.github)
2876
+ continue;
2877
+ refreshable.push({ toolId: tool.id, strategy: "github" });
2878
+ break;
2879
+ }
2880
+ case "maven": {
2881
+ if (!tool.maven)
2882
+ continue;
2883
+ refreshable.push({
2884
+ toolId: tool.id,
2885
+ strategy: "maven",
2886
+ pinnedCoordinate: mavenCoordinate(tool.maven),
2887
+ });
2888
+ break;
2889
+ }
2890
+ case "archive": {
2891
+ if (!tool.archive)
2892
+ continue;
2893
+ const url = resolveArchiveUrl(tool.archive);
2894
+ // No archive for this platform/arch: nothing is installed and nothing
2895
+ // can be refreshed.
2896
+ if (!url)
2897
+ continue;
2898
+ refreshable.push({
2899
+ toolId: tool.id,
2900
+ strategy: "archive",
2901
+ pinnedCoordinate: url,
2902
+ });
2903
+ break;
2904
+ }
2905
+ }
2906
+ }
2907
+ return refreshable;
2908
+ }
2909
+ /**
2910
+ * Is this tool present in a location pi-lens itself installs to?
2911
+ *
2912
+ * Deliberately filesystem-only — no spawn, no network. The refresh runs this
2913
+ * over candidates on a background timer, and a presence check that spawned a
2914
+ * probe per tool would cost more than the refresh it gates.
2915
+ *
2916
+ * A tool pi-lens has never installed is never refreshed: `pip install -U` or a
2917
+ * release download on an absent tool would turn a refresh into an unrequested
2918
+ * install.
2919
+ */
2920
+ export async function isManagedToolPresent(toolId) {
2921
+ const tool = TOOLS.find((t) => t.id === toolId);
2922
+ if (!tool)
2923
+ return false;
2924
+ switch (tool.installStrategy) {
2925
+ case "npm": {
2926
+ if (!tool.packageName)
2927
+ return false;
2928
+ try {
2929
+ await fs.access(path.join(TOOLS_DIR, "node_modules", tool.packageName, "package.json"));
2930
+ return true;
2931
+ }
2932
+ catch {
2933
+ return false;
2934
+ }
2935
+ }
2936
+ case "github":
2937
+ case "maven":
2938
+ return ((await findGitHubToolPath(tool.binaryName ?? tool.id)) !== undefined);
2939
+ case "archive":
2940
+ // A launcher archive leaves a shim in the managed bin; a TREE BUNDLE
2941
+ // leaves only its extract dir, confirmed via the tree marker.
2942
+ return ((await findGitHubToolPath(tool.binaryName ?? tool.id)) !== undefined ||
2943
+ (await getArchiveTreeBundlePath(tool)) !== undefined);
2944
+ case "pip":
2945
+ case "gem": {
2946
+ // pip/gem installs are not namespaced to pi-lens: they land in the
2947
+ // user's site/gem dirs, so there is no pi-lens-owned directory to look
2948
+ // in. The persisted probe cache is the record of where this tool last
2949
+ // resolved, and it is written from the same discovery pass the install
2950
+ // path uses. An entry whose file is gone is not presence.
2951
+ const cached = (await readProbeCache())[toolId];
2952
+ return cached?.path !== undefined && existsSync(cached.path);
2953
+ }
2954
+ default:
2955
+ return false;
2956
+ }
2957
+ }
2958
+ /**
2959
+ * Version this tool reports today, read by running its own `--version` probe.
2960
+ *
2961
+ * Only used for `pip` and `gem`, the two strategies with no readable
2962
+ * coordinate: their package managers install into user-owned directories with
2963
+ * no manifest pi-lens can read, so the binary itself is the only source. Costs
2964
+ * one spawn, bounded by the same one-tool-per-session budget as the refresh.
2965
+ */
2966
+ async function probeManagedToolVersion(tool) {
2967
+ const cached = (await readProbeCache())[tool.id];
2968
+ if (!cached?.path || !existsSync(cached.path))
2969
+ return undefined;
2970
+ try {
2971
+ const result = await safeSpawnAsync(cached.path, tool.checkArgs, {
2972
+ timeout: 10_000,
2973
+ ignoreAmbientSignal: true,
2974
+ resourceLabel: `tool-refresh-version:${tool.id}`,
2975
+ });
2976
+ if (result.status !== 0)
2977
+ return undefined;
2978
+ return extractVersionToken(`${result.stdout}\n${result.stderr}`);
2979
+ }
2980
+ catch {
2981
+ return undefined;
2982
+ }
2983
+ }
2984
+ /**
2985
+ * The three checks every install-triggering path must clear before touching
2986
+ * `TOOLS_DIR`: the `PI_LENS_DISABLE_TOOL_INSTALL` kill-switch, the
2987
+ * `assertInstallAllowed` project-trust gate (the #1334 S5 boundary), and the
2988
+ * shared install lock — acquired here, released by the caller when its
2989
+ * strategy work finishes.
2990
+ *
2991
+ * Shared by `refreshManagedTool` below (the five non-npm strategies) and
2992
+ * `refreshNpmOne` in `managed-tool-refresh.ts` (#1759 review R2), so a
2993
+ * refusal means the same thing everywhere — a declined, unstamped skip, not
2994
+ * a degradation — regardless of which of the six strategies asked. Before
2995
+ * this was pulled out as its own function, npm's refresh spawned `npm
2996
+ * update` directly with none of these three checks: the kill switch, the
2997
+ * trust gate and the lock governed five strategies and silently exempted
2998
+ * the sixth.
2999
+ */
3000
+ export async function acquireManagedInstallGate(context) {
3001
+ if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
3002
+ return {
3003
+ ok: false,
3004
+ reason: "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1",
3005
+ };
3006
+ }
3007
+ if (!assertInstallAllowed(context)) {
3008
+ return { ok: false, reason: `project trust: ${projectTrustDenialReason()}` };
3009
+ }
3010
+ // Held for the whole strategy call, not just the final write — a refresh
3011
+ // can race a concurrent `ensureTool` install of the SAME tool into the SAME
3012
+ // managed destination, and both write to `TOOLS_DIR` unlocked otherwise.
3013
+ const lock = await acquireInstallLock();
3014
+ if (!lock.release) {
3015
+ return { ok: false, reason: lock.reason ?? "install lock held" };
3016
+ }
3017
+ return { ok: true, release: lock.release };
3018
+ }
3019
+ /**
3020
+ * Re-resolve one non-npm managed tool and, only if its coordinate actually
3021
+ * moved, reinstall it (#1747).
3022
+ *
3023
+ * Never throws and never removes the installed copy: on any failure the
3024
+ * currently installed version keeps serving and the caller records a
3025
+ * degradation. npm is NOT handled here — `managed-tool-refresh.ts` owns that
3026
+ * path because it needs the package-manager resolver, and gates itself
3027
+ * through `acquireManagedInstallGate` directly.
3028
+ *
3029
+ * #1759 review F2: this used to call the strategy functions directly, which
3030
+ * bypassed every install guard `installTool`/`ensureTool` honor —
3031
+ * `PI_LENS_DISABLE_TOOL_INSTALL`, the `assertInstallAllowed` project-trust
3032
+ * gate (the #1334 S5 boundary), and `acquireInstallLock`. A refresh IS an
3033
+ * install trigger, just an unattended one, so it now passes through the same
3034
+ * three gates before any strategy runs. A refusal is `declined: true`, not a
3035
+ * degradation — see `ManagedToolRefreshAttempt.declined`.
3036
+ */
3037
+ export async function refreshManagedTool(toolId, known = {}) {
3038
+ const tool = TOOLS.find((t) => t.id === toolId);
3039
+ if (!tool) {
3040
+ return { ok: false, unchanged: true, reason: "unknown tool id" };
3041
+ }
3042
+ if (tool.installStrategy !== "github" &&
3043
+ tool.installStrategy !== "pip" &&
3044
+ tool.installStrategy !== "gem" &&
3045
+ tool.installStrategy !== "maven" &&
3046
+ tool.installStrategy !== "archive") {
3047
+ return {
3048
+ ok: false,
3049
+ unchanged: true,
3050
+ reason: `strategy ${tool.installStrategy} is not refreshable here`,
3051
+ };
3052
+ }
3053
+ // Captured into a local so the narrowing above survives the `await` below —
3054
+ // TS drops property narrowing (`tool.installStrategy`) across an `await`,
3055
+ // which would otherwise make the switch look non-exhaustive.
3056
+ const strategy = tool.installStrategy;
3057
+ const gate = await acquireManagedInstallGate(`managed tool refresh: ${toolId}`);
3058
+ if (!gate.ok) {
3059
+ return { ok: false, unchanged: true, declined: true, reason: gate.reason };
3060
+ }
3061
+ try {
3062
+ switch (strategy) {
3063
+ case "github":
3064
+ return await refreshGitHubManagedTool(tool, known);
3065
+ case "pip":
3066
+ case "gem":
3067
+ return await refreshPackageManagerManagedTool(tool);
3068
+ case "maven":
3069
+ case "archive":
3070
+ return await refreshPinnedManagedTool(tool, known);
3071
+ default:
3072
+ return {
3073
+ ok: false,
3074
+ unchanged: true,
3075
+ reason: `strategy ${strategy} is not refreshable here`,
3076
+ };
3077
+ }
3078
+ }
3079
+ finally {
3080
+ await gate.release?.();
3081
+ }
3082
+ }
3083
+ /**
3084
+ * `github`: ask `releases/latest` whether the tag moved, and download only if
3085
+ * it did.
3086
+ *
3087
+ * Three things keep 27 GitHub-release tools off GitHub's rate limit. The
3088
+ * per-tool weekly stamp means one query per tool per week; the one-refresh-per-
3089
+ * session budget means at most one query per session; and the stored `ETag`
3090
+ * replayed as `If-None-Match` makes the common answer a 304, which GitHub does
3091
+ * not charge against the limit at all. A 304, or a tag equal to the recorded
3092
+ * one, downloads nothing.
3093
+ */
3094
+ async function refreshGitHubManagedTool(tool, known) {
3095
+ const spec = tool.github;
3096
+ if (!spec)
3097
+ return { ok: false, unchanged: true, reason: "no github spec" };
3098
+ let response;
3099
+ try {
3100
+ response = await httpsGetWithMeta(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, {
3101
+ ...githubApiAuthHeaders(),
3102
+ ...(known.etag ? { "If-None-Match": known.etag } : {}),
3103
+ });
3104
+ }
3105
+ catch (err) {
3106
+ return {
3107
+ ok: false,
3108
+ unchanged: true,
3109
+ reason: `release query failed: ${err.message}`,
3110
+ };
3111
+ }
3112
+ if (response.statusCode === 304) {
3113
+ return {
3114
+ ok: true,
3115
+ unchanged: true,
3116
+ resolutionId: known.resolutionId,
3117
+ etag: known.etag,
3118
+ version: known.version,
3119
+ };
3120
+ }
3121
+ let release;
3122
+ try {
3123
+ const parsed = JSON.parse(response.body.toString("utf8"));
3124
+ // #1759 review F3: `JSON.parse` succeeds on `null`, an array, or a bare
3125
+ // string just as happily as on an object — any of those would throw
3126
+ // reading `.tag_name` below, uncaught, and skip every stamp write this
3127
+ // candidate ever gets. Reject the shape here instead of downstream.
3128
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
3129
+ throw new Error("release metadata is not an object");
3130
+ }
3131
+ release = parsed;
3132
+ }
3133
+ catch (err) {
3134
+ return {
3135
+ ok: false,
3136
+ unchanged: true,
3137
+ reason: `release metadata unparseable: ${err.message}`,
3138
+ };
3139
+ }
3140
+ const tag = release.tag_name;
3141
+ if (!tag) {
3142
+ return { ok: false, unchanged: true, reason: "release has no tag_name" };
3143
+ }
3144
+ if (known.resolutionId === tag) {
3145
+ return {
3146
+ ok: true,
3147
+ unchanged: true,
3148
+ resolutionId: tag,
3149
+ etag: response.etag ?? known.etag,
3150
+ version: known.version ?? tag,
3151
+ };
3152
+ }
3153
+ const installed = await installGitHubTool(tool, release);
3154
+ if (!installed) {
3155
+ return {
3156
+ ok: false,
3157
+ unchanged: true,
3158
+ // Keep the ETag off the failure stamp: replaying it would make the next
3159
+ // attempt a 304 and skip the download this one never completed.
3160
+ resolutionId: known.resolutionId,
3161
+ version: known.version,
3162
+ reason: `install from release ${tag} failed`,
3163
+ };
3164
+ }
3165
+ const verified = await verifyRefreshedArtifact(tool, installed);
3166
+ if (!verified) {
3167
+ return {
3168
+ ok: false,
3169
+ unchanged: false,
3170
+ resolutionId: known.resolutionId,
3171
+ version: known.version,
3172
+ reason: `release ${tag} installed but its binary does not run`,
3173
+ };
3174
+ }
3175
+ return {
3176
+ ok: true,
3177
+ unchanged: false,
3178
+ resolutionId: tag,
3179
+ etag: response.etag,
3180
+ version: tag,
3181
+ };
3182
+ }
3183
+ /**
3184
+ * Confirm a just-refreshed artifact still runs, and re-stamp the probe cache
3185
+ * with its new mtime.
3186
+ *
3187
+ * A refresh REPLACES a working binary in place. Without this check a release
3188
+ * that ships a broken or wrong-architecture asset would be written over a
3189
+ * healthy copy and surface later as a dispatch failure with no obvious cause.
3190
+ * The verdict goes back to the caller, which degrades and marks the stamp
3191
+ * failed so the shorter retry cooldown applies.
3192
+ *
3193
+ * An archive TREE BUNDLE has no single binary to run; `installArchiveTool`
3194
+ * already confirms its tree marker, so the install's own success is the check.
3195
+ */
3196
+ async function verifyRefreshedArtifact(tool, installedPath) {
3197
+ if (tool.installStrategy === "archive" && !tool.archive?.launcher) {
3198
+ // A tree bundle has no single binary to run — `installArchiveTool` already
3199
+ // confirmed its tree marker — but it still needs its NEW mtime recorded
3200
+ // (#1759 review F7), or the persisted probe entry keeps the pre-refresh
3201
+ // mtime and forces a full re-resolution on the next dispatch anyway.
3202
+ await updateProbeCache(tool.id, installedPath);
3203
+ return true;
3204
+ }
3205
+ if (!(await verifyToolBinary(installedPath))) {
3206
+ logSessionStart(`managed-tool-refresh ${tool.id}: refreshed artifact at ${installedPath} failed its --version check`);
3207
+ return false;
3208
+ }
3209
+ // Record the NEW mtime rather than leaving the persisted entry to miss its
3210
+ // mtime check and force a full re-resolution on the next dispatch.
3211
+ await updateProbeCache(tool.id, installedPath);
3212
+ return true;
3213
+ }
3214
+ /**
3215
+ * `pip` and `gem`: re-run pi-lens's own install command in upgrade form.
3216
+ *
3217
+ * There is no coordinate to compare first — the registry pins no version and
3218
+ * the package managers install into user-owned directories with no manifest
3219
+ * pi-lens can read — so the tool's own `--version` output before and after is
3220
+ * what says whether anything moved.
3221
+ */
3222
+ async function refreshPackageManagerManagedTool(tool) {
3223
+ if (!tool.packageName) {
3224
+ return { ok: false, unchanged: true, reason: "no package name" };
3225
+ }
3226
+ const previous = await probeManagedToolVersion(tool);
3227
+ const installed = tool.installStrategy === "pip"
3228
+ ? // `-U` is the whole fix: without it pip treats the installed copy as
3229
+ // satisfying the requirement and the day-one version never moves.
3230
+ await installPipTool(tool.packageName, { upgrade: true })
3231
+ : // `gem install` always fetches the newest version that satisfies the
3232
+ // requirement, so the install command IS the upgrade command.
3233
+ await installGemTool(tool.packageName);
3234
+ if (!installed) {
3235
+ return {
3236
+ ok: false,
3237
+ unchanged: true,
3238
+ version: previous,
3239
+ reason: `${tool.installStrategy} upgrade failed`,
3240
+ };
3241
+ }
3242
+ const current = await probeManagedToolVersion(tool);
3243
+ if (previous !== undefined && current === undefined) {
3244
+ // The tool answered `--version` before the upgrade and does not now: the
3245
+ // upgrade replaced a working copy with a broken one. Say so rather than
3246
+ // stamping success on a tool that no longer runs.
3247
+ return {
3248
+ ok: false,
3249
+ unchanged: false,
3250
+ version: previous,
3251
+ reason: `${tool.installStrategy} upgrade left the binary unable to report a version`,
3252
+ };
3253
+ }
3254
+ return {
3255
+ ok: true,
3256
+ // An unreadable version on both sides cannot prove a move, so it reads as
3257
+ // unchanged rather than inventing one.
3258
+ unchanged: previous === undefined || current === undefined || previous === current,
3259
+ version: current ?? previous,
3260
+ };
3261
+ }
3262
+ /**
3263
+ * `maven` and `archive`: both pin an explicit version in THIS registry, so
3264
+ * their coordinate cannot move upstream — it moves when pi-lens bumps it.
3265
+ *
3266
+ * The freeze for these two is therefore the mirror image of the npm one: the
3267
+ * installer only installs when the tool is ABSENT, so a repo that bumps
3268
+ * spotbugs from 4.10.2 to 4.11.0 leaves every existing machine on 4.10.2
3269
+ * forever. Comparing the recorded coordinate against the registry's current one
3270
+ * catches exactly that, and costs no network when they match.
3271
+ *
3272
+ * A tool with no recorded coordinate is reinstalled once. pi-lens cannot read
3273
+ * which pin the installed artifact came from — an archive bundle carries no
3274
+ * manifest it writes — so the only way to make the installed copy provably
3275
+ * match the declared pin is to install it. That happens at most once per tool,
3276
+ * ever, and the stamp it writes suppresses it from then on.
3277
+ */
3278
+ async function refreshPinnedManagedTool(tool, known) {
3279
+ const coordinate = tool.installStrategy === "maven"
3280
+ ? tool.maven && mavenCoordinate(tool.maven)
3281
+ : tool.archive && resolveArchiveUrl(tool.archive);
3282
+ if (!coordinate) {
3283
+ return {
3284
+ ok: false,
3285
+ unchanged: true,
3286
+ reason: "no coordinate for this platform",
3287
+ };
3288
+ }
3289
+ if (known.resolutionId === coordinate) {
3290
+ return {
3291
+ ok: true,
3292
+ unchanged: true,
3293
+ resolutionId: coordinate,
3294
+ version: known.version,
3295
+ };
3296
+ }
3297
+ const installed = tool.installStrategy === "maven"
3298
+ ? await installMavenTool(tool)
3299
+ : await installArchiveTool(tool);
3300
+ if (!installed) {
3301
+ return {
3302
+ ok: false,
3303
+ unchanged: true,
3304
+ resolutionId: known.resolutionId,
3305
+ version: known.version,
3306
+ reason: `reinstall from ${coordinate} failed`,
3307
+ };
3308
+ }
3309
+ if (!(await verifyRefreshedArtifact(tool, installed))) {
3310
+ return {
3311
+ ok: false,
3312
+ unchanged: false,
3313
+ resolutionId: known.resolutionId,
3314
+ version: known.version,
3315
+ reason: `reinstall from ${coordinate} produced an artifact that does not run`,
3316
+ };
3317
+ }
3318
+ return {
3319
+ ok: true,
3320
+ // A first-ever stamp is an adoption, not a version move: the coordinate did
3321
+ // not change, pi-lens simply had no record of it. Reporting it as a move
3322
+ // would put a false "x → y" row in the log.
3323
+ unchanged: known.resolutionId === undefined,
3324
+ resolutionId: coordinate,
3325
+ version: coordinate,
3326
+ };
3327
+ }
3328
+ /**
3329
+ * The managed `node_modules/.bin` path for a tool binary, spelled the way
3330
+ * `installNpmTool` spells it (npm writes a `.cmd` shim on Windows, and that
3331
+ * shim — not its extensionless POSIX sibling — is the executable).
3332
+ *
3333
+ * Exported so the periodic refresh (#1730) verifies the same path the install
3334
+ * verified, instead of rebuilding the convention next to it.
3335
+ */
3336
+ export function resolveManagedNpmBinPath(binaryName) {
3337
+ const binBase = path.join(TOOLS_DIR, "node_modules", ".bin", binaryName);
3338
+ return installerPlatform() === "win32" ? `${binBase}.cmd` : binBase;
3339
+ }
3340
+ /**
3341
+ * Drop every cached claim about where a tool resolves: the in-memory
3342
+ * resolved-path cache and the 24h on-disk probe cache.
3343
+ *
3344
+ * The probe cache keys on the binary's path and mtime, and a package-manager
3345
+ * update rewrites the package while frequently leaving the `.bin` shim's mtime
3346
+ * alone. Without this, a refresh that changed the version — or left a binary
3347
+ * that no longer runs — kept serving the previous answer from cache for up to
3348
+ * a day (#1746 review F2). Callers fall through to a fresh probe, which is the
3349
+ * path that can repair a broken install.
3350
+ */
3351
+ export function invalidateManagedToolResolution(toolId) {
3352
+ resolvedPathCache.delete(toolId);
3353
+ if (_probeCache !== null)
3354
+ delete _probeCache[toolId];
3355
+ markProbeCacheChange(toolId, null);
3356
+ }
2549
3357
  const MAVEN_CENTRAL_BASE = "https://repo1.maven.org/maven2";
2550
3358
  /**
2551
3359
  * Install a Maven-distributed runnable fat JAR: download it into the managed bin
@@ -2571,6 +3379,7 @@ async function installMavenTool(tool) {
2571
3379
  const groupPath = spec.groupId.replace(/\./g, "/");
2572
3380
  const jarFile = `${spec.artifactId}-${spec.version}${spec.classifier ? `-${spec.classifier}` : ""}.jar`;
2573
3381
  const url = `${base}/${groupPath}/${spec.artifactId}/${spec.version}/${jarFile}`;
3382
+ lastInstallResolutionId.set(tool.id, mavenCoordinate(spec));
2574
3383
  logSessionStart(`maven-install ${tool.id}: downloading ${url}`);
2575
3384
  let jarBuffer;
2576
3385
  try {
@@ -2583,15 +3392,15 @@ async function installMavenTool(tool) {
2583
3392
  try {
2584
3393
  await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
2585
3394
  const jarPath = path.join(GITHUB_BIN_DIR, `${tool.id}.jar`);
2586
- await fs.writeFile(jarPath, jarBuffer);
3395
+ await writeFileAtomicAsync(jarPath, jarBuffer, { bestEffort: false });
2587
3396
  // Launcher so the tool resolves as a normal command in the managed bin.
2588
3397
  const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
2589
3398
  const launcherPath = path.join(GITHUB_BIN_DIR, launcherName);
2590
3399
  if (isWindows) {
2591
- await fs.writeFile(launcherPath, `@echo off\r\njava -jar "%~dp0${tool.id}.jar" %*\r\n`);
3400
+ await writeFileAtomicAsync(launcherPath, `@echo off\r\njava -jar "%~dp0${tool.id}.jar" %*\r\n`, { bestEffort: false });
2592
3401
  }
2593
3402
  else {
2594
- await fs.writeFile(launcherPath, `#!/bin/sh\nexec java -jar "$(dirname "$0")/${tool.id}.jar" "$@"\n`, { mode: 0o750 });
3403
+ await writeFileAtomicAsync(launcherPath, `#!/bin/sh\nexec java -jar "$(dirname "$0")/${tool.id}.jar" "$@"\n`, { bestEffort: false, mode: 0o750 });
2595
3404
  }
2596
3405
  logSessionStart(`maven-install ${tool.id}: installed → ${launcherPath} (${jarBuffer.length} bytes)`);
2597
3406
  debugLog(`[maven] installed ${tool.name} → ${launcherPath}`);
@@ -2619,6 +3428,76 @@ async function installMavenTool(tool) {
2619
3428
  export function resolveArchiveUrl(spec, platform = process.platform, arch = process.arch) {
2620
3429
  return typeof spec.url === "function" ? spec.url(platform, arch) : spec.url;
2621
3430
  }
3431
+ /**
3432
+ * Move a verified-good extracted tree into its final, stable location without
3433
+ * ever leaving `finalDir` empty or missing while a working copy was there
3434
+ * (#1759 review F1).
3435
+ *
3436
+ * Both renames are same-volume directory renames (TOOLS_DIR never spans a
3437
+ * filesystem boundary here), so each one individually is as close to atomic
3438
+ * as the OS gives us. The gap between them — `finalDir` briefly absent — is
3439
+ * unavoidable without OS-level directory-swap support, but it is now
3440
+ * MICROSECONDS, not "however long the network download and tar extraction
3441
+ * take", and a failure on the second rename restores the backup rather than
3442
+ * leaving `finalDir` gone.
3443
+ *
3444
+ * Worst case (#1759 review R4): the second rename AND the rollback rename it
3445
+ * triggers both fail (e.g. the volume went read-only mid-swap). `finalDir` is
3446
+ * then genuinely empty and the only surviving copy sits at `finalDir.rollback`
3447
+ * — this function still throws either way, so the caller's install/refresh
3448
+ * reports failure rather than success, and `invalidateManagedToolResolution`
3449
+ * on that failure path (#1759 review F1) is what actually recovers: it drops
3450
+ * the cached resolved path, so the next `ensureTool` probe finds nothing at
3451
+ * `finalDir`, treats the tool as absent, and reinstalls from scratch. The
3452
+ * orphaned `.rollback` directory is NOT reclaimed by that reinstall — it is
3453
+ * only cleaned up the next time `swapExtractedDir` runs a SUCCESSFUL swap for
3454
+ * this same tool (the unconditional `fs.rm(backupDir, ...)` below), so it can
3455
+ * sit on disk for a while. The degradation this records below is what makes
3456
+ * that orphan discoverable rather than a silent leak.
3457
+ */
3458
+ // Exported for the rollback-path unit test (#1759 review R3) — the failure
3459
+ // this guards is only reachable by making the SECOND rename throw, which a
3460
+ // full `installArchiveTool` run has no test-controlled pause point to force
3461
+ // without mocking `node:fs` wholesale.
3462
+ export async function swapExtractedDir(toolId, tmpDir, finalDir) {
3463
+ const backupDir = `${finalDir}.rollback`;
3464
+ await fs.rm(backupDir, { recursive: true, force: true });
3465
+ let hadPrevious = false;
3466
+ try {
3467
+ await fs.rename(finalDir, backupDir);
3468
+ hadPrevious = true;
3469
+ }
3470
+ catch (err) {
3471
+ if (err.code !== "ENOENT")
3472
+ throw err;
3473
+ }
3474
+ try {
3475
+ await fs.rename(tmpDir, finalDir);
3476
+ }
3477
+ catch (err) {
3478
+ if (hadPrevious) {
3479
+ try {
3480
+ await fs.rename(backupDir, finalDir);
3481
+ }
3482
+ catch (rollbackErr) {
3483
+ // The double-failure worst case: `finalDir` is empty and the only
3484
+ // surviving copy is the orphan at `backupDir`. Record it — this is
3485
+ // the one path where a swallowed catch here would make that orphan
3486
+ // invisible, not just inconvenient.
3487
+ recordDegradationOnce({
3488
+ kind: "managed-tool-refresh",
3489
+ subject: toolId,
3490
+ reason: `swap rollback failed after a failed install: working copy orphaned at ${backupDir} (${rollbackErr.message})`,
3491
+ });
3492
+ logSessionStart(`archive-install ${toolId}: swap rollback failed — working copy orphaned at ${backupDir} (${rollbackErr.message})`);
3493
+ }
3494
+ }
3495
+ throw err;
3496
+ }
3497
+ if (hadPrevious) {
3498
+ await fs.rm(backupDir, { recursive: true, force: true }).catch(() => { });
3499
+ }
3500
+ }
2622
3501
  async function installArchiveTool(tool) {
2623
3502
  const spec = tool.archive;
2624
3503
  if (!spec)
@@ -2630,6 +3509,7 @@ async function installArchiveTool(tool) {
2630
3509
  logSessionStart(`archive-install ${tool.id}: no archive for ${process.platform}/${process.arch} — unsupported, skipping`);
2631
3510
  return undefined;
2632
3511
  }
3512
+ lastInstallResolutionId.set(tool.id, url);
2633
3513
  logSessionStart(`archive-install ${tool.id}: downloading ${url}`);
2634
3514
  let archiveBuffer;
2635
3515
  try {
@@ -2644,15 +3524,27 @@ async function installArchiveTool(tool) {
2644
3524
  // `host:path` ("Cannot connect to C:"). Relative paths work for both GNU tar
2645
3525
  // and Windows bsdtar, so we avoid the GNU-only `--force-local` (which bsdtar
2646
3526
  // rejects). fs.* calls still use the absolute paths.
3527
+ //
3528
+ // #1759 review F1: extraction and verification happen in a TMP dir, never
3529
+ // in `extractDir` itself. `extractDir` is the location a refresh's caller
3530
+ // (and every prior session) is currently serving from; the old code
3531
+ // `fs.rm`'d it before extraction even started, so a corrupt download or a
3532
+ // failed tar left the tool GONE rather than "kept the installed version" —
3533
+ // exactly the outcome the failure log line claimed did not happen. The
3534
+ // installed copy is now untouched until the replacement is proven good.
2647
3535
  const extractName = tool.id;
3536
+ const tmpExtractName = `${extractName}.refresh-tmp`;
2648
3537
  const archiveName = `${tool.id}.download.${spec.kind === "zip" ? "zip" : "tgz"}`;
2649
3538
  const extractDir = path.join(TOOLS_DIR, extractName);
3539
+ const tmpExtractDir = path.join(TOOLS_DIR, tmpExtractName);
2650
3540
  const tmpArchive = path.join(TOOLS_DIR, archiveName);
2651
3541
  try {
2652
3542
  await fs.mkdir(TOOLS_DIR, { recursive: true });
2653
- // Clear any prior extraction so a reinstall is clean.
2654
- await fs.rm(extractDir, { recursive: true, force: true });
2655
- await fs.mkdir(extractDir, { recursive: true });
3543
+ // Clear any stale tmp dir from an interrupted prior attempt this is the
3544
+ // SCRATCH location, never the live install, so clearing it never risks the
3545
+ // working copy.
3546
+ await fs.rm(tmpExtractDir, { recursive: true, force: true });
3547
+ await fs.mkdir(tmpExtractDir, { recursive: true });
2656
3548
  await fs.writeFile(tmpArchive, archiveBuffer);
2657
3549
  // `--strip-components=N` drops N leading path components. Default 1 drops a
2658
3550
  // versioned top-level dir so a launcher path stays stable (bin/… not
@@ -2664,10 +3556,8 @@ async function installArchiveTool(tool) {
2664
3556
  spec.kind === "tgz" ? "-xzf" : "-xf",
2665
3557
  archiveName,
2666
3558
  "-C",
2667
- extractName,
2668
- ...(stripComponents > 0
2669
- ? [`--strip-components=${stripComponents}`]
2670
- : []),
3559
+ tmpExtractName,
3560
+ ...(stripComponents > 0 ? [`--strip-components=${stripComponents}`] : []),
2671
3561
  ];
2672
3562
  // Resolve `tar` to an absolute path on Windows (System32\tar.exe is the
2673
3563
  // bsdtar shipped with Windows 10+) so extraction can't be hijacked via a
@@ -2689,39 +3579,54 @@ async function installArchiveTool(tool) {
2689
3579
  };
2690
3580
  await fs.rm(tmpArchive, { force: true });
2691
3581
  if (!extracted.ok) {
2692
- logSessionStart(`archive-install ${tool.id}: extraction failed: ${extracted.stderr}`);
3582
+ logSessionStart(`archive-install ${tool.id}: extraction failed: ${extracted.stderr} — keeping installed version`);
3583
+ await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
2693
3584
  return undefined;
2694
3585
  }
2695
3586
  // Tree bundle (no launcher): the whole extracted tree IS the artifact. Verify
2696
- // the marker exists and resolve to the extract dir the consuming server
2697
- // launches a runtime against a bootstrap inside it (e.g. PSES via pwsh).
3587
+ // the marker exists in the TMP dir, before it becomes the live one — the
3588
+ // consuming server launches a runtime against a bootstrap inside it (e.g.
3589
+ // PSES via pwsh).
2698
3590
  if (!spec.launcher) {
2699
- const marker = spec.treeMarker
2700
- ? path.join(extractDir, ...spec.treeMarker.split("/"))
2701
- : extractDir;
3591
+ const tmpMarker = spec.treeMarker
3592
+ ? path.join(tmpExtractDir, ...spec.treeMarker.split("/"))
3593
+ : tmpExtractDir;
2702
3594
  try {
2703
- await fs.access(marker);
3595
+ await fs.access(tmpMarker);
2704
3596
  }
2705
3597
  catch {
2706
- logSessionStart(`archive-install ${tool.id}: tree marker not found at ${marker} after extraction`);
3598
+ logSessionStart(`archive-install ${tool.id}: tree marker not found at ${tmpMarker} after extraction — keeping installed version`);
3599
+ await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
2707
3600
  return undefined;
2708
3601
  }
3602
+ await swapExtractedDir(tool.id, tmpExtractDir, extractDir);
2709
3603
  logSessionStart(`archive-install ${tool.id}: installed tree bundle → ${extractDir} (extracted ${archiveBuffer.length} bytes)`);
2710
3604
  debugLog(`[archive] installed ${tool.name} bundle → ${extractDir}`);
2711
3605
  return extractDir;
2712
3606
  }
2713
- // The launcher inside the extracted tree (e.g. bin/spotbugs[.bat]).
2714
- const innerLauncher = path.join(extractDir, ...spec.launcher.split("/").map((p) => p));
2715
- const resolvedInner = isWindows ? `${innerLauncher}.bat` : innerLauncher;
3607
+ // The launcher inside the TMP extracted tree (e.g. bin/spotbugs[.bat]),
3608
+ // verified before the tree becomes the live one.
3609
+ const launcherParts = spec.launcher.split("/").map((p) => p);
3610
+ const tmpInnerLauncher = path.join(tmpExtractDir, ...launcherParts);
3611
+ const tmpResolvedInner = isWindows
3612
+ ? `${tmpInnerLauncher}.bat`
3613
+ : tmpInnerLauncher;
2716
3614
  try {
2717
- await fs.access(resolvedInner);
3615
+ await fs.access(tmpResolvedInner);
2718
3616
  }
2719
3617
  catch {
2720
- logSessionStart(`archive-install ${tool.id}: launcher not found at ${resolvedInner} after extraction`);
3618
+ logSessionStart(`archive-install ${tool.id}: launcher not found at ${tmpResolvedInner} after extraction — keeping installed version`);
3619
+ await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
2721
3620
  return undefined;
2722
3621
  }
2723
3622
  if (!isWindows)
2724
- await fs.chmod(resolvedInner, 0o750).catch(() => { });
3623
+ await fs.chmod(tmpResolvedInner, 0o750).catch(() => { });
3624
+ await swapExtractedDir(tool.id, tmpExtractDir, extractDir);
3625
+ // The launcher path inside `extractDir` — stable across every install and
3626
+ // refresh, since `extractDir` itself never changes — computed fresh now
3627
+ // that the swap has put the verified tree there.
3628
+ const innerLauncher = path.join(extractDir, ...launcherParts);
3629
+ const resolvedInner = isWindows ? `${innerLauncher}.bat` : innerLauncher;
2725
3630
  // Thin shim in the managed bin so discovery (findGitHubToolPath) resolves
2726
3631
  // it like any other managed tool. `call`/`exec` preserves the real
2727
3632
  // launcher's own %~dp0/$0 so it still finds its sibling lib/.
@@ -2729,10 +3634,10 @@ async function installArchiveTool(tool) {
2729
3634
  const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
2730
3635
  const shimPath = path.join(GITHUB_BIN_DIR, launcherName);
2731
3636
  if (isWindows) {
2732
- await fs.writeFile(shimPath, `@echo off\r\ncall "${resolvedInner}" %*\r\n`);
3637
+ await writeFileAtomicAsync(shimPath, `@echo off\r\ncall "${resolvedInner}" %*\r\n`, { bestEffort: false });
2733
3638
  }
2734
3639
  else {
2735
- await fs.writeFile(shimPath, `#!/bin/sh\nexec "${resolvedInner}" "$@"\n`, { mode: 0o750 });
3640
+ await writeFileAtomicAsync(shimPath, `#!/bin/sh\nexec "${resolvedInner}" "$@"\n`, { bestEffort: false, mode: 0o750 });
2736
3641
  }
2737
3642
  logSessionStart(`archive-install ${tool.id}: installed → ${shimPath} (extracted ${archiveBuffer.length} bytes)`);
2738
3643
  debugLog(`[archive] installed ${tool.name} → ${shimPath}`);
@@ -2740,7 +3645,8 @@ async function installArchiveTool(tool) {
2740
3645
  }
2741
3646
  catch (err) {
2742
3647
  await fs.rm(tmpArchive, { force: true }).catch(() => { });
2743
- logSessionStart(`archive-install ${tool.id}: install failed: ${err.message}`);
3648
+ await fs.rm(tmpExtractDir, { recursive: true, force: true }).catch(() => { });
3649
+ logSessionStart(`archive-install ${tool.id}: install failed: ${err.message} — keeping installed version`);
2744
3650
  return undefined;
2745
3651
  }
2746
3652
  }
@@ -2754,7 +3660,7 @@ async function installNpmTool(packageName, binaryName) {
2754
3660
  await fs.access(packageJsonPath);
2755
3661
  }
2756
3662
  catch {
2757
- await fs.writeFile(packageJsonPath, JSON.stringify({ name: "pi-lens-tools", version: "1.0.0" }, null, 2));
3663
+ await writeFileAtomicAsync(packageJsonPath, JSON.stringify({ name: "pi-lens-tools", version: "1.0.0" }, null, 2), { bestEffort: false });
2758
3664
  }
2759
3665
  // Resolve the package manager for the tools dir and build install args.
2760
3666
  const isWindows = installerPlatform() === "win32";
@@ -2859,31 +3765,43 @@ async function installNpmTool(packageName, binaryName) {
2859
3765
  /**
2860
3766
  * Install a pip package tool
2861
3767
  */
2862
- async function installPipTool(packageName) {
3768
+ async function installPipTool(packageName,
3769
+ /**
3770
+ * Add `-U`, turning the install into an upgrade. Without it `pip install`
3771
+ * treats an already-present package as satisfied and leaves the day-one
3772
+ * version in place forever — the freeze #1747 is about. The flag is the ONLY
3773
+ * difference between install and refresh: same command ladder, same
3774
+ * `--user` target, so a refresh can never write somewhere the install would
3775
+ * not have.
3776
+ */
3777
+ options = {}) {
2863
3778
  try {
2864
3779
  const isWindows = process.platform === "win32";
3780
+ const verb = options.upgrade
3781
+ ? ["install", "-U", "--user"]
3782
+ : ["install", "--user"];
2865
3783
  const pipCandidates = isWindows
2866
3784
  ? [
2867
- { command: "pip", args: ["install", "--user", packageName] },
3785
+ { command: "pip", args: [...verb, packageName] },
2868
3786
  {
2869
3787
  command: "py",
2870
- args: ["-m", "pip", "install", "--user", packageName],
3788
+ args: ["-m", "pip", ...verb, packageName],
2871
3789
  },
2872
3790
  {
2873
3791
  command: "python",
2874
- args: ["-m", "pip", "install", "--user", packageName],
3792
+ args: ["-m", "pip", ...verb, packageName],
2875
3793
  },
2876
3794
  ]
2877
3795
  : [
2878
- { command: "pip3", args: ["install", "--user", packageName] },
2879
- { command: "pip", args: ["install", "--user", packageName] },
3796
+ { command: "pip3", args: [...verb, packageName] },
3797
+ { command: "pip", args: [...verb, packageName] },
2880
3798
  {
2881
3799
  command: "python3",
2882
- args: ["-m", "pip", "install", "--user", packageName],
3800
+ args: ["-m", "pip", ...verb, packageName],
2883
3801
  },
2884
3802
  {
2885
3803
  command: "python",
2886
- args: ["-m", "pip", "install", "--user", packageName],
3804
+ args: ["-m", "pip", ...verb, packageName],
2887
3805
  },
2888
3806
  ];
2889
3807
  let lastError = "";
@@ -3007,8 +3925,46 @@ async function installGemTool(packageName) {
3007
3925
  return undefined;
3008
3926
  }
3009
3927
  }
3928
+ /**
3929
+ * Stamp the refresh state at INSTALL time (#1747).
3930
+ *
3931
+ * An install has just done, by definition, the freshest possible resolution.
3932
+ * Recording it here means the periodic refresh starts its cadence from the
3933
+ * install rather than treating a day-old tool as never checked — and it is what
3934
+ * spares the archive/maven entries a redundant re-download the first time their
3935
+ * coordinate comparison runs with no recorded pin.
3936
+ *
3937
+ * npm is skipped: its install case already calls `stampManagedToolInstalled`
3938
+ * directly (#1746 review F4), which reads the installed version off disk. That
3939
+ * call happens synchronously before this funnel runs, so stamping npm again
3940
+ * here — with no version, since `lastInstallResolutionId` is never set for
3941
+ * npm — would overwrite the version-bearing stamp with a version-less one.
3942
+ *
3943
+ * Lazily imported so `installer/index.ts` keeps no static dependency on the
3944
+ * refresh module (which imports this one). Best-effort: a stamp that cannot be
3945
+ * written only costs one extra re-resolution later, so it never fails an
3946
+ * install.
3947
+ */
3948
+ async function stampInstallResolution(toolId) {
3949
+ const tool = TOOLS.find((t) => t.id === toolId);
3950
+ if (tool?.installStrategy === "npm")
3951
+ return;
3952
+ try {
3953
+ const resolutionId = lastInstallResolutionId.get(toolId);
3954
+ const refresh = await import("./managed-tool-refresh.js");
3955
+ await refresh.stampManagedToolInstall(toolId, resolutionId);
3956
+ }
3957
+ catch {
3958
+ // best-effort
3959
+ }
3960
+ }
3010
3961
  async function finishInstallAttempt(toolId, ok, startedAt) {
3011
3962
  logSessionStart(`auto-install ${toolId}: ${ok ? "success" : "failed"} (${Date.now() - startedAt}ms)`);
3963
+ if (ok)
3964
+ await stampInstallResolution(toolId);
3965
+ // Every install strategy funnels its outcome through here, so this one write
3966
+ // records attempt-ness for all of them (#1500).
3967
+ noteInstallAttempt(toolId, ok ? "succeeded" : "failed", ok ? undefined : (installFailureReasons.get(toolId) ?? "install failed"));
3012
3968
  if (ok) {
3013
3969
  // A prior availability probe may have cached ENOENT for this exact child
3014
3970
  // PATH. Make a successful mutation visible immediately rather than waiting
@@ -3040,11 +3996,13 @@ async function finishInstallAttempt(toolId, ok, startedAt) {
3040
3996
  export async function installTool(toolId) {
3041
3997
  if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
3042
3998
  installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
3999
+ noteInstallAttempt(toolId, "declined", "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
3043
4000
  logSessionStart(`auto-install ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
3044
4001
  return false;
3045
4002
  }
3046
4003
  const tool = TOOLS.find((t) => t.id === toolId);
3047
4004
  if (!tool) {
4005
+ noteInstallAttempt(toolId, "declined", "unknown tool id");
3048
4006
  logSessionStart(`auto-install ${toolId}: unknown tool id`);
3049
4007
  return false;
3050
4008
  }
@@ -3056,6 +4014,26 @@ export async function installTool(toolId) {
3056
4014
  if (!tool.packageName || !tool.binaryName)
3057
4015
  return false;
3058
4016
  const npmPath = await installNpmTool(tool.packageName, tool.binaryName);
4017
+ if (npmPath !== undefined) {
4018
+ // #1746 review F4: an install just resolved this package's range
4019
+ // against the registry, so record it as freshly checked. Otherwise a
4020
+ // new machine that installs 22 tools today has 22 unstamped tools,
4021
+ // and spends the next 22 sessions running `npm update` on packages
4022
+ // it installed minutes ago.
4023
+ //
4024
+ // Dynamic import, not a static one: managed-tool-refresh.ts imports
4025
+ // THIS module for the tool registry, and a static import back would
4026
+ // be a cycle. By the time this line runs the module graph is long
4027
+ // since evaluated, so the lazy import is safe and also keeps the
4028
+ // refresh module off the startup path.
4029
+ await import("./managed-tool-refresh.js")
4030
+ .then((m) => m.stampManagedToolInstalled(tool.id, tool.packageName))
4031
+ .catch(() => {
4032
+ // Best-effort telemetry: a missing stamp costs one wasted
4033
+ // update later, never a wrong version, and must not fail the
4034
+ // install that just succeeded.
4035
+ });
4036
+ }
3059
4037
  return finishInstallAttempt(tool.id, npmPath !== undefined, startedAt);
3060
4038
  }
3061
4039
  case "pip": {
@@ -3112,16 +4090,27 @@ export async function ensureTool(toolId, opts) {
3112
4090
  if (opts?.allowInstall !== false &&
3113
4091
  !assertInstallAllowed(`managed tool ensure: ${toolId}`)) {
3114
4092
  logSessionStart(`auto-install ensure ${toolId}: install gated — ${projectTrustDenialReason()}; discovery only`);
3115
- return ensureToolResolved(toolId, { ...opts, allowInstall: false });
4093
+ const denialReason = projectTrustDenialReason();
4094
+ const discovered = await ensureToolResolved(toolId, {
4095
+ ...opts,
4096
+ allowInstall: false,
4097
+ });
4098
+ // AFTER the discovery pass, which clears the per-attempt record on entry.
4099
+ noteInstallAttempt(toolId, "declined", `project trust: ${denialReason}`);
4100
+ return discovered;
3116
4101
  }
3117
4102
  return ensureToolResolved(toolId, opts);
3118
4103
  }
3119
4104
  async function ensureToolResolved(toolId, opts) {
3120
4105
  installFailureReasons.delete(toolId);
4106
+ // A fresh ensure supersedes whatever the last one recorded, and the trust-gate
4107
+ // branch above deliberately keeps its `declined` record by never reaching here.
4108
+ installAttempts.delete(toolId);
4109
+ lastEnsureResolutionSource.delete(toolId);
3121
4110
  const cacheResolvedPath = (result) => {
3122
4111
  if (result) {
3123
4112
  resolvedPathCache.set(toolId, result);
3124
- void updateProbeCache(toolId, result);
4113
+ void updateProbeCache(toolId, result, wasLastResolveTransient(toolId));
3125
4114
  }
3126
4115
  return result;
3127
4116
  };
@@ -3144,16 +4133,19 @@ async function ensureToolResolved(toolId, opts) {
3144
4133
  // best-effort
3145
4134
  }
3146
4135
  if (opts.allowInstall === false) {
4136
+ noteInstallAttempt(toolId, "declined", "install disabled by caller");
3147
4137
  logSessionStart(`auto-install ensure ${toolId}: force reinstall blocked — install disabled, discovery only (${Date.now() - ensureStartMs}ms)`);
3148
4138
  return cacheResolvedPath(await getToolPath(toolId));
3149
4139
  }
3150
4140
  if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
3151
4141
  installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
4142
+ noteInstallAttempt(toolId, "declined", "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
3152
4143
  logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
3153
4144
  return undefined;
3154
4145
  }
3155
4146
  const lock = await acquireInstallLock();
3156
4147
  if (!lock.release) {
4148
+ noteInstallAttempt(toolId, "skipped", lock.reason ?? "install lock held");
3157
4149
  logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
3158
4150
  return undefined;
3159
4151
  }
@@ -3165,6 +4157,9 @@ async function ensureToolResolved(toolId, opts) {
3165
4157
  await lock.release();
3166
4158
  }
3167
4159
  if (!installed) {
4160
+ // installTool RAN. Whatever it recorded stands; otherwise this is a
4161
+ // genuine failure, which is what the caller needs to see (#1500).
4162
+ noteInstallAttemptIfUnrecorded(toolId, "failed", "install failed");
3168
4163
  logSessionStart(`auto-install ensure ${toolId}: force reinstall failed (${Date.now() - ensureStartMs}ms)`);
3169
4164
  return undefined;
3170
4165
  }
@@ -3178,10 +4173,13 @@ async function ensureToolResolved(toolId, opts) {
3178
4173
  // Fast path 1: in-memory session cache — no I/O.
3179
4174
  const cached = resolvedPathCache.get(toolId);
3180
4175
  if (cached) {
3181
- if (!isFullyQualified(cached))
4176
+ if (!isFullyQualified(cached)) {
4177
+ lastEnsureResolutionSource.set(toolId, "session-cache");
3182
4178
  return cached;
4179
+ }
3183
4180
  try {
3184
4181
  await fs.access(cached);
4182
+ lastEnsureResolutionSource.set(toolId, "session-cache");
3185
4183
  return cached;
3186
4184
  }
3187
4185
  catch {
@@ -3198,6 +4196,7 @@ async function ensureToolResolved(toolId, opts) {
3198
4196
  const diskCached = await checkProbeCache(toolId);
3199
4197
  if (diskCached) {
3200
4198
  resolvedPathCache.set(toolId, diskCached);
4199
+ lastEnsureResolutionSource.set(toolId, "probe-cache");
3201
4200
  logSessionStart(`auto-install ensure ${toolId}: probe cache hit → ${diskCached}`);
3202
4201
  return diskCached;
3203
4202
  }
@@ -3242,7 +4241,8 @@ async function ensureToolResolved(toolId, opts) {
3242
4241
  }
3243
4242
  }
3244
4243
  resolvedPathCache.set(toolId, existingPath);
3245
- void updateProbeCache(toolId, existingPath);
4244
+ void updateProbeCache(toolId, existingPath, wasLastResolveTransient(toolId));
4245
+ lastEnsureResolutionSource.set(toolId, "path");
3246
4246
  logSessionStart(`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`);
3247
4247
  return existingPath;
3248
4248
  }
@@ -3251,17 +4251,20 @@ async function ensureToolResolved(toolId, opts) {
3251
4251
  // caller forbids installs (allowInstall:false, e.g. PI_LENS_DISABLE_LSP_INSTALL=1)
3252
4252
  // we must still return a discovered binary and only skip the actual install.
3253
4253
  if (opts?.allowInstall === false) {
4254
+ noteInstallAttempt(toolId, "declined", "install disabled by caller");
3254
4255
  logSessionStart(`auto-install ensure ${toolId}: install disabled — discovery only, not found (${Date.now() - ensureStartMs}ms)`);
3255
4256
  return undefined;
3256
4257
  }
3257
4258
  if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
3258
4259
  installFailureReasons.set(toolId, "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
4260
+ noteInstallAttempt(toolId, "declined", "installation disabled by PI_LENS_DISABLE_TOOL_INSTALL=1");
3259
4261
  logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1 (${Date.now() - ensureStartMs}ms)`);
3260
4262
  return undefined;
3261
4263
  }
3262
4264
  const lock = await acquireInstallLock();
3263
4265
  if (!lock.release) {
3264
4266
  installFailureReasons.set(toolId, lock.reason ?? "install lock failed");
4267
+ noteInstallAttempt(toolId, "skipped", lock.reason ?? "install lock held");
3265
4268
  logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
3266
4269
  return undefined;
3267
4270
  }
@@ -3271,8 +4274,9 @@ async function ensureToolResolved(toolId, opts) {
3271
4274
  // installed by its predecessor and must not run a second package manager.
3272
4275
  const installedByPeer = await getToolPath(toolId);
3273
4276
  if (installedByPeer) {
4277
+ noteInstallAttempt(toolId, "succeeded", "installed by a concurrent process");
3274
4278
  resolvedPathCache.set(toolId, installedByPeer);
3275
- void updateProbeCache(toolId, installedByPeer);
4279
+ void updateProbeCache(toolId, installedByPeer, wasLastResolveTransient(toolId));
3276
4280
  return installedByPeer;
3277
4281
  }
3278
4282
  installed = await installTool(toolId);
@@ -3281,13 +4285,16 @@ async function ensureToolResolved(toolId, opts) {
3281
4285
  await lock.release();
3282
4286
  }
3283
4287
  if (!installed) {
4288
+ // installTool RAN and did not succeed. A genuine failure unless it
4289
+ // recorded a more specific outcome of its own (#1500).
4290
+ noteInstallAttemptIfUnrecorded(toolId, "failed", "install failed");
3284
4291
  logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
3285
4292
  return undefined;
3286
4293
  }
3287
4294
  const result = await getToolPath(toolId);
3288
4295
  if (result) {
3289
4296
  resolvedPathCache.set(toolId, result);
3290
- void updateProbeCache(toolId, result);
4297
+ void updateProbeCache(toolId, result, wasLastResolveTransient(toolId));
3291
4298
  logSessionStart(`auto-install ensure ${toolId}: success at ${result} (${Date.now() - ensureStartMs}ms)`);
3292
4299
  }
3293
4300
  else {
@@ -3345,6 +4352,16 @@ export async function checkAllTools() {
3345
4352
  export function isKnownToolId(toolId) {
3346
4353
  return TOOLS.some((tool) => tool.id === toolId);
3347
4354
  }
4355
+ /**
4356
+ * The registry's own install strategy for `toolId`, or `undefined` for an
4357
+ * unknown id. Single source of truth for anything that needs to LABEL how a
4358
+ * tool gets installed (e.g. the availability-decision evidence's `source`
4359
+ * tag, #1612) — derived from the same `TOOLS` entry `ensureTool` dispatches
4360
+ * on, so the label can never drift out of sync with the actual installer.
4361
+ */
4362
+ export function getToolInstallStrategy(toolId) {
4363
+ return TOOLS.find((tool) => tool.id === toolId)?.installStrategy;
4364
+ }
3348
4365
  /**
3349
4366
  * GitHub-release tools that ship an asset for **every** supported
3350
4367
  * platform/arch combo (linux/darwin/win32 × x64/arm64). This is the set the
@@ -3378,6 +4395,7 @@ export const GITHUB_TOOLS = [
3378
4395
  "opengrep",
3379
4396
  "deno",
3380
4397
  "clojure-lsp",
4398
+ "cue",
3381
4399
  "gleam",
3382
4400
  "marksman",
3383
4401
  "expert",