@herbertgao/pi-extensions 2026.8.4 → 2026.8.6

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 (360) hide show
  1. package/README.md +5 -5
  2. package/node_modules/@herbertgao/pi-cc-extensions/README.en.md +11 -3
  3. package/node_modules/@herbertgao/pi-cc-extensions/README.md +11 -3
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/config.ts +24 -0
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/config/panel.ts +91 -1
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +7 -3
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/context.ts +74 -5
  8. package/node_modules/@herbertgao/pi-cc-extensions/extensions/index.ts +7 -6
  9. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +21 -12
  10. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +4 -5
  11. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/markdown-enhance.ts +53 -14
  12. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +22 -12
  13. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +2 -4
  14. package/node_modules/@herbertgao/pi-cc-extensions/package.json +3 -3
  15. package/node_modules/@herbertgao/pi-subagents/CHANGELOG.md +18 -0
  16. package/node_modules/@herbertgao/pi-subagents/package.json +8 -4
  17. package/node_modules/@herbertgao/pi-subagents/src/agent-color.ts +72 -67
  18. package/node_modules/@herbertgao/pi-subagents/src/agent-file-toggle.ts +255 -0
  19. package/node_modules/@herbertgao/pi-subagents/src/agent-manager.ts +191 -3
  20. package/node_modules/@herbertgao/pi-subagents/src/agent-runner.ts +20 -4
  21. package/node_modules/@herbertgao/pi-subagents/src/index.ts +289 -113
  22. package/node_modules/@herbertgao/pi-subagents/src/output-file.ts +24 -1
  23. package/node_modules/@herbertgao/pi-subagents/src/ui/agent-widget.ts +24 -2
  24. package/node_modules/@herbertgao/pi-subagents/src/ui/fleet-list.ts +15 -6
  25. package/node_modules/@herbertgao/pi-subagents/src/ui/schedule-menu.ts +9 -8
  26. package/node_modules/@herbertgao/pi-subagents/src/ui/select-item.ts +48 -0
  27. package/node_modules/@herbertgao/pi-subagents/src/worktree.ts +9 -5
  28. package/node_modules/@juicesharp/rpiv-ask-user-question/README.md +2 -0
  29. package/node_modules/@juicesharp/rpiv-ask-user-question/ask-user-question.ts +20 -0
  30. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/hosts.md +6 -0
  31. package/node_modules/@juicesharp/rpiv-ask-user-question/docs/keyboard.md +7 -0
  32. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  33. package/node_modules/@juicesharp/rpiv-ask-user-question/state/build-questionnaire.ts +7 -0
  34. package/node_modules/@juicesharp/rpiv-ask-user-question/state/key-router.ts +16 -5
  35. package/node_modules/@juicesharp/rpiv-ask-user-question/state/questionnaire-session.ts +4 -2
  36. package/node_modules/@narumitw/pi-btw/README.md +32 -24
  37. package/node_modules/@narumitw/pi-btw/package.json +6 -6
  38. package/node_modules/@narumitw/pi-btw/src/btw.ts +4 -2
  39. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -13
  40. package/node_modules/@narumitw/pi-btw/src/settings.ts +22 -2
  41. package/node_modules/pi-lens/CHANGELOG.md +2535 -954
  42. package/node_modules/pi-lens/README.md +13 -0
  43. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +73 -18
  44. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +314 -0
  45. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +21 -4
  46. package/node_modules/pi-lens/dist/clients/agent-nudge.js +14 -7
  47. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +68 -16
  48. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -1
  49. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +37 -10
  50. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +63 -0
  51. package/node_modules/pi-lens/dist/clients/atomic-write.js +114 -14
  52. package/node_modules/pi-lens/dist/clients/bash-file-access.js +193 -23
  53. package/node_modules/pi-lens/dist/clients/biome-client.js +136 -24
  54. package/node_modules/pi-lens/dist/clients/bootstrap.js +11 -2
  55. package/node_modules/pi-lens/dist/clients/bounded-cache.js +34 -0
  56. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +192 -0
  57. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +62 -6
  58. package/node_modules/pi-lens/dist/clients/bus-publish.js +19 -4
  59. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +47 -5
  60. package/node_modules/pi-lens/dist/clients/cache-manager.js +97 -9
  61. package/node_modules/pi-lens/dist/clients/cache-observability.js +301 -14
  62. package/node_modules/pi-lens/dist/clients/call-graph.js +449 -117
  63. package/node_modules/pi-lens/dist/clients/cascade-format.js +57 -2
  64. package/node_modules/pi-lens/dist/clients/child-unref.js +85 -0
  65. package/node_modules/pi-lens/dist/clients/codebase-model.js +52 -23
  66. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +40 -0
  67. package/node_modules/pi-lens/dist/clients/complexity-client.js +2 -1
  68. package/node_modules/pi-lens/dist/clients/console-guard-install.js +26 -0
  69. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +42 -0
  70. package/node_modules/pi-lens/dist/clients/dead-code-client.js +137 -31
  71. package/node_modules/pi-lens/dist/clients/debug-handles.js +206 -0
  72. package/node_modules/pi-lens/dist/clients/debug-heap.js +167 -0
  73. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +153 -0
  74. package/node_modules/pi-lens/dist/clients/dependency-checker.js +361 -77
  75. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +65 -13
  76. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +18 -4
  77. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +176 -29
  78. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +23 -7
  79. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +8 -18
  80. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +8 -5
  81. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +35 -0
  82. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +17 -8
  83. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +1172 -683
  84. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +14 -0
  85. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +11 -1
  86. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +50 -0
  87. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +135 -0
  88. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +23 -5
  89. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +5 -9
  90. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +3 -2
  91. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +2 -1
  92. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +154 -0
  93. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +3 -2
  94. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +5 -1
  95. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +47 -6
  96. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -9
  97. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +9 -5
  98. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +4 -8
  99. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +2 -3
  100. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +2 -3
  101. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +2 -1
  102. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +2 -1
  103. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +163 -0
  104. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +37 -5
  105. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +3 -1
  106. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +13 -7
  107. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +226 -0
  108. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +69 -0
  109. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +3 -0
  110. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +473 -42
  111. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +22 -0
  112. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +97 -0
  113. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +5 -4
  114. package/node_modules/pi-lens/dist/clients/disposition-publish.js +18 -4
  115. package/node_modules/pi-lens/dist/clients/durable-store.js +97 -0
  116. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +17 -0
  117. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +94 -3
  118. package/node_modules/pi-lens/dist/clients/extension-log.js +432 -0
  119. package/node_modules/pi-lens/dist/clients/extension-mode.js +73 -0
  120. package/node_modules/pi-lens/dist/clients/file-kinds.js +57 -0
  121. package/node_modules/pi-lens/dist/clients/file-role.js +18 -3
  122. package/node_modules/pi-lens/dist/clients/file-utils.js +56 -34
  123. package/node_modules/pi-lens/dist/clients/fix-worklog.js +5 -1
  124. package/node_modules/pi-lens/dist/clients/format-events-publish.js +51 -7
  125. package/node_modules/pi-lens/dist/clients/format-service.js +24 -8
  126. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +8 -0
  127. package/node_modules/pi-lens/dist/clients/formatters.js +358 -61
  128. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +148 -15
  129. package/node_modules/pi-lens/dist/clients/git-guard.js +678 -11
  130. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +4 -0
  131. package/node_modules/pi-lens/dist/clients/go-client.js +23 -40
  132. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +124 -7
  133. package/node_modules/pi-lens/dist/clients/grammar-source.js +12 -4
  134. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +21 -3
  135. package/node_modules/pi-lens/dist/clients/host-ports.js +31 -0
  136. package/node_modules/pi-lens/dist/clients/installer/index.js +395 -100
  137. package/node_modules/pi-lens/dist/clients/instance-reaper.js +142 -172
  138. package/node_modules/pi-lens/dist/clients/jscpd-client.js +27 -55
  139. package/node_modules/pi-lens/dist/clients/knip-client.js +58 -42
  140. package/node_modules/pi-lens/dist/clients/language-policy.js +14 -2
  141. package/node_modules/pi-lens/dist/clients/language-profile.js +13 -7
  142. package/node_modules/pi-lens/dist/clients/latency-logger.js +72 -1
  143. package/node_modules/pi-lens/dist/clients/lens-config.js +12 -1
  144. package/node_modules/pi-lens/dist/clients/lens-engine.js +86 -17
  145. package/node_modules/pi-lens/dist/clients/lens-events.js +63 -9
  146. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +16 -0
  147. package/node_modules/pi-lens/dist/clients/lens-map.js +94 -19
  148. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +73 -0
  149. package/node_modules/pi-lens/dist/clients/log-cleanup.js +2 -1
  150. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +30 -4
  151. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +59 -14
  152. package/node_modules/pi-lens/dist/clients/lsp/client.js +662 -56
  153. package/node_modules/pi-lens/dist/clients/lsp/config.js +13 -2
  154. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +115 -0
  155. package/node_modules/pi-lens/dist/clients/lsp/edits.js +1012 -121
  156. package/node_modules/pi-lens/dist/clients/lsp/index.js +2025 -800
  157. package/node_modules/pi-lens/dist/clients/lsp/launch.js +32 -26
  158. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +1 -1
  159. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +83 -0
  160. package/node_modules/pi-lens/dist/clients/lsp/server.js +437 -86
  161. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +96 -0
  162. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +21 -5
  163. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +18 -3
  164. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +28 -4
  165. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +8 -0
  166. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +248 -0
  167. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +127 -32
  168. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +157 -24
  169. package/node_modules/pi-lens/dist/clients/mcp/session.js +235 -14
  170. package/node_modules/pi-lens/dist/clients/memory-sampler.js +115 -0
  171. package/node_modules/pi-lens/dist/clients/metrics-client.js +2 -1
  172. package/node_modules/pi-lens/dist/clients/metrics-history.js +2 -1
  173. package/node_modules/pi-lens/dist/clients/model-provider.js +53 -0
  174. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +5 -0
  175. package/node_modules/pi-lens/dist/clients/module-report.js +168 -3
  176. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +319 -118
  177. package/node_modules/pi-lens/dist/clients/opengrep-client.js +1 -1
  178. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +120 -5
  179. package/node_modules/pi-lens/dist/clients/path-utils.js +110 -9
  180. package/node_modules/pi-lens/dist/clients/pipeline.js +76 -15
  181. package/node_modules/pi-lens/dist/clients/print-mode.js +21 -0
  182. package/node_modules/pi-lens/dist/clients/project-changes.js +40 -0
  183. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +3 -2
  184. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +1 -1
  185. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +8 -0
  186. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +23 -2
  187. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +21 -1
  188. package/node_modules/pi-lens/dist/clients/project-lens-config.js +86 -9
  189. package/node_modules/pi-lens/dist/clients/project-report.js +27 -13
  190. package/node_modules/pi-lens/dist/clients/project-snapshot.js +175 -20
  191. package/node_modules/pi-lens/dist/clients/project-trust.js +181 -0
  192. package/node_modules/pi-lens/dist/clients/quiet-window.js +18 -4
  193. package/node_modules/pi-lens/dist/clients/read-bridge.js +147 -0
  194. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +159 -1
  195. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +150 -29
  196. package/node_modules/pi-lens/dist/clients/read-guard.js +185 -4
  197. package/node_modules/pi-lens/dist/clients/resource-sampler.js +70 -84
  198. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +1122 -221
  199. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +2 -1
  200. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +21 -10
  201. package/node_modules/pi-lens/dist/clients/review-graph/query.js +24 -0
  202. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +46 -0
  203. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +57 -6
  204. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +51 -3
  205. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +30 -1
  206. package/node_modules/pi-lens/dist/clients/ruff-client.js +27 -44
  207. package/node_modules/pi-lens/dist/clients/run-duration.js +55 -0
  208. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +257 -77
  209. package/node_modules/pi-lens/dist/clients/runtime-context.js +129 -12
  210. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +242 -26
  211. package/node_modules/pi-lens/dist/clients/runtime-session.js +428 -117
  212. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +71 -48
  213. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +342 -33
  214. package/node_modules/pi-lens/dist/clients/runtime-turn.js +619 -93
  215. package/node_modules/pi-lens/dist/clients/rust-client.js +23 -38
  216. package/node_modules/pi-lens/dist/clients/safe-spawn.js +777 -62
  217. package/node_modules/pi-lens/dist/clients/security-scan-client.js +90 -6
  218. package/node_modules/pi-lens/dist/clients/session-state-store.js +3 -2
  219. package/node_modules/pi-lens/dist/clients/sg-runner.js +424 -196
  220. package/node_modules/pi-lens/dist/clients/slow-fs.js +2 -1
  221. package/node_modules/pi-lens/dist/clients/smells-rollup.js +231 -0
  222. package/node_modules/pi-lens/dist/clients/source-filter.js +184 -20
  223. package/node_modules/pi-lens/dist/clients/source-walker.js +66 -10
  224. package/node_modules/pi-lens/dist/clients/startup-scan.js +15 -16
  225. package/node_modules/pi-lens/dist/clients/startup-timing.js +7 -1
  226. package/node_modules/pi-lens/dist/clients/test-runner-client.js +469 -32
  227. package/node_modules/pi-lens/dist/clients/tool-event.js +35 -0
  228. package/node_modules/pi-lens/dist/clients/tool-policy.js +390 -39
  229. package/node_modules/pi-lens/dist/clients/tool-render.js +155 -0
  230. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +76 -0
  231. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +6 -1
  232. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +419 -50
  233. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +21 -0
  234. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +7 -1
  235. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +7 -2
  236. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +44 -7
  237. package/node_modules/pi-lens/dist/clients/user-notify.js +51 -0
  238. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +57 -0
  239. package/node_modules/pi-lens/dist/clients/warm-attach.js +50 -13
  240. package/node_modules/pi-lens/dist/clients/widget-state.js +300 -43
  241. package/node_modules/pi-lens/dist/clients/word-index.js +680 -81
  242. package/node_modules/pi-lens/dist/clients/workspace-topology.js +89 -6
  243. package/node_modules/pi-lens/dist/index.js +51785 -36118
  244. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +182 -29
  245. package/node_modules/pi-lens/dist/mcp/server.js +219 -46
  246. package/node_modules/pi-lens/dist/tools/activate-tools.js +17 -5
  247. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +9 -4
  248. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +93 -27
  249. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +5 -2
  250. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +203 -38
  251. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +210 -42
  252. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +46 -3
  253. package/node_modules/pi-lens/dist/tools/module-report.js +9 -0
  254. package/node_modules/pi-lens/dist/tools/symbol-search.js +1 -1
  255. package/node_modules/pi-lens/docs/agent-guide.md +40 -15
  256. package/node_modules/pi-lens/docs/agent-tools.md +21 -10
  257. package/node_modules/pi-lens/docs/analysisall.md +192 -0
  258. package/node_modules/pi-lens/docs/api-ports-inventory.md +165 -0
  259. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +14 -3
  260. package/node_modules/pi-lens/docs/durable-store-audit-1202.md +32 -0
  261. package/node_modules/pi-lens/docs/environment-variables.md +44 -0
  262. package/node_modules/pi-lens/docs/fable.md +27 -0
  263. package/node_modules/pi-lens/docs/features.md +45 -3
  264. package/node_modules/pi-lens/docs/globalconfig.md +56 -1
  265. package/node_modules/pi-lens/docs/language-coverage.md +4 -3
  266. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +2 -2
  267. package/node_modules/pi-lens/docs/mcp.md +37 -4
  268. package/node_modules/pi-lens/docs/servercapabilities.md +3 -3
  269. package/node_modules/pi-lens/docs/settings.md +18 -1
  270. package/node_modules/pi-lens/docs/tools.md +4 -1
  271. package/node_modules/pi-lens/docs/usage.md +41 -5
  272. package/node_modules/pi-lens/docs/word-index.md +35 -0
  273. package/node_modules/pi-lens/package.json +10 -10
  274. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-except-test.yml +49 -0
  275. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-bare-host-path-in-win32-branch-test.yml +87 -0
  276. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-chained-type-assertions-test.yml +8 -0
  277. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-js-test.yml +9 -0
  278. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-conditional-empty-object-spread-test.yml +9 -0
  279. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-nested-links-test.yml +11 -1
  280. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-json-store-write-test.yml +17 -0
  281. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-js-test.yml +7 -0
  282. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-apply-test.yml +7 -0
  283. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-js-test.yml +8 -0
  284. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-reflect-get-test.yml +8 -0
  285. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-unknown-laundering-test.yml +11 -0
  286. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-win32-isabsolute-for-qualification-test.yml +16 -0
  287. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-except.yml +3 -15
  288. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-bare-host-path-in-win32-branch.yml +25 -0
  289. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-chained-type-assertions.yml +21 -0
  290. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread-js.yml +21 -0
  291. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-conditional-empty-object-spread.yml +29 -0
  292. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-nested-links.yml +7 -0
  293. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-json-store-write.yml +30 -0
  294. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply-js.yml +9 -0
  295. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-apply.yml +9 -0
  296. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get-js.yml +13 -0
  297. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-reflect-get.yml +16 -0
  298. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-unknown-laundering.yml +27 -0
  299. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-win32-isabsolute-for-qualification.yml +23 -0
  300. package/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml +4 -6
  301. package/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml +2 -2
  302. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +134 -2
  303. package/node_modules/pi-lens/skills/pi-lens-ast-grep/SKILL.md +10 -11
  304. package/node_modules/pi-lens/skills/pi-lens-lsp-navigation/SKILL.md +22 -22
  305. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/SKILL.md +8 -114
  306. package/node_modules/pi-lens/skills/pi-lens-write-ast-grep-rule/reference.md +129 -0
  307. package/node_modules/pi-lens/skills/pi-lens-write-tree-sitter-rule/SKILL.md +3 -1
  308. package/node_modules/pi-mcp-adapter/CHANGELOG.md +38 -0
  309. package/node_modules/pi-mcp-adapter/README.md +38 -4
  310. package/node_modules/pi-mcp-adapter/agent-dir.ts +12 -4
  311. package/node_modules/pi-mcp-adapter/agent-plugin-loader.ts +1 -1
  312. package/node_modules/pi-mcp-adapter/cli.js +25 -4
  313. package/node_modules/pi-mcp-adapter/commands.ts +15 -7
  314. package/node_modules/pi-mcp-adapter/config.ts +4 -4
  315. package/node_modules/pi-mcp-adapter/direct-tools.ts +58 -8
  316. package/node_modules/pi-mcp-adapter/host-html-template.ts +0 -8
  317. package/node_modules/pi-mcp-adapter/index.ts +8 -3
  318. package/node_modules/pi-mcp-adapter/init.ts +29 -5
  319. package/node_modules/pi-mcp-adapter/lifecycle.ts +0 -4
  320. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +1 -2
  321. package/node_modules/pi-mcp-adapter/mcp-auth.ts +60 -2
  322. package/node_modules/pi-mcp-adapter/mcp-panel.ts +45 -9
  323. package/node_modules/pi-mcp-adapter/mcp-script-worker.mjs +22 -20
  324. package/node_modules/pi-mcp-adapter/mcp-setup-panel.ts +2 -1
  325. package/node_modules/pi-mcp-adapter/metadata-cache.ts +37 -4
  326. package/node_modules/pi-mcp-adapter/npx-resolver.ts +81 -26
  327. package/node_modules/pi-mcp-adapter/package.json +4 -3
  328. package/node_modules/pi-mcp-adapter/proxy-modes.ts +108 -26
  329. package/node_modules/pi-mcp-adapter/request-headers-command.ts +336 -0
  330. package/node_modules/pi-mcp-adapter/search-ranking.ts +86 -7
  331. package/node_modules/pi-mcp-adapter/server-manager.ts +41 -7
  332. package/node_modules/pi-mcp-adapter/session-recovery.ts +8 -2
  333. package/node_modules/pi-mcp-adapter/tool-approval.ts +48 -7
  334. package/node_modules/pi-mcp-adapter/tool-metadata.ts +62 -4
  335. package/node_modules/pi-mcp-adapter/tool-result-renderer.ts +138 -12
  336. package/node_modules/pi-mcp-adapter/types.ts +176 -27
  337. package/node_modules/pi-mcp-adapter/ui-server.ts +20 -1
  338. package/node_modules/pi-web-access/CHANGELOG.md +14 -0
  339. package/node_modules/pi-web-access/README.md +18 -12
  340. package/node_modules/pi-web-access/auth-fetch.ts +148 -0
  341. package/node_modules/pi-web-access/chrome-cookies.ts +110 -23
  342. package/node_modules/pi-web-access/curator-page.ts +5 -3
  343. package/node_modules/pi-web-access/curator-server.ts +2 -1
  344. package/node_modules/pi-web-access/extract.ts +106 -34
  345. package/node_modules/pi-web-access/fetch-params.ts +17 -3
  346. package/node_modules/pi-web-access/firecrawl.ts +172 -12
  347. package/node_modules/pi-web-access/gemini-search.ts +18 -4
  348. package/node_modules/pi-web-access/index.ts +120 -48
  349. package/node_modules/pi-web-access/package.json +2 -2
  350. package/node_modules/pi-web-access/summary-review.ts +11 -5
  351. package/node_modules/pi-web-access/youtube-extract.ts +2 -2
  352. package/package.json +12 -13
  353. package/node_modules/pi-lens/dist/clients/ast-grep-parser.js +0 -86
  354. package/node_modules/pi-lens/dist/clients/lsp/interactive-install.js +0 -367
  355. package/node_modules/pi-lens/dist/clients/lsp/lsp-index.js +0 -10
  356. package/node_modules/pi-lens/dist/clients/lsp/server-strategies.js +0 -5
  357. package/node_modules/pi-lens/dist/clients/project-metadata.js +0 -690
  358. package/node_modules/pi-lens/dist/clients/source-groups.js +0 -96
  359. package/node_modules/pi-lens/dist/clients/startup-marker.js +0 -6
  360. package/node_modules/pi-lens/dist/clients/types.js +0 -11
@@ -17,7 +17,153 @@ import { spawn, spawnSync, } from "node:child_process";
17
17
  import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { logLatency } from "./latency-logger.js";
20
+ import { recordDegradation } from "./degradation-ledger.js";
21
+ import { logExtension } from "./extension-log.js";
22
+ import { isFullyQualifiedWin32 } from "./path-utils.js";
20
23
  import { startSpawnUsageSampler } from "./resource-sampler.js";
24
+ /** Intent-level spawn failure. `cause` retains the original OS Error/errno. */
25
+ export class SpawnFailureError extends Error {
26
+ kind;
27
+ cause;
28
+ name = "SpawnFailureError";
29
+ constructor(kind, message, cause) {
30
+ super(message, { cause });
31
+ this.kind = kind;
32
+ this.cause = cause;
33
+ }
34
+ }
35
+ export function hasSpawnFailureKind(error, kind) {
36
+ return (error instanceof Error &&
37
+ "kind" in error &&
38
+ error.kind === kind);
39
+ }
40
+ function toError(error) {
41
+ return error instanceof Error ? error : new Error(String(error));
42
+ }
43
+ function errorCode(error) {
44
+ return error.code;
45
+ }
46
+ async function cwdIsUnresolvable(cwd) {
47
+ if (cwd === undefined)
48
+ return false;
49
+ try {
50
+ return !(await fs.promises.stat(cwd)).isDirectory();
51
+ }
52
+ catch {
53
+ return true;
54
+ }
55
+ }
56
+ function cwdIsUnresolvableSync(cwd) {
57
+ if (cwd === undefined)
58
+ return false;
59
+ try {
60
+ return !fs.statSync(cwd).isDirectory();
61
+ }
62
+ catch {
63
+ return true;
64
+ }
65
+ }
66
+ /**
67
+ * Best-effort presence probe used ONLY to disambiguate ENOENT when the cwd is
68
+ * ALSO unresolvable (#1340 review): a genuinely missing tool must classify as
69
+ * tool-not-found even under a broken cwd, or auto-install can never repair it.
70
+ * Absolute commands are probed directly (with PATHEXT variants on Windows);
71
+ * bare names scan PATH. A relative-with-separator command under a broken cwd
72
+ * is genuinely ambiguous -- we err toward cwd-unresolvable there, because
73
+ * repairing the cwd is actionable while a reinstall loop (#1199) is not.
74
+ */
75
+ function commandProbablyPresent(command) {
76
+ const exts = process.platform === "win32"
77
+ ? ["", ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean)]
78
+ : [""];
79
+ const existsWithExt = (base) => {
80
+ for (const ext of exts) {
81
+ try {
82
+ if (fs.existsSync(base + ext))
83
+ return true;
84
+ }
85
+ catch {
86
+ // unreadable candidate -- keep probing
87
+ }
88
+ }
89
+ return false;
90
+ };
91
+ if (path.isAbsolute(command))
92
+ return existsWithExt(command);
93
+ if (command.includes("/") || command.includes("\\"))
94
+ return "ambiguous";
95
+ for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
96
+ if (dir && existsWithExt(path.join(dir, command)))
97
+ return true;
98
+ }
99
+ return false;
100
+ }
101
+ /**
102
+ * Shared errno->bucket mapping. `cwdUnresolvable` is the only async-vs-sync
103
+ * difference between the two public classifiers, so it arrives as a resolved
104
+ * flag and everything else lives once (Sonar duplication finding, #1340).
105
+ */
106
+ function classifyWithCwdFlag(cause, options, cwdUnresolvable) {
107
+ const code = errorCode(cause);
108
+ let failure;
109
+ if (code === "ENOENT" &&
110
+ cwdUnresolvable &&
111
+ commandProbablyPresent(options.command) !== false) {
112
+ failure = new SpawnFailureError("cwd-unresolvable", `Cannot spawn ${options.command}: working directory is unresolvable (${options.cwd})`, cause);
113
+ }
114
+ else if (code === "ENOENT") {
115
+ failure = new SpawnFailureError("tool-not-found", `Cannot spawn ${options.command}: tool not found (${cause.message})`, cause);
116
+ }
117
+ else if (code === "EACCES" || code === "EPERM") {
118
+ failure = new SpawnFailureError("permission-denied", `Cannot spawn ${options.command}: permission denied`, cause);
119
+ }
120
+ else {
121
+ failure = new SpawnFailureError("spawn-failed", `Cannot spawn ${options.command}: ${cause.message}`, cause);
122
+ }
123
+ recordSpawnClassification(failure, options);
124
+ return failure;
125
+ }
126
+ const loggedSpawnClassifications = new Set();
127
+ const SPAWN_CLASSIFICATION_LOG_CAP = 200;
128
+ function recordSpawnClassification(failure, options) {
129
+ const pair = `${failure.kind}\0${options.command}`;
130
+ if (!loggedSpawnClassifications.has(pair)) {
131
+ if (loggedSpawnClassifications.size >= SPAWN_CLASSIFICATION_LOG_CAP) {
132
+ loggedSpawnClassifications.clear();
133
+ }
134
+ loggedSpawnClassifications.add(pair);
135
+ logExtension({
136
+ subsystem: "safe-spawn",
137
+ level: "debug",
138
+ message: "spawn failure classified",
139
+ metadata: {
140
+ kind: failure.kind,
141
+ command: options.command,
142
+ cwd: options.cwd,
143
+ },
144
+ });
145
+ }
146
+ if (failure.kind !== "tool-not-found") {
147
+ recordDegradation({
148
+ kind: "spawn-failure",
149
+ subject: options.command,
150
+ reason: `${failure.kind}${options.cwd ? ` in ${options.cwd}` : ""}`,
151
+ });
152
+ }
153
+ }
154
+ /** Classify a raw Node spawn error without discarding its errno-bearing Error. */
155
+ export async function classifySpawnFailure(error, options) {
156
+ const cause = toError(error);
157
+ const needsCwdProbe = errorCode(cause) === "ENOENT";
158
+ const cwdUnresolvable = needsCwdProbe && (await cwdIsUnresolvable(options.cwd));
159
+ return classifyWithCwdFlag(cause, options, cwdUnresolvable);
160
+ }
161
+ function classifySpawnFailureSync(error, options) {
162
+ const cause = toError(error);
163
+ const needsCwdProbe = errorCode(cause) === "ENOENT";
164
+ const cwdUnresolvable = needsCwdProbe && cwdIsUnresolvableSync(options.cwd);
165
+ return classifyWithCwdFlag(cause, options, cwdUnresolvable);
166
+ }
21
167
  // Vitest reloads modules inside a reused worker. Keep this registry on the
22
168
  // process so those module instances share one signal/exit listener set.
23
169
  const lifetimeStateKey = Symbol.for("pi-lens.safe-spawn.lifetime-state");
@@ -128,19 +274,271 @@ export function buildWindowsShellCommand(command, args) {
128
274
  return `chcp 65001 >nul 2>&1 && ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
129
275
  }
130
276
  /**
131
- * Cached PATH + PATHEXT resolution results, keyed by
132
- * `${command}\0${PATH}\0${cwd}` so a changed PATH or cwd never hits a stale
133
- * entry. Session-lived (matches this repo's other resolution caches, e.g.
277
+ * The extensions this resolver treats as executable suffixes on their own,
278
+ * independent of PATHEXT: `.exe`/`.com` and `.cmd`/`.bat`. An explicit suffix
279
+ * outside this set is not treated as a "known executable extension" unless
280
+ * the caller's own PATHEXT says so (#1201).
281
+ *
282
+ * This is a RESOLUTION set, not a dispatch whitelist — do not read it as "the
283
+ * only extensions this module can spawn". Dispatch in
284
+ * `safeSpawnAsync`/`safeSpawn` below routes `.cmd`/`.bat` through the cmd.exe
285
+ * wrapper and EVERYTHING ELSE to direct spawn, so a resolved path with any
286
+ * other suffix (reachable only when the caller's own PATHEXT lists it) is
287
+ * direct-spawned. That is deliberate and not a widening: CreateProcess does no
288
+ * ShellExecute file-association lookup, the default Windows PATHEXT already
289
+ * contains `.VBS`/`.JS`/`.WSF`/`.MSC`, and the set here is strictly narrower
290
+ * than the pre-#1201 behavior. Only the doc claim was ever wrong.
291
+ */
292
+ const WINDOWS_DIRECT_RESOLVABLE_EXTS = new Set([
293
+ ".exe",
294
+ ".com",
295
+ ".cmd",
296
+ ".bat",
297
+ ]);
298
+ /**
299
+ * Merge a parent and child environment using Windows' case-insensitive variable
300
+ * names. Node's JavaScript environment object can contain both `PATH` and
301
+ * `Path`, even though Windows treats them as one variable. Removing an older
302
+ * spelling before each assignment gives explicit child overrides precedence
303
+ * over every ambient spelling and ensures the environment passed to spawn has
304
+ * one unambiguous value.
305
+ */
306
+ export function mergeWindowsEnvironment(base, overrides) {
307
+ const entries = new Map();
308
+ const assign = (source) => {
309
+ if (!source)
310
+ return;
311
+ for (const [key, value] of Object.entries(source)) {
312
+ const folded = key.toLowerCase();
313
+ // Delete before setting so the last explicit spelling wins while the
314
+ // folded map remains O(n) instead of scanning the merged object for
315
+ // every environment entry.
316
+ entries.delete(folded);
317
+ if (value !== undefined)
318
+ entries.set(folded, { key, value });
319
+ }
320
+ };
321
+ assign(base);
322
+ assign(overrides);
323
+ const merged = {};
324
+ for (const { key, value } of entries.values())
325
+ merged[key] = value;
326
+ return merged;
327
+ }
328
+ function getWindowsEnvironmentValue(env, name) {
329
+ let value;
330
+ for (const [key, entry] of Object.entries(env)) {
331
+ if (key.toLowerCase() === name.toLowerCase())
332
+ value = entry;
333
+ }
334
+ return value;
335
+ }
336
+ function driveLetter(value) {
337
+ const match = /^([A-Za-z]):/.exec(value);
338
+ return match?.[1]?.toUpperCase();
339
+ }
340
+ function isDriveAbsolute(value, drive) {
341
+ return (value.length >= 3 &&
342
+ value[0]?.toUpperCase() === drive.toUpperCase() &&
343
+ value[1] === ":" &&
344
+ (value[2] === "\\" || value[2] === "/"));
345
+ }
346
+ /** `X:\...` or `X:/...` — a drive-absolute path, for any drive letter. */
347
+ function isDriveAbsoluteAnyDrive(value) {
348
+ const drive = driveLetter(value);
349
+ return drive !== undefined && isDriveAbsolute(value, drive);
350
+ }
351
+ /** `\\server\share\...` — a UNC path. Two leading separators, not more. */
352
+ function isUncWindowsPath(value) {
353
+ return ((value[0] === "\\" || value[0] === "/") &&
354
+ (value[1] === "\\" || value[1] === "/") &&
355
+ value[2] !== "\\" &&
356
+ value[2] !== "/");
357
+ }
358
+ /**
359
+ * A path is "fully qualified" (self-contained, no ambient current-directory
360
+ * lookup required) only when it is drive-absolute (`X:\...`) or UNC
361
+ * (`\\server\share`). `path.win32.isAbsolute("\tools")` also returns `true`
362
+ * for a *rooted* path — one relative to the current drive's root rather than
363
+ * a specific drive — and treating that as fully qualified was the #1201
364
+ * bug: it got statSync'd against the *host* drive while a different
365
+ * `effectiveCwd`/`resolvedDrive` supplied the drive cmd.exe actually used to
366
+ * execute it, so a different file could be validated than executed.
367
+ */
368
+ function isFullyQualifiedWindowsPath(value) {
369
+ return isFullyQualifiedWin32(value) && !isRootedWindowsPath(value);
370
+ }
371
+ /**
372
+ * `\tools` or `/tools` — rooted at the current drive's root, but naming no
373
+ * drive of its own (distinct from both `X:\tools` fully-qualified and
374
+ * `X:tools` drive-relative-to-current-directory). Windows resolves this
375
+ * against the root of whichever drive is "current", not the current
376
+ * directory on that drive.
377
+ */
378
+ function isRootedWindowsPath(value) {
379
+ return ((value[0] === "\\" || value[0] === "/") &&
380
+ driveLetter(value) === undefined &&
381
+ !isUncWindowsPath(value));
382
+ }
383
+ /**
384
+ * Resolve a rooted path (`\tools`) against the ROOT of `driveSource`'s drive
385
+ * — not `driveSource` itself — matching Windows' own rooted-path semantics.
386
+ *
387
+ * When `driveSource` carries NO drive letter, this returns the normalized
388
+ * value rather than `undefined` (#1201, recurring defect shape 2). A
389
+ * drive-less "current drive" provenance means we are not on a drive-lettered
390
+ * filesystem at all — a POSIX host, or a UNC `process.cwd()` such as
391
+ * `\\server\share\...`, which Node permits on real Windows. In that namespace
392
+ * a rooted path is already as qualified as any path can be, so there is
393
+ * nothing to anchor and normalizing is the correct (and pre-#1201) answer.
394
+ * Returning `undefined` instead used to collapse the whole resolution to
395
+ * `null` without a single `statSync`, i.e. a false ENOENT — the exact failure
396
+ * signature #1199 exists to remove. This function must therefore never fail
397
+ * closed on the *shape* of a value that may not be a Windows path at all;
398
+ * the filesystem probe downstream is what decides whether it exists.
399
+ */
400
+ function resolveRootedWindowsPath(value, driveSource) {
401
+ const drive = driveLetter(driveSource);
402
+ if (drive === undefined)
403
+ return path.win32.normalize(value);
404
+ return path.win32.normalize(path.win32.resolve(`${drive}:\\`, value));
405
+ }
406
+ /**
407
+ * Return a validated Windows per-drive current directory. Windows exposes
408
+ * these as environment entries such as `=D:`; unlike ordinary environment
409
+ * variables they are not safe to synthesize from a drive letter. A malformed
410
+ * or wrong-drive value is deliberately ignored so drive-relative resolution
411
+ * fails closed instead of guessing a root.
412
+ *
413
+ * Node never surfaces `=X:` keys from the ambient `process.env` (verified:
414
+ * `Object.keys(process.env).filter(k => k.startsWith("="))` is empty), so
415
+ * this provenance is only ever available when a caller supplies it
416
+ * explicitly in an `env` override — e.g. a test, or a future integration
417
+ * that reads it from a lower-level Windows API. That's a real, if narrow,
418
+ * use: keep this path rather than deleting it, but never synthesize a value
419
+ * Node itself can't hand us.
420
+ */
421
+ function getValidatedPerDriveCwd(env, drive) {
422
+ const value = getWindowsEnvironmentValue(env, `=${drive}:`);
423
+ if (value === undefined || !isDriveAbsolute(value, drive))
424
+ return undefined;
425
+ return path.win32.normalize(value);
426
+ }
427
+ function resolveEffectiveWindowsCwd(cwd, env) {
428
+ if (cwd === undefined) {
429
+ // No caller-supplied cwd: `process.cwd()` IS the effective cwd already —
430
+ // the real, canonical filesystem cwd of THIS process, not a string that
431
+ // needs (re)classifying. On a POSIX CI host it naturally carries no
432
+ // drive letter; that's fine, it never needs one here. Only a
433
+ // CALLER-SUPPLIED cwd string goes through the fully-qualified/rooted/
434
+ // drive-relative classification below — running `process.cwd()`'s own
435
+ // value through `isRootedWindowsPath` was itself a #1201 regression: a
436
+ // drive-less POSIX path (e.g. Linux CI's real cwd) satisfies that
437
+ // predicate too (single leading separator, no drive letter), which then
438
+ // sent it to `resolveRootedWindowsPath` looking for a drive letter on
439
+ // `process.cwd()` that — being the very same drive-less string — could
440
+ // never supply one, resolving to `undefined` (recurring defect shape
441
+ // 2/7: `path.win32.resolve` falls back to `process.cwd()`, which
442
+ // supplies a drive on Windows and none on Linux).
443
+ return path.win32.normalize(process.cwd());
444
+ }
445
+ const requestedDrive = driveLetter(cwd);
446
+ if (requestedDrive !== undefined && !isDriveAbsoluteAnyDrive(cwd)) {
447
+ // `D:foo` — drive-relative to that drive's own current directory.
448
+ const base = getValidatedPerDriveCwd(env, requestedDrive);
449
+ return base === undefined
450
+ ? undefined
451
+ : path.win32.normalize(path.win32.resolve(base, cwd));
452
+ }
453
+ if (isFullyQualifiedWindowsPath(cwd))
454
+ return path.win32.normalize(cwd);
455
+ if (isRootedWindowsPath(cwd)) {
456
+ // `\tools` — rooted at the CURRENT drive's root.
457
+ //
458
+ // Note the deliberate asymmetry with `resolveWindowsPathEntry`, which
459
+ // anchors rooted PATH entries to `effectiveCwd`'s drive instead. Both
460
+ // use the SAME rule — "anchor to the best available "current drive"
461
+ // provenance at this point in the pipeline" — they just sit at
462
+ // different points. Here we are *computing* `effectiveCwd`, so it does
463
+ // not exist yet and the process's own cwd is the only provenance
464
+ // available (on real Windows it always carries a drive letter). By the
465
+ // time a PATH entry is resolved, `effectiveCwd` is known and is the
466
+ // strictly better answer, because it is the drive the child will
467
+ // actually run from — anchoring PATH entries to the host drive there
468
+ // would validate one file and execute another. Neither call can fail
469
+ // closed on a drive-less provenance; see `resolveRootedWindowsPath`.
470
+ return resolveRootedWindowsPath(cwd, process.cwd());
471
+ }
472
+ return path.win32.normalize(path.win32.resolve(process.cwd(), cwd));
473
+ }
474
+ function resolveDriveRelativeWindowsPath(value, effectiveCwd, env) {
475
+ const drive = driveLetter(value);
476
+ if (drive === undefined)
477
+ return undefined;
478
+ const cwdDrive = effectiveCwd === undefined ? undefined : driveLetter(effectiveCwd);
479
+ const base = cwdDrive?.toUpperCase() === drive
480
+ ? effectiveCwd
481
+ : getValidatedPerDriveCwd(env, drive);
482
+ if (base === undefined)
483
+ return undefined;
484
+ return path.win32.normalize(path.win32.resolve(base, value));
485
+ }
486
+ function resolveWindowsPathEntry(entry, effectiveCwd, env) {
487
+ if (driveLetter(entry) !== undefined && !isDriveAbsoluteAnyDrive(entry)) {
488
+ return resolveDriveRelativeWindowsPath(entry, effectiveCwd, env);
489
+ }
490
+ if (isFullyQualifiedWindowsPath(entry))
491
+ return path.win32.normalize(entry);
492
+ if (isRootedWindowsPath(entry)) {
493
+ // `\tools` — rooted at the effective child cwd's drive root, not the
494
+ // host process's drive (#1201: a rooted PATH entry must resolve on
495
+ // the SAME drive the child will actually run cmd.exe/the resolved
496
+ // binary from). See `resolveEffectiveWindowsCwd`'s rooted branch for
497
+ // why that function anchors to `process.cwd()` instead — same rule,
498
+ // different point in the pipeline. `undefined` here means the cwd
499
+ // itself was unprovable, which is a genuinely different condition
500
+ // from "the provenance carries no drive letter".
501
+ return effectiveCwd === undefined
502
+ ? undefined
503
+ : resolveRootedWindowsPath(entry, effectiveCwd);
504
+ }
505
+ if (effectiveCwd === undefined)
506
+ return undefined;
507
+ return path.win32.normalize(path.win32.resolve(effectiveCwd, entry));
508
+ }
509
+ /**
510
+ * Cached PATH + PATHEXT resolution results. The effective child environment is
511
+ * part of the key, not the ambient process environment: one caller's managed
512
+ * bin directory must never poison another caller's resolution. The key also
513
+ * carries the canonical child cwd and Windows `=X:` per-drive cwd entries,
514
+ * because both affect relative PATH and drive-relative lookup. Session-lived
515
+ * (matches this repo's other resolution caches, e.g.
134
516
  * `clients/workspace-topology.ts`'s `resetWorkspaceTopology`) — cleared via
135
- * `resetSafeSpawnWindowsCommandCache()`, wired into `handleSessionStart`.
517
+ * `resetSafeSpawnWindowsCommandCache()`, wired into `handleSessionStart`. The
518
+ * cache is also count-bounded with oldest-entry eviction because a long-lived
519
+ * process can encounter unbounded cwd/environment combinations.
136
520
  */
521
+ const WINDOWS_COMMAND_CACHE_MAX_ENTRIES = 256;
522
+ const WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS = 1000;
137
523
  const windowsCommandCache = new Map();
138
- /** Reset hook for session start see `clients/runtime-session.ts`. */
524
+ /** Reset after session replacement or a successful managed install. */
139
525
  export function resetSafeSpawnWindowsCommandCache() {
140
526
  windowsCommandCache.clear();
527
+ loggedSpawnClassifications.clear();
141
528
  }
142
- function getPathExts() {
143
- const raw = process.env.PATHEXT || ".COM;.EXE;.BAT;.CMD";
529
+ function cacheWindowsCommandResult(key, resolved) {
530
+ if (windowsCommandCache.size >= WINDOWS_COMMAND_CACHE_MAX_ENTRIES) {
531
+ // Resolution entries are session-scoped and cheap to recompute. Evict the
532
+ // oldest insertion first so a long-lived process cannot retain every cwd /
533
+ // environment it has ever touched.
534
+ const oldest = windowsCommandCache.keys().next().value;
535
+ if (oldest !== undefined)
536
+ windowsCommandCache.delete(oldest);
537
+ }
538
+ windowsCommandCache.set(key, { resolved, checkedAt: Date.now() });
539
+ }
540
+ function getPathExts(env) {
541
+ const raw = getWindowsEnvironmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD";
144
542
  return raw
145
543
  .split(";")
146
544
  .map((ext) => ext.trim().toLowerCase())
@@ -154,48 +552,145 @@ function statIsFile(candidate) {
154
552
  return false;
155
553
  }
156
554
  }
157
- function resolveWindowsCommandUncached(command, cwd) {
158
- const pathExts = getPathExts();
159
- const existingExt = path.extname(command).toLowerCase();
160
- const hasKnownExt = pathExts.includes(existingExt);
555
+ function resolveWindowsCommandUncached(command, effectiveCwd, env) {
556
+ const pathExts = getPathExts(env);
557
+ const existingExt = path.win32.extname(command).toLowerCase();
558
+ const hasExplicitExt = existingExt.length > 0;
559
+ // A suffix is "known executable" when it's one of the four extensions this
560
+ // resolver actually knows how to run (direct .exe/.com, or the
561
+ // cmd.exe-wrapped .cmd/.bat) OR it's explicitly listed in the caller's own
562
+ // PATHEXT. Anything else with a dot is NOT necessarily "an extension" — a
563
+ // versioned interpreter (`python3.11`, `node-v20.1`) has an `extname()` of
564
+ // `.11`/`.1` that is part of the basename, not a suffix to match exactly
565
+ // (#1201).
566
+ const isKnownExecutableExt = (ext) => WINDOWS_DIRECT_RESOLVABLE_EXTS.has(ext) || pathExts.includes(ext);
161
567
  const tryBase = (base) => {
162
- if (hasKnownExt) {
163
- return statIsFile(base) ? { resolvedPath: base, ext: existingExt } : null;
568
+ // Additive, not exclusive: an explicit, known-executable suffix is
569
+ // tried as an exact candidate FIRST (this also lets a caller ask for
570
+ // `foo.cmd` even when the current PATHEXT doesn't happen to list
571
+ // `.CMD`). Whenever the suffix ISN'T a PATHEXT entry — no extension at
572
+ // all, or a versioned/unknown one like `.11` — the PATHEXT-append loop
573
+ // ALSO runs against the full base, so `python3.11` still finds
574
+ // `python3.11.exe`. An unknown suffix (e.g. `foo.txt`) never gets an
575
+ // exact-match short-circuit: that would let an arbitrary
576
+ // non-executable file resolve as "spawnable" and reach the
577
+ // direct-spawn branch downstream.
578
+ if (hasExplicitExt &&
579
+ isKnownExecutableExt(existingExt) &&
580
+ statIsFile(base)) {
581
+ return { resolvedPath: base, ext: existingExt };
164
582
  }
165
- for (const ext of pathExts) {
166
- const candidate = base + ext;
167
- if (statIsFile(candidate))
168
- return { resolvedPath: candidate, ext };
583
+ if (!hasExplicitExt || !pathExts.includes(existingExt)) {
584
+ for (const ext of pathExts) {
585
+ const candidate = base + ext;
586
+ if (statIsFile(candidate))
587
+ return { resolvedPath: candidate, ext };
588
+ }
169
589
  }
170
590
  return null;
171
591
  };
172
592
  const hasPathSep = /[\\/]/.test(command);
173
- if (hasPathSep || path.isAbsolute(command)) {
174
- const base = path.isAbsolute(command)
175
- ? command
176
- : path.resolve(cwd ?? process.cwd(), command);
177
- return tryBase(base);
593
+ // `D:tool.exe` is drive-relative on Windows, not a bare PATH command. It
594
+ // uses the effective cwd only when that cwd is on D:. For another drive we
595
+ // require an explicit, validated `=D:` provenance entry; otherwise this
596
+ // resolver fails closed rather than guessing `D:\\` or searching PATH.
597
+ const hasDrivePrefix = driveLetter(command) !== undefined;
598
+ if (hasPathSep || hasDrivePrefix || isFullyQualifiedWindowsPath(command)) {
599
+ let base;
600
+ if (isFullyQualifiedWindowsPath(command)) {
601
+ base = path.win32.normalize(command);
602
+ }
603
+ else if (hasDrivePrefix) {
604
+ base = resolveDriveRelativeWindowsPath(command, effectiveCwd, env);
605
+ }
606
+ else if (isRootedWindowsPath(command)) {
607
+ base =
608
+ effectiveCwd === undefined
609
+ ? undefined
610
+ : resolveRootedWindowsPath(command, effectiveCwd);
611
+ }
612
+ else if (effectiveCwd !== undefined) {
613
+ base = path.win32.normalize(path.win32.resolve(effectiveCwd, command));
614
+ }
615
+ return base === undefined ? null : tryBase(base);
178
616
  }
179
- const pathDirs = (process.env.PATH ?? process.env.Path ?? "").split(";");
617
+ // A bare command (no path separator) is a plain PATH search. Absolute PATH
618
+ // entries don't need `effectiveCwd` at all, so an unresolvable cwd must
619
+ // only skip the relative entries (handled per-entry in
620
+ // resolveWindowsPathEntry below), not abort the whole search (#1201: a bad
621
+ // cwd shouldn't make an otherwise-resolvable command look "not installed").
622
+ const pathValue = getWindowsEnvironmentValue(env, "PATH") ?? "";
623
+ const pathDirs = pathValue.split(path.win32.delimiter);
180
624
  for (const dir of pathDirs) {
181
625
  if (!dir)
182
626
  continue;
183
- const found = tryBase(path.join(dir, command));
627
+ const resolvedDir = resolveWindowsPathEntry(dir, effectiveCwd, env);
628
+ if (resolvedDir === undefined)
629
+ continue;
630
+ const found = tryBase(path.win32.join(resolvedDir, command));
184
631
  if (found)
185
632
  return found;
186
633
  }
187
634
  return null;
188
635
  }
189
- /** Cached `where`-equivalent: resolve `command` to a real file via PATH + PATHEXT. */
190
- function resolveWindowsCommand(command, cwd) {
191
- const cacheKey = `${command}\0${process.env.PATH ?? process.env.Path ?? ""}\0${cwd ?? ""}`;
192
- if (windowsCommandCache.has(cacheKey)) {
193
- return windowsCommandCache.get(cacheKey) ?? null;
636
+ /**
637
+ * Resolve a Windows command using the exact environment that will be passed to
638
+ * the child. Exported as a small platform-independent test/diagnostic seam;
639
+ * callers should pass a Windows-shaped environment and do not need to mutate
640
+ * `process.env` to exercise resolution. Drive-relative commands (`D:tool.exe`)
641
+ * use a same-drive effective cwd, or a validated absolute `=D:` entry for a
642
+ * different drive; without that provenance they fail closed and never search
643
+ * PATH. Relative PATH entries use the canonical effective child cwd.
644
+ */
645
+ export function resolveWindowsCommandForEnvironment(command, cwd, env) {
646
+ const effectiveCwd = resolveEffectiveWindowsCwd(cwd, env);
647
+ const pathValue = getWindowsEnvironmentValue(env, "PATH");
648
+ const pathExtValue = getWindowsEnvironmentValue(env, "PATHEXT");
649
+ const perDriveCwds = Object.entries(env)
650
+ .flatMap(([key, value]) => /^=[A-Za-z]:$/.test(key)
651
+ ? [[key.toLowerCase(), value]]
652
+ : [])
653
+ .sort(([left], [right]) => left.localeCompare(right));
654
+ // Keep presence separate from value: absent PATHEXT means the Windows
655
+ // default extension list, while PATHEXT="" means no implicit extensions.
656
+ // The per-drive snapshot is equally important: it is provenance for
657
+ // drive-relative commands and PATH entries, not ambient decoration.
658
+ const cacheKey = JSON.stringify([
659
+ "win32",
660
+ command,
661
+ pathValue === undefined ? ["absent"] : ["present", pathValue],
662
+ pathExtValue === undefined ? ["absent"] : ["present", pathExtValue],
663
+ effectiveCwd === undefined ? ["unresolved"] : ["resolved", effectiveCwd],
664
+ perDriveCwds,
665
+ ]);
666
+ const cached = windowsCommandCache.get(cacheKey);
667
+ if (cached) {
668
+ // Positive entries are revalidated on every hit so an executable deleted
669
+ // or replaced mid-session cannot remain spawnable through stale cache
670
+ // state. Negative entries are short-lived to discover external installs;
671
+ // pi-lens-managed installs also reset the cache immediately on success.
672
+ if (cached.resolved && statIsFile(cached.resolved.resolvedPath)) {
673
+ return cached.resolved;
674
+ }
675
+ if (!cached.resolved &&
676
+ Date.now() - cached.checkedAt <= WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS) {
677
+ return null;
678
+ }
679
+ windowsCommandCache.delete(cacheKey);
194
680
  }
195
- const resolved = resolveWindowsCommandUncached(command, cwd);
196
- windowsCommandCache.set(cacheKey, resolved);
681
+ const resolved = resolveWindowsCommandUncached(command, effectiveCwd, env);
682
+ cacheWindowsCommandResult(cacheKey, resolved);
197
683
  return resolved;
198
684
  }
685
+ /** Cached `where`-equivalent for the effective child environment. */
686
+ function resolveWindowsCommand(command, cwd, env) {
687
+ return resolveWindowsCommandForEnvironment(command, cwd, env);
688
+ }
689
+ function getSpawnEnvironment(overrides) {
690
+ return process.platform === "win32"
691
+ ? mergeWindowsEnvironment(process.env, overrides)
692
+ : { ...process.env, ...overrides };
693
+ }
199
694
  /**
200
695
  * Characters that make a cmd.exe `/c` command line unsound to build from
201
696
  * caller-tainted input: `%`/`!` expand even inside double quotes and cannot
@@ -215,10 +710,8 @@ function findCmdUnsafeValue(command, args) {
215
710
  }
216
711
  function synthesizeEnoentError(command) {
217
712
  // Shaped like Node's native `spawn <cmd> ENOENT` error (message/code/
218
- // syscall/path) so existing `err.message.includes("ENOENT")` /
219
- // `err.code === "ENOENT"` call sites (e.g. sg-runner.ts, lsp/launch.ts)
220
- // keep working now that Windows resolution happens before spawn instead
221
- // of inside cmd.exe.
713
+ // syscall/path) so the typed classifier retains the same diagnostic cause
714
+ // now that Windows resolution happens before spawn instead of inside cmd.exe.
222
715
  const err = new Error(`spawn ${command} ENOENT`);
223
716
  err.code = "ENOENT";
224
717
  err.syscall = "spawn";
@@ -251,11 +744,22 @@ function ensureUtf8ConsoleCodePageOnce() {
251
744
  });
252
745
  }
253
746
  catch {
254
- // Best-effort: worst case is non-ASCII tool output mis-decoded, not a
747
+ // Best-effort: worst case is non-ASCII tool output decoded incorrectly, not a
255
748
  // spawn failure — never let this block the real spawn.
256
749
  }
257
750
  }
258
- /** Test-only: allow tests to force the chcp one-shot to run again. */
751
+ /**
752
+ * Test-only seam: clear the one-shot `chcp` memoization.
753
+ *
754
+ * No test observes it today — the memoized work only runs inside a real
755
+ * Windows direct spawn, so exercising it would mean a Windows-only test, and
756
+ * the state it guards is a console code page, not behavior any assertion
757
+ * depends on. This is kept purely so a future Windows-only test (or a second
758
+ * caller of `ensureUtf8ConsoleCodePageOnce`) can reset process-lifetime state
759
+ * without reaching into the module; it was dropped once as unrelated scope
760
+ * creep in an earlier #1199 revision and restored on review. Do not read the
761
+ * export as evidence of existing coverage (#1201).
762
+ */
259
763
  export function resetUtf8ConsoleCodePageStateForTests() {
260
764
  utf8ConsoleCodePageApplied = false;
261
765
  }
@@ -275,7 +779,15 @@ export function resetUtf8ConsoleCodePageStateForTests() {
275
779
  * if (result.error) console.error("Failed:", result.error);
276
780
  */
277
781
  export async function safeSpawnAsync(command, args, options) {
278
- const timeout = options?.timeout ?? 30000;
782
+ const configuredTimeout = options?.timeout !== undefined &&
783
+ Number.isFinite(options.timeout) &&
784
+ options.timeout >= 0
785
+ ? options.timeout
786
+ : 30000;
787
+ const deadlineRemaining = options?.deadlineAt !== undefined && Number.isFinite(options.deadlineAt)
788
+ ? options.deadlineAt - Date.now()
789
+ : Number.POSITIVE_INFINITY;
790
+ const timeout = Math.max(0, Math.min(configuredTimeout, deadlineRemaining));
279
791
  // Fall back to the current turn's ambient signal (set from ctx.signal) so an
280
792
  // Esc/abort mid-turn cancels dispatches that didn't thread a signal of their
281
793
  // own — unless the caller opts out (installs, which must run to completion).
@@ -284,18 +796,72 @@ export async function safeSpawnAsync(command, args, options) {
284
796
  return new Promise((resolve) => {
285
797
  // Check for early abort
286
798
  if (abortSignal?.aborted) {
799
+ const cause = new Error("Spawn aborted before start");
800
+ resolve({
801
+ stdout: "",
802
+ stderr: "",
803
+ status: null,
804
+ error: cause,
805
+ failure: "aborted",
806
+ spawnFailure: new SpawnFailureError("killed", cause.message, cause),
807
+ });
808
+ return;
809
+ }
810
+ if (timeout <= 0) {
811
+ const cause = new Error(`Process timed out after ${timeout}ms`);
287
812
  resolve({
288
813
  stdout: "",
289
814
  stderr: "",
290
815
  status: null,
291
- error: new Error("Spawn aborted before start"),
816
+ error: cause,
817
+ failure: "timeout",
818
+ spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
292
819
  });
293
820
  return;
294
821
  }
295
822
  let stdout = "";
296
823
  let stderr = "";
297
824
  let timedOut = false;
825
+ let aborted = false;
298
826
  let killed = false;
827
+ let outputTruncated = false;
828
+ let spawnErrored = false;
829
+ // #1109: the non-Windows SIGTERM→SIGKILL escalation timer (armed in
830
+ // killTree below). Stored per-call (never shared) so the close/error
831
+ // handlers can clear it if the child exits before it fires — same
832
+ // uncleared-race-timeout class as the LSP client-wait leak (#1097):
833
+ // a ref'd 1s timer that outlives the child it was escalating against
834
+ // would keep a one-shot `pi --print` process alive for up to 1s.
835
+ let escalationTimer;
836
+ // #1114: `child.killed` is set by Node the moment `kill()` successfully
837
+ // SENDS a signal, not when the child actually dies — so gating the
838
+ // escalation on `!child.killed` right after a successful `SIGTERM` send
839
+ // is always false and the SIGKILL branch is unreachable. Track observed
840
+ // death via the close/error handlers instead (set synchronously, before
841
+ // any `await`, so a timer firing during the close handler's `await
842
+ // killPromise` still observes the flag correctly).
843
+ let closed = false;
844
+ const maxOutputBytes = options?.maxOutputBytes !== undefined &&
845
+ Number.isFinite(options.maxOutputBytes) &&
846
+ options.maxOutputBytes > 0
847
+ ? Math.floor(options.maxOutputBytes)
848
+ : undefined;
849
+ const appendOutput = (current, chunk) => {
850
+ const text = typeof chunk === "string" ? chunk : chunk.toString();
851
+ if (maxOutputBytes === undefined)
852
+ return current + text;
853
+ const used = Buffer.byteLength(stdout) + Buffer.byteLength(stderr);
854
+ const remaining = maxOutputBytes - used;
855
+ if (remaining <= 0) {
856
+ outputTruncated = true;
857
+ return current;
858
+ }
859
+ const bytes = Buffer.byteLength(text);
860
+ if (bytes <= remaining)
861
+ return current + text;
862
+ outputTruncated = true;
863
+ return current + Buffer.from(text).subarray(0, remaining).toString();
864
+ };
299
865
  // Spawn the process (non-blocking). Keeping Node's `shell` option false
300
866
  // is important on every path here: shell:true concatenates tainted
301
867
  // arguments into a command line before spawning (CodeQL #17 / CWE-78).
@@ -313,12 +879,36 @@ export async function safeSpawnAsync(command, args, options) {
313
879
  // - unresolvable → synthesize an ENOENT-shaped error instead of
314
880
  // letting cmd.exe report "not recognized" from inside a shell.
315
881
  const isWindows = process.platform === "win32";
882
+ const spawnEnv = getSpawnEnvironment(options?.env);
883
+ // The cwd handed to the CHILD process doesn't need OUR validation —
884
+ // Windows resolves it natively, exactly as it did before #817 ever
885
+ // touched this file. A drive-relative cwd (`D:work`) without a
886
+ // validated `=D:` env entry can't be canonicalized by us (Node never
887
+ // surfaces `=X:` keys from the ambient environment — see
888
+ // `getValidatedPerDriveCwd`'s doc comment), but that is a gap in OUR
889
+ // provenance, not evidence the cwd itself is bad. Fall back to the raw
890
+ // value instead of failing the whole spawn before ever attempting it
891
+ // (#1201) — command resolution just below still fails closed for any
892
+ // relative PATH entry that would need this cwd to be canonical.
893
+ // No `?? process.cwd()` third arm: `resolveEffectiveWindowsCwd(undefined,
894
+ // env)` always returns a string, so the only way to reach `undefined`
895
+ // here is a DEFINED-but-unprovable `options.cwd`, which `?? options?.cwd`
896
+ // already covers. A `process.cwd()` arm would be unreachable, and it
897
+ // would read like a live silent-wrong-directory hazard (#1201).
898
+ const spawnCwd = isWindows
899
+ ? (resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd)
900
+ : options?.cwd;
316
901
  let spawnCmd = command;
317
902
  let spawnArgs = args;
318
903
  let windowsVerbatimArguments = false;
319
904
  let resolutionError;
320
905
  if (isWindows) {
321
- const resolved = resolveWindowsCommand(command, options?.cwd);
906
+ // Pass the ORIGINAL (unresolved) cwd here, not `spawnCwd` above — this
907
+ // seam intentionally fails closed on unprovable drive-relative
908
+ // provenance (matches `resolveWindowsCommandForEnvironment`'s
909
+ // documented contract and its dedicated test coverage), independent
910
+ // of the passthrough fallback the actual child cwd gets above.
911
+ const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
322
912
  if (!resolved) {
323
913
  resolutionError = synthesizeEnoentError(command);
324
914
  }
@@ -359,14 +949,24 @@ export async function safeSpawnAsync(command, args, options) {
359
949
  }
360
950
  }
361
951
  if (resolutionError) {
362
- resolve({ stdout: "", stderr: "", status: null, error: resolutionError });
952
+ void classifySpawnFailure(resolutionError, {
953
+ command,
954
+ cwd: options?.cwd,
955
+ }).then((spawnFailure) => resolve({
956
+ stdout: "",
957
+ stderr: "",
958
+ status: null,
959
+ error: resolutionError,
960
+ failure: "spawn",
961
+ spawnFailure,
962
+ }));
363
963
  return;
364
964
  }
365
965
  let child;
366
966
  try {
367
967
  child = spawn(spawnCmd, spawnArgs, {
368
- cwd: options?.cwd,
369
- env: { ...process.env, ...options?.env },
968
+ cwd: spawnCwd,
969
+ env: spawnEnv,
370
970
  windowsHide: true,
371
971
  shell: false,
372
972
  windowsVerbatimArguments,
@@ -380,12 +980,15 @@ export async function safeSpawnAsync(command, args, options) {
380
980
  // a rejection here could surface as an unhandledRejection that crashes
381
981
  // the host. Resolve the failure gracefully instead — same contract as an
382
982
  // asynchronously-emitted `'error'` event (handled below).
383
- resolve({
983
+ const cause = toError(err);
984
+ void classifySpawnFailure(cause, { command, cwd: options?.cwd }).then((spawnFailure) => resolve({
384
985
  stdout: "",
385
986
  stderr: "",
386
987
  status: null,
387
- error: err instanceof Error ? err : new Error(String(err)),
388
- });
988
+ error: cause,
989
+ failure: "spawn",
990
+ spawnFailure,
991
+ }));
389
992
  return;
390
993
  }
391
994
  if (options?.lifetimeCoupled && child.pid) {
@@ -434,27 +1037,43 @@ export async function safeSpawnAsync(command, args, options) {
434
1037
  }
435
1038
  else {
436
1039
  child.kill("SIGTERM");
437
- setTimeout(() => {
438
- if (!child.killed)
1040
+ escalationTimer = setTimeout(() => {
1041
+ if (!closed)
439
1042
  child.kill("SIGKILL");
440
1043
  }, 1000);
441
1044
  }
442
1045
  };
443
1046
  // Handle abort signal
444
1047
  const onAbort = () => {
1048
+ aborted = true;
445
1049
  if (!killed && !child.killed) {
446
1050
  killed = true;
447
1051
  void killTree();
448
1052
  }
449
1053
  };
450
1054
  abortSignal?.addEventListener("abort", onAbort, { once: true });
1055
+ // Output-cap kills are awaited by the close handler below, just like
1056
+ // timeout/abort kills. This keeps a noisy CLI from continuing in the
1057
+ // background after its retained output has been bounded.
1058
+ let killPromise;
1059
+ const stopForOutputLimit = () => {
1060
+ if (outputTruncated && !killed && !child.killed) {
1061
+ killed = true;
1062
+ killPromise = killTree();
1063
+ }
1064
+ };
451
1065
  // Collect output
452
1066
  child.stdout?.setEncoding("utf-8");
453
1067
  child.stderr?.setEncoding("utf-8");
454
- child.stdout?.on("data", (data) => (stdout += data));
455
- child.stderr?.on("data", (data) => (stderr += data));
1068
+ child.stdout?.on("data", (data) => {
1069
+ stdout = appendOutput(stdout, data);
1070
+ stopForOutputLimit();
1071
+ });
1072
+ child.stderr?.on("data", (data) => {
1073
+ stderr = appendOutput(stderr, data);
1074
+ stopForOutputLimit();
1075
+ });
456
1076
  // Timeout handling - KILL the process, don't just abandon it
457
- let killPromise;
458
1077
  const timeoutId = setTimeout(() => {
459
1078
  timedOut = true;
460
1079
  if (!killed && !child.killed) {
@@ -488,41 +1107,99 @@ export async function safeSpawnAsync(command, args, options) {
488
1107
  };
489
1108
  // Process completion
490
1109
  child.on("close", async (code, signal) => {
1110
+ if (spawnErrored)
1111
+ return;
1112
+ closed = true;
491
1113
  clearTimeout(timeoutId);
492
1114
  abortSignal?.removeEventListener("abort", onAbort);
493
1115
  if (child.pid)
494
1116
  lifetimeState.pids.delete(child.pid);
495
1117
  await killPromise;
1118
+ // #1109: the child has exited — if killTree armed the non-Windows
1119
+ // SIGTERM→SIGKILL escalation timer and it hasn't fired yet, clear it
1120
+ // so it doesn't linger as a ref'd handle after this promise resolves.
1121
+ if (escalationTimer)
1122
+ clearTimeout(escalationTimer);
496
1123
  const resourceUsage = finishResourceUsage();
1124
+ const outputInfo = outputTruncated ? { outputTruncated: true } : {};
497
1125
  if (timedOut) {
1126
+ const cause = new Error(`Process timed out after ${timeout}ms (killed with ${signal || "SIGTERM"})`);
498
1127
  resolve({
499
1128
  stdout,
500
1129
  stderr,
501
1130
  status: null,
502
- error: new Error(`Process timed out after ${timeout}ms (killed with ${signal || "SIGTERM"})`),
1131
+ error: cause,
1132
+ failure: "timeout",
1133
+ spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
1134
+ ...outputInfo,
1135
+ resourceUsage,
1136
+ });
1137
+ }
1138
+ else if (aborted) {
1139
+ const cause = new Error("Spawn aborted");
1140
+ resolve({
1141
+ stdout,
1142
+ stderr,
1143
+ status: null,
1144
+ error: cause,
1145
+ failure: "aborted",
1146
+ spawnFailure: new SpawnFailureError("killed", cause.message, cause),
1147
+ ...outputInfo,
503
1148
  resourceUsage,
504
1149
  });
505
1150
  }
506
1151
  else if (signal) {
1152
+ const cause = new Error(`Process killed by signal: ${signal}`);
507
1153
  resolve({
508
1154
  stdout,
509
1155
  stderr,
510
1156
  status: null,
511
- error: new Error(`Process killed by signal: ${signal}`),
1157
+ error: cause,
1158
+ failure: "signal",
1159
+ spawnFailure: new SpawnFailureError("killed", cause.message, cause),
1160
+ ...outputInfo,
512
1161
  resourceUsage,
513
1162
  });
514
1163
  }
515
1164
  else {
516
- resolve({ stdout, stderr, status: code, resourceUsage });
1165
+ resolve({ stdout, stderr, status: code, ...outputInfo, resourceUsage });
517
1166
  }
518
1167
  });
519
1168
  child.on("error", (err) => {
1169
+ spawnErrored = true;
1170
+ closed = true;
520
1171
  clearTimeout(timeoutId);
521
1172
  abortSignal?.removeEventListener("abort", onAbort);
1173
+ if (escalationTimer)
1174
+ clearTimeout(escalationTimer);
522
1175
  if (child.pid)
523
1176
  lifetimeState.pids.delete(child.pid);
524
1177
  const resourceUsage = finishResourceUsage();
525
- resolve({ stdout, stderr, status: null, error: err, resourceUsage });
1178
+ let failure = "spawn";
1179
+ if (aborted)
1180
+ failure = "aborted";
1181
+ else if (timedOut)
1182
+ failure = "timeout";
1183
+ const controlFailure = aborted
1184
+ ? new SpawnFailureError("killed", err.message, err)
1185
+ : timedOut
1186
+ ? new SpawnFailureError("timeout", err.message, err)
1187
+ : undefined;
1188
+ const finish = (spawnFailure) => resolve({
1189
+ stdout,
1190
+ stderr,
1191
+ status: null,
1192
+ error: err,
1193
+ failure,
1194
+ spawnFailure,
1195
+ ...(outputTruncated ? { outputTruncated: true } : {}),
1196
+ resourceUsage,
1197
+ });
1198
+ if (controlFailure)
1199
+ finish(controlFailure);
1200
+ else {
1201
+ void classifySpawnFailure(err, { command, cwd: options?.cwd }).then(finish);
1202
+ }
526
1203
  });
527
1204
  });
528
1205
  }
@@ -591,14 +1268,29 @@ export async function findCommandAsync(command) {
591
1268
  * No `shell: true` anywhere.
592
1269
  */
593
1270
  export function safeSpawn(command, args, options) {
1271
+ const spawnEnv = getSpawnEnvironment(options?.env);
594
1272
  if (process.platform === "win32") {
595
- const resolved = resolveWindowsCommand(command, options?.cwd);
1273
+ // See the matching comment in safeSpawnAsync: the child's cwd doesn't
1274
+ // need OUR validation (Windows resolves it natively), so an unprovable
1275
+ // drive-relative cwd falls back to the raw value instead of failing the
1276
+ // whole spawn before ever attempting it (#1201). Command resolution
1277
+ // below intentionally still uses the ORIGINAL raw cwd and fails closed
1278
+ // for PATH entries that would need it to be canonical.
1279
+ // See safeSpawnAsync for why there is no `?? process.cwd()` third arm.
1280
+ const spawnCwd = resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd;
1281
+ const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
596
1282
  if (!resolved) {
1283
+ const error = synthesizeEnoentError(command);
597
1284
  return {
598
1285
  stdout: "",
599
1286
  stderr: "",
600
1287
  status: null,
601
- error: synthesizeEnoentError(command),
1288
+ error,
1289
+ failure: "spawn",
1290
+ spawnFailure: classifySpawnFailureSync(error, {
1291
+ command,
1292
+ cwd: options?.cwd,
1293
+ }),
602
1294
  };
603
1295
  }
604
1296
  let spawnCmd;
@@ -610,15 +1302,21 @@ export function safeSpawn(command, args, options) {
610
1302
  // extensionless) `command` string — plus every arg.
611
1303
  const unsafeValue = findCmdUnsafeValue(resolved.resolvedPath, args);
612
1304
  if (unsafeValue !== undefined) {
1305
+ const error = new Error(`Refusing to spawn "${resolved.resolvedPath}" via cmd.exe: ` +
1306
+ `${JSON.stringify(unsafeValue)} contains a character ("` +
1307
+ `, %, !, or CR/LF) that cannot be safely escaped on a ` +
1308
+ `cmd.exe /c command line (CWE-78, #817). Rename/quote the ` +
1309
+ "value or invoke the tool without going through cmd.exe.");
613
1310
  return {
614
1311
  stdout: "",
615
1312
  stderr: "",
616
1313
  status: null,
617
- error: new Error(`Refusing to spawn "${resolved.resolvedPath}" via cmd.exe: ` +
618
- `${JSON.stringify(unsafeValue)} contains a character ("` +
619
- `, %, !, or CR/LF) that cannot be safely escaped on a ` +
620
- `cmd.exe /c command line (CWE-78, #817). Rename/quote the ` +
621
- "value or invoke the tool without going through cmd.exe."),
1314
+ error,
1315
+ failure: "spawn",
1316
+ spawnFailure: classifySpawnFailureSync(error, {
1317
+ command,
1318
+ cwd: options?.cwd,
1319
+ }),
622
1320
  };
623
1321
  }
624
1322
  spawnCmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
@@ -637,29 +1335,46 @@ export function safeSpawn(command, args, options) {
637
1335
  }
638
1336
  const result = spawnSync(spawnCmd, spawnArgs, {
639
1337
  ...options,
1338
+ cwd: spawnCwd,
1339
+ env: spawnEnv,
640
1340
  encoding: "utf-8",
641
1341
  shell: false,
642
1342
  windowsHide: true,
643
1343
  windowsVerbatimArguments,
644
1344
  });
1345
+ const spawnFailure = result.error
1346
+ ? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
1347
+ : undefined;
645
1348
  return {
646
1349
  stdout: result.stdout?.toString() || "",
647
1350
  stderr: result.stderr?.toString() || "",
648
1351
  status: result.status,
649
1352
  error: result.error,
1353
+ ...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
650
1354
  };
651
1355
  }
652
1356
  const result = spawnSync(command, args, {
653
1357
  ...options,
1358
+ // Explicit override, not just the spread above: `options.env` alone
1359
+ // would otherwise reach the child as a full replacement (no
1360
+ // process.env merge, no PATH) instead of the merged environment the
1361
+ // resolver/Windows branch above both use (#1201) — the module's
1362
+ // "resolver and child receive the same merged environment" invariant
1363
+ // must hold on every platform, not just Windows.
1364
+ env: spawnEnv,
654
1365
  encoding: "utf-8",
655
1366
  shell: false,
656
1367
  windowsHide: true,
657
1368
  });
1369
+ const spawnFailure = result.error
1370
+ ? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
1371
+ : undefined;
658
1372
  return {
659
1373
  stdout: result.stdout?.toString() || "",
660
1374
  stderr: result.stderr?.toString() || "",
661
1375
  status: result.status,
662
1376
  error: result.error,
1377
+ ...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
663
1378
  };
664
1379
  }
665
1380
  /**