@herbertgao/pi-extensions 2026.10.0 → 2026.10.2

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 (769) hide show
  1. package/README.md +16 -12
  2. package/THIRD_PARTY_NOTICES.md +3 -3
  3. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-audit.md +2 -2
  4. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-debt.md +2 -2
  5. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-gain.md +1 -1
  6. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-help.md +1 -1
  7. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail-review.md +2 -2
  8. package/node_modules/@dietrichgebert/ponytail/.opencode/command/ponytail.md +1 -1
  9. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/index.js +4 -0
  10. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail-frontmatter.cjs +43 -3
  11. package/node_modules/@dietrichgebert/ponytail/.opencode/plugins/ponytail.mjs +142 -59
  12. package/node_modules/@dietrichgebert/ponytail/.qoder/rules/ponytail.md +21 -21
  13. package/node_modules/@dietrichgebert/ponytail/.qoder-plugin/plugin.json +1 -1
  14. package/node_modules/@dietrichgebert/ponytail/AGENTS.md +21 -21
  15. package/node_modules/@dietrichgebert/ponytail/README.md +52 -242
  16. package/node_modules/@dietrichgebert/ponytail/hooks/claude-codex-hooks.json +3 -3
  17. package/node_modules/@dietrichgebert/ponytail/hooks/copilot-hooks.json +4 -4
  18. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-activate.js +70 -16
  19. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-config.js +8 -7
  20. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-instructions.js +30 -30
  21. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-map.js +103 -0
  22. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-mode-tracker.js +46 -23
  23. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-runtime.js +39 -7
  24. package/node_modules/@dietrichgebert/ponytail/hooks/ponytail-subagent.js +20 -3
  25. package/node_modules/@dietrichgebert/ponytail/hooks/qoder-hooks.json +3 -3
  26. package/node_modules/@dietrichgebert/ponytail/package.json +3 -3
  27. package/node_modules/@dietrichgebert/ponytail/pi-extension/index.js +47 -17
  28. package/node_modules/@dietrichgebert/ponytail/scripts/uninstall.js +21 -7
  29. package/node_modules/@dietrichgebert/ponytail/skills/plugin.json +5 -0
  30. package/node_modules/@dietrichgebert/ponytail/skills/ponytail/SKILL.md +33 -101
  31. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-audit/SKILL.md +89 -27
  32. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-debt/SKILL.md +13 -12
  33. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-gain/SKILL.md +21 -18
  34. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-help/SKILL.md +12 -13
  35. package/node_modules/@dietrichgebert/ponytail/skills/ponytail-review/SKILL.md +96 -52
  36. package/node_modules/@herbertgao/pi-agency/LICENSE +21 -0
  37. package/node_modules/@herbertgao/pi-agency/README.md +40 -0
  38. package/node_modules/@herbertgao/pi-agency/package.json +53 -0
  39. package/node_modules/@herbertgao/pi-agency/src/index.ts +441 -0
  40. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/compact-thinking.ts +12 -12
  41. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/compact-mode.ts +30 -6
  42. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/default-mode.ts +15 -9
  43. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/mouse/interaction.ts +27 -9
  44. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/codemode.ts +4 -1
  45. package/node_modules/@herbertgao/pi-cc-extensions/package.json +2 -2
  46. package/node_modules/@narumitw/pi-caffeinate/package.json +4 -4
  47. package/node_modules/@tifan/pi-mermaid-open/package.json +5 -1
  48. package/node_modules/@tifan/pi-mermaid-open/src/index.ts +26 -9
  49. package/node_modules/@tifan/pi-recap/package.json +1 -1
  50. package/node_modules/@tifan/pi-recap/src/index.ts +104 -20
  51. package/node_modules/@tifan/pi-recap/src/models.ts +46 -4
  52. package/node_modules/pi-antigravity/README.md +23 -2
  53. package/node_modules/pi-antigravity/package.json +5 -3
  54. package/node_modules/pi-antigravity/src/diagnostics/diagnostics.ts +3 -2
  55. package/node_modules/pi-antigravity/src/features/generate-image.ts +72 -0
  56. package/node_modules/pi-antigravity/src/features/google-search.ts +71 -0
  57. package/node_modules/pi-antigravity/src/image/image.ts +86 -47
  58. package/node_modules/pi-antigravity/src/image/native.ts +69 -0
  59. package/node_modules/pi-antigravity/src/index.ts +52 -137
  60. package/node_modules/pi-antigravity/src/search/search.ts +130 -31
  61. package/node_modules/pi-antigravity/src/stream/stream.ts +162 -113
  62. package/node_modules/pi-antigravity/src/types/enums.ts +1 -0
  63. package/node_modules/pi-antigravity/src/types/types.ts +1 -1
  64. package/node_modules/pi-antigravity/src/utils/security.ts +47 -1
  65. package/node_modules/pi-antigravity/src/utils/util.ts +38 -6
  66. package/node_modules/pi-lens/CHANGELOG.md +1064 -0
  67. package/node_modules/pi-lens/README.md +115 -25
  68. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +6 -2
  69. package/node_modules/pi-lens/dist/chunk-22AVIGXS.js +560 -0
  70. package/node_modules/pi-lens/dist/chunk-27BBHSZC.js +106 -0
  71. package/node_modules/pi-lens/dist/chunk-2BEMIWEW.js +51 -0
  72. package/node_modules/pi-lens/dist/chunk-2EECBNC5.js +89 -0
  73. package/node_modules/pi-lens/dist/chunk-34U7PLGE.js +23 -0
  74. package/node_modules/pi-lens/dist/chunk-3I5QFT3N.js +181 -0
  75. package/node_modules/pi-lens/dist/chunk-3MED75HT.js +37 -0
  76. package/node_modules/pi-lens/dist/chunk-47D2BO4S.js +87 -0
  77. package/node_modules/pi-lens/dist/chunk-4RERDEXR.js +348 -0
  78. package/node_modules/pi-lens/dist/chunk-4W2PLC4J.js +27 -0
  79. package/node_modules/pi-lens/dist/chunk-52H5B5TC.js +202 -0
  80. package/node_modules/pi-lens/dist/chunk-54B42YRX.js +27 -0
  81. package/node_modules/pi-lens/dist/chunk-5RXBNSW2.js +266 -0
  82. package/node_modules/pi-lens/dist/chunk-5THXD6X5.js +201 -0
  83. package/node_modules/pi-lens/dist/chunk-5W3R6LFT.js +24 -0
  84. package/node_modules/pi-lens/dist/chunk-5ZEJHV35.js +341 -0
  85. package/node_modules/pi-lens/dist/chunk-6P62QVEA.js +118 -0
  86. package/node_modules/pi-lens/dist/chunk-6QIUX4EP.js +459 -0
  87. package/node_modules/pi-lens/dist/chunk-6VCD4VKZ.js +78 -0
  88. package/node_modules/pi-lens/dist/chunk-7F72PXY2.js +326 -0
  89. package/node_modules/pi-lens/dist/chunk-7FBF7NPR.js +998 -0
  90. package/node_modules/pi-lens/dist/chunk-7U2IDNXN.js +37 -0
  91. package/node_modules/pi-lens/dist/chunk-ABBOA7UD.js +157 -0
  92. package/node_modules/pi-lens/dist/chunk-AHDW3GPE.js +50 -0
  93. package/node_modules/pi-lens/dist/chunk-AJ3QXTRQ.js +756 -0
  94. package/node_modules/pi-lens/dist/chunk-AJVLWNFN.js +1610 -0
  95. package/node_modules/pi-lens/dist/chunk-AJXODZY6.js +112 -0
  96. package/node_modules/pi-lens/dist/chunk-ARFCXG7S.js +84 -0
  97. package/node_modules/pi-lens/dist/chunk-AS44CFQ4.js +708 -0
  98. package/node_modules/pi-lens/dist/chunk-AWE5TMPO.js +38 -0
  99. package/node_modules/pi-lens/dist/chunk-B3I2UUT4.js +4692 -0
  100. package/node_modules/pi-lens/dist/chunk-BRJKOBZP.js +331 -0
  101. package/node_modules/pi-lens/dist/chunk-BW5KFIQC.js +59 -0
  102. package/node_modules/pi-lens/dist/chunk-C2AUYXQH.js +149 -0
  103. package/node_modules/pi-lens/dist/chunk-CGGAIP7J.js +29 -0
  104. package/node_modules/pi-lens/dist/chunk-D6QS3U6M.js +30 -0
  105. package/node_modules/pi-lens/dist/chunk-DFVMIKMQ.js +349 -0
  106. package/node_modules/pi-lens/dist/chunk-DIULIWKU.js +169 -0
  107. package/node_modules/pi-lens/dist/chunk-DQ2NZ7C4.js +2317 -0
  108. package/node_modules/pi-lens/dist/chunk-E2WFBRHM.js +213 -0
  109. package/node_modules/pi-lens/dist/chunk-E4MAEDPM.js +50 -0
  110. package/node_modules/pi-lens/dist/chunk-EOKRQ3SA.js +30 -0
  111. package/node_modules/pi-lens/dist/chunk-F3AU2TMM.js +2626 -0
  112. package/node_modules/pi-lens/dist/chunk-F3P7VQ7H.js +170 -0
  113. package/node_modules/pi-lens/dist/chunk-FC4RP4I2.js +12 -0
  114. package/node_modules/pi-lens/dist/chunk-FJNKANOS.js +32 -0
  115. package/node_modules/pi-lens/dist/chunk-GUOGVTCQ.js +146 -0
  116. package/node_modules/pi-lens/dist/chunk-GVXA6CCE.js +18 -0
  117. package/node_modules/pi-lens/dist/chunk-HHYAELLJ.js +2334 -0
  118. package/node_modules/pi-lens/dist/chunk-HMFXILPV.js +83 -0
  119. package/node_modules/pi-lens/dist/chunk-HVUZXSWK.js +421 -0
  120. package/node_modules/pi-lens/dist/chunk-HWBZIGMJ.js +62 -0
  121. package/node_modules/pi-lens/dist/chunk-I3R7GKDR.js +17 -0
  122. package/node_modules/pi-lens/dist/chunk-I7SPGOWI.js +55 -0
  123. package/node_modules/pi-lens/dist/chunk-IIXOAGHR.js +38 -0
  124. package/node_modules/pi-lens/dist/chunk-IVP5KAB7.js +2250 -0
  125. package/node_modules/pi-lens/dist/chunk-IXEBJSUE.js +22 -0
  126. package/node_modules/pi-lens/dist/chunk-JOLITGCC.js +27 -0
  127. package/node_modules/pi-lens/dist/chunk-K6VYLB2U.js +145 -0
  128. package/node_modules/pi-lens/dist/chunk-LJDODBBZ.js +78 -0
  129. package/node_modules/pi-lens/dist/chunk-LNAKEO63.js +78 -0
  130. package/node_modules/pi-lens/dist/chunk-LSMJ6KBE.js +4139 -0
  131. package/node_modules/pi-lens/dist/chunk-LW6XKDZQ.js +5565 -0
  132. package/node_modules/pi-lens/dist/chunk-N3YQJI6O.js +899 -0
  133. package/node_modules/pi-lens/dist/chunk-NABLC7W3.js +148 -0
  134. package/node_modules/pi-lens/dist/chunk-NKOUFPQF.js +659 -0
  135. package/node_modules/pi-lens/dist/chunk-NL5GLMFI.js +211 -0
  136. package/node_modules/pi-lens/dist/chunk-NMABWBZN.js +327 -0
  137. package/node_modules/pi-lens/dist/chunk-NTHH2J74.js +27 -0
  138. package/node_modules/pi-lens/dist/chunk-NTUJEGDZ.js +59 -0
  139. package/node_modules/pi-lens/dist/chunk-NXL4FFQQ.js +47 -0
  140. package/node_modules/pi-lens/dist/chunk-NXPS2L6U.js +466 -0
  141. package/node_modules/pi-lens/dist/chunk-O5WILGMI.js +42 -0
  142. package/node_modules/pi-lens/dist/chunk-O6TQT6RI.js +48 -0
  143. package/node_modules/pi-lens/dist/chunk-OAXNDZJ7.js +323 -0
  144. package/node_modules/pi-lens/dist/chunk-ODEEOXPH.js +44 -0
  145. package/node_modules/pi-lens/dist/chunk-OQGFCZJ4.js +4533 -0
  146. package/node_modules/pi-lens/dist/chunk-ORTMY4Q4.js +282 -0
  147. package/node_modules/pi-lens/dist/chunk-OS32RNR2.js +442 -0
  148. package/node_modules/pi-lens/dist/chunk-PAK6IDME.js +189 -0
  149. package/node_modules/pi-lens/dist/chunk-PWFHBTSF.js +406 -0
  150. package/node_modules/pi-lens/dist/chunk-Q5U6FMDI.js +2352 -0
  151. package/node_modules/pi-lens/dist/chunk-QOZXJ4NA.js +3364 -0
  152. package/node_modules/pi-lens/dist/chunk-QYF5S6P6.js +45 -0
  153. package/node_modules/pi-lens/dist/chunk-RIISMHKN.js +36 -0
  154. package/node_modules/pi-lens/dist/chunk-RPD4ZSB7.js +25 -0
  155. package/node_modules/pi-lens/dist/chunk-RPD5K5NC.js +79 -0
  156. package/node_modules/pi-lens/dist/chunk-RUOG5D2C.js +262 -0
  157. package/node_modules/pi-lens/dist/chunk-T4ERT73Y.js +599 -0
  158. package/node_modules/pi-lens/dist/chunk-T6D4Q4AQ.js +54 -0
  159. package/node_modules/pi-lens/dist/chunk-TGPXLY2J.js +391 -0
  160. package/node_modules/pi-lens/dist/chunk-TLGFBM3F.js +12116 -0
  161. package/node_modules/pi-lens/dist/chunk-TO6WL3GM.js +88 -0
  162. package/node_modules/pi-lens/dist/chunk-TT22N74J.js +517 -0
  163. package/node_modules/pi-lens/dist/chunk-TVN5N6GY.js +21216 -0
  164. package/node_modules/pi-lens/dist/chunk-U3VGR4VU.js +308 -0
  165. package/node_modules/pi-lens/dist/chunk-UK3CMEAL.js +1250 -0
  166. package/node_modules/pi-lens/dist/chunk-UOPVJQ4N.js +103 -0
  167. package/node_modules/pi-lens/dist/chunk-UXVNL3D7.js +23 -0
  168. package/node_modules/pi-lens/dist/chunk-UY752QSN.js +45 -0
  169. package/node_modules/pi-lens/dist/chunk-V5PXFSZK.js +188 -0
  170. package/node_modules/pi-lens/dist/chunk-V7R425XG.js +398 -0
  171. package/node_modules/pi-lens/dist/chunk-VCSRSYLF.js +81 -0
  172. package/node_modules/pi-lens/dist/chunk-VD4JQCQC.js +162 -0
  173. package/node_modules/pi-lens/dist/chunk-VDBPVIKD.js +62 -0
  174. package/node_modules/pi-lens/dist/chunk-VHQLPIRJ.js +114 -0
  175. package/node_modules/pi-lens/dist/chunk-VN2AXLEX.js +5414 -0
  176. package/node_modules/pi-lens/dist/chunk-VNMT7C64.js +478 -0
  177. package/node_modules/pi-lens/dist/chunk-WONERJTP.js +309 -0
  178. package/node_modules/pi-lens/dist/chunk-XECLWQZN.js +85 -0
  179. package/node_modules/pi-lens/dist/chunk-XIMYZ3JA.js +27 -0
  180. package/node_modules/pi-lens/dist/chunk-XK4CIILI.js +144 -0
  181. package/node_modules/pi-lens/dist/chunk-Y4T27COV.js +562 -0
  182. package/node_modules/pi-lens/dist/chunk-YGVYKNSN.js +1860 -0
  183. package/node_modules/pi-lens/dist/chunk-YLF3PPT5.js +308 -0
  184. package/node_modules/pi-lens/dist/chunk-YSNFMJKN.js +14 -0
  185. package/node_modules/pi-lens/dist/chunk-Z3CCVDUB.js +76 -0
  186. package/node_modules/pi-lens/dist/chunk-Z54ZSOBZ.js +61 -0
  187. package/node_modules/pi-lens/dist/chunk-ZAWW3AF6.js +97 -0
  188. package/node_modules/pi-lens/dist/chunk-ZMOL6RQC.js +167 -0
  189. package/node_modules/pi-lens/dist/index.js +16456 -8335
  190. package/node_modules/pi-lens/dist/mcp/analyze-cli.js +186 -232
  191. package/node_modules/pi-lens/dist/mcp/cli.js +137 -98
  192. package/node_modules/pi-lens/dist/mcp/server.js +22589 -1573
  193. package/node_modules/pi-lens/dist/mcp/worker.js +97 -38
  194. package/node_modules/pi-lens/dist/probes/installer.js +135 -0
  195. package/node_modules/pi-lens/dist/workers/project-snapshot-persist-worker.js +22 -0
  196. package/node_modules/pi-lens/dist/workers/review-graph-persist-worker.js +15 -0
  197. package/node_modules/pi-lens/docs/agent-guide.md +13 -4
  198. package/node_modules/pi-lens/docs/agent-tools.md +42 -0
  199. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +3 -2
  200. package/node_modules/pi-lens/docs/configuration.md +20 -1
  201. package/node_modules/pi-lens/docs/environment-variables.md +52 -2
  202. package/node_modules/pi-lens/docs/features.md +8 -4
  203. package/node_modules/pi-lens/docs/globalconfig.md +1 -0
  204. package/node_modules/pi-lens/docs/io-bridge-v2-migration.md +94 -0
  205. package/node_modules/pi-lens/docs/lsp-capability-matrix.md +36 -4
  206. package/node_modules/pi-lens/docs/lsp-idle-eviction.md +124 -0
  207. package/node_modules/pi-lens/docs/mcp.md +21 -5
  208. package/node_modules/pi-lens/docs/merge-queue-rollout.md +77 -0
  209. package/node_modules/pi-lens/docs/pi-lens-fixer.md +156 -88
  210. package/node_modules/pi-lens/docs/pi-lens-investigator.md +74 -26
  211. package/node_modules/pi-lens/docs/pi-lens-merge-policy.md +694 -0
  212. package/node_modules/pi-lens/docs/pi-lens-monitor.md +73 -7
  213. package/node_modules/pi-lens/docs/pi-lens-retro.md +6 -4
  214. package/node_modules/pi-lens/docs/pi-lens-reviewer.md +188 -48
  215. package/node_modules/pi-lens/docs/pi-lens-subagent.md +246 -45
  216. package/node_modules/pi-lens/docs/pi-lens-warden.md +18 -7
  217. package/node_modules/pi-lens/docs/public-api-stability.md +55 -0
  218. package/node_modules/pi-lens/docs/release-qa-baseline.md +84 -17
  219. package/node_modules/pi-lens/docs/rfcs/3654-unified-io-bridge-v2.md +316 -0
  220. package/node_modules/pi-lens/docs/servercapabilities.md +53 -49
  221. package/node_modules/pi-lens/docs/settings.md +1 -0
  222. package/node_modules/pi-lens/docs/usage.md +5 -1
  223. package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm +0 -0
  224. package/node_modules/pi-lens/grammars/tree-sitter-bash.wasm.json +3 -3
  225. package/node_modules/pi-lens/package.json +18 -6
  226. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/advisory-registry-subset-test.yml +41 -0
  227. package/node_modules/pi-lens/rules/ast-grep-rules/rules/advisory-registry-subset.yml +50 -0
  228. package/node_modules/pi-lens/scripts/analyze-pi-lens-logs.mjs +1275 -62
  229. package/node_modules/pi-lens/scripts/download-grammars.js +5 -0
  230. package/node_modules/pi-lens/scripts/grammars.lock.json +6 -1
  231. package/node_modules/pi-lens/scripts/install-selftest.mjs +33 -34
  232. package/node_modules/pi-lens/scripts/lib/host-provided-deps.mjs +13 -4
  233. package/node_modules/pi-lens/scripts/lib/web-tree-sitter-dir.mjs +18 -0
  234. package/node_modules/pi-typesafe/README.md +1 -1
  235. package/node_modules/pi-typesafe/dist/backends.d.ts +15 -5
  236. package/node_modules/pi-typesafe/dist/backends.js +18 -0
  237. package/node_modules/pi-typesafe/dist/client.d.ts +4 -3
  238. package/node_modules/pi-typesafe/dist/client.js +22 -7
  239. package/node_modules/pi-typesafe/dist/index.d.ts +2 -2
  240. package/node_modules/pi-typesafe/dist/index.js +1 -1
  241. package/node_modules/pi-typesafe/dist/usage.d.ts +14 -4
  242. package/node_modules/pi-typesafe/dist/usage.js +147 -42
  243. package/node_modules/pi-typesafe/package.json +1 -1
  244. package/node_modules/pi-web-access/CHANGELOG.md +48 -0
  245. package/node_modules/pi-web-access/README.md +57 -11
  246. package/node_modules/pi-web-access/brave-rate-limit.ts +8 -2
  247. package/node_modules/pi-web-access/curator-page.ts +5 -1
  248. package/node_modules/pi-web-access/curator-server.ts +2 -0
  249. package/node_modules/pi-web-access/declared-web-links.ts +2 -2
  250. package/node_modules/pi-web-access/degoog.ts +323 -0
  251. package/node_modules/pi-web-access/dist/index.js +3483 -2745
  252. package/node_modules/pi-web-access/dist/mcp-cli.js +27443 -0
  253. package/node_modules/pi-web-access/exa.ts +5 -1
  254. package/node_modules/pi-web-access/extract.ts +41 -3
  255. package/node_modules/pi-web-access/firecrawl.ts +35 -4
  256. package/node_modules/pi-web-access/gemini-search.ts +34 -11
  257. package/node_modules/pi-web-access/gemini-web.ts +14 -9
  258. package/node_modules/pi-web-access/index.ts +153 -1202
  259. package/node_modules/pi-web-access/keenable.ts +221 -0
  260. package/node_modules/pi-web-access/mcp-cli.ts +40 -0
  261. package/node_modules/pi-web-access/mcp-server.ts +81 -0
  262. package/node_modules/pi-web-access/mcp-tool-definitions.ts +117 -0
  263. package/node_modules/pi-web-access/openai-search.ts +34 -1
  264. package/node_modules/pi-web-access/package.json +23 -5
  265. package/node_modules/pi-web-access/perplexity.ts +64 -56
  266. package/node_modules/pi-web-access/tool-arguments.ts +20 -0
  267. package/node_modules/pi-web-access/video-extract.ts +1 -1
  268. package/node_modules/pi-web-access/web-tool-contract.ts +86 -0
  269. package/node_modules/pi-web-access/web-tool-core.ts +1502 -0
  270. package/node_modules/pi-web-access/youtube-extract.ts +2 -5
  271. package/package.json +17 -12
  272. package/node_modules/@dietrichgebert/ponytail/README.es.md +0 -329
  273. package/node_modules/@dietrichgebert/ponytail/README.ko.md +0 -346
  274. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-3model.svg +0 -21
  275. package/node_modules/@dietrichgebert/ponytail/assets/benchmark-agentic.svg +0 -62
  276. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.png +0 -0
  277. package/node_modules/@dietrichgebert/ponytail/assets/logo-dark.svg +0 -115
  278. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt-dark.svg +0 -27
  279. package/node_modules/@dietrichgebert/ponytail/assets/logo-greenpt.svg +0 -27
  280. package/node_modules/@dietrichgebert/ponytail/assets/logo.png +0 -0
  281. package/node_modules/@dietrichgebert/ponytail/assets/retriever-icon.png +0 -0
  282. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-dark.svg +0 -4
  283. package/node_modules/@dietrichgebert/ponytail/assets/retriever-logo-light.svg +0 -4
  284. package/node_modules/@dietrichgebert/ponytail/assets/social-preview.png +0 -0
  285. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-es.png +0 -0
  286. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner-ko.png +0 -0
  287. package/node_modules/@dietrichgebert/ponytail/assets/waitlist-banner.png +0 -0
  288. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +0 -35
  289. package/node_modules/pi-lens/dist/clients/actionable-warnings.js +0 -1610
  290. package/node_modules/pi-lens/dist/clients/advisory-provenance.js +0 -593
  291. package/node_modules/pi-lens/dist/clients/agent-behavior-client.js +0 -147
  292. package/node_modules/pi-lens/dist/clients/agent-nudge.js +0 -325
  293. package/node_modules/pi-lens/dist/clients/analysed-root.js +0 -1
  294. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +0 -731
  295. package/node_modules/pi-lens/dist/clients/ast-grep-rule-manager.js +0 -146
  296. package/node_modules/pi-lens/dist/clients/ast-grep-tool-logger.js +0 -118
  297. package/node_modules/pi-lens/dist/clients/ast-grep-types.js +0 -10
  298. package/node_modules/pi-lens/dist/clients/ast-grep-yaml-synth.js +0 -154
  299. package/node_modules/pi-lens/dist/clients/atomic-write-staging.js +0 -75
  300. package/node_modules/pi-lens/dist/clients/atomic-write.js +0 -189
  301. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +0 -12
  302. package/node_modules/pi-lens/dist/clients/bash-file-access.js +0 -1056
  303. package/node_modules/pi-lens/dist/clients/biome-client.js +0 -357
  304. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +0 -837
  305. package/node_modules/pi-lens/dist/clients/blocker-past-eof.js +0 -129
  306. package/node_modules/pi-lens/dist/clients/bootstrap.js +0 -569
  307. package/node_modules/pi-lens/dist/clients/bounded-cache.js +0 -190
  308. package/node_modules/pi-lens/dist/clients/bounded-pid-file-lock.js +0 -202
  309. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +0 -295
  310. package/node_modules/pi-lens/dist/clients/build-identity.js +0 -95
  311. package/node_modules/pi-lens/dist/clients/bundled-resource-health.js +0 -113
  312. package/node_modules/pi-lens/dist/clients/bus-events-logger.js +0 -105
  313. package/node_modules/pi-lens/dist/clients/bus-publish.js +0 -171
  314. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +0 -190
  315. package/node_modules/pi-lens/dist/clients/cache-manager.js +0 -524
  316. package/node_modules/pi-lens/dist/clients/cache-observability.js +0 -1313
  317. package/node_modules/pi-lens/dist/clients/call-graph.js +0 -852
  318. package/node_modules/pi-lens/dist/clients/cargo-manifest.js +0 -422
  319. package/node_modules/pi-lens/dist/clients/cascade-budget.js +0 -196
  320. package/node_modules/pi-lens/dist/clients/cascade-format.js +0 -315
  321. package/node_modules/pi-lens/dist/clients/cascade-logger.js +0 -31
  322. package/node_modules/pi-lens/dist/clients/cascade-types.js +0 -9
  323. package/node_modules/pi-lens/dist/clients/child-unref.js +0 -124
  324. package/node_modules/pi-lens/dist/clients/code-quality-warnings.js +0 -286
  325. package/node_modules/pi-lens/dist/clients/codebase-model.js +0 -173
  326. package/node_modules/pi-lens/dist/clients/collateral-test-role.js +0 -40
  327. package/node_modules/pi-lens/dist/clients/complexity-client.js +0 -462
  328. package/node_modules/pi-lens/dist/clients/config-core/deny.js +0 -221
  329. package/node_modules/pi-lens/dist/clients/config-core/index.js +0 -50
  330. package/node_modules/pi-lens/dist/clients/config-core/merge.js +0 -357
  331. package/node_modules/pi-lens/dist/clients/config-core/normalize.js +0 -340
  332. package/node_modules/pi-lens/dist/clients/config-core/process-spec.js +0 -248
  333. package/node_modules/pi-lens/dist/clients/config-core/provenance.js +0 -164
  334. package/node_modules/pi-lens/dist/clients/config-core/records.js +0 -218
  335. package/node_modules/pi-lens/dist/clients/config-core/resolve.js +0 -125
  336. package/node_modules/pi-lens/dist/clients/config-core/safe-object.js +0 -78
  337. package/node_modules/pi-lens/dist/clients/config-core/schema.js +0 -165
  338. package/node_modules/pi-lens/dist/clients/config-diagnostic-codes.js +0 -285
  339. package/node_modules/pi-lens/dist/clients/config-locations.js +0 -388
  340. package/node_modules/pi-lens/dist/clients/config-resolve.js +0 -798
  341. package/node_modules/pi-lens/dist/clients/config-schema.js +0 -251
  342. package/node_modules/pi-lens/dist/clients/config-warn.js +0 -407
  343. package/node_modules/pi-lens/dist/clients/console-guard-install.js +0 -26
  344. package/node_modules/pi-lens/dist/clients/cooperative-budget.js +0 -42
  345. package/node_modules/pi-lens/dist/clients/dead-code-client.js +0 -510
  346. package/node_modules/pi-lens/dist/clients/dead-code-logger.js +0 -30
  347. package/node_modules/pi-lens/dist/clients/deadline-utils.js +0 -280
  348. package/node_modules/pi-lens/dist/clients/debug-handles.js +0 -211
  349. package/node_modules/pi-lens/dist/clients/debug-heap.js +0 -174
  350. package/node_modules/pi-lens/dist/clients/deferred-lsp-work.js +0 -106
  351. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +0 -426
  352. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +0 -169
  353. package/node_modules/pi-lens/dist/clients/dependency-checker.js +0 -885
  354. package/node_modules/pi-lens/dist/clients/deps/ast-grep-napi.js +0 -22
  355. package/node_modules/pi-lens/dist/clients/deps/js-yaml.js +0 -9
  356. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +0 -4
  357. package/node_modules/pi-lens/dist/clients/deps/pi-tui.js +0 -14
  358. package/node_modules/pi-lens/dist/clients/deps/typebox.js +0 -14
  359. package/node_modules/pi-lens/dist/clients/deps/vscode-jsonrpc.js +0 -1
  360. package/node_modules/pi-lens/dist/clients/deps/web-tree-sitter.js +0 -24
  361. package/node_modules/pi-lens/dist/clients/diagnostic-dispositions.js +0 -630
  362. package/node_modules/pi-lens/dist/clients/diagnostic-line-freshness.js +0 -305
  363. package/node_modules/pi-lens/dist/clients/diagnostic-logger.js +0 -73
  364. package/node_modules/pi-lens/dist/clients/diagnostic-tracker.js +0 -105
  365. package/node_modules/pi-lens/dist/clients/diagnostics-publish.js +0 -230
  366. package/node_modules/pi-lens/dist/clients/dispatch/ast-grep-catalog.js +0 -65
  367. package/node_modules/pi-lens/dist/clients/dispatch/auxiliary-lsp.js +0 -343
  368. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +0 -35
  369. package/node_modules/pi-lens/dist/clients/dispatch/diagnostic-taxonomy.js +0 -66
  370. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +0 -1269
  371. package/node_modules/pi-lens/dist/clients/dispatch/fact-provider-types.js +0 -1
  372. package/node_modules/pi-lens/dist/clients/dispatch/fact-rule-runner.js +0 -17
  373. package/node_modules/pi-lens/dist/clients/dispatch/fact-runner.js +0 -30
  374. package/node_modules/pi-lens/dist/clients/dispatch/fact-scheduler.js +0 -68
  375. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +0 -377
  376. package/node_modules/pi-lens/dist/clients/dispatch/facts/comment-facts.js +0 -22
  377. package/node_modules/pi-lens/dist/clients/dispatch/facts/file-content.js +0 -19
  378. package/node_modules/pi-lens/dist/clients/dispatch/facts/function-facts.js +0 -348
  379. package/node_modules/pi-lens/dist/clients/dispatch/facts/import-facts.js +0 -204
  380. package/node_modules/pi-lens/dist/clients/dispatch/facts/tree-sitter-facts.js +0 -34
  381. package/node_modules/pi-lens/dist/clients/dispatch/facts/try-catch-facts.js +0 -119
  382. package/node_modules/pi-lens/dist/clients/dispatch/finding-policy.js +0 -339
  383. package/node_modules/pi-lens/dist/clients/dispatch/indent-detect.js +0 -417
  384. package/node_modules/pi-lens/dist/clients/dispatch/inline-suppressions.js +0 -89
  385. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +0 -2377
  386. package/node_modules/pi-lens/dist/clients/dispatch/lazy.js +0 -11
  387. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +0 -87
  388. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +0 -278
  389. package/node_modules/pi-lens/dist/clients/dispatch/priorities.js +0 -20
  390. package/node_modules/pi-lens/dist/clients/dispatch/rule-id-normalize.js +0 -50
  391. package/node_modules/pi-lens/dist/clients/dispatch/rule-ignores.js +0 -69
  392. package/node_modules/pi-lens/dist/clients/dispatch/rule-policy.js +0 -135
  393. package/node_modules/pi-lens/dist/clients/dispatch/rules/async-noise.js +0 -40
  394. package/node_modules/pi-lens/dist/clients/dispatch/rules/async-unnecessary-wrapper.js +0 -27
  395. package/node_modules/pi-lens/dist/clients/dispatch/rules/cors-wildcard.js +0 -54
  396. package/node_modules/pi-lens/dist/clients/dispatch/rules/error-obscuring.js +0 -32
  397. package/node_modules/pi-lens/dist/clients/dispatch/rules/error-swallowing.js +0 -31
  398. package/node_modules/pi-lens/dist/clients/dispatch/rules/framework-call-noise.js +0 -67
  399. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-complexity.js +0 -72
  400. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-fan-out.js +0 -74
  401. package/node_modules/pi-lens/dist/clients/dispatch/rules/high-import-coupling.js +0 -37
  402. package/node_modules/pi-lens/dist/clients/dispatch/rules/missing-error-propagation.js +0 -62
  403. package/node_modules/pi-lens/dist/clients/dispatch/rules/no-commented-credentials.js +0 -52
  404. package/node_modules/pi-lens/dist/clients/dispatch/rules/pass-through-wrappers.js +0 -42
  405. package/node_modules/pi-lens/dist/clients/dispatch/rules/placeholder-comments.js +0 -40
  406. package/node_modules/pi-lens/dist/clients/dispatch/rules/unsafe-boundary.js +0 -126
  407. package/node_modules/pi-lens/dist/clients/dispatch/runner-context.js +0 -52
  408. package/node_modules/pi-lens/dist/clients/dispatch/runners/actionlint.js +0 -111
  409. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +0 -1196
  410. package/node_modules/pi-lens/dist/clients/dispatch/runners/biome-check.js +0 -296
  411. package/node_modules/pi-lens/dist/clients/dispatch/runners/cpp-check.js +0 -219
  412. package/node_modules/pi-lens/dist/clients/dispatch/runners/credo.js +0 -82
  413. package/node_modules/pi-lens/dist/clients/dispatch/runners/cue-vet.js +0 -416
  414. package/node_modules/pi-lens/dist/clients/dispatch/runners/dart-analyze.js +0 -183
  415. package/node_modules/pi-lens/dist/clients/dispatch/runners/detekt.js +0 -176
  416. package/node_modules/pi-lens/dist/clients/dispatch/runners/dotnet-build.js +0 -164
  417. package/node_modules/pi-lens/dist/clients/dispatch/runners/elixir-check.js +0 -195
  418. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +0 -137
  419. package/node_modules/pi-lens/dist/clients/dispatch/runners/fact-rules.js +0 -29
  420. package/node_modules/pi-lens/dist/clients/dispatch/runners/fish-indent.js +0 -69
  421. package/node_modules/pi-lens/dist/clients/dispatch/runners/gleam-check.js +0 -167
  422. package/node_modules/pi-lens/dist/clients/dispatch/runners/go-vet.js +0 -104
  423. package/node_modules/pi-lens/dist/clients/dispatch/runners/golangci-lint.js +0 -135
  424. package/node_modules/pi-lens/dist/clients/dispatch/runners/hadolint.js +0 -91
  425. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +0 -171
  426. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +0 -940
  427. package/node_modules/pi-lens/dist/clients/dispatch/runners/htmlhint.js +0 -117
  428. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +0 -108
  429. package/node_modules/pi-lens/dist/clients/dispatch/runners/javac.js +0 -99
  430. package/node_modules/pi-lens/dist/clients/dispatch/runners/ktlint.js +0 -110
  431. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +0 -379
  432. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +0 -167
  433. package/node_modules/pi-lens/dist/clients/dispatch/runners/mypy.js +0 -102
  434. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +0 -414
  435. package/node_modules/pi-lens/dist/clients/dispatch/runners/php-lint.js +0 -85
  436. package/node_modules/pi-lens/dist/clients/dispatch/runners/phpstan.js +0 -120
  437. package/node_modules/pi-lens/dist/clients/dispatch/runners/prisma-validate.js +0 -62
  438. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +0 -445
  439. package/node_modules/pi-lens/dist/clients/dispatch/runners/pyright.js +0 -169
  440. package/node_modules/pi-lens/dist/clients/dispatch/runners/rubocop.js +0 -99
  441. package/node_modules/pi-lens/dist/clients/dispatch/runners/ruff.js +0 -88
  442. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +0 -256
  443. package/node_modules/pi-lens/dist/clients/dispatch/runners/shellcheck.js +0 -173
  444. package/node_modules/pi-lens/dist/clients/dispatch/runners/shfmt.js +0 -102
  445. package/node_modules/pi-lens/dist/clients/dispatch/runners/spellcheck.js +0 -128
  446. package/node_modules/pi-lens/dist/clients/dispatch/runners/spotbugs.js +0 -259
  447. package/node_modules/pi-lens/dist/clients/dispatch/runners/sqlfluff.js +0 -183
  448. package/node_modules/pi-lens/dist/clients/dispatch/runners/stylelint.js +0 -166
  449. package/node_modules/pi-lens/dist/clients/dispatch/runners/swiftlint.js +0 -163
  450. package/node_modules/pi-lens/dist/clients/dispatch/runners/taplo.js +0 -174
  451. package/node_modules/pi-lens/dist/clients/dispatch/runners/terragrunt.js +0 -168
  452. package/node_modules/pi-lens/dist/clients/dispatch/runners/tflint.js +0 -125
  453. package/node_modules/pi-lens/dist/clients/dispatch/runners/tree-sitter.js +0 -643
  454. package/node_modules/pi-lens/dist/clients/dispatch/runners/trivy-config.js +0 -253
  455. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/availability-policy.js +0 -496
  456. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/candidate-probe.js +0 -90
  457. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/diagnostic-parsers.js +0 -95
  458. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/lazy-installer.js +0 -274
  459. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +0 -1701
  460. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +0 -101
  461. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +0 -235
  462. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +0 -169
  463. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils.js +0 -15
  464. package/node_modules/pi-lens/dist/clients/dispatch/runners/vale.js +0 -128
  465. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +0 -242
  466. package/node_modules/pi-lens/dist/clients/dispatch/runners/yamllint.js +0 -95
  467. package/node_modules/pi-lens/dist/clients/dispatch/runners/zig-check.js +0 -79
  468. package/node_modules/pi-lens/dist/clients/dispatch/suppress-writer.js +0 -79
  469. package/node_modules/pi-lens/dist/clients/dispatch/tool-profile.js +0 -30
  470. package/node_modules/pi-lens/dist/clients/dispatch/types.js +0 -21
  471. package/node_modules/pi-lens/dist/clients/dispatch/utils/format-utils.js +0 -45
  472. package/node_modules/pi-lens/dist/clients/dispatch/utils/lsp-diagnostics.js +0 -35
  473. package/node_modules/pi-lens/dist/clients/disposition-logger.js +0 -32
  474. package/node_modules/pi-lens/dist/clients/disposition-publish.js +0 -124
  475. package/node_modules/pi-lens/dist/clients/durable-store.js +0 -97
  476. package/node_modules/pi-lens/dist/clients/effective-config.js +0 -415
  477. package/node_modules/pi-lens/dist/clients/env-utils.js +0 -83
  478. package/node_modules/pi-lens/dist/clients/error-class.js +0 -23
  479. package/node_modules/pi-lens/dist/clients/eval-timestamp.js +0 -17
  480. package/node_modules/pi-lens/dist/clients/event-loop-hold.js +0 -274
  481. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +0 -250
  482. package/node_modules/pi-lens/dist/clients/extension-log.js +0 -504
  483. package/node_modules/pi-lens/dist/clients/extension-mode.js +0 -73
  484. package/node_modules/pi-lens/dist/clients/feature-hints.js +0 -103
  485. package/node_modules/pi-lens/dist/clients/file-kinds.js +0 -412
  486. package/node_modules/pi-lens/dist/clients/file-role.js +0 -134
  487. package/node_modules/pi-lens/dist/clients/file-time.js +0 -160
  488. package/node_modules/pi-lens/dist/clients/file-utils.js +0 -1032
  489. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +0 -494
  490. package/node_modules/pi-lens/dist/clients/finding-identity.js +0 -88
  491. package/node_modules/pi-lens/dist/clients/fix-worklog.js +0 -103
  492. package/node_modules/pi-lens/dist/clients/format-events-publish.js +0 -343
  493. package/node_modules/pi-lens/dist/clients/format-service.js +0 -222
  494. package/node_modules/pi-lens/dist/clients/formatters-lazy.js +0 -9
  495. package/node_modules/pi-lens/dist/clients/formatters.js +0 -2032
  496. package/node_modules/pi-lens/dist/clients/freshness-cadence.js +0 -17
  497. package/node_modules/pi-lens/dist/clients/freshness.js +0 -38
  498. package/node_modules/pi-lens/dist/clients/generated-artifacts.js +0 -394
  499. package/node_modules/pi-lens/dist/clients/generation-guard.js +0 -215
  500. package/node_modules/pi-lens/dist/clients/git-guard.js +0 -1136
  501. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +0 -243
  502. package/node_modules/pi-lens/dist/clients/gitleaks-client.js +0 -509
  503. package/node_modules/pi-lens/dist/clients/go-client.js +0 -104
  504. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +0 -605
  505. package/node_modules/pi-lens/dist/clients/gradle-ktfmt-style.js +0 -348
  506. package/node_modules/pi-lens/dist/clients/grammar-source.js +0 -465
  507. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +0 -135
  508. package/node_modules/pi-lens/dist/clients/hashline-anchor.js +0 -424
  509. package/node_modules/pi-lens/dist/clients/hook-budgets.js +0 -76
  510. package/node_modules/pi-lens/dist/clients/host-edit-normalize.js +0 -140
  511. package/node_modules/pi-lens/dist/clients/host-ports.js +0 -39
  512. package/node_modules/pi-lens/dist/clients/indent-retarget.js +0 -154
  513. package/node_modules/pi-lens/dist/clients/inline-blocker-dispositions.js +0 -186
  514. package/node_modules/pi-lens/dist/clients/install-diagnostics.js +0 -156
  515. package/node_modules/pi-lens/dist/clients/installer/index.js +0 -5416
  516. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh-session.js +0 -58
  517. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +0 -783
  518. package/node_modules/pi-lens/dist/clients/instance-reaper-state.js +0 -7
  519. package/node_modules/pi-lens/dist/clients/instance-reaper.js +0 -1137
  520. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +0 -145
  521. package/node_modules/pi-lens/dist/clients/instance-registry.js +0 -773
  522. package/node_modules/pi-lens/dist/clients/jscpd-client.js +0 -378
  523. package/node_modules/pi-lens/dist/clients/json-cache-read.js +0 -65
  524. package/node_modules/pi-lens/dist/clients/knip-client.js +0 -788
  525. package/node_modules/pi-lens/dist/clients/language-policy.js +0 -252
  526. package/node_modules/pi-lens/dist/clients/language-profile.js +0 -361
  527. package/node_modules/pi-lens/dist/clients/language-registry.js +0 -603
  528. package/node_modules/pi-lens/dist/clients/latency-logger.js +0 -727
  529. package/node_modules/pi-lens/dist/clients/lazy-import.js +0 -21
  530. package/node_modules/pi-lens/dist/clients/ledger-bounds.js +0 -54
  531. package/node_modules/pi-lens/dist/clients/lens-config.js +0 -404
  532. package/node_modules/pi-lens/dist/clients/lens-engine.js +0 -358
  533. package/node_modules/pi-lens/dist/clients/lens-events.js +0 -125
  534. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +0 -467
  535. package/node_modules/pi-lens/dist/clients/lens-map.js +0 -1156
  536. package/node_modules/pi-lens/dist/clients/live-bus-emitter.js +0 -74
  537. package/node_modules/pi-lens/dist/clients/log-cleanup.js +0 -222
  538. package/node_modules/pi-lens/dist/clients/lsp/aggregation.js +0 -178
  539. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +0 -421
  540. package/node_modules/pi-lens/dist/clients/lsp/client.js +0 -4593
  541. package/node_modules/pi-lens/dist/clients/lsp/config.js +0 -691
  542. package/node_modules/pi-lens/dist/clients/lsp/content-limits.js +0 -41
  543. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +0 -225
  544. package/node_modules/pi-lens/dist/clients/lsp/document-drift.js +0 -467
  545. package/node_modules/pi-lens/dist/clients/lsp/edits.js +0 -1219
  546. package/node_modules/pi-lens/dist/clients/lsp/index.js +0 -8305
  547. package/node_modules/pi-lens/dist/clients/lsp/inferred-project.js +0 -216
  548. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +0 -298
  549. package/node_modules/pi-lens/dist/clients/lsp/language.js +0 -63
  550. package/node_modules/pi-lens/dist/clients/lsp/launch.js +0 -746
  551. package/node_modules/pi-lens/dist/clients/lsp/lombok.js +0 -158
  552. package/node_modules/pi-lens/dist/clients/lsp/path-utils.js +0 -5
  553. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +0 -251
  554. package/node_modules/pi-lens/dist/clients/lsp/position-encoding.js +0 -61
  555. package/node_modules/pi-lens/dist/clients/lsp/ruby-drive-dirs.js +0 -83
  556. package/node_modules/pi-lens/dist/clients/lsp/server.js +0 -3290
  557. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +0 -192
  558. package/node_modules/pi-lens/dist/clients/lsp/spawn-history.js +0 -17
  559. package/node_modules/pi-lens/dist/clients/lsp/sync-kind.js +0 -56
  560. package/node_modules/pi-lens/dist/clients/lsp/tsserver-sync.js +0 -332
  561. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/capability-snapshot.js +0 -1
  562. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/classification.js +0 -85
  563. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/index.js +0 -10
  564. package/node_modules/pi-lens/dist/clients/lsp/wait-policy/strategies.js +0 -392
  565. package/node_modules/pi-lens/dist/clients/lsp/watch-queue.js +0 -61
  566. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +0 -639
  567. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-session.js +0 -30
  568. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +0 -184
  569. package/node_modules/pi-lens/dist/clients/lsp-budget.js +0 -186
  570. package/node_modules/pi-lens/dist/clients/lsp-document-symbols.js +0 -110
  571. package/node_modules/pi-lens/dist/clients/lsp-lazy.js +0 -9
  572. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +0 -396
  573. package/node_modules/pi-lens/dist/clients/lsp-status.js +0 -59
  574. package/node_modules/pi-lens/dist/clients/map-with-concurrency.js +0 -40
  575. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +0 -420
  576. package/node_modules/pi-lens/dist/clients/mcp/host-shim.js +0 -31
  577. package/node_modules/pi-lens/dist/clients/mcp/ipc.js +0 -523
  578. package/node_modules/pi-lens/dist/clients/mcp/review.js +0 -197
  579. package/node_modules/pi-lens/dist/clients/mcp/session.js +0 -412
  580. package/node_modules/pi-lens/dist/clients/memory-sampler.js +0 -208
  581. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +0 -30
  582. package/node_modules/pi-lens/dist/clients/metrics-client.js +0 -107
  583. package/node_modules/pi-lens/dist/clients/metrics-history.js +0 -327
  584. package/node_modules/pi-lens/dist/clients/middle-man-analysis.js +0 -263
  585. package/node_modules/pi-lens/dist/clients/model-provider.js +0 -53
  586. package/node_modules/pi-lens/dist/clients/module-report-lsp.js +0 -189
  587. package/node_modules/pi-lens/dist/clients/module-report.js +0 -2150
  588. package/node_modules/pi-lens/dist/clients/mutating-tool.js +0 -651
  589. package/node_modules/pi-lens/dist/clients/mutation-attribution.js +0 -368
  590. package/node_modules/pi-lens/dist/clients/mutation-bridge.js +0 -240
  591. package/node_modules/pi-lens/dist/clients/ndjson-logger.js +0 -734
  592. package/node_modules/pi-lens/dist/clients/observed-mutation-sources.js +0 -101
  593. package/node_modules/pi-lens/dist/clients/observed-mutation.js +0 -1253
  594. package/node_modules/pi-lens/dist/clients/oldtext-autopatch.js +0 -128
  595. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +0 -496
  596. package/node_modules/pi-lens/dist/clients/opengrep-client.js +0 -333
  597. package/node_modules/pi-lens/dist/clients/opengrep-config.js +0 -76
  598. package/node_modules/pi-lens/dist/clients/package-manager.js +0 -607
  599. package/node_modules/pi-lens/dist/clients/package-root.js +0 -38
  600. package/node_modules/pi-lens/dist/clients/partial-edit-apply.js +0 -439
  601. package/node_modules/pi-lens/dist/clients/path-attribution-telemetry.js +0 -43
  602. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +0 -108
  603. package/node_modules/pi-lens/dist/clients/path-utils.js +0 -1244
  604. package/node_modules/pi-lens/dist/clients/performance-report.js +0 -331
  605. package/node_modules/pi-lens/dist/clients/persist-debounce.js +0 -70
  606. package/node_modules/pi-lens/dist/clients/persistent-reverify.js +0 -271
  607. package/node_modules/pi-lens/dist/clients/php-cs-fixer-config.js +0 -119
  608. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -1480
  609. package/node_modules/pi-lens/dist/clients/post-autofix-notice.js +0 -37
  610. package/node_modules/pi-lens/dist/clients/print-mode.js +0 -21
  611. package/node_modules/pi-lens/dist/clients/probe-home-state.js +0 -228
  612. package/node_modules/pi-lens/dist/clients/process-bridge.js +0 -66
  613. package/node_modules/pi-lens/dist/clients/process-singletons.js +0 -210
  614. package/node_modules/pi-lens/dist/clients/process-snapshot.js +0 -68
  615. package/node_modules/pi-lens/dist/clients/project-changes.js +0 -181
  616. package/node_modules/pi-lens/dist/clients/project-conventions.js +0 -171
  617. package/node_modules/pi-lens/dist/clients/project-diagnostics/cache.js +0 -137
  618. package/node_modules/pi-lens/dist/clients/project-diagnostics/extractors.js +0 -109
  619. package/node_modules/pi-lens/dist/clients/project-diagnostics/fresh-fetch.js +0 -668
  620. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/call-graph-impact.js +0 -118
  621. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/dead-code.js +0 -47
  622. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/gitleaks.js +0 -40
  623. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/govulncheck.js +0 -34
  624. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/jscpd.js +0 -38
  625. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/knip.js +0 -47
  626. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +0 -48
  627. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/opengrep.js +0 -40
  628. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/runner-findings.js +0 -127
  629. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/trivy.js +0 -81
  630. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +0 -520
  631. package/node_modules/pi-lens/dist/clients/project-diagnostics/types.js +0 -1
  632. package/node_modules/pi-lens/dist/clients/project-lens-config.js +0 -807
  633. package/node_modules/pi-lens/dist/clients/project-report.js +0 -701
  634. package/node_modules/pi-lens/dist/clients/project-scale.js +0 -237
  635. package/node_modules/pi-lens/dist/clients/project-scan-policy.js +0 -11
  636. package/node_modules/pi-lens/dist/clients/project-snapshot-fingerprint.js +0 -48
  637. package/node_modules/pi-lens/dist/clients/project-snapshot-persist-worker.js +0 -20
  638. package/node_modules/pi-lens/dist/clients/project-snapshot.js +0 -1653
  639. package/node_modules/pi-lens/dist/clients/project-trust.js +0 -181
  640. package/node_modules/pi-lens/dist/clients/python-environment.js +0 -217
  641. package/node_modules/pi-lens/dist/clients/python-provenance.js +0 -639
  642. package/node_modules/pi-lens/dist/clients/quiet-window-config.js +0 -47
  643. package/node_modules/pi-lens/dist/clients/quiet-window.js +0 -236
  644. package/node_modules/pi-lens/dist/clients/read-bridge.js +0 -139
  645. package/node_modules/pi-lens/dist/clients/read-expansion.js +0 -266
  646. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +0 -251
  647. package/node_modules/pi-lens/dist/clients/read-guard-tool-lines.js +0 -1306
  648. package/node_modules/pi-lens/dist/clients/read-guard.js +0 -1561
  649. package/node_modules/pi-lens/dist/clients/recent-touches.js +0 -240
  650. package/node_modules/pi-lens/dist/clients/redact/secrets.js +0 -449
  651. package/node_modules/pi-lens/dist/clients/resource-sampler.js +0 -628
  652. package/node_modules/pi-lens/dist/clients/reverse-deps.js +0 -270
  653. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +0 -5126
  654. package/node_modules/pi-lens/dist/clients/review-graph/format.js +0 -33
  655. package/node_modules/pi-lens/dist/clients/review-graph/git-identity.js +0 -163
  656. package/node_modules/pi-lens/dist/clients/review-graph/import-resolvers.js +0 -481
  657. package/node_modules/pi-lens/dist/clients/review-graph/persist-worker.js +0 -10
  658. package/node_modules/pi-lens/dist/clients/review-graph/query.js +0 -244
  659. package/node_modules/pi-lens/dist/clients/review-graph/revision-drift.js +0 -21
  660. package/node_modules/pi-lens/dist/clients/review-graph/service.js +0 -96
  661. package/node_modules/pi-lens/dist/clients/review-graph/shared-extraction-ir.js +0 -82
  662. package/node_modules/pi-lens/dist/clients/review-graph/symbol-id.js +0 -103
  663. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +0 -299
  664. package/node_modules/pi-lens/dist/clients/review-graph/types.js +0 -1
  665. package/node_modules/pi-lens/dist/clients/review-graph/workspace-modules.js +0 -449
  666. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +0 -81
  667. package/node_modules/pi-lens/dist/clients/ruff-client.js +0 -179
  668. package/node_modules/pi-lens/dist/clients/rules-scanner.js +0 -131
  669. package/node_modules/pi-lens/dist/clients/run-duration.js +0 -55
  670. package/node_modules/pi-lens/dist/clients/runtime-agent-end.js +0 -746
  671. package/node_modules/pi-lens/dist/clients/runtime-config.js +0 -77
  672. package/node_modules/pi-lens/dist/clients/runtime-context.js +0 -211
  673. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +0 -1324
  674. package/node_modules/pi-lens/dist/clients/runtime-session.js +0 -2485
  675. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +0 -1295
  676. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +0 -2049
  677. package/node_modules/pi-lens/dist/clients/runtime-turn.js +0 -3767
  678. package/node_modules/pi-lens/dist/clients/rust-client.js +0 -97
  679. package/node_modules/pi-lens/dist/clients/safe-spawn.js +0 -2357
  680. package/node_modules/pi-lens/dist/clients/sanitize.js +0 -286
  681. package/node_modules/pi-lens/dist/clients/scan-utils.js +0 -25
  682. package/node_modules/pi-lens/dist/clients/scratch-tree-policy.js +0 -165
  683. package/node_modules/pi-lens/dist/clients/search-read-registration.js +0 -94
  684. package/node_modules/pi-lens/dist/clients/secret-findings.js +0 -114
  685. package/node_modules/pi-lens/dist/clients/security-scan-client.js +0 -352
  686. package/node_modules/pi-lens/dist/clients/session-event-guard.js +0 -297
  687. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +0 -484
  688. package/node_modules/pi-lens/dist/clients/session-start-observability.js +0 -94
  689. package/node_modules/pi-lens/dist/clients/session-state-store.js +0 -101
  690. package/node_modules/pi-lens/dist/clients/session-summary.js +0 -15
  691. package/node_modules/pi-lens/dist/clients/sessionstart-logger.js +0 -27
  692. package/node_modules/pi-lens/dist/clients/sg-runner.js +0 -876
  693. package/node_modules/pi-lens/dist/clients/sgconfig.js +0 -347
  694. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +0 -338
  695. package/node_modules/pi-lens/dist/clients/single-flight.js +0 -158
  696. package/node_modules/pi-lens/dist/clients/situational-tool-telemetry.js +0 -108
  697. package/node_modules/pi-lens/dist/clients/skills-resolver.js +0 -141
  698. package/node_modules/pi-lens/dist/clients/slow-fs.js +0 -138
  699. package/node_modules/pi-lens/dist/clients/smells-rollup.js +0 -231
  700. package/node_modules/pi-lens/dist/clients/source-filter.js +0 -596
  701. package/node_modules/pi-lens/dist/clients/source-walker.js +0 -211
  702. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +0 -120
  703. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +0 -110
  704. package/node_modules/pi-lens/dist/clients/stale-marker.js +0 -17
  705. package/node_modules/pi-lens/dist/clients/startup-scan.js +0 -400
  706. package/node_modules/pi-lens/dist/clients/startup-timing.js +0 -130
  707. package/node_modules/pi-lens/dist/clients/string-utils.js +0 -33
  708. package/node_modules/pi-lens/dist/clients/subagent-mode.js +0 -111
  709. package/node_modules/pi-lens/dist/clients/symbol-containment.js +0 -24
  710. package/node_modules/pi-lens/dist/clients/symbol-types.js +0 -5
  711. package/node_modules/pi-lens/dist/clients/test-runner-client.js +0 -2507
  712. package/node_modules/pi-lens/dist/clients/test-runner-delivery.js +0 -287
  713. package/node_modules/pi-lens/dist/clients/todo-scanner.js +0 -229
  714. package/node_modules/pi-lens/dist/clients/tool-agreement.js +0 -423
  715. package/node_modules/pi-lens/dist/clients/tool-config.js +0 -249
  716. package/node_modules/pi-lens/dist/clients/tool-cwd.js +0 -270
  717. package/node_modules/pi-lens/dist/clients/tool-definition.js +0 -47
  718. package/node_modules/pi-lens/dist/clients/tool-event.js +0 -105
  719. package/node_modules/pi-lens/dist/clients/tool-policy.js +0 -2755
  720. package/node_modules/pi-lens/dist/clients/tool-probe.js +0 -53
  721. package/node_modules/pi-lens/dist/clients/tool-render.js +0 -156
  722. package/node_modules/pi-lens/dist/clients/tool-set-policy.js +0 -118
  723. package/node_modules/pi-lens/dist/clients/tree-sitter-cache.js +0 -364
  724. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +0 -4195
  725. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +0 -100
  726. package/node_modules/pi-lens/dist/clients/tree-sitter-navigator.js +0 -269
  727. package/node_modules/pi-lens/dist/clients/tree-sitter-query-loader.js +0 -569
  728. package/node_modules/pi-lens/dist/clients/tree-sitter-shared.js +0 -158
  729. package/node_modules/pi-lens/dist/clients/tree-sitter-symbol-extractor.js +0 -1265
  730. package/node_modules/pi-lens/dist/clients/trivy-client.js +0 -467
  731. package/node_modules/pi-lens/dist/clients/tui-fit.js +0 -50
  732. package/node_modules/pi-lens/dist/clients/turn-context.js +0 -52
  733. package/node_modules/pi-lens/dist/clients/turn-end/lane.js +0 -51
  734. package/node_modules/pi-lens/dist/clients/turn-end/lanes/govulncheck.js +0 -112
  735. package/node_modules/pi-lens/dist/clients/turn-end/lanes/secrets.js +0 -204
  736. package/node_modules/pi-lens/dist/clients/turn-summary-render.js +0 -72
  737. package/node_modules/pi-lens/dist/clients/turn-summary.js +0 -132
  738. package/node_modules/pi-lens/dist/clients/typos-config.js +0 -29
  739. package/node_modules/pi-lens/dist/clients/user-notify.js +0 -51
  740. package/node_modules/pi-lens/dist/clients/vanished-instance-marker.js +0 -57
  741. package/node_modules/pi-lens/dist/clients/warm-attach.js +0 -305
  742. package/node_modules/pi-lens/dist/clients/widget-state.js +0 -1808
  743. package/node_modules/pi-lens/dist/clients/word-index-logger.js +0 -49
  744. package/node_modules/pi-lens/dist/clients/word-index-store.js +0 -459
  745. package/node_modules/pi-lens/dist/clients/word-index.js +0 -2080
  746. package/node_modules/pi-lens/dist/clients/workspace-topology.js +0 -443
  747. package/node_modules/pi-lens/dist/clients/write-ordering-guard.js +0 -65
  748. package/node_modules/pi-lens/dist/clients/zizmor-config.js +0 -340
  749. package/node_modules/pi-lens/dist/i18n.js +0 -69
  750. package/node_modules/pi-lens/dist/mcp/build-staleness.js +0 -124
  751. package/node_modules/pi-lens/dist/scripts/lib/process-scan.mjs +0 -583
  752. package/node_modules/pi-lens/dist/scripts/lib/skills-predicate.mjs +0 -129
  753. package/node_modules/pi-lens/dist/scripts/lib/web-tree-sitter-dir.mjs +0 -123
  754. package/node_modules/pi-lens/dist/tools/activate-tools.js +0 -87
  755. package/node_modules/pi-lens/dist/tools/ast-grep-outline.js +0 -137
  756. package/node_modules/pi-lens/dist/tools/ast-grep-replace.js +0 -244
  757. package/node_modules/pi-lens/dist/tools/ast-grep-search.js +0 -762
  758. package/node_modules/pi-lens/dist/tools/effective-config.js +0 -90
  759. package/node_modules/pi-lens/dist/tools/lens-diagnostic-mark.js +0 -375
  760. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +0 -2571
  761. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +0 -1745
  762. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +0 -1367
  763. package/node_modules/pi-lens/dist/tools/lsp-structured-output.js +0 -294
  764. package/node_modules/pi-lens/dist/tools/module-report.js +0 -382
  765. package/node_modules/pi-lens/dist/tools/project-report.js +0 -86
  766. package/node_modules/pi-lens/dist/tools/render-compact.js +0 -374
  767. package/node_modules/pi-lens/dist/tools/scan-progress.js +0 -53
  768. package/node_modules/pi-lens/dist/tools/shared.js +0 -29
  769. package/node_modules/pi-lens/dist/tools/symbol-search.js +0 -155
@@ -1,2357 +0,0 @@
1
- /**
2
- * Safe cross-platform spawn utilities
3
- *
4
- * Provides both sync (deprecated) and async versions for gradual migration.
5
- *
6
- * Async version features:
7
- * - Non-blocking execution
8
- * - Proper process cleanup on timeout (no zombies)
9
- * - Batch execution with concurrency limits
10
- * - AbortSignal support for cancellation
11
- *
12
- * Migration guide:
13
- * - Change: safeSpawn(cmd, args, opts)
14
- * - To: await safeSpawnAsync(cmd, args, opts)
15
- */
16
- import { spawn, spawnSync, } from "node:child_process";
17
- import * as fs from "node:fs";
18
- import * as path from "node:path";
19
- import { BoundedFifoMap, BoundedSet } from "./bounded-cache.js";
20
- import { logLatency } from "./latency-logger.js";
21
- import { incrementDegradationCount, recordDegradation, recordDegradationOnce, } from "./degradation-ledger.js";
22
- import { logExtension } from "./extension-log.js";
23
- import { isFullyQualifiedWin32 } from "./path-utils.js";
24
- import { startSpawnUsageSampler } from "./resource-sampler.js";
25
- import { DEFAULT_MAX_OUTPUT_BYTES } from "./spawn-output-cap.js";
26
- import { compareOrdinal } from "./string-utils.js";
27
- /** Intent-level spawn failure. `cause` retains the original OS Error/errno. */
28
- export class SpawnFailureError extends Error {
29
- kind;
30
- cause;
31
- name = "SpawnFailureError";
32
- constructor(kind, message, cause) {
33
- super(message, { cause });
34
- this.kind = kind;
35
- this.cause = cause;
36
- }
37
- }
38
- export function hasSpawnFailureKind(error, kind) {
39
- return (error instanceof Error &&
40
- "kind" in error &&
41
- error.kind === kind);
42
- }
43
- /**
44
- * #2010: derive teardown evidence from observed timestamps.
45
- *
46
- * Pure by design so every branch is deterministically unit-testable,
47
- * including the natural-exit-at-budget-expiry race where the timeout
48
- * callback arms a kill against an already-dead child.
49
- *
50
- * - No recorded kill start (the guard saw the child already dying), no
51
- * observed death at all, or death PREDATING the kill start: the child
52
- * left on its own -> "exited", ms 0. Claiming a kill we cannot
53
- * distinguish from a natural exit would be invented evidence.
54
- * - Death observed after the kill started: "escalate-kill" when the
55
- * SIGKILL escalation fired, otherwise "killed-by-signal"; ms is the
56
- * observed gap (floor 0 - clock jitter must never produce a negative).
57
- */
58
- export function resolveTeardownOutcome(input) {
59
- // Another path (abort / output-limit) owns this death: emitting teardown
60
- // evidence here would describe a kill this timeout didn't perform.
61
- if (input.killStartedAtMs === undefined ||
62
- input.deathObservedAtMs === undefined) {
63
- return undefined;
64
- }
65
- // True natural-exit race: the child beat the budget-expired kill by a
66
- // hair - death observed before (or exactly at) the kill start.
67
- if (input.deathObservedAtMs <= input.killStartedAtMs) {
68
- return { ms: 0, outcome: "exited" };
69
- }
70
- return {
71
- ms: Math.max(0, input.deathObservedAtMs - input.killStartedAtMs),
72
- outcome: input.escalated ? "escalate-kill" : "killed-by-signal",
73
- };
74
- }
75
- function toError(error) {
76
- return error instanceof Error ? error : new Error(String(error));
77
- }
78
- function errorCode(error) {
79
- return error.code;
80
- }
81
- async function cwdIsUnresolvable(cwd) {
82
- if (cwd === undefined)
83
- return false;
84
- try {
85
- return !(await fs.promises.stat(cwd)).isDirectory();
86
- }
87
- catch {
88
- return true;
89
- }
90
- }
91
- function cwdIsUnresolvableSync(cwd) {
92
- if (cwd === undefined)
93
- return false;
94
- try {
95
- return !fs.statSync(cwd).isDirectory();
96
- }
97
- catch {
98
- return true;
99
- }
100
- }
101
- /**
102
- * Best-effort presence probe used ONLY to disambiguate ENOENT when the cwd is
103
- * ALSO unresolvable (#1340 review): a genuinely missing tool must classify as
104
- * tool-not-found even under a broken cwd, or auto-install can never repair it.
105
- * Absolute commands are probed directly (with PATHEXT variants on Windows);
106
- * bare names scan PATH. A relative-with-separator command under a broken cwd
107
- * is genuinely ambiguous -- we err toward cwd-unresolvable there, because
108
- * repairing the cwd is actionable while a reinstall loop (#1199) is not.
109
- */
110
- function commandProbablyPresent(command) {
111
- const exts = process.platform === "win32"
112
- ? [
113
- "",
114
- ...(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
115
- .split(";")
116
- .filter(Boolean),
117
- ]
118
- : [""];
119
- const existsWithExt = (base) => {
120
- for (const ext of exts) {
121
- try {
122
- if (fs.existsSync(base + ext))
123
- return true;
124
- }
125
- catch {
126
- // unreadable candidate -- keep probing
127
- }
128
- }
129
- return false;
130
- };
131
- if (path.isAbsolute(command))
132
- return existsWithExt(command);
133
- if (command.includes("/") || command.includes("\\"))
134
- return "ambiguous";
135
- for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
136
- if (dir && existsWithExt(path.join(dir, command)))
137
- return true;
138
- }
139
- return false;
140
- }
141
- /**
142
- * Shared errno->bucket mapping. `cwdUnresolvable` is the only async-vs-sync
143
- * difference between the two public classifiers, so it arrives as a resolved
144
- * flag and everything else lives once (Sonar duplication finding, #1340).
145
- */
146
- function classifyWithCwdFlag(cause, options, cwdUnresolvable) {
147
- const code = errorCode(cause);
148
- let failure;
149
- if (code === "ENOENT" &&
150
- cwdUnresolvable &&
151
- commandProbablyPresent(options.command) !== false) {
152
- failure = new SpawnFailureError("cwd-unresolvable", `Cannot spawn ${options.command}: working directory is unresolvable (${options.cwd})`, cause);
153
- }
154
- else if (code === "ENOENT") {
155
- failure = new SpawnFailureError("tool-not-found", `Cannot spawn ${options.command}: tool not found (${cause.message})`, cause);
156
- }
157
- else if (code === "EACCES" || code === "EPERM") {
158
- failure = new SpawnFailureError("permission-denied", `Cannot spawn ${options.command}: permission denied`, cause);
159
- }
160
- else {
161
- failure = new SpawnFailureError("spawn-failed", `Cannot spawn ${options.command}: ${cause.message}`, cause);
162
- }
163
- recordSpawnClassification(failure, options);
164
- return failure;
165
- }
166
- const loggedSpawnClassifications = new Set();
167
- const SPAWN_CLASSIFICATION_LOG_CAP = 200;
168
- function recordSpawnClassification(failure, options) {
169
- const pair = `${failure.kind}\0${options.command}`;
170
- if (!loggedSpawnClassifications.has(pair)) {
171
- if (loggedSpawnClassifications.size >= SPAWN_CLASSIFICATION_LOG_CAP) {
172
- loggedSpawnClassifications.clear();
173
- }
174
- loggedSpawnClassifications.add(pair);
175
- logExtension({
176
- subsystem: "safe-spawn",
177
- level: "debug",
178
- message: "spawn failure classified",
179
- metadata: {
180
- kind: failure.kind,
181
- command: options.command,
182
- cwd: options.cwd,
183
- },
184
- });
185
- }
186
- if (failure.kind !== "tool-not-found") {
187
- recordDegradation({
188
- kind: "spawn-failure",
189
- subject: options.command,
190
- reason: `${failure.kind}${options.cwd ? ` in ${options.cwd}` : ""}`,
191
- });
192
- }
193
- }
194
- /** Classify a raw Node spawn error without discarding its errno-bearing Error. */
195
- export async function classifySpawnFailure(error, options) {
196
- const cause = toError(error);
197
- const needsCwdProbe = errorCode(cause) === "ENOENT";
198
- const cwdUnresolvable = needsCwdProbe && (await cwdIsUnresolvable(options.cwd));
199
- return classifyWithCwdFlag(cause, options, cwdUnresolvable);
200
- }
201
- function classifySpawnFailureSync(error, options) {
202
- const cause = toError(error);
203
- const needsCwdProbe = errorCode(cause) === "ENOENT";
204
- const cwdUnresolvable = needsCwdProbe && cwdIsUnresolvableSync(options.cwd);
205
- return classifyWithCwdFlag(cause, options, cwdUnresolvable);
206
- }
207
- // Vitest reloads modules inside a reused worker. Keep this registry on the
208
- // process so those module instances share one signal/exit listener set.
209
- const lifetimeStateKey = Symbol.for("pi-lens.safe-spawn.lifetime-state");
210
- const processWithLifetimeState = process;
211
- const lifetimeState = processWithLifetimeState[lifetimeStateKey] ??
212
- (processWithLifetimeState[lifetimeStateKey] = {
213
- pids: new Set(),
214
- installed: false,
215
- });
216
- /**
217
- * THE ownership predicate for every kill-by-raw-pid in this repo (#2042).
218
- *
219
- * A pid is signalable only when it is a LIVE DIRECT CHILD of this process.
220
- * `tests/clients/lsp/launch.test.ts` mocked `node:child_process` with a fake
221
- * child carrying the literal pid `2468`; `safeSpawnAsync` registered it in
222
- * `lifetimeState.pids`, the fake never emitted `close` so nothing removed it,
223
- * and at fork teardown `installLifetimeCleanup()` fired
224
- * `process.kill(-2468, "SIGKILL")`. On ~10 % of GitHub runners pid 2468 was
225
- * one of the job's own long-lived processes — that is the whole of #2042:
226
- * exit 137, no failing assertion, no kernel record, five weeks of "infra
227
- * kill" reruns. A `pid <= 0` guard would not have stopped it; 2468 is a
228
- * perfectly plausible pid. The defect is OWNERSHIP, not sign.
229
- *
230
- * Three guards fold into this one (net-count rule): `lsp/client.ts`'s
231
- * `pid <= 0`, `lsp/launch.ts`'s `handle.pid <= 0` plus its already-exited
232
- * recycled-pid check, and `killTree`'s `child.pid > 0` below. It closes
233
- * fabricated, stale and recycled pids together.
234
- *
235
- * NOT folded: `instance-reaper.ts#killPidTree`. Its pids come from OTHER
236
- * pi-lens instances' registries and from an OS scan of managed binaries with
237
- * confirmed-dead parents — deliberately NOT our children. Its `!isFinite ||
238
- * pid <= 0` guard is well-formedness on a different axis, and applying this
239
- * predicate there would turn the orphan reaper into a no-op.
240
- *
241
- * Platform behaviour, stated rather than implied:
242
- * - Linux (the authoritative lane, and the one that lost five weeks):
243
- * `/proc/<pid>/status` is the kernel's own answer. Missing entry = the pid
244
- * does not exist, so there is nothing of ours to kill; `PPid` other than
245
- * ours = someone else's process, which is recorded and never signalled.
246
- * - Windows and macOS have no `/proc` (and neither does a Linux host with
247
- * `/proc` unmounted — probed once, not assumed from `process.platform`),
248
- * so ownership is unverifiable from a raw pid and the predicate keeps
249
- * today's best-effort behaviour. The `handle` arm applies THERE and only
250
- * there: it is the only ownership evidence Windows has, and it is what
251
- * `killWindowsTree` already used. It must not gate the POSIX group kill,
252
- * which legitimately runs after its leader has died (#3091 F1).
253
- */
254
- /**
255
- * Whether `/proc/<pid>/status` can actually be read here, probed ONCE against
256
- * this process's own entry (#3091 F4). `process.platform === "linux"` is not
257
- * the same question: a container or a hardened host can run Linux with no
258
- * `/proc` mounted, and there the per-pid read fails exactly the way a dead pid
259
- * does. Without this probe that silently refused every registration, so
260
- * `lifetimeState.pids` stayed empty and the host-exit tree kill stopped working
261
- * with no record anywhere.
262
- */
263
- const PROC_PPID_READABLE = (() => {
264
- if (process.platform !== "linux")
265
- return false;
266
- try {
267
- return /^PPid:\s*\d+$/m.test(fs.readFileSync("/proc/self/status", "utf8"));
268
- }
269
- catch {
270
- return false;
271
- }
272
- })();
273
- /**
274
- * Pids this process has PROVEN, from the kernel, to be its own live children.
275
- * FIFO-bounded (`BoundedFifoMap`, not a wholesale clear): a process that spawns
276
- * more than the cap drops its OLDEST verdicts, where a wholesale clear would
277
- * drop the one it is about to need.
278
- */
279
- export const VERIFIED_OWN_PID_CAP = 512;
280
- const verifiedOwnPids = new BoundedFifoMap(VERIFIED_OWN_PID_CAP);
281
- /**
282
- * Pids whose OWNERSHIP IS HELD for the lifetime of a long-lived resource —
283
- * today, the LSP children `clients/lsp/launch.ts` spawns with `nodeSpawn`
284
- * (#3091 F1-r2b).
285
- *
286
- * These deliberately do NOT live in `verifiedOwnPids`. Age is the wrong
287
- * retirement axis for a resource-scoped verdict: an LSP leader is verified once
288
- * at spawn and then sits untouched for the whole session, so in a FIFO that
289
- * every `safeSpawnAsync` writes to it is the OLDEST entry and therefore the
290
- * FIRST evicted — measured, 520 later verdicts were enough — and its group kill
291
- * at host exit is refused precisely because the server was long-lived enough to
292
- * matter. Retirement here is by RESOURCE STATE instead: `releaseOwnChildPid`
293
- * when the shutdown ladder is done with the pid, plus the confirmed-dead sweep
294
- * below for the paths that never reach a ladder (a server that crashes, or a
295
- * host killed mid-session).
296
- *
297
- * The bound that remains, stated rather than implied: at most
298
- * `HELD_OWN_PID_SWEEP_AT` entries — the confirmed-dead sweep below runs before
299
- * every insert, and `BoundedSet`'s own FIFO overflow is the last resort for the
300
- * case where every held group really is still alive. A crash-looping server cannot grow it without bound
301
- * because each sweep drops every pid whose process GROUP no longer exists,
302
- * which is exactly the condition under which the entry can never be needed
303
- * again.
304
- */
305
- const HELD_OWN_PID_SWEEP_AT = 256;
306
- const heldOwnPids = new BoundedSet(HELD_OWN_PID_SWEEP_AT);
307
- /** A process group that no longer exists can never need another signal. */
308
- function groupIsGone(pid) {
309
- try {
310
- process.kill(-pid, 0);
311
- return false;
312
- }
313
- catch (error) {
314
- return errorCode(error) === "ESRCH";
315
- }
316
- }
317
- function sweepHeldOwnPids() {
318
- if (heldOwnPids.size < HELD_OWN_PID_SWEEP_AT)
319
- return;
320
- // Deleting the current entry mid-iteration is well-defined for a Set, so
321
- // no snapshot copy is needed.
322
- for (const pid of heldOwnPids) {
323
- if (groupIsGone(pid))
324
- heldOwnPids.delete(pid);
325
- }
326
- // No hand-rolled "drop the oldest" block (#2442 / #3091 round 4): the
327
- // last-resort eviction, for the case where every held group is still alive
328
- // at the ceiling, is `BoundedSet.add`'s own FIFO overflow.
329
- }
330
- /**
331
- * Verify a pid at ADMISSION and hold the verdict for the resource's lifetime.
332
- *
333
- * This is AGENTS.md shape 50's own rule, which #3091 round 1 broke at the site
334
- * it was fixing: `isOwnLiveChild`'s three call sites were the `safeSpawnAsync`
335
- * registration, the Windows tree kill, and the SHUTDOWN group kill — so for an
336
- * LSP child, spawned with `nodeSpawn`, the first offer of verification was also
337
- * the last, and on the `processExiting` path the leader could already be dead
338
- * by then (`/proc` gone, nothing on record, signal refused, grandchildren
339
- * orphaned).
340
- */
341
- export function holdOwnChildPid(pid, site) {
342
- const owned = isOwnLiveChild(pid, site);
343
- if (!owned || !PROC_PPID_READABLE || typeof pid !== "number")
344
- return owned;
345
- // Exactly one home: the verification above filed the verdict in the
346
- // age-bounded FIFO, and leaving a copy there would make BOTH the held set
347
- // and its release mutation-inert — the FIFO copy would answer for them.
348
- verifiedOwnPids.delete(pid);
349
- sweepHeldOwnPids();
350
- heldOwnPids.add(pid);
351
- return true;
352
- }
353
- /**
354
- * Retire a held pid once nothing will signal it again — the end of the
355
- * shutdown ladder. Idempotent.
356
- */
357
- export function releaseOwnChildPid(pid) {
358
- if (typeof pid === "number")
359
- heldOwnPids.delete(pid);
360
- }
361
- /** Once per session: a Linux host whose `/proc` cannot answer the question. */
362
- function recordProcUnavailable(site) {
363
- if (process.platform !== "linux")
364
- return;
365
- recordDegradationOnce({
366
- kind: "kill-ownership-unverifiable",
367
- subject: site,
368
- reason: "/proc/self/status is unreadable on this Linux host; kill-by-pid ownership falls back to best-effort",
369
- });
370
- }
371
- export function isOwnLiveChild(pid, site, handle) {
372
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
373
- return false;
374
- if (!PROC_PPID_READABLE) {
375
- // No `/proc` to consult. A handle that has already reported exit is then
376
- // the ONLY ownership evidence available, and it is exactly what
377
- // `killWindowsTree` used before this predicate existed: a dead pid may
378
- // have been recycled, and `taskkill /F /T` on a recycled pid destroys an
379
- // unrelated tree. It overrides the memo below deliberately.
380
- if (handle && (handle.exitCode != null || handle.signalCode != null))
381
- return false;
382
- recordProcUnavailable(site);
383
- return true;
384
- }
385
- let status;
386
- try {
387
- status = fs.readFileSync(`/proc/${pid}/status`, "utf8");
388
- }
389
- catch {
390
- // The pid does not exist. Not a degradation — a child that exited before
391
- // the teardown signal is the ordinary case — but not automatically a
392
- // refusal either: a POSIX process GROUP outlives its leader, and #2026's
393
- // whole point is that the escalation must still reach a SIGTERM-hardy
394
- // grandchild after the direct child has died. Ownership verified while
395
- // the leader was alive is what answers that; a pid this process never
396
- // owned is still refused.
397
- return heldOwnPids.has(pid) || verifiedOwnPids.has(pid);
398
- }
399
- const parent = /^PPid:\s*(\d+)$/m.exec(status);
400
- if (!parent)
401
- return true;
402
- if (Number(parent[1]) === process.pid) {
403
- // Memoized only on a KERNEL-verified verdict, and only here: a pid we
404
- // never proved was ours can never enter the memo, and a pid that is
405
- // alive under a different parent is refused above regardless of what the
406
- // memo holds — so a recycled pid cannot be signalled on the strength of
407
- // its previous owner.
408
- //
409
- // Never for a pid already HELD, though: the shutdown ladder verifies a
410
- // still-live leader on its way to the group signal, and filing that
411
- // verdict in the FIFO too would put a held pid back in both stores —
412
- // which outlives `releaseOwnChildPid` (the FIFO copy would keep
413
- // answering for a pid this process has explicitly stopped claiming) and
414
- // makes the held store's own tests unable to fail.
415
- if (!heldOwnPids.has(pid))
416
- verifiedOwnPids.set(pid, true);
417
- return true;
418
- }
419
- // Alive AND someone else's: the dangerous case, and the only one worth a
420
- // record. Subject is the SITE (a fixed, tiny set), never the pid, so the
421
- // ledger stays bounded however often it fires.
422
- incrementDegradationCount({
423
- kind: "kill-foreign-pid-refused",
424
- subject: site,
425
- reason: `pid ${pid} has parent ${parent[1]}, not this process — signal refused`,
426
- });
427
- return false;
428
- }
429
- /**
430
- * Kill one registered pid's tree at host exit. Deliberately NOT re-checked
431
- * against `isOwnLiveChild` here: `lifetimeState.pids` has exactly one writer
432
- * (the registration below), that writer is now gated, and a second gate on
433
- * the same feeder would make BOTH mutation-inert — remove either one and the
434
- * other still blocks the kill, so neither would have a test that reds. The
435
- * gate lives where the pid enters, which is also where ownership is a fact
436
- * rather than a stale reading (#2042).
437
- */
438
- function killPidTreeSync(pid) {
439
- if (process.platform === "win32") {
440
- try {
441
- const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
442
- spawnSync(taskkill, ["/F", "/T", "/PID", String(pid)], {
443
- shell: false,
444
- windowsHide: true,
445
- stdio: "ignore",
446
- });
447
- }
448
- catch {
449
- // Runs from process `exit`/signal handlers — a SYNCHRONOUS spawn throw
450
- // (Windows `spawn UNKNOWN`/EINVAL, the pidusage bug class, #533) would
451
- // become an uncaughtException during shutdown. Best-effort tree-kill:
452
- // swallow it, mirroring the already-guarded POSIX branch below.
453
- }
454
- return;
455
- }
456
- try {
457
- // #2026: negative pid = whole process group (POSIX children spawn
458
- // detached), reaching grandchildren too. Any failure (ESRCH = already
459
- // gone, EPERM = raced) falls through to the direct-child attempt.
460
- try {
461
- process.kill(-pid, "SIGKILL");
462
- }
463
- catch {
464
- process.kill(pid, "SIGKILL");
465
- }
466
- }
467
- catch {
468
- // Child already exited.
469
- }
470
- }
471
- function installLifetimeCleanup() {
472
- if (lifetimeState.installed)
473
- return;
474
- lifetimeState.installed = true;
475
- process.once("exit", () => {
476
- for (const pid of lifetimeState.pids)
477
- killPidTreeSync(pid);
478
- });
479
- for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
480
- process.once(signal, () => {
481
- for (const pid of lifetimeState.pids)
482
- killPidTreeSync(pid);
483
- if (process.platform === "win32" && signal === "SIGHUP") {
484
- // Windows emits SIGHUP when its console closes, but libuv cannot
485
- // self-send it (process.kill(..., "SIGHUP") throws ENOSYS). The
486
- // child cleanup above remains the required shutdown action; record
487
- // the intentional decline synchronously and let the host exit cleanly.
488
- recordDegradationOnce({
489
- kind: "safe-spawn-signal-reraise-unsupported",
490
- subject: `${process.platform}:${signal}`,
491
- reason: `${process.platform} ${signal}: self signal re-raise is unsupported by the Windows libuv signal capability`,
492
- metadata: {
493
- platform: process.platform,
494
- signal,
495
- reason: "unsupported",
496
- },
497
- });
498
- return;
499
- }
500
- try {
501
- process.kill(process.pid, signal);
502
- }
503
- catch (error) {
504
- // #3383: this runs inside a `process.once(signal)` handler, so a
505
- // throw here is an uncaughtException DURING SHUTDOWN, not a
506
- // rejection anyone can see. The one failure measured in the field
507
- // (Windows SIGHUP ENOSYS) is declined in advance just above; this
508
- // covers every other platform/signal/errno pair the OS can refuse —
509
- // same ledger kind, `reason: "refused"` instead of "unsupported",
510
- // because a reader of either row wants the same fact: the host
511
- // exited without the signal's default disposition. The child
512
- // cleanup above has already run either way.
513
- recordDegradationOnce({
514
- kind: "safe-spawn-signal-reraise-unsupported",
515
- subject: `${process.platform}:${signal}`,
516
- reason: `${process.platform} ${signal}: self signal re-raise was refused (${error.code ?? error.message})`,
517
- metadata: {
518
- platform: process.platform,
519
- signal,
520
- reason: "refused",
521
- code: error.code ?? null,
522
- },
523
- });
524
- }
525
- });
526
- }
527
- }
528
- // ============================================================================
529
- // AMBIENT TURN ABORT SIGNAL
530
- // ============================================================================
531
- /**
532
- * The current turn's abort signal, published by the lifecycle handlers from
533
- * pi's `ctx.signal`. Threading the signal explicitly through every
534
- * dispatch → runner → spawn call site would be invasive, so instead
535
- * `safeSpawnAsync` defaults to this ambient signal when a call doesn't pass its
536
- * own. The effect: pressing Esc mid-turn aborts in-flight linter / formatter /
537
- * type-checker child processes (process-tree kill on Windows) instead of letting
538
- * them run to their timeout.
539
- *
540
- * Each spawn captures the signal at call time (attaching its own abort listener),
541
- * so clearing this after a handler returns only affects *future* spawns — work
542
- * already in flight keeps the signal it started with.
543
- */
544
- let ambientAbortSignal;
545
- /** Publish (or clear, with `undefined`) the current turn's abort signal. */
546
- export function setAmbientAbortSignal(signal) {
547
- ambientAbortSignal = signal;
548
- }
549
- /**
550
- * The current turn's abort signal, for in-process awaits that aren't child
551
- * spawns (e.g. an LSP JSON-RPC write that can backpressure on a wedged server).
552
- * Child spawns read the ambient signal internally; this getter lets the
553
- * interactive pipeline honor Escape on non-spawn LSP calls too.
554
- */
555
- export function getAmbientAbortSignal() {
556
- return ambientAbortSignal;
557
- }
558
- // ============================================================================
559
- // INTERNAL HELPERS
560
- // ============================================================================
561
- /**
562
- * Escape a single argument for the Windows command interpreter.
563
- * The result is embedded in the `/c` command string below, so metacharacters
564
- * must remain inside a quoted argument rather than becoming command syntax.
565
- */
566
- function cmdEscapeArg(arg) {
567
- if (!/[\s"&|<>^()]/.test(arg))
568
- return arg;
569
- return `"${arg.replace(/"/g, '""')}"`;
570
- }
571
- /**
572
- * Build the cmd.exe command string used for Windows wrapper spawning.
573
- *
574
- * The COMMAND must be escaped the same way as the args — escaping only the args
575
- * (the bug behind #214) means a tool whose resolved path contains a space (e.g.
576
- * `C:\Program Files\Go\bin\go.exe`) makes cmd.exe parse `C:\Program` as the
577
- * command and fail with "'C:\Program' is not recognized". `cmdEscapeArg` is a
578
- * no-op for space-free commands, so this is safe for the npm/.pi-lens tool paths
579
- * that already worked. The `chcp 65001` prefix forces the UTF-8 code page (so
580
- * tool output isn't mangled by the system code page) and, as a side benefit,
581
- * keeps the (possibly quoted) command off the front of the line, avoiding
582
- * cmd.exe's `/s` outer-quote-stripping quirk. #2023: chcp is invoked via its
583
- * absolute `%SystemRoot%\System32\chcp.com` path and chained with `&`, not
584
- * `&&` — a child environment whose PATH cannot resolve System32 used to fail
585
- * the bare `chcp` lookup, and `&&` then short-circuited EVERY .cmd/.bat spawn
586
- * into exit 1 with empty output. With the pin plus `&`, even a chcp failure
587
- * can never suppress the real command.
588
- */
589
- export function buildWindowsShellCommand(command, args) {
590
- const chcp = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\chcp.com`;
591
- return `${chcp} 65001 >nul 2>&1 & ${[command, ...args].map(cmdEscapeArg).join(" ")}`;
592
- }
593
- /**
594
- * The extensions this resolver treats as executable suffixes on their own,
595
- * independent of PATHEXT: `.exe`/`.com` and `.cmd`/`.bat`. An explicit suffix
596
- * outside this set is not treated as a "known executable extension" unless
597
- * the caller's own PATHEXT says so (#1201).
598
- *
599
- * This is a RESOLUTION set, not a dispatch whitelist — do not read it as "the
600
- * only extensions this module can spawn". Dispatch in
601
- * `safeSpawnAsync`/`safeSpawn` below routes `.cmd`/`.bat` through the cmd.exe
602
- * wrapper and EVERYTHING ELSE to direct spawn, so a resolved path with any
603
- * other suffix (reachable only when the caller's own PATHEXT lists it) is
604
- * direct-spawned. That is deliberate and not a widening: CreateProcess does no
605
- * ShellExecute file-association lookup, the default Windows PATHEXT already
606
- * contains `.VBS`/`.JS`/`.WSF`/`.MSC`, and the set here is strictly narrower
607
- * than the pre-#1201 behavior. Only the doc claim was ever wrong.
608
- */
609
- const WINDOWS_DIRECT_RESOLVABLE_EXTS = new Set([
610
- ".exe",
611
- ".com",
612
- ".cmd",
613
- ".bat",
614
- ]);
615
- /**
616
- * Merge a parent and child environment using Windows' case-insensitive variable
617
- * names. Node's JavaScript environment object can contain both `PATH` and
618
- * `Path`, even though Windows treats them as one variable. Removing an older
619
- * spelling before each assignment gives explicit child overrides precedence
620
- * over every ambient spelling and ensures the environment passed to spawn has
621
- * one unambiguous value.
622
- */
623
- export function mergeWindowsEnvironment(base, overrides) {
624
- const entries = new Map();
625
- const assign = (source) => {
626
- if (!source)
627
- return;
628
- for (const [key, value] of Object.entries(source)) {
629
- const folded = key.toLowerCase();
630
- // Delete before setting so the last explicit spelling wins while the
631
- // folded map remains O(n) instead of scanning the merged object for
632
- // every environment entry.
633
- entries.delete(folded);
634
- if (value !== undefined)
635
- entries.set(folded, { key, value });
636
- }
637
- };
638
- assign(base);
639
- assign(overrides);
640
- const merged = {};
641
- for (const { key, value } of entries.values())
642
- merged[key] = value;
643
- return merged;
644
- }
645
- function getWindowsEnvironmentValue(env, name) {
646
- let value;
647
- for (const [key, entry] of Object.entries(env)) {
648
- if (key.toLowerCase() === name.toLowerCase())
649
- value = entry;
650
- }
651
- return value;
652
- }
653
- function driveLetter(value) {
654
- const match = /^([A-Za-z]):/.exec(value);
655
- return match?.[1]?.toUpperCase();
656
- }
657
- function isDriveAbsolute(value, drive) {
658
- return (value.length >= 3 &&
659
- value[0]?.toUpperCase() === drive.toUpperCase() &&
660
- value[1] === ":" &&
661
- (value[2] === "\\" || value[2] === "/"));
662
- }
663
- /** `X:\...` or `X:/...` — a drive-absolute path, for any drive letter. */
664
- function isDriveAbsoluteAnyDrive(value) {
665
- const drive = driveLetter(value);
666
- return drive !== undefined && isDriveAbsolute(value, drive);
667
- }
668
- /** `\\server\share\...` — a UNC path. Two leading separators, not more. */
669
- function isUncWindowsPath(value) {
670
- return ((value[0] === "\\" || value[0] === "/") &&
671
- (value[1] === "\\" || value[1] === "/") &&
672
- value[2] !== "\\" &&
673
- value[2] !== "/");
674
- }
675
- /**
676
- * A path is "fully qualified" (self-contained, no ambient current-directory
677
- * lookup required) only when it is drive-absolute (`X:\...`) or UNC
678
- * (`\\server\share`). `path.win32.isAbsolute("\tools")` also returns `true`
679
- * for a *rooted* path — one relative to the current drive's root rather than
680
- * a specific drive — and treating that as fully qualified was the #1201
681
- * bug: it got statSync'd against the *host* drive while a different
682
- * `effectiveCwd`/`resolvedDrive` supplied the drive cmd.exe actually used to
683
- * execute it, so a different file could be validated than executed.
684
- */
685
- function isFullyQualifiedWindowsPath(value) {
686
- return isFullyQualifiedWin32(value) && !isRootedWindowsPath(value);
687
- }
688
- /**
689
- * `\tools` or `/tools` — rooted at the current drive's root, but naming no
690
- * drive of its own (distinct from both `X:\tools` fully-qualified and
691
- * `X:tools` drive-relative-to-current-directory). Windows resolves this
692
- * against the root of whichever drive is "current", not the current
693
- * directory on that drive.
694
- */
695
- function isRootedWindowsPath(value) {
696
- return ((value[0] === "\\" || value[0] === "/") &&
697
- driveLetter(value) === undefined &&
698
- !isUncWindowsPath(value));
699
- }
700
- /**
701
- * Resolve a rooted path (`\tools`) against the ROOT of `driveSource`'s drive
702
- * — not `driveSource` itself — matching Windows' own rooted-path semantics.
703
- *
704
- * When `driveSource` carries NO drive letter, this returns the normalized
705
- * value rather than `undefined` (#1201, recurring defect shape 2). A
706
- * drive-less "current drive" provenance means we are not on a drive-lettered
707
- * filesystem at all — a POSIX host, or a UNC `process.cwd()` such as
708
- * `\\server\share\...`, which Node permits on real Windows. In that namespace
709
- * a rooted path is already as qualified as any path can be, so there is
710
- * nothing to anchor and normalizing is the correct (and pre-#1201) answer.
711
- * Returning `undefined` instead used to collapse the whole resolution to
712
- * `null` without a single `statSync`, i.e. a false ENOENT — the exact failure
713
- * signature #1199 exists to remove. This function must therefore never fail
714
- * closed on the *shape* of a value that may not be a Windows path at all;
715
- * the filesystem probe downstream is what decides whether it exists.
716
- */
717
- function resolveRootedWindowsPath(value, driveSource) {
718
- const drive = driveLetter(driveSource);
719
- if (drive === undefined)
720
- return path.win32.normalize(value);
721
- return path.win32.normalize(path.win32.resolve(`${drive}:\\`, value));
722
- }
723
- /**
724
- * Return a validated Windows per-drive current directory. Windows exposes
725
- * these as environment entries such as `=D:`; unlike ordinary environment
726
- * variables they are not safe to synthesize from a drive letter. A malformed
727
- * or wrong-drive value is deliberately ignored so drive-relative resolution
728
- * fails closed instead of guessing a root.
729
- *
730
- * Node never surfaces `=X:` keys from the ambient `process.env` (verified:
731
- * `Object.keys(process.env).filter(k => k.startsWith("="))` is empty), so
732
- * this provenance is only ever available when a caller supplies it
733
- * explicitly in an `env` override — e.g. a test, or a future integration
734
- * that reads it from a lower-level Windows API. That's a real, if narrow,
735
- * use: keep this path rather than deleting it, but never synthesize a value
736
- * Node itself can't hand us.
737
- */
738
- function getValidatedPerDriveCwd(env, drive) {
739
- const value = getWindowsEnvironmentValue(env, `=${drive}:`);
740
- if (value === undefined || !isDriveAbsolute(value, drive))
741
- return undefined;
742
- return path.win32.normalize(value);
743
- }
744
- function resolveEffectiveWindowsCwd(cwd, env) {
745
- if (cwd === undefined) {
746
- // No caller-supplied cwd: `process.cwd()` IS the effective cwd already —
747
- // the real, canonical filesystem cwd of THIS process, not a string that
748
- // needs (re)classifying. On a POSIX CI host it naturally carries no
749
- // drive letter; that's fine, it never needs one here. Only a
750
- // CALLER-SUPPLIED cwd string goes through the fully-qualified/rooted/
751
- // drive-relative classification below — running `process.cwd()`'s own
752
- // value through `isRootedWindowsPath` was itself a #1201 regression: a
753
- // drive-less POSIX path (e.g. Linux CI's real cwd) satisfies that
754
- // predicate too (single leading separator, no drive letter), which then
755
- // sent it to `resolveRootedWindowsPath` looking for a drive letter on
756
- // `process.cwd()` that — being the very same drive-less string — could
757
- // never supply one, resolving to `undefined` (recurring defect shape
758
- // 2/7: `path.win32.resolve` falls back to `process.cwd()`, which
759
- // supplies a drive on Windows and none on Linux).
760
- return path.win32.normalize(process.cwd());
761
- }
762
- const requestedDrive = driveLetter(cwd);
763
- if (requestedDrive !== undefined && !isDriveAbsoluteAnyDrive(cwd)) {
764
- // `D:foo` — drive-relative to that drive's own current directory.
765
- const base = getValidatedPerDriveCwd(env, requestedDrive);
766
- return base === undefined
767
- ? undefined
768
- : path.win32.normalize(path.win32.resolve(base, cwd));
769
- }
770
- if (isFullyQualifiedWindowsPath(cwd))
771
- return path.win32.normalize(cwd);
772
- if (isRootedWindowsPath(cwd)) {
773
- // `\tools` — rooted at the CURRENT drive's root.
774
- //
775
- // Note the deliberate asymmetry with `resolveWindowsPathEntry`, which
776
- // anchors rooted PATH entries to `effectiveCwd`'s drive instead. Both
777
- // use the SAME rule — "anchor to the best available "current drive"
778
- // provenance at this point in the pipeline" — they just sit at
779
- // different points. Here we are *computing* `effectiveCwd`, so it does
780
- // not exist yet and the process's own cwd is the only provenance
781
- // available (on real Windows it always carries a drive letter). By the
782
- // time a PATH entry is resolved, `effectiveCwd` is known and is the
783
- // strictly better answer, because it is the drive the child will
784
- // actually run from — anchoring PATH entries to the host drive there
785
- // would validate one file and execute another. Neither call can fail
786
- // closed on a drive-less provenance; see `resolveRootedWindowsPath`.
787
- return resolveRootedWindowsPath(cwd, process.cwd());
788
- }
789
- return path.win32.normalize(path.win32.resolve(process.cwd(), cwd));
790
- }
791
- function resolveDriveRelativeWindowsPath(value, effectiveCwd, env) {
792
- const drive = driveLetter(value);
793
- if (drive === undefined)
794
- return undefined;
795
- const cwdDrive = effectiveCwd === undefined ? undefined : driveLetter(effectiveCwd);
796
- const base = cwdDrive?.toUpperCase() === drive
797
- ? effectiveCwd
798
- : getValidatedPerDriveCwd(env, drive);
799
- if (base === undefined)
800
- return undefined;
801
- return path.win32.normalize(path.win32.resolve(base, value));
802
- }
803
- function resolveWindowsPathEntry(entry, effectiveCwd, env) {
804
- if (driveLetter(entry) !== undefined && !isDriveAbsoluteAnyDrive(entry)) {
805
- return resolveDriveRelativeWindowsPath(entry, effectiveCwd, env);
806
- }
807
- if (isFullyQualifiedWindowsPath(entry))
808
- return path.win32.normalize(entry);
809
- if (isRootedWindowsPath(entry)) {
810
- // `\tools` — rooted at the effective child cwd's drive root, not the
811
- // host process's drive (#1201: a rooted PATH entry must resolve on
812
- // the SAME drive the child will actually run cmd.exe/the resolved
813
- // binary from). See `resolveEffectiveWindowsCwd`'s rooted branch for
814
- // why that function anchors to `process.cwd()` instead — same rule,
815
- // different point in the pipeline. `undefined` here means the cwd
816
- // itself was unprovable, which is a genuinely different condition
817
- // from "the provenance carries no drive letter".
818
- return effectiveCwd === undefined
819
- ? undefined
820
- : resolveRootedWindowsPath(entry, effectiveCwd);
821
- }
822
- if (effectiveCwd === undefined)
823
- return undefined;
824
- return path.win32.normalize(path.win32.resolve(effectiveCwd, entry));
825
- }
826
- /**
827
- * Cached PATH + PATHEXT resolution results. The effective child environment is
828
- * part of the key, not the ambient process environment: one caller's managed
829
- * bin directory must never poison another caller's resolution. The key also
830
- * carries the canonical child cwd and Windows `=X:` per-drive cwd entries,
831
- * because both affect relative PATH and drive-relative lookup. Session-lived
832
- * (matches this repo's other resolution caches, e.g.
833
- * `clients/workspace-topology.ts`'s `resetWorkspaceTopology`) — cleared via
834
- * `resetSafeSpawnWindowsCommandCache()`, wired into `handleSessionStart`. The
835
- * cache is also count-bounded with oldest-entry eviction because a long-lived
836
- * process can encounter unbounded cwd/environment combinations.
837
- */
838
- export const WINDOWS_COMMAND_CACHE_MAX_ENTRIES = 256;
839
- const WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS = 1000;
840
- const windowsCommandCache = new BoundedFifoMap(WINDOWS_COMMAND_CACHE_MAX_ENTRIES);
841
- /** Reset after session replacement or a successful managed install. */
842
- export function resetSafeSpawnWindowsCommandCache() {
843
- windowsCommandCache.clear();
844
- loggedSpawnClassifications.clear();
845
- }
846
- function cacheWindowsCommandResult(key, resolved) {
847
- // Resolution entries are session-scoped and cheap to recompute. The
848
- // caller always deletes any stale entry for `key` before reaching here
849
- // (see resolveWindowsCommandForEnvironment), so this can only grow the
850
- // map; BoundedFifoMap evicts the oldest insertion first once the map
851
- // exceeds capacity, so a long-lived process cannot retain every cwd /
852
- // environment it has ever touched.
853
- windowsCommandCache.set(key, { resolved, checkedAt: Date.now() });
854
- }
855
- function getPathExts(env) {
856
- const raw = getWindowsEnvironmentValue(env, "PATHEXT") ?? ".COM;.EXE;.BAT;.CMD";
857
- return raw
858
- .split(";")
859
- .map((ext) => ext.trim().toLowerCase())
860
- .filter(Boolean);
861
- }
862
- function statIsFile(candidate) {
863
- try {
864
- return fs.statSync(candidate).isFile();
865
- }
866
- catch {
867
- return false;
868
- }
869
- }
870
- function resolveWindowsCommandUncached(command, effectiveCwd, env) {
871
- const pathExts = getPathExts(env);
872
- const existingExt = path.win32.extname(command).toLowerCase();
873
- const hasExplicitExt = existingExt.length > 0;
874
- // A suffix is "known executable" when it's one of the four extensions this
875
- // resolver actually knows how to run (direct .exe/.com, or the
876
- // cmd.exe-wrapped .cmd/.bat) OR it's explicitly listed in the caller's own
877
- // PATHEXT. Anything else with a dot is NOT necessarily "an extension" — a
878
- // versioned interpreter (`python3.11`, `node-v20.1`) has an `extname()` of
879
- // `.11`/`.1` that is part of the basename, not a suffix to match exactly
880
- // (#1201).
881
- const isKnownExecutableExt = (ext) => WINDOWS_DIRECT_RESOLVABLE_EXTS.has(ext) || pathExts.includes(ext);
882
- const tryBase = (base) => {
883
- // Additive, not exclusive: an explicit, known-executable suffix is
884
- // tried as an exact candidate FIRST (this also lets a caller ask for
885
- // `foo.cmd` even when the current PATHEXT doesn't happen to list
886
- // `.CMD`). Whenever the suffix ISN'T a PATHEXT entry — no extension at
887
- // all, or a versioned/unknown one like `.11` — the PATHEXT-append loop
888
- // ALSO runs against the full base, so `python3.11` still finds
889
- // `python3.11.exe`. An unknown suffix (e.g. `foo.txt`) never gets an
890
- // exact-match short-circuit: that would let an arbitrary
891
- // non-executable file resolve as "spawnable" and reach the
892
- // direct-spawn branch downstream.
893
- if (hasExplicitExt &&
894
- isKnownExecutableExt(existingExt) &&
895
- statIsFile(base)) {
896
- return { resolvedPath: base, ext: existingExt };
897
- }
898
- if (!hasExplicitExt || !pathExts.includes(existingExt)) {
899
- for (const ext of pathExts) {
900
- const candidate = base + ext;
901
- if (statIsFile(candidate))
902
- return { resolvedPath: candidate, ext };
903
- }
904
- }
905
- return null;
906
- };
907
- const hasPathSep = /[\\/]/.test(command);
908
- // `D:tool.exe` is drive-relative on Windows, not a bare PATH command. It
909
- // uses the effective cwd only when that cwd is on D:. For another drive we
910
- // require an explicit, validated `=D:` provenance entry; otherwise this
911
- // resolver fails closed rather than guessing `D:\\` or searching PATH.
912
- const hasDrivePrefix = driveLetter(command) !== undefined;
913
- if (hasPathSep || hasDrivePrefix || isFullyQualifiedWindowsPath(command)) {
914
- let base;
915
- if (isFullyQualifiedWindowsPath(command)) {
916
- base = path.win32.normalize(command);
917
- }
918
- else if (hasDrivePrefix) {
919
- base = resolveDriveRelativeWindowsPath(command, effectiveCwd, env);
920
- }
921
- else if (isRootedWindowsPath(command)) {
922
- base =
923
- effectiveCwd === undefined
924
- ? undefined
925
- : resolveRootedWindowsPath(command, effectiveCwd);
926
- }
927
- else if (effectiveCwd !== undefined) {
928
- base = path.win32.normalize(path.win32.resolve(effectiveCwd, command));
929
- }
930
- return base === undefined ? null : tryBase(base);
931
- }
932
- // A bare command (no path separator) is a plain PATH search. Absolute PATH
933
- // entries don't need `effectiveCwd` at all, so an unresolvable cwd must
934
- // only skip the relative entries (handled per-entry in
935
- // resolveWindowsPathEntry below), not abort the whole search (#1201: a bad
936
- // cwd shouldn't make an otherwise-resolvable command look "not installed").
937
- const pathValue = getWindowsEnvironmentValue(env, "PATH") ?? "";
938
- const pathDirs = pathValue.split(path.win32.delimiter);
939
- for (const dir of pathDirs) {
940
- if (!dir)
941
- continue;
942
- const resolvedDir = resolveWindowsPathEntry(dir, effectiveCwd, env);
943
- if (resolvedDir === undefined)
944
- continue;
945
- const found = tryBase(path.win32.join(resolvedDir, command));
946
- if (found)
947
- return found;
948
- }
949
- return null;
950
- }
951
- /**
952
- * Resolve a Windows command using the exact environment that will be passed to
953
- * the child. Exported as a small platform-independent test/diagnostic seam;
954
- * callers should pass a Windows-shaped environment and do not need to mutate
955
- * `process.env` to exercise resolution. Drive-relative commands (`D:tool.exe`)
956
- * use a same-drive effective cwd, or a validated absolute `=D:` entry for a
957
- * different drive; without that provenance they fail closed and never search
958
- * PATH. Relative PATH entries use the canonical effective child cwd.
959
- */
960
- export function resolveWindowsCommandForEnvironment(command, cwd, env) {
961
- const effectiveCwd = resolveEffectiveWindowsCwd(cwd, env);
962
- const pathValue = getWindowsEnvironmentValue(env, "PATH");
963
- const pathExtValue = getWindowsEnvironmentValue(env, "PATHEXT");
964
- const perDriveCwds = Object.entries(env)
965
- .flatMap(([key, value]) => /^=[A-Za-z]:$/.test(key)
966
- ? [[key.toLowerCase(), value]]
967
- : [])
968
- .sort(([left], [right]) => compareOrdinal(left, right));
969
- // Keep presence separate from value: absent PATHEXT means the Windows
970
- // default extension list, while PATHEXT="" means no implicit extensions.
971
- // The per-drive snapshot is equally important: it is provenance for
972
- // drive-relative commands and PATH entries, not ambient decoration.
973
- const cacheKey = JSON.stringify([
974
- "win32",
975
- command,
976
- pathValue === undefined ? ["absent"] : ["present", pathValue],
977
- pathExtValue === undefined ? ["absent"] : ["present", pathExtValue],
978
- effectiveCwd === undefined ? ["unresolved"] : ["resolved", effectiveCwd],
979
- perDriveCwds,
980
- ]);
981
- const cached = windowsCommandCache.get(cacheKey);
982
- if (cached) {
983
- // Positive entries are revalidated on every hit so an executable deleted
984
- // or replaced mid-session cannot remain spawnable through stale cache
985
- // state. Negative entries are short-lived to discover external installs;
986
- // pi-lens-managed installs also reset the cache immediately on success.
987
- if (cached.resolved && statIsFile(cached.resolved.resolvedPath)) {
988
- return cached.resolved;
989
- }
990
- if (!cached.resolved &&
991
- Date.now() - cached.checkedAt <= WINDOWS_COMMAND_NEGATIVE_CACHE_TTL_MS) {
992
- return null;
993
- }
994
- windowsCommandCache.delete(cacheKey);
995
- }
996
- const resolved = resolveWindowsCommandUncached(command, effectiveCwd, env);
997
- cacheWindowsCommandResult(cacheKey, resolved);
998
- return resolved;
999
- }
1000
- /** Cached `where`-equivalent for the effective child environment. */
1001
- function resolveWindowsCommand(command, cwd, env) {
1002
- return resolveWindowsCommandForEnvironment(command, cwd, env);
1003
- }
1004
- function getSpawnEnvironment(overrides) {
1005
- return process.platform === "win32"
1006
- ? mergeWindowsEnvironment(process.env, overrides)
1007
- : { ...process.env, ...overrides };
1008
- }
1009
- /**
1010
- * Characters that make a cmd.exe `/c` command line unsound to build from
1011
- * caller-tainted input: `%`/`!` expand even inside double quotes and cannot
1012
- * be escaped on a `/c` line (only `%%` works, and only in batch files), `"`
1013
- * can toggle cmd's quote-parsing state past the doubling convention, and
1014
- * CR/LF let an argument masquerade as a second command. Reject rather than
1015
- * attempt to escape these (#817) — silent stripping would just move the
1016
- * unsoundness, not remove it.
1017
- */
1018
- const CMD_UNSAFE_CHARS = /["%!\r\n]/;
1019
- /** First caller-tainted value (command or an arg) that can't be safely
1020
- * passed through the .cmd/.bat cmd.exe wrapper, or `undefined` if all clear. */
1021
- function findCmdUnsafeValue(command, args) {
1022
- if (CMD_UNSAFE_CHARS.test(command))
1023
- return command;
1024
- return args.find((arg) => CMD_UNSAFE_CHARS.test(arg));
1025
- }
1026
- function synthesizeEnoentError(command) {
1027
- // Shaped like Node's native `spawn <cmd> ENOENT` error (message/code/
1028
- // syscall/path) so the typed classifier retains the same diagnostic cause
1029
- // now that Windows resolution happens before spawn instead of inside cmd.exe.
1030
- const err = new Error(`spawn ${command} ENOENT`);
1031
- err.code = "ENOENT";
1032
- err.syscall = "spawn";
1033
- err.path = command;
1034
- return err;
1035
- }
1036
- /**
1037
- * One-shot `chcp 65001` before the FIRST direct (.exe/.com) Windows spawn.
1038
- *
1039
- * Mechanism: `chcp` mutates the code page of the console the current process
1040
- * is attached to, and that mutation persists for the console's lifetime —
1041
- * it is not per-child-process state. The old cmd.exe-wrapper path re-ran
1042
- * `chcp 65001 >nul 2>&1 &&` on every single spawn, which was always
1043
- * redundant after the first call; memoizing here (module-level, once per
1044
- * process) is a strict improvement, not a behavior change. Runs through a
1045
- * tiny pinned cmd.exe spawn (same `%SystemRoot%\System32\cmd.exe` pin as
1046
- * `killTree` below) with a static, hardcoded command string — no caller-
1047
- * tainted data anywhere near it.
1048
- */
1049
- let utf8ConsoleCodePageApplied = false;
1050
- function ensureUtf8ConsoleCodePageOnce() {
1051
- if (utf8ConsoleCodePageApplied)
1052
- return;
1053
- utf8ConsoleCodePageApplied = true;
1054
- try {
1055
- const cmdExe = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
1056
- spawnSync(cmdExe, ["/d", "/s", "/c", "chcp 65001 >nul 2>&1"], {
1057
- shell: false,
1058
- windowsHide: true,
1059
- // #1980 population sweep: a synchronous spawn with no timeout parks
1060
- // the event loop for as long as the child takes, and nothing here
1061
- // bounds that. `chcp` is normally instant, but this runs before the
1062
- // FIRST real spawn of the process, which is exactly when a cold or
1063
- // antivirus-throttled cmd.exe is slowest. Matches the 5s bound every
1064
- // other synchronous child-process site in this repo already carries.
1065
- timeout: 5000,
1066
- });
1067
- }
1068
- catch {
1069
- // Best-effort: worst case is non-ASCII tool output decoded incorrectly, not a
1070
- // spawn failure — never let this block the real spawn.
1071
- }
1072
- }
1073
- /**
1074
- * Test-only seam: clear the one-shot `chcp` memoization.
1075
- *
1076
- * No test observes it today — the memoized work only runs inside a real
1077
- * Windows direct spawn, so exercising it would mean a Windows-only test, and
1078
- * the state it guards is a console code page, not behavior any assertion
1079
- * depends on. This is kept purely so a future Windows-only test (or a second
1080
- * caller of `ensureUtf8ConsoleCodePageOnce`) can reset process-lifetime state
1081
- * without reaching into the module; it was dropped once as unrelated scope
1082
- * creep in an earlier #1199 revision and restored on review. Do not read the
1083
- * export as evidence of existing coverage (#1201).
1084
- */
1085
- export function resetUtf8ConsoleCodePageStateForTests() {
1086
- utf8ConsoleCodePageApplied = false;
1087
- }
1088
- // #1656: post-exit pipe-idle wait constants. Node's "close" event only fires
1089
- // once every stdio fd referencing the child has been released — a
1090
- // daemonized descendant that inherited our stdout/stderr pipe (the Windows
1091
- // no-job-object case) can hold that fd open forever, hanging "close" even
1092
- // though the child we spawned is long dead. A fixed post-exit deadline
1093
- // avoids that hang but truncates a legitimately still-streaming child. The
1094
- // idle-grace construction (adopted from pi's `waitForChildProcess`) does
1095
- // neither: after "exit", wait for the pipes to go quiet — no data for
1096
- // EXIT_PIPE_IDLE_GRACE_MS, re-armed on every chunk — so a quiet inherited
1097
- // handle still releases after one grace window while an actively-writing
1098
- // descendant keeps being read. EXIT_PIPE_IDLE_MAX_WAIT_MS caps the total
1099
- // extra wait so a pathological, never-quiet descendant can't extend the
1100
- // caller's overall budget unboundedly.
1101
- const EXIT_PIPE_IDLE_GRACE_MS = 100;
1102
- const EXIT_PIPE_IDLE_MAX_WAIT_MS = 2000;
1103
- /**
1104
- * #2968: how long past its own deadline this spawn's CPU/RSS sampler may keep
1105
- * polling. Everything this function does to end a child after the deadline
1106
- * fits inside it: `killTree`'s SIGTERM plus its 1000ms SIGKILL escalation (or
1107
- * Windows' `taskkill /F /T`), then at most EXIT_PIPE_IDLE_MAX_WAIT_MS of
1108
- * pipe-idle wait. A child still alive past deadline + this grace has outlived
1109
- * every teardown available here, so polling it further only buys the Windows
1110
- * process pile-up the report measured — and `stop()` still returns whatever
1111
- * the sampler gathered before the cap.
1112
- */
1113
- const SPAWN_SAMPLER_TEARDOWN_GRACE_MS = 5_000;
1114
- // ============================================================================
1115
- // ASYNC VERSION (Recommended - Non-blocking)
1116
- // ============================================================================
1117
- /**
1118
- * Async spawn with timeout and proper process cleanup.
1119
- *
1120
- * Unlike spawnSync, this:
1121
- * - Doesn't block the event loop
1122
- * - Kills the process on timeout (preventing zombies)
1123
- * - Supports cancellation via AbortSignal
1124
- *
1125
- * ## Why this bound is NOT `bounded()` — the one documented exception (#2523)
1126
- *
1127
- * #2523 slice 2 folded every other "deadline AND abort signal" implementation
1128
- * in the tree onto `clients/deadline-utils.ts#bounded`. This one stays, and it
1129
- * is not a copy of the same idea:
1130
- *
1131
- * `bounded()` ABANDONS a promise — it stops waiting and lets the work run on
1132
- * unobserved, which is safe precisely because everything it wraps is in-process
1133
- * (a dynamic import, an LSP round trip, a `stat`). This function's work is a
1134
- * CHILD PROCESS, and abandoning one leaks it. So its timer does not settle a
1135
- * race at all: `timeoutId` sets `timedOut`, then runs `killTree()` — SIGTERM,
1136
- * an escalation timer, SIGKILL, and on Windows a `taskkill /T` of the whole
1137
- * tree — and the promise settles only when the child's own `close` arrives (or
1138
- * the escalation gives up). The result it produces carries the teardown
1139
- * EVIDENCE (`timeoutTeardown`, #2010): which signal killed it, whether the
1140
- * tree actually died, how long it took. Replacing that with `bounded()` would
1141
- * return `undefined` at the deadline and leave a live `vitest`/`gradle` behind
1142
- * — the orphan class #2504 measured as 59 concurrent spawns starving a turn.
1143
- *
1144
- * The rule the exception preserves: a bound that can CANCEL its work must
1145
- * cancel it. `bounded()` is for work that cannot be cancelled; this is not
1146
- * that. Anything wrapping THIS function still gets `bounded()` around its own
1147
- * await if it needs one.
1148
- *
1149
- * @example
1150
- * const result = await safeSpawnAsync("npm", ["test"], { timeout: 30000 });
1151
- * if (result.error) console.error("Failed:", result.error);
1152
- */
1153
- export async function safeSpawnAsync(command, args, options) {
1154
- const configuredTimeout = options?.timeout !== undefined &&
1155
- Number.isFinite(options.timeout) &&
1156
- options.timeout >= 0
1157
- ? options.timeout
1158
- : 30000;
1159
- const deadlineRemaining = options?.deadlineAt !== undefined && Number.isFinite(options.deadlineAt)
1160
- ? options.deadlineAt - Date.now()
1161
- : Number.POSITIVE_INFINITY;
1162
- const timeout = Math.max(0, Math.min(configuredTimeout, deadlineRemaining));
1163
- // Fall back to the current turn's ambient signal (set from ctx.signal) so an
1164
- // Esc/abort mid-turn cancels dispatches that didn't thread a signal of their
1165
- // own — unless the caller opts out (installs, which must run to completion).
1166
- const abortSignal = options?.signal ??
1167
- (options?.ignoreAmbientSignal ? undefined : ambientAbortSignal);
1168
- return new Promise((resolve) => {
1169
- // Check for early abort
1170
- if (abortSignal?.aborted) {
1171
- const cause = new Error("Spawn aborted before start");
1172
- resolve({
1173
- stdout: "",
1174
- stderr: "",
1175
- status: null,
1176
- error: cause,
1177
- failure: "aborted",
1178
- spawnFailure: new SpawnFailureError("killed", cause.message, cause),
1179
- });
1180
- return;
1181
- }
1182
- if (timeout <= 0) {
1183
- const cause = new Error(`Process timed out after ${timeout}ms`);
1184
- resolve({
1185
- stdout: "",
1186
- stderr: "",
1187
- status: null,
1188
- error: cause,
1189
- failure: "timeout",
1190
- spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
1191
- });
1192
- return;
1193
- }
1194
- let stdout = "";
1195
- let stderr = "";
1196
- let timedOut = false;
1197
- let aborted = false;
1198
- let killed = false;
1199
- let outputTruncated = false;
1200
- let killedForOutputCap = false;
1201
- // #3375: bytes the child actually EMITTED (not what survived the cap) —
1202
- // the one number the field crash report could not supply, so the ledger
1203
- // row below can name how chatty the offending command really was.
1204
- let observedOutputBytes = 0;
1205
- // #3375: bytes RETAINED so far, kept incrementally. The cap check used to
1206
- // re-measure `Buffer.byteLength(stdout) + Buffer.byteLength(stderr)` on
1207
- // every chunk, which flattens both accumulated strings each time — O(n^2)
1208
- // in total output. Harmless while only 12 of 110 call sites passed a cap;
1209
- // with the cap now on by default it would tax every spawn (measured: a
1210
- // 32 MiB child resolved in 7944 ms before this counter, 469 ms after).
1211
- // Equal to the two byte lengths by construction: before
1212
- // truncation `stdout`/`stderr` are exactly the appended texts, and after
1213
- // it the branch that renders from the retained head/tail never reads it.
1214
- let retainedOutputBytes = 0;
1215
- // #3375: latched the first time a chunk handler throws. Later chunks
1216
- // return immediately, so the same fault cannot be re-entered and
1217
- // `refreshRetainedOutputs` can never overwrite the bytes already kept.
1218
- let outputHandlerFailed = false;
1219
- let streamingMatch = false;
1220
- // #1651 review: a single boolean the close/error handlers both check
1221
- // AND set, so whichever one decides the outcome first wins outright —
1222
- // the DECISION itself is shape-based (see the `close` handler below),
1223
- // this flag only stops the loser from resolving a second time.
1224
- let resolved = false;
1225
- // The most recent 'error' Error, captured the instant the event fires
1226
- // regardless of whether that handler goes on to resolve. The `close`
1227
- // handler's shape-based branch (code === null || code < 0) reads this
1228
- // when it decides FIRST, so a genuinely informative ENOENT/EACCES/etc.
1229
- // Error is used when it happens to already be available, instead of
1230
- // always falling back to a generic synthesized one.
1231
- let pendingSpawnError;
1232
- // #1109: the non-Windows SIGTERM→SIGKILL escalation timer (armed in
1233
- // killTree below). Stored per-call (never shared) so the close/error
1234
- // handlers can clear it if the child exits before it fires — same
1235
- // uncleared-race-timeout class as the LSP client-wait leak (#1097):
1236
- // a ref'd 1s timer that outlives the child it was escalating against
1237
- // would keep a one-shot `pi --print` process alive for up to 1s.
1238
- let escalationTimer;
1239
- let teardownStartedAtMs;
1240
- let teardownEscalated = false;
1241
- // #2010: teardown evidence for the spawn's OWN timeout. The wall clock
1242
- // starts when the budget expires and the tree-kill is issued, and stops
1243
- // when the process tree is observed dead (`await killPromise` settles)
1244
- // - measured SEPARATELY from the budget itself, so a slow teardown is
1245
- // visible instead of silently inflating "how long the tool ran".
1246
- // `teardownEscalated` names whether SIGTERM sufficed or the SIGKILL
1247
- // escalation fired (POSIX; Windows taskkill /F is always forceful).
1248
- // #1114: `child.killed` is set by Node the moment `kill()` successfully
1249
- // SENDS a signal, not when the child actually dies — so gating the
1250
- // escalation on `!child.killed` right after a successful `SIGTERM` send
1251
- // is always false and the SIGKILL branch is unreachable. Track observed
1252
- // death via the close/error handlers instead (set synchronously, before
1253
- // any `await`, so a timer firing during the close handler's `await
1254
- // killPromise` still observes the flag correctly).
1255
- let closed = false;
1256
- // #1656: set only while the post-exit idle-pipe wait is active (see
1257
- // waitForPipeIdle below); the stdout/stderr "data" handlers call this to
1258
- // re-arm the grace timer on every chunk. `undefined` before exit / after
1259
- // the wait settles, so the calls below are no-ops outside that window.
1260
- let rearmIdleGrace;
1261
- // #3375: never `undefined`. An omitted or unusable cap resolves to the
1262
- // module default instead of to unbounded retention, so `appendOutput`
1263
- // below has no arm that concatenates without a ceiling.
1264
- const callerCap = options?.maxOutputBytes;
1265
- const capFromCaller = callerCap !== undefined && Number.isFinite(callerCap) && callerCap > 0;
1266
- const maxOutputBytes = capFromCaller
1267
- ? Math.floor(callerCap)
1268
- : DEFAULT_MAX_OUTPUT_BYTES;
1269
- const outputTruncationMarker = "\n...[output truncated]...\n";
1270
- let retainedHead = [];
1271
- let retainedTail = [];
1272
- let retainedTailBytes = 0;
1273
- let retainedTailLimit = 0;
1274
- let truncationStream = "stderr";
1275
- const streamingMatcher = options?.matchWhileStreaming;
1276
- const streamingCarryBytes = streamingMatcher
1277
- ? Math.max(0, streamingMatcher.source.length - 1)
1278
- : 0;
1279
- const streamingCarry = {
1280
- stdout: "",
1281
- stderr: "",
1282
- };
1283
- const bytePrefix = (text, bytes) => Buffer.from(text).subarray(0, bytes).toString();
1284
- const byteSuffix = (text, bytes) => {
1285
- const buffer = Buffer.from(text);
1286
- return buffer.subarray(Math.max(0, buffer.byteLength - bytes)).toString();
1287
- };
1288
- const appendTail = (part, maxBytes) => {
1289
- if (maxBytes <= 0)
1290
- return;
1291
- retainedTail.push({ stream: part.stream, text: part.text });
1292
- retainedTailBytes += Buffer.byteLength(part.text);
1293
- while (retainedTailBytes > maxBytes && retainedTail.length > 0) {
1294
- const first = retainedTail[0];
1295
- const excess = retainedTailBytes - maxBytes;
1296
- const firstBytes = Buffer.byteLength(first.text);
1297
- if (firstBytes <= excess) {
1298
- retainedTail.shift();
1299
- retainedTailBytes -= firstBytes;
1300
- }
1301
- else {
1302
- first.text = byteSuffix(first.text, firstBytes - excess);
1303
- retainedTailBytes -= excess;
1304
- }
1305
- }
1306
- };
1307
- const renderOutput = (stream) => {
1308
- const head = retainedHead
1309
- .filter((part) => part.stream === stream)
1310
- .map((part) => part.text)
1311
- .join("");
1312
- const tail = retainedTail
1313
- .filter((part) => part.stream === stream)
1314
- .map((part) => part.text)
1315
- .join("");
1316
- const marker = stream === truncationStream ? outputTruncationMarker : "";
1317
- return head + marker + tail;
1318
- };
1319
- const refreshRetainedOutputs = () => {
1320
- stdout = renderOutput("stdout");
1321
- stderr = renderOutput("stderr");
1322
- };
1323
- const appendOutput = (stream, current, chunk) => {
1324
- const text = typeof chunk === "string" ? chunk : chunk.toString();
1325
- if (outputTruncated) {
1326
- appendTail({ stream, text }, retainedTailLimit);
1327
- truncationStream = stream;
1328
- return renderOutput(stream);
1329
- }
1330
- const bytes = Buffer.byteLength(text);
1331
- if (retainedOutputBytes + bytes <= maxOutputBytes) {
1332
- retainedOutputBytes += bytes;
1333
- return current + text;
1334
- }
1335
- outputTruncated = true;
1336
- truncationStream = stream;
1337
- const available = Math.max(0, maxOutputBytes - Buffer.byteLength(outputTruncationMarker));
1338
- const headBytes = Math.floor(available / 2);
1339
- const tailBytes = available - headBytes;
1340
- retainedTailLimit = tailBytes;
1341
- const parts = [
1342
- { stream: "stdout", text: stdout },
1343
- { stream: "stderr", text: stderr },
1344
- { stream, text },
1345
- ];
1346
- let headRemaining = headBytes;
1347
- for (const part of parts) {
1348
- if (headRemaining <= 0)
1349
- break;
1350
- const kept = bytePrefix(part.text, headRemaining);
1351
- if (kept)
1352
- retainedHead.push({ stream: part.stream, text: kept });
1353
- headRemaining -= Buffer.byteLength(kept);
1354
- }
1355
- let tailRemaining = tailBytes;
1356
- for (let index = parts.length - 1; index >= 0 && tailRemaining > 0; index--) {
1357
- const part = parts[index];
1358
- const kept = byteSuffix(part.text, tailRemaining);
1359
- if (kept) {
1360
- retainedTail.unshift({ stream: part.stream, text: kept });
1361
- tailRemaining -= Buffer.byteLength(kept);
1362
- }
1363
- }
1364
- retainedTailBytes = tailBytes - tailRemaining;
1365
- return renderOutput(stream);
1366
- };
1367
- const matchStreamingChunk = (stream, chunk) => {
1368
- if (streamingMatch || !streamingMatcher)
1369
- return;
1370
- const text = typeof chunk === "string" ? chunk : chunk.toString();
1371
- streamingMatcher.lastIndex = 0;
1372
- streamingMatch = streamingMatcher.test(streamingCarry[stream] + text);
1373
- if (streamingCarryBytes > 0) {
1374
- streamingCarry[stream] = Buffer.from(streamingCarry[stream] + text)
1375
- .subarray(-streamingCarryBytes)
1376
- .toString();
1377
- }
1378
- };
1379
- // Spawn the process (non-blocking). Keeping Node's `shell` option false
1380
- // is important on every path here: shell:true concatenates tainted
1381
- // arguments into a command line before spawning (CodeQL #17 / CWE-78).
1382
- //
1383
- // On Windows (#817): resolve `command` ourselves (PATH + PATHEXT walk,
1384
- // cached) instead of always routing through cmd.exe.
1385
- // - .exe/.com → spawn the resolved path directly, args stay a real
1386
- // array, no shell involved at all (injection-proof).
1387
- // - .cmd/.bat → these are scripts cmd.exe must interpret (npm shims
1388
- // are the common case), so keep the wrapper, but pin the
1389
- // interpreter to %SystemRoot%\System32\cmd.exe (never trust
1390
- // ComSpec — CodeQL #18) and VALIDATE args instead of trying to
1391
- // escape them: reject anything containing `"`, `%`, `!`, or CR/LF
1392
- // rather than spawn with an unsound escape.
1393
- // - unresolvable → synthesize an ENOENT-shaped error instead of
1394
- // letting cmd.exe report "not recognized" from inside a shell.
1395
- const isWindows = process.platform === "win32";
1396
- const spawnEnv = getSpawnEnvironment(options?.env);
1397
- // The cwd handed to the CHILD process doesn't need OUR validation —
1398
- // Windows resolves it natively, exactly as it did before #817 ever
1399
- // touched this file. A drive-relative cwd (`D:work`) without a
1400
- // validated `=D:` env entry can't be canonicalized by us (Node never
1401
- // surfaces `=X:` keys from the ambient environment — see
1402
- // `getValidatedPerDriveCwd`'s doc comment), but that is a gap in OUR
1403
- // provenance, not evidence the cwd itself is bad. Fall back to the raw
1404
- // value instead of failing the whole spawn before ever attempting it
1405
- // (#1201) — command resolution just below still fails closed for any
1406
- // relative PATH entry that would need this cwd to be canonical.
1407
- // No `?? process.cwd()` third arm: `resolveEffectiveWindowsCwd(undefined,
1408
- // env)` always returns a string, so the only way to reach `undefined`
1409
- // here is a DEFINED-but-unprovable `options.cwd`, which `?? options?.cwd`
1410
- // already covers. A `process.cwd()` arm would be unreachable, and it
1411
- // would read like a live silent-wrong-directory hazard (#1201).
1412
- const spawnCwd = isWindows
1413
- ? (resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd)
1414
- : options?.cwd;
1415
- let spawnCmd = command;
1416
- let spawnArgs = args;
1417
- let windowsVerbatimArguments = false;
1418
- let resolutionError;
1419
- if (isWindows) {
1420
- // Pass the ORIGINAL (unresolved) cwd here, not `spawnCwd` above — this
1421
- // seam intentionally fails closed on unprovable drive-relative
1422
- // provenance (matches `resolveWindowsCommandForEnvironment`'s
1423
- // documented contract and its dedicated test coverage), independent
1424
- // of the passthrough fallback the actual child cwd gets above.
1425
- const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
1426
- if (!resolved) {
1427
- resolutionError = synthesizeEnoentError(command);
1428
- }
1429
- else if (resolved.ext === ".cmd" || resolved.ext === ".bat") {
1430
- // Validate the RESOLVED path (what actually gets interpolated
1431
- // into the /c line via buildWindowsShellCommand below), not the
1432
- // caller's original `command` string — a resolved path
1433
- // containing `%`/`!` would otherwise reach the shell unvalidated.
1434
- const unsafeValue = findCmdUnsafeValue(resolved.resolvedPath, args);
1435
- if (unsafeValue !== undefined) {
1436
- resolutionError = new Error(`Refusing to spawn "${resolved.resolvedPath}" via cmd.exe: ` +
1437
- `${JSON.stringify(unsafeValue)} contains a character ("` +
1438
- `, %, !, or CR/LF) that cannot be safely escaped on a ` +
1439
- `cmd.exe /c command line (CWE-78, #817). Rename/quote the ` +
1440
- "value or invoke the tool without going through cmd.exe.");
1441
- }
1442
- else {
1443
- // Pin the interpreter — never trust ComSpec/COMSPEC (#18): an
1444
- // env-controlled ComSpec could point anywhere.
1445
- spawnCmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
1446
- spawnArgs = [
1447
- "/d",
1448
- "/s",
1449
- "/c",
1450
- buildWindowsShellCommand(resolved.resolvedPath, args),
1451
- ];
1452
- // The `/c` payload is already quoted by buildWindowsShellCommand.
1453
- // Prevent Node from escaping those quotes a second time when it
1454
- // builds cmd.exe's Windows command line.
1455
- windowsVerbatimArguments = true;
1456
- }
1457
- }
1458
- else {
1459
- // .exe / .com: direct spawn, no cmd.exe anywhere in the picture.
1460
- ensureUtf8ConsoleCodePageOnce();
1461
- spawnCmd = resolved.resolvedPath;
1462
- spawnArgs = args;
1463
- }
1464
- }
1465
- if (resolutionError) {
1466
- void classifySpawnFailure(resolutionError, {
1467
- command,
1468
- cwd: options?.cwd,
1469
- }).then((spawnFailure) => resolve({
1470
- stdout: "",
1471
- stderr: "",
1472
- status: null,
1473
- error: resolutionError,
1474
- failure: "spawn",
1475
- spawnFailure,
1476
- }));
1477
- return;
1478
- }
1479
- let child;
1480
- // #2026: on POSIX, run the child in its OWN process group (detached).
1481
- // killTree/killPidTreeSync then signal the whole GROUP (-pid), which
1482
- // makes grandchildren (npm -> node, sh -> sleep, etc.) die with their
1483
- // tool instead of surviving every timeout as orphans. Detachment stops
1484
- // direct terminal-signal delivery, so EVERY POSIX pid registers for
1485
- // lifetime cleanup - pi's signal/exit handlers forward the kill,
1486
- // preserving die-with-host semantics.
1487
- const posixProcessGroup = process.platform !== "win32";
1488
- try {
1489
- child = spawn(spawnCmd, spawnArgs, {
1490
- cwd: spawnCwd,
1491
- env: spawnEnv,
1492
- windowsHide: true,
1493
- shell: false,
1494
- windowsVerbatimArguments,
1495
- detached: posixProcessGroup,
1496
- });
1497
- }
1498
- catch (err) {
1499
- // A SYNCHRONOUS spawn throw (Windows `spawn UNKNOWN`/EINVAL — the
1500
- // pidusage bug class, #533) must NOT reject this Promise: every caller
1501
- // relies on safeSpawnAsync never rejecting (they inspect result.error),
1502
- // and many invoke it fire-and-forget in best-effort/background paths, so
1503
- // a rejection here could surface as an unhandledRejection that crashes
1504
- // the host. Resolve the failure gracefully instead — same contract as an
1505
- // asynchronously-emitted `'error'` event (handled below).
1506
- const cause = toError(err);
1507
- void classifySpawnFailure(cause, { command, cwd: options?.cwd }).then((spawnFailure) => resolve({
1508
- stdout: "",
1509
- stderr: "",
1510
- status: null,
1511
- error: cause,
1512
- failure: "spawn",
1513
- spawnFailure,
1514
- }));
1515
- return;
1516
- }
1517
- // Keep historical open stdin by default. An opted-in payload is written
1518
- // completely and followed by EOF; input: "" closes stdin immediately.
1519
- if (options?.input !== undefined) {
1520
- child.stdin?.on("error", () => { });
1521
- child.stdin?.end(options.input);
1522
- }
1523
- // #2042: ownership is a SPAWN-TIME fact — a real child is alive with
1524
- // `PPid == ours` the instant `spawn` returns, while a test double's
1525
- // invented pid never is. Resolve it once, here, and reuse the verdict:
1526
- // the group escalation below must still fire after the direct child
1527
- // dies (#2027: a SIGTERM-hardy grandchild keeps the group alive), so it
1528
- // cannot re-read `/proc` at kill time.
1529
- const ownsChildPid = isOwnLiveChild(child.pid, "safe-spawn-register");
1530
- if (ownsChildPid && (posixProcessGroup || options?.lifetimeCoupled)) {
1531
- // #2026: POSIX registers unconditionally - detached children no
1532
- // longer receive terminal signals directly, so the lifetime
1533
- // cleanup's signal forwarding IS their die-with-host path.
1534
- installLifetimeCleanup();
1535
- lifetimeState.pids.add(child.pid);
1536
- }
1537
- // #620: bracket this spawn's lifetime with a short-interval CPU/RSS poll
1538
- // (started right here, stopped in finalize() below) so transient
1539
- // analyzer children (jscpd, knip, madge, gitleaks, etc.) — which live too
1540
- // briefly for heartbeat-cadence sampling to reliably catch — still get a
1541
- // peak/average resource reading. `startSpawnUsageSampler` itself is
1542
- // best-effort/never-throws by design, but this call site wraps it anyway
1543
- // (belt and suspenders: the sampling seam must never be the reason a real
1544
- // spawn fails) with a no-op fallback sampler.
1545
- //
1546
- // #2968: the sampler's polling lifetime is bounded by THIS spawn's own
1547
- // deadline plus the teardown grace, not by the child's willingness to
1548
- // exit. `finalize`/the `error` handler still stop it on every settle
1549
- // path, but none of those paths exist for a child that never settles —
1550
- // a hung `.cmd` shim whose tree survives `taskkill /F /T` kept polling
1551
- // for 5-6 hours in the report.
1552
- let usageSampler;
1553
- try {
1554
- usageSampler = startSpawnUsageSampler(
1555
- // The poll CADENCE is the sampler's own policy (it owns the backoff
1556
- // too); the DEADLINE is this function's. Passing `undefined` rather
1557
- // than importing the cadence constant keeps this module's view of
1558
- // the sampler at one export, which is also what every existing
1559
- // `vi.mock` of it provides.
1560
- child.pid, undefined, timeout + SPAWN_SAMPLER_TEARDOWN_GRACE_MS);
1561
- }
1562
- catch {
1563
- usageSampler = { stop: () => null };
1564
- }
1565
- const resourceLabel = options?.resourceLabel ?? command;
1566
- let killFailed = false;
1567
- /** Send one signal. INVARIANT (#3375 r3 / M3384-2): every signal SEND in
1568
- * `killTree` goes through this function. Exactly two `try`/`catch`
1569
- * blocks remain in there and neither contains a bare send: the Windows
1570
- * `await` guard around the taskkill child (its `catch` body sends only
1571
- * through `trySend`), and the group LIVENESS PROBE, whose `catch`
1572
- * computes a predicate rather than sending anything — the same shape as
1573
- * the module-level `groupIsGone`. Returns false instead of throwing, and
1574
- * records nothing itself: a refused signal is often ORDINARY (a negative-pid
1575
- * ESRCH means the group already exited and the direct-child fallback
1576
- * takes over), so only a send with no fallback left behind it pairs
1577
- * with `noteKillFailure`. */
1578
- const trySend = (send) => {
1579
- try {
1580
- send();
1581
- return true;
1582
- }
1583
- catch {
1584
- return false;
1585
- }
1586
- };
1587
- /** Every signal for this teardown was refused, so the child may still be
1588
- * running. One record per spawn — the reviewer's reproduction refused
1589
- * two kills (the fault's and then the timeout's) and a row per attempt
1590
- * would be a flood. A kind of its own rather than a field on
1591
- * `spawn-output-cap-truncated`: this fires on the abort and timeout
1592
- * teardowns too, which have nothing to do with an output cap. */
1593
- const noteKillFailure = (phase) => {
1594
- if (killFailed)
1595
- return;
1596
- killFailed = true;
1597
- incrementDegradationCount({
1598
- kind: "spawn-kill-failed",
1599
- subject: resourceLabel,
1600
- reason: `every signal available for the ${phase} teardown was refused; the child may still be running`,
1601
- metadata: { phase, pid: child.pid },
1602
- });
1603
- };
1604
- // On Windows, shell:true means child.pid is cmd.exe — child.kill() only
1605
- // kills the wrapper, leaving the actual subprocess (e.g. knip/npx) alive
1606
- // as an orphan. Use taskkill /F /T to kill the full process tree instead.
1607
- const killTree = async (phase) => {
1608
- if (isWindows && child.pid && child.pid > 0) {
1609
- const taskkill = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\taskkill.exe`;
1610
- try {
1611
- await new Promise((done) => {
1612
- const killer = spawn(taskkill, ["/F", "/T", "/PID", String(child.pid)], {
1613
- shell: false,
1614
- windowsHide: true,
1615
- stdio: "ignore",
1616
- });
1617
- killer.once("close", () => done());
1618
- killer.once("error", () => {
1619
- // E4: a throw HERE would leave the EventEmitter as an
1620
- // uncaught exception, not a rejection.
1621
- if (!trySend(() => child.kill("SIGKILL")))
1622
- noteKillFailure(phase);
1623
- done();
1624
- });
1625
- });
1626
- }
1627
- catch {
1628
- if (!trySend(() => child.kill("SIGKILL")))
1629
- noteKillFailure(phase);
1630
- }
1631
- }
1632
- else if (posixProcessGroup && ownsChildPid) {
1633
- // #2026: signal the whole process group. Grandchildren spawned
1634
- // by the tool share its group, so one signal reaches the whole
1635
- // tree; a negative-pid ESRCH means it already exited.
1636
- const pgid = -child.pid;
1637
- // #3375 round 3 (M3384-2): through the one sink, like every other
1638
- // send. A second ad-hoc `try`/`catch` here would be a rival
1639
- // signal-safety mechanism inside the same teardown — the shape this
1640
- // change deleted four of — and a later edit to this branch would not
1641
- // inherit `trySend`'s total send or its failure accounting.
1642
- // `trySend` returning false here is ORDINARY, not a failure: #2026's
1643
- // negative-pid ESRCH means the group already exited, so the direct
1644
- // child below is the normal fallback and nothing is recorded. Only
1645
- // the last-resort send can fail the teardown.
1646
- if (!trySend(() => process.kill(pgid, "SIGTERM"))) {
1647
- if (!trySend(() => child.kill("SIGTERM")))
1648
- noteKillFailure(phase);
1649
- }
1650
- // #2027 round-1: gate the SIGKILL escalation on GROUP liveness,
1651
- // not direct-child death - a tool can exit instantly on SIGTERM
1652
- // while a SIGTERM-hardy grandchild keeps the group alive. The
1653
- // timer stays REF'D for group mode (max 1s tail after resolve):
1654
- // it is the only backstop for this group once finalize deletes
1655
- // the pid from lifetimeState.
1656
- escalationTimer = setTimeout(() => {
1657
- // The WHOLE body, not just the signal: this runs in a bare
1658
- // timer callback, where any throw is an uncaught exception.
1659
- const escalated = trySend(() => {
1660
- let groupAlive = true;
1661
- try {
1662
- process.kill(pgid, 0);
1663
- }
1664
- catch {
1665
- groupAlive = false;
1666
- }
1667
- if (!groupAlive)
1668
- return;
1669
- teardownEscalated = true;
1670
- logLatency({
1671
- type: "phase",
1672
- phase: "spawn_group_kill_escalation",
1673
- filePath: "",
1674
- durationMs: 0,
1675
- metadata: { pgid: child.pid },
1676
- });
1677
- // Through the sink too (#3375 r3 / M3384-2): one vocabulary
1678
- // for every send in this function. A false here is ORDINARY
1679
- // — the group raced its own exit — so it is deliberately not
1680
- // escalated to `noteKillFailure`; the enclosing `trySend`
1681
- // already guarantees nothing escapes this timer callback.
1682
- trySend(() => process.kill(pgid, "SIGKILL"));
1683
- });
1684
- if (!escalated)
1685
- noteKillFailure(phase);
1686
- }, 1000);
1687
- }
1688
- else {
1689
- // E2/E3. This branch runs whenever `/proc` cannot prove pid
1690
- // ownership — every macOS host, and any Linux host with an
1691
- // unreadable `/proc` — so it is not a hypothetical path.
1692
- if (!trySend(() => child.kill("SIGTERM")))
1693
- noteKillFailure(phase);
1694
- escalationTimer = setTimeout(() => {
1695
- if (closed)
1696
- return;
1697
- teardownEscalated = true;
1698
- if (!trySend(() => child.kill("SIGKILL")))
1699
- noteKillFailure(phase);
1700
- }, 1000);
1701
- }
1702
- };
1703
- // Handle abort signal
1704
- const onAbort = () => {
1705
- aborted = true;
1706
- if (!killed && !child.killed) {
1707
- killed = true;
1708
- void killTree("abort");
1709
- }
1710
- };
1711
- abortSignal?.addEventListener("abort", onAbort, { once: true });
1712
- // Output-cap kills are awaited by finalize() below, just like
1713
- // timeout/abort kills. An armed, unmatched streaming matcher is the one
1714
- // exception: retention remains bounded, but the caller's timeout is the
1715
- // bound while the child gets a chance to emit its rescue line.
1716
- let killPromise;
1717
- const stopForOutputLimit = () => {
1718
- const waitingForStreamingMatch = streamingMatcher !== undefined && !streamingMatch;
1719
- if (outputTruncated &&
1720
- !waitingForStreamingMatch &&
1721
- !killed &&
1722
- !child.killed) {
1723
- killedForOutputCap = true;
1724
- killed = true;
1725
- killPromise = killTree("output-cap");
1726
- }
1727
- };
1728
- // #3375: one bounded, counted ledger row per command label the moment the
1729
- // cap first bites - the crash report named no command, no byte count and
1730
- // no cap value, so nothing in it could identify the chatty producer.
1731
- // `incrementDegradationCount` keeps one latest-reason entry per subject
1732
- // with an exact tally and writes durable rows only at powers of two, so a
1733
- // tool that trips the cap on every dispatch cannot flood the sink.
1734
- const recordOutputCapTrip = () => {
1735
- incrementDegradationCount({
1736
- kind: "spawn-output-cap-truncated",
1737
- subject: resourceLabel,
1738
- reason: `retained output reached the ${maxOutputBytes}-byte ${capFromCaller ? "caller" : "default"} cap after ${observedOutputBytes} bytes; child ${killedForOutputCap ? "terminated" : "left running"}`,
1739
- metadata: {
1740
- capBytes: maxOutputBytes,
1741
- capSource: capFromCaller ? "caller" : "default",
1742
- observedBytes: observedOutputBytes,
1743
- killed: killedForOutputCap,
1744
- },
1745
- });
1746
- };
1747
- // #3375: the fault path for a chunk handler that threw. Every statement
1748
- // here is total by construction - plain assignments, a ledger call that
1749
- // swallows its own failures, and `killTree()`, which is `async` so a
1750
- // synchronous fault inside it becomes a rejected promise rather than a
1751
- // throw - because a throw raised HERE would escape the same way the
1752
- // original RangeError did.
1753
- const failOutputHandler = (stream, error) => {
1754
- outputHandlerFailed = true;
1755
- // Retention is over. The bytes already in `stdout`/`stderr` stay as
1756
- // the bounded output: nothing renders from the retained head/tail
1757
- // again, because the re-entry guard returns before that can happen.
1758
- // That is also why `truncationStream` is deliberately NOT set here —
1759
- // only `renderOutput` reads it, and nothing renders after a fault, so
1760
- // the assignment was mutation-inert (removing it reds nothing).
1761
- outputTruncated = true;
1762
- incrementDegradationCount({
1763
- kind: "spawn-output-handler-fault",
1764
- subject: resourceLabel,
1765
- reason: `${stream} chunk handling threw ${error.name} after ${observedOutputBytes} bytes; retention stopped and the child was terminated`,
1766
- metadata: {
1767
- stream,
1768
- error: error.name,
1769
- capBytes: maxOutputBytes,
1770
- observedBytes: observedOutputBytes,
1771
- },
1772
- });
1773
- if (!killed && !child.killed) {
1774
- killedForOutputCap = true;
1775
- killed = true;
1776
- killPromise = killTree("handler-fault");
1777
- }
1778
- };
1779
- // Collect output
1780
- child.stdout?.setEncoding("utf-8");
1781
- child.stderr?.setEncoding("utf-8");
1782
- // #3375: a throw raised inside a stream `data` handler is delivered to
1783
- // the process, not to the caller awaiting this spawn - no `try`/`catch`
1784
- // around `await safeSpawnAsync(...)` can contain it, which is why a
1785
- // `RangeError: Invalid string length` from `appendOutput` terminated the
1786
- // Pi host on 2026-09-22. Every statement the handler runs lives inside
1787
- // this `try`, so ANY fault in the retention path (not just that one
1788
- // RangeError) becomes the bounded output-cap result the caller already
1789
- // knows how to read.
1790
- const onOutputChunk = (stream, data) => {
1791
- if (outputHandlerFailed)
1792
- return;
1793
- try {
1794
- observedOutputBytes += Buffer.byteLength(data);
1795
- const wasTruncated = outputTruncated;
1796
- matchStreamingChunk(stream, data);
1797
- if (stream === "stdout")
1798
- stdout = appendOutput(stream, stdout, data);
1799
- else
1800
- stderr = appendOutput(stream, stderr, data);
1801
- if (outputTruncated)
1802
- refreshRetainedOutputs();
1803
- stopForOutputLimit();
1804
- if (!wasTruncated && outputTruncated)
1805
- recordOutputCapTrip();
1806
- rearmIdleGrace?.();
1807
- }
1808
- catch (error) {
1809
- failOutputHandler(stream, toError(error));
1810
- }
1811
- };
1812
- child.stdout?.on("data", (data) => onOutputChunk("stdout", data));
1813
- child.stderr?.on("data", (data) => onOutputChunk("stderr", data));
1814
- // #1673 review round 3 (F2): registered HERE, at spawn time, alongside
1815
- // the other handlers above — not inside `waitForPipeIdle`. Real Node
1816
- // emits `close` a fraction of a millisecond after `exit` (both fire in
1817
- // the same "process exit" turn, well before any microtask), so a
1818
- // listener attached inside `waitForPipeIdle` — which `finalize` only
1819
- // reaches after `await killPromise`, at least one microtask later — is
1820
- // always attaching to an event that has already happened. That's why
1821
- // the round-2 `child.on("close", finish)` placed inside
1822
- // `waitForPipeIdle` never actually caught a real spawn's `close` early:
1823
- // every real call still paid the full grace window. Capturing the
1824
- // boolean here, before any await, means `waitForPipeIdle` can check
1825
- // "did close already happen" synchronously the moment it runs.
1826
- let closeSeen = false;
1827
- // #2010: WHEN death was observed - the stop-clock for teardown evidence
1828
- // and the discriminator for its outcome. A close that predates the
1829
- // budget-expired kill means the child exited on its own ("exited"), no
1830
- // matter that the timer callback raced and armed a kill anyway.
1831
- let closedAtMs;
1832
- child.on("close", () => {
1833
- closeSeen = true;
1834
- closedAtMs ??= Date.now();
1835
- });
1836
- // Timeout handling - KILL the process, don't just abandon it
1837
- const timeoutId = setTimeout(() => {
1838
- timedOut = true;
1839
- if (!killed && !child.killed) {
1840
- killed = true;
1841
- teardownStartedAtMs = Date.now();
1842
- killPromise = killTree("timeout");
1843
- }
1844
- }, timeout);
1845
- // #620: stop the poll and log peak/average CPU%+RSS for this invocation
1846
- // into the existing per-runner latency.log phase entries — best-effort,
1847
- // wrapped so a logging hiccup can never affect the resolved SpawnResult.
1848
- const finishResourceUsage = () => {
1849
- const summary = usageSampler.stop();
1850
- if (!summary)
1851
- return undefined;
1852
- try {
1853
- logLatency({
1854
- type: "phase",
1855
- phase: "spawn_resource_usage",
1856
- filePath: "",
1857
- durationMs: 0,
1858
- metadata: {
1859
- command: resourceLabel,
1860
- ...summary,
1861
- },
1862
- });
1863
- }
1864
- catch {
1865
- // best-effort logging only
1866
- }
1867
- return summary;
1868
- };
1869
- // #1656: wait for stdout/stderr to fall idle after exit instead of
1870
- // waiting for Node's "close" event (see the constants' doc comment
1871
- // above). Resolves immediately if the child has no pipes to drain.
1872
- //
1873
- // These timers stay REF'D, matching `timeoutId`/`escalationTimer`
1874
- // elsewhere in this function: the caller's own settlement depends on
1875
- // them firing, so unref'ing would let a short-lived host process (a
1876
- // one-shot CLI whose only other in-flight work was this spawn) exit
1877
- // the event loop before `finish` ever runs — silently abandoning this
1878
- // promise instead of merely leaking a handle. Both paths to `finish`
1879
- // clear both timers, so nothing outlives settlement either way.
1880
- const waitForPipeIdle = () => {
1881
- if (!child.stdout && !child.stderr)
1882
- return Promise.resolve();
1883
- // #1673 review round 3 (F2): `close` fires a fraction of a
1884
- // millisecond after `exit` for a real Node child — well before
1885
- // `finalize`'s `await killPromise` ever yields, i.e. before this
1886
- // function is even called. By the time we get here, `closeSeen`
1887
- // (set by the listener registered at spawn time, above) already
1888
- // reflects reality for the overwhelming majority of real spawns:
1889
- // nothing is holding this child's stdio open, so don't pay out the
1890
- // 100ms grace window for a signal that already arrived.
1891
- if (closeSeen)
1892
- return Promise.resolve();
1893
- return new Promise((finishIdleWait) => {
1894
- let settled = false;
1895
- let graceTimer;
1896
- const capTimer = setTimeout(() => {
1897
- finish();
1898
- }, EXIT_PIPE_IDLE_MAX_WAIT_MS);
1899
- const finish = () => {
1900
- if (settled)
1901
- return;
1902
- settled = true;
1903
- rearmIdleGrace = undefined;
1904
- if (graceTimer)
1905
- clearTimeout(graceTimer);
1906
- clearTimeout(capTimer);
1907
- // Optional chaining, not a bare call: `child` is a real Node
1908
- // ChildProcess (always an EventEmitter, always has this) in
1909
- // production, but several existing test doubles across the
1910
- // suite fake stdio-bearing children as plain objects with only
1911
- // the handful of methods (`on`, `kill`, ...) their scenario
1912
- // needs — no `removeListener`. Skipping cleanup on those is
1913
- // harmless (the fake is torn down with the test either way);
1914
- // crashing here from inside a timer callback is not: it would
1915
- // throw before `finishIdleWait()` runs, hanging every caller's
1916
- // `safeSpawnAsync` promise (#1673 review round 2 caught this
1917
- // live in tests/clients/installer/version-drift.test.ts).
1918
- child.removeListener?.("close", finish);
1919
- finishIdleWait();
1920
- };
1921
- // The `closeSeen` fast-path above catches the common case (close
1922
- // already happened by the time we get here). This listener is
1923
- // the fallback for the rare case where `close` genuinely lands
1924
- // DURING the wait — e.g. a child whose stdio takes a beat
1925
- // longer than usual to release, but still well under the grace
1926
- // window. `close` is a stronger signal than "no data for
1927
- // EXIT_PIPE_IDLE_GRACE_MS", so finishing on it here still beats
1928
- // waiting out the timer. The anti-hang property is untouched
1929
- // either way: a daemonized descendant holding the pipe open is
1930
- // EXACTLY the case where `close` never fires at all, so the
1931
- // grace/cap timers still bound that case as before.
1932
- child.on("close", finish);
1933
- rearmIdleGrace = () => {
1934
- if (graceTimer)
1935
- clearTimeout(graceTimer);
1936
- graceTimer = setTimeout(finish, EXIT_PIPE_IDLE_GRACE_MS);
1937
- };
1938
- rearmIdleGrace();
1939
- });
1940
- };
1941
- // Process completion
1942
- //
1943
- // #1656 x #1651/#1670: finalize off BOTH `exit` and `close`, not just
1944
- // one — each covers a shape the other misses.
1945
- // - `exit` is what unblocks the #1656 hang: a daemonized descendant
1946
- // that inherited our stdout/stderr pipe can hold that fd open
1947
- // forever, so `close` (which waits for every fd referencing the
1948
- // child to release) never fires even though the child we spawned
1949
- // is long dead. `exit` always fires once the child itself exits,
1950
- // independent of who still holds its pipes.
1951
- // - `close` is still wired because a child that never actually
1952
- // launched (bad binary, EACCES, ...) gets NO `exit` event at all —
1953
- // Node only emits `error` then `close(code<0, null)` for that
1954
- // shape (#1651/#1670's shape guard below decides from exactly
1955
- // this). `exit`-only would make that guard unreachable.
1956
- // Whichever fires first drives `finalize`; `settled` (distinct from
1957
- // `resolved`, which `finalize` itself owns) stops the second event
1958
- // from dispatching a redundant, concurrently-racing `finalize` call —
1959
- // double-invoking it would double-run `waitForPipeIdle` and
1960
- // `finishResourceUsage` against the same child.
1961
- let settled = false;
1962
- const onChildSettle = (code, signal) => {
1963
- if (settled || resolved)
1964
- return;
1965
- settled = true;
1966
- closed = true;
1967
- clearTimeout(timeoutId);
1968
- abortSignal?.removeEventListener("abort", onAbort);
1969
- if (child.pid)
1970
- lifetimeState.pids.delete(child.pid);
1971
- void finalize(code, signal);
1972
- };
1973
- const finalize = async (code, signal) => {
1974
- // Belt, not the decision: whichever path settles `resolved` FIRST
1975
- // wins. A healthy run's outcome must never be discarded by a late,
1976
- // unrelated 'error' (e.g. a post-exit kill() that itself failed
1977
- // with EPERM, #1651 review F4) — so this only bails when something
1978
- // else has ALREADY resolved, never on `error` merely having fired.
1979
- await killPromise;
1980
- if (resolved)
1981
- return;
1982
- // #1109: the child has exited - clear the non-Windows escalation
1983
- // timer so it doesn't linger as a ref'd handle. #2027 EXCEPTION:
1984
- // in POSIX group mode the timer is the liveness-gated SIGKILL
1985
- // backstop for SIGTERM-hardy grandchildren; it stays armed and
1986
- // self-cleans within 1s of firing (max 1s ref'd tail).
1987
- if (escalationTimer && !posixProcessGroup) {
1988
- clearTimeout(escalationTimer);
1989
- }
1990
- // #1673 review F1: latch the verdict as DECIDED here, before the
1991
- // idle-pipe wait — not after it. `code`/`signal`/`timedOut`/`aborted`
1992
- // are already fixed by this point (they don't change during the
1993
- // wait), so nothing below depends on staying unresolved across it.
1994
- // Leaving `resolved` false across the wait would reopen the exact
1995
- // race #1651 review F4 closed: a late, unrelated 'error' (e.g. a
1996
- // post-exit kill() failing with EPERM) landing inside the
1997
- // idle-grace window (bounded, but up to EXIT_PIPE_IDLE_MAX_WAIT_MS)
1998
- // would steal an already-decided healthy verdict. Pre-#1656 that
1999
- // race window was one microtask — the gap between `close` firing
2000
- // and `resolve()` being called synchronously in the same handler.
2001
- // This closes the window back down to that same shape instead of
2002
- // widening it to the whole wait.
2003
- resolved = true;
2004
- await waitForPipeIdle();
2005
- const resourceUsage = finishResourceUsage();
2006
- const outputInfo = {
2007
- ...(outputTruncated ? { outputTruncated: true } : {}),
2008
- ...(killedForOutputCap ? { killedForOutputCap: true } : {}),
2009
- ...(killFailed ? { killFailed: true } : {}),
2010
- };
2011
- const streamingMatchInfo = streamingMatch ? { streamingMatch: true } : {};
2012
- // #1816: surface the signal name as a field on every path where one
2013
- // exists, so callers can NAME it instead of scraping `error.message`.
2014
- const signalInfo = signal ? { signal } : {};
2015
- if (timedOut) {
2016
- const cause = new Error(`Process timed out after ${timeout}ms (killed with ${signal || "SIGTERM"})`);
2017
- // #2010: teardown evidence lives ONLY on the timeout path - it
2018
- // describes how long the tree took to die after the budget
2019
- // expired, and how it died ("exited" = the child beat the signal;
2020
- // "killed-by-signal" / "escalate-kill" = how WE ended it).
2021
- const timeoutTeardown = resolveTeardownOutcome({
2022
- killStartedAtMs: teardownStartedAtMs,
2023
- deathObservedAtMs: closedAtMs,
2024
- escalated: teardownEscalated,
2025
- });
2026
- resolve({
2027
- stdout,
2028
- stderr,
2029
- status: null,
2030
- error: cause,
2031
- failure: "timeout",
2032
- ...signalInfo,
2033
- ...(timeoutTeardown && { timeoutTeardown }),
2034
- spawnFailure: new SpawnFailureError("timeout", cause.message, cause),
2035
- ...outputInfo,
2036
- ...streamingMatchInfo,
2037
- resourceUsage,
2038
- });
2039
- }
2040
- else if (aborted) {
2041
- const cause = new Error("Spawn aborted");
2042
- resolve({
2043
- stdout,
2044
- stderr,
2045
- status: null,
2046
- error: cause,
2047
- failure: "aborted",
2048
- ...signalInfo,
2049
- spawnFailure: new SpawnFailureError("killed", cause.message, cause),
2050
- ...outputInfo,
2051
- ...streamingMatchInfo,
2052
- resourceUsage,
2053
- });
2054
- }
2055
- else if (signal) {
2056
- const cause = new Error(`Process killed by signal: ${signal}`);
2057
- resolve({
2058
- stdout,
2059
- stderr,
2060
- status: null,
2061
- error: cause,
2062
- failure: "signal",
2063
- ...signalInfo,
2064
- spawnFailure: new SpawnFailureError("killed", cause.message, cause),
2065
- ...outputInfo,
2066
- ...streamingMatchInfo,
2067
- resourceUsage,
2068
- });
2069
- }
2070
- else if (code === null || code < 0) {
2071
- // #1651 review F3: a real completed process never exits with a
2072
- // null or negative code (Node reports the negated libuv/errno for
2073
- // a child that never launched — e.g. ENOENT: -2 on Linux, -4058
2074
- // on Windows). This decides from that SHAPE alone, independent of
2075
- // whether/when 'error' has fired — Node's docs leave the
2076
- // 'error'/'close' ordering for a failed spawn unspecified, and a
2077
- // same-microtask re-check (the #1651 first-round fix) still loses
2078
- // if 'error' lands a tick later via setImmediate/nextTick.
2079
- // Reuse `pendingSpawnError` when 'error' already landed (a more
2080
- // informative Error — ENOENT vs EACCES vs other); otherwise
2081
- // synthesize an ENOENT-shaped one so a genuinely missing tool is
2082
- // never read as a clean, answered run.
2083
- const cause = pendingSpawnError ?? synthesizeEnoentError(command);
2084
- const spawnFailure = await classifySpawnFailure(cause, {
2085
- command,
2086
- cwd: options?.cwd,
2087
- });
2088
- resolve({
2089
- stdout,
2090
- stderr,
2091
- status: code,
2092
- error: cause,
2093
- failure: "spawn",
2094
- spawnFailure,
2095
- ...outputInfo,
2096
- ...streamingMatchInfo,
2097
- resourceUsage,
2098
- });
2099
- }
2100
- else {
2101
- resolve({
2102
- stdout,
2103
- stderr,
2104
- status: code,
2105
- ...outputInfo,
2106
- ...streamingMatchInfo,
2107
- resourceUsage,
2108
- });
2109
- }
2110
- };
2111
- child.on("exit", onChildSettle);
2112
- child.on("close", onChildSettle);
2113
- child.on("error", (err) => {
2114
- // Capture unconditionally — even when `close` already resolved, this
2115
- // keeps the most recent Error available for anyone reading state
2116
- // mid-flight, and costs nothing (the assignment, not a resolve).
2117
- pendingSpawnError = err;
2118
- // A completed run's own verdict must never be downgraded by an
2119
- // unrelated LATE error (#1651 review F4 — e.g. a post-exit kill()
2120
- // failing with EPERM after `close(0, null)` already answered).
2121
- if (resolved)
2122
- return;
2123
- resolved = true;
2124
- closed = true;
2125
- clearTimeout(timeoutId);
2126
- abortSignal?.removeEventListener("abort", onAbort);
2127
- // #2027: group-mode timer stays armed as the grandchild backstop.
2128
- if (escalationTimer && !posixProcessGroup) {
2129
- clearTimeout(escalationTimer);
2130
- }
2131
- if (child.pid)
2132
- lifetimeState.pids.delete(child.pid);
2133
- const resourceUsage = finishResourceUsage();
2134
- let failure = "spawn";
2135
- if (aborted)
2136
- failure = "aborted";
2137
- else if (timedOut)
2138
- failure = "timeout";
2139
- const controlFailure = aborted
2140
- ? new SpawnFailureError("killed", err.message, err)
2141
- : timedOut
2142
- ? new SpawnFailureError("timeout", err.message, err)
2143
- : undefined;
2144
- const finish = (spawnFailure) => resolve({
2145
- stdout,
2146
- stderr,
2147
- status: null,
2148
- error: err,
2149
- failure,
2150
- spawnFailure,
2151
- ...(outputTruncated ? { outputTruncated: true } : {}),
2152
- ...(killedForOutputCap ? { killedForOutputCap: true } : {}),
2153
- ...(killFailed ? { killFailed: true } : {}),
2154
- ...(streamingMatch ? { streamingMatch: true } : {}),
2155
- resourceUsage,
2156
- });
2157
- if (controlFailure)
2158
- finish(controlFailure);
2159
- else {
2160
- void classifySpawnFailure(err, { command, cwd: options?.cwd }).then(finish);
2161
- }
2162
- });
2163
- });
2164
- }
2165
- /**
2166
- * Run multiple commands concurrently with limited concurrency.
2167
- *
2168
- * This prevents resource contention when running many linters.
2169
- * Uses async spawn with concurrency limiting built-in.
2170
- *
2171
- * @example
2172
- * const results = await safeSpawnBatch([
2173
- * { command: "biome", args: ["check", "file.ts"] },
2174
- * { command: "ruff", args: ["check", "file.py"] },
2175
- * ], 3); // Max 3 concurrent
2176
- */
2177
- export async function safeSpawnBatch(commands, concurrency = 3) {
2178
- const results = [];
2179
- // Process in batches to limit concurrent processes
2180
- for (let i = 0; i < commands.length; i += concurrency) {
2181
- const batch = commands.slice(i, i + concurrency);
2182
- const batchResults = await Promise.all(batch.map(({ command, args, options }) => safeSpawnAsync(command, args, options)));
2183
- results.push(...batchResults);
2184
- }
2185
- return results;
2186
- }
2187
- /**
2188
- * Check if a command is available in PATH (async version)
2189
- */
2190
- export async function isCommandAvailableAsync(command) {
2191
- const finder = process.platform === "win32" ? "where" : "which";
2192
- const result = await safeSpawnAsync(finder, [command], { timeout: 5000 });
2193
- return result.status === 0 && !result.error;
2194
- }
2195
- /**
2196
- * Find the full path to a command (async version)
2197
- */
2198
- export async function findCommandAsync(command) {
2199
- const finder = process.platform === "win32" ? "where" : "which";
2200
- const result = await safeSpawnAsync(finder, [command], { timeout: 5000 });
2201
- if (result.status !== 0 || result.error)
2202
- return null;
2203
- // Take first line (first match)
2204
- return result.stdout.trim().split("\n")[0] || null;
2205
- }
2206
- // ============================================================================
2207
- // SYNC VERSION (Deprecated - Blocking, for backward compatibility)
2208
- // ============================================================================
2209
- /**
2210
- * ⚠️ DEPRECATED: Use safeSpawnAsync instead.
2211
- *
2212
- * This blocks the entire Node.js event loop until the process exits.
2213
- * If the process hangs, pi will freeze.
2214
- *
2215
- * Kept for backward compatibility during migration (today's only caller:
2216
- * `test-runner-client.ts`'s synchronous pytest-on-PATH probe, called from a
2217
- * sync detection path with many sync callers/tests — not a trivial async
2218
- * migration, see #817 follow-up discussion).
2219
- *
2220
- * #817: the Windows branch used to build a `cmd.exe`/`shell:true` command
2221
- * line from unvalidated caller input (CodeQL #17/#18/#19), same unsoundness
2222
- * as the async version had. It now shares the exact same resolution/
2223
- * validation seams as `safeSpawnAsync` — `resolveWindowsCommand` (cached
2224
- * PATH+PATHEXT walk), direct `spawnSync(resolvedPath, args, { shell: false })`
2225
- * for `.exe`/`.com`, the pinned-cmd.exe wrapper + `findCmdUnsafeValue`
2226
- * rejection for `.cmd`/`.bat`, and a synthesized ENOENT when unresolvable.
2227
- * No `shell: true` anywhere.
2228
- */
2229
- export function safeSpawn(command, args, options) {
2230
- const spawnEnv = getSpawnEnvironment(options?.env);
2231
- if (process.platform === "win32") {
2232
- // See the matching comment in safeSpawnAsync: the child's cwd doesn't
2233
- // need OUR validation (Windows resolves it natively), so an unprovable
2234
- // drive-relative cwd falls back to the raw value instead of failing the
2235
- // whole spawn before ever attempting it (#1201). Command resolution
2236
- // below intentionally still uses the ORIGINAL raw cwd and fails closed
2237
- // for PATH entries that would need it to be canonical.
2238
- // See safeSpawnAsync for why there is no `?? process.cwd()` third arm.
2239
- const spawnCwd = resolveEffectiveWindowsCwd(options?.cwd, spawnEnv) ?? options?.cwd;
2240
- const resolved = resolveWindowsCommand(command, options?.cwd, spawnEnv);
2241
- if (!resolved) {
2242
- const error = synthesizeEnoentError(command);
2243
- return {
2244
- stdout: "",
2245
- stderr: "",
2246
- status: null,
2247
- error,
2248
- failure: "spawn",
2249
- spawnFailure: classifySpawnFailureSync(error, {
2250
- command,
2251
- cwd: options?.cwd,
2252
- }),
2253
- };
2254
- }
2255
- let spawnCmd;
2256
- let spawnArgs;
2257
- let windowsVerbatimArguments = false;
2258
- if (resolved.ext === ".cmd" || resolved.ext === ".bat") {
2259
- // Validate the value that actually gets interpolated into the /c
2260
- // line — the RESOLVED path, not the caller's original (possibly
2261
- // extensionless) `command` string — plus every arg.
2262
- const unsafeValue = findCmdUnsafeValue(resolved.resolvedPath, args);
2263
- if (unsafeValue !== undefined) {
2264
- const error = new Error(`Refusing to spawn "${resolved.resolvedPath}" via cmd.exe: ` +
2265
- `${JSON.stringify(unsafeValue)} contains a character ("` +
2266
- `, %, !, or CR/LF) that cannot be safely escaped on a ` +
2267
- `cmd.exe /c command line (CWE-78, #817). Rename/quote the ` +
2268
- "value or invoke the tool without going through cmd.exe.");
2269
- return {
2270
- stdout: "",
2271
- stderr: "",
2272
- status: null,
2273
- error,
2274
- failure: "spawn",
2275
- spawnFailure: classifySpawnFailureSync(error, {
2276
- command,
2277
- cwd: options?.cwd,
2278
- }),
2279
- };
2280
- }
2281
- spawnCmd = `${process.env.SystemRoot ?? "C:\\Windows"}\\System32\\cmd.exe`;
2282
- spawnArgs = [
2283
- "/d",
2284
- "/s",
2285
- "/c",
2286
- buildWindowsShellCommand(resolved.resolvedPath, args),
2287
- ];
2288
- windowsVerbatimArguments = true;
2289
- }
2290
- else {
2291
- ensureUtf8ConsoleCodePageOnce();
2292
- spawnCmd = resolved.resolvedPath;
2293
- spawnArgs = args;
2294
- }
2295
- const result = spawnSync(spawnCmd, spawnArgs, {
2296
- ...options,
2297
- cwd: spawnCwd,
2298
- env: spawnEnv,
2299
- encoding: "utf-8",
2300
- shell: false,
2301
- windowsHide: true,
2302
- windowsVerbatimArguments,
2303
- });
2304
- const spawnFailure = result.error
2305
- ? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
2306
- : undefined;
2307
- return {
2308
- stdout: result.stdout?.toString() || "",
2309
- stderr: result.stderr?.toString() || "",
2310
- status: result.status,
2311
- error: result.error,
2312
- ...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
2313
- };
2314
- }
2315
- const result = spawnSync(command, args, {
2316
- ...options,
2317
- // Explicit override, not just the spread above: `options.env` alone
2318
- // would otherwise reach the child as a full replacement (no
2319
- // process.env merge, no PATH) instead of the merged environment the
2320
- // resolver/Windows branch above both use (#1201) — the module's
2321
- // "resolver and child receive the same merged environment" invariant
2322
- // must hold on every platform, not just Windows.
2323
- env: spawnEnv,
2324
- encoding: "utf-8",
2325
- shell: false,
2326
- windowsHide: true,
2327
- });
2328
- const spawnFailure = result.error
2329
- ? classifySpawnFailureSync(result.error, { command, cwd: options?.cwd })
2330
- : undefined;
2331
- return {
2332
- stdout: result.stdout?.toString() || "",
2333
- stderr: result.stderr?.toString() || "",
2334
- status: result.status,
2335
- error: result.error,
2336
- ...(spawnFailure ? { failure: "spawn", spawnFailure } : {}),
2337
- };
2338
- }
2339
- /**
2340
- * Check if a command is available in PATH (sync version - deprecated)
2341
- * @deprecated Use isCommandAvailableAsync
2342
- */
2343
- export function isCommandAvailable(command) {
2344
- const result = safeSpawn(process.platform === "win32" ? "where" : "which", [command], { timeout: 5000 });
2345
- return result.status === 0;
2346
- }
2347
- /**
2348
- * Find the full path to a command (sync version - deprecated)
2349
- * @deprecated Use findCommandAsync
2350
- */
2351
- export function findCommand(command) {
2352
- const finder = process.platform === "win32" ? "where" : "which";
2353
- const result = safeSpawn(finder, [command], { timeout: 5000 });
2354
- if (result.status !== 0)
2355
- return null;
2356
- return result.stdout.trim().split("\n")[0] || null;
2357
- }