@herbertgao/pi-extensions 2026.8.13 → 2026.8.15

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 (263) hide show
  1. package/README.md +19 -14
  2. package/THIRD_PARTY_NOTICES.md +0 -1
  3. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +19 -0
  4. package/node_modules/@czottmann/pi-automode/README.md +5 -1
  5. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +126 -13
  6. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/config.ts +7 -3
  7. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/constants.ts +2 -0
  8. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/hard-deny.ts +134 -23
  9. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  10. package/node_modules/@herbertgao/{pi-stash → pi-bark}/LICENSE +4 -5
  11. package/node_modules/@herbertgao/pi-bark/README.md +44 -0
  12. package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
  13. package/node_modules/@herbertgao/pi-bark/package.json +53 -0
  14. package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
  15. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +1 -1
  16. package/node_modules/@herbertgao/pi-cc-extensions/README.md +1 -1
  17. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  18. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +6 -0
  19. package/node_modules/@herbertgao/pi-subagents/package.json +2 -1
  20. package/node_modules/@herbertgao/pi-subagents/src/ui/conversation-viewer.ts +8 -1
  21. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  22. package/node_modules/@narumitw/pi-btw/README.md +125 -88
  23. package/node_modules/@narumitw/pi-btw/dist/index.ts +140 -45
  24. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
  25. package/node_modules/@narumitw/pi-btw/package.json +5 -8
  26. package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
  27. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +106 -17
  28. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  29. package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
  30. package/node_modules/@tifan/pi-handoff/README.md +36 -13
  31. package/node_modules/@tifan/pi-handoff/package.json +2 -2
  32. package/node_modules/@tifan/pi-handoff/src/index.ts +160 -4
  33. package/node_modules/@tifan/pi-preferred-thinking/README.md +1 -1
  34. package/node_modules/@tifan/pi-preferred-thinking/package.json +1 -1
  35. package/node_modules/@tifan/pi-preferred-thinking/src/index.ts +15 -2
  36. package/node_modules/@tifan/pi-recap/README.md +1 -1
  37. package/node_modules/@tifan/pi-recap/package.json +2 -2
  38. package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
  39. package/node_modules/@tifan/pi-rename/README.md +42 -21
  40. package/node_modules/@tifan/pi-rename/package.json +1 -1
  41. package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
  42. package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
  43. package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
  44. package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
  45. package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
  46. package/node_modules/pi-lens/CHANGELOG.md +320 -0
  47. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
  48. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
  50. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
  51. package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
  52. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
  53. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
  54. package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
  55. package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
  56. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
  57. package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
  58. package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
  59. package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
  60. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
  61. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
  62. package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
  63. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
  64. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
  65. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
  66. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
  67. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
  68. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
  69. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
  70. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
  71. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
  72. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
  73. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
  74. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
  75. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
  76. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
  77. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
  78. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
  79. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
  80. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
  81. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  83. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
  84. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  85. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
  86. package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
  87. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
  88. package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
  89. package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
  90. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
  91. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
  92. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
  93. package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
  94. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
  95. package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
  96. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
  97. package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
  98. package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
  99. package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
  100. package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
  101. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  102. package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
  103. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
  104. package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
  105. package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
  106. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
  107. package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
  108. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
  109. package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
  110. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
  111. package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
  112. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
  113. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
  114. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  115. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
  116. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
  117. package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
  118. package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
  119. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
  120. package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
  121. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
  122. package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
  123. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
  124. package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
  125. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
  126. package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
  127. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  128. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
  129. package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
  130. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
  131. package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
  132. package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
  133. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
  134. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
  135. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
  136. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
  137. package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
  138. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
  139. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
  140. package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
  141. package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
  142. package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
  143. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
  144. package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
  145. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
  146. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
  147. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
  148. package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
  149. package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
  150. package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
  151. package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
  152. package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
  153. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
  154. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
  155. package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
  156. package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
  157. package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
  158. package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
  159. package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
  160. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
  161. package/node_modules/pi-lens/dist/index.js +11243 -6651
  162. package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
  163. package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
  164. package/node_modules/pi-lens/dist/mcp/server.js +6 -1
  165. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
  166. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
  167. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
  168. package/node_modules/pi-lens/docs/agent-guide.md +4 -2
  169. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
  170. package/node_modules/pi-lens/docs/astplayground.md +26 -17
  171. package/node_modules/pi-lens/package.json +5 -1
  172. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  173. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  174. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  175. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  176. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  177. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  178. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  179. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  180. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  181. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  182. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  183. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  184. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  185. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  186. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  187. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  188. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  189. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  190. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  191. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  192. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  193. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  194. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  195. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  196. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  197. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  198. package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  199. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  200. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  201. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  202. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  203. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  204. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  205. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  206. package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  207. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  208. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  209. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  210. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  211. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  212. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  213. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  214. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  215. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
  216. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  217. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  218. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  219. package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  220. package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  221. package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  222. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  223. package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  224. package/node_modules/pi-mcp-adapter/CHANGELOG.md +35 -0
  225. package/node_modules/pi-mcp-adapter/README.md +5 -2
  226. package/node_modules/pi-mcp-adapter/cli.js +4 -4
  227. package/node_modules/pi-mcp-adapter/commands.ts +1 -1
  228. package/node_modules/pi-mcp-adapter/config.ts +1 -0
  229. package/node_modules/pi-mcp-adapter/dist/config.js +1 -0
  230. package/node_modules/pi-mcp-adapter/dist/config.js.map +1 -1
  231. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.d.ts +24 -0
  232. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js +336 -0
  233. package/node_modules/pi-mcp-adapter/dist/mcp-bearer-store.js.map +1 -0
  234. package/node_modules/pi-mcp-adapter/dist/types.d.ts +3 -1
  235. package/node_modules/pi-mcp-adapter/dist/types.js.map +1 -1
  236. package/node_modules/pi-mcp-adapter/index.ts +90 -6
  237. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +124 -32
  238. package/node_modules/pi-mcp-adapter/mcp-bearer-store.ts +0 -2
  239. package/node_modules/pi-mcp-adapter/mcp-oauth-provider.ts +89 -0
  240. package/node_modules/pi-mcp-adapter/mcp-references.ts +9 -1
  241. package/node_modules/pi-mcp-adapter/package.json +1 -1
  242. package/node_modules/pi-mcp-adapter/proxy-modes.ts +62 -22
  243. package/node_modules/pi-mcp-adapter/request-headers-command.ts +1 -1
  244. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -0
  245. package/node_modules/pi-mcp-adapter/types.ts +3 -1
  246. package/node_modules/pi-web-access/CHANGELOG.md +42 -0
  247. package/node_modules/pi-web-access/README.md +26 -10
  248. package/node_modules/pi-web-access/curator-page.ts +12 -3
  249. package/node_modules/pi-web-access/curator-server.ts +3 -1
  250. package/node_modules/pi-web-access/extract.ts +88 -13
  251. package/node_modules/pi-web-access/gemini-search.ts +10 -5
  252. package/node_modules/pi-web-access/github-extract.ts +47 -3
  253. package/node_modules/pi-web-access/index.ts +40 -40
  254. package/node_modules/pi-web-access/package.json +1 -1
  255. package/node_modules/pi-web-access/page-query.ts +61 -8
  256. package/node_modules/pi-web-access/xcrawl.ts +264 -0
  257. package/package.json +19 -22
  258. package/node_modules/@herbertgao/pi-stash/README.md +0 -34
  259. package/node_modules/@herbertgao/pi-stash/package.json +0 -51
  260. package/node_modules/@herbertgao/pi-stash/src/index.ts +0 -118
  261. package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
  262. package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
  263. package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
@@ -26,17 +26,22 @@ const rustClient = new RustClient();
26
26
  // The verdict is governed by the shared availability policy (#1494): a probe
27
27
  // timeout expires on a cooldown, so only a genuinely absent clippy latches.
28
28
  const CLIPPY_PROBE_BUDGET_MS = 8000;
29
- const makeClippyProbe = (cargoExe) => createCwdCachedProbe((cwd) => safeSpawnAsync(cargoExe, ["clippy", "--version"], {
29
+ let clippyProbeRevision = 0;
30
+ const makeClippyProbe = (cargoExe, flightKeyComponent = cargoExe) => createCwdCachedProbe((cwd) => safeSpawnAsync(cargoExe, ["clippy", "--version"], {
30
31
  timeout: CLIPPY_PROBE_BUDGET_MS,
31
32
  cwd,
32
- }), { tool: "clippy", budgetMs: CLIPPY_PROBE_BUDGET_MS });
33
+ }), {
34
+ tool: "clippy",
35
+ budgetMs: CLIPPY_PROBE_BUDGET_MS,
36
+ flightKeyComponent,
37
+ });
33
38
  const clippyAvailabilityByCargo = new Map();
34
39
  function getClippyProbe(cargoExe) {
35
40
  return (clippyAvailabilityByCargo.get(cargoExe) ?? refreshClippyProbe(cargoExe));
36
41
  }
37
42
  /** Replace the cached probe so a post-install state is observed. */
38
43
  function refreshClippyProbe(cargoExe) {
39
- const created = makeClippyProbe(cargoExe);
44
+ const created = makeClippyProbe(cargoExe, `${cargoExe}#refresh-${++clippyProbeRevision}`);
40
45
  clippyAvailabilityByCargo.set(cargoExe, created);
41
46
  return created;
42
47
  }
@@ -10,6 +10,7 @@ import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
11
  import { fileURLToPath } from "node:url";
12
12
  import { logSessionStart } from "../../../sessionstart-logger.js";
13
+ import { incrementDegradationCount } from "../../../degradation-ledger.js";
13
14
  import { getGlobalPiLensDir } from "../../../file-utils.js";
14
15
  import { createGenerationSource, } from "../../../generation-guard.js";
15
16
  import { PathKeyedMap } from "../../../path-keyed-map.js";
@@ -18,8 +19,10 @@ import { ensureTool, getInstallAttempt, getLastEnsureResolutionSource, getToolIn
18
19
  import { getServersForFileWithConfig, isServerDisabled, } from "../../../lsp/config.js";
19
20
  import { findGlobalBinary } from "../../../package-manager.js";
20
21
  import { safeSpawnAsync } from "../../../safe-spawn.js";
22
+ import { compareOrdinal } from "../../../string-utils.js";
21
23
  import { getToolCommandSpec, shouldAutoInstallTool, } from "../../../tool-policy.js";
22
24
  import { isInSpawnTimeoutCooldown } from "../../../spawn-timeout-cooldown.js";
25
+ import { createAvailabilityProbeFlight } from "../../../availability-probe-flight.js";
23
26
  import { classifyProbeFailure, createAvailabilityLatch, describeInstallAttempt, describeProbeEvidence, isLatchingOutcome, logAvailabilityDecision, startHostStallSampler, transientRetryDelayMs, } from "./availability-policy.js";
24
27
  export { createAvailabilityLatch, classifyProbeFailure, describeProbeEvidence, describeUnavailability, isTransientDecision, logAvailabilityDecision, startHostStallSampler, } from "./availability-policy.js";
25
28
  /**
@@ -118,14 +121,14 @@ const MANAGED_VERIFY_COOLDOWN_MAX_MS = 15 * 60_000;
118
121
  function managedVerifyCooldownMs(attempts) {
119
122
  return Math.min(MANAGED_VERIFY_COOLDOWN_MAX_MS, MANAGED_VERIFY_COOLDOWN_MS * 2 ** (attempts - 1));
120
123
  }
121
- async function runManagedVerification(candidate, stamp, priorAttempts, generation) {
124
+ async function runManagedVerification(candidate, stamp, priorAttempts, generation, verificationArgs = ["--version"]) {
122
125
  let transient = false;
123
126
  let ok;
124
127
  try {
125
128
  const { verifyToolBinary } = await import("../../../installer/index.js");
126
129
  ok = await verifyToolBinary(candidate, undefined, () => {
127
130
  transient = true;
128
- }, MANAGED_VERIFY_TIMEOUT_MS);
131
+ }, MANAGED_VERIFY_TIMEOUT_MS, verificationArgs);
129
132
  }
130
133
  catch {
131
134
  // The verifier itself could not run — installer-isolated unit tests mock
@@ -164,7 +167,7 @@ async function runManagedVerification(candidate, stamp, priorAttempts, generatio
164
167
  }
165
168
  return ok ? "ok" : "broken";
166
169
  }
167
- async function verifyManagedCandidate(candidate) {
170
+ async function verifyManagedCandidate(candidate, verificationArgs = ["--version"]) {
168
171
  let stamp;
169
172
  try {
170
173
  const stat = fs.statSync(candidate);
@@ -186,7 +189,7 @@ async function verifyManagedCandidate(candidate) {
186
189
  if (existing)
187
190
  return existing;
188
191
  const generation = availabilityGeneration.capture();
189
- const probe = runManagedVerification(candidate, stamp, priorAttempts, generation).finally(() => {
192
+ const probe = runManagedVerification(candidate, stamp, priorAttempts, generation, verificationArgs).finally(() => {
190
193
  // A settling old-session probe must not evict the live entry a new
191
194
  // session already started for the same shim (#1674 review F5).
192
195
  generation.guardedWrite(stamp, () => {
@@ -218,9 +221,9 @@ async function verifyManagedCandidate(candidate) {
218
221
  * stalls on a cold cache still gets re-probed later (#1674 review F1).
219
222
  * Concurrent first touches share one probe (#1674 review F2).
220
223
  */
221
- export async function findManagedNodeToolBinary(tool) {
224
+ export async function findManagedNodeToolBinary(tool, verificationArgs = ["--version"]) {
222
225
  for (const candidate of managedNodeToolCandidates(tool)) {
223
- const verdict = await verifyManagedCandidate(candidate);
226
+ const verdict = await verifyManagedCandidate(candidate, verificationArgs);
224
227
  if (verdict === "ok" || verdict === "unverified")
225
228
  return candidate;
226
229
  }
@@ -237,7 +240,7 @@ export async function findManagedNodeToolBinary(tool) {
237
240
  * `shell: false` (safe-spawn, #817), so wrapping it in quotes would make it
238
241
  * a literal filename that ENOENTs on every platform (#1508).
239
242
  */
240
- export function createVenvFinder(command, windowsExt = "") {
243
+ export function createVenvFinder(command, windowsExt = "", verificationArgs = ["--version"]) {
241
244
  return async (cwd) => {
242
245
  const venvPaths = [
243
246
  `.venv/bin/${command}`,
@@ -261,7 +264,7 @@ export function createVenvFinder(command, windowsExt = "") {
261
264
  // check settles it without a spawn — after one verification per shim per
262
265
  // session, so a shim that cannot run falls through to PATH instead of
263
266
  // shadowing a working binary (#1657).
264
- const managed = await findManagedNodeToolBinary(command);
267
+ const managed = await findManagedNodeToolBinary(command, verificationArgs);
265
268
  if (managed)
266
269
  return managed;
267
270
  // Fall back to global
@@ -366,6 +369,19 @@ const latchedUnavailableByCwd = new PathKeyedMap(normalizeMapKey);
366
369
  // instead of five hand-rolled ones, and a dropped straddling write is visible
367
370
  // in the degradation ledger instead of silent.
368
371
  const availabilityGeneration = createGenerationSource("dispatch-availability");
372
+ export const getDispatchAvailabilityGeneration = () => availabilityGeneration.current();
373
+ const checkerProbeFlights = createAvailabilityProbeFlight({ generation: getDispatchAvailabilityGeneration });
374
+ export function recordAvailabilityProbeOverrun(tool, key, elapsedMs, budgetMs, failure) {
375
+ if (budgetMs === undefined || elapsedMs <= budgetMs)
376
+ return;
377
+ if (failure === "timeout" && elapsedMs <= budgetMs * 2)
378
+ return;
379
+ incrementDegradationCount({
380
+ kind: "availability-probe-overrun",
381
+ subject: `${tool}:${key}`,
382
+ reason: `probe took ${Math.round(elapsedMs)}ms; budget was ${Math.round(budgetMs)}ms`,
383
+ });
384
+ }
369
385
  function installStateFor(cwd, toolId) {
370
386
  let states = installAttemptsByCwd.get(cwd);
371
387
  if (!states) {
@@ -598,6 +614,9 @@ export function resetDispatchAvailabilityState() {
598
614
  managedVerifyInFlight.clear();
599
615
  resetPathWalkMemo();
600
616
  availabilityGeneration.bump();
617
+ checkerProbeFlights.clear();
618
+ cwdProbeFlights.clear();
619
+ // The generation bump invalidates dispatcherProbeFlights in its owner module.
601
620
  }
602
621
  /**
603
622
  * Create a cached availability checker for a command.
@@ -620,18 +639,21 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
620
639
  const cacheByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
621
640
  const inFlightByCwd = new PathKeyedMap(normalizeEphemeralMapKey);
622
641
  let checkerGeneration = availabilityGeneration.current();
623
- const findCommand = createVenvFinder(command, windowsExt);
642
+ let checkerFlightGeneration = 0;
643
+ const findCommand = createVenvFinder(command, windowsExt, versionArgs);
624
644
  function ensureCurrentGeneration() {
625
645
  if (checkerGeneration === availabilityGeneration.current())
626
646
  return;
627
647
  cacheByCwd.clear();
628
648
  inFlightByCwd.clear();
629
649
  checkerGeneration = availabilityGeneration.current();
650
+ checkerFlightGeneration++;
630
651
  }
631
652
  const reset = () => {
632
653
  cacheByCwd.clear();
633
654
  inFlightByCwd.clear();
634
655
  checkerGeneration = availabilityGeneration.current();
656
+ checkerFlightGeneration++;
635
657
  };
636
658
  function getCache(cwd) {
637
659
  ensureCurrentGeneration();
@@ -692,9 +714,9 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
692
714
  cause: verdict.cause,
693
715
  elapsedMs: verdict.elapsedMs,
694
716
  latched,
695
- ...(verdict.classifiedBy !== undefined && {
696
- classifiedBy: verdict.classifiedBy,
697
- }),
717
+ // Every caller passes this (#2209); a direct key, not a conditional
718
+ // spread, keeps it visible to the call-shaped classifiedBy sweep.
719
+ classifiedBy: verdict.classifiedBy,
698
720
  ...(verdict.evidence !== undefined && { evidence: verdict.evidence }),
699
721
  ...(verdict.hostStallMs !== undefined && {
700
722
  hostStallMs: verdict.hostStallMs,
@@ -807,17 +829,22 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
807
829
  const startedAt = Date.now();
808
830
  let result;
809
831
  let hostStallMs;
832
+ let probeJoined = false;
810
833
  try {
811
- result = await safeSpawnAsync(cmd, versionArgs, {
834
+ const shared = checkerProbeFlights.run(`checker:${command}|${versionArgs.join("|")}|${key}|${checkerFlightGeneration}|${windowsExt}|${cmd}|${JSON.stringify(Object.entries(env ?? {}).sort(([a], [b]) => compareOrdinal(a, b)))}`, () => safeSpawnAsync(cmd, versionArgs, {
812
835
  timeout: options.probeTimeout ?? 5000,
813
836
  cwd: resolvedCwd,
814
837
  env,
815
- });
838
+ input: "",
839
+ }));
840
+ probeJoined = shared.joined;
841
+ result = await shared.promise;
816
842
  }
817
843
  finally {
818
844
  hostStallMs = stallSampler.stop();
819
845
  }
820
846
  const elapsedMs = Date.now() - startedAt;
847
+ recordAvailabilityProbeOverrun(command, resolvedCwd, elapsedMs, options.probeTimeout ?? 5000, result.failure);
821
848
  if (!result.error && result.status === 0) {
822
849
  cache.command = cmd;
823
850
  noteDecision(cache, resolvedCwd, {
@@ -826,7 +853,7 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
826
853
  cause: "ok",
827
854
  elapsedMs,
828
855
  hostStallMs,
829
- classifiedBy: "probe",
856
+ classifiedBy: probeJoined ? "joined" : "probe",
830
857
  evidence: describeProbeEvidence(result),
831
858
  });
832
859
  return true;
@@ -846,7 +873,7 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
846
873
  cause,
847
874
  elapsedMs,
848
875
  hostStallMs,
849
- classifiedBy: "probe",
876
+ classifiedBy: probeJoined ? "joined" : "probe",
850
877
  evidence,
851
878
  });
852
879
  return false;
@@ -885,6 +912,9 @@ export function createAvailabilityChecker(command, windowsExt = "", versionArgs
885
912
  }
886
913
  return { isAvailableAsync, getCommand, getOutcome, getVerdict, reset };
887
914
  }
915
+ const cwdProbeFlights = createAvailabilityProbeFlight({
916
+ generation: getDispatchAvailabilityGeneration,
917
+ });
888
918
  /**
889
919
  * Per-cwd cached availability probe for spawn signatures that don't fit
890
920
  * `createAvailabilityChecker` — multi-arg subcommands like `cargo clippy
@@ -957,7 +987,7 @@ export function createCwdCachedProbe(probe, options) {
957
987
  ...(retryAfterMs !== undefined && { retryAfterMs }),
958
988
  ...(options.budgetMs !== undefined && { budgetMs: options.budgetMs }),
959
989
  // Every verdict here is derived from the probe this seam just ran.
960
- classifiedBy: "probe",
990
+ classifiedBy: verdict.classifiedBy ?? "probe",
961
991
  ...(verdict.evidence !== undefined && { evidence: verdict.evidence }),
962
992
  }, key);
963
993
  }
@@ -981,20 +1011,24 @@ export function createCwdCachedProbe(probe, options) {
981
1011
  const startedAt = Date.now();
982
1012
  let result;
983
1013
  let thrown;
1014
+ let probeJoined = false;
984
1015
  try {
985
- result = await probe(key);
1016
+ const shared = cwdProbeFlights.run(`cwd:${options.tool}|${options.flightKeyComponent ?? ""}|${key}`, () => probe(key));
1017
+ probeJoined = shared.joined;
1018
+ result = await shared.promise;
986
1019
  }
987
1020
  catch (error) {
988
1021
  thrown = error;
989
1022
  }
990
1023
  const hostStallMs = stallSampler.stop();
991
1024
  const elapsedMs = Date.now() - startedAt;
992
- // A probe that threw carries its errno in the Error, which is exactly
993
- // what the taxonomy reads — so a thrown EAGAIN stays transient instead
994
- // of collapsing into an untyped `false`.
995
1025
  const shape = result ?? {
996
1026
  error: thrown instanceof Error ? thrown : new Error(String(thrown)),
997
1027
  };
1028
+ recordAvailabilityProbeOverrun(options.tool, key, elapsedMs, options.budgetMs, shape.failure);
1029
+ // A probe that threw carries its errno in the Error, which is exactly
1030
+ // what the taxonomy reads — so a thrown EAGAIN stays transient instead
1031
+ // of collapsing into an untyped `false`.
998
1032
  if (result && !result.error && result.status === 0) {
999
1033
  note(latch, key, {
1000
1034
  available: true,
@@ -1002,6 +1036,7 @@ export function createCwdCachedProbe(probe, options) {
1002
1036
  cause: "ok",
1003
1037
  elapsedMs,
1004
1038
  hostStallMs,
1039
+ classifiedBy: probeJoined ? "joined" : "probe",
1005
1040
  evidence: describeProbeEvidence(result, options.tool),
1006
1041
  });
1007
1042
  return true;
@@ -1017,6 +1052,7 @@ export function createCwdCachedProbe(probe, options) {
1017
1052
  cause,
1018
1053
  elapsedMs,
1019
1054
  hostStallMs,
1055
+ classifiedBy: probeJoined ? "joined" : "probe",
1020
1056
  evidence,
1021
1057
  });
1022
1058
  return false;
@@ -1108,6 +1144,7 @@ async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--ve
1108
1144
  const versionCheck = await safeSpawnAsync(command, versionArgs, {
1109
1145
  timeout,
1110
1146
  cwd,
1147
+ input: "",
1111
1148
  });
1112
1149
  if (!versionCheck.error && versionCheck.status === 0) {
1113
1150
  return command;
@@ -1141,7 +1178,7 @@ async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--ve
1141
1178
  return null;
1142
1179
  }
1143
1180
  export async function resolveCommandArgsWithInstallFallback(command, toolId, cwd, versionArgs = ["--version"], timeout = 5000) {
1144
- const versionCheck = await safeSpawnAsync(command.cmd, [...command.args, ...versionArgs], { timeout, cwd });
1181
+ const versionCheck = await safeSpawnAsync(command.cmd, [...command.args, ...versionArgs], { timeout, cwd, input: "" });
1145
1182
  if (!versionCheck.error && versionCheck.status === 0) {
1146
1183
  return command;
1147
1184
  }
@@ -1353,7 +1390,7 @@ export async function isSgAvailableAsync() {
1353
1390
  return memo;
1354
1391
  if (sgAvailableInFlight)
1355
1392
  return sgAvailableInFlight;
1356
- sgAvailableInFlight = (async () => {
1393
+ const flight = (async () => {
1357
1394
  const startedAt = Date.now();
1358
1395
  sgSweepSawTransient = false;
1359
1396
  sgSweepTransientCause = "probe-timeout";
@@ -1415,9 +1452,19 @@ export async function isSgAvailableAsync() {
1415
1452
  noteSgUnavailable(startedAt, sgSweepSawTransient ? "transient" : "missing", sgSweepSawTransient ? sgSweepTransientCause : "not-found");
1416
1453
  return false;
1417
1454
  })().finally(() => {
1418
- sgAvailableInFlight = null;
1455
+ // Identity-guarded release (#1968's pattern): clear the slot only if THIS
1456
+ // flight still owns it. `ensureCurrentSgGeneration()` above nulls the slot
1457
+ // on a generation bump (a session reset arriving mid-flight) so the NEXT
1458
+ // caller starts a fresh flight rather than sharing this one's stale-generation
1459
+ // answer — that fresh flight is a live successor. Without the guard, this
1460
+ // flight's own late settlement would stomp the slot back to null out from
1461
+ // under that successor, and the caller after it would start a redundant
1462
+ // THIRD probe instead of sharing the still-running second one.
1463
+ if (sgAvailableInFlight === flight)
1464
+ sgAvailableInFlight = null;
1419
1465
  });
1420
- return sgAvailableInFlight;
1466
+ sgAvailableInFlight = flight;
1467
+ return flight;
1421
1468
  }
1422
1469
  /**
1423
1470
  * Record a successful shared-ast-grep sweep, with one decision record.
@@ -1449,6 +1496,7 @@ function noteSgAvailable(startedAt, opts = {}) {
1449
1496
  cause: provisional ? sgSweepTransientCause : "ok",
1450
1497
  elapsedMs: Date.now() - startedAt,
1451
1498
  latched: !provisional,
1499
+ classifiedBy: "probe",
1452
1500
  hostStallMs: sgSweepHostStallMs,
1453
1501
  budgetMs: 5000,
1454
1502
  ...(provisional && {
@@ -1472,6 +1520,9 @@ function noteSgUnavailable(startedAt, outcome, cause) {
1472
1520
  hostStallMs: sgSweepHostStallMs,
1473
1521
  ...(retryAfterMs > 0 && { retryAfterMs }),
1474
1522
  budgetMs: 5000,
1523
+ // Sibling of noteSgAvailable's "probe": both report the same
1524
+ // candidate sweep this function ran (#2209).
1525
+ classifiedBy: "probe",
1475
1526
  });
1476
1527
  }
1477
1528
  export function getSgCommand() {
@@ -1,4 +1,5 @@
1
1
  import { LEDGER_FIELD_MAX } from "../../../ledger-bounds.js";
2
+ import { truncatedByOutputCap } from "../../../spawn-output-cap.js";
2
3
  /**
3
4
  * The first non-empty line of a tool's output, bounded.
4
5
  *
@@ -58,6 +59,9 @@ export function classifyRunOutcome(input) {
58
59
  firstOutputLine: firstOutputLine(result.stderr) ||
59
60
  firstOutputLine(result.stdout) ||
60
61
  firstOutputLine(result.error?.message),
62
+ // Evidence carried alongside the verdict, never part of deciding it — the
63
+ // branches below are byte-for-byte the pre-#2100 rules.
64
+ outputCapped: truncatedByOutputCap(result),
61
65
  };
62
66
  if (result.error || signal)
63
67
  return { kind: "did-not-run", ...base };
@@ -5,6 +5,23 @@
5
5
  import { incrementDegradationCount } from "../../../degradation-ledger.js";
6
6
  import { truncateForLedger } from "../../../ledger-bounds.js";
7
7
  import { classifyRunOutcome, firstOutputLine, } from "./spawn-outcome.js";
8
+ /**
9
+ * What ended this run, as one clause, most specific cause first.
10
+ *
11
+ * A signal is a stronger explanation than the exit status, so it displaces a
12
+ * `null` one rather than sitting beside it. Our own output cap is stronger
13
+ * still: the signal it killed with is a detail of OUR decision, and naming that
14
+ * signal alone reads as the tool misbehaving (#2100).
15
+ */
16
+ function describeEnding(input) {
17
+ if (input.outputCapped)
18
+ return "was stopped at its output cap";
19
+ if (input.signal)
20
+ return `was killed by ${input.signal}`;
21
+ if (input.status === null)
22
+ return "did not run (spawn failure)";
23
+ return `exited ${input.status}`;
24
+ }
8
25
  /**
9
26
  * One wording for "this tool did not produce a usable result" (#1816).
10
27
  *
@@ -21,13 +38,7 @@ export function formatToolFailure(input) {
21
38
  const identity = entries.length
22
39
  ? `${input.tool} (${entries.map(([key, value]) => `${key}=${value}`).join(", ")})`
23
40
  : input.tool;
24
- // A signal is the strongest available explanation, so it displaces the exit
25
- // status rather than sitting beside a `null` one.
26
- const what = input.signal
27
- ? `was killed by ${input.signal}`
28
- : input.status === null
29
- ? "did not run (spawn failure)"
30
- : `exited ${input.status}`;
41
+ const what = describeEnding(input);
31
42
  const missing = input.reportMissing ? "no report file" : "no output";
32
43
  const detail = firstOutputLine(input.stderr) ||
33
44
  firstOutputLine(input.stdout) ||
@@ -44,6 +55,7 @@ export function formatRunOutcomeFailure(tool, outcome, fields) {
44
55
  tool,
45
56
  status: outcome.status,
46
57
  signal: outcome.signal,
58
+ outputCapped: outcome.outputCapped,
47
59
  stderr: outcome.firstOutputLine,
48
60
  reportMissing: outcome.kind === "report-missing",
49
61
  fields: { outcome: outcome.kind, ...fields },
@@ -13,6 +13,9 @@
13
13
  */
14
14
  import { createAvailabilityLatch, logAvailabilityDecision, } from "./availability-policy.js";
15
15
  import { probeAvailabilityCandidates } from "./candidate-probe.js";
16
+ import { createAvailabilityProbeFlight } from "../../../availability-probe-flight.js";
17
+ import { createSingleFlight } from "../../../single-flight.js";
18
+ const toolchainProbeFlights = createAvailabilityProbeFlight();
16
19
  /**
17
20
  * Own one toolchain's availability: sweep the platform candidate list, memoize
18
21
  * the path that answered, and park the verdict behind the shared latch so a
@@ -28,12 +31,13 @@ export function createToolchainAvailability(config) {
28
31
  let sweepHostStallMs = 0;
29
32
  /** What the last classified candidate returned, for the decision record. */
30
33
  let sweepEvidence;
31
- let ensureInFlight = null;
34
+ const ensureFlight = createSingleFlight();
32
35
  async function findPath() {
33
36
  if (toolPath)
34
37
  return toolPath;
35
38
  const paths = process.platform === "win32" ? config.windowsPaths : config.unixPaths;
36
- const sweep = await probeAvailabilityCandidates(paths, config.probeArgs, config.budgetMs);
39
+ const shared = toolchainProbeFlights.run(`toolchain:${config.tool}|${config.probeArgs.join("|")}|${config.windowsPaths.join("|")}|${config.unixPaths.join("|")}`, () => probeAvailabilityCandidates(paths, config.probeArgs, config.budgetMs));
40
+ const sweep = await shared.promise;
37
41
  sweepSawTransient = sweep.sawTransient;
38
42
  sweepTransientCause = sweep.transientCause;
39
43
  sweepHostStallMs = sweep.hostStallMs;
@@ -90,17 +94,7 @@ export function createToolchainAvailability(config) {
90
94
  const memo = availabilityLatch.read();
91
95
  if (memo !== null)
92
96
  return memo;
93
- // Now that a verdict can expire, concurrent callers arriving just after a
94
- // cooldown must share ONE sweep rather than each spawning their own.
95
- if (ensureInFlight)
96
- return ensureInFlight;
97
- ensureInFlight = resolveAvailability();
98
- try {
99
- return await ensureInFlight;
100
- }
101
- finally {
102
- ensureInFlight = null;
103
- }
97
+ return ensureFlight.run("availability", resolveAvailability);
104
98
  }
105
99
  return { findPath, isAvailable };
106
100
  }
@@ -16,6 +16,7 @@ import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import yaml from "../../deps/js-yaml.js";
18
18
  import { BoundedLruCache } from "../../bounded-cache.js";
19
+ import { compareOrdinal } from "../../string-utils.js";
19
20
  // --- Constants ---
20
21
  /** Overly broad patterns that match everything (cause false positive explosions) */
21
22
  export const OVERLY_BROAD_PATTERNS = [
@@ -28,18 +29,22 @@ export const OVERLY_BROAD_PATTERNS = [
28
29
  ];
29
30
  /** Maximum complexity score for rules in blockingOnly mode */
30
31
  export const MAX_BLOCKING_RULE_COMPLEXITY = 8;
32
+ /**
33
+ * `getCachedRules` re-stats a rule directory's files at most once per this
34
+ * window (#2262 round 2). `scanner.ts:235` calls `loadYamlRules` per file
35
+ * scanned; a full metadata sweep (readdir + stat every rule file) on every
36
+ * warmed call measured 250x master's per-call cost on a mid-size catalog.
37
+ * Same value and same shape as `file-utils.ts`'s
38
+ * `PROJECT_IGNORE_FRESHNESS_CADENCE_MS` (#2159/#2071): 2s is the accepted
39
+ * staleness bound for a rule tree pi-lens itself did not just write.
40
+ */
41
+ export const RULES_CACHE_FRESHNESS_CADENCE_MS = 2_000;
31
42
  // --- Caches ---
32
43
  const rulesCache = new BoundedLruCache(64);
33
44
  const blockingRulesCache = new BoundedLruCache(64);
34
45
  const contentRulesCache = new BoundedLruCache(64);
35
46
  const contentBlockingRulesCache = new BoundedLruCache(64);
36
47
  // --- Public API ---
37
- export function clearRulesCache() {
38
- rulesCache.clear();
39
- blockingRulesCache.clear();
40
- contentRulesCache.clear();
41
- contentBlockingRulesCache.clear();
42
- }
43
48
  export function loadYamlRules(ruleDir, severityFilter) {
44
49
  return getCachedRules(ruleDir, severityFilter);
45
50
  }
@@ -48,7 +53,14 @@ function findYamlRuleFiles(ruleDir) {
48
53
  try {
49
54
  entries = fs
50
55
  .readdirSync(ruleDir, { withFileTypes: true })
51
- .sort((a, b) => a.name.localeCompare(b.name));
56
+ // Ordinal (code-unit), not locale, comparison: this order feeds the
57
+ // index-aligned file-stamp identity compared in `getCachedRules` and
58
+ // the hash input order in `loadYamlRulesFresh`. `localeCompare` can
59
+ // order the same file set differently across locales/processes,
60
+ // which would desync the two and mint spurious cache misses or
61
+ // (worse) content-hash collisions across differently-ordered runs.
62
+ // Same reasoning as `rule-cache.ts`'s `computeRuleHash` (#2155/#2165).
63
+ .sort((a, b) => compareOrdinal(a.name, b.name));
52
64
  }
53
65
  catch {
54
66
  return [];
@@ -118,20 +130,42 @@ export function getCachedRules(ruleDir, severityFilter) {
118
130
  if (!fs.existsSync(ruleDir)) {
119
131
  return [];
120
132
  }
121
- let currentMtime = 0;
122
- try {
123
- currentMtime = fs.statSync(ruleDir).mtimeMs;
124
- }
125
- catch {
126
- return [];
127
- }
128
133
  const cache = severityFilter === "error" ? blockingRulesCache : rulesCache;
129
134
  const cached = cache.get(ruleDir);
130
- if (cached && cached.mtime === currentMtime) {
135
+ const now = Date.now();
136
+ if (cached && now - cached.checkedAtMs < RULES_CACHE_FRESHNESS_CADENCE_MS) {
137
+ // Inside the cadence window: no stat sweep at all. `getCachedRules` is
138
+ // the bundled-catalog path only (project rules route through
139
+ // `loadYamlRulesFresh` — see `ast-grep-napi.ts`'s route split), and
140
+ // `scanner.ts:235` calls it once per scanned file, so a per-call
141
+ // `readdirSync`+`statSync*N` sweep is the hot-path cost this window
142
+ // exists to remove.
131
143
  return cached.rules;
132
144
  }
133
- const rules = loadYamlRulesUncached(ruleDir, severityFilter);
134
- cache.set(ruleDir, { rules, mtime: currentMtime });
145
+ const files = findYamlRuleFiles(ruleDir);
146
+ const fileStamps = files.flatMap((file) => {
147
+ try {
148
+ const stat = fs.statSync(file);
149
+ return [{ path: file, mtimeMs: stat.mtimeMs, size: stat.size }];
150
+ }
151
+ catch {
152
+ return [];
153
+ }
154
+ });
155
+ const metadataMatches = cached?.files.length === fileStamps.length &&
156
+ cached.files.every((file, index) => file.path === fileStamps[index].path &&
157
+ file.mtimeMs === fileStamps[index].mtimeMs &&
158
+ file.size === fileStamps[index].size);
159
+ if (cached && metadataMatches) {
160
+ // Nothing drifted. Re-arm the window from *now* regardless — otherwise
161
+ // the next call re-stats immediately instead of waiting a full cadence
162
+ // (the #2260/#2071 "checkedAtMs must re-arm on every check, not only on
163
+ // a miss" lesson).
164
+ cached.checkedAtMs = now;
165
+ return cached.rules;
166
+ }
167
+ const rules = loadYamlRuleFiles(files, severityFilter);
168
+ cache.set(ruleDir, { rules, files: fileStamps, checkedAtMs: now });
135
169
  return rules;
136
170
  }
137
171
  export function isOverlyBroadPattern(pattern) {
@@ -82,6 +82,66 @@ export function startEventLoopMonitor(resolutionMs = 20) {
82
82
  });
83
83
  }
84
84
  }
85
+ /**
86
+ * Below this, a block is not classified at all (`below-floor`). #1980 mined
87
+ * the 5 s+ tier; 1 s is a deliberately conservative floor that still covers
88
+ * every block that tier cares about while keeping sub-second jitter — where
89
+ * measurement slop rivals the signal — out of the verdict.
90
+ */
91
+ export const STALL_CLASSIFY_FLOOR_MS = 1000;
92
+ /**
93
+ * Slack allowed to the CPU budget before a block counts as unaccounted.
94
+ * `process.cpuUsage()` and the histogram are sampled at slightly different
95
+ * instants and the histogram quantizes, so a block whose CPU coverage is
96
+ * within this much of exact is called `cpu-accounted`, not a stall.
97
+ */
98
+ export const STALL_CLASSIFY_SLOP_MS = 250;
99
+ /**
100
+ * Split a block into compute-vs-stall from the CPU it could possibly have
101
+ * burned (#1980). Pure, so the discrimination is testable without the native
102
+ * histogram or a real machine stall — the same reason
103
+ * {@link isSuspendSuspectedBlock} is pure.
104
+ *
105
+ * ## Why this is sound in exactly one direction
106
+ *
107
+ * `windowCpuMs` is the CPU the WHOLE process consumed across the WHOLE window
108
+ * — every thread, every phase, not just this block. So it is an UPPER BOUND on
109
+ * the CPU this one block could have burned. A synchronous compute block of D ms
110
+ * needs ≈ D ms of main-thread CPU. Therefore:
111
+ *
112
+ * - `windowCpuMs + slop < maxMs` PROVES the block was not compute. Even if the
113
+ * block had been the only thing running, there was not enough CPU in the
114
+ * whole window to cover it. This is the `non-cpu-stall` verdict and it is a
115
+ * real inference, not a heuristic.
116
+ * - The converse proves nothing. A window can burn 30 s of CPU in other phases
117
+ * and still contain a 5 s I/O park. That is why the label is
118
+ * `cpu-accounted` ("the budget covers it") rather than "compute": it reports
119
+ * the absence of proof, not the presence of compute.
120
+ *
121
+ * The existing 20 s suspend verdict is not re-derived here; this delegates to
122
+ * {@link isSuspendSuspectedBlock} so there is one definition of a suspend, and
123
+ * `system-stall` is checked BEFORE `non-cpu-stall` because a suspend is the
124
+ * strictly more specific case of the same evidence.
125
+ *
126
+ * ## Known ambiguity, carried forward not silenced
127
+ *
128
+ * `isSuspendSuspectedBlock`'s own doc records that a >20 s synchronous syscall
129
+ * stall is CPU-indistinguishable from a suspend. Nothing here fixes that. What
130
+ * this adds is the tier BELOW that floor: a 5-20 s non-CPU block, which used
131
+ * to read as ordinary compute and now reads as `non-cpu-stall` with the
132
+ * `inFlightPhase` attribution beside it.
133
+ */
134
+ export function classifyLoopBlock(maxMs, windowCpuMs, options = {}) {
135
+ const { floorMs = STALL_CLASSIFY_FLOOR_MS, slopMs = STALL_CLASSIFY_SLOP_MS, suspendFloorMs, suspendSlopMs, } = options;
136
+ const cpuCoverageRatio = maxMs > 0 ? Math.round((windowCpuMs / maxMs) * 100) / 100 : undefined;
137
+ if (maxMs < floorMs)
138
+ return { stallClass: "below-floor", cpuCoverageRatio };
139
+ if (isSuspendSuspectedBlock(maxMs, windowCpuMs, suspendFloorMs, suspendSlopMs))
140
+ return { stallClass: "system-stall", cpuCoverageRatio };
141
+ if (windowCpuMs + slopMs < maxMs)
142
+ return { stallClass: "non-cpu-stall", cpuCoverageRatio };
143
+ return { stallClass: "cpu-accounted", cpuCoverageRatio };
144
+ }
85
145
  const safeMs = (ns) => Number.isFinite(ns) ? Math.round((ns / NS_PER_MS) * 10) / 10 : 0;
86
146
  /**
87
147
  * Classify a worst-block sample as genuine CPU work or a suspend/freeze
@@ -123,13 +183,18 @@ export function getEventLoopStats() {
123
183
  const maxMs = safeMs(histogram.max);
124
184
  const windowWallMs = Math.max(0, Date.now() - windowStartWallMs);
125
185
  const windowCpuMs = Math.max(0, cpuTotalMs() - windowStartCpuMs);
186
+ // One classification pass feeds both fields, so `suspectSystemStall` and
187
+ // `stallClass` can never disagree about the same sample (#1980).
188
+ const classification = classifyLoopBlock(maxMs, windowCpuMs);
126
189
  return {
127
190
  maxMs,
128
191
  p99Ms: safeMs(histogram.percentile(99)),
129
192
  meanMs: safeMs(histogram.mean),
130
193
  windowWallMs: Math.round(windowWallMs),
131
194
  windowCpuMs: Math.round(windowCpuMs),
132
- suspectSystemStall: isSuspendSuspectedBlock(maxMs, windowCpuMs),
195
+ suspectSystemStall: classification.stallClass === "system-stall",
196
+ stallClass: classification.stallClass,
197
+ cpuCoverageRatio: classification.cpuCoverageRatio,
133
198
  };
134
199
  }
135
200
  /**