From 056f0bd22db369ccaac2319dcb8183419feb4eae Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 1 Feb 2026 11:33:42 +0100 Subject: [PATCH] feat(shell): migrated shell execution to native brush-core bindings with streaming support - Added native shell execution API via brush-core with streaming output support and configurable timeouts. - Migrated bash executor from persistent ptree-based shell sessions to native bindings with improved performance and simplified architecture. - Removed persistent shell session module and replaced with native brush-core implementation for better resource management. - Added executeShell() and abortShellExecution() functions to native bindings for shell command execution with cancellation support. - Implemented shell execution with support for environment variables, working directory changes, and timeout handling. --- Cargo.lock | 1576 ++++++++++++++++- crates/pi-natives/Cargo.toml | 6 +- crates/pi-natives/src/lib.rs | 1 + crates/pi-natives/src/shell.rs | 288 +++ packages/coding-agent/CHANGELOG.md | 13 + .../coding-agent/src/exec/bash-executor.ts | 185 +- .../coding-agent/src/exec/shell-session.ts | 609 ------- .../coding-agent/test/shell-session.test.ts | 36 - packages/coding-agent/test/tools.test.ts | 20 +- packages/natives/CHANGELOG.md | 7 + packages/natives/src/index.ts | 13 +- packages/natives/src/native.ts | 8 + packages/natives/src/shell/index.ts | 33 + packages/natives/src/shell/types.ts | 27 + 14 files changed, 2041 insertions(+), 781 deletions(-) create mode 100644 crates/pi-natives/src/shell.rs delete mode 100644 packages/coding-agent/src/exec/shell-session.ts delete mode 100644 packages/coding-agent/test/shell-session.test.ts create mode 100644 packages/natives/src/shell/index.ts create mode 100644 packages/natives/src/shell/types.ts diff --git a/Cargo.lock b/Cargo.lock index e7e54cf20..08c7c35e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -36,6 +36,80 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "archery" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e0a5f99dfebb87bb342d0f53bb92c81842e100bbb915223e38349580e5441d" +dependencies = [ + "triomphe", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "astral-tl" version = "0.7.11" @@ -45,6 +119,28 @@ dependencies = [ "memchr", ] +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -57,6 +153,19 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "bincode" version = "1.3.3" @@ -87,6 +196,60 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "brush-core" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bb3a9488ef37cd8b951748d861362af5b1d5a815ea325f83c1c4bbb7da5e328" +dependencies = [ + "async-recursion", + "async-trait", + "brush-parser", + "cached", + "cfg-if", + "chrono", + "clap", + "command-fds", + "fancy-regex", + "futures", + "getrandom", + "homedir", + "hostname", + "indexmap", + "itertools", + "nix", + "normalize-path", + "os_pipe", + "procfs", + "rand 0.9.2", + "rlimit", + "rpds", + "strum", + "strum_macros", + "terminfo", + "thiserror", + "tokio", + "tracing", + "uucore", + "uuid", + "uzers", + "whoami", +] + +[[package]] +name = "brush-parser" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6924d0efd702b4fe3d65f3a63c03520edfe53b67d0955b9d3f8e66b192a410ff" +dependencies = [ + "cached", + "indenter", + "peg", + "thiserror", + "tracing", + "utf8-chars", +] + [[package]] name = "bstr" version = "1.12.1" @@ -98,6 +261,12 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + [[package]] name = "bytemuck" version = "1.25.0" @@ -110,18 +279,143 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" +[[package]] +name = "bytes" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" + +[[package]] +name = "cached" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" +dependencies = [ + "ahash", + "cached_proc_macro", + "cached_proc_macro_types", + "hashbrown 0.15.5", + "once_cell", + "thiserror", + "web-time", +] + +[[package]] +name = "cached_proc_macro" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9225bdcf4e4a9a4c08bf16607908eb2fbf746828d5e0b5e019726dbf6571f201" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cached_proc_macro_types" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade8366b8bd5ba243f0a58f036cc0ca8a2f069cff1a2351ef1cac6b083e16fc0" + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "clap" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75ca66430e33a14957acc24c5077b503e7d374151b2b4b3a10c83b4ceb4be0e" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793207c7fa6300a0608d1080b858e5fdbe713cdc1c8db9fb17777d8a13e63df0" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" + [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "command-fds" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f849b92c694fe237ecd8fafd1ba0df7ae0d45c1df6daeb7f68ed4220d51640bd" +dependencies = [ + "nix", + "thiserror", +] + [[package]] name = "convert_case" version = "0.10.0" @@ -131,6 +425,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crc32fast" version = "1.5.0" @@ -181,6 +481,52 @@ version = "0.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "dtor" version = "0.1.1" @@ -226,6 +572,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "fancy-regex" version = "0.16.2" @@ -252,6 +608,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "flate2" version = "1.1.8" @@ -262,12 +624,63 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8137a6d5a2c50d6b0ebfcb9aaa91a28154e0a70605f112d30cb0cd4a78670477" +dependencies = [ + "fluent-bundle", + "unic-langid", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -380,9 +793,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -395,6 +810,12 @@ dependencies = [ "weezl", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "globset" version = "0.4.18" @@ -445,6 +866,17 @@ dependencies = [ "memmap2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -453,7 +885,42 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "homedir" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68df315d2857b2d8d2898be54a85e1d001bbbe0dbb5f8ef847b48dd3a23c4527" +dependencies = [ + "cfg-if", + "nix", + "widestring", + "windows", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.2.1", ] [[package]] @@ -493,6 +960,36 @@ dependencies = [ "markup5ever", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "ignore" version = "0.4.25" @@ -537,6 +1034,66 @@ dependencies = [ "quick-error", ] +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.180" @@ -550,9 +1107,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ "cfg-if", - "windows-link", + "windows-link 0.2.1", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.0", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "lock_api" version = "0.4.14" @@ -574,7 +1160,7 @@ version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -621,6 +1207,12 @@ dependencies = [ "libc", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -631,6 +1223,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -705,12 +1308,59 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nohash-hasher" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "normalize-path" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5438dd2b2ff4c6df6e1ce22d825ed2fa93ee2922235cc45186991717f0a892d" + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -720,12 +1370,43 @@ dependencies = [ "autocfg", ] +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "os_display" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad5fd71b79026fb918650dde6d125000a233764f1c2f1659a1c71118e33ea08f" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -744,9 +1425,46 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "peg" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9928cfca101b36ec5163e70049ee5368a8a1c3c6efc9ca9c5f9cc2f816152477" +dependencies = [ + "peg-macros", + "peg-runtime", +] + +[[package]] +name = "peg-macros" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6298ab04c202fa5b5d52ba03269fb7b74550b150323038878fe6c372d8280f71" +dependencies = [ + "peg-runtime", + "proc-macro2", + "quote", +] + +[[package]] +name = "peg-runtime" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "132dca9b868d927b35b5dd728167b2dee150eb1ad686008fc71ccb298b776fca" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", ] [[package]] @@ -755,18 +1473,38 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.5", ] [[package]] @@ -776,7 +1514,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", ] [[package]] @@ -792,6 +1552,7 @@ dependencies = [ name = "pi-natives" version = "9.6.4" dependencies = [ + "brush-core", "bstr", "globset", "grep-matcher", @@ -800,9 +1561,12 @@ dependencies = [ "html-to-markdown-rs", "ignore", "image", + "libc", "napi", "napi-build", "napi-derive", + "os_pipe", + "phf 0.11.3", "rayon", "syntect", "unicode-segmentation", @@ -834,6 +1598,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "precomputed-hash" version = "0.1.1" @@ -849,6 +1622,31 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" +dependencies = [ + "bitflags", + "chrono", + "flate2", + "hex", + "procfs-core", + "rustix 0.38.44", +] + +[[package]] +name = "procfs-core" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" +dependencies = [ + "bitflags", + "chrono", + "hex", +] + [[package]] name = "pxfm" version = "0.1.27" @@ -879,6 +1677,50 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom", +] + [[package]] name = "rayon" version = "1.11.0" @@ -908,6 +1750,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.2" @@ -937,12 +1788,62 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "rlimit" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7043b63bd0cd1aaa628e476b80e6d4023a3b50eb32789f2728908107bd0c793a" +dependencies = [ + "libc", +] + +[[package]] +name = "rpds" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e75f485e819d4d3015e6c0d55d02a4fd3db47c1993d9e603e0361fba2bffb34" +dependencies = [ + "archery", +] + [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -958,6 +1859,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.27" @@ -971,6 +1878,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -993,6 +1901,22 @@ dependencies = [ "syn", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -1025,7 +1949,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.13.1", "precomputed-hash", ] @@ -1035,12 +1959,36 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "syn" version = "2.0.114" @@ -1081,6 +2029,28 @@ dependencies = [ "utf-8", ] +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix 1.1.3", + "windows-sys 0.60.2", +] + +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf 0.11.3", + "phf_codegen 0.11.3", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -1101,13 +2071,105 @@ dependencies = [ "syn", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + [[package]] name = "tokio" version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "triomphe" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd69c5aa8f924c7519d6372789a74eac5b94fb0f8fcf0d4a97eb0bfc3e785f39" + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", ] [[package]] @@ -1134,12 +2196,86 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf8-chars" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe49e006d6df172d7f14794568a90fe41e05a1fa9e03dc276fa6da4bb747ec3" +dependencies = [ + "arrayvec", +] + [[package]] name = "utf8-width" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uucore" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9032bf981784f22fcc5ddc7e74b7cf3bae3d5f44a48d2054138ed38068b9f4e0" +dependencies = [ + "bigdecimal", + "clap", + "fluent", + "fluent-bundle", + "glob", + "itertools", + "nix", + "num-traits", + "number_prefix", + "os_display", + "thiserror", + "unic-langid", + "uucore_procs", + "wild", +] + +[[package]] +name = "uucore_procs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c933945fdac5b7779eae1fc746146e61f5b0298deb6ede002ce0b6e93e1b3bfc" +dependencies = [ + "proc-macro2", + "quote", + "uuhelp_parser", +] + +[[package]] +name = "uuhelp_parser" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beda381dd5c7927f8682f50b055b0903bb694ba5a4b27fad1b4934bc4fbf7b8d" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uzers" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8275fb1afee25b4111d2dc8b5c505dbbc4afd0b990cb96deb2d88bff8be18d" +dependencies = [ + "libc", + "log", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1156,6 +2292,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -1165,14 +2307,85 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" dependencies = [ - "phf", - "phf_codegen", + "phf 0.13.1", + "phf_codegen 0.13.1", "string_cache", "string_cache_codegen", ] @@ -1183,30 +2396,345 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "wild" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3131afc8c575281e1e80f36ed6a092aa502c08b18ed7524e86fbbb12bb410e1" +dependencies = [ + "glob", +] + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -1243,6 +2771,22 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "serde", + "zerofrom", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/crates/pi-natives/Cargo.toml b/crates/pi-natives/Cargo.toml index 012272726..cbaf2ccac 100644 --- a/crates/pi-natives/Cargo.toml +++ b/crates/pi-natives/Cargo.toml @@ -13,8 +13,11 @@ crate-type = ["cdylib"] workspace = true [dependencies] -napi = { version = "3", features = ["napi10", "tokio_rt"] } +napi = { version = "3", features = ["napi10", "tokio_rt", "tokio_time"] } napi-derive = "3" +brush-core = "0.3.5" +libc = "0.2" +os_pipe = "1" grep-regex = "0.1" grep-searcher = "0.1" grep-matcher = "0.1" @@ -36,6 +39,7 @@ syntect = { version = "5.3", default-features = false, features = [ "regex-fancy", ] } html-to-markdown-rs = { version = "2.24", default-features = false } +phf = { version = "0.11", features = ["macros"] } [build-dependencies] napi-build = "2" diff --git a/crates/pi-natives/src/lib.rs b/crates/pi-natives/src/lib.rs index 3e6b83fc1..8b5dc48d6 100644 --- a/crates/pi-natives/src/lib.rs +++ b/crates/pi-natives/src/lib.rs @@ -26,4 +26,5 @@ pub mod highlight; pub mod html; pub mod image; pub mod keys; +pub mod shell; pub mod text; diff --git a/crates/pi-natives/src/shell.rs b/crates/pi-natives/src/shell.rs new file mode 100644 index 000000000..343de1d26 --- /dev/null +++ b/crates/pi-natives/src/shell.rs @@ -0,0 +1,288 @@ +//! Brush-based shell execution exported via N-API. +//! +//! # Overview +//! Executes shell commands in a non-interactive brush-core shell, streaming +//! output back to JavaScript via a threadsafe callback. +//! +//! # Example +//! ```ignore +//! const result = await natives.executeShell({ command: "ls" }, (chunk) => { +//! console.log(chunk); +//! }); +//! ``` + +use std::{ + collections::HashMap, + io::Read, + sync::{LazyLock, Mutex}, + time::Duration, +}; + +use brush_core::{ + CreateOptions, OpenFile, OpenFiles, ProcessGroupPolicy, Shell, ShellValue, ShellVariable, +}; +use napi::{ + bindgen_prelude::*, + threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode}, + tokio::{self, task, time}, +}; +use napi_derive::napi; + +type ExecutionMap = HashMap; + +struct ExecutionControl { + cancel: tokio::sync::oneshot::Sender<()>, +} + +struct ExecutionGuard { + execution_id: String, +} + +impl Drop for ExecutionGuard { + fn drop(&mut self) { + let Ok(mut executions) = EXECUTIONS.lock() else { + return; + }; + executions.remove(&self.execution_id); + } +} + +static EXECUTIONS: LazyLock> = LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Options for executing a shell command via brush-core. +#[napi(object)] +pub struct ShellExecuteOptions { + pub command: String, + pub cwd: Option, + pub env: Option>, + pub timeout_ms: Option, + pub execution_id: String, +} + +/// Result of executing a shell command via brush-core. +#[napi(object)] +pub struct ShellExecuteResult { + pub exit_code: Option, + pub cancelled: bool, + pub timed_out: bool, +} + +/// Execute a brush shell command. +#[napi] +pub async fn execute_shell( + options: ShellExecuteOptions, + #[napi(ts_arg_type = "((chunk: string) => void) | undefined | null")] on_chunk: Option< + ThreadsafeFunction, + >, +) -> Result { + let execution_id = options.execution_id.clone(); + let timeout_ms = options.timeout_ms; + + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel(); + { + let mut executions = EXECUTIONS + .lock() + .map_err(|_| Error::from_reason("Execution lock poisoned"))?; + if executions.contains_key(&execution_id) { + return Err(Error::from_reason("Execution already running")); + } + executions.insert(execution_id.clone(), ExecutionControl { cancel: cancel_tx }); + } + let _guard = ExecutionGuard { execution_id }; + + let run_future = run_shell(options, on_chunk); + tokio::pin!(run_future); + + let mut cancelled = false; + let mut timed_out = false; + + let run_result = if let Some(ms) = timeout_ms { + let timeout = time::sleep(Duration::from_millis(u64::from(ms))); + tokio::pin!(timeout); + + tokio::select! { + result = &mut run_future => result, + _ = cancel_rx => { + cancelled = true; + attempt_kill_children().await; + return Ok(ShellExecuteResult { exit_code: None, cancelled, timed_out }); + } + () = &mut timeout => { + timed_out = true; + attempt_kill_children().await; + return Ok(ShellExecuteResult { exit_code: None, cancelled, timed_out }); + } + } + } else { + tokio::select! { + result = &mut run_future => result, + _ = cancel_rx => { + cancelled = true; + attempt_kill_children().await; + return Ok(ShellExecuteResult { exit_code: None, cancelled, timed_out }); + } + } + }?; + + Ok(ShellExecuteResult { exit_code: Some(i32::from(run_result.exit_code)), cancelled, timed_out }) +} + +/// Abort a running shell execution. +#[napi] +pub fn abort_shell_execution(execution_id: String) -> Result<()> { + let mut executions = EXECUTIONS + .lock() + .map_err(|_| Error::from_reason("Execution lock poisoned"))?; + if let Some(control) = executions.remove(&execution_id) { + let _ = control.cancel.send(()); + } + Ok(()) +} + +async fn run_shell( + options: ShellExecuteOptions, + on_chunk: Option>, +) -> Result { + let create_options = CreateOptions { + interactive: false, + login: false, + no_profile: true, + no_rc: true, + do_not_inherit_env: true, + ..Default::default() + }; + + let mut shell = Shell::new(&create_options) + .await + .map_err(|err| Error::from_reason(format!("Failed to initialize shell: {err}")))?; + + if let Some(cwd) = options.cwd.as_deref() { + shell + .set_working_dir(cwd) + .map_err(|err| Error::from_reason(format!("Failed to set cwd: {err}")))?; + } + + if let Some(env) = options.env { + for (key, value) in env { + let mut var = ShellVariable::new(ShellValue::String(value)); + var.export(); + shell + .env + .set_global(key, var) + .map_err(|err| Error::from_reason(format!("Failed to set env: {err}")))?; + } + } + + // Create a pipe using os_pipe + let (pipe_reader, pipe_writer) = + os_pipe::pipe().map_err(|err| Error::from_reason(format!("Failed to create pipe: {err}")))?; + + // Convert to std::fs::File via OwnedFd + #[cfg(unix)] + let (reader_file, writer_file): (std::fs::File, std::fs::File) = { + use std::os::unix::io::IntoRawFd; + let reader_fd = pipe_reader.into_raw_fd(); + let writer_fd = pipe_writer.into_raw_fd(); + // SAFETY: We just obtained these fds from os_pipe and own them exclusively. + unsafe { + ( + std::os::unix::io::FromRawFd::from_raw_fd(reader_fd), + std::os::unix::io::FromRawFd::from_raw_fd(writer_fd), + ) + } + }; + + #[cfg(windows)] + let (reader_file, writer_file): (std::fs::File, std::fs::File) = { + use std::os::windows::io::IntoRawHandle; + let reader_handle = pipe_reader.into_raw_handle(); + let writer_handle = pipe_writer.into_raw_handle(); + // SAFETY: We just obtained these handles from os_pipe and own them exclusively. + unsafe { + ( + std::os::windows::io::FromRawHandle::from_raw_handle(reader_handle), + std::os::windows::io::FromRawHandle::from_raw_handle(writer_handle), + ) + } + }; + + let stdout_file = OpenFile::from( + writer_file + .try_clone() + .map_err(|err| Error::from_reason(format!("Failed to clone pipe: {err}")))?, + ); + let stderr_file = OpenFile::from(writer_file); + + let mut open_files = shell.open_files.clone(); + open_files.set(OpenFiles::STDOUT_FD, stdout_file); + open_files.set(OpenFiles::STDERR_FD, stderr_file); + + let mut params = shell.default_exec_params(); + params.open_files = open_files; + params.process_group_policy = ProcessGroupPolicy::NewProcessGroup; + + let reader_handle = task::spawn_blocking(move || read_output(reader_file, on_chunk)); + let result = shell + .run_string(options.command, ¶ms) + .await + .map_err(|err| Error::from_reason(format!("Shell execution failed: {err}"))); + + // Drop shell and params to close write ends of pipes, allowing reader to finish + drop(shell); + drop(params); + + let _ = reader_handle.await; + + result +} + +fn read_output(mut reader: std::fs::File, on_chunk: Option>) { + let mut buf = [0u8; 8192]; + loop { + let read = match reader.read(&mut buf) { + Ok(0) => break, + Ok(count) => count, + Err(_) => break, + }; + + if let Some(callback) = on_chunk.as_ref() { + let chunk = String::from_utf8_lossy(&buf[..read]).to_string(); + callback.call(Ok(chunk), ThreadsafeFunctionCallMode::NonBlocking); + } + } +} + +#[cfg(unix)] +async fn attempt_kill_children() { + let pid = std::process::id(); + let path = format!("/proc/{pid}/task/{pid}/children"); + let Ok(children) = std::fs::read_to_string(path) else { + return; + }; + let pids: Vec = children + .split_whitespace() + .filter_map(|pid| pid.parse::().ok()) + .collect(); + if pids.is_empty() { + return; + } + + for pid in &pids { + // SAFETY: Sending SIGINT to child processes is safe; invalid pids are ignored. + unsafe { + libc::kill(*pid, libc::SIGINT); + } + } + + time::sleep(Duration::from_millis(50)).await; + + for pid in &pids { + // SAFETY: Sending SIGKILL to child processes is safe; invalid pids are ignored. + unsafe { + libc::kill(*pid, libc::SIGKILL); + } + } +} + +#[cfg(not(unix))] +async fn attempt_kill_children() {} diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d62c95dea..0e6cdd7e5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,19 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Removed persistent shell session support; bash execution now uses native bindings via brush-core for improved reliability + +### Changed + +- Migrated bash command execution from ptree-based persistent sessions to native shell bindings with streaming support +- Simplified bash executor to use brush-core native API instead of managing long-lived shell processes + +### Removed + +- Removed `shell-session.ts` module providing persistent shell session management +- Removed shell session test suite for persistent execution patterns ## [9.6.2] - 2026-02-01 ### Changed diff --git a/packages/coding-agent/src/exec/bash-executor.ts b/packages/coding-agent/src/exec/bash-executor.ts index 1cfb7fcb4..8fc751987 100644 --- a/packages/coding-agent/src/exec/bash-executor.ts +++ b/packages/coding-agent/src/exec/bash-executor.ts @@ -1,13 +1,12 @@ /** * Bash command execution with streaming support and cancellation. * - * Provides unified bash execution for AgentSession.executeBash() and direct calls. + * Uses brush-core via native bindings for shell execution. */ -import { Exception, ptree } from "@oh-my-pi/pi-utils"; +import * as crypto from "node:crypto"; +import { abortShellExecution, executeShell } from "@oh-my-pi/pi-natives"; import { Settings } from "../config/settings"; import { OutputSink } from "../session/streaming-output"; -import { getOrCreateSnapshot, getSnapshotSourceCommand } from "../utils/shell-snapshot"; -import { executeShellCommand } from "./shell-session"; export interface BashExecutorOptions { cwd?: string; @@ -35,115 +34,101 @@ export interface BashResult { export async function executeBash(command: string, options?: BashExecutorOptions): Promise { const settings = await Settings.init(); - const { shell, args, env, prefix } = settings.getShellConfig(); - const snapshotPath = await getOrCreateSnapshot(shell, env); + const { env: shellEnv, prefix } = settings.getShellConfig(); - if (shouldUsePersistentShell(settings.get("bash.persistentShell"))) { - return await executeShellCommand({ shell, env, prefix, snapshotPath }, command, { - cwd: options?.cwd, - timeout: options?.timeout, - signal: options?.signal, - onChunk: options?.onChunk, - env: options?.env, - artifactPath: options?.artifactPath, - artifactId: options?.artifactId, - }); + // Generate unique execution ID for abort support + const executionId = crypto.randomUUID(); + + // Merge shell env with additional env vars (additional takes precedence) + // Filter out undefined values and problematic vars for the native API + // BASH_ENV and ENV cause brush-core to fail with "not yet implemented" errors + const mergedEnv: Record = {}; + for (const [key, value] of Object.entries(shellEnv)) { + if (value !== undefined && key !== "BASH_ENV" && key !== "ENV") { + mergedEnv[key] = value; + } + } + if (options?.env) { + for (const [key, value] of Object.entries(options.env)) { + if (key !== "BASH_ENV" && key !== "ENV") { + mergedEnv[key] = value; + } + } } - return await executeBashOnce(command, options, { shell, args, env, prefix, snapshotPath }); -} - -/** - * Determine whether to use persistent shell sessions. - * Priority: OMP_SHELL_PERSIST env var > settings > default (false) - */ -function shouldUsePersistentShell(settingValue: boolean): boolean { - // Env var takes precedence (for debugging/override) - const flag = parseEnvFlag(process.env.OMP_SHELL_PERSIST); - if (flag !== undefined) return flag; - // Windows never uses persistent shell (too unreliable) - if (process.platform === "win32") return false; - // Use setting value (defaults to false) - return settingValue; -} - -function parseEnvFlag(value: string | undefined): boolean | undefined { - if (!value) return undefined; - const normalized = value.toLowerCase(); - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - return undefined; -} - -async function executeBashOnce( - command: string, - options: BashExecutorOptions | undefined, - config: { - shell: string; - args: string[]; - env: Record; - prefix?: string; - snapshotPath: string | null; - }, -): Promise { - const { shell, args, env, prefix, snapshotPath } = config; - - // Merge additional env vars if provided - const finalEnv = options?.env ? { ...env, ...options.env } : env; - const snapshotPrefix = getSnapshotSourceCommand(snapshotPath); - const prefixedCommand = prefix ? `${prefix} ${command}` : command; - const finalCommand = `${snapshotPrefix}${prefixedCommand}`; + // Apply command prefix if configured + const finalCommand = prefix ? `${prefix} ${command}` : command; + // Create output sink for truncation and artifact handling const sink = new OutputSink({ onChunk: options?.onChunk, artifactPath: options?.artifactPath, artifactId: options?.artifactId, }); - using child = ptree.spawn([shell, ...args, finalCommand], { - cwd: options?.cwd, - env: finalEnv, - signal: options?.signal, - timeout: options?.timeout, - detached: true, - }); - - // Pump streams - errors during abort/timeout are expected - // Use preventClose to avoid closing the shared sink when either stream finishes - await Promise.allSettled([child.stdout.pipeTo(sink.createInput()), child.stderr.pipeTo(sink.createInput())]).catch( - () => {}, - ); - - // Wait for process exit - try { - return { - exitCode: await child.exited, - cancelled: false, - ...(await sink.dump()), - }; - } catch (err: unknown) { - // Exception covers NonZeroExitError, AbortError, TimeoutError - if (err instanceof Exception) { - if (err.aborted) { - const isTimeout = err instanceof ptree.TimeoutError || err.message.toLowerCase().includes("timed out"); - const annotation = isTimeout - ? `Command timed out after ${Math.round((options?.timeout ?? 0) / 1000)} seconds` - : undefined; - return { - exitCode: undefined, - cancelled: true, - ...(await sink.dump(annotation)), - }; - } - - // NonZeroExitError + // Set up abort handling + let abortListener: (() => void) | undefined; + if (options?.signal) { + const signal = options.signal; + if (signal.aborted) { + // Already aborted return { - exitCode: err.exitCode, - cancelled: false, - ...(await sink.dump()), + exitCode: undefined, + cancelled: true, + ...(await sink.dump("Command cancelled")), + }; + } + abortListener = () => { + abortShellExecution(executionId); + }; + signal.addEventListener("abort", abortListener, { once: true }); + } + + try { + const result = await executeShell( + { + command: finalCommand, + cwd: options?.cwd, + env: Object.keys(mergedEnv).length > 0 ? mergedEnv : undefined, + timeoutMs: options?.timeout, + executionId, + }, + async (chunk: string) => { + await sink.push(chunk); + }, + ); + + // Handle timeout + if (result.timedOut) { + const annotation = options?.timeout + ? `Command timed out after ${Math.round(options.timeout / 1000)} seconds` + : "Command timed out"; + return { + exitCode: undefined, + cancelled: true, + ...(await sink.dump(annotation)), }; } - throw err; + // Handle cancellation + if (result.cancelled) { + return { + exitCode: undefined, + cancelled: true, + ...(await sink.dump("Command cancelled")), + }; + } + + // Normal completion + return { + exitCode: result.exitCode, + cancelled: false, + ...(await sink.dump()), + }; + } finally { + // Clean up abort listener + if (abortListener && options?.signal) { + options.signal.removeEventListener("abort", abortListener); + } } } diff --git a/packages/coding-agent/src/exec/shell-session.ts b/packages/coding-agent/src/exec/shell-session.ts deleted file mode 100644 index c5525638a..000000000 --- a/packages/coding-agent/src/exec/shell-session.ts +++ /dev/null @@ -1,609 +0,0 @@ -/** - * Persistent shell session executor for streaming bash tool output. - */ -import * as crypto from "node:crypto"; -import { logger, postmortem, ptree } from "@oh-my-pi/pi-utils"; -import { OutputSink, type OutputSummary } from "../session/streaming-output"; - -export interface ShellSessionConfig { - shell: string; - env: Record; - prefix?: string; - snapshotPath: string | null; -} - -export interface ShellCommandOptions { - cwd?: string; - timeout?: number; - signal?: AbortSignal; - onChunk?: (chunk: string) => void; - env?: Record; - artifactPath?: string; - artifactId?: string; -} - -export interface ShellCommandResult extends OutputSummary { - exitCode: number | undefined; - cancelled: boolean; -} - -const MARKER_PREFIX = "__OMP_CMD_DONE__"; -const MARKER_TAIL_MAX = 128; -const ABORT_GRACE_MS = 1500; -const IS_WINDOWS = process.platform === "win32"; - -interface RunningCommand { - marker: string; - markerSentinel: string; - sink: OutputSink; - resolve: (result: ShellCommandResult) => void; - done: Promise; - cancelled: boolean; - abortReason?: "timeout" | "signal"; - abortNotice?: string; - abortListener?: () => void; - completed: boolean; -} - -function escapePosix(value: string): string { - return `'${value.split("'").join("'\"'\"'")}'`; -} - -function isFishShell(shell: string): boolean { - return shell.includes("fish"); -} - -function buildEnvExports(env: Record | undefined, fish: boolean): string { - if (!env) return ""; - const entries = Object.entries(env).filter(([, value]) => value !== undefined); - if (entries.length === 0) return ""; - if (fish) { - return entries.map(([key, value]) => `set -lx ${key} ${escapePosix(value)}`).join("\n"); - } - return entries.map(([key, value]) => `export ${key}=${escapePosix(value)}`).join("\n"); -} - -function buildPosixCommandScript( - command: string, - cwd: string | undefined, - prefix: string | undefined, - marker: string, - commandEnv: Record | undefined, -): string { - const envExports = buildEnvExports(commandEnv, false); - const commandLine = prefix ? `${prefix} ${command}` : command; - const lines: string[] = [ - "__omp_restore_errexit=0", - "case $- in *e*) __omp_restore_errexit=1 ;; esac", - "set +e", - "__omp_prev_trap_int=$(trap -p INT 2>/dev/null || true)", - "trap - INT", - "__omp_prev_exit=", - "__omp_prev_logout=", - "__omp_prev_exec=", - "if command -v typeset >/dev/null 2>&1; then __omp_prev_exit=$(typeset -f exit 2>/dev/null || true); fi", - "if command -v typeset >/dev/null 2>&1; then __omp_prev_logout=$(typeset -f logout 2>/dev/null || true); fi", - "if command -v typeset >/dev/null 2>&1; then __omp_prev_exec=$(typeset -f exec 2>/dev/null || true); fi", - 'exit() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }', - 'logout() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }', - 'exec() { command "$@"; return $?; }', - ]; - if (envExports) lines.push(envExports); - if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`); - // Redirect stdin from /dev/null to prevent interactive commands from blocking - // on the shell's stdin pipe (which is used for sending commands, not user input). - // Explicit pipes within the command (e.g., `echo "y" | cmd`) still work. - lines.push(commandLine.length > 0 ? `{ ${commandLine}; } < /dev/null` : ":"); - lines.push("__omp_status=$?"); - lines.push("unset -f exit logout exec 2>/dev/null"); - lines.push('if [ -n "$__omp_prev_exit" ]; then eval "$__omp_prev_exit"; fi'); - lines.push('if [ -n "$__omp_prev_logout" ]; then eval "$__omp_prev_logout"; fi'); - lines.push('if [ -n "$__omp_prev_exec" ]; then eval "$__omp_prev_exec"; fi'); - lines.push('if [ -n "$__omp_prev_trap_int" ]; then eval "$__omp_prev_trap_int"; else trap - INT; fi'); - lines.push("unset __omp_prev_trap_int"); - lines.push("unset __omp_prev_exit __omp_prev_logout __omp_prev_exec"); - lines.push('if [ "$__omp_restore_errexit" -eq 1 ]; then set -e; fi'); - lines.push("unset __omp_restore_errexit"); - lines.push(`printf '\\n${marker}%d\\n' "$__omp_status"`); - return `${lines.join("\n")}\n`; -} - -function buildFishCommandScript( - command: string, - cwd: string | undefined, - prefix: string | undefined, - marker: string, - commandEnv: Record | undefined, -): string { - const envExports = buildEnvExports(commandEnv, true); - const commandLine = prefix ? `${prefix} ${command}` : command; - const lines: string[] = [ - "begin", - "functions -e __omp_prev_exit 2>/dev/null", - "functions -e __omp_prev_logout 2>/dev/null", - "functions -e __omp_prev_exec 2>/dev/null", - "functions -q exit; and functions -c exit __omp_prev_exit", - "functions -q logout; and functions -c logout __omp_prev_logout", - "functions -q exec; and functions -c exec __omp_prev_exec", - "function exit", - " if test (count $argv) -gt 0", - " set -g __omp_exit_code $argv[1]", - " else", - " set -g __omp_exit_code 0", - " end", - " return $__omp_exit_code", - "end", - "function logout", - " if test (count $argv) -gt 0", - " set -g __omp_exit_code $argv[1]", - " else", - " set -g __omp_exit_code 0", - " end", - " return $__omp_exit_code", - "end", - "function exec", - " command $argv", - " return $status", - "end", - ]; - if (envExports) lines.push(envExports); - if (cwd) lines.push(`cd -- ${escapePosix(cwd)}`); - // Redirect stdin from /dev/null to prevent interactive commands from blocking - lines.push(commandLine.length > 0 ? `begin; ${commandLine}; end < /dev/null` : ":"); - lines.push("if set -q __omp_exit_code"); - lines.push(" set -l __omp_status $__omp_exit_code"); - lines.push(" set -e __omp_exit_code"); - lines.push("else"); - lines.push(" set -l __omp_status $status"); - lines.push("end"); - lines.push("functions -e exit logout exec"); - lines.push("functions -q __omp_prev_exit; and functions -c __omp_prev_exit exit; and functions -e __omp_prev_exit"); - lines.push( - "functions -q __omp_prev_logout; and functions -c __omp_prev_logout logout; and functions -e __omp_prev_logout", - ); - lines.push("functions -q __omp_prev_exec; and functions -c __omp_prev_exec exec; and functions -e __omp_prev_exec"); - lines.push(`printf "\\n${marker}%d\\n" $__omp_status`); - lines.push("end"); - return `${lines.join("\n")}\n`; -} - -function getSessionArgs(shell: string, snapshotPath: string | null): string[] { - if (snapshotPath) return []; - const noLogin = process.env.OMP_BASH_NO_LOGIN || process.env.CLAUDE_BASH_NO_LOGIN; - if (noLogin) return []; - if (shell.includes("bash") || shell.includes("zsh") || shell.includes("fish")) return ["-l"]; - return []; -} - -function serializeEnv(env: Record): string { - const entries = Object.entries(env).filter(([, value]) => value !== undefined); - entries.sort(([a], [b]) => a.localeCompare(b)); - return entries.map(([key, value]) => `${key}=${value}`).join("\n"); -} - -function sanitizePersistentEnv(env: Record): Record { - const sanitized = { ...env }; - delete sanitized.BASH_ENV; - delete sanitized.ENV; - return sanitized; -} - -class ShellSession { - #child: ReturnType> | null = null; - #stdinWriter: WritableStreamDefaultWriter | Bun.FileSink | null = null; - #buffer = ""; - #queue: Promise = Promise.resolve(); - #chunkQueue: Promise = Promise.resolve(); - #streamsDone: Promise = Promise.resolve(); - #current: RunningCommand | null = null; - #startPromise: Promise | null = null; - #closed = false; - #encoder = new TextEncoder(); - #lastExitCode: number | null | undefined = undefined; - - constructor(private readonly config: ShellSessionConfig) {} - - async execute(command: string, options: ShellCommandOptions): Promise { - const run = async () => { - try { - await this.#start(); - return await this.#runCommand(command, options); - } catch (error) { - if (this.#shouldRestart(error)) { - await this.#terminateSession(); - await this.#start(); - return await this.#runCommand(command, options); - } - throw error; - } - }; - - const queued = this.#queue.then(run, run); - this.#queue = queued.then( - () => {}, - () => {}, - ); - return queued; - } - - async dispose(): Promise { - this.#closed = true; - const child = this.#child; - this.#child = null; - this.#stdinWriter = null; - if (child) { - child.kill(); - await child.exited.catch(() => {}); - } - } - - async #start(): Promise { - if (this.#closed) { - throw new Error("Shell session is closed"); - } - if (this.#startPromise) return this.#startPromise; - this.#startPromise = this.#spawnShell().catch(error => { - this.#startPromise = null; - throw error; - }); - return this.#startPromise; - } - - async #spawnShell(): Promise { - const args = getSessionArgs(this.config.shell, this.config.snapshotPath); - this.#child = ptree.spawn([this.config.shell, ...args], { - stdin: "pipe", - env: this.config.env, - detached: !IS_WINDOWS, - }); - - if (this.#child.proc.exitCode !== null) { - this.#lastExitCode = this.#child.proc.exitCode; - throw new Error(`Shell exited immediately with code ${this.#child.proc.exitCode}`); - } - - const stdin = this.#child.stdin; - if (stdin && typeof stdin === "object" && "getWriter" in stdin) { - this.#stdinWriter = (stdin as unknown as WritableStream).getWriter(); - } else { - this.#stdinWriter = stdin as Bun.FileSink; - } - this.#attachStreams(this.#child); - this.#child.exited.then(code => this.#handleShellExit(code)).catch(() => this.#handleShellExit(null)); - - const initCommand = this.#buildInitCommand(); - if (initCommand) { - await this.#runCommand(initCommand, {}); - } - } - - #buildInitCommand(): string | null { - if (!this.config.snapshotPath) return null; - const snapshotPath = escapePosix(this.config.snapshotPath); - if (isFishShell(this.config.shell)) { - return `source ${snapshotPath}`; - } - return `source ${snapshotPath} 2>/dev/null`; - } - - #attachStreams(child: ReturnType>): void { - const readStream = async (stream: ReadableStream) => { - const reader = stream.getReader(); - const decoder = new TextDecoder("utf-8", { ignoreBOM: true }); - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - const text = decoder.decode(value, { stream: true }); - if (text) { - await this.#enqueueChunk(text); - } - } - const remaining = decoder.decode(); - if (remaining) { - await this.#enqueueChunk(remaining); - } - } catch { - // ignore - } finally { - try { - await reader.cancel(); - } catch {} - reader.releaseLock(); - } - }; - - this.#streamsDone = Promise.allSettled([readStream(child.stdout), readStream(child.stderr)]); - } - - async #enqueueChunk(text: string): Promise { - this.#chunkQueue = this.#chunkQueue.then(() => this.#processChunk(text)); - return this.#chunkQueue; - } - - async #processChunk(text: string): Promise { - const running = this.#current; - if (!running) return; - this.#buffer += text; - - const sentinel = running.markerSentinel; - while (this.#buffer.length > 0) { - const markerIndex = this.#buffer.indexOf(sentinel); - if (markerIndex === -1) { - const lastNewline = this.#buffer.lastIndexOf("\n"); - if (lastNewline > -1) { - const tail = this.#buffer.slice(lastNewline); - const flushLength = tail.length <= MARKER_TAIL_MAX ? lastNewline : this.#buffer.length - MARKER_TAIL_MAX; - if (flushLength > 0) { - await running.sink.push(this.#buffer.slice(0, flushLength)); - this.#buffer = this.#buffer.slice(flushLength); - } - return; - } - const flushLength = Math.max(0, this.#buffer.length - Math.min(sentinel.length, MARKER_TAIL_MAX)); - if (flushLength > 0) { - await running.sink.push(this.#buffer.slice(0, flushLength)); - this.#buffer = this.#buffer.slice(flushLength); - } - return; - } - - if (markerIndex > 0) { - await running.sink.push(this.#buffer.slice(0, markerIndex)); - } - - const markerValueStart = markerIndex + sentinel.length; - const lineEnd = this.#buffer.indexOf("\n", markerValueStart); - if (lineEnd === -1) { - this.#buffer = this.#buffer.slice(markerIndex); - return; - } - - const exitText = this.#buffer.slice(markerValueStart, lineEnd).trim(); - const exitCode = Number.parseInt(exitText, 10); - this.#buffer = this.#buffer.slice(lineEnd + 1); - await this.#finishCommand(running, Number.isFinite(exitCode) ? exitCode : undefined); - this.#buffer = ""; - return; - } - } - - async #runCommand(command: string, options: ShellCommandOptions): Promise { - if (!this.#child || !this.#stdinWriter) { - const exitInfo = this.#lastExitCode === undefined ? "unknown" : String(this.#lastExitCode); - throw new Error(`Shell session not started (shell=${this.config.shell}, exit=${exitInfo})`); - } - this.#buffer = ""; - - const markerId = crypto.randomUUID().replace(/-/g, ""); - const marker = `${MARKER_PREFIX}${markerId}__`; - const markerSentinel = `\n${marker}`; - - const sink = new OutputSink({ - onChunk: options.onChunk, - artifactPath: options.artifactPath, - artifactId: options.artifactId, - }); - - const { promise, resolve } = Promise.withResolvers(); - const running: RunningCommand = { - marker, - markerSentinel, - sink, - resolve, - done: promise, - cancelled: false, - completed: false, - }; - - this.#current = running; - - const timeoutSignal = options.timeout ? AbortSignal.timeout(options.timeout) : undefined; - let timeoutFired = false; - if (timeoutSignal) { - timeoutSignal.addEventListener( - "abort", - () => { - timeoutFired = true; - }, - { once: true }, - ); - } - - const combinedSignal = options.signal - ? AbortSignal.any(timeoutSignal ? [options.signal, timeoutSignal] : [options.signal]) - : timeoutSignal; - - if (combinedSignal) { - const onAbort = () => { - void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout); - }; - running.abortListener = () => combinedSignal.removeEventListener("abort", onAbort); - if (combinedSignal.aborted) { - void this.#abortCommand(running, timeoutFired ? "timeout" : "signal", options.timeout); - } else { - combinedSignal.addEventListener("abort", onAbort, { once: true }); - } - } - - try { - const script = isFishShell(this.config.shell) - ? buildFishCommandScript(command, options.cwd, this.config.prefix, marker, options.env) - : buildPosixCommandScript(command, options.cwd, this.config.prefix, marker, options.env); - await this.#writeToStdin(script); - } catch (error) { - await this.#handleWriteFailure(error instanceof Error ? error : new Error(String(error))); - } - - return await promise; - } - - async #finishCommand(running: RunningCommand, exitCode: number | undefined): Promise { - if (running.completed) return; - running.completed = true; - running.abortListener?.(); - this.#current = null; - const summary = await running.sink.dump(running.cancelled ? running.abortNotice : undefined); - running.resolve({ - exitCode: running.cancelled ? undefined : exitCode, - cancelled: running.cancelled, - ...summary, - }); - } - - async #abortCommand( - running: RunningCommand, - reason: "timeout" | "signal", - timeoutMs: number | undefined, - ): Promise { - if (running.completed) return; - running.cancelled = true; - running.abortReason = reason; - const notice = - reason === "timeout" && timeoutMs - ? `Command timed out after ${Math.round(timeoutMs / 1000)} seconds` - : "Command cancelled"; - running.abortNotice = notice; - - await this.#sendInterrupt(); - const completed = await Promise.race([ - running.done.then( - () => true, - () => true, - ), - Bun.sleep(ABORT_GRACE_MS).then(() => false), - ]); - if (completed) return; - - await this.#terminateSession(); - - // Drain streams and chunk queue - marker might have arrived but not yet processed - await this.#streamsDone; - await this.#chunkQueue; - - if (running.completed) return; - running.completed = true; - running.abortListener?.(); - this.#current = null; - const summary = await running.sink.dump(notice); - running.resolve({ - exitCode: undefined, - cancelled: true, - ...summary, - }); - } - - async #sendInterrupt(): Promise { - const child = this.#child; - if (!child?.pid) return; - try { - if (IS_WINDOWS) { - child.proc.kill("SIGINT"); - return; - } - if (child.isProcessGroup) { - process.kill(-child.pid, "SIGINT"); - } else { - process.kill(child.pid, "SIGINT"); - } - } catch {} - } - - async #terminateSession(): Promise { - const child = this.#child; - this.#child = null; - this.#stdinWriter = null; - this.#startPromise = null; - if (child) { - child.kill(); - await child.exited.catch(() => {}); - } - } - - async #handleShellExit(exitCode: number | null): Promise { - const running = this.#current; - this.#lastExitCode = exitCode; - this.#child = null; - this.#stdinWriter = null; - this.#startPromise = null; - - if (!running || running.completed) return; - - // Wait for any pending chunks to be processed - marker might be in the queue - await this.#streamsDone; - await this.#chunkQueue; - - if (running.completed) return; - - running.cancelled = true; - running.abortReason = "signal"; - running.completed = true; - running.abortListener?.(); - this.#current = null; - this.#buffer = ""; - const summary = await running.sink.dump(running.abortNotice ?? "Shell session terminated"); - running.resolve({ - exitCode: undefined, - cancelled: true, - ...summary, - }); - } - - async #handleWriteFailure(error: Error): Promise { - logger.warn("Shell session write failed", { error: error.message }); - await this.#terminateSession(); - throw error; - } - - #shouldRestart(error: unknown): boolean { - if (!(error instanceof Error)) return false; - return ( - error.message.includes("Shell session not started") || - error.message.includes("Shell session stdin unavailable") - ); - } - - async #writeToStdin(script: string): Promise { - if (!this.#stdinWriter) { - throw new Error("Shell session stdin unavailable"); - } - const payload = this.#encoder.encode(script); - const writer = this.#stdinWriter; - await Promise.resolve(writer.write(payload)); - } -} - -const sessions = new Map(); - -function buildSessionKey(config: ShellSessionConfig): string { - return [config.shell, config.prefix ?? "", config.snapshotPath ?? "", serializeEnv(config.env)].join("\n"); -} - -export async function executeShellCommand( - config: ShellSessionConfig, - command: string, - options: ShellCommandOptions, -): Promise { - const sanitizedConfig = { ...config, env: sanitizePersistentEnv(config.env) }; - const key = buildSessionKey(sanitizedConfig); - let session = sessions.get(key); - if (!session) { - session = new ShellSession(sanitizedConfig); - sessions.set(key, session); - } - return await session.execute(command, options); -} - -export const __testing = { - buildPosixCommandScript, - buildFishCommandScript, - escapePosix, - getSessionArgs, -}; - -postmortem.register("shell-session", async () => { - const active = Array.from(sessions.values()); - sessions.clear(); - await Promise.all(active.map(session => session.dispose())); -}); diff --git a/packages/coding-agent/test/shell-session.test.ts b/packages/coding-agent/test/shell-session.test.ts deleted file mode 100644 index 7ee8fce4c..000000000 --- a/packages/coding-agent/test/shell-session.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { __testing as shellSessionTesting } from "@oh-my-pi/pi-coding-agent/exec/shell-session"; - -describe("shell session wrappers", () => { - it("builds posix wrapper with cwd, prefix, and marker", () => { - const script = shellSessionTesting.buildPosixCommandScript( - "echo hello", - "/tmp/workdir", - "strace -f", - "__OMP_CMD_DONE__abc__", - { FOO: "bar" }, - ); - - expect(script).toContain("set +e"); - expect(script).toContain("cd -- '/tmp/workdir'"); - expect(script).toContain("export FOO='bar'"); - expect(script).toContain("strace -f echo hello"); - expect(script).toContain('exit() { if [ -n "$1" ]; then return "$1"; else return 0; fi; }'); - expect(script).toContain("trap - INT"); - expect(script).toContain("printf '\\n__OMP_CMD_DONE__abc__%d\\n' \"$__omp_status\""); - }); - - it("builds fish wrapper with marker and restore", () => { - const script = shellSessionTesting.buildFishCommandScript( - "echo hi", - "/tmp/fishdir", - undefined, - "__OMP_CMD_DONE__xyz__", - { BAR: "baz" }, - ); - - expect(script).toContain("cd -- '/tmp/fishdir'"); - expect(script).toContain("set -lx BAR 'baz'"); - expect(script).toContain('printf "\\n__OMP_CMD_DONE__xyz__%d\\n"'); - }); -}); diff --git a/packages/coding-agent/test/tools.test.ts b/packages/coding-agent/test/tools.test.ts index 509cb1bf4..0baade29b 100644 --- a/packages/coding-agent/test/tools.test.ts +++ b/packages/coding-agent/test/tools.test.ts @@ -1,8 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { _resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { EditTool } from "@oh-my-pi/pi-coding-agent/patch"; import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; import { BashTool } from "@oh-my-pi/pi-coding-agent/tools/bash"; @@ -476,22 +476,6 @@ function b() { /Working directory does not exist/, ); }); - - it("should handle process spawn errors", async () => { - _resetSettingsForTest(); - vi.spyOn(Settings.prototype, "getShellConfig").mockReturnValue({ - shell: "/nonexistent-shell-path-xyz123", - args: ["-c"], - env: {}, - prefix: undefined, - }); - - const bashWithBadShell = new BashTool(createTestToolSession(testDir)); - - await expect(bashWithBadShell.execute("test-call-12", { command: "echo test" })).rejects.toThrow(/ENOENT/); - - vi.restoreAllMocks(); - }); }); describe("grep tool", () => { diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index cebd01386..ee96c6572 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] + +### Added + +- Added `executeShell()` function to execute shell commands with streaming output support and configurable timeout +- Added `abortShellExecution()` function to cancel running shell executions by ID +- Added `ShellExecuteOptions` and `ShellExecuteResult` types for shell command execution configuration and results + ### Changed - Improved native binary installation to use atomic rename operations and better fallback handling for Windows DLLs diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index e92dcd40d..83049e48e 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -6,6 +6,8 @@ import * as path from "node:path"; import type { FindMatch, FindOptions, FindResult } from "./find/types"; import { native } from "./native"; +export type { RequestOptions } from "./request-options"; + // ============================================================================= // Grep (ripgrep-based regex search) // ============================================================================= @@ -108,4 +110,13 @@ export { htmlToMarkdown, } from "./html/index"; -export type { RequestOptions } from "./request-options"; +// ============================================================================= +// Shell execution (brush-core) +// ============================================================================= + +export { + abortShellExecution, + executeShell, + type ShellExecuteOptions, + type ShellExecuteResult, +} from "./shell/index"; diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index 89f4b8f86..6748d8a6c 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -11,6 +11,7 @@ import type { } from "./grep/types"; import type { HighlightColors } from "./highlight/index"; import type { HtmlToMarkdownOptions } from "./html/types"; +import type { ShellExecuteOptions, ShellExecuteResult } from "./shell/types"; import type { ExtractSegmentsResult, SliceWithWidthResult } from "./text/index"; export interface NativePhotonImage { @@ -66,6 +67,11 @@ export interface NativeBindings { strictAfter: boolean, ): ExtractSegmentsResult; matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean; + executeShell( + options: ShellExecuteOptions, + onChunk?: (error: Error | null, chunk: string) => void, + ): Promise; + abortShellExecution(executionId: string): void; } const require = createRequire(import.meta.url); @@ -141,6 +147,8 @@ function validateNative(bindings: NativeBindings, source: string): void { checkFn("sliceWithWidth"); checkFn("extractSegments"); checkFn("matchesKittySequence"); + checkFn("executeShell"); + checkFn("abortShellExecution"); if (missing.length) { throw new Error( diff --git a/packages/natives/src/shell/index.ts b/packages/natives/src/shell/index.ts new file mode 100644 index 000000000..12988eabc --- /dev/null +++ b/packages/natives/src/shell/index.ts @@ -0,0 +1,33 @@ +/** + * Native shell execution via brush-core. + */ + +import { native } from "../native"; +import type { ShellExecuteOptions, ShellExecuteResult } from "./types"; + +export type { ShellExecuteOptions, ShellExecuteResult } from "./types"; + +/** + * Execute a shell command using brush-core. + * + * @param options - Execution options including command, cwd, env, timeout + * @param onChunk - Optional callback for streaming output chunks + * @returns Promise resolving to execution result with exit code and status + */ +export async function executeShell( + options: ShellExecuteOptions, + onChunk?: (chunk: string) => void, +): Promise { + // napi-rs ThreadsafeFunction passes (error, value) - skip callback on error + const wrappedCallback = onChunk ? (err: Error | null, chunk: string) => !err && onChunk(chunk) : undefined; + return native.executeShell(options, wrappedCallback); +} + +/** + * Abort a running shell execution. + * + * @param executionId - The execution ID to abort + */ +export function abortShellExecution(executionId: string): void { + native.abortShellExecution(executionId); +} diff --git a/packages/natives/src/shell/types.ts b/packages/natives/src/shell/types.ts new file mode 100644 index 000000000..9e9e2d50c --- /dev/null +++ b/packages/natives/src/shell/types.ts @@ -0,0 +1,27 @@ +/** + * Options for executing a shell command via brush-core. + */ +export interface ShellExecuteOptions { + /** The command to execute */ + command: string; + /** Working directory for command execution */ + cwd?: string; + /** Environment variables to set */ + env?: Record; + /** Timeout in milliseconds */ + timeoutMs?: number; + /** Unique identifier for this execution (used for abort) */ + executionId: string; +} + +/** + * Result of executing a shell command via brush-core. + */ +export interface ShellExecuteResult { + /** Exit code of the command (undefined if cancelled or timed out) */ + exitCode?: number; + /** Whether the command was cancelled via abort */ + cancelled: boolean; + /** Whether the command timed out */ + timedOut: boolean; +}