@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
@@ -41,12 +41,21 @@ import { createHash } from "node:crypto";
41
41
  import { normalizeMapKey } from "./path-utils.js";
42
42
  import { freshnessFromMtime } from "./freshness.js";
43
43
  import { safeSpawnAsync } from "./safe-spawn.js";
44
+ import { truncatedByOutputCap } from "./spawn-output-cap.js";
44
45
  /** Stop hashing once this many cumulative bytes were read (per capture). */
45
46
  export const OPAQUE_HASH_BUDGET_BYTES = 8 * 1024 * 1024;
46
47
  /** Hard cap on scanned files — beyond it the verdict is coverage-unknown. */
47
48
  export const OPAQUE_SCAN_MAX_FILES = 2000;
48
49
  /** How far before recorded start an earlier write may still be attributed. */
49
50
  export const OPAQUE_MTIME_TOLERANCE_MS = 150;
51
+ // `--untracked-files=all` lists untracked files individually instead of
52
+ // collapsing them per directory (it does NOT add ignored paths — that needs
53
+ // `--ignored`), so the worst realistic case is a working tree with a large
54
+ // unignored generated or vendored directory, and a rename entry costs two
55
+ // paths. 16 MiB is a blast-radius bound on that, well past any tree this can
56
+ // answer usefully about, and it is what makes the truncation guard below
57
+ // reachable at all (#2100).
58
+ const MAX_GIT_STATUS_OUTPUT_BYTES = 16 * 1024 * 1024;
50
59
  export async function captureFileStats(root, options = {}) {
51
60
  const budgetMs = options.budgetMs ?? 50;
52
61
  try {
@@ -152,6 +161,7 @@ const gitRepoMemo = new Map();
152
161
  export function resetOpaqueMutationState() {
153
162
  getOpaqueBaselineStore().takeAllForTest();
154
163
  gitRepoMemo.clear();
164
+ gitToplevelMemo.clear();
155
165
  }
156
166
  /** Cached git-worktree probe (repos don't stop being git mid-session). */
157
167
  export async function isGitWorktree(root) {
@@ -166,14 +176,141 @@ export async function isGitWorktree(root) {
166
176
  }
167
177
  export function _resetGitWorktreeMemoForTests() {
168
178
  gitRepoMemo.clear();
179
+ gitToplevelMemo.clear();
180
+ }
181
+ const gitToplevelMemo = new Map();
182
+ /**
183
+ * The root of the working tree `root` belongs to, or `undefined` when it is
184
+ * not inside one (#2007).
185
+ *
186
+ * This is WORKTREE IDENTITY, which path containment cannot supply. A linked
187
+ * worktree lives at a path nested under the main checkout — this repo keeps
188
+ * agent worktrees under `.claude/worktrees/` — yet shares no working files
189
+ * with it. `--show-toplevel` answers which tree a directory really belongs
190
+ * to, so two directories are the same checkout when, and only when, their
191
+ * toplevels match.
192
+ *
193
+ * Memoized beside `isGitWorktree`, and cleared by the same
194
+ * `resetOpaqueMutationState` session boundary, so it cannot become a
195
+ * process-lifetime latch (catalog shape 17). `undefined` is a real cached
196
+ * answer, so the memo is probed with `has`, never by truthiness.
197
+ */
198
+ export async function resolveGitToplevel(root) {
199
+ const key = normalizeMapKey(path.resolve(root));
200
+ if (gitToplevelMemo.has(key))
201
+ return gitToplevelMemo.get(key);
202
+ const result = await safeSpawnAsync("git", ["rev-parse", "--show-toplevel"], {
203
+ cwd: root,
204
+ timeout: 3000,
205
+ });
206
+ const toplevel = !result.error && result.status === 0 && result.stdout?.trim()
207
+ ? result.stdout.trim()
208
+ : undefined;
209
+ gitToplevelMemo.set(key, toplevel);
210
+ return toplevel;
211
+ }
212
+ const UNMERGED_PORCELAIN_STATUSES = new Set([
213
+ "DD",
214
+ "AU",
215
+ "UD",
216
+ "UA",
217
+ "DU",
218
+ "AA",
219
+ "UU",
220
+ ]);
221
+ /**
222
+ * Git's Porcelain v1 ordinary-status table, not a Cartesian product. A clean
223
+ * index permits worktree M/T/D, intent-to-add A, and worktree rename/copy R/C.
224
+ * M/T/A/R/C may pair with blank/M/T/D; staged deletion is D<space>, DR or DC.
225
+ * Broaden only with Git docs and a real-status probe.
226
+ *
227
+ * Membership no longer decides pass/fail for a whole command - an absent pair
228
+ * is counted and kept, not fatal (#2060). It still decides which entries the
229
+ * failed-integration filter may treat as "clean index-only incoming", so an
230
+ * unknown pair is never silently classified as someone else's content.
231
+ */
232
+ const LEGAL_ORDINARY_PORCELAIN_STATUSES = new Set([
233
+ " M",
234
+ " T",
235
+ " D",
236
+ " A",
237
+ " R",
238
+ " C",
239
+ "DR",
240
+ "DC",
241
+ "M ",
242
+ "MM",
243
+ "MT",
244
+ "MD",
245
+ "T ",
246
+ "TM",
247
+ "TT",
248
+ "TD",
249
+ "A ",
250
+ "AM",
251
+ "AT",
252
+ "AD",
253
+ "D ",
254
+ "R ",
255
+ "RM",
256
+ "RT",
257
+ "RD",
258
+ "C ",
259
+ "CM",
260
+ "CT",
261
+ "CD",
262
+ ]);
263
+ function isUnmergedStatus(status) {
264
+ return UNMERGED_PORCELAIN_STATUSES.has(status);
265
+ }
266
+ /** An XY pair this module can classify. Unknown pairs are kept, not rejected. */
267
+ function isKnownPorcelainStatus(status) {
268
+ return (status === "??" ||
269
+ status === "!!" ||
270
+ isUnmergedStatus(status) ||
271
+ LEGAL_ORDINARY_PORCELAIN_STATUSES.has(status));
272
+ }
273
+ /**
274
+ * The characters Git's short format can put in an XY pair. This is the real
275
+ * fail-closed line (#2060): output whose status field is outside this alphabet,
276
+ * or carries no status at all, is not Porcelain v1 and nothing in it can be
277
+ * trusted. An in-alphabet pair we happen not to have tabulated is a gap in our
278
+ * table, so voiding the whole command's recovery for it would throw away every
279
+ * other path - the read-guard hole this subsystem exists to close.
280
+ */
281
+ const PORCELAIN_STATUS_CHARS = /^[ MTADRCU?!]{2}$/;
282
+ function isStructurallyValidStatus(status) {
283
+ return PORCELAIN_STATUS_CHARS.test(status) && status.trim() !== "";
169
284
  }
170
285
  /**
171
286
  * Files dirty in the working tree whose mtime falls inside
172
287
  * [startedAt - tolerance, now]. Porcelain -z parsing handles renames
173
- * (the NEW path is reported; the old path token is skipped).
288
+ * (the NEW path is reported; the old path token is skipped before filtering).
174
289
  */
175
- export async function recoverOpaqueChangesViaGit(root, startedAt) {
176
- const result = await safeSpawnAsync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], { cwd: root, timeout: 5000 });
290
+ export async function recoverOpaqueChangesViaGit(root, startedAt, options = {}) {
291
+ const result = await safeSpawnAsync("git", ["status", "--porcelain", "-z", "--untracked-files=all"], {
292
+ cwd: root,
293
+ timeout: 5000,
294
+ maxOutputBytes: MAX_GIT_STATUS_OUTPUT_BYTES,
295
+ });
296
+ // #2060: safe-spawn caps stdout before the child finishes. A capped listing
297
+ // is a PREFIX of the truth, so reading it as complete would report every
298
+ // path the cap removed as unchanged.
299
+ //
300
+ // #2100: FIRST, ahead of the git-failed check. Hitting the cap makes
301
+ // safe-spawn SIGTERM the child, so the result also carries an error and a
302
+ // null status — read in the other order every cap kill reported as
303
+ // "git-failed" and this guard could never speak. `truncatedByOutputCap`
304
+ // leaves a timed-out or aborted read to the git-failed branch below, which
305
+ // is the honest answer for those.
306
+ if (truncatedByOutputCap(result)) {
307
+ return {
308
+ verdict: "unknown",
309
+ paths: [],
310
+ unknownReason: "git-status-parse-failed",
311
+ scannedCount: 0,
312
+ };
313
+ }
177
314
  if (result.error || (result.status !== 0 && result.status !== null)) {
178
315
  return {
179
316
  verdict: "unknown",
@@ -182,12 +319,19 @@ export async function recoverOpaqueChangesViaGit(root, startedAt) {
182
319
  scannedCount: 0,
183
320
  };
184
321
  }
185
- const floorMs = startedAt - OPAQUE_MTIME_TOLERANCE_MS;
186
322
  const raw = result.stdout ?? "";
187
- const tokens = raw.split("\0");
188
- const paths = [];
323
+ if (raw && !raw.endsWith("\0")) {
324
+ return {
325
+ verdict: "unknown",
326
+ paths: [],
327
+ unknownReason: "git-status-parse-failed",
328
+ scannedCount: 0,
329
+ };
330
+ }
331
+ const entries = [];
332
+ let unknownStatusCount = 0;
189
333
  let skipNext = false; // rename's OLD path follows its NEW path
190
- for (const token of tokens) {
334
+ for (const token of raw.split("\0")) {
191
335
  if (!token)
192
336
  continue;
193
337
  if (skipNext) {
@@ -195,26 +339,96 @@ export async function recoverOpaqueChangesViaGit(root, startedAt) {
195
339
  continue;
196
340
  }
197
341
  // Each entry: two status chars, one space, then the path.
198
- if (token.length < 4 || token[2] !== " ")
199
- continue;
342
+ if (token.length < 4 || token[2] !== " ") {
343
+ return {
344
+ verdict: "unknown",
345
+ paths: [],
346
+ unknownReason: "git-status-parse-failed",
347
+ scannedCount: 0,
348
+ };
349
+ }
200
350
  const status = token.slice(0, 2);
201
351
  const relPath = token.slice(3);
352
+ if (!isStructurallyValidStatus(status) || !relPath) {
353
+ return {
354
+ verdict: "unknown",
355
+ paths: [],
356
+ unknownReason: "git-status-parse-failed",
357
+ scannedCount: 0,
358
+ };
359
+ }
360
+ if (!isKnownPorcelainStatus(status))
361
+ unknownStatusCount += 1;
202
362
  if (status.includes("R") || status.includes("C"))
203
363
  skipNext = true;
204
- const abs = path.resolve(root, relPath);
364
+ entries.push({ status, absPath: path.resolve(root, relPath) });
365
+ }
366
+ if (skipNext) {
367
+ return {
368
+ verdict: "unknown",
369
+ paths: [],
370
+ unknownReason: "git-status-parse-failed",
371
+ scannedCount: 0,
372
+ };
373
+ }
374
+ const hasUnmerged = entries.some((entry) => isUnmergedStatus(entry.status));
375
+ const floorMs = startedAt - OPAQUE_MTIME_TOLERANCE_MS;
376
+ // Kernel "stale" = modified AFTER the window floor - exactly the writes
377
+ // this command may have authored, so exactly the entries that would be
378
+ // dispatched absent any other filtering.
379
+ async function isInWindow(absPath) {
205
380
  try {
206
- const stat = await fs.promises.stat(abs);
207
- if (stat.isFile() &&
381
+ const stat = await fs.promises.stat(absPath);
382
+ return (stat.isFile() &&
208
383
  freshnessFromMtime({ mtimeMs: stat.mtimeMs, referenceMs: floorMs })
209
- .verdict === "stale") {
210
- // Kernel "stale" = modified AFTER the window floor - exactly the
211
- // writes this command may have authored.
212
- paths.push(normalizeMapKey(abs));
213
- }
384
+ .verdict === "stale");
214
385
  }
215
386
  catch {
216
387
  // Deleted or vanished: deletions are deliberately unreported.
388
+ return false;
389
+ }
390
+ }
391
+ let excludedIncomingCount = 0;
392
+ let candidates = entries;
393
+ if (options.excludeIndexOnlyWhenUnmerged === true && hasUnmerged) {
394
+ // Clean index-only (`XY` with a blank Y) means "staged, worktree matches
395
+ // the index". After a failed integration that content came from the other
396
+ // side, never from the agent: merge, rebase, cherry-pick and revert all
397
+ // REFUSE to start against a dirty index, so no agent-staged file can be
398
+ // sitting here (#2060 F5, probed on git 2.55). An unknown-but-well-formed
399
+ // pair is NOT classified as incoming - capture wins when in doubt.
400
+ // Scope: the refusal argument covers content staged BEFORE the
401
+ // integration started. Content staged mid-call, after the conflict
402
+ // began, is indistinguishable from incoming and is excluded too; the
403
+ // excluded count below is the visibility for that edge.
404
+ // A blank Y already excludes every unmerged pair (all seven are two
405
+ // letters), so this needs no separate unmerged term.
406
+ const kept = [];
407
+ for (const entry of entries) {
408
+ const cleanIndexOnly = entry.status[1] === " " && isKnownPorcelainStatus(entry.status);
409
+ if (!cleanIndexOnly) {
410
+ kept.push(entry);
411
+ continue;
412
+ }
413
+ // #2081: only count entries the mtime window would otherwise have
414
+ // dispatched. A long-staged clean-index path outside the window was
415
+ // never going to be reported, so dropping it here is not suppression.
416
+ if (await isInWindow(entry.absPath))
417
+ excludedIncomingCount += 1;
217
418
  }
419
+ candidates = kept;
420
+ }
421
+ const paths = [];
422
+ for (const { absPath } of candidates) {
423
+ if (await isInWindow(absPath))
424
+ paths.push(normalizeMapKey(absPath));
218
425
  }
219
- return { verdict: "recovered", paths, scannedCount: paths.length };
426
+ return {
427
+ verdict: "recovered",
428
+ paths,
429
+ scannedCount: paths.length,
430
+ // Omitted when zero so the common outcome keeps one shape.
431
+ ...(excludedIncomingCount > 0 ? { excludedIncomingCount } : {}),
432
+ ...(unknownStatusCount > 0 ? { unknownStatusCount } : {}),
433
+ };
220
434
  }
@@ -18,6 +18,7 @@ import * as os from "node:os";
18
18
  import * as path from "node:path";
19
19
  import { classifyProbeFailure, createAvailabilityLatch, isTransientDecision, logAvailabilityDecision, startHostStallSampler, } from "./dispatch/runners/utils/availability-policy.js";
20
20
  import { safeSpawnAsync } from "./safe-spawn.js";
21
+ import { createAvailabilityProbeFlight } from "./availability-probe-flight.js";
21
22
  /**
22
23
  * Fallback preference when nothing is declared (or the declared manager is
23
24
  * missing). npm first for maximum compatibility; bun last. A project lockfile
@@ -29,6 +30,7 @@ const PREFERENCE = [
29
30
  "yarn",
30
31
  "bun",
31
32
  ];
33
+ const packageManagerProbeFlights = createAvailabilityProbeFlight();
32
34
  function onWindows() {
33
35
  return process.platform === "win32";
34
36
  }
@@ -94,7 +96,6 @@ const PROBE_TIMEOUT_MS = 5_000;
94
96
  * a timeout, abort or host stall expires on a cooldown and is re-probed.
95
97
  */
96
98
  const availabilityLatches = new Map();
97
- const inFlightProbes = new Map();
98
99
  function getLatch(pm) {
99
100
  let latch = availabilityLatches.get(pm);
100
101
  if (!latch) {
@@ -128,6 +129,7 @@ async function probeAvailability(pm) {
128
129
  latched: true,
129
130
  hostStallMs,
130
131
  budgetMs: PROBE_TIMEOUT_MS,
132
+ classifiedBy: "probe",
131
133
  });
132
134
  return true;
133
135
  }
@@ -181,22 +183,8 @@ function isAvailable(pm, onTransient) {
181
183
  return Promise.resolve(reportIfTransient(memo));
182
184
  // A verdict can now expire, so concurrent callers arriving just after a
183
185
  // cooldown must share ONE probe rather than each spawning their own.
184
- const inFlight = inFlightProbes.get(pm);
185
- if (inFlight)
186
- return inFlight.then(reportIfTransient);
187
- // #1653 review F1: a probe started before a session reset can settle AFTER
188
- // a later session's own probe for the same manager is already in flight.
189
- // An unconditional delete-by-key would evict that NEWER entry out from
190
- // under it, so a third caller in the gap finds nothing in-flight and
191
- // spawns a duplicate. Only remove the entry if it is still THIS call's
192
- // promise — the same identity guard `resolveMadge` uses in
193
- // dependency-checker.ts for the equivalent race.
194
- const probe = probeAvailability(pm).finally(() => {
195
- if (inFlightProbes.get(pm) === probe)
196
- inFlightProbes.delete(pm);
197
- });
198
- inFlightProbes.set(pm, probe);
199
- return probe.then(reportIfTransient);
186
+ const shared = packageManagerProbeFlights.run(`package-manager:${pm}`, () => probeAvailability(pm));
187
+ return shared.promise.then(reportIfTransient);
200
188
  }
201
189
  /**
202
190
  * Clear the process-wide availability cache: pnpm/yarn/bun/npm each sit
@@ -213,7 +201,7 @@ function isAvailable(pm, onTransient) {
213
201
  */
214
202
  export function _resetPackageManagerCache() {
215
203
  availabilityLatches.clear();
216
- inFlightProbes.clear();
204
+ packageManagerProbeFlights.clear();
217
205
  }
218
206
  // ============================================================================
219
207
  // RESOLUTION
@@ -48,7 +48,20 @@ export class PathKeyedMap {
48
48
  return this;
49
49
  }
50
50
  delete(path) {
51
- return this.store.delete(this.normalize(path));
51
+ if (this.store.delete(this.normalize(path)))
52
+ return true;
53
+ // A filesystem-aware normalizer can change after the keyed file is
54
+ // deleted (for example, real on-disk casing becomes a lowercased missing
55
+ // tail on Windows). The caller may still hold the exact display key it
56
+ // received from this map. Honor that captured identity without deriving a
57
+ // second path form, so deletion remains possible across existence changes.
58
+ for (const [key, entry] of this.store) {
59
+ if (entry.displayPath !== path)
60
+ continue;
61
+ this.store.delete(key);
62
+ return true;
63
+ }
64
+ return false;
52
65
  }
53
66
  clear() {
54
67
  this.store.clear();
@@ -110,6 +110,38 @@ export function normalizeFilePath(filePath) {
110
110
  }
111
111
  }
112
112
  }
113
+ /**
114
+ * Normalize a logged `filePath`/`cwd` value, but ONLY when it is already a
115
+ * fully-qualified path (#2219, the #2141 class's sibling loggers). Several
116
+ * NDJSON log-entry types reuse a `filePath`-typed field for non-path
117
+ * sentinels alongside genuine paths — `"<quiet-window>"` in
118
+ * `cascade-logger.ts`, `"<tree-sitter>"` in `tree-sitter-logger.ts`, a shell
119
+ * command or an empty placeholder in `latency-logger.ts`. `normalizeFilePath`
120
+ * resolves a relative-looking string against the CURRENT process cwd (see
121
+ * `resolveNonExisting` above), so running it over one of those sentinels
122
+ * would silently corrupt it into `"<repoRoot>/<quiet-window>"` instead of
123
+ * normalizing it. Only a value that is already fully qualified can be the
124
+ * #2141 mixed-raw/normalized-path defect; anything else is passed through
125
+ * unchanged.
126
+ *
127
+ * #2229 review round 1, F1: this classifier checks BOTH host shapes
128
+ * (`isFullyQualifiedWin32(value) || isFullyQualifiedPosix(value)`), not
129
+ * `isFullyQualified(value)` (host-dispatched on `process.platform`). A
130
+ * Windows-shaped absolute path (`C:\Users\...`) is exactly the log payload
131
+ * this fix exists to normalize, but `isFullyQualified` on Linux CI routes to
132
+ * `isFullyQualifiedPosix`, which rejects it (no leading `/`) — so on Linux
133
+ * the guard silently no-ops for the very inputs the #2141 defect produces,
134
+ * passing the raw backslash form straight through. Checking both shapes
135
+ * makes the classifier's answer for a given STRING independent of which OS
136
+ * is asking; `normalizeFilePath` itself still branches on `process.platform`
137
+ * for how the file is resolved, but whether to normalize at all no longer
138
+ * does (AGENTS.md shape 2, the #1024/#1150 OS-divergence class).
139
+ */
140
+ export function normalizeLoggedPath(value) {
141
+ return isFullyQualifiedWin32(value) || isFullyQualifiedPosix(value)
142
+ ? normalizeFilePath(value)
143
+ : value;
144
+ }
113
145
  /**
114
146
  * Resolve a non-existing path by finding the nearest existing parent,
115
147
  * getting its canonical casing, then appending the non-existent parts lowercased.
@@ -25,7 +25,6 @@ import { classifyProbeFailure, createAvailabilityChecker, createAvailabilityLatc
25
25
  import { findDetektConfig } from "./dispatch/runners/detekt.js";
26
26
  import { detectFileKind, getFileKindLabel } from "./file-kinds.js";
27
27
  import { detectFileChangedAfterCommand, getProjectIgnoreMatcher, isExcludedDirName, } from "./file-utils.js";
28
- import { isInSpawnTimeoutCooldown } from "./spawn-timeout-cooldown.js";
29
28
  import { logLatency } from "./latency-logger.js";
30
29
  import { emitLensAnalysisComplete } from "./lens-events.js";
31
30
  import { publishFilesTouched } from "./bus-publish.js";
@@ -338,17 +337,10 @@ async function tryDetektFix(filePath, cwd) {
338
337
  const absPath = path.resolve(cwd, filePath);
339
338
  return detectFileChangedAfterCommand(filePath, cmd, ["--auto-correct", "--input", absPath, "--config", configPath], cwd, [1, 2]);
340
339
  }
341
- // Exported for #1995 cooldown wiring tests: the guard on this lane is
342
- // one of three mutation-proof surfaces.
343
340
  export async function tryMarkdownlintFix(filePath, cwd) {
344
341
  const cmd = await resolveToolCommandWithInstallFallback(cwd, "markdownlint");
345
342
  if (!cmd)
346
343
  return 0;
347
- // #1995: skip the spawn entirely when the command is cooling down after a
348
- // timeout — detectFileChangedAfterCommand also self-guards, but a wedged
349
- // command should not even reach a second budget in the hot loop.
350
- if (isInSpawnTimeoutCooldown(cmd))
351
- return 0;
352
344
  // Shared config-args seam (#1247): the lint runner consumes the same
353
345
  // builder, so the bare --fix here can never fall back to markdownlint's
354
346
  // default all-rules-on config again (the whole-file CHANGELOG/AGENTS
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Process singletons — state that must exist ONCE PER PROCESS, not once per
3
+ * module evaluation (#2146).
4
+ *
5
+ * WHY THIS MODULE EXISTS. pi evaluates the pi-lens module graph more than once
6
+ * in a single process: dogfood pass 3 measured one pid emitting `host_boot`
7
+ * nine times, another four times. Source and compiled entries load through
8
+ * separate module graphs, in-process subagent sessions re-enter the extension
9
+ * loader, and vitest re-evaluates modules on `vi.resetModules()`. Every
10
+ * module-scope `let` therefore exists N times, and any state whose CORRECTNESS
11
+ * depends on being the process's only copy silently breaks:
12
+ *
13
+ * - `session-lifecycle.ts`'s registered primary: evaluation 2 starts with an
14
+ * empty registration, classifies a subagent temp root as `primary`, and runs
15
+ * the full session_start battery the #473/#2129/#2133 guard exists to
16
+ * decline. Measured cost: three identical word-index full rebuilds in one
17
+ * 90s burst, 240.8s of CPU for one index.
18
+ * - `instance-registry.ts`'s mutation tail: N tails are N concurrent
19
+ * read-modify-write cycles over one `instances.json`. Measured: three
20
+ * `instance-registry-corrupt` records in a 9s window, with two project roots
21
+ * and one live instance lost from the file.
22
+ *
23
+ * PRECEDENT, both in-repo and both cited rather than invented:
24
+ * - `clients/runtime-session.ts:1826` hangs `__piLensFirstSessionDone` /
25
+ * `__piLensWarmupScheduled` on `globalThis` for exactly this reason.
26
+ * - `clients/ndjson-logger.ts:125` does the versioned form: a
27
+ * `Symbol.for()` key, a schema string, a version number, and an explicit
28
+ * adopt-or-decline decision for a state written by a different build.
29
+ *
30
+ * This module generalizes the second one so each state family gets the same
31
+ * treatment without re-deriving the protocol per site.
32
+ *
33
+ * WHAT BELONGS HERE. Only state that is WRONG when duplicated: process-wide
34
+ * registrations, serialization points, and once-per-process latches. A memo or
35
+ * cache that re-derives the same answer from a stable source (an env read, a
36
+ * host probe) is merely wasteful when duplicated, not wrong, and stays at
37
+ * module scope — see the class-sweep table in the #2146 PR body for the
38
+ * per-family verdicts.
39
+ *
40
+ * VERSIONING AND THE OLDER-SHAPE FALLBACK. Two builds can meet in one process:
41
+ * a stale `dist/` graph and a fresh source graph, or an extension reload after
42
+ * an upgrade. Each family carries its own `version`. The rule is
43
+ * adopt-if-compatible, reset otherwise:
44
+ *
45
+ * - Same schema and same version -> ADOPT the existing value. This is the
46
+ * common case and the whole point of the module.
47
+ * - Anything else (missing schema, different schema, older version, NEWER
48
+ * version) -> do NOT adopt. A shape this build cannot read is not safely
49
+ * readable in either direction, so guessing is worse than starting clean.
50
+ * The cell is replaced with a fresh value from `create()` and ONE bounded
51
+ * `process-singleton-reset` entry is logged per family, so a nine-evaluation
52
+ * process still reports at most one row per family. Behavior after a reset is
53
+ * exactly today's module-scope behavior for that family, which is the
54
+ * fail-safe direction.
55
+ *
56
+ * The container key itself is versioned (`SINGLETON_HOST_KEY`). A future change
57
+ * to the CONTAINER shape bumps the key, so an old container is simply not
58
+ * found rather than mis-read.
59
+ */
60
+ /**
61
+ * STATIC IMPORTS: none, deliberately. This module is a dependency leaf.
62
+ *
63
+ * It cannot import the degradation ledger. `instance-registry.ts` imports this
64
+ * module, and the ledger reaches `instance-reaper.ts` (through `extension-log`
65
+ * -> `file-utils` -> `git-tracked-ignore` -> `safe-spawn` -> `resource-sampler`),
66
+ * and `instance-reaper` imports the registry back. Any edge from here to the
67
+ * ledger closes a `no-client-cycles` cycle, which CI's dependency-boundaries
68
+ * lane rejects. A dynamic import does not help: the rule excludes
69
+ * `dynamic-import` only on the edge it evaluates, not on the intermediate edges
70
+ * of the cycle it walks.
71
+ *
72
+ * So the direction is inverted, exactly as `ndjson-logger.ts` already does for
73
+ * its own sink-write failures: this module keeps a bounded reset log, and
74
+ * `degradation-ledger.ts` PULLS it at read time through
75
+ * {@link getProcessSingletonResets} and folds it into `getDegradationSummary()`.
76
+ * The record still reaches `pilens_health`, with no import from here.
77
+ */
78
+ /** Bump only when the CONTAINER shape changes, never for a family's shape. */
79
+ const SINGLETON_HOST_KEY = Symbol.for("pi-lens.process-singletons.v1");
80
+ const SINGLETON_SCHEMA = "pi-lens.process-singletons";
81
+ /** Degradation kind emitted when an incompatible cell is discarded. */
82
+ export const PROCESS_SINGLETON_RESET_KIND = "process-singleton-reset";
83
+ const singletonHost = globalThis;
84
+ /**
85
+ * The process's one container. Deliberately created on first read rather than
86
+ * at module scope of a single graph: whichever evaluation runs first wins, and
87
+ * every later evaluation adopts it.
88
+ */
89
+ function container() {
90
+ const existing = singletonHost[SINGLETON_HOST_KEY];
91
+ if (existing instanceof Map)
92
+ return existing;
93
+ const fresh = new Map();
94
+ singletonHost[SINGLETON_HOST_KEY] = fresh;
95
+ return fresh;
96
+ }
97
+ function isAdoptable(cell, version) {
98
+ if (!cell || typeof cell !== "object")
99
+ return false;
100
+ const candidate = cell;
101
+ return (candidate.schema === SINGLETON_SCHEMA &&
102
+ candidate.version === version &&
103
+ candidate.value !== undefined);
104
+ }
105
+ /** One bounded reset log per process, capped so a pathological build pair
106
+ * cannot grow it without limit. */
107
+ export const PROCESS_SINGLETON_RESET_LOG_FAMILY = "process-singleton-reset-log";
108
+ const RESET_LOG_FAMILY = PROCESS_SINGLETON_RESET_LOG_FAMILY;
109
+ const RESET_LOG_VERSION = 1;
110
+ const RESET_LOG_CAP = 16;
111
+ function resetLog() {
112
+ return getProcessSingleton(RESET_LOG_FAMILY, RESET_LOG_VERSION, () => ({
113
+ entries: [],
114
+ }));
115
+ }
116
+ /**
117
+ * Record that an incompatible cell was discarded. Bounded twice: once per
118
+ * family (a nine-evaluation process logs at most one entry per family) and once
119
+ * overall by {@link RESET_LOG_CAP}.
120
+ */
121
+ function recordIncompatibleCell(family, wantedVersion, found) {
122
+ // The reset log is itself a family, so recording ITS reset would recurse.
123
+ // Dropping that one record is correct: the log it would be written to is the
124
+ // thing being discarded.
125
+ if (family === RESET_LOG_FAMILY)
126
+ return;
127
+ const log = resetLog();
128
+ if (log.entries.some((entry) => entry.family === family))
129
+ return;
130
+ if (log.entries.length >= RESET_LOG_CAP)
131
+ return;
132
+ log.entries.push({
133
+ family,
134
+ reason: `incompatible process singleton discarded (found schema=${String(found.schema)} ` +
135
+ `version=${String(found.version)}, this build wants schema=${SINGLETON_SCHEMA} ` +
136
+ `version=${wantedVersion})`,
137
+ });
138
+ }
139
+ /**
140
+ * Read-time view for `degradation-ledger.ts` (see the header). Never performs
141
+ * I/O and never throws, so folding it into a health summary is free.
142
+ */
143
+ export function getProcessSingletonResets() {
144
+ return resetLog().entries.map((entry) => ({ ...entry }));
145
+ }
146
+ /**
147
+ * Return the process-wide value for `family`, creating it on the first
148
+ * evaluation that asks and adopting it on every later one.
149
+ *
150
+ * `version` describes the SHAPE of the value `create()` builds. Bump it when a
151
+ * build changes that shape, so a graph carrying the old shape is discarded
152
+ * instead of mis-read (see the module docstring's fallback rules).
153
+ *
154
+ * `create()` runs at most once per process per family, unless an incompatible
155
+ * cell forced a reset.
156
+ *
157
+ * `onIncompatible` is for values that own external resources. It runs before
158
+ * the cell is replaced, so the owner can tear those resources down safely.
159
+ */
160
+ export function getProcessSingleton(family, version, create, onIncompatible) {
161
+ const cells = container();
162
+ const existing = cells.get(family);
163
+ if (isAdoptable(existing, version))
164
+ return existing.value;
165
+ if (existing !== undefined) {
166
+ // An incompatible cell from another build. One bounded row per family:
167
+ // nine evaluations must not write nine records (AGENTS.md's bounded-record
168
+ // rule; `recordDegradationOnce` keys on kind + subject).
169
+ recordIncompatibleCell(family, version, existing);
170
+ onIncompatible?.(existing.value);
171
+ }
172
+ const value = create();
173
+ cells.set(family, { schema: SINGLETON_SCHEMA, version, value });
174
+ return value;
175
+ }
176
+ /**
177
+ * Test-only: drop the process-wide container so the next
178
+ * {@link getProcessSingleton} call rebuilds every family from `create()`.
179
+ *
180
+ * This clears the GLOBAL state, not a module-local copy — a reset that only
181
+ * cleared module scope would be the very defect this module fixes, and would
182
+ * make every suite that relies on isolation pass vacuously (catalog shape 7).
183
+ */
184
+ export function _resetProcessSingletonsForTests() {
185
+ singletonHost[SINGLETON_HOST_KEY] = new Map();
186
+ }
187
+ /**
188
+ * Test-only: install a raw cell so a suite can exercise the older/newer-shape
189
+ * fallback without needing two real builds in one process.
190
+ */
191
+ export function _seedProcessSingletonCellForTests(family, cell) {
192
+ container().set(family, cell);
193
+ }
194
+ // --- Module-evaluation ordinal (#2146 observability) ---
195
+ const EVALUATION_ORDINAL_FAMILY = "module-evaluation-ordinal";
196
+ const EVALUATION_ORDINAL_VERSION = 1;
197
+ /**
198
+ * How many times this process has evaluated the pi-lens module graph, 1-based.
199
+ *
200
+ * Called once, at module scope of `clients/startup-timing.ts`, so the number it
201
+ * returns is the evaluation count of the graph that contains the extension
202
+ * entry. It is carried on `host_boot.metadata.evaluationOrdinal` so a multi-eval
203
+ * process is greppable from `latency.log` instead of having to be inferred by
204
+ * counting `host_boot` lines per pid — the observability gap #2146 names.
205
+ */
206
+ export function nextModuleEvaluationOrdinal() {
207
+ const state = getProcessSingleton(EVALUATION_ORDINAL_FAMILY, EVALUATION_ORDINAL_VERSION, () => ({ evaluations: 0 }));
208
+ state.evaluations += 1;
209
+ return state.evaluations;
210
+ }