@herbertgao/pi-extensions 2026.8.6 → 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 (343) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  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/renderer/compact-mode.ts +94 -77
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/message-display.ts +1 -1
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/hover.ts +14 -0
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +55 -17
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/layout.ts +41 -2
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/diff-renderer.ts +75 -6
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/types.ts +7 -3
  14. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +64 -1
  15. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  16. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  17. package/node_modules/@herbertgao/pi-subagents/README.md +30 -22
  18. package/node_modules/@herbertgao/pi-subagents/examples/agent-tool-description.md +5 -5
  19. package/node_modules/@herbertgao/pi-subagents/package.json +7 -7
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +7 -1
  21. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +48 -19
  22. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +17 -13
  23. package/node_modules/@herbertgao/pi-subagents/src/custom-agents.ts +26 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/index.ts +219 -40
  25. package/node_modules/@herbertgao/pi-subagents/src/invocation-config.ts +101 -3
  26. package/node_modules/@herbertgao/pi-subagents/src/nested-tools.ts +18 -4
  27. package/node_modules/@herbertgao/pi-subagents/src/settings.ts +99 -0
  28. package/node_modules/@herbertgao/pi-subagents/src/types.ts +22 -3
  29. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +51 -3
  30. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +20 -2
  31. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +21 -6
  32. package/node_modules/@herbertgao/pi-subagents/src/usage.ts +129 -1
  33. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +20 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +1 -0
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +26 -10
  36. package/node_modules/@juicesharp/rpiv-ask-user-question/config.ts +25 -0
  37. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/configuration.md +13 -4
  38. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +2 -1
  39. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/localization.md +3 -1
  40. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/en.json +2 -2
  41. package/node_modules/@juicesharp/rpiv-ask-user-question/locales/zh.json +2 -2
  42. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +3 -2
  43. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +10 -0
  44. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +3 -1
  45. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +30 -7
  46. package/node_modules/@juicesharp/rpiv-ask-user-question/state/state.ts +14 -6
  47. package/node_modules/@juicesharp/rpiv-ask-user-question/view/dialog-builder.ts +30 -4
  48. package/node_modules/@juicesharp/rpiv-ask-user-question/view/tab-content-strategy.ts +26 -5
  49. package/node_modules/@narumitw/pi-btw/README.md +120 -111
  50. package/node_modules/@narumitw/pi-btw/dist/index.ts +2898 -0
  51. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +7 -0
  52. package/node_modules/@narumitw/pi-btw/package.json +10 -6
  53. package/node_modules/@narumitw/pi-btw/src/btw.ts +36 -40
  54. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +55 -10
  55. package/node_modules/@narumitw/pi-btw/src/main-tree-picker.ts +390 -0
  56. package/node_modules/@narumitw/pi-btw/src/menu.ts +45 -2
  57. package/node_modules/@narumitw/pi-btw/src/transcript-pager.ts +136 -45
  58. package/node_modules/pi-lens/CHANGELOG.md +909 -0
  59. package/node_modules/pi-lens/README.md +11 -3
  60. package/node_modules/pi-lens/config/ruff/core.toml +42 -0
  61. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +130 -27
  62. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +126 -13
  63. package/node_modules/pi-lens/dist/clients/agent-nudge.js +67 -11
  64. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +9 -3
  65. package/node_modules/pi-lens/dist/clients/atomic-write.js +9 -3
  66. package/node_modules/pi-lens/dist/clients/bash-file-access.js +285 -8
  67. package/node_modules/pi-lens/dist/clients/biome-client.js +61 -30
  68. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +428 -0
  69. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +119 -0
  70. package/node_modules/pi-lens/dist/clients/bootstrap.js +12 -1
  71. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +12 -3
  72. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +192 -0
  73. package/node_modules/pi-lens/dist/clients/cascade-budget.js +196 -0
  74. package/node_modules/pi-lens/dist/clients/cascade-format.js +20 -1
  75. package/node_modules/pi-lens/dist/clients/cascade-types.js +9 -1
  76. package/node_modules/pi-lens/dist/clients/child-unref.js +54 -6
  77. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +76 -24
  78. package/node_modules/pi-lens/dist/clients/dead-code-client.js +123 -31
  79. package/node_modules/pi-lens/dist/clients/deadline-utils.js +8 -1
  80. package/node_modules/pi-lens/dist/clients/debug-handles.js +5 -0
  81. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +62 -13
  82. package/node_modules/pi-lens/dist/clients/dependency-checker.js +25 -3
  83. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +147 -33
  84. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +298 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +52 -2
  86. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +256 -88
  87. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +7 -4
  88. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +5 -0
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +234 -18
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +187 -19
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +6 -3
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +11 -9
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +398 -0
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +11 -3
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +12 -7
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +18 -0
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +895 -0
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +4 -0
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +16 -3
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +60 -53
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +16 -0
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +23 -0
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +331 -55
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +38 -5
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +97 -19
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +14 -0
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +20 -0
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +22 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +10 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +18 -6
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +73 -12
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +275 -13
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +21 -6
  114. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +260 -27
  115. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +734 -64
  116. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +86 -11
  117. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +72 -0
  118. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +9 -0
  119. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +9 -0
  120. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +18 -1
  121. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +4 -2
  122. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +28 -5
  123. package/node_modules/pi-lens/dist/clients/extension-log.js +22 -1
  124. package/node_modules/pi-lens/dist/clients/file-kinds.js +8 -2
  125. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +294 -0
  126. package/node_modules/pi-lens/dist/clients/finding-identity.js +88 -0
  127. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +3 -2
  128. package/node_modules/pi-lens/dist/clients/formatters.js +500 -115
  129. package/node_modules/pi-lens/dist/clients/generation-guard.js +216 -0
  130. package/node_modules/pi-lens/dist/clients/git-guard.js +31 -0
  131. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +181 -8
  132. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +109 -9
  133. package/node_modules/pi-lens/dist/clients/grammar-source.js +333 -10
  134. package/node_modules/pi-lens/dist/clients/host-ports.js +8 -0
  135. package/node_modules/pi-lens/dist/clients/installer/index.js +1114 -100
  136. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +58 -0
  137. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +715 -0
  138. package/node_modules/pi-lens/dist/clients/instance-reaper.js +643 -97
  139. package/node_modules/pi-lens/dist/clients/instance-registry.js +87 -17
  140. package/node_modules/pi-lens/dist/clients/jscpd-client.js +82 -6
  141. package/node_modules/pi-lens/dist/clients/knip-client.js +310 -10
  142. package/node_modules/pi-lens/dist/clients/language-policy.js +8 -2
  143. package/node_modules/pi-lens/dist/clients/language-profile.js +2 -0
  144. package/node_modules/pi-lens/dist/clients/latency-logger.js +440 -11
  145. package/node_modules/pi-lens/dist/clients/lazy-import.js +21 -0
  146. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +21 -0
  147. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  148. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +102 -7
  149. package/node_modules/pi-lens/dist/clients/lsp/client.js +1516 -159
  150. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +83 -1
  151. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +389 -0
  152. package/node_modules/pi-lens/dist/clients/lsp/index.js +2274 -147
  153. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +215 -0
  154. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +146 -14
  155. package/node_modules/pi-lens/dist/clients/lsp/language.js +48 -4
  156. package/node_modules/pi-lens/dist/clients/lsp/server.js +277 -16
  157. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +22 -0
  158. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +43 -0
  159. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +79 -3
  160. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +43 -0
  161. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +422 -28
  162. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +30 -0
  163. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +175 -0
  164. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +3 -2
  165. package/node_modules/pi-lens/dist/clients/mcp/session.js +10 -2
  166. package/node_modules/pi-lens/dist/clients/module-report.js +8 -0
  167. package/node_modules/pi-lens/dist/clients/opengrep-client.js +15 -1
  168. package/node_modules/pi-lens/dist/clients/package-manager.js +170 -14
  169. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +27 -0
  170. package/node_modules/pi-lens/dist/clients/path-utils.js +160 -0
  171. package/node_modules/pi-lens/dist/clients/pipeline.js +154 -33
  172. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +37 -0
  173. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +17 -3
  174. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +89 -22
  175. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +84 -14
  176. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +15 -3
  177. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +50 -2
  178. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +5 -0
  179. package/node_modules/pi-lens/dist/clients/project-lens-config.js +3 -0
  180. package/node_modules/pi-lens/dist/clients/project-snapshot.js +299 -0
  181. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +47 -0
  182. package/node_modules/pi-lens/dist/clients/quiet-window.js +11 -25
  183. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +1 -1
  184. package/node_modules/pi-lens/dist/clients/read-guard.js +184 -10
  185. package/node_modules/pi-lens/dist/clients/resource-sampler.js +49 -17
  186. package/node_modules/pi-lens/dist/clients/reverse-deps.js +50 -11
  187. package/node_modules/pi-lens/dist/clients/review-graph/query.js +52 -4
  188. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +96 -7
  189. package/node_modules/pi-lens/dist/clients/runtime-context.js +8 -1
  190. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +308 -45
  191. package/node_modules/pi-lens/dist/clients/runtime-session.js +332 -9
  192. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +217 -22
  193. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +212 -79
  194. package/node_modules/pi-lens/dist/clients/runtime-turn.js +575 -67
  195. package/node_modules/pi-lens/dist/clients/safe-spawn.js +220 -7
  196. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +161 -0
  197. package/node_modules/pi-lens/dist/clients/search-read-registration.js +34 -6
  198. package/node_modules/pi-lens/dist/clients/security-scan-client.js +157 -24
  199. package/node_modules/pi-lens/dist/clients/sg-runner.js +116 -22
  200. package/node_modules/pi-lens/dist/clients/single-flight.js +158 -0
  201. package/node_modules/pi-lens/dist/clients/stale-marker.js +17 -0
  202. package/node_modules/pi-lens/dist/clients/startup-timing.js +19 -1
  203. package/node_modules/pi-lens/dist/clients/test-runner-client.js +151 -16
  204. package/node_modules/pi-lens/dist/clients/tool-event.js +56 -5
  205. package/node_modules/pi-lens/dist/clients/tool-policy.js +125 -20
  206. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +75 -1
  207. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +769 -64
  208. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +11 -0
  209. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +90 -0
  210. package/node_modules/pi-lens/dist/clients/trivy-client.js +19 -1
  211. package/node_modules/pi-lens/dist/clients/widget-state.js +336 -36
  212. package/node_modules/pi-lens/dist/clients/zizmor-config.js +214 -14
  213. package/node_modules/pi-lens/dist/index.js +67738 -55739
  214. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +466 -54
  215. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +132 -43
  216. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -7
  217. package/node_modules/pi-lens/docs/agent-guide.md +6 -1
  218. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +515 -506
  219. package/node_modules/pi-lens/docs/dispositions.md +21 -0
  220. package/node_modules/pi-lens/docs/features.md +19 -5
  221. package/node_modules/pi-lens/docs/globalconfig.md +3 -0
  222. package/node_modules/pi-lens/docs/language-coverage.md +71 -5
  223. package/node_modules/pi-lens/docs/mcp.md +2 -2
  224. package/node_modules/pi-lens/docs/servercapabilities.md +4 -1
  225. package/node_modules/pi-lens/docs/settings.md +19 -1
  226. package/node_modules/pi-lens/docs/usage.md +1 -0
  227. package/node_modules/pi-lens/package.json +10 -6
  228. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-js-snapshot.yml +23 -0
  229. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/no-reflect-get-snapshot.yml +23 -0
  230. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/__snapshots__/ts-void-zero-snapshot.yml +23 -0
  231. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-object-param-test.yml +16 -0
  232. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +15 -1
  233. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-compile-call-test.yml +29 -1
  234. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-dupe-class-members-test.yml +33 -0
  235. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-js-test.yml +7 -1
  236. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-inner-html-test.yml +7 -1
  237. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-known-value-widening-test.yml +9 -0
  238. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-non-null-assertion-test.yml +216 -0
  239. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +2 -0
  240. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +2 -0
  241. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +2 -1
  242. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +2 -1
  243. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-return-value-in-generator-test.yml +12 -0
  244. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-runtime-typeof-test.yml +33 -0
  245. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-server-bind-wildcard-test.yml +9 -0
  246. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-shape-in-symbol-names-test.yml +13 -0
  247. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-js-test.yml +5 -1
  248. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-sql-in-code-test.yml +5 -1
  249. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-parameters-test.yml +12 -0
  250. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-returns-test.yml +16 -0
  251. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-any-test.yml +10 -0
  252. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unsafe-dictionary-unknown-test.yml +8 -0
  253. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/redundant-unsafe-function-test.yml +35 -0
  254. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/require-safety-comment-for-as-unknown-as-test.yml +177 -0
  255. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ts-void-zero-test.yml +1 -0
  256. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member-js.yml +13 -2
  257. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-await-expression-member.yml +13 -2
  258. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +2 -0
  259. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-object-param.yml +60 -0
  260. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +57 -12
  261. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-compile-call.yml +63 -5
  262. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-dupe-class-members.yml +52 -8
  263. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html-js.yml +27 -2
  264. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-inner-html.yml +27 -2
  265. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-known-value-widening.yml +71 -0
  266. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-non-null-assertion.yml +316 -0
  267. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +7 -0
  268. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +22 -2
  269. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +23 -2
  270. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +33 -5
  271. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +34 -8
  272. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-return-value-in-generator.yml +22 -5
  273. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-runtime-typeof.yml +121 -0
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-server-bind-wildcard.yml +23 -7
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-shape-in-symbol-names.yml +68 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code-js.yml +41 -2
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-sql-in-code.yml +41 -2
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +6 -1
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-parameters.yml +67 -0
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-returns.yml +106 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-any.yml +50 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unsafe-dictionary-unknown.yml +56 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +2 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rules/redundant-unsafe-function.yml +32 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rules/require-safety-comment-for-as-unknown-as.yml +184 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ts-void-zero.yml +5 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/self-scan-baseline.json +5 -0
  288. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +53 -1
  289. package/node_modules/pi-lens/scripts/download-grammars.js +25 -0
  290. package/node_modules/pi-lens/scripts/grammars.lock.json +9 -0
  291. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +2 -0
  292. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +80 -3
  293. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +41 -0
  294. package/node_modules/pi-lens/vendor/grammars/tree-sitter-cue.wasm +0 -0
  295. package/node_modules/pi-mcp-adapter/CHANGELOG.md +36 -0
  296. package/node_modules/pi-mcp-adapter/README.md +47 -5
  297. package/node_modules/pi-mcp-adapter/cli.js +163 -1
  298. package/node_modules/pi-mcp-adapter/commands.ts +128 -22
  299. package/node_modules/pi-mcp-adapter/config.ts +25 -7
  300. package/node_modules/pi-mcp-adapter/direct-tools.ts +5 -4
  301. package/node_modules/pi-mcp-adapter/index.ts +219 -32
  302. package/node_modules/pi-mcp-adapter/init.ts +36 -4
  303. package/node_modules/pi-mcp-adapter/lifecycle.ts +315 -24
  304. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +39 -6
  305. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +387 -0
  306. package/node_modules/pi-mcp-adapter/mcp-callback-server.ts +27 -3
  307. package/node_modules/pi-mcp-adapter/mcp-code.ts +4 -0
  308. package/node_modules/pi-mcp-adapter/mcp-panel.ts +42 -13
  309. package/node_modules/pi-mcp-adapter/metadata-cache.ts +32 -21
  310. package/node_modules/pi-mcp-adapter/package-mcp-loader.ts +168 -0
  311. package/node_modules/pi-mcp-adapter/package.json +3 -1
  312. package/node_modules/pi-mcp-adapter/proxy-modes.ts +5 -4
  313. package/node_modules/pi-mcp-adapter/request-headers-command.ts +31 -37
  314. package/node_modules/pi-mcp-adapter/search-ranking.ts +92 -22
  315. package/node_modules/pi-mcp-adapter/server-manager.ts +140 -2
  316. package/node_modules/pi-mcp-adapter/session-recovery.ts +7 -0
  317. package/node_modules/pi-mcp-adapter/tool-approval.ts +15 -2
  318. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +44 -4
  319. package/node_modules/pi-mcp-adapter/types.ts +2 -0
  320. package/node_modules/pi-mcp-adapter/ui-server.ts +18 -16
  321. package/node_modules/pi-mcp-adapter/utils.ts +70 -0
  322. package/node_modules/pi-web-access/CHANGELOG.md +23 -0
  323. package/node_modules/pi-web-access/README.md +27 -9
  324. package/node_modules/pi-web-access/anysearch.ts +4 -2
  325. package/node_modules/pi-web-access/brave.ts +16 -4
  326. package/node_modules/pi-web-access/curator-page.ts +6 -3
  327. package/node_modules/pi-web-access/curator-server.ts +4 -1
  328. package/node_modules/pi-web-access/exa.ts +18 -7
  329. package/node_modules/pi-web-access/extract.ts +28 -3
  330. package/node_modules/pi-web-access/gemini-search.ts +16 -4
  331. package/node_modules/pi-web-access/index.ts +41 -57
  332. package/node_modules/pi-web-access/package.json +1 -1
  333. package/node_modules/pi-web-access/page-query.ts +4 -2
  334. package/node_modules/pi-web-access/parallel-mcp.ts +249 -0
  335. package/node_modules/pi-web-access/parallel.ts +2 -2
  336. package/node_modules/pi-web-access/query-rewrite.ts +51 -0
  337. package/node_modules/pi-web-access/serper.ts +211 -0
  338. package/node_modules/pi-web-access/summary-model-scope.ts +12 -4
  339. package/node_modules/pi-web-access/summary-review.ts +44 -22
  340. package/node_modules/pi-web-access/tavily.ts +16 -4
  341. package/node_modules/pi-web-access/utils.ts +87 -0
  342. package/node_modules/pi-web-access/valyu.ts +199 -0
  343. 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 ||
@@ -803,7 +805,11 @@ export const TOOLS = [
803
805
  id: "powershell-editor-services",
804
806
  name: "PowerShell Editor Services",
805
807
  checkCommand: "pwsh",
806
- checkArgs: ["-NoProfile", "-Command", "$PSVersionTable.PSVersion.ToString()"],
808
+ checkArgs: [
809
+ "-NoProfile",
810
+ "-Command",
811
+ "$PSVersionTable.PSVersion.ToString()",
812
+ ],
807
813
  installStrategy: "archive",
808
814
  binaryName: "powershell-editor-services",
809
815
  archive: {
@@ -834,7 +840,9 @@ export const TOOLS = [
834
840
  const version = "22.1.0";
835
841
  const base = `https://github.com/clangd/clangd/releases/download/${version}`;
836
842
  if (platform === "linux")
837
- return arch === "x64" ? `${base}/clangd-linux-${version}.zip` : undefined;
843
+ return arch === "x64"
844
+ ? `${base}/clangd-linux-${version}.zip`
845
+ : undefined;
838
846
  if (platform === "darwin")
839
847
  return `${base}/clangd-mac-${version}.zip`;
840
848
  if (platform === "win32")
@@ -1202,6 +1210,31 @@ export const TOOLS = [
1202
1210
  binaryInArchive: "clojure-lsp",
1203
1211
  },
1204
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
+ },
1205
1238
  {
1206
1239
  // gleam ships a single static binary per platform on GitHub releases; the
1207
1240
  // LSP runs via `gleam lsp`. Used as managedToolId by GleamServer. The linux
@@ -1278,9 +1311,7 @@ export const TOOLS = [
1278
1311
  if (arch !== "x64" && arch !== "arm64")
1279
1312
  return undefined;
1280
1313
  if (platform === "linux")
1281
- return arch === "arm64"
1282
- ? "expert_linux_arm64"
1283
- : "expert_linux_amd64";
1314
+ return arch === "arm64" ? "expert_linux_arm64" : "expert_linux_amd64";
1284
1315
  if (platform === "darwin")
1285
1316
  return arch === "arm64"
1286
1317
  ? "expert_darwin_arm64"
@@ -1299,11 +1330,45 @@ const installFailureReasons = new Map();
1299
1330
  export function getInstallFailureReason(toolId) {
1300
1331
  return installFailureReasons.get(toolId);
1301
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
+ }
1302
1355
  // Session-lifetime cache: once a tool path is resolved, skip the process-spawn check on subsequent calls.
1303
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
+ }
1304
1361
  const PROBE_CACHE_PATH = path.join(getGlobalPiLensDir(), "probe-cache.json");
1305
1362
  const PROBE_CACHE_LOCK_STALE_MS = 180_000;
1306
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;
1307
1372
  const PROBE_CACHE_FLUSH_LOCK_WAIT_MS = 250;
1308
1373
  const PROBE_CACHE_FLUSH_RETRY_DELAY_MS = 300;
1309
1374
  const PROBE_CACHE_FLUSH_RETRY_MAX_DELAY_MS = 30_000;
@@ -1353,7 +1418,8 @@ function scheduleProbeFlush(delayMs = PROBE_CACHE_FLUSH_RETRY_DELAY_MS) {
1353
1418
  _probeCacheFlushTimer.unref?.();
1354
1419
  }
1355
1420
  function scheduleProbeFlushRetry() {
1356
- 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));
1357
1423
  _probeCacheRetryAttempt += 1;
1358
1424
  scheduleProbeFlush(delay);
1359
1425
  }
@@ -1363,14 +1429,32 @@ function snapshotProbeCacheChanges() {
1363
1429
  versions: new Map(_probeCacheChangeVersions),
1364
1430
  };
1365
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
+ */
1366
1444
  function deserializeProbeCache(contents) {
1367
1445
  if (contents === undefined)
1368
1446
  return {};
1369
- const parsed = JSON.parse(contents);
1370
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
1371
- 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 {};
1372
1457
  }
1373
- return parsed;
1374
1458
  }
1375
1459
  function applyProbeCacheChanges(disk, changes) {
1376
1460
  for (const [toolId, entry] of changes) {
@@ -1387,9 +1471,12 @@ function applyProbeCacheChanges(disk, changes) {
1387
1471
  * supplies the probe cache's former quarantine/stale-owner recovery.
1388
1472
  */
1389
1473
  function ageProbeCache(disk) {
1390
- const cutoff = Date.now() - PROBE_CACHE_TTL_MS;
1474
+ const now = Date.now();
1391
1475
  for (const [toolId, entry] of Object.entries(disk)) {
1392
- 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) {
1393
1480
  delete disk[toolId];
1394
1481
  }
1395
1482
  }
@@ -1518,8 +1605,15 @@ export async function checkProbeCache(toolId) {
1518
1605
  const entry = cache[toolId];
1519
1606
  if (!entry)
1520
1607
  return undefined;
1521
- if (Date.now() - entry.cachedAt > PROBE_CACHE_TTL_MS) {
1522
- 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)`);
1523
1617
  delete cache[toolId];
1524
1618
  markProbeCacheChange(toolId, null);
1525
1619
  return undefined;
@@ -1549,7 +1643,7 @@ export async function checkProbeCache(toolId) {
1549
1643
  }
1550
1644
  }
1551
1645
  // Exported for testing only.
1552
- export async function updateProbeCache(toolId, resolvedPath) {
1646
+ export async function updateProbeCache(toolId, resolvedPath, transient = false) {
1553
1647
  try {
1554
1648
  const stat = await fs.stat(resolvedPath);
1555
1649
  const cache = await readProbeCache();
@@ -1557,6 +1651,7 @@ export async function updateProbeCache(toolId, resolvedPath) {
1557
1651
  path: resolvedPath,
1558
1652
  mtimeMs: stat.mtimeMs,
1559
1653
  cachedAt: Date.now(),
1654
+ ...(transient && { transient: true }),
1560
1655
  };
1561
1656
  cache[toolId] = entry;
1562
1657
  markProbeCacheChange(toolId, entry);
@@ -1573,10 +1668,13 @@ export function resetProbeCacheStateForTesting() {
1573
1668
  _probeCacheChangeVersions.clear();
1574
1669
  _probeCacheChangeGeneration = 0;
1575
1670
  _probeCacheRetryAttempt = 0;
1576
- resolvedPathCache.clear();
1671
+ resetResolvedPathCache();
1577
1672
  ensureInFlight.clear();
1578
1673
  installFailureReasons.clear();
1674
+ installAttempts.clear();
1675
+ lastEnsureResolutionSource.clear();
1579
1676
  lastManagedInstallVersion.clear();
1677
+ lastResolveTransient.clear();
1580
1678
  resetPathWalkMemo();
1581
1679
  if (_probeCacheFlushTimer !== null) {
1582
1680
  clearTimeout(_probeCacheFlushTimer);
@@ -1703,8 +1801,28 @@ const lastManagedInstallVersion = new Map();
1703
1801
  * `onVersionOutput`, when provided, receives the raw stdout on a successful
1704
1802
  * (exit 0) probe — used to piggyback version-pin drift detection onto this
1705
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).
1706
1808
  */
1707
- 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) {
1708
1826
  return new Promise((resolve) => {
1709
1827
  const isWindows = installerPlatform() === "win32";
1710
1828
  const hasKnownWindowsExt = /\.(cmd|exe|ps1)$/i.test(binPath);
@@ -1730,7 +1848,7 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1730
1848
  let proc;
1731
1849
  try {
1732
1850
  proc = spawn(spawnCmd, useShell ? [] : ["--version"], {
1733
- timeout: 10000,
1851
+ timeout: timeoutMs,
1734
1852
  stdio: ["ignore", "pipe", "pipe"],
1735
1853
  shell: useShell,
1736
1854
  });
@@ -1738,7 +1856,9 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1738
1856
  catch (err) {
1739
1857
  // SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL, the pidusage
1740
1858
  // bug class, #533) — best-effort verify, resolve rather than reject.
1859
+ // The prober itself never ran, so this says nothing about the binary.
1741
1860
  logSessionStart(`auto-install verify: spawn threw for ${binPath} (${err instanceof Error ? err.message : String(err)})`);
1861
+ onTransient?.();
1742
1862
  resolve(false);
1743
1863
  return;
1744
1864
  }
@@ -1746,7 +1866,7 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1746
1866
  let stderr = "";
1747
1867
  proc.stdout?.on("data", (data) => (stdout += data));
1748
1868
  proc.stderr?.on("data", (data) => (stderr += data));
1749
- proc.on("exit", (code) => {
1869
+ proc.on("exit", (code, signal) => {
1750
1870
  if (code === 0) {
1751
1871
  debugLog(`Verified: ${binPath} (version: ${stdout.trim()})`);
1752
1872
  onVersionOutput?.(stdout);
@@ -1759,11 +1879,20 @@ async function verifyToolBinary(binPath, onVersionOutput) {
1759
1879
  resolve(true);
1760
1880
  }
1761
1881
  else {
1762
- 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}` : ""})`);
1763
1888
  resolve(false);
1764
1889
  }
1765
1890
  });
1766
1891
  proc.on("error", (err) => {
1892
+ const errno = err.code;
1893
+ if (errno === "EAGAIN" || errno === "EBUSY" || errno === "ETIMEDOUT") {
1894
+ onTransient?.();
1895
+ }
1767
1896
  logSessionStart(`auto-install verify: error for ${binPath}: ${err.message}`);
1768
1897
  resolve(false);
1769
1898
  });
@@ -1962,7 +2091,47 @@ export function resolvePlatformPackageBinary(tool) {
1962
2091
  }
1963
2092
  return undefined;
1964
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
+ }
1965
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) {
1966
2135
  const tool = TOOLS.find((t) => t.id === toolId);
1967
2136
  if (!tool)
1968
2137
  return undefined;
@@ -1999,7 +2168,7 @@ export async function getToolPath(toolId) {
1999
2168
  const cmdPath = `${localBase}.cmd`;
2000
2169
  try {
2001
2170
  await fs.access(cmdPath);
2002
- if (await verifyToolBinary(cmdPath, recordVersion)) {
2171
+ if (await verifyToolBinary(cmdPath, recordVersion, onTransient)) {
2003
2172
  return cmdPath;
2004
2173
  }
2005
2174
  logSessionStart(`auto-install verify: ${cmdPath} exists but is broken, will reinstall`);
@@ -2012,7 +2181,7 @@ export async function getToolPath(toolId) {
2012
2181
  const exePath = `${localBase}.exe`;
2013
2182
  try {
2014
2183
  await fs.access(exePath);
2015
- if (await verifyToolBinary(exePath, recordVersion)) {
2184
+ if (await verifyToolBinary(exePath, recordVersion, onTransient)) {
2016
2185
  return exePath;
2017
2186
  }
2018
2187
  logSessionStart(`auto-install verify: ${exePath} exists but is broken, will reinstall`);
@@ -2024,7 +2193,7 @@ export async function getToolPath(toolId) {
2024
2193
  if (installerPlatform() !== "win32") {
2025
2194
  try {
2026
2195
  await fs.access(localBase);
2027
- if (await verifyToolBinary(localBase, recordVersion)) {
2196
+ if (await verifyToolBinary(localBase, recordVersion, onTransient)) {
2028
2197
  return localBase;
2029
2198
  }
2030
2199
  logSessionStart(`auto-install verify: ${localBase} exists but is broken, will reinstall`);
@@ -2039,7 +2208,8 @@ export async function getToolPath(toolId) {
2039
2208
  // before falling back to PATH or a (re)install.
2040
2209
  if (tool.platformPackage) {
2041
2210
  const platformBin = resolvePlatformPackageBinary(tool);
2042
- if (platformBin && (await verifyToolBinary(platformBin))) {
2211
+ if (platformBin &&
2212
+ (await verifyToolBinary(platformBin, undefined, onTransient))) {
2043
2213
  logSessionStart(`auto-install ${toolId}: resolved platform-package binary at ${platformBin}`);
2044
2214
  return platformBin;
2045
2215
  }
@@ -2061,14 +2231,14 @@ export async function getToolPath(toolId) {
2061
2231
  return tool.checkCommand;
2062
2232
  }
2063
2233
  if (tool.installStrategy === "npm") {
2064
- const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id);
2234
+ const npmPath = await findNpmGlobalToolPath(tool.binaryName || tool.id, onTransient);
2065
2235
  if (npmPath) {
2066
2236
  return npmPath;
2067
2237
  }
2068
2238
  }
2069
2239
  // For pip tools, also probe user-level script locations
2070
2240
  if (tool.installStrategy === "pip") {
2071
- const pipPath = await findPipUserToolPath(tool.binaryName || tool.id);
2241
+ const pipPath = await findPipUserToolPath(tool.binaryName || tool.id, onTransient);
2072
2242
  if (pipPath) {
2073
2243
  return pipPath;
2074
2244
  }
@@ -2126,9 +2296,9 @@ function getArchiveBinaryCandidates(binaryName, platform, assetName) {
2126
2296
  candidates.add(`${binaryName}.cmd`);
2127
2297
  return [...candidates];
2128
2298
  }
2129
- async function findNpmGlobalToolPath(binaryName) {
2299
+ async function findNpmGlobalToolPath(binaryName, onTransient) {
2130
2300
  const isWindows = process.platform === "win32";
2131
- const binDirs = await getNpmGlobalBinCandidates();
2301
+ const binDirs = await getNpmGlobalBinCandidates(onTransient);
2132
2302
  for (const dir of binDirs) {
2133
2303
  const candidates = isWindows
2134
2304
  ? [
@@ -2139,7 +2309,7 @@ async function findNpmGlobalToolPath(binaryName) {
2139
2309
  for (const candidate of candidates) {
2140
2310
  try {
2141
2311
  await fs.access(candidate);
2142
- if (await verifyToolBinary(candidate)) {
2312
+ if (await verifyToolBinary(candidate, undefined, onTransient)) {
2143
2313
  return candidate;
2144
2314
  }
2145
2315
  }
@@ -2150,7 +2320,7 @@ async function findNpmGlobalToolPath(binaryName) {
2150
2320
  }
2151
2321
  return undefined;
2152
2322
  }
2153
- async function getNpmGlobalBinCandidates() {
2323
+ async function getNpmGlobalBinCandidates(onTransient) {
2154
2324
  const dirs = [];
2155
2325
  const seen = new Set();
2156
2326
  const add = (value) => {
@@ -2171,13 +2341,16 @@ async function getNpmGlobalBinCandidates() {
2171
2341
  add(path.join(os.homedir(), ".npm-global", "bin"));
2172
2342
  }
2173
2343
  // Global bin dirs for every installed manager (npm/pnpm/yarn/bun) — a tool
2174
- // may have been installed globally via any of them.
2175
- 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)) {
2176
2349
  add(dir);
2177
2350
  }
2178
2351
  return dirs;
2179
2352
  }
2180
- async function findPipUserToolPath(binaryName) {
2353
+ async function findPipUserToolPath(binaryName, onTransient) {
2181
2354
  const isWindows = process.platform === "win32";
2182
2355
  const userBaseCandidates = await getPythonUserBaseCandidates();
2183
2356
  for (const userBase of userBaseCandidates) {
@@ -2210,7 +2383,7 @@ async function findPipUserToolPath(binaryName) {
2210
2383
  for (const candidate of candidates) {
2211
2384
  try {
2212
2385
  await fs.access(candidate);
2213
- if (await verifyToolBinary(candidate)) {
2386
+ if (await verifyToolBinary(candidate, undefined, onTransient)) {
2214
2387
  return candidate;
2215
2388
  }
2216
2389
  }
@@ -2299,12 +2472,20 @@ function sameHost(a, b) {
2299
2472
  }
2300
2473
  }
2301
2474
  /**
2302
- * Fetch a URL, following up to `maxRedirects` redirects.
2303
- * Returns the raw Buffer of the response body. Any caller-supplied headers are
2304
- * dropped when a redirect crosses to a different host, so an Authorization
2305
- * 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).
2306
2487
  */
2307
- function httpsGet(url, maxRedirects = 5, headers = {}) {
2488
+ function httpsGetWithMeta(url, maxRedirects = 5, headers = {}) {
2308
2489
  return new Promise((resolve, reject) => {
2309
2490
  https
2310
2491
  .get(url, { headers: { "User-Agent": "pi-lens/1.0", ...headers } }, (res) => {
@@ -2321,7 +2502,12 @@ function httpsGet(url, maxRedirects = 5, headers = {}) {
2321
2502
  const { Authorization: _drop, ...rest } = headers;
2322
2503
  return rest;
2323
2504
  })();
2324
- 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 });
2325
2511
  }
2326
2512
  if (res.statusCode !== 200) {
2327
2513
  res.resume();
@@ -2329,12 +2515,24 @@ function httpsGet(url, maxRedirects = 5, headers = {}) {
2329
2515
  }
2330
2516
  const chunks = [];
2331
2517
  res.on("data", (chunk) => chunks.push(chunk));
2332
- res.on("end", () => resolve(Buffer.concat(chunks)));
2518
+ res.on("end", () => resolve({ statusCode: 200, body: Buffer.concat(chunks), etag }));
2333
2519
  res.on("error", reject);
2334
2520
  })
2335
2521
  .on("error", reject);
2336
2522
  });
2337
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
+ }
2338
2536
  /**
2339
2537
  * Run a shell command and return true on exit code 0.
2340
2538
  */
@@ -2362,7 +2560,14 @@ function runCommand(command, args, cwd) {
2362
2560
  * Download and install a tool from a GitHub release.
2363
2561
  * Returns the path to the installed binary, or undefined on failure.
2364
2562
  */
2365
- 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) {
2366
2571
  const spec = tool.github;
2367
2572
  if (!spec)
2368
2573
  return undefined;
@@ -2373,15 +2578,35 @@ async function installGitHubTool(tool) {
2373
2578
  logSessionStart(`github-install ${tool.id}: unsupported platform=${platform} arch=${arch}`);
2374
2579
  return undefined;
2375
2580
  }
2376
- // Fetch latest release metadata from GitHub API
2377
- logSessionStart(`github-install ${tool.id}: fetching release metadata from ${spec.repo}`);
2581
+ // Fetch latest release metadata from GitHub API, unless the caller already did.
2378
2582
  let releaseJson;
2379
- try {
2380
- const body = await httpsGet(`https://api.github.com/repos/${spec.repo}/releases/latest`, 5, githubApiAuthHeaders());
2381
- 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"})`);
2382
2586
  }
2383
- catch (err) {
2384
- 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`);
2385
2610
  return undefined;
2386
2611
  }
2387
2612
  const asset = pickReleaseAsset(releaseJson.assets, assetSubstring) ??
@@ -2420,7 +2645,10 @@ async function installGitHubTool(tool) {
2420
2645
  gunzip.on("error", reject);
2421
2646
  gunzip.end(assetBuffer);
2422
2647
  });
2423
- await fs.writeFile(destPath, decompressed, { mode: 0o750 });
2648
+ await writeFileAtomicAsync(destPath, decompressed, {
2649
+ bestEffort: false,
2650
+ mode: 0o750,
2651
+ });
2424
2652
  }
2425
2653
  else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tar.xz")) {
2426
2654
  // Write archive to temp file, extract with system tar
@@ -2487,7 +2715,10 @@ async function installGitHubTool(tool) {
2487
2715
  }
2488
2716
  else {
2489
2717
  // Bare binary (e.g. shfmt_*_linux_amd64)
2490
- await fs.writeFile(destPath, assetBuffer, { mode: 0o750 });
2718
+ await writeFileAtomicAsync(destPath, assetBuffer, {
2719
+ bestEffort: false,
2720
+ mode: 0o750,
2721
+ });
2491
2722
  }
2492
2723
  }
2493
2724
  catch (err) {
@@ -2505,9 +2736,7 @@ async function installGitHubTool(tool) {
2505
2736
  }
2506
2737
  try {
2507
2738
  const extraBuffer = await httpsGet(extraAsset.browser_download_url);
2508
- await fs.writeFile(path.join(GITHUB_BIN_DIR, extraName), extraBuffer, {
2509
- mode: 0o750,
2510
- });
2739
+ await writeFileAtomicAsync(path.join(GITHUB_BIN_DIR, extraName), extraBuffer, { bestEffort: false, mode: 0o750 });
2511
2740
  logSessionStart(`github-install ${tool.id}: installed extra asset ${extraName} (${extraBuffer.length} bytes)`);
2512
2741
  }
2513
2742
  catch (err) {
@@ -2550,6 +2779,581 @@ const NEEDS_POSTINSTALL = new Set([
2550
2779
  "esbuild",
2551
2780
  "intelephense", // postinstall fetches platform binary; --ignore-scripts breaks install
2552
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
+ }
2553
3357
  const MAVEN_CENTRAL_BASE = "https://repo1.maven.org/maven2";
2554
3358
  /**
2555
3359
  * Install a Maven-distributed runnable fat JAR: download it into the managed bin
@@ -2575,6 +3379,7 @@ async function installMavenTool(tool) {
2575
3379
  const groupPath = spec.groupId.replace(/\./g, "/");
2576
3380
  const jarFile = `${spec.artifactId}-${spec.version}${spec.classifier ? `-${spec.classifier}` : ""}.jar`;
2577
3381
  const url = `${base}/${groupPath}/${spec.artifactId}/${spec.version}/${jarFile}`;
3382
+ lastInstallResolutionId.set(tool.id, mavenCoordinate(spec));
2578
3383
  logSessionStart(`maven-install ${tool.id}: downloading ${url}`);
2579
3384
  let jarBuffer;
2580
3385
  try {
@@ -2587,15 +3392,15 @@ async function installMavenTool(tool) {
2587
3392
  try {
2588
3393
  await fs.mkdir(GITHUB_BIN_DIR, { recursive: true });
2589
3394
  const jarPath = path.join(GITHUB_BIN_DIR, `${tool.id}.jar`);
2590
- await fs.writeFile(jarPath, jarBuffer);
3395
+ await writeFileAtomicAsync(jarPath, jarBuffer, { bestEffort: false });
2591
3396
  // Launcher so the tool resolves as a normal command in the managed bin.
2592
3397
  const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
2593
3398
  const launcherPath = path.join(GITHUB_BIN_DIR, launcherName);
2594
3399
  if (isWindows) {
2595
- 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 });
2596
3401
  }
2597
3402
  else {
2598
- 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 });
2599
3404
  }
2600
3405
  logSessionStart(`maven-install ${tool.id}: installed → ${launcherPath} (${jarBuffer.length} bytes)`);
2601
3406
  debugLog(`[maven] installed ${tool.name} → ${launcherPath}`);
@@ -2623,6 +3428,76 @@ async function installMavenTool(tool) {
2623
3428
  export function resolveArchiveUrl(spec, platform = process.platform, arch = process.arch) {
2624
3429
  return typeof spec.url === "function" ? spec.url(platform, arch) : spec.url;
2625
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
+ }
2626
3501
  async function installArchiveTool(tool) {
2627
3502
  const spec = tool.archive;
2628
3503
  if (!spec)
@@ -2634,6 +3509,7 @@ async function installArchiveTool(tool) {
2634
3509
  logSessionStart(`archive-install ${tool.id}: no archive for ${process.platform}/${process.arch} — unsupported, skipping`);
2635
3510
  return undefined;
2636
3511
  }
3512
+ lastInstallResolutionId.set(tool.id, url);
2637
3513
  logSessionStart(`archive-install ${tool.id}: downloading ${url}`);
2638
3514
  let archiveBuffer;
2639
3515
  try {
@@ -2648,15 +3524,27 @@ async function installArchiveTool(tool) {
2648
3524
  // `host:path` ("Cannot connect to C:"). Relative paths work for both GNU tar
2649
3525
  // and Windows bsdtar, so we avoid the GNU-only `--force-local` (which bsdtar
2650
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.
2651
3535
  const extractName = tool.id;
3536
+ const tmpExtractName = `${extractName}.refresh-tmp`;
2652
3537
  const archiveName = `${tool.id}.download.${spec.kind === "zip" ? "zip" : "tgz"}`;
2653
3538
  const extractDir = path.join(TOOLS_DIR, extractName);
3539
+ const tmpExtractDir = path.join(TOOLS_DIR, tmpExtractName);
2654
3540
  const tmpArchive = path.join(TOOLS_DIR, archiveName);
2655
3541
  try {
2656
3542
  await fs.mkdir(TOOLS_DIR, { recursive: true });
2657
- // Clear any prior extraction so a reinstall is clean.
2658
- await fs.rm(extractDir, { recursive: true, force: true });
2659
- 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 });
2660
3548
  await fs.writeFile(tmpArchive, archiveBuffer);
2661
3549
  // `--strip-components=N` drops N leading path components. Default 1 drops a
2662
3550
  // versioned top-level dir so a launcher path stays stable (bin/… not
@@ -2668,10 +3556,8 @@ async function installArchiveTool(tool) {
2668
3556
  spec.kind === "tgz" ? "-xzf" : "-xf",
2669
3557
  archiveName,
2670
3558
  "-C",
2671
- extractName,
2672
- ...(stripComponents > 0
2673
- ? [`--strip-components=${stripComponents}`]
2674
- : []),
3559
+ tmpExtractName,
3560
+ ...(stripComponents > 0 ? [`--strip-components=${stripComponents}`] : []),
2675
3561
  ];
2676
3562
  // Resolve `tar` to an absolute path on Windows (System32\tar.exe is the
2677
3563
  // bsdtar shipped with Windows 10+) so extraction can't be hijacked via a
@@ -2693,39 +3579,54 @@ async function installArchiveTool(tool) {
2693
3579
  };
2694
3580
  await fs.rm(tmpArchive, { force: true });
2695
3581
  if (!extracted.ok) {
2696
- 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(() => { });
2697
3584
  return undefined;
2698
3585
  }
2699
3586
  // Tree bundle (no launcher): the whole extracted tree IS the artifact. Verify
2700
- // the marker exists and resolve to the extract dir — the consuming server
2701
- // 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).
2702
3590
  if (!spec.launcher) {
2703
- const marker = spec.treeMarker
2704
- ? path.join(extractDir, ...spec.treeMarker.split("/"))
2705
- : extractDir;
3591
+ const tmpMarker = spec.treeMarker
3592
+ ? path.join(tmpExtractDir, ...spec.treeMarker.split("/"))
3593
+ : tmpExtractDir;
2706
3594
  try {
2707
- await fs.access(marker);
3595
+ await fs.access(tmpMarker);
2708
3596
  }
2709
3597
  catch {
2710
- 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(() => { });
2711
3600
  return undefined;
2712
3601
  }
3602
+ await swapExtractedDir(tool.id, tmpExtractDir, extractDir);
2713
3603
  logSessionStart(`archive-install ${tool.id}: installed tree bundle → ${extractDir} (extracted ${archiveBuffer.length} bytes)`);
2714
3604
  debugLog(`[archive] installed ${tool.name} bundle → ${extractDir}`);
2715
3605
  return extractDir;
2716
3606
  }
2717
- // The launcher inside the extracted tree (e.g. bin/spotbugs[.bat]).
2718
- const innerLauncher = path.join(extractDir, ...spec.launcher.split("/").map((p) => p));
2719
- 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;
2720
3614
  try {
2721
- await fs.access(resolvedInner);
3615
+ await fs.access(tmpResolvedInner);
2722
3616
  }
2723
3617
  catch {
2724
- 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(() => { });
2725
3620
  return undefined;
2726
3621
  }
2727
3622
  if (!isWindows)
2728
- 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;
2729
3630
  // Thin shim in the managed bin so discovery (findGitHubToolPath) resolves
2730
3631
  // it like any other managed tool. `call`/`exec` preserves the real
2731
3632
  // launcher's own %~dp0/$0 so it still finds its sibling lib/.
@@ -2733,10 +3634,10 @@ async function installArchiveTool(tool) {
2733
3634
  const launcherName = isWindows ? `${binaryName}.bat` : binaryName;
2734
3635
  const shimPath = path.join(GITHUB_BIN_DIR, launcherName);
2735
3636
  if (isWindows) {
2736
- await fs.writeFile(shimPath, `@echo off\r\ncall "${resolvedInner}" %*\r\n`);
3637
+ await writeFileAtomicAsync(shimPath, `@echo off\r\ncall "${resolvedInner}" %*\r\n`, { bestEffort: false });
2737
3638
  }
2738
3639
  else {
2739
- await fs.writeFile(shimPath, `#!/bin/sh\nexec "${resolvedInner}" "$@"\n`, { mode: 0o750 });
3640
+ await writeFileAtomicAsync(shimPath, `#!/bin/sh\nexec "${resolvedInner}" "$@"\n`, { bestEffort: false, mode: 0o750 });
2740
3641
  }
2741
3642
  logSessionStart(`archive-install ${tool.id}: installed → ${shimPath} (extracted ${archiveBuffer.length} bytes)`);
2742
3643
  debugLog(`[archive] installed ${tool.name} → ${shimPath}`);
@@ -2744,7 +3645,8 @@ async function installArchiveTool(tool) {
2744
3645
  }
2745
3646
  catch (err) {
2746
3647
  await fs.rm(tmpArchive, { force: true }).catch(() => { });
2747
- 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`);
2748
3650
  return undefined;
2749
3651
  }
2750
3652
  }
@@ -2758,7 +3660,7 @@ async function installNpmTool(packageName, binaryName) {
2758
3660
  await fs.access(packageJsonPath);
2759
3661
  }
2760
3662
  catch {
2761
- 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 });
2762
3664
  }
2763
3665
  // Resolve the package manager for the tools dir and build install args.
2764
3666
  const isWindows = installerPlatform() === "win32";
@@ -2863,31 +3765,43 @@ async function installNpmTool(packageName, binaryName) {
2863
3765
  /**
2864
3766
  * Install a pip package tool
2865
3767
  */
2866
- 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 = {}) {
2867
3778
  try {
2868
3779
  const isWindows = process.platform === "win32";
3780
+ const verb = options.upgrade
3781
+ ? ["install", "-U", "--user"]
3782
+ : ["install", "--user"];
2869
3783
  const pipCandidates = isWindows
2870
3784
  ? [
2871
- { command: "pip", args: ["install", "--user", packageName] },
3785
+ { command: "pip", args: [...verb, packageName] },
2872
3786
  {
2873
3787
  command: "py",
2874
- args: ["-m", "pip", "install", "--user", packageName],
3788
+ args: ["-m", "pip", ...verb, packageName],
2875
3789
  },
2876
3790
  {
2877
3791
  command: "python",
2878
- args: ["-m", "pip", "install", "--user", packageName],
3792
+ args: ["-m", "pip", ...verb, packageName],
2879
3793
  },
2880
3794
  ]
2881
3795
  : [
2882
- { command: "pip3", args: ["install", "--user", packageName] },
2883
- { command: "pip", args: ["install", "--user", packageName] },
3796
+ { command: "pip3", args: [...verb, packageName] },
3797
+ { command: "pip", args: [...verb, packageName] },
2884
3798
  {
2885
3799
  command: "python3",
2886
- args: ["-m", "pip", "install", "--user", packageName],
3800
+ args: ["-m", "pip", ...verb, packageName],
2887
3801
  },
2888
3802
  {
2889
3803
  command: "python",
2890
- args: ["-m", "pip", "install", "--user", packageName],
3804
+ args: ["-m", "pip", ...verb, packageName],
2891
3805
  },
2892
3806
  ];
2893
3807
  let lastError = "";
@@ -3011,8 +3925,46 @@ async function installGemTool(packageName) {
3011
3925
  return undefined;
3012
3926
  }
3013
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
+ }
3014
3961
  async function finishInstallAttempt(toolId, ok, startedAt) {
3015
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"));
3016
3968
  if (ok) {
3017
3969
  // A prior availability probe may have cached ENOENT for this exact child
3018
3970
  // PATH. Make a successful mutation visible immediately rather than waiting
@@ -3044,11 +3996,13 @@ async function finishInstallAttempt(toolId, ok, startedAt) {
3044
3996
  export async function installTool(toolId) {
3045
3997
  if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
3046
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");
3047
4000
  logSessionStart(`auto-install ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
3048
4001
  return false;
3049
4002
  }
3050
4003
  const tool = TOOLS.find((t) => t.id === toolId);
3051
4004
  if (!tool) {
4005
+ noteInstallAttempt(toolId, "declined", "unknown tool id");
3052
4006
  logSessionStart(`auto-install ${toolId}: unknown tool id`);
3053
4007
  return false;
3054
4008
  }
@@ -3060,6 +4014,26 @@ export async function installTool(toolId) {
3060
4014
  if (!tool.packageName || !tool.binaryName)
3061
4015
  return false;
3062
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
+ }
3063
4037
  return finishInstallAttempt(tool.id, npmPath !== undefined, startedAt);
3064
4038
  }
3065
4039
  case "pip": {
@@ -3116,16 +4090,27 @@ export async function ensureTool(toolId, opts) {
3116
4090
  if (opts?.allowInstall !== false &&
3117
4091
  !assertInstallAllowed(`managed tool ensure: ${toolId}`)) {
3118
4092
  logSessionStart(`auto-install ensure ${toolId}: install gated — ${projectTrustDenialReason()}; discovery only`);
3119
- 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;
3120
4101
  }
3121
4102
  return ensureToolResolved(toolId, opts);
3122
4103
  }
3123
4104
  async function ensureToolResolved(toolId, opts) {
3124
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);
3125
4110
  const cacheResolvedPath = (result) => {
3126
4111
  if (result) {
3127
4112
  resolvedPathCache.set(toolId, result);
3128
- void updateProbeCache(toolId, result);
4113
+ void updateProbeCache(toolId, result, wasLastResolveTransient(toolId));
3129
4114
  }
3130
4115
  return result;
3131
4116
  };
@@ -3148,16 +4133,19 @@ async function ensureToolResolved(toolId, opts) {
3148
4133
  // best-effort
3149
4134
  }
3150
4135
  if (opts.allowInstall === false) {
4136
+ noteInstallAttempt(toolId, "declined", "install disabled by caller");
3151
4137
  logSessionStart(`auto-install ensure ${toolId}: force reinstall blocked — install disabled, discovery only (${Date.now() - ensureStartMs}ms)`);
3152
4138
  return cacheResolvedPath(await getToolPath(toolId));
3153
4139
  }
3154
4140
  if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
3155
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");
3156
4143
  logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1`);
3157
4144
  return undefined;
3158
4145
  }
3159
4146
  const lock = await acquireInstallLock();
3160
4147
  if (!lock.release) {
4148
+ noteInstallAttempt(toolId, "skipped", lock.reason ?? "install lock held");
3161
4149
  logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
3162
4150
  return undefined;
3163
4151
  }
@@ -3169,6 +4157,9 @@ async function ensureToolResolved(toolId, opts) {
3169
4157
  await lock.release();
3170
4158
  }
3171
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");
3172
4163
  logSessionStart(`auto-install ensure ${toolId}: force reinstall failed (${Date.now() - ensureStartMs}ms)`);
3173
4164
  return undefined;
3174
4165
  }
@@ -3182,10 +4173,13 @@ async function ensureToolResolved(toolId, opts) {
3182
4173
  // Fast path 1: in-memory session cache — no I/O.
3183
4174
  const cached = resolvedPathCache.get(toolId);
3184
4175
  if (cached) {
3185
- if (!isFullyQualified(cached))
4176
+ if (!isFullyQualified(cached)) {
4177
+ lastEnsureResolutionSource.set(toolId, "session-cache");
3186
4178
  return cached;
4179
+ }
3187
4180
  try {
3188
4181
  await fs.access(cached);
4182
+ lastEnsureResolutionSource.set(toolId, "session-cache");
3189
4183
  return cached;
3190
4184
  }
3191
4185
  catch {
@@ -3202,6 +4196,7 @@ async function ensureToolResolved(toolId, opts) {
3202
4196
  const diskCached = await checkProbeCache(toolId);
3203
4197
  if (diskCached) {
3204
4198
  resolvedPathCache.set(toolId, diskCached);
4199
+ lastEnsureResolutionSource.set(toolId, "probe-cache");
3205
4200
  logSessionStart(`auto-install ensure ${toolId}: probe cache hit → ${diskCached}`);
3206
4201
  return diskCached;
3207
4202
  }
@@ -3246,7 +4241,8 @@ async function ensureToolResolved(toolId, opts) {
3246
4241
  }
3247
4242
  }
3248
4243
  resolvedPathCache.set(toolId, existingPath);
3249
- void updateProbeCache(toolId, existingPath);
4244
+ void updateProbeCache(toolId, existingPath, wasLastResolveTransient(toolId));
4245
+ lastEnsureResolutionSource.set(toolId, "path");
3250
4246
  logSessionStart(`auto-install ensure ${toolId}: already available at ${existingPath} (${Date.now() - ensureStartMs}ms)`);
3251
4247
  return existingPath;
3252
4248
  }
@@ -3255,17 +4251,20 @@ async function ensureToolResolved(toolId, opts) {
3255
4251
  // caller forbids installs (allowInstall:false, e.g. PI_LENS_DISABLE_LSP_INSTALL=1)
3256
4252
  // we must still return a discovered binary and only skip the actual install.
3257
4253
  if (opts?.allowInstall === false) {
4254
+ noteInstallAttempt(toolId, "declined", "install disabled by caller");
3258
4255
  logSessionStart(`auto-install ensure ${toolId}: install disabled — discovery only, not found (${Date.now() - ensureStartMs}ms)`);
3259
4256
  return undefined;
3260
4257
  }
3261
4258
  if (process.env.PI_LENS_DISABLE_TOOL_INSTALL === "1") {
3262
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");
3263
4261
  logSessionStart(`auto-install ensure ${toolId}: refused — PI_LENS_DISABLE_TOOL_INSTALL=1 (${Date.now() - ensureStartMs}ms)`);
3264
4262
  return undefined;
3265
4263
  }
3266
4264
  const lock = await acquireInstallLock();
3267
4265
  if (!lock.release) {
3268
4266
  installFailureReasons.set(toolId, lock.reason ?? "install lock failed");
4267
+ noteInstallAttempt(toolId, "skipped", lock.reason ?? "install lock held");
3269
4268
  logSessionStart(`auto-install ensure ${toolId}: ${lock.reason}`);
3270
4269
  return undefined;
3271
4270
  }
@@ -3275,8 +4274,9 @@ async function ensureToolResolved(toolId, opts) {
3275
4274
  // installed by its predecessor and must not run a second package manager.
3276
4275
  const installedByPeer = await getToolPath(toolId);
3277
4276
  if (installedByPeer) {
4277
+ noteInstallAttempt(toolId, "succeeded", "installed by a concurrent process");
3278
4278
  resolvedPathCache.set(toolId, installedByPeer);
3279
- void updateProbeCache(toolId, installedByPeer);
4279
+ void updateProbeCache(toolId, installedByPeer, wasLastResolveTransient(toolId));
3280
4280
  return installedByPeer;
3281
4281
  }
3282
4282
  installed = await installTool(toolId);
@@ -3285,13 +4285,16 @@ async function ensureToolResolved(toolId, opts) {
3285
4285
  await lock.release();
3286
4286
  }
3287
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");
3288
4291
  logSessionStart(`auto-install ensure ${toolId}: unavailable (${Date.now() - ensureStartMs}ms)`);
3289
4292
  return undefined;
3290
4293
  }
3291
4294
  const result = await getToolPath(toolId);
3292
4295
  if (result) {
3293
4296
  resolvedPathCache.set(toolId, result);
3294
- void updateProbeCache(toolId, result);
4297
+ void updateProbeCache(toolId, result, wasLastResolveTransient(toolId));
3295
4298
  logSessionStart(`auto-install ensure ${toolId}: success at ${result} (${Date.now() - ensureStartMs}ms)`);
3296
4299
  }
3297
4300
  else {
@@ -3349,6 +4352,16 @@ export async function checkAllTools() {
3349
4352
  export function isKnownToolId(toolId) {
3350
4353
  return TOOLS.some((tool) => tool.id === toolId);
3351
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
+ }
3352
4365
  /**
3353
4366
  * GitHub-release tools that ship an asset for **every** supported
3354
4367
  * platform/arch combo (linux/darwin/win32 × x64/arm64). This is the set the
@@ -3382,6 +4395,7 @@ export const GITHUB_TOOLS = [
3382
4395
  "opengrep",
3383
4396
  "deno",
3384
4397
  "clojure-lsp",
4398
+ "cue",
3385
4399
  "gleam",
3386
4400
  "marksman",
3387
4401
  "expert",