diff --git a/Cargo.lock b/Cargo.lock index 4cd226809..84b00b8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -88,7 +88,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" dependencies = [ "android-properties", - "bitflags 2.13.0", + "bitflags 2.11.1", "cc", "cesu8", "jni 0.21.1", @@ -185,15 +185,6 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170433209e817da6aae2c51aa0dd443009a613425dd041ebfb2492d1c4c11a25" -[[package]] -name = "approx" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" -dependencies = [ - "num-traits", -] - [[package]] name = "ar_archive_writer" version = "0.5.1" @@ -356,9 +347,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.18" +version = "1.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33f815b73a3899c03b380d543532e5865f230dce9678d108dc10732a8682275" +checksum = "517aa062d8bd9015ee23d6daa5e1c1372328412fdae4e6c4c1be9b69c6ad37a2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -447,9 +438,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrock" -version = "1.145.0" +version = "1.144.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0517c31b708b01136276121818c06d9b2d34399641ddda055569c55b03e6ef" +checksum = "b683a930642668b42b19acb7d26d60400252e950dcd1e13d506748a53f1336b6" dependencies = [ "arc-swap", "aws-credential-types", @@ -472,9 +463,9 @@ dependencies = [ [[package]] name = "aws-sdk-bedrockruntime" -version = "1.133.0" +version = "1.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76314880945928a4ee3956e92af451ef29ef04b734d008bb94b11158a60a1034" +checksum = "41a2940faeb61f4f579a434bc3a546e9ab49a89596e94527d329281ef55fd44d" dependencies = [ "arc-swap", "aws-credential-types", @@ -500,11 +491,10 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.101.0" +version = "1.100.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b647baea49ff551960b904f905681e9b4765a6c4ea08631e89dc52d8bd3f5896" +checksum = "bee2719d4a5e5e147bb9e9b77490df6ece750df1094968aa857b09b618a1881a" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -525,9 +515,9 @@ dependencies = [ [[package]] name = "aws-sdk-ssooidc" -version = "1.103.0" +version = "1.102.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ae401c65ff288aa7873117fe535cd32b7b1bb0bc43751d28901a1d5f20636b9" +checksum = "b30d254992d56ef19f430396e5765b11e0f5bd21a7a557cb12fca1c8c18b9636" dependencies = [ "arc-swap", "aws-credential-types", @@ -550,11 +540,10 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.106.0" +version = "1.105.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c80de7bb7d03e9ca8c9fd7b489f20f3948d3f3be91a7953591347d238115408" +checksum = "59f4f8065fe615dbed9096458ba98dda6d641553ffd5aedd27e37e65211aca9f" dependencies = [ - "arc-swap", "aws-credential-types", "aws-runtime", "aws-smithy-async", @@ -576,9 +565,9 @@ dependencies = [ [[package]] name = "aws-sigv4" -version = "1.4.5" +version = "1.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bae38512beae0ffee7010fc24e7a8a123c53efdfef42a61e80fda4882418dc71" +checksum = "b7083fb918b38474ac65ffbf8a69fc8792d36879f4ac5f1667b43aec61efe9a5" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -885,6 +874,12 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -969,9 +964,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" dependencies = [ "serde_core", ] @@ -1188,7 +1183,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "log", "polling", "rustix 0.38.44", @@ -1315,9 +1310,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.45" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -1689,12 +1684,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "cross_agent_session_resumer" version = "0.2.2" @@ -1785,7 +1774,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "crossterm_winapi", "derive_more", "document-features", @@ -1875,7 +1864,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libloading 0.8.9", "winapi", ] @@ -2022,7 +2011,7 @@ checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" [[package]] name = "dcg-core" version = "0.6.0-rc.1" -source = "git+https://github.com/quangdang46/destructive_command_guard?branch=main#6002f69cb2806f918daf90c461272ce8b09442c6" +source = "git+https://github.com/quangdang46/destructive_command_guard?branch=main#6ae9e3f5f9dc93ad8ea8b20f64e7bba24740d29e" dependencies = [ "aho-corasick", "chrono", @@ -2480,7 +2469,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "objc2 0.6.4", ] @@ -3350,7 +3339,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libc", "libgit2-sys", "log", @@ -3509,7 +3498,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2409cffa4fe8b303847d5b6ba8df9da9ba65d302fc5ee474ea0cac5afde79840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "bstr", "gix-path", "libc", @@ -3648,7 +3637,7 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8546300aee4c65c5862c22a3e321124a69b654a61a8b60de546a9284812b7e2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "bstr", "gix-features", "gix-path", @@ -3696,7 +3685,7 @@ version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ea6d3e9e11647ba49f441dea0782494cc6d2875ff43fa4ad9094e6957f42051" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "bstr", "filetime", "fnv", @@ -3819,7 +3808,7 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed9e0c881933c37a7ef45288d6c5779c4a7b3ad240b4c37657e1d9829eb90085" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "bstr", "gix-attributes", "gix-config-value", @@ -3900,7 +3889,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91898c83b18c635696f7355d171cfa74a52f38022ff89581f567768935ebc4c8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "bstr", "gix-commitgraph", "gix-date", @@ -3933,7 +3922,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea9962ed6d9114f7f100efe038752f41283c225bb507a2888903ac593dffa6be" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "gix-path", "libc", "windows-sys 0.61.2", @@ -4032,7 +4021,7 @@ version = "0.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d052b83d1d1744be95ac6448ac02f95f370a8f6720e466be9ce57146e39f5280" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "gix-commitgraph", "gix-date", "gix-hash", @@ -4188,7 +4177,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "gpu-alloc-types", ] @@ -4198,7 +4187,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -4220,7 +4209,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "gpu-descriptor-types", "hashbrown 0.14.5", ] @@ -4231,7 +4220,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -4341,11 +4330,6 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] [[package]] name = "hashline" @@ -4392,7 +4376,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "com", "libc", "libloading 0.8.9", @@ -4429,7 +4413,7 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad82d6598ccf1dac15c8b758a1bd282b755b6776be600429176757190a1b0202" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "byteorder", "heed-traits", "heed-types", @@ -4664,6 +4648,19 @@ dependencies = [ "webpki-roots 1.0.7", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -4698,7 +4695,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.4", - "system-configuration", + "system-configuration 0.7.0", "tokio", "tower-service", "tracing", @@ -4717,7 +4714,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.58.0", ] [[package]] @@ -4867,9 +4864,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.26" +version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", @@ -4986,7 +4983,7 @@ version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "inotify-sys", "libc", ] @@ -5172,6 +5169,7 @@ dependencies = [ "jcode-embedding", "jcode-experiment-flags", "jcode-gateway-types", + "jcode-hooks", "jcode-logging", "jcode-memory-types", "jcode-message-types", @@ -5223,6 +5221,7 @@ dependencies = [ "serde_yaml", "sha2 0.10.9", "similar", + "strum 0.26.3", "tar", "tempfile", "thiserror 1.0.69", @@ -5299,6 +5298,7 @@ dependencies = [ "jcode-core", "jcode-experiment-flags", "jcode-gateway-types", + "jcode-hooks", "jcode-import-core", "jcode-keywords", "jcode-logging", @@ -5561,6 +5561,22 @@ dependencies = [ "serde", ] +[[package]] +name = "jcode-hooks" +version = "0.1.0" +dependencies = [ + "chrono", + "dirs 5.0.1", + "futures", + "regex", + "reqwest 0.11.27", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "toml", +] + [[package]] name = "jcode-import-core" version = "0.1.0" @@ -6363,7 +6379,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "serde", "unicode-segmentation", ] @@ -6401,7 +6417,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libc", ] @@ -6522,7 +6538,7 @@ version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.8.1", @@ -6557,7 +6573,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -6663,9 +6679,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lopdf" @@ -6703,15 +6719,6 @@ dependencies = [ "hashbrown 0.16.1", ] -[[package]] -name = "lru" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" -dependencies = [ - "hashbrown 0.17.1", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -6914,7 +6921,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "block", "core-graphics-types", "foreign-types 0.5.0", @@ -7008,7 +7015,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" dependencies = [ "bit-set 0.5.3", - "bitflags 2.13.0", + "bitflags 2.11.1", "codespan-reporting", "hexf-parse", "indexmap", @@ -7075,7 +7082,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -7121,7 +7128,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -7162,7 +7169,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "crossbeam-channel", "filetime", "fsevent-sys", @@ -7181,7 +7188,7 @@ version = "9.0.0-rc.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "inotify 0.11.2", "kqueue", "libc", @@ -7201,7 +7208,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -7362,7 +7369,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-core-graphics", "objc2-foundation", @@ -7374,7 +7381,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "dispatch2", "objc2 0.6.4", ] @@ -7385,7 +7392,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -7420,7 +7427,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -7431,7 +7438,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -7472,7 +7479,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "libc", "once_cell", "onig_sys", @@ -7505,7 +7512,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "cfg-if", "foreign-types 0.3.2", "libc", @@ -7659,7 +7666,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" dependencies = [ - "approx", "bytemuck", "fast-srgb8", "libm", @@ -7975,7 +7981,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "crc32fast", "fdeflate", "flate2", @@ -8192,7 +8198,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -8450,9 +8456,9 @@ checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] name = "ratatui" -version = "0.30.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" dependencies = [ "instability", "ratatui-core", @@ -8460,26 +8466,22 @@ dependencies = [ "ratatui-macros", "ratatui-termwiz", "ratatui-widgets", - "serde", ] [[package]] name = "ratatui-core" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "compact_str 0.9.1", - "critical-section", - "hashbrown 0.17.1", + "hashbrown 0.16.1", "indoc", "itertools 0.14.0", "kasuari", - "lru 0.18.0", - "palette", - "serde", - "strum 0.28.0", + "lru 0.16.4", + "strum 0.27.2", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -8488,9 +8490,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" dependencies = [ "cfg-if", "crossterm", @@ -8516,9 +8518,9 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.1" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" dependencies = [ "ratatui-core", "ratatui-widgets", @@ -8526,9 +8528,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" dependencies = [ "ratatui-core", "termwiz", @@ -8536,19 +8538,18 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.1" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.17.1", + "bitflags 2.11.1", + "hashbrown 0.16.1", "indoc", "instability", "itertools 0.14.0", "line-clipping", "ratatui-core", - "serde", - "strum 0.28.0", + "strum 0.27.2", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -8560,7 +8561,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -8651,7 +8652,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -8660,7 +8661,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -8746,6 +8747,46 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -8764,7 +8805,7 @@ dependencies = [ "http-body-util", "hyper 1.10.1", "hyper-rustls 0.27.9", - "hyper-tls", + "hyper-tls 0.6.0", "hyper-util", "js-sys", "log", @@ -8779,7 +8820,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-native-tls", "tokio-rustls 0.26.4", @@ -8819,7 +8860,7 @@ dependencies = [ "rustls 0.23.40", "rustls-pki-types", "rustls-platform-verifier", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tokio-rustls 0.26.4", "tokio-util", @@ -8875,7 +8916,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28b19f5711867dc33a82cdbfd437c03b4089308f63a7ec3ee6ab34a9d74ff519" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "crossterm", "fancy-regex 0.17.0", "log", @@ -9013,7 +9054,7 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c6d5e5acb6f6129fe3f7ba0a7fc77bca1942cb568535e18e7bc40262baf3110" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -9072,7 +9113,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -9085,7 +9126,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -9154,7 +9195,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" dependencies = [ "openssl-probe 0.1.6", - "rustls-pemfile", + "rustls-pemfile 2.2.0", "rustls-pki-types", "schannel", "security-framework 2.11.1", @@ -9172,6 +9213,15 @@ dependencies = [ "security-framework 3.7.0", ] +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.2.0" @@ -9280,7 +9330,7 @@ version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "bytemuck", "core_maths", "log", @@ -9455,7 +9505,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -9468,7 +9518,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -9831,7 +9881,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -9885,7 +9935,7 @@ version = "0.3.0+sdk-1.3.268.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", ] [[package]] @@ -10014,11 +10064,11 @@ dependencies = [ [[package]] name = "strum" -version = "0.28.0" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.28.0", + "strum_macros 0.27.2", ] [[package]] @@ -10036,9 +10086,9 @@ dependencies = [ [[package]] name = "strum_macros" -version = "0.28.0" +version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -10159,7 +10209,7 @@ version = "25.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a207e09f23885ff1f4354ebfdd4e715ccd4a68fca2e7e0df09baafbca850762e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "is-macro", "num-bigint", "once_cell", @@ -10224,7 +10274,7 @@ version = "41.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "303c2e32df97d5d4f2f3fc35dab367ff676668786a0d3ad496cefb0357b0bbb1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "either", "num-bigint", "phf", @@ -10398,6 +10448,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -10454,15 +10510,36 @@ dependencies = [ "libc", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -10543,7 +10620,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64 0.22.1", - "bitflags 2.13.0", + "bitflags 2.11.1", "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", @@ -11006,7 +11083,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper", + "sync_wrapper 1.0.2", "tokio", "tower-layer", "tower-service", @@ -11019,7 +11096,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.13.0", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -11883,7 +11960,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -11909,7 +11986,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -11921,7 +11998,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "cursor-icon", "wayland-backend", ] @@ -11943,7 +12020,7 @@ version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -11955,7 +12032,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -11967,7 +12044,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols 0.31.2", @@ -11980,7 +12057,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols 0.31.2", @@ -11993,7 +12070,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "wayland-backend", "wayland-client", "wayland-protocols 0.32.12", @@ -12191,7 +12268,7 @@ checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" dependencies = [ "arrayvec", "bit-vec 0.6.3", - "bitflags 2.13.0", + "bitflags 2.11.1", "cfg_aliases 0.1.1", "codespan-reporting", "indexmap", @@ -12219,7 +12296,7 @@ dependencies = [ "arrayvec", "ash", "bit-set 0.5.3", - "bitflags 2.13.0", + "bitflags 2.11.1", "block", "cfg_aliases 0.1.1", "core-graphics-types", @@ -12260,7 +12337,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "js-sys", "web-sys", ] @@ -12379,26 +12456,13 @@ version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" dependencies = [ - "windows-implement 0.58.0", - "windows-interface 0.58.0", + "windows-implement", + "windows-interface", "windows-result 0.2.0", "windows-strings 0.1.0", "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - [[package]] name = "windows-implement" version = "0.58.0" @@ -12410,17 +12474,6 @@ dependencies = [ "syn 2.0.117", ] -[[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 2.0.117", -] - [[package]] name = "windows-interface" version = "0.58.0" @@ -12432,17 +12485,6 @@ dependencies = [ "syn 2.0.117", ] -[[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 2.0.117", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -12803,7 +12845,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.13.0", + "bitflags 2.11.1", "bytemuck", "calloop", "cfg_aliases 0.1.1", @@ -12860,6 +12902,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winsafe" version = "0.0.19" @@ -12930,7 +12982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.13.0", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -13047,7 +13099,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.11.1", "dlib", "log", "once_cell", @@ -13098,9 +13150,9 @@ checksum = "c94451ac9513335b5e23d7a8a2b61a7102398b8cca5160829d313e84c9d98be1" [[package]] name = "yoke" -version = "0.8.3" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", diff --git a/Cargo.toml b/Cargo.toml index 0fda2ecca..5bb33010c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,12 +72,14 @@ members = [ "crates/jcode-mobile-core", "crates/jcode-mobile-sim", "crates/jcode-desktop", + "crates/jcode-hooks", + "crates/jcode-keywords", + "crates/jcode-mempalace-adapter", "crates/jcode-plugin-core", "crates/jcode-plugin-runtime", - "crates/jcode-mempalace-adapter", "crates/jcode-render-core", - "crates/jcode-keywords", "evals/jbench", + ] # Local override: build against the fast_file_search main branch which @@ -122,6 +124,9 @@ path = "src/bin/tui_bench.rs" required-features = ["dev-bins"] [dependencies] +# Hook system for lifecycle events +jcode-hooks = { path = "crates/jcode-hooks" } + # Cross-provider session conversion engine (resume/import any provider -> jcode). # Pinned to a specific commit SHA on the upstream fork so the dependency is # reproducible and doesn't accidentally pick up in-flight changes. Bump the @@ -177,6 +182,7 @@ similar = "2" # diffing for edits dirs = "5" # home directory anyhow = "1" thiserror = "1" +strum = { version = "0.26", features = ["derive"] } libc = "0.2" # Unix system calls (flock) chrono = { version = "0.4", features = ["serde"] } regex = "1" diff --git a/crates/jcode-app-core/Cargo.toml b/crates/jcode-app-core/Cargo.toml index 7e6b81e62..f809c13e6 100644 --- a/crates/jcode-app-core/Cargo.toml +++ b/crates/jcode-app-core/Cargo.toml @@ -58,6 +58,7 @@ proctitle = "0.1" # Embeddings (local inference) live in jcode-base; this crate forwards the # `embeddings` feature to jcode-base rather than depending on jcode-embedding. jcode-gateway-types = { path = "../jcode-gateway-types" } +jcode-hooks = { path = "../jcode-hooks" } jcode-logging = { path = "../jcode-logging" } # OAuth diff --git a/crates/jcode-app-core/src/agent.rs b/crates/jcode-app-core/src/agent.rs index 3e10aaa78..d037b7b9f 100644 --- a/crates/jcode-app-core/src/agent.rs +++ b/crates/jcode-app-core/src/agent.rs @@ -37,6 +37,10 @@ use crate::skill::SkillRegistry; use crate::tool::{Registry, ToolContext, ToolExecutionMode}; use anyhow::Result; use futures::StreamExt; +use jcode_hooks::{DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry}; +#[cfg(feature = "dcp")] +use std::cell::Cell; + use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::io::{self, Write}; @@ -235,6 +239,13 @@ pub struct Agent { stdin_request_tx: Option>, /// Canonical reducer-backed view of runtime provider/model selection. provider_runtime_state: ProviderRuntimeState, + /// Hook registry for dispatching lifecycle hooks. + hook_registry: HookRegistry, + /// Dispatch configuration for hook execution. + dispatch_config: DispatchConfig, + /// DCP plugin for context pruning (behind feature flag). + #[cfg(feature = "dcp")] + dcp: Option, } impl Agent { @@ -286,6 +297,10 @@ impl Agent { rewind_undo_snapshot: None, stdin_request_tx: None, provider_runtime_state: ProviderRuntimeState::observed(initial_provider_model), + hook_registry: HookRegistry::default(), + dispatch_config: DispatchConfig::default(), + #[cfg(feature = "dcp")] + dcp: crate::dcp_plugin::DcpPlugin::new().ok(), }; crate::tool::set_session_tool_policy( &agent.session.id, @@ -325,6 +340,33 @@ impl Agent { agent.session.provider_key = crate::session::derive_session_provider_key(agent.provider.name()); agent.session.ensure_initial_session_context_message(); + + // Dispatch SessionStart hooks (fire-and-forget, observational only) + { + let registry = agent.hook_registry.clone(); + let config = agent.dispatch_config.clone(); + let session_id = agent.session.id.clone(); + let cwd = agent.session.working_dir.clone().unwrap_or_default(); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionStart") + .build(); + let ctx = HookContext::for_session_start(session_id, cwd); + let event = HookEvent::SessionStart; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } + + // Wire DCP plugin into registry so DCP tools can access it + #[cfg(feature = "dcp")] + if let Some(dcp) = agent.dcp.take() { + agent.registry.set_dcp(dcp); + } + agent.seed_compaction_from_session(); agent.log_env_snapshot("create"); crate::telemetry::begin_session_with_parent( @@ -384,6 +426,33 @@ impl Agent { agent.restore_reasoning_effort_from_session(); agent.session.ensure_initial_session_context_message(); agent.sync_memory_dedup_state_from_session(); + + // Dispatch SessionStart hooks (fire-and-forget, observational only) + { + let registry = agent.hook_registry.clone(); + let config = agent.dispatch_config.clone(); + let session_id = agent.session.id.clone(); + let cwd = agent.session.working_dir.clone().unwrap_or_default(); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionStart") + .build(); + let ctx = HookContext::for_session_start(session_id, cwd); + let event = HookEvent::SessionStart; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } + + // Wire DCP plugin into registry so DCP tools can access it + #[cfg(feature = "dcp")] + if let Some(dcp) = agent.dcp.take() { + agent.registry.set_dcp(dcp); + } + agent.seed_compaction_from_session(); agent.log_env_snapshot("attach"); crate::telemetry::begin_session_with_parent( @@ -832,11 +901,71 @@ impl Agent { &self.provider.model(), crate::telemetry::SessionEndReason::NormalExit, ); + + // Dispatch SessionEnd hooks (fire-and-forget, observational only) + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let hook_input = HookInputBuilder::new() + .session(&session_id, "") + .event("SessionEnd") + .build(); + let ctx = HookContext::for_session_end(session_id.clone()); + let event = HookEvent::SessionEnd; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } + + // Dispatch AgentEnd hooks (fire-and-forget, observational only) + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let hook_input = HookInputBuilder::new() + .session(&session_id, "") + .event("AgentEnd") + .build(); + let ctx = HookContext::for_agent_end(session_id); + let event = HookEvent::AgentEnd; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } + self.persist_soft_interrupt_snapshot(); self.session.mark_closed(); if !self.session.messages.is_empty() { self.persist_session_best_effort("session close state"); } + + // Dispatch SessionUpdated hooks — session state changed to "closed" + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionUpdated") + .session_state("active", "closed", "normal_exit") + .build(); + let ctx = HookContext::for_session_updated(session_id, cwd); + let event = HookEvent::SessionUpdated; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } } pub fn mark_crashed(&mut self, message: Option) { @@ -845,11 +974,56 @@ impl Agent { &self.provider.model(), crate::telemetry::SessionEndReason::Unknown, ); + let crash_msg = message + .clone() + .unwrap_or_else(|| "unknown crash".to_string()); self.persist_soft_interrupt_snapshot(); self.session.mark_crashed(message); if !self.session.messages.is_empty() { self.persist_session_best_effort("session crash state"); } + + // Dispatch SessionUpdated hooks — session state changed to "crashed" + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionUpdated") + .session_state("active", "crashed", &crash_msg) + .build(); + let ctx = HookContext::for_session_updated(session_id.clone(), cwd.clone()); + let event = HookEvent::SessionUpdated; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } + + // Dispatch SessionError hooks — the session encountered a fatal error + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let mut hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionError") + .build(); + hook_input.error = Some(crash_msg); + let ctx = HookContext::for_session_error(session_id, cwd); + let event = HookEvent::SessionError; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } } /// Get the last token usage from the most recent API request @@ -857,6 +1031,16 @@ impl Agent { &self.last_usage } + /// Get a reference to the hook registry for external dispatch. + pub fn hook_registry(&self) -> &HookRegistry { + &self.hook_registry + } + + /// Get a reference to the dispatch configuration for external dispatch. + pub fn dispatch_config(&self) -> &DispatchConfig { + &self.dispatch_config + } + pub fn token_usage_totals(&self) -> crate::protocol::TokenUsageTotals { self.session.token_usage_totals() } diff --git a/crates/jcode-app-core/src/agent/compaction.rs b/crates/jcode-app-core/src/agent/compaction.rs index 455bb4aed..d5ac82f60 100644 --- a/crates/jcode-app-core/src/agent/compaction.rs +++ b/crates/jcode-app-core/src/agent/compaction.rs @@ -25,6 +25,24 @@ impl Agent { if event.is_some() { self.note_compaction_applied(); self.persist_session_best_effort("compaction completion"); + + // PostCompact hook (fire-and-forget) + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let ctx = HookContext::for_post_compact(session_id.clone(), cwd.clone()); + let hook_event = HookEvent::PostCompact; + tokio::spawn(async move { + let handlers = registry.get_matching(&hook_event, &ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("PostCompact") + .build(); + jcode_hooks::dispatch_hooks(&hook_event, &hook_input, &handlers, &config).await; + } + }); } event @@ -65,15 +83,87 @@ impl Agent { } ); + // PreCompact hook (blocking - can cancel compaction) + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let hook_session_id = self.session.id.clone(); + let hook_cwd = self.session.working_dir.clone().unwrap_or_default(); + let ctx = + HookContext::for_pre_compact(hook_session_id.clone(), hook_cwd.clone(), 0); + let hook_event = HookEvent::PreCompact; + let handlers = registry.get_matching(&hook_event, &ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&hook_session_id, &hook_cwd) + .event("PreCompact") + .build(); + let hook_stats = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(jcode_hooks::dispatch_hooks( + &hook_event, + &hook_input, + &handlers, + &config, + )) + }); + if hook_stats.any_denied() { + let deny_reason = hook_stats + .results + .iter() + .find(|r| { + matches!(r.outcome, jcode_hooks::ClassifiedOutcome::Deny { .. }) + }) + .map(|r| match &r.outcome { + jcode_hooks::ClassifiedOutcome::Deny { reason } => { + reason.clone() + } + _ => String::new(), + }) + .unwrap_or_else(|| "blocked by hook".to_string()); + return ( + format!( + "{status_msg}\n\n**Compaction cancelled by hook:** {deny_reason}" + ), + false, + ); + } + } + } + match manager.force_compact_with(&messages, provider) { - Ok(()) => ( - format!( - "{}\n\nšŸ“¦ **Compacting context** (manual) — summarizing older messages in the background to stay within the context window.\n\ - The summary will be applied automatically when ready.", - status_msg - ), - true, - ), + Ok(()) => { + // PostCompact hook (fire-and-forget) + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let ctx = HookContext::for_post_compact(session_id.clone(), cwd.clone()); + let hook_event = HookEvent::PostCompact; + tokio::spawn(async move { + let handlers = registry.get_matching(&hook_event, &ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("PostCompact") + .build(); + jcode_hooks::dispatch_hooks( + &hook_event, + &hook_input, + &handlers, + &config, + ) + .await; + } + }); + ( + format!( + "{}\n\nšŸ“¦ **Compacting context** (manual) — summarizing older messages in the background to stay within the context window.\n\ + The summary will be applied automatically when ready.", + status_msg + ), + true, + ) + } Err(reason) => ( format!("{status_msg}\n\n⚠ **Cannot compact:** {reason}"), false, @@ -123,12 +213,50 @@ impl Agent { let context_limit = self.provider.context_window() as u64; let compaction = self.registry.compaction(); - let (dropped, usage_pct) = match compaction.try_write() { + let (dropped, usage_pct, compaction_count, avg_saved_bytes) = match compaction.try_write() { Ok(mut manager) => { - let (dropped, usage_pct) = { + let hook_session_id = self.session.id.clone(); + let hook_cwd = self.session.working_dir.clone().unwrap_or_default(); + let (dropped, usage_pct, saved_bytes) = { let all_messages = self.session.provider_messages(); manager.update_observed_input_tokens(context_limit); let usage_pct = manager.context_usage_with(all_messages) * 100.0; + // PreCompact hook (blocking - can cancel compaction) + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let ctx = HookContext::for_pre_compact( + hook_session_id.clone(), + hook_cwd.clone(), + 0, + ); + let hook_event = HookEvent::PreCompact; + let handlers = registry.get_matching(&hook_event, &ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&hook_session_id, &hook_cwd) + .event("PreCompact") + .build(); + let hook_stats = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on( + jcode_hooks::dispatch_hooks( + &hook_event, + &hook_input, + &handlers, + &config, + ), + ) + }); + if hook_stats.any_denied() { + logging::warn( + "Context-limit auto-recovery blocked by PreCompact hook", + ); + return false; + } + } + } + + let pre_tokens = manager.effective_token_count_with(all_messages) as u64; let dropped = match manager.hard_compact_with(all_messages) { Ok(dropped) => dropped, Err(reason) => { @@ -139,10 +267,13 @@ impl Agent { return false; } }; - (dropped, usage_pct) + let post_tokens = manager.effective_token_count_with(all_messages) as u64; + let saved_bytes = pre_tokens.saturating_sub(post_tokens); + (dropped, usage_pct, saved_bytes) }; + let compaction_count = manager.compacted_count(); self.sync_session_compaction_state_from_manager(&manager); - (dropped, usage_pct) + (dropped, usage_pct, compaction_count, saved_bytes) } Err(_) => { logging::warn("Context-limit auto-recovery skipped: compaction manager lock busy"); @@ -155,6 +286,55 @@ impl Agent { self.provider_session_id = None; self.session.provider_session_id = None; + // PostCompact hook (fire-and-forget) + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let ctx = HookContext::for_post_compact(session_id.clone(), cwd.clone()); + let hook_event = HookEvent::PostCompact; + tokio::spawn(async move { + let handlers = registry.get_matching(&hook_event, &ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("PostCompact") + .build(); + jcode_hooks::dispatch_hooks(&hook_event, &hook_input, &handlers, &config).await; + } + }); + } + + // AutoCompactionControl hook (fire-and-forget, observational) + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + // auto_compaction_enabled is true here — we only reach this + // code path when auto-compaction was triggered by a context + // limit error and the provider supports compaction. + let ctx = HookContext::for_auto_compaction_control( + session_id.clone(), + cwd.clone(), + true, + compaction_count, + avg_saved_bytes, + ); + let hook_event = HookEvent::AutoCompactionControl; + tokio::spawn(async move { + let handlers = registry.get_matching(&hook_event, &ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("AutoCompactionControl") + .build(); + jcode_hooks::dispatch_hooks(&hook_event, &hook_input, &handlers, &config).await; + } + }); + } + logging::warn(&format!( "Context limit exceeded; auto-compacted and retrying (dropped {} messages, usage was {:.1}%)", dropped, usage_pct diff --git a/crates/jcode-app-core/src/agent/turn_execution.rs b/crates/jcode-app-core/src/agent/turn_execution.rs index 146296dea..9be416a51 100644 --- a/crates/jcode-app-core/src/agent/turn_execution.rs +++ b/crates/jcode-app-core/src/agent/turn_execution.rs @@ -33,6 +33,76 @@ impl Agent { self.run_turn(false).await } + /// Run a single message with events streamed to a broadcast channel (for server mode) + pub async fn run_once_streaming( + &mut self, + user_message: &str, + event_tx: broadcast::Sender, + ) -> Result<()> { + // Inject any pending notifications before the user message + let alerts = self.take_alerts(); + if !alerts.is_empty() { + let alert_text = format!( + "[NOTIFICATION]\nYou received {} notification(s) from other agents working in this codebase:\n\n{}\n\nUse the communicate tool (actions: list, read, message/broadcast, dm, channel, share) to coordinate with other agents.", + alerts.len(), + alerts.join("\n\n---\n\n") + ); + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: alert_text, + cache_control: None, + }], + ); + } + + // UserPromptSubmit hook — BLOCKING: can deny the prompt before it enters the conversation + { + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let hook_ctx = HookContext::new(&session_id, "", &cwd, "UserPromptSubmit"); + let handlers = self + .hook_registry + .get_matching(&HookEvent::UserPromptSubmit, &hook_ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("UserPromptSubmit") + .prompt(user_message) + .build(); + let stats = jcode_hooks::dispatch_hooks( + &HookEvent::UserPromptSubmit, + &hook_input, + &handlers, + &self.dispatch_config, + ) + .await; + if stats.any_denied() { + let deny_reason = stats + .results + .iter() + .find(|r| matches!(r.outcome, jcode_hooks::ClassifiedOutcome::Deny { .. })) + .map(|r| match &r.outcome { + jcode_hooks::ClassifiedOutcome::Deny { reason } => reason.clone(), + _ => String::new(), + }) + .unwrap_or_else(|| "blocked by hook".to_string()); + return Err(anyhow::anyhow!("Prompt blocked by hook: {}", deny_reason)); + } + } + } + + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: user_message.to_string(), + cache_control: None, + }], + ); + self.session.save()?; + self.run_turn_streaming(event_tx).await + } + /// Run one conversation turn with streaming events via mpsc channel (per-client) pub async fn run_once_streaming_mpsc( &mut self, @@ -77,6 +147,42 @@ impl Agent { )); } + // UserPromptSubmit hook — BLOCKING: can deny the prompt before it enters the conversation + { + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let hook_ctx = HookContext::new(&session_id, "", &cwd, "UserPromptSubmit"); + let handlers = self + .hook_registry + .get_matching(&HookEvent::UserPromptSubmit, &hook_ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("UserPromptSubmit") + .prompt(user_message) + .build(); + let stats = jcode_hooks::dispatch_hooks( + &HookEvent::UserPromptSubmit, + &hook_input, + &handlers, + &self.dispatch_config, + ) + .await; + if stats.any_denied() { + let deny_reason = stats + .results + .iter() + .find(|r| matches!(r.outcome, jcode_hooks::ClassifiedOutcome::Deny { .. })) + .map(|r| match &r.outcome { + jcode_hooks::ClassifiedOutcome::Deny { reason } => reason.clone(), + _ => String::new(), + }) + .unwrap_or_else(|| "blocked by hook".to_string()); + return Err(anyhow::anyhow!("Prompt blocked by hook: {}", deny_reason)); + } + } + } + self.add_message(Role::User, blocks); crate::telemetry::record_turn(); self.session.save()?; @@ -553,6 +659,29 @@ impl Agent { "Session restored: {} messages in session", self.session.messages.len() )); + + // Dispatch SessionUpdated hooks — session state changed to "active" via restore + { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let prev = previous_status.display().to_string(); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionUpdated") + .session_state(&prev, "active", "session_restored") + .build(); + let ctx = HookContext::for_session_updated(session_id, cwd); + let event = HookEvent::SessionUpdated; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } + Ok(previous_status) } diff --git a/crates/jcode-app-core/src/agent/turn_loops.rs b/crates/jcode-app-core/src/agent/turn_loops.rs index 706cf678d..a139c7b7b 100644 --- a/crates/jcode-app-core/src/agent/turn_loops.rs +++ b/crates/jcode-app-core/src/agent/turn_loops.rs @@ -934,6 +934,47 @@ impl Agent { match result { Ok(output) => { let output = cap_tool_output_for_history(&tc.name, output); + + // Dispatch SessionDiff hooks for file-modifying tools + if matches!(tc.name.as_str(), "Edit" | "Write" | "ApplyPatch") { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let tool_name = tc.name.clone(); + let tool_output_preview = if output.output.len() > 4096 { + output.output[..4096].to_string() + } else { + output.output.clone() + }; + let file_path = tc + .input + .get("file_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionDiff") + .tool(&tool_name, tc.input.clone(), &tc.id) + .tool_output(serde_json::json!({ "output": tool_output_preview })) + .diff(&tool_output_preview, file_path.as_deref()) + .build(); + let ctx = HookContext::for_session_diff(session_id, cwd, file_path); + let event = HookEvent::SessionDiff; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks( + &event, + &hook_input, + &handlers, + &config, + ) + .await; + } + }); + } + Bus::global().publish(BusEvent::ToolUpdated(ToolEvent { session_id: self.session.id.clone(), message_id: message_id.clone(), diff --git a/crates/jcode-app-core/src/agent/turn_streaming_broadcast.rs b/crates/jcode-app-core/src/agent/turn_streaming_broadcast.rs new file mode 100644 index 000000000..3759514bf --- /dev/null +++ b/crates/jcode-app-core/src/agent/turn_streaming_broadcast.rs @@ -0,0 +1,1100 @@ +use super::*; + +impl Agent { + pub(super) async fn run_turn_streaming( + &mut self, + event_tx: broadcast::Sender, + ) -> Result<()> { + self.set_log_context(); + let trace = trace_enabled(); + let mut context_limit_retries = 0u32; + let mut incomplete_continuations = 0u32; + + loop { + let repaired = self.repair_missing_tool_outputs(); + if repaired > 0 { + logging::warn(&format!( + "Recovered {} missing tool output(s) before API call", + repaired + )); + } + let (messages, compaction_event) = self.messages_for_provider(); + if let Some(event) = compaction_event { + // Reset cache tracker and tool lock on compaction since the message history changes + self.cache_tracker.reset(); + self.locked_tools = None; + logging::info(&format!( + "Context compacted ({}{})", + event.trigger, + event + .pre_tokens + .map(|t| format!(" {} tokens", t)) + .unwrap_or_default() + )); + let _ = event_tx.send(ServerEvent::Compaction { + trigger: event.trigger.clone(), + pre_tokens: event.pre_tokens, + post_tokens: event.post_tokens, + tokens_saved: event.tokens_saved, + duration_ms: event.duration_ms, + messages_dropped: None, + messages_compacted: event.messages_compacted, + summary_chars: event.summary_chars, + active_messages: event.active_messages, + }); + } + + let tools = self.tool_definitions().await; + // Non-blocking memory: uses pending result from last turn, spawns check for next turn + let memory_pending = self.build_memory_prompt_nonblocking( + &messages, + Some(std::sync::Arc::new({ + let event_tx = event_tx.clone(); + move |event| { + let _ = event_tx.send(event); + } + })), + ); + // Use split prompt for better caching - static content cached, dynamic not + let split_prompt = self.build_system_prompt_split(None); + self.log_prompt_prefix_accounting(&split_prompt, &tools); + + // Check for client-side cache violations before memory injection. + // Memory is an ephemeral suffix that changes each turn; tracking it would cause + // false-positive violations every turn (prior turn's memory ≠ current history prefix). + if self.should_track_client_cache() + && let Some(violation) = self.cache_tracker.record_request(&messages) + { + logging::warn(&format!( + "CLIENT_CACHE_VIOLATION: {} | turn={} messages={}", + violation.reason, violation.turn, violation.message_count + )); + } + + let mut cache_signature_messages = + if crate::config::config().features.message_timestamps { + Message::with_timestamps(&messages) + } else { + messages.clone() + }; + let mut ephemeral_signature_messages = Vec::new(); + + // Inject memory as a user message at the end (preserves cache prefix) + let mut messages_with_memory = messages; + if let Some(memory) = memory_pending.as_ref() { + let memory_count = memory.count.max(1); + let computed_age_ms = memory.computed_at.elapsed().as_millis() as u64; + crate::memory::record_injected_prompt( + &memory.prompt, + memory_count, + computed_age_ms, + ); + self.record_memory_injection_in_session(memory); + let _ = event_tx.send(ServerEvent::MemoryInjected { + count: memory_count, + prompt: memory.prompt.clone(), + display_prompt: memory.display_prompt.clone(), + prompt_chars: memory.prompt.chars().count(), + computed_age_ms, + }); + let (memory_msg, persisted) = self.prepare_memory_injection_message(memory); + if !persisted { + ephemeral_signature_messages.push(memory_msg.clone()); + } else { + cache_signature_messages.push(memory_msg.clone()); + } + messages_with_memory.push(memory_msg); + } + + logging::info(&format!( + "API call starting: {} messages, {} tools", + messages_with_memory.len(), + tools.len() + )); + let api_start = Instant::now(); + + let stamped; + let send_messages: &[Message] = if crate::config::config().features.message_timestamps { + stamped = Message::with_timestamps(&messages_with_memory); + &stamped + } else { + &messages_with_memory + }; + let provider = Arc::clone(&self.provider); + let resume_session_id = self.provider_session_id.clone(); + self.last_status_detail = None; + let _ = event_tx.send(kv_cache_request_event( + &cache_signature_messages, + &tools, + &split_prompt.static_part, + &ephemeral_signature_messages, + )); + let mut keepalive = stream_keepalive_ticker(); + let mut stream = { + let mut complete_future = std::pin::pin!(provider.complete_split( + send_messages, + &tools, + &split_prompt.static_part, + &split_prompt.dynamic_part, + resume_session_id.as_deref(), + )); + loop { + tokio::select! { + _ = keepalive.tick() => { + send_stream_keepalive_broadcast(&event_tx); + } + result = &mut complete_future => { + match result { + Ok(stream) => break stream, + Err(e) => { + if self.try_auto_compact_after_context_limit(&e.to_string()) { + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + let _ = event_tx.send(ServerEvent::Compaction { + trigger: "auto_recovery".to_string(), + pre_tokens: None, + post_tokens: None, + tokens_saved: None, + duration_ms: None, + messages_dropped: None, + messages_compacted: None, + summary_chars: None, + active_messages: None, + }); + continue; + } + return Err(e); + } + } + } + } + } + }; + + // Successful API call - reset retry counter + context_limit_retries = 0; + + logging::info(&format!( + "API stream opened in {:.2}s", + api_start.elapsed().as_secs_f64() + )); + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "stream_opened", + api_start, + vec![("mode", "broadcast".to_string())], + ); + + let mut text_content = String::new(); + let mut text_wrapped_detected = false; + let mut tool_calls: Vec = Vec::new(); + let mut current_tool: Option = None; + let mut current_tool_input = String::new(); + let mut generated_image_contexts: Vec> = Vec::new(); + let mut usage_input: Option = None; + let mut usage_output: Option = None; + let mut usage_cache_read: Option = None; + let mut usage_cache_creation: Option = None; + let mut saw_message_end = false; + let mut stop_reason: Option = None; + let mut sdk_tool_results: std::collections::HashMap = + std::collections::HashMap::new(); + let provider_name = self.provider.name().to_string(); + let store_reasoning_content = + crate::provider::stores_reasoning_content_for_context(&provider_name); + let mut reasoning_content = String::new(); + let mut reasoning_signature = String::new(); + let mut reasoning_fmt = crate::agent::reasoning_format::ReasoningStreamFormatter::new(); + let mut openai_reasoning_items: Vec = Vec::new(); + let mut openai_native_compaction: Option<(String, usize)> = None; + // Track tool_use_id -> name for tool results + let mut tool_id_to_name: std::collections::HashMap = + std::collections::HashMap::new(); + + let mut retry_after_compaction = false; + let mut keepalive = stream_keepalive_ticker(); + loop { + let next_event = std::pin::pin!(stream.next()); + let event = tokio::select! { + _ = keepalive.tick() => { + send_stream_keepalive_broadcast(&event_tx); + continue; + } + event = next_event => event, + }; + let Some(event) = event else { + log_agent_provider_stream_lifecycle( + if saw_message_end { + logging::LogLevel::Info + } else { + logging::LogLevel::Warn + }, + self, + "stream_eof", + api_start, + vec![ + ("mode", "broadcast".to_string()), + ("saw_message_end", saw_message_end.to_string()), + ], + ); + break; + }; + let event = match event { + Ok(event) => event, + Err(e) => { + let err_str = e.to_string(); + if self.try_auto_compact_after_context_limit(&err_str) { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "stream_error_retry_after_compaction", + api_start, + vec![ + ("mode", "broadcast".to_string()), + ("error", err_str.clone()), + ( + "context_limit_retries", + (context_limit_retries + 1).to_string(), + ), + ], + ); + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + retry_after_compaction = true; + let _ = event_tx.send(ServerEvent::Compaction { + trigger: "auto_recovery".to_string(), + pre_tokens: None, + post_tokens: None, + tokens_saved: None, + duration_ms: None, + messages_dropped: None, + messages_compacted: None, + summary_chars: None, + active_messages: None, + }); + break; + } + log_agent_provider_stream_lifecycle( + logging::LogLevel::Error, + self, + "stream_error", + api_start, + vec![("mode", "broadcast".to_string()), ("error", err_str)], + ); + return Err(e); + } + }; + + match event { + StreamEvent::ThinkingStart => { + // Reasoning tokens are counted in provider output usage even when + // `display.show_thinking` hides the text. Let remote clients start + // their TPS timer without forcing hidden reasoning into the transcript. + let _ = event_tx.send(ServerEvent::ConnectionPhase { + phase: crate::message::ConnectionPhase::Streaming.to_string(), + }); + } + StreamEvent::ThinkingEnd => {} + StreamEvent::ThinkingSignatureDelta(signature) => { + if store_reasoning_content { + reasoning_signature.push_str(&signature); + } + } + StreamEvent::ThinkingDelta(thinking_text) => { + // Only send thinking content if enabled in config + if crate::config::config().display.show_thinking { + let formatted = reasoning_fmt.push_delta(&thinking_text); + if !formatted.is_empty() { + let _ = event_tx.send(ServerEvent::TextDelta { text: formatted }); + } + } + if store_reasoning_content { + reasoning_content.push_str(&thinking_text); + } + } + StreamEvent::ThinkingDone { duration_secs } => { + if reasoning_fmt.is_open() { + let closing = reasoning_fmt + .finish(Some(&format!("*Thought for {:.1}s*", duration_secs))); + if !closing.is_empty() { + let _ = event_tx.send(ServerEvent::TextDelta { text: closing }); + } + } + } + StreamEvent::TextDelta(text) => { + // Close any open reasoning blockquote before real output so the + // answer renders as a normal paragraph rather than inside the quote. + if reasoning_fmt.is_open() && !text.trim().is_empty() { + let closing = reasoning_fmt.finish(None); + if !closing.is_empty() { + let _ = event_tx.send(ServerEvent::TextDelta { text: closing }); + } + } + text_content.push_str(&text); + if !text_wrapped_detected { + if let Some(marker_idx) = text_content + .find("to=functions.") + .or_else(|| text_content.find("+#+#")) + { + text_wrapped_detected = true; + let clean_prefix = + text_content[..marker_idx].trim_end().to_string(); + let _ = + event_tx.send(ServerEvent::TextReplace { text: clean_prefix }); + } else { + let _ = + event_tx.send(ServerEvent::TextDelta { text: text.clone() }); + } + } + } + StreamEvent::ToolUseStart { id, name } => { + if reasoning_fmt.is_open() { + let closing = reasoning_fmt.finish(None); + if !closing.is_empty() { + let _ = event_tx.send(ServerEvent::TextDelta { text: closing }); + } + } + let _ = event_tx.send(ServerEvent::ToolStart { + id: id.clone(), + name: name.clone(), + }); + // Track tool name for later tool_done event + tool_id_to_name.insert(id.clone(), name.clone()); + current_tool = Some(ToolCall { + id, + name, + input: serde_json::Value::Null, + intent: None, + }); + current_tool_input.clear(); + } + StreamEvent::ToolInputDelta(delta) => { + let _ = event_tx.send(ServerEvent::ToolInput { + delta: delta.clone(), + }); + current_tool_input.push_str(&delta); + } + StreamEvent::ToolUseEnd => { + if let Some(mut tool) = current_tool.take() { + tool.input = + ToolCall::parse_streamed_input_to_object(¤t_tool_input); + tool.refresh_intent_from_input(); + + let _ = event_tx.send(ServerEvent::ToolExec { + id: tool.id.clone(), + name: tool.name.clone(), + }); + + // Issue #164: dedup by tool_use_id (see turn_loops.rs). + let tool_id = tool.id.clone(); + let tool_name = tool.name.clone(); + if !super::tools::push_dedup_by_id(&mut tool_calls, tool) { + logging::warn(&format!( + "Dropping duplicate tool_use_id={} name={} (already accumulated this turn)", + tool_id, tool_name + )); + } + current_tool_input.clear(); + } + } + StreamEvent::ToolResult { + tool_use_id, + content, + is_error, + } => { + // SDK executed tool - send result and store for later + let tool_name = tool_id_to_name + .get(&tool_use_id) + .cloned() + .unwrap_or_default(); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tool_use_id.clone(), + name: tool_name, + output: content.clone(), + error: if is_error { + Some("Tool error".to_string()) + } else { + None + }, + }); + sdk_tool_results.insert(tool_use_id, (content, is_error)); + } + StreamEvent::GeneratedImage { + id, + path, + metadata_path, + output_format, + revised_prompt, + } => { + if let Some(snapshot) = self.update_generated_image_side_panel( + &id, + &path, + metadata_path.as_deref(), + &output_format, + revised_prompt.as_deref(), + ) { + let _ = event_tx.send(ServerEvent::SidePanelState { snapshot }); + } + if self.provider.supports_image_input() { + if let Some(blocks) = + crate::message::generated_image_visual_context_blocks( + &path, + metadata_path.as_deref(), + &output_format, + revised_prompt.as_deref(), + ) + { + generated_image_contexts.push(blocks); + } else { + crate::logging::warn(&format!( + "Generated image was not attached as visual context: {}", + path + )); + } + } + let _ = event_tx.send(ServerEvent::GeneratedImage { + id, + path, + metadata_path, + output_format, + revised_prompt, + }); + } + StreamEvent::TokenUsage { + input_tokens, + output_tokens, + cache_read_input_tokens, + cache_creation_input_tokens, + } => { + if let Some(input) = input_tokens { + usage_input = Some(input); + } + if let Some(output) = output_tokens { + usage_output = Some(output); + } + if cache_read_input_tokens.is_some() { + usage_cache_read = cache_read_input_tokens; + } + if cache_creation_input_tokens.is_some() { + usage_cache_creation = cache_creation_input_tokens; + } + if let Some(input) = usage_input { + self.update_compaction_usage_from_stream( + input, + usage_cache_read, + usage_cache_creation, + ); + } + } + StreamEvent::ConnectionType { connection } => { + crate::telemetry::record_connection_type(&connection); + self.last_connection_type = Some(connection.clone()); + let _ = event_tx.send(ServerEvent::ConnectionType { connection }); + } + StreamEvent::ConnectionPhase { phase } => { + let _ = event_tx.send(ServerEvent::ConnectionPhase { + phase: phase.to_string(), + }); + } + StreamEvent::StatusDetail { detail } => { + self.last_status_detail = Some(detail.clone()); + let _ = event_tx.send(ServerEvent::StatusDetail { detail }); + } + StreamEvent::MessageEnd { + stop_reason: reason, + } => { + saw_message_end = true; + if reason.is_some() { + stop_reason = reason; + } + let _ = event_tx.send(ServerEvent::MessageEnd); + } + StreamEvent::SessionId(sid) => { + self.provider_session_id = Some(sid.clone()); + self.session.provider_session_id = Some(sid.clone()); + let _ = event_tx.send(ServerEvent::SessionId { session_id: sid }); + } + StreamEvent::OpenAIReasoning { + id, + summary, + encrypted_content, + status, + } => { + if store_reasoning_content { + openai_reasoning_items.push(ContentBlock::OpenAIReasoning { + id, + summary, + encrypted_content, + status, + }); + } + } + StreamEvent::Compaction { + openai_encrypted_content, + .. + } => { + if let Some(encrypted_content) = openai_encrypted_content { + openai_native_compaction + .get_or_insert((encrypted_content, self.session.messages.len())); + } + } + StreamEvent::NativeToolCall { + request_id, + tool_name, + input, + } => { + // Execute native tool and send result back to SDK bridge + let ctx = ToolContext { + session_id: self.session.id.clone(), + message_id: self.session.id.clone(), + tool_call_id: request_id.clone(), + working_dir: self.working_dir().map(PathBuf::from), + sandbox_root: crate::sandbox::current_sandbox_root(), + stdin_request_tx: self.stdin_request_tx.clone(), + graceful_shutdown_signal: Some(self.graceful_shutdown.clone()), + execution_mode: ToolExecutionMode::AgentTurn, + }; + crate::telemetry::record_tool_call(); + let tool_result = self + .registry + .execute(&tool_name, ToolCall::normalize_input_to_object(input), ctx) + .await; + if tool_result.is_err() { + crate::telemetry::record_tool_failure(); + } + let native_result = match tool_result { + Ok(output) => NativeToolResult::success(request_id, output.output), + Err(e) => NativeToolResult::error(request_id, e.to_string()), + }; + if let Some(sender) = self.provider.native_result_sender() { + let _ = sender.send(native_result).await; + } + } + StreamEvent::UpstreamProvider { provider } => { + self.last_upstream_provider = Some(provider.clone()); + let _ = event_tx.send(ServerEvent::UpstreamProvider { provider }); + } + StreamEvent::Error { + message, + retry_after_secs, + } => { + if self.try_auto_compact_after_context_limit(&message) { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Warn, + self, + "stream_event_retry_after_compaction", + api_start, + vec![ + ("mode", "broadcast".to_string()), + ("error", message.clone()), + ( + "context_limit_retries", + (context_limit_retries + 1).to_string(), + ), + ], + ); + context_limit_retries += 1; + if context_limit_retries > Self::MAX_CONTEXT_LIMIT_RETRIES { + logging::warn( + "Context-limit compaction retry limit reached; giving up", + ); + return Err(anyhow::anyhow!( + "Context limit exceeded after {} compaction retries", + Self::MAX_CONTEXT_LIMIT_RETRIES + )); + } + retry_after_compaction = true; + let _ = event_tx.send(ServerEvent::Compaction { + trigger: "auto_recovery".to_string(), + pre_tokens: None, + post_tokens: None, + tokens_saved: None, + duration_ms: None, + messages_dropped: None, + messages_compacted: None, + summary_chars: None, + active_messages: None, + }); + break; + } + log_agent_provider_stream_lifecycle( + logging::LogLevel::Error, + self, + "stream_event_error", + api_start, + vec![ + ("mode", "broadcast".to_string()), + ("error", message.clone()), + ( + "retry_after_secs", + retry_after_secs + .map(|seconds| seconds.to_string()) + .unwrap_or_else(|| "none".to_string()), + ), + ], + ); + return Err(StreamError::new(message, retry_after_secs).into()); + } + } + } + + if retry_after_compaction { + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "retry_after_compaction", + api_start, + vec![("mode", "broadcast".to_string())], + ); + continue; + } + + let api_elapsed = api_start.elapsed(); + logging::info(&format!( + "API call complete in {:.2}s (input={} output={} cache_read={} cache_write={})", + api_elapsed.as_secs_f64(), + usage_input.unwrap_or(0), + usage_output.unwrap_or(0), + usage_cache_read.unwrap_or(0), + usage_cache_creation.unwrap_or(0), + )); + log_agent_provider_stream_lifecycle( + logging::LogLevel::Info, + self, + "stream_complete", + api_start, + vec![ + ("mode", "broadcast".to_string()), + ("saw_message_end", saw_message_end.to_string()), + ("input_tokens", usage_input.unwrap_or(0).to_string()), + ("output_tokens", usage_output.unwrap_or(0).to_string()), + ("cache_read", usage_cache_read.unwrap_or(0).to_string()), + ("cache_write", usage_cache_creation.unwrap_or(0).to_string()), + ], + ); + + // Send token usage + if usage_input.is_some() + || usage_output.is_some() + || usage_cache_read.is_some() + || usage_cache_creation.is_some() + { + crate::telemetry::record_token_usage( + usage_input.unwrap_or(0), + usage_output.unwrap_or(0), + usage_cache_read, + usage_cache_creation, + ); + let _ = event_tx.send(ServerEvent::TokenUsage { + input: usage_input.unwrap_or(0), + output: usage_output.unwrap_or(0), + cache_read_input: usage_cache_read, + cache_creation_input: usage_cache_creation, + }); + } + + // Store usage for debug queries + self.last_usage = TokenUsage { + input_tokens: usage_input.unwrap_or(0), + output_tokens: usage_output.unwrap_or(0), + cache_read_input_tokens: usage_cache_read, + cache_creation_input_tokens: usage_cache_creation, + }; + + let had_tool_calls_before = !tool_calls.is_empty(); + self.recover_text_wrapped_tool_call(&mut text_content, &mut tool_calls); + + if !had_tool_calls_before + && !tool_calls.is_empty() + && let Some(tc) = tool_calls.last() + && tc.id.starts_with("fallback_text_call_") + { + let _ = event_tx.send(ServerEvent::TextReplace { + text: text_content.clone(), + }); + let _ = event_tx.send(ServerEvent::ToolStart { + id: tc.id.clone(), + name: tc.name.clone(), + }); + tool_id_to_name.insert(tc.id.clone(), tc.name.clone()); + let _ = event_tx.send(ServerEvent::ToolInput { + delta: tc.input.to_string(), + }); + let _ = event_tx.send(ServerEvent::ToolExec { + id: tc.id.clone(), + name: tc.name.clone(), + }); + } + + // Add assistant message to history + let mut content_blocks = Vec::new(); + if !text_content.is_empty() { + content_blocks.push(ContentBlock::Text { + text: text_content.clone(), + cache_control: None, + }); + } + if store_reasoning_content { + crate::message::push_reasoning_content_block( + &mut content_blocks, + &provider_name, + &reasoning_content, + Some(&reasoning_signature), + ); + content_blocks.extend(openai_reasoning_items.iter().cloned()); + } + for tc in &tool_calls { + content_blocks.push(ContentBlock::ToolUse { + id: tc.id.clone(), + name: tc.name.clone(), + input: tc.input.clone(), + }); + } + + let assistant_message_id = if !content_blocks.is_empty() { + crate::telemetry::record_assistant_response(); + let token_usage = Some(crate::session::StoredTokenUsage { + input_tokens: self.last_usage.input_tokens, + output_tokens: self.last_usage.output_tokens, + cache_read_input_tokens: self.last_usage.cache_read_input_tokens, + cache_creation_input_tokens: self.last_usage.cache_creation_input_tokens, + }); + let message_id = + self.add_message_ext(Role::Assistant, content_blocks, None, token_usage); + self.push_embedding_snapshot_if_semantic(&text_content); + self.session.save()?; + Some(message_id) + } else { + None + }; + + if let Some((encrypted_content, compacted_count)) = openai_native_compaction.take() { + self.apply_openai_native_compaction(encrypted_content, compacted_count)?; + } + + // If stop_reason indicates truncation (e.g. max_tokens), discard tool calls + // with null/empty inputs since they were likely truncated mid-generation. + self.filter_truncated_tool_calls( + stop_reason.as_deref(), + &mut tool_calls, + assistant_message_id.as_ref(), + ); + + if tool_calls.is_empty() && !generated_image_contexts.is_empty() { + for blocks in generated_image_contexts.drain(..) { + self.add_message(Role::User, blocks); + } + self.session.save()?; + logging::info( + "Continuing turn so model can inspect generated image visual context", + ); + continue; + } + + // If no tool calls, check for soft interrupt or exit + // NOTE: We only inject here (Point B) when there are no tools. + // Injecting before tool_results would break the API requirement that + // tool_use must be immediately followed by tool_result. + if tool_calls.is_empty() { + match self.handle_streaming_no_tool_calls( + stop_reason.as_deref(), + &mut incomplete_continuations, + )? { + NoToolCallOutcome::Break => break, + NoToolCallOutcome::ContinueWithoutEvent => continue, + NoToolCallOutcome::ContinueWithSoftInterrupt { injected, point } => { + for event in Self::build_soft_interrupt_events(injected, point, None) { + let _ = event_tx.send(event); + } + continue; + } + } + } + + logging::info(&format!( + "Turn has {} tool calls to execute", + tool_calls.len() + )); + + // If provider handles tools internally, only run native tools locally + if self.provider.handles_tools_internally() { + tool_calls.retain(|tc| JCODE_NATIVE_TOOLS.contains(&tc.name.as_str())); + if tool_calls.is_empty() { + // === INJECTION POINT D: After provider-handled tools, before next API call === + let injected = self.inject_soft_interrupts(); + if !injected.is_empty() { + for event in Self::build_soft_interrupt_events(injected, "D", None) { + let _ = event_tx.send(event); + } + // Don't break - continue loop to process injected message + continue; + } + break; + } + } + + // Execute tools and add results + let tool_count = tool_calls.len(); + let mut tool_results_dirty = false; + for tool_index in 0..tool_count { + // === INJECTION POINT C (before): Check for urgent abort before each tool (except first) === + if tool_index > 0 && self.has_urgent_interrupt() { + // Add tool_results for all remaining skipped tools to maintain valid history + for skipped_tc in &tool_calls[tool_index..] { + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: skipped_tc.id.clone(), + content: "[Skipped: user interrupted]".to_string(), + is_error: Some(true), + }], + ); + } + let tools_remaining = tool_count - tool_index; + let injected = self.inject_soft_interrupts(); + if !injected.is_empty() { + for event in + Self::build_soft_interrupt_events(injected, "C", Some(tools_remaining)) + { + let _ = event_tx.send(event); + } + // Add note about skipped tools for the AI + self.add_message( + Role::User, + vec![ContentBlock::Text { + text: format!( + "[User interrupted: {} remaining tool(s) skipped]", + tools_remaining + ), + cache_control: None, + }], + ); + } + self.persist_session_best_effort("streamed tool output"); + break; // Skip remaining tools + } + let tc = &tool_calls[tool_index]; + + let message_id = assistant_message_id + .clone() + .unwrap_or_else(|| self.session.id.clone()); + + if let Some(error_msg) = tc.validation_error() { + logging::warn(&error_msg); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: error_msg.clone(), + error: Some(error_msg.clone()), + }); + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: error_msg, + is_error: Some(true), + }], + ); + tool_results_dirty = true; + continue; + } + + self.validate_tool_allowed(&tc.name)?; + + let is_native_tool = JCODE_NATIVE_TOOLS.contains(&tc.name.as_str()); + + // Check if SDK already executed this tool + if let Some((sdk_content, sdk_is_error)) = sdk_tool_results.remove(&tc.id) { + // For native tools, ignore SDK errors and execute locally + if !(is_native_tool && sdk_is_error) { + let sdk_content = cap_sdk_tool_content_for_history(&tc.name, sdk_content); + self.add_message( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: sdk_content, + is_error: if sdk_is_error { Some(true) } else { None }, + }], + ); + tool_results_dirty = true; + + // NOTE: No injection here - wait for Point D after all tools + + continue; + } + // Fall through to local execution for native tools with SDK errors + } + + // SDK didn't execute this tool (or native tool with SDK error), run it locally + let ctx = ToolContext { + session_id: self.session.id.clone(), + message_id: message_id.clone(), + tool_call_id: tc.id.clone(), + working_dir: self.working_dir().map(PathBuf::from), + sandbox_root: crate::sandbox::current_sandbox_root(), + stdin_request_tx: self.stdin_request_tx.clone(), + graceful_shutdown_signal: Some(self.graceful_shutdown.clone()), + execution_mode: ToolExecutionMode::AgentTurn, + }; + + if trace { + eprintln!("[trace] tool_exec_start name={} id={}", tc.name, tc.id); + } + + logging::info(&format!("Tool starting: {}", tc.name)); + let tool_start = Instant::now(); + + let result = self.registry.execute(&tc.name, tc.input.clone(), ctx).await; + crate::telemetry::record_tool_call(); + self.unlock_tools_if_needed(&tc.name); + let tool_elapsed = tool_start.elapsed(); + logging::info(&format!( + "Tool finished: {} in {:.2}s", + tc.name, + tool_elapsed.as_secs_f64() + )); + + match result { + Ok(output) => { + let output = cap_tool_output_for_history(&tc.name, output); + + // Dispatch SessionDiff hooks for file-modifying tools + if matches!(tc.name.as_str(), "Edit" | "Write" | "ApplyPatch") { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let tool_name = tc.name.clone(); + let tool_output_preview = if output.output.len() > 4096 { + output.output[..4096].to_string() + } else { + output.output.clone() + }; + let file_path = tc + .input + .get("file_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionDiff") + .tool(&tool_name, tc.input.clone(), &tc.id) + .tool_output(serde_json::json!({ "output": tool_output_preview })) + .diff(&tool_output_preview, file_path.as_deref()) + .build(); + let ctx = HookContext::for_session_diff(session_id, cwd, file_path); + let event = HookEvent::SessionDiff; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks( + &event, + &hook_input, + &handlers, + &config, + ) + .await; + } + }); + } + + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: output.output.clone(), + error: None, + }); + + let side_pane_images = + tool_output_side_pane_images(&tc.name, &tc.input, &output); + if !side_pane_images.is_empty() { + logging::info(&format!( + "SidePaneImages: emitting {} image(s) from tool '{}' (session={})", + side_pane_images.len(), + tc.name, + self.session.id + )); + let _ = event_tx.send(ServerEvent::SidePaneImages { + session_id: self.session.id.clone(), + images: side_pane_images, + }); + } + + let blocks = tool_output_to_content_blocks(tc.id.clone(), output); + self.add_message_with_duration( + Role::User, + blocks, + Some(tool_elapsed.as_millis() as u64), + ); + tool_results_dirty = true; + } + Err(e) => { + let error_msg = format!("Error: {}", e); + let _ = event_tx.send(ServerEvent::ToolDone { + id: tc.id.clone(), + name: tc.name.clone(), + output: error_msg.clone(), + error: Some(error_msg.clone()), + }); + + self.add_message_with_duration( + Role::User, + vec![ContentBlock::ToolResult { + tool_use_id: tc.id.clone(), + content: error_msg, + is_error: Some(true), + }], + Some(tool_elapsed.as_millis() as u64), + ); + tool_results_dirty = true; + } + } + + // NOTE: We do NOT inject between tools (non-urgent) because that would + // place user text between tool_results, which may violate API constraints. + // All non-urgent injection happens at Point D after all tools are done. + } + + if tool_results_dirty { + self.session.save()?; + } + + if !generated_image_contexts.is_empty() { + for blocks in generated_image_contexts.drain(..) { + self.add_message(Role::User, blocks); + } + self.session.save()?; + } + + // === INJECTION POINT D: All tools done, before next API call === + // This is the safest point for non-urgent injection since all tool_results + // have been added and the conversation is in a valid state. + if let PostToolInterruptOutcome::SoftInterrupt { injected, point } = + self.take_post_tool_soft_interrupt() + { + for event in Self::build_soft_interrupt_events(injected, point, None) { + let _ = event_tx.send(event); + } + } + } + + Ok(()) + } +} diff --git a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs index 13f65df1a..3eef22e58 100644 --- a/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs +++ b/crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs @@ -1178,6 +1178,49 @@ impl Agent { match result { Ok(output) => { let output = cap_tool_output_for_history(&tc.name, output); + + // Dispatch SessionDiff hooks for file-modifying tools + if matches!(tc.name.as_str(), "Edit" | "Write" | "ApplyPatch") { + let registry = self.hook_registry.clone(); + let config = self.dispatch_config.clone(); + let session_id = self.session.id.clone(); + let cwd = self.session.working_dir.clone().unwrap_or_default(); + let tool_name = tc.name.clone(); + let tool_output_preview = if output.output.len() > 4096 { + output.output[..4096].to_string() + } else { + output.output.clone() + }; + let file_path = tc + .input + .get("file_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionDiff") + .tool(&tool_name, tc.input.clone(), &tc.id) + .tool_output( + serde_json::json!({ "output": tool_output_preview }), + ) + .diff(&tool_output_preview, file_path.as_deref()) + .build(); + let ctx = HookContext::for_session_diff(session_id, cwd, file_path); + let event = HookEvent::SessionDiff; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks( + &event, + &hook_input, + &handlers, + &config, + ) + .await; + } + }); + } + let _ = event_tx.send(ServerEvent::ToolDone { id: tc.id.clone(), name: tc.name.clone(), diff --git a/crates/jcode-app-core/src/dcg_bridge.rs b/crates/jcode-app-core/src/dcg_bridge.rs index 1612992b3..9f66a8023 100644 --- a/crates/jcode-app-core/src/dcg_bridge.rs +++ b/crates/jcode-app-core/src/dcg_bridge.rs @@ -37,7 +37,8 @@ use std::path::PathBuf; use std::sync::{LazyLock, Mutex}; use dcg_core::{Decision, Effect, Engine, EngineConfig, Mode, Session, ToolCall}; -use jcode_agent_runtime::permission::PermissionMode; +use jcode_hooks::{DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry}; + pub use crate::yolo_classifier::YoloClassifier; @@ -305,6 +306,183 @@ pub fn classify_with_mode(action: &str, mode: Mode) -> BridgeDecision { } } +/// Dispatch permission-related hooks after a bridge classification. +/// +/// This is the integration point between dcg-core's permission decision and +/// the jcode hooks v2 system. It fires the appropriate hook event based on the +/// [`BridgeDecision`] so that user-configured hooks can observe or override +/// permission outcomes. +/// +/// # Behavior +/// +/// - [`BridgeDecision::Prompt`]: Dispatches `PermissionRequest` hooks. If any +/// hook returns a **deny** decision, this function returns `true` (meaning +/// the caller should treat the request as blocked). Otherwise returns +/// `false` (proceed with the normal prompt flow). +/// - [`BridgeDecision::Deny`]: Dispatches `PermissionDenied` hooks as an +/// **observational** event (fire-and-forget). Always returns `false` since +/// the decision is already a denial. +/// - [`BridgeDecision::Allow`]: No-op, returns `false`. +/// +/// # Errors +/// +/// Hook dispatch failures are logged to stderr but never propagated. A +/// failing hook never blocks or changes the permission outcome. +pub async fn dispatch_permission_hooks( + action: &str, + decision: BridgeDecision, + session_id: &str, + cwd: &str, +) -> bool { + match decision { + BridgeDecision::Allow => return false, + BridgeDecision::Prompt | BridgeDecision::Deny => {} + } + + let config = jcode_hooks::load_hooks_config(); + if config.is_empty() { + return false; + } + + let registry = HookRegistry::from_config(config.clone()); + + let (event, mut context) = match decision { + BridgeDecision::Prompt => ( + HookEvent::PermissionRequest, + HookContext::new(session_id, "", cwd, "PermissionRequest"), + ), + BridgeDecision::Deny => ( + HookEvent::PermissionDenied, + HookContext::new(session_id, "", cwd, "PermissionDenied"), + ), + BridgeDecision::Allow => unreachable!(), + }; + let mode_name = format!("{:?}", current_mode()); + context.tool_name = Some(action.to_string()); + context.permission_mode = Some(mode_name.clone()); + + let handlers = registry.get_matching(&event, &context); + if handlers.is_empty() { + return false; + } + + let input = HookInputBuilder::new() + .session(session_id, cwd) + .event(event.display_name()) + .permission(&mode_name, "", action) + .build(); + + let dispatch_config = DispatchConfig::from_settings(&config.settings); + let stats = jcode_hooks::dispatch_hooks(&event, &input, &handlers, &dispatch_config).await; + + // For PermissionRequest: return true if any hook denied (blocks the prompt). + // For PermissionDenied: fire-and-forget, always return false. + if matches!(decision, BridgeDecision::Prompt) { + stats.any_denied() + } else { + false + } +} + +/// Dispatch `PermissionAsked` hooks when a permission request is presented to +/// the user. +/// +/// This is a **blocking** event — hooks can return `"allow"` to pre-approve +/// the permission (skipping the user prompt) or `"deny"` to block it. +/// +/// # Returns +/// +/// `true` if any hook pre-approved the permission (the caller should treat +/// the request as auto-approved). `false` otherwise (proceed with normal +/// prompt flow, or a hook denied). +pub async fn dispatch_permission_asked_hooks( + action: &str, + request_id: &str, + session_id: &str, + cwd: &str, +) -> bool { + let config = jcode_hooks::load_hooks_config(); + if config.is_empty() { + return false; + } + + let registry = HookRegistry::from_config(config.clone()); + let mode_name = format!("{:?}", current_mode()); + + let context = HookContext::for_permission_asked( + action.to_string(), + session_id.to_string(), + mode_name.clone(), + request_id.to_string(), + ); + + let event = HookEvent::PermissionAsked; + let handlers = registry.get_matching(&event, &context); + if handlers.is_empty() { + return false; + } + + let input = HookInputBuilder::new() + .session(session_id, cwd) + .event(event.display_name()) + .permission(&mode_name, request_id, action) + .build(); + + let dispatch_config = DispatchConfig::from_settings(&config.settings); + let stats = jcode_hooks::dispatch_hooks(&event, &input, &handlers, &dispatch_config).await; + + // Return true if any hook explicitly allowed (pre-approve). + stats.allowed > 0 +} + +/// Dispatch `PermissionReplied` hooks after a permission decision is recorded. +/// +/// This is an **observational** event — hooks cannot change the outcome. +/// Fire-and-forget: failures are logged but never propagated. +pub async fn dispatch_permission_replied_hooks( + request_id: &str, + session_id: &str, + approved: bool, + via: &str, +) { + let config = jcode_hooks::load_hooks_config(); + if config.is_empty() { + return; + } + + let registry = HookRegistry::from_config(config.clone()); + + let mut context = HookContext::for_permission_replied( + request_id.to_string(), + session_id.to_string(), + approved, + ); + // Populate permission_decision so hooks can see the outcome. + context.permission_mode = Some(via.to_string()); + + let event = HookEvent::PermissionReplied; + let handlers = registry.get_matching(&event, &context); + if handlers.is_empty() { + return; + } + + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let input = HookInputBuilder::new() + .session(session_id, &cwd) + .event(event.display_name()) + .permission(via, request_id, "") + .build(); + // Populate permission_decision in the input. + let mut input = input; + input.permission_decision = Some(if approved { "approved" } else { "denied" }.to_string()); + + let dispatch_config = DispatchConfig::from_settings(&config.settings); + let _ = jcode_hooks::dispatch_hooks(&event, &input, &handlers, &dispatch_config).await; +} + /// Centralized list of action names that auto-allowed under jcode's /// legacy `AUTO_ALLOWED` table. Used by the `Default` / `Auto` mode path. /// Kept in lockstep with [`action_to_tool_call`] so the two views never diff --git a/crates/jcode-app-core/src/server/client_lifecycle.rs b/crates/jcode-app-core/src/server/client_lifecycle.rs index a6d4100e9..6b10d738b 100644 --- a/crates/jcode-app-core/src/server/client_lifecycle.rs +++ b/crates/jcode-app-core/src/server/client_lifecycle.rs @@ -61,6 +61,9 @@ use crate::transport::Stream; use anyhow::Result; use futures::FutureExt; use jcode_agent_runtime::{InterruptSignal, SoftInterruptSource, StreamError}; +use jcode_hooks::{ + ClassifiedOutcome, DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry, +}; use std::collections::{HashMap, HashSet}; use std::sync::{ Arc, @@ -2617,6 +2620,62 @@ async fn cancel_processing_message( *state.task = Some(handle); return; } + + // --- Stop hook (BLOCKING) --- + // Dispatch Stop hooks before cancelling. If any hook denies, abort the + // cancel and leave the task running. + { + let hook_config = jcode_hooks::load_hooks_config(); + if !hook_config.is_empty() { + let registry = HookRegistry::from_config(hook_config.clone()); + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let hook_ctx = HookContext::for_stop( + session_control.session_id.clone(), + cwd.clone(), + Some("user_cancel".to_string()), + ); + let handlers = registry.get_matching(&HookEvent::Stop, &hook_ctx); + if !handlers.is_empty() { + let mut hook_input = HookInputBuilder::new() + .session(&session_control.session_id, &cwd) + .event("Stop") + .build(); + hook_input.stop_type = Some("user_cancel".to_string()); + let dispatch_config = DispatchConfig::from_settings(&hook_config.settings); + let stats = jcode_hooks::dispatch_hooks( + &HookEvent::Stop, + &hook_input, + &handlers, + &dispatch_config, + ) + .await; + if stats.any_denied() { + let deny_reason = stats + .results + .iter() + .find(|r| matches!(r.outcome, ClassifiedOutcome::Deny { .. })) + .map(|r| match &r.outcome { + ClassifiedOutcome::Deny { reason } => reason.clone(), + _ => String::new(), + }) + .unwrap_or_else(|| "blocked by hook".to_string()); + crate::logging::info(&format!( + "SERVER_INTERRUPT_CANCEL_BLOCKED_BY_HOOK request_id={:?} session={} message_id={:?} reason={} elapsed_ms={}", + request_id, + session_label, + *state.message_id, + deny_reason, + cancel_start.elapsed().as_millis() + )); + *state.task = Some(handle); + return; + } + } + } + } + session_control.request_cancel(); crate::logging::info(&format!( "SERVER_INTERRUPT_CANCEL_SIGNALLED request_id={:?} session={} message_id={:?} wait_ms=500", @@ -2818,6 +2877,7 @@ pub(super) async fn process_message_streaming_mpsc( ) -> Result<()> { let mut agent = agent.lock().await; let session_id = agent.session_id().to_string(); + let cwd = agent.working_dir().unwrap_or_default().to_string(); let result = agent .run_once_streaming_mpsc(content, images, system_reminder, event_tx) .await; @@ -2827,9 +2887,51 @@ pub(super) async fn process_message_streaming_mpsc( "turn_completed", "message_turn_finished", ) - .with_session_id(session_id) + .with_session_id(session_id.clone()) .force_attribution(), ); + + // Dispatch SessionIdle hooks — turn completed, session is now idle + { + let registry = agent.hook_registry().clone(); + let config = agent.dispatch_config().clone(); + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionIdle") + .build(); + let ctx = HookContext::for_session_idle(session_id, cwd); + let event = HookEvent::SessionIdle; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } + } else { + // Dispatch SessionError hooks — turn failed with an error + let error_msg = result + .as_ref() + .err() + .map(crate::util::format_error_chain) + .unwrap_or_else(|| "unknown error".to_string()); + { + let registry = agent.hook_registry().clone(); + let config = agent.dispatch_config().clone(); + let mut hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("SessionError") + .build(); + hook_input.error = Some(error_msg); + let ctx = HookContext::for_session_error(session_id, cwd); + let event = HookEvent::SessionError; + tokio::spawn(async move { + let handlers = registry.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &config).await; + } + }); + } } result } diff --git a/crates/jcode-app-core/src/tool/edit.rs b/crates/jcode-app-core/src/tool/edit.rs index 17a235b13..e4cd97222 100644 --- a/crates/jcode-app-core/src/tool/edit.rs +++ b/crates/jcode-app-core/src/tool/edit.rs @@ -2,6 +2,9 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; use anyhow::Result; use async_trait::async_trait; +use jcode_hooks::{ + DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry, load_hooks_config, +}; use serde::Deserialize; use serde_json::{Value, json}; use similar::{ChangeTag, TextDiff}; @@ -136,6 +139,42 @@ impl Tool for EditTool { detail, })); + // FileChanged hook (fire-and-forget, observational) + { + let session_id = ctx.session_id.clone(); + let cwd = ctx + .working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let file_path = path.to_string_lossy().to_string(); + let hook_diff = diff.clone(); + tokio::spawn(async move { + let hook_config = load_hooks_config(); + let hook_registry = HookRegistry::from_config(hook_config.clone()); + let dispatch_config = DispatchConfig::from_settings(&hook_config.settings); + let mut hook_ctx = HookContext::new(&session_id, "", &cwd, "FileChanged"); + hook_ctx.file_path = Some(file_path.clone()); + let handlers = hook_registry.get_matching(&HookEvent::FileChanged, &hook_ctx); + if !handlers.is_empty() { + let mut hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("FileChanged") + .build(); + hook_input.file_path = Some(file_path); + hook_input.change_type = Some("modified".to_string()); + hook_input.diff = Some(hook_diff); + let _ = jcode_hooks::dispatch_hooks( + &HookEvent::FileChanged, + &hook_input, + &handlers, + &dispatch_config, + ) + .await; + } + }); + } + // Extract context around the edit to help with consecutive edits let end_line = start_line + params.new_string.lines().count().saturating_sub(1); let context = extract_context(&new_content, start_line, end_line, 3); diff --git a/crates/jcode-app-core/src/tool/mod.rs b/crates/jcode-app-core/src/tool/mod.rs index a25ed1b0f..bccfcee6f 100644 --- a/crates/jcode-app-core/src/tool/mod.rs +++ b/crates/jcode-app-core/src/tool/mod.rs @@ -38,10 +38,14 @@ use crate::compaction::CompactionManager; use crate::provider::Provider; use crate::skill::SkillRegistry; use anyhow::Result; +use jcode_hooks::{DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry}; use jcode_message_types::ToolDefinition; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +#[cfg(feature = "dcp")] +use std::sync::Mutex; + use std::sync::{LazyLock, RwLock as StdRwLock}; use tokio::sync::RwLock; @@ -99,6 +103,12 @@ pub struct Registry { tools: Arc>>>, skills: Arc>, compaction: Arc>, + /// Hook system for lifecycle events (PreToolUse, PostToolUse, etc.) + hook_registry: Arc>, + /// Dispatch configuration for hooks + dispatch_config: DispatchConfig, + #[cfg(feature = "dcp")] + dcp: Option>>, } impl Clone for Registry { @@ -109,11 +119,25 @@ impl Clone for Registry { // Each clone gets a fresh CompactionManager to prevent parallel // subagents from corrupting each other's message history compaction: Arc::new(RwLock::new(CompactionManager::new())), + hook_registry: self.hook_registry.clone(), + dispatch_config: self.dispatch_config.clone(), + #[cfg(feature = "dcp")] + dcp: self.dcp.clone(), } } } impl Registry { + /// Access the hook registry for dispatching lifecycle hooks. + pub fn hook_registry(&self) -> &Arc> { + &self.hook_registry + } + + /// Access the dispatch configuration for hooks. + pub fn dispatch_config(&self) -> &DispatchConfig { + &self.dispatch_config + } + fn shared_skills_registry() -> Arc> { SkillRegistry::shared_registry() } @@ -145,6 +169,10 @@ impl Registry { tools: Arc::new(RwLock::new(HashMap::new())), skills: Arc::new(RwLock::new(SkillRegistry::default())), compaction: Arc::new(RwLock::new(CompactionManager::new())), + hook_registry: Arc::new(RwLock::new(HookRegistry::default())), + dispatch_config: DispatchConfig::default(), + #[cfg(feature = "dcp")] + dcp: None, } } @@ -267,10 +295,17 @@ impl Registry { let compaction = Arc::new(RwLock::new(CompactionManager::new())); let compaction_ms = compaction_start.elapsed().as_millis(); let registry_struct_start = std::time::Instant::now(); + let hook_config = jcode_hooks::load_hooks_config(); + let hook_registry = Arc::new(RwLock::new(HookRegistry::from_config(hook_config.clone()))); + let dispatch_config = DispatchConfig::from_settings(&hook_config.settings); let registry = Self { tools: Arc::new(RwLock::new(HashMap::new())), skills: skills.clone(), compaction: compaction.clone(), + hook_registry, + dispatch_config, + #[cfg(feature = "dcp")] + dcp: None, }; let registry_struct_ms = registry_struct_start.elapsed().as_millis(); @@ -545,6 +580,52 @@ impl Registry { Self::tool_lifecycle_fields("start", name, resolved_name, &input, &ctx), ); + // --- PreToolUse hook --- + let cwd = ctx + .working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let hook_ctx = HookContext::for_tool( + resolved_name.to_string(), + ctx.session_id.clone(), + cwd.clone(), + ); + { + let hook_registry = self.hook_registry.read().await; + let handlers = hook_registry.get_matching(&HookEvent::PreToolUse, &hook_ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&ctx.session_id, &cwd) + .event("PreToolUse") + .tool(resolved_name, input.clone(), &ctx.tool_call_id) + .build(); + let stats = jcode_hooks::dispatch_hooks( + &HookEvent::PreToolUse, + &hook_input, + &handlers, + &self.dispatch_config, + ) + .await; + if stats.any_denied() { + let deny_reason = stats + .results + .iter() + .find(|r| matches!(r.outcome, jcode_hooks::ClassifiedOutcome::Deny { .. })) + .map(|r| match &r.outcome { + jcode_hooks::ClassifiedOutcome::Deny { reason } => reason.clone(), + _ => String::new(), + }) + .unwrap_or_else(|| "blocked by hook".to_string()); + return Err(anyhow::anyhow!( + "Tool '{}' blocked by hook: {}", + resolved_name, + deny_reason + )); + } + } + } + let started_at = std::time::Instant::now(); let result = tool.execute(input.clone(), ctx.clone()).await; let latency_ms = started_at.elapsed().as_millis().min(u128::from(u64::MAX)) as u64; @@ -552,8 +633,51 @@ impl Registry { crate::telemetry::record_tool_execution(resolved_name, &input, result.is_ok(), latency_ms); let mut output = match result { - Ok(output) => output, + Ok(output) => { + // --- PostToolUse hook --- + let hook_registry = self.hook_registry.read().await; + let handlers = hook_registry.get_matching(&HookEvent::PostToolUse, &hook_ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&ctx.session_id, &cwd) + .event("PostToolUse") + .tool(resolved_name, input.clone(), &ctx.tool_call_id) + .tool_output(serde_json::json!({ "output": &output.output })) + .duration(latency_ms) + .build(); + let _ = jcode_hooks::dispatch_hooks( + &HookEvent::PostToolUse, + &hook_input, + &handlers, + &self.dispatch_config, + ) + .await; + } + drop(hook_registry); + output + } Err(error) => { + // --- PostToolUseFailure hook --- + let hook_registry = self.hook_registry.read().await; + let handlers = + hook_registry.get_matching(&HookEvent::PostToolUseFailure, &hook_ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&ctx.session_id, &cwd) + .event("PostToolUseFailure") + .tool(resolved_name, input.clone(), &ctx.tool_call_id) + .error(&crate::util::format_error_chain(&error), -1) + .duration(latency_ms) + .build(); + let _ = jcode_hooks::dispatch_hooks( + &HookEvent::PostToolUseFailure, + &hook_input, + &handlers, + &self.dispatch_config, + ) + .await; + } + drop(hook_registry); let mut fields = Self::tool_lifecycle_fields("error", name, resolved_name, &input, &ctx); fields.push(("elapsed_ms".to_string(), latency_ms.to_string())); diff --git a/crates/jcode-app-core/src/tool/task.rs b/crates/jcode-app-core/src/tool/task.rs index c390a836e..b8f74dfce 100644 --- a/crates/jcode-app-core/src/tool/task.rs +++ b/crates/jcode-app-core/src/tool/task.rs @@ -7,6 +7,7 @@ use crate::provider::Provider; use crate::session::Session; use anyhow::Result; use async_trait::async_trait; +use jcode_hooks::{HookContext, HookEvent, HookInputBuilder}; use serde::Deserialize; use serde_json::{Value, json}; use std::collections::{HashMap, HashSet}; @@ -214,6 +215,28 @@ impl Tool for SubagentTool { Some(allowed), ); + // Dispatch SubagentStart hooks (fire-and-forget, observational only) + { + let hook_registry = self.registry.hook_registry().clone(); + let dispatch_config = self.registry.dispatch_config().clone(); + let sub_session_id = agent.session_id().to_string(); + let subagent_type = params.subagent_type.clone(); + let hook_input = HookInputBuilder::new() + .session(&sub_session_id, "") + .event("SubagentStart") + .build(); + let ctx = HookContext::for_subagent_start(sub_session_id, None, Some(subagent_type)); + let event = HookEvent::SubagentStart; + tokio::spawn(async move { + let handlers = hook_registry.read().await; + let handlers = handlers.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &dispatch_config) + .await; + } + }); + } + let start = std::time::Instant::now(); let final_text = agent.run_once_capture(¶ms.prompt).await.map_err(|err| { logging::warn(&format!( @@ -245,6 +268,28 @@ impl Tool for SubagentTool { start.elapsed().as_secs_f64() )); + // Dispatch SubagentStop hooks (fire-and-forget, observational only) + { + let hook_registry = self.registry.hook_registry().clone(); + let dispatch_config = self.registry.dispatch_config().clone(); + let sub_session_id = sub_session_id.clone(); + let subagent_type = params.subagent_type.clone(); + let hook_input = HookInputBuilder::new() + .session(&sub_session_id, "") + .event("SubagentStop") + .build(); + let ctx = HookContext::for_subagent_stop(sub_session_id, None, Some(subagent_type)); + let event = HookEvent::SubagentStop; + tokio::spawn(async move { + let handlers = hook_registry.read().await; + let handlers = handlers.get_matching(&event, &ctx); + if !handlers.is_empty() { + jcode_hooks::dispatch_hooks(&event, &hook_input, &handlers, &dispatch_config) + .await; + } + }); + } + listener.abort(); let mut summary: Vec = summary_map diff --git a/crates/jcode-app-core/src/tool/todo.rs b/crates/jcode-app-core/src/tool/todo.rs index 42fcd87ce..0fa2eeb7b 100644 --- a/crates/jcode-app-core/src/tool/todo.rs +++ b/crates/jcode-app-core/src/tool/todo.rs @@ -3,6 +3,9 @@ use crate::bus::{Bus, BusEvent, TodoEvent}; use crate::todo::{TodoItem, load_todos, save_todos}; use anyhow::Result; use async_trait::async_trait; +use jcode_hooks::{ + DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry, load_hooks_config, +}; use serde::Deserialize; use serde_json::{Value, json}; @@ -89,6 +92,15 @@ impl Tool for TodoTool { }; match params.todos { Some(todos) => { + let existing = load_todos(&ctx.session_id).unwrap_or_default(); + let existing_ids: std::collections::HashSet<&str> = + existing.iter().map(|t| t.id.as_str()).collect(); + let completed_ids: std::collections::HashSet<&str> = existing + .iter() + .filter(|t| t.status == "completed") + .map(|t| t.id.as_str()) + .collect(); + save_todos(&ctx.session_id, &todos)?; Bus::global().publish(BusEvent::TodoUpdated(TodoEvent { @@ -96,6 +108,69 @@ impl Tool for TodoTool { todos: todos.clone(), })); + // Fire TaskCreated / TaskCompleted hooks (fire-and-forget, observational) + let session_id = ctx.session_id.clone(); + let cwd = ctx + .working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let new_todos: Vec = todos + .iter() + .filter(|t| !existing_ids.contains(t.id.as_str())) + .cloned() + .collect(); + let newly_completed: Vec = todos + .iter() + .filter(|t| t.status == "completed" && !completed_ids.contains(t.id.as_str())) + .cloned() + .collect(); + tokio::spawn(async move { + let hook_config = load_hooks_config(); + let hook_registry = HookRegistry::from_config(hook_config.clone()); + let dispatch_config = DispatchConfig::from_settings(&hook_config.settings); + + for todo in &new_todos { + let mut hook_ctx = HookContext::new(&session_id, "", &cwd, "TaskCreated"); + hook_ctx.task_id = Some(todo.id.clone()); + let handlers = + hook_registry.get_matching(&HookEvent::TaskCreated, &hook_ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("TaskCreated") + .build(); + let _ = jcode_hooks::dispatch_hooks( + &HookEvent::TaskCreated, + &hook_input, + &handlers, + &dispatch_config, + ) + .await; + } + } + + for todo in &newly_completed { + let mut hook_ctx = HookContext::new(&session_id, "", &cwd, "TaskCompleted"); + hook_ctx.task_id = Some(todo.id.clone()); + let handlers = + hook_registry.get_matching(&HookEvent::TaskCompleted, &hook_ctx); + if !handlers.is_empty() { + let hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("TaskCompleted") + .build(); + let _ = jcode_hooks::dispatch_hooks( + &HookEvent::TaskCompleted, + &hook_input, + &handlers, + &dispatch_config, + ) + .await; + } + } + }); + let remaining = todos.iter().filter(|t| t.status != "completed").count(); Ok(ToolOutput::new(serde_json::to_string_pretty(&todos)?) .with_title(format!("{} todos", remaining)) diff --git a/crates/jcode-app-core/src/tool/write.rs b/crates/jcode-app-core/src/tool/write.rs index 223b09868..e50997fbb 100644 --- a/crates/jcode-app-core/src/tool/write.rs +++ b/crates/jcode-app-core/src/tool/write.rs @@ -2,6 +2,9 @@ use super::{Tool, ToolContext, ToolOutput}; use crate::bus::{Bus, BusEvent, FileOp, FileTouch}; use anyhow::Result; use async_trait::async_trait; +use jcode_hooks::{ + DispatchConfig, HookContext, HookEvent, HookInputBuilder, HookRegistry, load_hooks_config, +}; use serde::Deserialize; use serde_json::{Value, json}; use similar::{ChangeTag, TextDiff}; @@ -103,6 +106,43 @@ impl Tool for WriteTool { detail, })); + // FileChanged hook (fire-and-forget, observational) + { + let session_id = ctx.session_id.clone(); + let cwd = ctx + .working_dir + .as_ref() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + let file_path = path.to_string_lossy().to_string(); + let change_type = if existed { "modified" } else { "created" }.to_string(); + let hook_diff = diff.clone(); + tokio::spawn(async move { + let hook_config = load_hooks_config(); + let hook_registry = HookRegistry::from_config(hook_config.clone()); + let dispatch_config = DispatchConfig::from_settings(&hook_config.settings); + let mut hook_ctx = HookContext::new(&session_id, "", &cwd, "FileChanged"); + hook_ctx.file_path = Some(file_path.clone()); + let handlers = hook_registry.get_matching(&HookEvent::FileChanged, &hook_ctx); + if !handlers.is_empty() { + let mut hook_input = HookInputBuilder::new() + .session(&session_id, &cwd) + .event("FileChanged") + .build(); + hook_input.file_path = Some(file_path); + hook_input.change_type = Some(change_type); + hook_input.diff = Some(hook_diff); + let _ = jcode_hooks::dispatch_hooks( + &HookEvent::FileChanged, + &hook_input, + &handlers, + &dispatch_config, + ) + .await; + } + }); + } + if existed { Ok(ToolOutput::new(format!( "Updated {} ({} lines){}\n{}", diff --git a/crates/jcode-base/src/live_tests.rs b/crates/jcode-base/src/live_tests.rs index e320c73be..a393f48fb 100644 --- a/crates/jcode-base/src/live_tests.rs +++ b/crates/jcode-base/src/live_tests.rs @@ -2238,10 +2238,10 @@ pub fn classify_provider_test_coverage_line(line: &str) -> CoverageLineStyle { } // Per-pair in-progress rows lead with an `N/M` stage count. - if let Some(first) = t.split_whitespace().next() { - if is_stage_fraction(first) { - return if t.contains("failed at") { Fail } else { Warn }; - } + if let Some(first) = t.split_whitespace().next() + && is_stage_fraction(first) + { + return if t.contains("failed at") { Fail } else { Warn }; } // Provider-monitor rows end with a `ready/seen` fraction; color by status diff --git a/crates/jcode-base/src/safety.rs b/crates/jcode-base/src/safety.rs index 914931ea1..d14f5ff78 100644 --- a/crates/jcode-base/src/safety.rs +++ b/crates/jcode-base/src/safety.rs @@ -28,6 +28,57 @@ fn dispatch_permission_notification(action: &str, description: &str, request_id: } } +/// Callback for dispatching `PermissionAsked` hooks. +/// +/// Args: `(action, request_id, session_id)`. +/// Returns `true` if a hook pre-approved the permission. +type PermissionAskedHookDispatcher = fn(&str, &str, &str) -> bool; + +static PERMISSION_ASKED_HOOK_DISPATCHER: OnceLock = OnceLock::new(); + +/// Register the `PermissionAsked` hook dispatcher. +/// +/// Called at startup by the app-core layer. The dispatcher fires +/// `PermissionAsked` hooks and returns `true` if any hook pre-approved. +pub fn register_permission_asked_hook_dispatcher(dispatcher: PermissionAskedHookDispatcher) { + let _ = PERMISSION_ASKED_HOOK_DISPATCHER.set(dispatcher); +} + +fn dispatch_permission_asked_hooks(action: &str, request_id: &str, session_id: &str) -> bool { + if let Some(dispatcher) = PERMISSION_ASKED_HOOK_DISPATCHER.get() { + dispatcher(action, request_id, session_id) + } else { + false + } +} + +/// Callback for dispatching `PermissionReplied` hooks. +/// +/// Args: `(request_id, session_id, approved, via)`. +type PermissionRepliedHookDispatcher = fn(&str, &str, bool, &str); + +static PERMISSION_REPLIED_HOOK_DISPATCHER: OnceLock = + OnceLock::new(); + +/// Register the `PermissionReplied` hook dispatcher. +/// +/// Called at startup by the app-core layer. The dispatcher fires +/// `PermissionReplied` hooks as an observational event. +pub fn register_permission_replied_hook_dispatcher(dispatcher: PermissionRepliedHookDispatcher) { + let _ = PERMISSION_REPLIED_HOOK_DISPATCHER.set(dispatcher); +} + +fn dispatch_permission_replied_hooks( + request_id: &str, + session_id: &str, + approved: bool, + via: &str, +) { + if let Some(dispatcher) = PERMISSION_REPLIED_HOOK_DISPATCHER.get() { + dispatcher(request_id, session_id, approved, via); + } +} + // --------------------------------------------------------------------------- // Action classification // --------------------------------------------------------------------------- @@ -184,10 +235,29 @@ impl SafetySystem { } /// Submit a permission request. Returns `Queued` with the request id. + /// + /// Before queuing, fires `PermissionAsked` hooks (blocking). If any hook + /// pre-approves (returns "allow"), the request is auto-approved and + /// `Approved` is returned without queuing or notifying. pub fn request_permission(&self, request: PermissionRequest) -> PermissionResult { let request_id = request.id.clone(); let action = request.action.clone(); let description = request.description.clone(); + let session_id = request + .context + .as_ref() + .and_then(|c| c.get("session_id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + // Fire PermissionAsked hooks (blocking, can pre-approve). + if dispatch_permission_asked_hooks(&action, &request_id, &session_id) { + // A hook pre-approved — auto-approve without queuing. + let _ = self.record_decision(&request_id, true, "hook_pre_approved", None); + return PermissionResult::Approved { message: None }; + } + if let Ok(mut q) = self.queue.lock() { q.push(request); let _ = persist_queue(&q); @@ -240,6 +310,9 @@ impl SafetySystem { } /// Record a user decision (approve / deny) for a pending request. + /// + /// After recording, fires `PermissionReplied` hooks as an observational + /// event (fire-and-forget). pub fn record_decision( &self, request_id: &str, @@ -247,6 +320,19 @@ impl SafetySystem { via: &str, message: Option, ) -> Result<()> { + // Look up session_id from the queued request before removing it. + let session_id = if let Ok(q) = self.queue.lock() { + q.iter() + .find(|r| r.id == request_id) + .and_then(|r| r.context.as_ref()) + .and_then(|c| c.get("session_id")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string() + } else { + String::new() + }; + // Remove from queue if let Ok(mut q) = self.queue.lock() { q.retain(|r| r.id != request_id); @@ -266,6 +352,9 @@ impl SafetySystem { let _ = persist_history(&h); } + // Fire PermissionReplied hooks (observational, fire-and-forget). + dispatch_permission_replied_hooks(request_id, &session_id, approved, via); + Ok(()) } diff --git a/crates/jcode-hooks/Cargo.toml b/crates/jcode-hooks/Cargo.toml new file mode 100644 index 000000000..64863e8c7 --- /dev/null +++ b/crates/jcode-hooks/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "jcode-hooks" +version = "0.1.0" +edition = "2021" +description = "Hook system for jcode lifecycle events" + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +dirs = "5" +futures = "0.3" +regex = "1" +reqwest = { version = "0.11", features = ["json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["full"] } +toml = "0.8" diff --git a/crates/jcode-hooks/src/cli.rs b/crates/jcode-hooks/src/cli.rs new file mode 100644 index 000000000..468d034a5 --- /dev/null +++ b/crates/jcode-hooks/src/cli.rs @@ -0,0 +1,1014 @@ +//! CLI commands for inspecting and managing the hooks system. +//! +//! Provides subcommands for listing, enabling/disabling, testing, and +//! displaying metrics of configured hooks. Designed to be integrated +//! into the main `jcode` CLI dispatch (e.g. `jcode hooks list`). +//! +//! # Subcommands +//! +//! | Command | Description | +//! |---------------------------------|--------------------------------------------------| +//! | `hooks list` | List all configured hooks across all events | +//! | `hooks list --event ` | List hooks for a specific event | +//! | `hooks enable ` | Enable a hook handler by event and index | +//! | `hooks disable ` | Disable a hook handler by event and index | +//! | `hooks test ` | Dry-run all hooks for an event | +//! | `hooks test --execute` | Actually execute hooks for an event | +//! | `hooks metrics` | Show execution metrics for all hooks | +//! | `hooks metrics --json` | Emit metrics as JSON | + +use std::collections::HashMap; +use std::fmt; +use std::path::PathBuf; + +use serde::Serialize; + +use crate::config::{load_hooks_config, HookEvent, HookHandlerConfig, HookSettings, HooksConfig}; +use crate::dispatch::{dispatch_hooks, ClassifiedOutcome, DispatchConfig}; +use crate::types::HookInput; + +// =========================================================================== +// Error type +// =========================================================================== + +/// Errors returned by CLI operations. +#[derive(Debug)] +pub enum CliError { + /// The user-supplied event name did not match any known variant. + UnknownEvent(String), + /// No hooks are configured for the given event. + NoHooksForEvent(String), + /// The handler index is out of range for the event's handler list. + IndexOutOfRange { + index: usize, + event: String, + count: usize, + }, + /// An I/O or serialization error occurred. + Io(std::io::Error), + /// A TOML serialization error occurred. + TomlSer(toml::ser::Error), + /// A JSON serialization error occurred. + Json(serde_json::Error), +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + CliError::UnknownEvent(name) => write!( + f, + "Unknown event '{}'. Use `jcode hooks list` to see valid event names.", + name + ), + CliError::NoHooksForEvent(name) => { + write!(f, "No hooks configured for event '{}'.", name) + } + CliError::IndexOutOfRange { + index, + event, + count, + } => write!( + f, + "Index {} out of range for event '{}' (has {} handler(s), valid range: 0..{}).", + index, + event, + count, + count.saturating_sub(1) + ), + CliError::Io(e) => write!(f, "{}", e), + CliError::TomlSer(e) => write!(f, "TOML serialization error: {}", e), + CliError::Json(e) => write!(f, "JSON serialization error: {}", e), + } + } +} + +impl std::error::Error for CliError {} + +impl From for CliError { + fn from(e: std::io::Error) -> Self { + CliError::Io(e) + } +} + +impl From for CliError { + fn from(e: toml::ser::Error) -> Self { + CliError::TomlSer(e) + } +} + +impl From for CliError { + fn from(e: serde_json::Error) -> Self { + CliError::Json(e) + } +} + +// =========================================================================== +// Public API -- called from the main CLI dispatcher +// =========================================================================== + +/// Entry point for all `jcode hooks` subcommands. +/// +/// Call this from the main CLI's dispatch function when the user runs +/// `jcode hooks `. +pub async fn run_hooks_command(subcmd: HooksSubcommand) -> Result<(), CliError> { + match subcmd { + HooksSubcommand::List { event, json } => run_hooks_list(event, json), + HooksSubcommand::Enable { event, index } => run_hooks_enable(&event, index), + HooksSubcommand::Disable { event, index } => run_hooks_disable(&event, index), + HooksSubcommand::Test { + event, + execute, + json, + } => run_hooks_test(&event, execute, json).await, + HooksSubcommand::Metrics { json } => run_hooks_metrics(json), + } +} + +/// Subcommands for `jcode hooks`. +#[derive(Debug, Clone)] +pub enum HooksSubcommand { + /// List all configured hooks, optionally filtered by event name. + List { + /// If set, only show hooks for this event. + event: Option, + /// Emit JSON instead of human-readable output. + json: bool, + }, + /// Enable a specific hook handler. + Enable { + /// The event name (e.g. "PreToolUse"). + event: String, + /// The 0-based index of the handler within that event's handler list. + index: usize, + }, + /// Disable a specific hook handler. + Disable { + /// The event name (e.g. "PreToolUse"). + event: String, + /// The 0-based index of the handler within that event's handler list. + index: usize, + }, + /// Dry-run or actually execute hooks for a given event to verify behavior. + Test { + /// The event to test (e.g. "PreToolUse", "SessionStart"). + event: String, + /// If set, actually execute the hooks instead of dry-run. + execute: bool, + /// Emit JSON instead of human-readable output. + json: bool, + }, + /// Show execution metrics and configuration summary. + Metrics { + /// Emit JSON instead of human-readable output. + json: bool, + }, +} + +// =========================================================================== +// hooks list +// =========================================================================== + +/// List configured hooks, optionally filtered by event. +fn run_hooks_list(event_filter: Option, json: bool) -> Result<(), CliError> { + let config = load_hooks_config(); + + if config.is_empty() { + if json { + let empty = HooksListOutput { + settings: config.settings.clone(), + events: Vec::new(), + total_handlers: 0, + }; + println!("{}", serde_json::to_string_pretty(&empty)?); + } else { + println!("No hooks configured."); + println!(); + println!("Config sources (checked in order, later overrides):"); + print_config_sources(); + } + return Ok(()); + } + + let entries = build_list_entries(&config, event_filter.as_deref()); + + if json { + let output = HooksListOutput { + settings: config.settings.clone(), + events: entries.clone(), + total_handlers: entries.iter().map(|e| e.handlers.len()).sum(), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + print_hooks_table(&config.settings, &entries); + } + + Ok(()) +} + +/// Build a list of event entries from the config, optionally filtering by event name. +fn build_list_entries(config: &HooksConfig, event_filter: Option<&str>) -> Vec { + let mut entries: Vec = Vec::new(); + + // Sort event names for deterministic output. + let mut event_names: Vec<&String> = config.events.keys().collect(); + event_names.sort(); + + for event_name in event_names { + if let Some(filter) = event_filter { + // Normalize both sides for case-insensitive matching. + let normalized_filter: String = filter + .chars() + .filter(|c| *c != '_' && *c != '-' && *c != ' ') + .collect::() + .to_ascii_lowercase(); + let normalized_event: String = event_name + .chars() + .filter(|c| *c != '_' && *c != '-' && *c != ' ') + .collect::() + .to_ascii_lowercase(); + if normalized_filter != normalized_event { + continue; + } + } + + let handlers = &config.events[event_name]; + if handlers.is_empty() { + continue; + } + + let handler_entries: Vec = handlers + .iter() + .enumerate() + .map(|(i, h)| handler_to_entry(i, h)) + .collect(); + + entries.push(HooksEventEntry { + event: event_name.clone(), + handler_count: handler_entries.len(), + blocking: HookEvent::parse(event_name) + .map(|e| e.is_blocking()) + .unwrap_or(false), + handlers: handler_entries, + }); + } + + entries +} + +/// Convert a handler config into a serializable entry for display. +fn handler_to_entry(index: usize, handler: &HookHandlerConfig) -> HandlerEntry { + match handler { + HookHandlerConfig::Command(cmd) => HandlerEntry { + index, + handler_type: "command".to_string(), + label: cmd.command.clone(), + enabled: cmd.enabled, + timeout_secs: cmd.timeout_secs, + matcher: cmd.matcher.as_ref().map(|m| format!("{:?}", m)), + condition: cmd.if_.clone(), + }, + HookHandlerConfig::Http(http) => HandlerEntry { + index, + handler_type: "http".to_string(), + label: format!("{} {}", http.method, http.url), + enabled: http.enabled, + timeout_secs: http.timeout_secs, + matcher: http.matcher.as_ref().map(|m| format!("{:?}", m)), + condition: http.if_.clone(), + }, + HookHandlerConfig::Agent(agent) => HandlerEntry { + index, + handler_type: "agent".to_string(), + label: agent.agent_id.clone(), + enabled: agent.enabled, + timeout_secs: Some(agent.timeout_secs), + matcher: agent.matcher.as_ref().map(|m| format!("{:?}", m)), + condition: agent.if_.clone(), + }, + HookHandlerConfig::Plugin(plugin) => HandlerEntry { + index, + handler_type: "plugin".to_string(), + label: plugin.path.clone(), + enabled: plugin.enabled, + timeout_secs: Some(plugin.timeout_secs), + matcher: plugin.matcher.as_ref().map(|m| format!("{:?}", m)), + condition: plugin.if_.clone(), + }, + } +} + +// =========================================================================== +// hooks enable / disable +// =========================================================================== + +/// Enable a hook handler by event name and index. +/// +/// Reads the config, modifies the enabled flag on the matching handler, +/// and writes it back to the project-level `.jcode/hooks.toml`. +fn run_hooks_enable(event_name: &str, index: usize) -> Result<(), CliError> { + set_handler_enabled(event_name, index, true) +} + +/// Disable a hook handler by event name and index. +fn run_hooks_disable(event_name: &str, index: usize) -> Result<(), CliError> { + set_handler_enabled(event_name, index, false) +} + +/// Set the `enabled` flag on a specific handler and write back to project config. +fn set_handler_enabled(event_name: &str, index: usize, enabled: bool) -> Result<(), CliError> { + // Parse the event to validate it. + let event = HookEvent::parse(event_name) + .ok_or_else(|| CliError::UnknownEvent(event_name.to_string()))?; + + let config = load_hooks_config(); + + let handlers = config + .events + .get(event.display_name()) + .ok_or_else(|| CliError::NoHooksForEvent(event.display_name().to_string()))?; + + if index >= handlers.len() { + return Err(CliError::IndexOutOfRange { + index, + event: event.display_name().to_string(), + count: handlers.len(), + }); + } + + // Update the enabled flag in the loaded config. + let mut updated_config = config; + let handlers = updated_config.events.get_mut(event.display_name()).unwrap(); + set_handler_enabled_flag(&mut handlers[index], enabled); + + // Write back to the project-level config. + let project_config_path = project_hooks_config_path()?; + write_hooks_config(&project_config_path, &updated_config)?; + + let action = if enabled { "Enabled" } else { "Disabled" }; + println!( + "{} handler #{} for event '{}'.", + action, + index, + event.display_name() + ); + println!("Config written to: {}", project_config_path.display()); + + Ok(()) +} + +/// Set the `enabled` field on any handler variant. +fn set_handler_enabled_flag(handler: &mut HookHandlerConfig, enabled: bool) { + match handler { + HookHandlerConfig::Command(cmd) => cmd.enabled = enabled, + HookHandlerConfig::Http(http) => http.enabled = enabled, + HookHandlerConfig::Agent(agent) => agent.enabled = enabled, + HookHandlerConfig::Plugin(plugin) => plugin.enabled = enabled, + } +} + +/// Get the `enabled` field from any handler variant. +fn get_handler_enabled(handler: &HookHandlerConfig) -> bool { + match handler { + HookHandlerConfig::Command(cmd) => cmd.enabled, + HookHandlerConfig::Http(http) => http.enabled, + HookHandlerConfig::Agent(agent) => agent.enabled, + HookHandlerConfig::Plugin(plugin) => plugin.enabled, + } +} + +// =========================================================================== +// hooks test +// =========================================================================== + +/// Test hooks for a given event. +/// +/// In dry-run mode (default), resolves matching handlers and reports which +/// would fire without actually executing them. With `--execute`, runs the +/// handlers for real using the dispatch engine. +async fn run_hooks_test(event_name: &str, execute: bool, json: bool) -> Result<(), CliError> { + let event = HookEvent::parse(event_name) + .ok_or_else(|| CliError::UnknownEvent(event_name.to_string()))?; + + let config = load_hooks_config(); + + if config.is_empty() { + if json { + println!( + "{}", + serde_json::to_string_pretty(&HooksTestOutput { + event: event.display_name().to_string(), + mode: if execute { "execute" } else { "dry-run" }.to_string(), + handlers_resolved: 0, + handlers_enabled: 0, + results: Vec::new(), + stats: None, + })? + ); + } else { + println!("No hooks configured. Nothing to test."); + } + return Ok(()); + } + + let handlers = config + .events + .get(event.display_name()) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + + let enabled_handlers: Vec<&HookHandlerConfig> = + handlers.iter().filter(|h| get_handler_enabled(h)).collect(); + + if enabled_handlers.is_empty() { + if json { + println!( + "{}", + serde_json::to_string_pretty(&HooksTestOutput { + event: event.display_name().to_string(), + mode: if execute { "execute" } else { "dry-run" }.to_string(), + handlers_resolved: handlers.len(), + handlers_enabled: 0, + results: Vec::new(), + stats: None, + })? + ); + } else { + println!( + "Event '{}' has {} handler(s), but none are enabled.", + event.display_name(), + handlers.len() + ); + } + return Ok(()); + } + + // Build a synthetic HookInput for the test. + let cwd = std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "/tmp".to_string()); + let input = HookInput { + session_id: "hooks-test-session".to_string(), + cwd, + hook_event_name: event.display_name().to_string(), + ..Default::default() + }; + + let dispatch_config = DispatchConfig { + dry_run: !execute, + ..DispatchConfig::from_settings(&config.settings) + }; + + println!( + "Testing {} enabled handler(s) for event '{}' (mode: {})...", + enabled_handlers.len(), + event.display_name(), + if execute { "execute" } else { "dry-run" } + ); + println!(); + + let stats = dispatch_hooks(&event, &input, &enabled_handlers, &dispatch_config).await; + + if json { + let results: Vec = stats + .results + .iter() + .map(|r| HooksTestResultEntry { + handler: r.handler_label.clone(), + outcome: format!("{:?}", r.outcome), + duration_ms: r.duration.as_millis() as u64, + }) + .collect(); + + let output = HooksTestOutput { + event: event.display_name().to_string(), + mode: if execute { "execute" } else { "dry-run" }.to_string(), + handlers_resolved: handlers.len(), + handlers_enabled: enabled_handlers.len(), + results, + stats: Some(HooksTestStatsSummary { + total_dispatched: stats.total_dispatched, + completed: stats.completed, + failed: stats.failed, + allowed: stats.allowed, + denied: stats.denied, + asked: stats.asked, + total_duration_ms: stats.total_duration.as_millis() as u64, + }), + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + for result in &stats.results { + let status = match &result.outcome { + ClassifiedOutcome::Allow => "\x1b[32mALLOW\x1b[0m", + ClassifiedOutcome::Ask { .. } => "\x1b[33mASK\x1b[0m", + ClassifiedOutcome::Deny { .. } => "\x1b[31mDENY\x1b[0m", + ClassifiedOutcome::Failed { .. } => "\x1b[31mFAILED\x1b[0m", + }; + println!( + " [{:>7}] {} ({}ms)", + status, + result.handler_label, + result.duration.as_millis() + ); + if let ClassifiedOutcome::Ask { reason } = &result.outcome { + if !reason.is_empty() { + println!(" reason: {}", reason); + } + } + if let ClassifiedOutcome::Deny { reason } = &result.outcome { + if !reason.is_empty() { + println!(" reason: {}", reason); + } + } + if let ClassifiedOutcome::Failed { error } = &result.outcome { + println!(" error: {}", error); + } + } + println!(); + println!( + "Summary: {} dispatched, {} completed, {} failed ({}ms total)", + stats.total_dispatched, + stats.completed, + stats.failed, + stats.total_duration.as_millis() + ); + if !execute { + println!(); + println!("Note: dry-run mode -- no hooks were actually executed."); + println!(" Use --execute to run hooks for real."); + } + } + + Ok(()) +} + +// =========================================================================== +// hooks metrics +// =========================================================================== + +/// Show hooks configuration summary and (future) execution metrics. +fn run_hooks_metrics(json: bool) -> Result<(), CliError> { + let config = load_hooks_config(); + + let total_handlers: usize = config.events.values().map(|v| v.len()).sum(); + let enabled_handlers: usize = config + .events + .values() + .flat_map(|v| v.iter()) + .filter(|h| get_handler_enabled(h)) + .count(); + let disabled_handlers = total_handlers - enabled_handlers; + + let handler_type_counts = count_handler_types(&config); + + let mut event_summaries: Vec = Vec::new(); + let mut event_names: Vec<&String> = config.events.keys().collect(); + event_names.sort(); + + for event_name in &event_names { + let handlers = &config.events[*event_name]; + if handlers.is_empty() { + continue; + } + let blocking = HookEvent::parse(event_name) + .map(|e| e.is_blocking()) + .unwrap_or(false); + let enabled_count = handlers.iter().filter(|h| get_handler_enabled(h)).count(); + event_summaries.push(EventMetricsSummary { + event: (*event_name).clone(), + total_handlers: handlers.len(), + enabled_handlers: enabled_count, + blocking, + }); + } + + if json { + let output = HooksMetricsOutput { + settings: config.settings.clone(), + total_events: event_summaries.len(), + total_handlers, + enabled_handlers, + disabled_handlers, + handler_type_counts: handler_type_counts.clone(), + events: event_summaries, + }; + println!("{}", serde_json::to_string_pretty(&output)?); + } else { + println!("Hooks Configuration Summary"); + println!("==========================="); + println!(); + println!("Settings:"); + println!(" Default timeout: {}s", config.settings.timeout_secs); + println!(" Max concurrency: {}", config.settings.max_concurrency); + println!(" Dry-run mode: {}", config.settings.dry_run); + println!(" Fail-closed: {}", config.settings.fail_closed); + println!(); + println!("Total events with hooks: {}", event_summaries.len()); + println!( + "Total handlers: {} ({} enabled, {} disabled)", + total_handlers, enabled_handlers, disabled_handlers + ); + println!(); + + if !handler_type_counts.is_empty() { + println!("Handler types:"); + let mut types: Vec<(&String, &usize)> = handler_type_counts.iter().collect(); + types.sort_by_key(|(k, _)| k.as_str()); + for (htype, count) in &types { + println!(" {:10} {}", htype, count); + } + println!(); + } + + if !event_summaries.is_empty() { + println!("Per-event breakdown:"); + println!( + " {:<30} {:>8} {:>8} {:>8}", + "EVENT", "HANDLERS", "ENABLED", "BLOCKING" + ); + println!(" {:-<30} {:-<8} {:-<8} {:-<8}", "", "", "", ""); + for entry in &event_summaries { + println!( + " {:<30} {:>8} {:>8} {:>8}", + entry.event, + entry.total_handlers, + entry.enabled_handlers, + if entry.blocking { "yes" } else { "no" } + ); + } + } + + if config.is_empty() { + println!(); + println!("No hooks configured."); + print_config_sources(); + } + } + + Ok(()) +} + +/// Count handlers by type across all events. +fn count_handler_types(config: &HooksConfig) -> HashMap { + let mut counts: HashMap = HashMap::new(); + for handler in config.events.values().flat_map(|v| v.iter()) { + let key = match handler { + HookHandlerConfig::Command(_) => "command", + HookHandlerConfig::Http(_) => "http", + HookHandlerConfig::Agent(_) => "agent", + HookHandlerConfig::Plugin(_) => "plugin", + }; + *counts.entry(key.to_string()).or_insert(0) += 1; + } + counts +} + +// =========================================================================== +// Config file I/O +// =========================================================================== + +/// Path to the project-level hooks config file: `/.jcode/hooks.toml`. +fn project_hooks_config_path() -> Result { + let cwd = std::env::current_dir()?; + Ok(cwd.join(".jcode").join("hooks.toml")) +} + +/// Serialize a [`HooksConfig`] to TOML and write it to the given path. +/// +/// Creates parent directories if they do not exist. +fn write_hooks_config(path: &PathBuf, config: &HooksConfig) -> Result<(), CliError> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let toml_string = toml::to_string_pretty(config)?; + std::fs::write(path, &toml_string)?; + + Ok(()) +} + +/// Print the list of config source paths (for help output when no config exists). +fn print_config_sources() { + if let Some(home) = dirs::home_dir() { + let user_path = home.join(".jcode").join("hooks.toml"); + println!(" User: {}", user_path.display()); + } + if let Ok(cwd) = std::env::current_dir() { + let project_path = cwd.join(".jcode").join("hooks.toml"); + println!(" Project: {}", project_path.display()); + } + println!(" Env: $JCODE_HOOKS_CONFIG"); +} + +// =========================================================================== +// Human-readable output helpers +// =========================================================================== + +/// Print a human-readable table of hooks grouped by event. +fn print_hooks_table(settings: &HookSettings, entries: &[HooksEventEntry]) { + println!("Hooks Configuration"); + println!("==================="); + println!( + " timeout: {}s | concurrency: {} | dry_run: {} | fail_closed: {}", + settings.timeout_secs, settings.max_concurrency, settings.dry_run, settings.fail_closed, + ); + println!(); + + let total: usize = entries.iter().map(|e| e.handlers.len()).sum(); + println!( + "{} event(s) with {} total handler(s):", + entries.len(), + total + ); + println!(); + + for entry in entries { + let blocking_tag = if entry.blocking { " [blocking]" } else { "" }; + println!( + "{} ({} handler(s)){}", + entry.event, entry.handler_count, blocking_tag + ); + for h in &entry.handlers { + let status = if h.enabled { + "\x1b[32mON\x1b[0m" + } else { + "\x1b[31mOFF\x1b[0m" + }; + let timeout_str = h + .timeout_secs + .map(|t| format!("{}s", t)) + .unwrap_or_else(|| "default".to_string()); + let matcher_str = h + .matcher + .as_deref() + .map(|m| format!(" match={}", m)) + .unwrap_or_default(); + let condition_str = h + .condition + .as_deref() + .map(|c| format!(" if={}", c)) + .unwrap_or_default(); + + println!( + " [{}] #{} {:<8} {} (timeout={}{}{})", + status, h.index, h.handler_type, h.label, timeout_str, matcher_str, condition_str, + ); + } + println!(); + } +} + +// =========================================================================== +// JSON output types +// =========================================================================== + +#[derive(Debug, Clone, Serialize)] +struct HooksListOutput { + settings: HookSettings, + events: Vec, + total_handlers: usize, +} + +#[derive(Debug, Clone, Serialize)] +struct HooksEventEntry { + event: String, + handler_count: usize, + blocking: bool, + handlers: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct HandlerEntry { + index: usize, + #[serde(rename = "type")] + handler_type: String, + label: String, + enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + timeout_secs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + matcher: Option, + #[serde(skip_serializing_if = "Option::is_none")] + condition: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct HooksTestOutput { + event: String, + mode: String, + handlers_resolved: usize, + handlers_enabled: usize, + results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + stats: Option, +} + +#[derive(Debug, Clone, Serialize)] +struct HooksTestResultEntry { + handler: String, + outcome: String, + duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize)] +struct HooksTestStatsSummary { + total_dispatched: u64, + completed: u64, + failed: u64, + allowed: u64, + denied: u64, + asked: u64, + total_duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize)] +struct HooksMetricsOutput { + settings: HookSettings, + total_events: usize, + total_handlers: usize, + enabled_handlers: usize, + disabled_handlers: usize, + handler_type_counts: HashMap, + events: Vec, +} + +#[derive(Debug, Clone, Serialize)] +struct EventMetricsSummary { + event: String, + total_handlers: usize, + enabled_handlers: usize, + blocking: bool, +} + +// =========================================================================== +// Tests +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CommandHandlerConfig, HttpHandlerConfig}; + + fn sample_config() -> HooksConfig { + let mut config = HooksConfig::default(); + config.settings.timeout_secs = 15; + config.settings.max_concurrency = 5; + + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "check.sh".to_string(), + enabled: true, + timeout_secs: Some(5), + ..Default::default() + })); + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "lint.sh".to_string(), + enabled: false, + ..Default::default() + })); + config + .events + .entry("SessionEnd".to_string()) + .or_default() + .push(HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://localhost:9090/hook".to_string(), + enabled: true, + ..Default::default() + })); + + config + } + + #[test] + fn build_list_entries_all() { + let config = sample_config(); + let entries = build_list_entries(&config, None); + assert_eq!(entries.len(), 2); + let event_names: Vec<&str> = entries.iter().map(|e| e.event.as_str()).collect(); + assert!(event_names.contains(&"PreToolUse")); + assert!(event_names.contains(&"SessionEnd")); + } + + #[test] + fn build_list_entries_filtered() { + let config = sample_config(); + let entries = build_list_entries(&config, Some("pretooluse")); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].event, "PreToolUse"); + assert_eq!(entries[0].handlers.len(), 2); + } + + #[test] + fn build_list_entries_filter_case_insensitive() { + let config = sample_config(); + let entries = build_list_entries(&config, Some("pre-tool-use")); + assert_eq!(entries.len(), 1); + } + + #[test] + fn build_list_entries_filter_no_match() { + let config = sample_config(); + let entries = build_list_entries(&config, Some("NonExistent")); + assert!(entries.is_empty()); + } + + #[test] + fn handler_to_entry_command() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + command: "test.sh".to_string(), + enabled: true, + timeout_secs: Some(10), + ..Default::default() + }); + let entry = handler_to_entry(3, &handler); + assert_eq!(entry.index, 3); + assert_eq!(entry.handler_type, "command"); + assert_eq!(entry.label, "test.sh"); + assert!(entry.enabled); + assert_eq!(entry.timeout_secs, Some(10)); + } + + #[test] + fn handler_to_entry_http() { + let handler = HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://example.com".to_string(), + method: "PUT".to_string(), + enabled: false, + ..Default::default() + }); + let entry = handler_to_entry(0, &handler); + assert_eq!(entry.handler_type, "http"); + assert_eq!(entry.label, "PUT http://example.com"); + assert!(!entry.enabled); + } + + #[test] + fn set_and_get_handler_enabled() { + let mut handler = HookHandlerConfig::Command(CommandHandlerConfig { + enabled: true, + ..Default::default() + }); + assert!(get_handler_enabled(&handler)); + set_handler_enabled_flag(&mut handler, false); + assert!(!get_handler_enabled(&handler)); + set_handler_enabled_flag(&mut handler, true); + assert!(get_handler_enabled(&handler)); + } + + #[test] + fn count_handler_types_mixed() { + let config = sample_config(); + let counts = count_handler_types(&config); + assert_eq!(counts.get("command"), Some(&2)); + assert_eq!(counts.get("http"), Some(&1)); + assert_eq!(counts.get("agent"), None); + } + + #[test] + fn empty_config_is_handled() { + let config = HooksConfig::default(); + let entries = build_list_entries(&config, None); + assert!(entries.is_empty()); + } + + #[test] + fn event_entry_blocking_flag() { + let config = sample_config(); + let entries = build_list_entries(&config, None); + let pre_tool = entries.iter().find(|e| e.event == "PreToolUse").unwrap(); + assert!(pre_tool.blocking, "PreToolUse should be blocking"); + + let session_end = entries.iter().find(|e| e.event == "SessionEnd").unwrap(); + assert!(!session_end.blocking, "SessionEnd should not be blocking"); + } + + #[test] + fn cli_error_display() { + let err = CliError::UnknownEvent("FooBar".to_string()); + assert!(format!("{}", err).contains("FooBar")); + + let err = CliError::IndexOutOfRange { + index: 5, + event: "PreToolUse".to_string(), + count: 3, + }; + let msg = format!("{}", err); + assert!(msg.contains("5")); + assert!(msg.contains("PreToolUse")); + assert!(msg.contains("3")); + } + + #[test] + fn cli_error_from_io() { + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing"); + let err: CliError = io_err.into(); + assert!(format!("{}", err).contains("file missing")); + } +} diff --git a/crates/jcode-hooks/src/config.rs b/crates/jcode-hooks/src/config.rs new file mode 100644 index 000000000..8488047df --- /dev/null +++ b/crates/jcode-hooks/src/config.rs @@ -0,0 +1,1319 @@ +//! Hook configuration types and loader for the v2 hook system. +//! +//! Defines the 28+1 [`HookEvent`] variants, four handler types +//! ([`CommandHandlerConfig`], [`HttpHandlerConfig`], [`AgentHandlerConfig`], +//! [`PluginHandlerConfig`]), global [`HookSettings`], and the top-level +//! [`HooksConfig`] with a 3-layer TOML loader ([`load_hooks_config`]). +//! +//! Configuration is loaded from three layers (lowest to highest priority): +//! 1. `~/.jcode/hooks.toml` (user-level) +//! 2. `.jcode/hooks.toml` (project-level, relative to cwd) +//! 3. `$JCODE_HOOKS_CONFIG` (env-level, path to TOML file) +//! +//! Settings from higher-priority layers override lower ones; event handlers +//! are **appended** across layers. + +use std::collections::HashMap; +use std::fmt; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use super::matcher::HookMatcher; + +// --------------------------------------------------------------------------- +// HookEvent +// --------------------------------------------------------------------------- + +/// Complete set of hook lifecycle events (28 standard + `Custom`). +/// +/// Each variant maps to a well-defined lifecycle point in the agent runtime. +/// The `Custom(String)` escape hatch allows user-defined event names that are +/// not in the standard set. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "PascalCase")] +pub enum HookEvent { + // -- Core tool events (6) -- + PreToolUse, + PostToolUse, + PostToolUseFailure, + ToolError, + UserPromptSubmit, + UserPromptExpansion, + + // -- Session lifecycle (6) -- + SessionStart, + SessionEnd, + SessionUpdated, + SessionDiff, + SessionError, + SessionIdle, + + // -- Permission events (4) -- + PermissionRequest, + PermissionDenied, + PermissionAsked, + PermissionReplied, + + // -- Agent & subagent events (5) -- + AgentStart, + AgentEnd, + SubagentStart, + SubagentStop, + + // -- Execution control (1) -- + Stop, + + // -- Compaction events (3) -- + PreCompact, + PostCompact, + AutoCompactionControl, + + // -- Task & setup events (3) -- + TaskCreated, + TaskCompleted, + Setup, + + // -- File events (1) -- + FileChanged, + + // -- User-defined escape hatch -- + /// Allows user-defined event names not in the standard set. + /// Configured as `Custom("my_event")` or `"custom:my_event"` in config. + Custom(String), +} + +impl HookEvent { + /// Parse a hook event name from a free-form string. + /// + /// Accepts PascalCase, snake_case, kebab-case, or any mixture thereof. + /// Matching is case-insensitive. Underscores, hyphens, and spaces are + /// stripped before comparison. + /// + /// Custom events use the `"custom:"` prefix, e.g. `"custom:my_event"`. + /// The part after the colon is preserved verbatim (no normalization). + /// + /// Returns `None` if the input does not match any known variant and does + /// not carry the `custom:` prefix. + pub fn parse(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + // Handle Custom events before normalization to preserve the custom name. + let lower = trimmed.to_ascii_lowercase(); + if let Some(rest) = lower.strip_prefix("custom:") { + let name = trimmed[7..].trim().to_string(); + // If nothing after "custom:", store an empty name. + return Some(Self::Custom(if name.is_empty() { + rest.trim().to_string() + } else { + name + })); + } + if lower == "custom" { + return Some(Self::Custom(String::new())); + } + + // Normalize: strip common delimiters, lowercase. + let normalized: String = trimmed + .chars() + .filter(|c| *c != '_' && *c != '-' && *c != ' ') + .collect::() + .to_ascii_lowercase(); + + match normalized.as_str() { + "pretooluse" => Some(Self::PreToolUse), + "posttooluse" => Some(Self::PostToolUse), + "posttoolusefailure" => Some(Self::PostToolUseFailure), + "toolerror" => Some(Self::ToolError), + "userpromptsubmit" => Some(Self::UserPromptSubmit), + "userpromptexpansion" => Some(Self::UserPromptExpansion), + "sessionstart" => Some(Self::SessionStart), + "sessionend" => Some(Self::SessionEnd), + "sessionupdated" => Some(Self::SessionUpdated), + "sessiondiff" => Some(Self::SessionDiff), + "sessionerror" => Some(Self::SessionError), + "sessionidle" => Some(Self::SessionIdle), + "permissionrequest" => Some(Self::PermissionRequest), + "permissiondenied" => Some(Self::PermissionDenied), + "permissionasked" => Some(Self::PermissionAsked), + "permissionreplied" => Some(Self::PermissionReplied), + "agentstart" => Some(Self::AgentStart), + "agentend" => Some(Self::AgentEnd), + "subagentstart" => Some(Self::SubagentStart), + "subagentstop" => Some(Self::SubagentStop), + "stop" => Some(Self::Stop), + "precompact" => Some(Self::PreCompact), + "postcompact" => Some(Self::PostCompact), + "autocompactioncontrol" => Some(Self::AutoCompactionControl), + "taskcreated" => Some(Self::TaskCreated), + "taskcompleted" => Some(Self::TaskCompleted), + "setup" => Some(Self::Setup), + "filechanged" => Some(Self::FileChanged), + _ => None, + } + } + + /// Whether this event can block execution (deny/ask/allow precedence). + /// + /// Blocking events: `PreToolUse`, `UserPromptSubmit`, `PermissionRequest`, + /// `PermissionAsked`, `AgentStart`, `Stop`, `PreCompact`. + pub fn is_blocking(&self) -> bool { + matches!( + self, + Self::PreToolUse + | Self::UserPromptSubmit + | Self::PermissionRequest + | Self::PermissionAsked + | Self::AgentStart + | Self::Stop + | Self::PreCompact + ) + } + + /// PascalCase display name (e.g. `"PreToolUse"`). + /// + /// For `Custom(name)` returns the stored name as-is. + pub fn display_name(&self) -> &str { + match self { + Self::PreToolUse => "PreToolUse", + Self::PostToolUse => "PostToolUse", + Self::PostToolUseFailure => "PostToolUseFailure", + Self::ToolError => "ToolError", + Self::UserPromptSubmit => "UserPromptSubmit", + Self::UserPromptExpansion => "UserPromptExpansion", + Self::SessionStart => "SessionStart", + Self::SessionEnd => "SessionEnd", + Self::SessionUpdated => "SessionUpdated", + Self::SessionDiff => "SessionDiff", + Self::SessionError => "SessionError", + Self::SessionIdle => "SessionIdle", + Self::PermissionRequest => "PermissionRequest", + Self::PermissionDenied => "PermissionDenied", + Self::PermissionAsked => "PermissionAsked", + Self::PermissionReplied => "PermissionReplied", + Self::AgentStart => "AgentStart", + Self::AgentEnd => "AgentEnd", + Self::SubagentStart => "SubagentStart", + Self::SubagentStop => "SubagentStop", + Self::Stop => "Stop", + Self::PreCompact => "PreCompact", + Self::PostCompact => "PostCompact", + Self::AutoCompactionControl => "AutoCompactionControl", + Self::TaskCreated => "TaskCreated", + Self::TaskCompleted => "TaskCompleted", + Self::Setup => "Setup", + Self::FileChanged => "FileChanged", + Self::Custom(name) => name, + } + } + + /// Uppercase form suitable for env-var keys + /// (e.g. `"PRETOOLUSE"` for `JCODE_SKIP_EVENT_PRETOOLUSE`). + pub fn name_uppercase(&self) -> String { + self.display_name().to_ascii_uppercase() + } + + /// Return all 28 standard variants (excluding `Custom`). + pub fn all_standard() -> Vec { + vec![ + Self::PreToolUse, + Self::PostToolUse, + Self::PostToolUseFailure, + Self::ToolError, + Self::UserPromptSubmit, + Self::UserPromptExpansion, + Self::SessionStart, + Self::SessionEnd, + Self::SessionUpdated, + Self::SessionDiff, + Self::SessionError, + Self::SessionIdle, + Self::PermissionRequest, + Self::PermissionDenied, + Self::PermissionAsked, + Self::PermissionReplied, + Self::AgentStart, + Self::AgentEnd, + Self::SubagentStart, + Self::SubagentStop, + Self::Stop, + Self::PreCompact, + Self::PostCompact, + Self::AutoCompactionControl, + Self::TaskCreated, + Self::TaskCompleted, + Self::Setup, + Self::FileChanged, + ] + } +} + +impl fmt::Display for HookEvent { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.display_name()) + } +} + +// --------------------------------------------------------------------------- +// Matcher pattern helpers +// --------------------------------------------------------------------------- + +/// Parse a matcher pattern string from a config file into a [`HookMatcher`]. +/// +/// Syntax: +/// - `"*"` -- matches every tool / target ([`HookMatcher::Wildcard`]) +/// - `"/^Bash/"` -- regex delimited by `/` ([`HookMatcher::Regex`]) +/// - `"Bash|Write|Edit"` -- pipe-separated list ([`HookMatcher::Multi`]) +/// - anything else -- exact match ([`HookMatcher::Exact`]) +pub fn parse_matcher_pattern(s: &str) -> HookMatcher { + let trimmed = s.trim(); + if trimmed == "*" { + return HookMatcher::Wildcard; + } + if trimmed.starts_with('/') && trimmed.ends_with('/') && trimmed.len() > 2 { + return HookMatcher::Regex(trimmed[1..trimmed.len() - 1].to_string()); + } + if trimmed.contains('|') { + let parts: Vec = trimmed.split('|').map(|p| p.trim().to_string()).collect(); + return HookMatcher::Multi(parts); + } + HookMatcher::Exact(trimmed.to_string()) +} + +/// Serde helper: deserialize an optional matcher string into `Option`. +fn deserialize_optional_matcher<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt: Option = Option::deserialize(deserializer)?; + Ok(opt.map(|s| parse_matcher_pattern(&s))) +} + +/// Serde helper: serialize `Option` back to a pattern string. +fn serialize_optional_matcher( + value: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match value { + None => serializer.serialize_none(), + Some(m) => { + let s = match m { + HookMatcher::Wildcard => "*".to_string(), + HookMatcher::Exact(v) => v.clone(), + HookMatcher::Multi(parts) => parts.join("|"), + HookMatcher::Regex(pat) => format!("/{}/", pat), + }; + serializer.serialize_some(&s) + } + } +} + +// --------------------------------------------------------------------------- +// Handler configuration structs +// --------------------------------------------------------------------------- + +/// Shell command handler (bash / powershell). +/// +/// Receives [`HookInput`](super::types::HookInput) as JSON on stdin, writes +/// [`HookOutput`](super::types::HookOutput) as JSON on stdout. +/// +/// Exit codes: 0 = continue, 1 = failure, 2 = block. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct CommandHandlerConfig { + /// Whether this handler is active. + pub enabled: bool, + /// The shell command to execute. + pub command: String, + /// Per-handler timeout override in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, + /// Extra environment variables passed to the child process. + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub env: HashMap, + /// Working directory for the child process. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// Matcher pattern limiting which targets this handler applies to. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_matcher", + serialize_with = "serialize_optional_matcher" + )] + pub matcher: Option, + /// Conditional expression (e.g. `"tool_name=Bash"`). + #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")] + pub if_: Option, +} + +impl Default for CommandHandlerConfig { + fn default() -> Self { + Self { + enabled: true, + command: String::new(), + timeout_secs: None, + env: HashMap::new(), + cwd: None, + matcher: None, + if_: None, + } + } +} + +/// HTTP/REST handler. +/// +/// Sends the [`HookInput`](super::types::HookInput) as a JSON body to the +/// configured URL. Expects a JSON [`HookOutput`](super::types::HookOutput) +/// response. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpHandlerConfig { + /// Whether this handler is active. + pub enabled: bool, + /// Target URL. + pub url: String, + /// HTTP method (GET, POST, PUT, DELETE, PATCH). + #[serde(default = "default_http_method")] + pub method: String, + /// Per-handler timeout in seconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_secs: Option, + /// Extra HTTP headers (values may contain `${VAR}` env interpolation). + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub headers: HashMap, + /// Optional static body (overrides the default JSON-serialized HookInput). + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + /// Matcher pattern. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_matcher", + serialize_with = "serialize_optional_matcher" + )] + pub matcher: Option, + /// Conditional expression. + #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")] + pub if_: Option, +} + +fn default_http_method() -> String { + "POST".to_string() +} + +impl Default for HttpHandlerConfig { + fn default() -> Self { + Self { + enabled: true, + url: String::new(), + method: default_http_method(), + timeout_secs: None, + headers: HashMap::new(), + body: None, + matcher: None, + if_: None, + } + } +} + +/// Inline agent handler. +/// +/// Dispatches the hook to a jcode sub-agent identified by `agent_id`. +/// The agent receives the hook input as context and its response is parsed +/// as [`HookOutput`](super::types::HookOutput). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AgentHandlerConfig { + /// Whether this handler is active. + pub enabled: bool, + /// Agent ID or name registered in jcode's agent registry. + pub agent_id: String, + /// Optional system-prompt override for the hook agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + /// Timeout in seconds (default 120 s for agent tasks). + #[serde(default = "default_agent_timeout")] + pub timeout_secs: u64, + /// Whether to block until the agent completes (default true). + #[serde(default = "default_true")] + pub wait_for_completion: bool, + /// Matcher pattern. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_matcher", + serialize_with = "serialize_optional_matcher" + )] + pub matcher: Option, + /// Conditional expression. + #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")] + pub if_: Option, +} + +fn default_agent_timeout() -> u64 { + 120 +} +fn default_true() -> bool { + true +} + +impl Default for AgentHandlerConfig { + fn default() -> Self { + Self { + enabled: true, + agent_id: String::new(), + system_prompt: None, + timeout_secs: default_agent_timeout(), + wait_for_completion: true, + matcher: None, + if_: None, + } + } +} + +/// External plugin/script handler. +/// +/// Runs a standalone executable that receives [`HookInput`](super::types::HookInput) +/// on stdin and returns [`HookOutput`](super::types::HookOutput) on stdout, +/// following the same exit-code protocol as command hooks (0/1/2). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct PluginHandlerConfig { + /// Whether this handler is active. + pub enabled: bool, + /// Path to the plugin executable. + pub path: String, + /// CLI arguments passed to the plugin. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + /// Plugin timeout in seconds. + #[serde(default = "default_plugin_timeout")] + pub timeout_secs: u64, + /// Optional semantic version requirement (e.g. `">=1.0.0"`). + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + /// Matcher pattern. + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "deserialize_optional_matcher", + serialize_with = "serialize_optional_matcher" + )] + pub matcher: Option, + /// Conditional expression. + #[serde(default, rename = "if", skip_serializing_if = "Option::is_none")] + pub if_: Option, +} + +fn default_plugin_timeout() -> u64 { + 30 +} + +impl Default for PluginHandlerConfig { + fn default() -> Self { + Self { + enabled: true, + path: String::new(), + args: Vec::new(), + timeout_secs: default_plugin_timeout(), + version: None, + matcher: None, + if_: None, + } + } +} + +// --------------------------------------------------------------------------- +// HookHandlerConfig enum +// --------------------------------------------------------------------------- + +/// Discriminated union of the four handler types. +/// +/// In TOML config files each entry carries a `type` field that selects the +/// variant (`"command"`, `"http"`, `"agent"`, `"plugin"`). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "lowercase")] +pub enum HookHandlerConfig { + /// Shell command handler. + Command(CommandHandlerConfig), + /// HTTP/REST handler. + Http(HttpHandlerConfig), + /// Inline agent handler. + Agent(AgentHandlerConfig), + /// External plugin handler. + Plugin(PluginHandlerConfig), +} + +impl Default for HookHandlerConfig { + fn default() -> Self { + Self::Command(CommandHandlerConfig::default()) + } +} + +// --------------------------------------------------------------------------- +// HookSettings +// --------------------------------------------------------------------------- + +/// Global hooks settings (the `[settings]` table in hooks.toml). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookSettings { + /// Default timeout for all hooks in seconds (1--300, default 30). + #[serde(default = "default_timeout_secs")] + pub timeout_secs: u64, + /// Maximum number of hooks executed concurrently per event (default 10). + #[serde(default = "default_max_concurrency")] + pub max_concurrency: usize, + /// Log-only mode -- hooks are resolved but never executed. + #[serde(default)] + pub dry_run: bool, + /// If `true`, a hook failure is treated as a block (fail-closed). + /// If `false` (default), failures are logged and execution continues. + #[serde(default)] + pub fail_closed: bool, +} + +fn default_timeout_secs() -> u64 { + 30 +} +fn default_max_concurrency() -> usize { + 10 +} + +impl Default for HookSettings { + fn default() -> Self { + Self { + timeout_secs: default_timeout_secs(), + max_concurrency: default_max_concurrency(), + dry_run: false, + fail_closed: false, + } + } +} + +// --------------------------------------------------------------------------- +// HooksConfig +// --------------------------------------------------------------------------- + +/// Top-level hooks configuration. +/// +/// Loaded from TOML files via [`load_hooks_config`]. The `events` map uses +/// PascalCase event names as keys (e.g. `"PreToolUse"`) and a vector of +/// handler configs as values. +#[derive(Debug, Clone, Default, Serialize)] +pub struct HooksConfig { + /// Global settings. + pub settings: HookSettings, + /// Event handlers keyed by event name. + #[serde(default)] + pub events: HashMap>, +} + +// Custom Deserialize to support both `event` and `events` TOML keys. +impl<'de> Deserialize<'de> for HooksConfig { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Raw { + #[serde(default)] + settings: HookSettings, + #[serde(default)] + events: HashMap>, + #[serde(default)] + event: HashMap>, + } + + let raw = Raw::deserialize(deserializer)?; + + // Merge: `events` takes priority; entries from `event` are appended. + let mut events = raw.events; + for (key, handlers) in raw.event { + events.entry(key).or_default().extend(handlers); + } + + Ok(HooksConfig { + settings: raw.settings, + events, + }) + } +} + +impl HooksConfig { + /// Merge another config into `self`. + /// + /// - **Settings**: the incoming config's values override `self` field by + /// field (i.e. the higher-priority layer wins). + /// - **Events**: handlers from the incoming config are **appended** to the + /// existing list for each event name. + pub fn merge(&mut self, other: HooksConfig) { + // Settings: other wins. + self.settings.timeout_secs = other.settings.timeout_secs; + self.settings.max_concurrency = other.settings.max_concurrency; + self.settings.dry_run = other.settings.dry_run; + self.settings.fail_closed = other.settings.fail_closed; + + // Events: append handlers. + for (event_name, new_handlers) in other.events { + self.events + .entry(event_name) + .or_default() + .extend(new_handlers); + } + } + + /// Return `true` if no event handlers are configured. + pub fn is_empty(&self) -> bool { + self.events.is_empty() || self.events.values().all(Vec::is_empty) + } +} + +// --------------------------------------------------------------------------- +// Config loading +// --------------------------------------------------------------------------- + +/// Load hooks configuration from the 3-layer TOML hierarchy, respecting the +/// `DISABLE_JCODE_HOOKS` kill-switch. +/// +/// Layers (lowest to highest priority): +/// 1. `~/.jcode/hooks.toml` +/// 2. `/.jcode/hooks.toml` +/// 3. Path in `$JCODE_HOOKS_CONFIG` +/// +/// Returns a default (empty) config when the kill-switch env var is set or +/// when no config files are found. +pub fn load_hooks_config() -> HooksConfig { + // Kill-switch: honour DISABLE_JCODE_HOOKS. + if std::env::var("DISABLE_JCODE_HOOKS").is_ok() { + eprintln!("[hooks] disabled via DISABLE_JCODE_HOOKS env var"); + return HooksConfig::default(); + } + + let mut merged = HooksConfig::default(); + + // Layer 1 -- user-level (~/.jcode/hooks.toml) + if let Some(path) = user_hooks_config_path() { + if let Some(config) = load_hooks_config_from_path(&path) { + merged.merge(config); + } + } + + // Layer 2 -- project-level (/.jcode/hooks.toml) + if let Some(path) = project_hooks_config_path() { + if let Some(config) = load_hooks_config_from_path(&path) { + merged.merge(config); + } + } + + // Layer 3 -- env-level ($JCODE_HOOKS_CONFIG) + if let Some(path) = env_hooks_config_path() { + if let Some(config) = load_hooks_config_from_path(&path) { + merged.merge(config); + } + } + + merged +} + +/// `$HOME/.jcode/hooks.toml` +fn user_hooks_config_path() -> Option { + dirs::home_dir().map(|home| home.join(".jcode").join("hooks.toml")) +} + +/// `/.jcode/hooks.toml` +fn project_hooks_config_path() -> Option { + std::env::current_dir() + .ok() + .map(|cwd| cwd.join(".jcode").join("hooks.toml")) +} + +/// Path from the `JCODE_HOOKS_CONFIG` environment variable. +fn env_hooks_config_path() -> Option { + std::env::var("JCODE_HOOKS_CONFIG") + .ok() + .filter(|s| !s.is_empty()) + .map(PathBuf::from) +} + +/// Read and parse a single TOML config file. +/// +/// Returns `None` when the file does not exist or cannot be parsed (errors are +/// logged at `warn` level). +fn load_hooks_config_from_path(path: &std::path::Path) -> Option { + if !path.exists() { + return None; + } + match std::fs::read_to_string(path) { + Ok(content) => match toml::from_str::(&content) { + Ok(config) => Some(config), + Err(e) => { + eprintln!("Failed to parse hooks config {}: {}", path.display(), e); + None + } + }, + Err(e) => { + eprintln!("Failed to read hooks config {}: {}", path.display(), e); + None + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- HookEvent::parse --------------------------------------------------- + + #[test] + fn parse_pascal_case() { + assert_eq!(HookEvent::parse("PreToolUse"), Some(HookEvent::PreToolUse)); + assert_eq!( + HookEvent::parse("PostToolUse"), + Some(HookEvent::PostToolUse) + ); + assert_eq!( + HookEvent::parse("FileChanged"), + Some(HookEvent::FileChanged) + ); + assert_eq!( + HookEvent::parse("AutoCompactionControl"), + Some(HookEvent::AutoCompactionControl) + ); + } + + #[test] + fn parse_snake_case() { + assert_eq!( + HookEvent::parse("pre_tool_use"), + Some(HookEvent::PreToolUse) + ); + assert_eq!( + HookEvent::parse("post_tool_use_failure"), + Some(HookEvent::PostToolUseFailure) + ); + assert_eq!( + HookEvent::parse("user_prompt_submit"), + Some(HookEvent::UserPromptSubmit) + ); + } + + #[test] + fn parse_kebab_case() { + assert_eq!( + HookEvent::parse("pre-tool-use"), + Some(HookEvent::PreToolUse) + ); + assert_eq!( + HookEvent::parse("session-idle"), + Some(HookEvent::SessionIdle) + ); + } + + #[test] + fn parse_case_insensitive() { + assert_eq!(HookEvent::parse("PRETOOLUSE"), Some(HookEvent::PreToolUse)); + assert_eq!(HookEvent::parse("pretooluse"), Some(HookEvent::PreToolUse)); + assert_eq!(HookEvent::parse("PreToolUse"), Some(HookEvent::PreToolUse)); + assert_eq!(HookEvent::parse("STOP"), Some(HookEvent::Stop)); + } + + #[test] + fn parse_with_spaces() { + assert_eq!( + HookEvent::parse("Pre Tool Use"), + Some(HookEvent::PreToolUse) + ); + } + + #[test] + fn parse_all_28_standard_variants() { + let cases = &[ + ("PreToolUse", HookEvent::PreToolUse), + ("PostToolUse", HookEvent::PostToolUse), + ("PostToolUseFailure", HookEvent::PostToolUseFailure), + ("ToolError", HookEvent::ToolError), + ("UserPromptSubmit", HookEvent::UserPromptSubmit), + ("UserPromptExpansion", HookEvent::UserPromptExpansion), + ("SessionStart", HookEvent::SessionStart), + ("SessionEnd", HookEvent::SessionEnd), + ("SessionUpdated", HookEvent::SessionUpdated), + ("SessionDiff", HookEvent::SessionDiff), + ("SessionError", HookEvent::SessionError), + ("SessionIdle", HookEvent::SessionIdle), + ("PermissionRequest", HookEvent::PermissionRequest), + ("PermissionDenied", HookEvent::PermissionDenied), + ("PermissionAsked", HookEvent::PermissionAsked), + ("PermissionReplied", HookEvent::PermissionReplied), + ("AgentStart", HookEvent::AgentStart), + ("AgentEnd", HookEvent::AgentEnd), + ("SubagentStart", HookEvent::SubagentStart), + ("SubagentStop", HookEvent::SubagentStop), + ("Stop", HookEvent::Stop), + ("PreCompact", HookEvent::PreCompact), + ("PostCompact", HookEvent::PostCompact), + ("AutoCompactionControl", HookEvent::AutoCompactionControl), + ("TaskCreated", HookEvent::TaskCreated), + ("TaskCompleted", HookEvent::TaskCompleted), + ("Setup", HookEvent::Setup), + ("FileChanged", HookEvent::FileChanged), + ]; + for (input, expected) in cases { + assert_eq!( + HookEvent::parse(input), + Some(expected.clone()), + "Failed to parse '{}'", + input + ); + } + } + + #[test] + fn parse_custom_with_colon() { + assert_eq!( + HookEvent::parse("custom:my_event"), + Some(HookEvent::Custom("my_event".to_string())), + ); + assert_eq!( + HookEvent::parse("Custom:my-event"), + Some(HookEvent::Custom("my-event".to_string())), + ); + } + + #[test] + fn parse_custom_case_insensitive_prefix() { + assert_eq!( + HookEvent::parse("CUSTOM:foo"), + Some(HookEvent::Custom("foo".to_string())), + ); + } + + #[test] + fn parse_custom_bare() { + assert_eq!( + HookEvent::parse("custom"), + Some(HookEvent::Custom(String::new())), + ); + } + + #[test] + fn parse_unknown_returns_none() { + assert_eq!(HookEvent::parse("NoSuchEvent"), None); + assert_eq!(HookEvent::parse(""), None); + assert_eq!(HookEvent::parse(" "), None); + } + + // -- HookEvent::is_blocking --------------------------------------------- + + #[test] + fn blocking_events() { + let blocking = &[ + HookEvent::PreToolUse, + HookEvent::UserPromptSubmit, + HookEvent::PermissionRequest, + HookEvent::PermissionAsked, + HookEvent::AgentStart, + HookEvent::Stop, + HookEvent::PreCompact, + ]; + for ev in blocking { + assert!(ev.is_blocking(), "{:?} should be blocking", ev); + } + } + + #[test] + fn non_blocking_events() { + let non_blocking = &[ + HookEvent::PostToolUse, + HookEvent::PostToolUseFailure, + HookEvent::ToolError, + HookEvent::UserPromptExpansion, + HookEvent::SessionStart, + HookEvent::SessionEnd, + HookEvent::SessionUpdated, + HookEvent::SessionDiff, + HookEvent::SessionError, + HookEvent::SessionIdle, + HookEvent::PermissionDenied, + HookEvent::PermissionReplied, + HookEvent::AgentEnd, + HookEvent::SubagentStart, + HookEvent::SubagentStop, + HookEvent::PostCompact, + HookEvent::AutoCompactionControl, + HookEvent::TaskCreated, + HookEvent::TaskCompleted, + HookEvent::Setup, + HookEvent::FileChanged, + HookEvent::Custom("anything".to_string()), + ]; + for ev in non_blocking { + assert!(!ev.is_blocking(), "{:?} should NOT be blocking", ev); + } + } + + // -- HookEvent helpers -------------------------------------------------- + + #[test] + fn display_name_standard() { + assert_eq!(HookEvent::PreToolUse.display_name(), "PreToolUse"); + assert_eq!(HookEvent::Stop.display_name(), "Stop"); + } + + #[test] + fn display_name_custom() { + assert_eq!( + HookEvent::Custom("my_hook".to_string()).display_name(), + "my_hook" + ); + } + + #[test] + fn name_uppercase() { + assert_eq!(HookEvent::PreToolUse.name_uppercase(), "PRETOOLUSE"); + assert_eq!( + HookEvent::AutoCompactionControl.name_uppercase(), + "AUTOCOMPACTIONCONTROL" + ); + } + + #[test] + fn all_standard_has_28_variants() { + assert_eq!(HookEvent::all_standard().len(), 28); + } + + #[test] + fn display_trait() { + assert_eq!(format!("{}", HookEvent::PreToolUse), "PreToolUse"); + assert_eq!(format!("{}", HookEvent::Custom("foo".to_string())), "foo"); + } + + // -- parse_matcher_pattern ----------------------------------------------- + + #[test] + fn matcher_wildcard() { + assert_eq!(parse_matcher_pattern("*"), HookMatcher::Wildcard); + } + + #[test] + fn matcher_exact() { + assert_eq!( + parse_matcher_pattern("Bash"), + HookMatcher::Exact("Bash".to_string()) + ); + } + + #[test] + fn matcher_multi() { + assert_eq!( + parse_matcher_pattern("Bash|Write|Edit"), + HookMatcher::Multi(vec![ + "Bash".to_string(), + "Write".to_string(), + "Edit".to_string() + ]) + ); + } + + #[test] + fn matcher_regex() { + assert_eq!( + parse_matcher_pattern("/^Bash/"), + HookMatcher::Regex("^Bash".to_string()) + ); + } + + // -- CommandHandlerConfig defaults --------------------------------------- + + #[test] + fn command_handler_default() { + let cfg = CommandHandlerConfig::default(); + assert!(cfg.enabled); + assert!(cfg.command.is_empty()); + assert!(cfg.timeout_secs.is_none()); + assert!(cfg.env.is_empty()); + assert!(cfg.cwd.is_none()); + assert!(cfg.matcher.is_none()); + assert!(cfg.if_.is_none()); + } + + // -- HooksConfig merge -------------------------------------------------- + + #[test] + fn merge_settings_override() { + let mut base = HooksConfig::default(); + base.settings.timeout_secs = 10; + + let mut override_cfg = HooksConfig::default(); + override_cfg.settings.timeout_secs = 60; + override_cfg.settings.dry_run = true; + + base.merge(override_cfg); + assert_eq!(base.settings.timeout_secs, 60); + assert!(base.settings.dry_run); + } + + #[test] + fn merge_events_append() { + let mut base = HooksConfig::default(); + base.events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "hook_a".to_string(), + ..Default::default() + })); + + let mut other = HooksConfig::default(); + other + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "hook_b".to_string(), + ..Default::default() + })); + + base.merge(other); + assert_eq!(base.events["PreToolUse"].len(), 2); + } + + #[test] + fn merge_new_event_key() { + let mut base = HooksConfig::default(); + let mut other = HooksConfig::default(); + other + .events + .entry("SessionStart".to_string()) + .or_default() + .push(HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://localhost/hook".to_string(), + ..Default::default() + })); + + base.merge(other); + assert!(base.events.contains_key("SessionStart")); + } + + // -- TOML round-trip ---------------------------------------------------- + + #[test] + fn toml_deserialize_settings() { + let toml = r#" +[settings] +timeout_secs = 15 +max_concurrency = 5 +dry_run = true +fail_closed = true +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.settings.timeout_secs, 15); + assert_eq!(config.settings.max_concurrency, 5); + assert!(config.settings.dry_run); + assert!(config.settings.fail_closed); + } + + #[test] + fn toml_deserialize_command_handler() { + let toml = r#" +[[events.PreToolUse]] +type = "command" +command = "check.sh" +enabled = true +timeout_secs = 5 +matcher = "Bash|Write|Edit" +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.events.len(), 1); + let handlers = &config.events["PreToolUse"]; + assert_eq!(handlers.len(), 1); + match &handlers[0] { + HookHandlerConfig::Command(cmd) => { + assert_eq!(cmd.command, "check.sh"); + assert!(cmd.enabled); + assert_eq!(cmd.timeout_secs, Some(5)); + assert_eq!( + cmd.matcher, + Some(HookMatcher::Multi(vec![ + "Bash".to_string(), + "Write".to_string(), + "Edit".to_string() + ])) + ); + } + other => panic!("Expected Command variant, got {:?}", other), + } + } + + #[test] + fn toml_deserialize_http_handler() { + let toml = r#" +[[events.SessionEnd]] +type = "http" +url = "http://localhost:9090/hooks/session-end" +method = "POST" +timeout_secs = 5 +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + let handlers = &config.events["SessionEnd"]; + assert_eq!(handlers.len(), 1); + match &handlers[0] { + HookHandlerConfig::Http(http) => { + assert_eq!(http.url, "http://localhost:9090/hooks/session-end"); + assert_eq!(http.method, "POST"); + assert_eq!(http.timeout_secs, Some(5)); + } + other => panic!("Expected Http variant, got {:?}", other), + } + } + + #[test] + fn toml_deserialize_agent_handler() { + let toml = r#" +[[events.AgentStart]] +type = "agent" +agent_id = "prompt_injector" +timeout_secs = 60 +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + match &config.events["AgentStart"][0] { + HookHandlerConfig::Agent(agent) => { + assert_eq!(agent.agent_id, "prompt_injector"); + assert_eq!(agent.timeout_secs, 60); + } + other => panic!("Expected Agent variant, got {:?}", other), + } + } + + #[test] + fn toml_deserialize_plugin_handler() { + let toml = r#" +[[events.FileChanged]] +type = "plugin" +path = "/usr/local/bin/file_watcher" +args = ["--verbose"] +timeout_secs = 10 +matcher = "/\\.(rs|toml)$/" +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + match &config.events["FileChanged"][0] { + HookHandlerConfig::Plugin(plugin) => { + assert_eq!(plugin.path, "/usr/local/bin/file_watcher"); + assert_eq!(plugin.args, vec!["--verbose".to_string()]); + assert_eq!( + plugin.matcher, + Some(HookMatcher::Regex("\\.(rs|toml)$".to_string())) + ); + } + other => panic!("Expected Plugin variant, got {:?}", other), + } + } + + #[test] + fn toml_event_key_alias() { + // The `event` key (singular) should also work. + let toml = r#" +[[event.PreToolUse]] +type = "command" +command = "legacy.toml" +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.events["PreToolUse"].len(), 1); + } + + #[test] + fn toml_multiple_handlers_per_event() { + let toml = r#" +[[events.PreToolUse]] +type = "command" +command = "check_a.sh" + +[[events.PreToolUse]] +type = "http" +url = "http://localhost/hooks" + +[[events.PreToolUse]] +type = "command" +command = "check_b.sh" +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.events["PreToolUse"].len(), 3); + } + + #[test] + fn toml_if_field() { + let toml = r#" +[[events.PreToolUse]] +type = "command" +command = "conditional.sh" +if = "tool_name=Bash" +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + match &config.events["PreToolUse"][0] { + HookHandlerConfig::Command(cmd) => { + assert_eq!(cmd.if_.as_deref(), Some("tool_name=Bash")); + } + other => panic!("Expected Command variant, got {:?}", other), + } + } + + #[test] + fn toml_default_settings() { + let toml = r#" +[[events.Stop]] +type = "command" +command = "noop" +"#; + let config: HooksConfig = toml::from_str(toml).unwrap(); + assert_eq!(config.settings.timeout_secs, 30); + assert_eq!(config.settings.max_concurrency, 10); + assert!(!config.settings.dry_run); + assert!(!config.settings.fail_closed); + } + + // -- HooksConfig::is_empty ---------------------------------------------- + + #[test] + fn is_empty_true_by_default() { + let config = HooksConfig::default(); + assert!(config.is_empty()); + } + + #[test] + fn is_empty_false_with_handlers() { + let mut config = HooksConfig::default(); + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::default()); + assert!(!config.is_empty()); + } + + // -- HookEvent serde round-trip ----------------------------------------- + + #[test] + fn hook_event_serde_round_trip() { + let events = HookEvent::all_standard(); + for ev in &events { + let json = serde_json::to_string(ev).unwrap(); + let deserialized: HookEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(*ev, deserialized); + } + // Custom + let custom = HookEvent::Custom("my_thing".to_string()); + let json = serde_json::to_string(&custom).unwrap(); + let deserialized: HookEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(custom, deserialized); + } +} diff --git a/crates/jcode-hooks/src/dispatch.rs b/crates/jcode-hooks/src/dispatch.rs new file mode 100644 index 000000000..9bb5f7824 --- /dev/null +++ b/crates/jcode-hooks/src/dispatch.rs @@ -0,0 +1,861 @@ +//! Parallel hook dispatch engine. +//! +//! Orchestrates concurrent execution of multiple hook handlers for a single +//! event using [`FuturesUnordered`] and a [`Semaphore`]-based concurrency cap. +//! +//! # Architecture +//! +//! 1. The caller provides an [`AggregatedInput`] (event + context) and a +//! reference to the [`HookRegistry`]. +//! 2. [`dispatch_hooks`] resolves matching handlers via the registry, then +//! fans them out into a [`FuturesUnordered`] stream bounded by the +//! configured semaphore permits. +//! 3. Each completed future yields a [`ClassifiedResult`] which is collected +//! into [`DispatchStats`]. +//! 4. For blocking events the collected results are fed through +//! [`aggregate_decision`] to produce a single [`AggregatedDecision`] +//! with precedence: **deny > ask > allow**. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; + +use chrono::Utc; +use futures::stream::FuturesUnordered; +use futures::StreamExt; +use tokio::sync::Semaphore; + +use crate::config::{HookEvent, HookHandlerConfig, HookSettings}; +use crate::execute::{execute_single_hook, ExecuteError}; +use crate::types::{AggregatedDecision, HookInput, HookMetrics, HookResult}; + +// --------------------------------------------------------------------------- +// Global metrics store +// --------------------------------------------------------------------------- + +/// Global metrics store keyed by `"event_name::handler_label"`. +static HOOK_METRICS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +// --------------------------------------------------------------------------- +// DispatchConfig +// --------------------------------------------------------------------------- + +/// Configuration for a single dispatch run. +/// +/// Derived from [`HookSettings`] but can be overridden per-call. +#[derive(Debug, Clone)] +pub struct DispatchConfig { + /// Maximum number of hooks executed concurrently (default 10). + pub max_concurrency: usize, + /// Default per-handler timeout in seconds. + pub timeout_secs: u64, + /// If `true`, hook failures are treated as blocks (fail-closed). + pub fail_closed: bool, + /// If `true`, hooks are resolved but never actually executed (dry-run). + pub dry_run: bool, +} + +impl DispatchConfig { + /// Build from the global [`HookSettings`]. + pub fn from_settings(settings: &HookSettings) -> Self { + Self { + max_concurrency: settings.max_concurrency, + timeout_secs: settings.timeout_secs, + fail_closed: settings.fail_closed, + dry_run: settings.dry_run, + } + } +} + +impl Default for DispatchConfig { + fn default() -> Self { + Self { + max_concurrency: 10, + timeout_secs: 30, + fail_closed: false, + dry_run: false, + } + } +} + +// --------------------------------------------------------------------------- +// ClassifiedResult +// --------------------------------------------------------------------------- + +/// A single hook's execution result classified into a policy decision. +#[derive(Debug)] +pub struct ClassifiedResult { + /// Label identifying the handler (command string, URL, agent id). + pub handler_label: String, + /// The classified outcome. + pub outcome: ClassifiedOutcome, + /// Wall-clock duration of the handler execution. + pub duration: Duration, +} + +/// Simplified outcome for a single hook. +#[derive(Debug)] +pub enum ClassifiedOutcome { + /// Hook explicitly allowed the operation. + Allow, + /// Hook wants to ask the user. + Ask { reason: String }, + /// Hook blocked / denied the operation. + Deny { reason: String }, + /// Hook failed (timeout, crash, non-zero exit). + /// Behaviour depends on [`DispatchConfig::fail_closed`]. + Failed { error: String }, +} + +// --------------------------------------------------------------------------- +// DispatchStats +// --------------------------------------------------------------------------- + +/// Aggregated statistics collected during a dispatch run. +#[derive(Debug, Default)] +pub struct DispatchStats { + /// Total number of handlers that were matched and dispatched. + pub total_dispatched: u64, + /// Number of handlers that completed successfully (any decision). + pub completed: u64, + /// Number of handlers that failed (timeout / crash / error). + pub failed: u64, + /// Number of handlers that timed out specifically. + pub timed_out: u64, + /// Number of handlers that returned "allow". + pub allowed: u64, + /// Number of handlers that returned "ask". + pub asked: u64, + /// Number of handlers that returned "deny". + pub denied: u64, + /// Per-handler results for post-mortem inspection. + pub results: Vec, + /// Total wall-clock time for the entire dispatch run. + pub total_duration: Duration, +} + +impl DispatchStats { + /// Return `true` if every handler succeeded without failure. + pub fn all_succeeded(&self) -> bool { + self.failed == 0 + } + + /// Return `true` if at least one handler blocked the operation. + pub fn any_denied(&self) -> bool { + self.denied > 0 + } + + /// Return `true` if at least one handler wants to ask the user. + pub fn any_asked(&self) -> bool { + self.asked > 0 + } +} + +// --------------------------------------------------------------------------- +// classify_decision +// --------------------------------------------------------------------------- + +/// Classify a raw [`HookResult`] into a [`ClassifiedOutcome`]. +/// +/// # Rules +/// +/// | HookResult | ClassifiedOutcome | +/// |----------------------|-------------------| +/// | `Continue` with `decision = "allow"` | `Allow` | +/// | `Continue` with `decision = "ask"` | `Ask` | +/// | `Continue` (no decision / other) | `Allow` | +/// | `Blocked` | `Deny` | +/// | `Failed` | `Failed` | +pub fn classify_decision(result: &HookResult) -> ClassifiedOutcome { + match result { + HookResult::Continue(output) => { + match output.decision.as_deref() { + Some("ask") => ClassifiedOutcome::Ask { + reason: output + .reason + .clone() + .or_else(|| output.stop_reason.clone()) + .unwrap_or_default(), + }, + Some("deny") => ClassifiedOutcome::Deny { + reason: output + .stop_reason + .clone() + .or_else(|| output.reason.clone()) + .unwrap_or_else(|| "denied by hook".to_string()), + }, + // "allow" or absent decision -- both mean "go ahead". + _ => ClassifiedOutcome::Allow, + } + } + HookResult::Blocked { reason, .. } => ClassifiedOutcome::Deny { + reason: reason.clone(), + }, + HookResult::Failed { error } => ClassifiedOutcome::Failed { + error: error.clone(), + }, + } +} + +// --------------------------------------------------------------------------- +// aggregate_decision +// --------------------------------------------------------------------------- + +/// Aggregate multiple [`ClassifiedOutcome`]s into a single +/// [`AggregatedDecision`] using precedence: **deny > ask > allow**. +/// +/// - If any outcome is `Deny`, the result is `Deny` with the first +/// deny reason encountered. +/// - Else if any outcome is `Ask`, the result is `Ask` with all ask +/// reasons collected. +/// - Else the result is `Allow`. +/// +/// `Failed` outcomes are **ignored** unless `fail_closed` is `true`, +/// in which case they are treated as `Deny`. +pub fn aggregate_decision(outcomes: &[ClassifiedOutcome], fail_closed: bool) -> AggregatedDecision { + let mut ask_reasons: Vec = Vec::new(); + let mut first_deny: Option<(String, &ClassifiedOutcome)> = None; + + for outcome in outcomes { + match outcome { + ClassifiedOutcome::Deny { reason } => { + if first_deny.is_none() { + first_deny = Some((reason.clone(), outcome)); + } + } + ClassifiedOutcome::Ask { reason } => { + ask_reasons.push(reason.clone()); + } + ClassifiedOutcome::Failed { error } => { + if fail_closed && first_deny.is_none() { + first_deny = Some((format!("hook failed (fail-closed): {}", error), outcome)); + } + } + ClassifiedOutcome::Allow => { /* no-op */ } + } + } + + if let Some((reason, _)) = first_deny { + return AggregatedDecision::Deny { + reason, + source_hook: String::new(), // caller can enrich from stats + }; + } + + if !ask_reasons.is_empty() { + return AggregatedDecision::Ask { + reasons: ask_reasons, + }; + } + + AggregatedDecision::Allow +} + +// --------------------------------------------------------------------------- +// dispatch_hooks -- the main entry point +// --------------------------------------------------------------------------- + +/// Dispatch all matching handlers for a single event in parallel. +/// +/// # Arguments +/// +/// * `event` -- the [`HookEvent`] being triggered. +/// * `input` -- the [`HookInput`] to pass to every handler. +/// * `handlers` -- pre-filtered list of handlers (from the registry's +/// `get_matching` call). +/// * `config` -- dispatch configuration (concurrency, timeouts, policy). +/// +/// # Returns +/// +/// A [`DispatchStats`] containing per-handler results and the aggregate +/// [`AggregatedDecision`]. +/// +/// # Concurrency +/// +/// Handlers are executed inside a [`FuturesUnordered`] stream. A shared +/// [`Semaphore`] with `config.max_concurrency` permits ensures that at most +/// N handlers run simultaneously. +pub async fn dispatch_hooks( + event: &HookEvent, + input: &HookInput, + handlers: &[&HookHandlerConfig], + config: &DispatchConfig, +) -> DispatchStats { + let start = Instant::now(); + + let mut stats = DispatchStats { + total_dispatched: handlers.len() as u64, + ..Default::default() + }; + + // Nothing to do. + if handlers.is_empty() { + stats.total_duration = start.elapsed(); + return stats; + } + + // Semaphore bounds concurrent handler execution. + let semaphore = Arc::new(Semaphore::new(config.max_concurrency)); + + // Atomic counters for lock-free stats updates from spawned tasks. + let completed_count = Arc::new(AtomicU64::new(0)); + let failed_count = Arc::new(AtomicU64::new(0)); + let timed_out_count = Arc::new(AtomicU64::new(0)); + + // Build the FuturesUnordered stream. + let mut futures = FuturesUnordered::new(); + + for handler in handlers { + let permit = semaphore.clone(); + let timeout = effective_timeout(handler, config.timeout_secs); + let dry_run = config.dry_run; + let fail_closed = config.fail_closed; + let handler_label = handler_label(handler); + + // Clone the input and handler so the future owns them. + let input = input.clone(); + let handler = (*handler).clone(); + + futures.push(async move { + // Acquire semaphore permit before starting execution. + let _permit = permit + .acquire() + .await + .expect("semaphore closed unexpectedly"); + + let handler_start = Instant::now(); + + if dry_run { + // In dry-run mode we skip execution and report "allow". + return ClassifiedResult { + handler_label, + outcome: ClassifiedOutcome::Allow, + duration: handler_start.elapsed(), + }; + } + + // Execute with a timeout wrapper. + let result = tokio::time::timeout( + Duration::from_secs(timeout), + execute_single_hook(&handler, &input, timeout), + ) + .await; + + let duration = handler_start.elapsed(); + + match result { + Ok(Ok(hook_result)) => { + let outcome = classify_decision(&hook_result); + ClassifiedResult { + handler_label, + outcome, + duration, + } + } + Ok(Err(exec_err)) => { + // Execution-level error (spawn failure, I/O error, etc.) + let error = format_execute_error(&exec_err); + ClassifiedResult { + handler_label, + outcome: if fail_closed { + ClassifiedOutcome::Deny { + reason: format!("execution error (fail-closed): {}", error), + } + } else { + ClassifiedOutcome::Failed { error } + }, + duration, + } + } + Err(_elapsed) => { + // Timeout expired. + ClassifiedResult { + handler_label, + outcome: if fail_closed { + ClassifiedOutcome::Deny { + reason: format!("hook timed out after {}s (fail-closed)", timeout), + } + } else { + ClassifiedOutcome::Failed { + error: format!("timed out after {}s", timeout), + } + }, + duration, + } + } + } + }); + } + + let event_name = event.to_string(); + + // Drain the stream, collecting results. + while let Some(result) = futures.next().await { + record_metrics(&event_name, &result); + match &result.outcome { + ClassifiedOutcome::Allow => { + stats.allowed += 1; + completed_count.fetch_add(1, Ordering::Relaxed); + } + ClassifiedOutcome::Ask { .. } => { + stats.asked += 1; + completed_count.fetch_add(1, Ordering::Relaxed); + } + ClassifiedOutcome::Deny { .. } => { + stats.denied += 1; + completed_count.fetch_add(1, Ordering::Relaxed); + } + ClassifiedOutcome::Failed { error } => { + stats.failed += 1; + failed_count.fetch_add(1, Ordering::Relaxed); + if error.contains("timed out") { + timed_out_count.fetch_add(1, Ordering::Relaxed); + } + } + } + stats.results.push(result); + } + + stats.completed = completed_count.load(Ordering::Relaxed); + stats.failed = failed_count.load(Ordering::Relaxed); + stats.timed_out = timed_out_count.load(Ordering::Relaxed); + stats.total_duration = start.elapsed(); + + stats +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// Derive the effective timeout for a handler: per-handler override wins, +/// falling back to the global default. +fn effective_timeout(handler: &HookHandlerConfig, default_secs: u64) -> u64 { + match handler { + HookHandlerConfig::Command(cmd) => cmd.timeout_secs.unwrap_or(default_secs), + HookHandlerConfig::Http(http) => http.timeout_secs.unwrap_or(default_secs), + HookHandlerConfig::Agent(agent) => agent.timeout_secs, + HookHandlerConfig::Plugin(plugin) => plugin.timeout_secs, + } +} + +/// Human-readable label for a handler (used in stats and error messages). +fn handler_label(handler: &HookHandlerConfig) -> String { + match handler { + HookHandlerConfig::Command(cmd) => { + if cmd.command.len() > 60 { + format!("cmd:{}...", &cmd.command[..57]) + } else { + format!("cmd:{}", cmd.command) + } + } + HookHandlerConfig::Http(http) => { + if http.url.len() > 60 { + format!("http:{}...", &http.url[..57]) + } else { + format!("http:{}", http.url) + } + } + HookHandlerConfig::Agent(agent) => format!("agent:{}", agent.agent_id), + HookHandlerConfig::Plugin(plugin) => { + if plugin.path.len() > 60 { + format!("plugin:{}...", &plugin.path[..57]) + } else { + format!("plugin:{}", plugin.path) + } + } + } +} + +/// Format an [`ExecuteError`] into a human-readable string. +fn format_execute_error(err: &ExecuteError) -> String { + format!("{:#}", err) +} + +// --------------------------------------------------------------------------- +// Metrics helpers +// --------------------------------------------------------------------------- + +/// Record execution metrics for a single handler result. +fn record_metrics(event_name: &str, result: &ClassifiedResult) { + let key = format!("{}::{}", event_name, result.handler_label); + let mut store = HOOK_METRICS.lock().expect("metrics lock poisoned"); + let entry = store.entry(key).or_insert_with(|| HookMetrics { + event_name: event_name.to_string(), + handler_label: result.handler_label.clone(), + execution_count: 0, + failure_count: 0, + blocked_count: 0, + total_duration_ms: 0, + avg_duration_ms: 0.0, + last_execution: None, + last_error: None, + }); + + let duration_ms = result.duration.as_millis() as u64; + entry.execution_count += 1; + entry.total_duration_ms += duration_ms; + entry.avg_duration_ms = entry.total_duration_ms as f64 / entry.execution_count as f64; + entry.last_execution = Some(Utc::now()); + + match &result.outcome { + ClassifiedOutcome::Failed { error } => { + entry.failure_count += 1; + entry.last_error = Some(error.clone()); + } + ClassifiedOutcome::Deny { .. } => { + entry.blocked_count += 1; + } + _ => {} + } +} + +/// Return a snapshot of all collected hook metrics. +/// +/// Each entry is keyed by `"event_name::handler_label"`. +pub fn get_hook_metrics() -> HashMap { + HOOK_METRICS.lock().expect("metrics lock poisoned").clone() +} + +/// Return metrics for all handlers that match the given event name. +pub fn get_hook_metrics_for_event(event_name: &str) -> Vec { + HOOK_METRICS + .lock() + .expect("metrics lock poisoned") + .values() + .filter(|m| m.event_name == event_name) + .cloned() + .collect() +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CommandHandlerConfig, HttpHandlerConfig}; + use crate::types::HookOutput; + + // -- DispatchConfig ------------------------------------------------------- + + #[test] + fn dispatch_config_defaults() { + let cfg = DispatchConfig::default(); + assert_eq!(cfg.max_concurrency, 10); + assert_eq!(cfg.timeout_secs, 30); + assert!(!cfg.fail_closed); + assert!(!cfg.dry_run); + } + + #[test] + fn dispatch_config_from_settings() { + let settings = HookSettings { + timeout_secs: 60, + max_concurrency: 5, + dry_run: true, + fail_closed: true, + }; + let cfg = DispatchConfig::from_settings(&settings); + assert_eq!(cfg.max_concurrency, 5); + assert_eq!(cfg.timeout_secs, 60); + assert!(cfg.dry_run); + assert!(cfg.fail_closed); + } + + // -- classify_decision ---------------------------------------------------- + + #[test] + fn classify_continue_allow_explicit() { + let result = HookResult::Continue(HookOutput::allow()); + let classified = classify_decision(&result); + assert!(matches!(classified, ClassifiedOutcome::Allow)); + } + + #[test] + fn classify_continue_allow_default() { + // No decision field set -- should classify as Allow. + let result = HookResult::Continue(HookOutput::continue_()); + let classified = classify_decision(&result); + assert!(matches!(classified, ClassifiedOutcome::Allow)); + } + + #[test] + fn classify_continue_ask() { + let result = HookResult::Continue(HookOutput::ask("need approval")); + let classified = classify_decision(&result); + if let ClassifiedOutcome::Ask { reason } = classified { + assert_eq!(reason, "need approval"); + } else { + panic!("expected Ask"); + } + } + + #[test] + fn classify_continue_deny_via_decision_field() { + let output = HookOutput { + continue_: false, + suppress_output: None, + stop_reason: Some("blocked".to_string()), + decision: Some("deny".to_string()), + reason: None, + system_message: None, + hook_specific_output: None, + }; + let result = HookResult::Continue(output); + let classified = classify_decision(&result); + if let ClassifiedOutcome::Deny { reason } = classified { + assert_eq!(reason, "blocked"); + } else { + panic!("expected Deny"); + } + } + + #[test] + fn classify_blocked() { + let result = HookResult::Blocked { + reason: "not allowed".to_string(), + output: HookOutput::block("not allowed"), + }; + let classified = classify_decision(&result); + if let ClassifiedOutcome::Deny { reason } = classified { + assert_eq!(reason, "not allowed"); + } else { + panic!("expected Deny"); + } + } + + #[test] + fn classify_failed() { + let result = HookResult::Failed { + error: "timeout".to_string(), + }; + let classified = classify_decision(&result); + if let ClassifiedOutcome::Failed { error } = classified { + assert_eq!(error, "timeout"); + } else { + panic!("expected Failed"); + } + } + + // -- aggregate_decision --------------------------------------------------- + + #[test] + fn aggregate_empty_is_allow() { + let decision = aggregate_decision(&[], false); + assert!(matches!(decision, AggregatedDecision::Allow)); + } + + #[test] + fn aggregate_all_allow() { + let outcomes = vec![ClassifiedOutcome::Allow, ClassifiedOutcome::Allow]; + let decision = aggregate_decision(&outcomes, false); + assert!(matches!(decision, AggregatedDecision::Allow)); + } + + #[test] + fn aggregate_single_ask() { + let outcomes = vec![ClassifiedOutcome::Ask { + reason: "review needed".to_string(), + }]; + let decision = aggregate_decision(&outcomes, false); + if let AggregatedDecision::Ask { reasons } = decision { + assert_eq!(reasons, vec!["review needed"]); + } else { + panic!("expected Ask"); + } + } + + #[test] + fn aggregate_multiple_asks() { + let outcomes = vec![ + ClassifiedOutcome::Allow, + ClassifiedOutcome::Ask { + reason: "first".to_string(), + }, + ClassifiedOutcome::Ask { + reason: "second".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, false); + if let AggregatedDecision::Ask { reasons } = decision { + assert_eq!(reasons.len(), 2); + } else { + panic!("expected Ask"); + } + } + + #[test] + fn aggregate_deny_takes_precedence_over_ask() { + let outcomes = vec![ + ClassifiedOutcome::Ask { + reason: "want to ask".to_string(), + }, + ClassifiedOutcome::Deny { + reason: "blocked".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, false); + if let AggregatedDecision::Deny { reason, .. } = decision { + assert_eq!(reason, "blocked"); + } else { + panic!("expected Deny"); + } + } + + #[test] + fn aggregate_fail_open_ignores_failures() { + let outcomes = vec![ + ClassifiedOutcome::Allow, + ClassifiedOutcome::Failed { + error: "crash".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, false); + assert!(matches!(decision, AggregatedDecision::Allow)); + } + + #[test] + fn aggregate_fail_closed_treats_failure_as_deny() { + let outcomes = vec![ + ClassifiedOutcome::Allow, + ClassifiedOutcome::Failed { + error: "crash".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, true); + if let AggregatedDecision::Deny { reason, .. } = decision { + assert!(reason.contains("fail-closed")); + assert!(reason.contains("crash")); + } else { + panic!("expected Deny"); + } + } + + #[test] + fn aggregate_first_deny_wins() { + let outcomes = vec![ + ClassifiedOutcome::Deny { + reason: "first".to_string(), + }, + ClassifiedOutcome::Deny { + reason: "second".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, false); + if let AggregatedDecision::Deny { reason, .. } = decision { + assert_eq!(reason, "first"); + } else { + panic!("expected Deny"); + } + } + + // -- DispatchStats -------------------------------------------------------- + + #[test] + fn stats_defaults() { + let stats = DispatchStats::default(); + assert_eq!(stats.total_dispatched, 0); + assert!(stats.all_succeeded()); + assert!(!stats.any_denied()); + assert!(!stats.any_asked()); + } + + // -- handler_label -------------------------------------------------------- + + #[test] + fn label_command_short() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + command: "check.sh".to_string(), + ..Default::default() + }); + assert_eq!(handler_label(&handler), "cmd:check.sh"); + } + + #[test] + fn label_command_long_truncated() { + let long_cmd = "a".repeat(100); + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + command: long_cmd, + ..Default::default() + }); + let label = handler_label(&handler); + assert!(label.starts_with("cmd:")); + assert!(label.ends_with("...")); + assert!(label.len() < 70); + } + + #[test] + fn label_http() { + let handler = HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://localhost:9090/hook".to_string(), + ..Default::default() + }); + assert_eq!(handler_label(&handler), "http:http://localhost:9090/hook"); + } + + // -- effective_timeout ---------------------------------------------------- + + #[test] + fn timeout_override_wins() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + timeout_secs: Some(99), + ..Default::default() + }); + assert_eq!(effective_timeout(&handler, 30), 99); + } + + #[test] + fn timeout_falls_back_to_default() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + timeout_secs: None, + ..Default::default() + }); + assert_eq!(effective_timeout(&handler, 30), 30); + } + + // -- dispatch_hooks (dry-run integration) --------------------------------- + + #[tokio::test] + async fn dispatch_dry_run_reports_allow_for_all() { + let config = DispatchConfig { + dry_run: true, + ..Default::default() + }; + let input = HookInput::default(); + let handlers: Vec = vec![ + HookHandlerConfig::Command(CommandHandlerConfig { + command: "hook_a.sh".to_string(), + ..Default::default() + }), + HookHandlerConfig::Command(CommandHandlerConfig { + command: "hook_b.sh".to_string(), + ..Default::default() + }), + ]; + let refs: Vec<&HookHandlerConfig> = handlers.iter().collect(); + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &refs, &config).await; + + assert_eq!(stats.total_dispatched, 2); + assert_eq!(stats.allowed, 2); + assert_eq!(stats.failed, 0); + assert!(stats.all_succeeded()); + } + + #[tokio::test] + async fn dispatch_empty_handlers() { + let config = DispatchConfig::default(); + let input = HookInput::default(); + let handlers: Vec<&HookHandlerConfig> = vec![]; + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &handlers, &config).await; + + assert_eq!(stats.total_dispatched, 0); + assert!(stats.all_succeeded()); + assert!(stats.results.is_empty()); + } +} diff --git a/crates/jcode-hooks/src/execute.rs b/crates/jcode-hooks/src/execute.rs new file mode 100644 index 000000000..c6645834f --- /dev/null +++ b/crates/jcode-hooks/src/execute.rs @@ -0,0 +1,1074 @@ +//! Hook v2 execution engine. +//! +//! This module provides the execution layer for the four handler types defined +//! in the hook configuration: +//! +//! - **Command** -- spawns a shell process, feeds `HookInput` as JSON on stdin, +//! reads `HookOutput` from stdout, and interprets the exit code. +//! - **HTTP** -- sends `HookInput` as a JSON POST (or other method) to a URL +//! and expects a `HookOutput` JSON response body. +//! - **Agent** -- placeholder for dispatching to an inline jcode sub-agent. +//! - **Plugin** -- runs an external executable with the same stdin/stdout +//! protocol as command hooks. +//! +//! # Exit-code protocol (command & plugin hooks) +//! +//! | Exit code | Meaning | +//! |-----------|------------------------------| +//! | 0 | Continue (success) | +//! | 1 | Failure (non-blocking error) | +//! | 2 | Block / deny the operation | +//! +//! Any other exit code is treated as a failure. +//! +//! # Environment variable expansion +//! +//! Values in handler config (commands, URLs, header values, plugin args) may +//! contain `${VAR}` placeholders that are expanded at execution time from the +//! current process environment. + +use std::collections::HashMap; +use std::process::Stdio; +use std::time::Duration; + +use regex::Regex; +use tokio::io::AsyncWriteExt; +use tokio::process::Command; + +use crate::config::{ + AgentHandlerConfig, CommandHandlerConfig, HookHandlerConfig, HttpHandlerConfig, + PluginHandlerConfig, +}; +use crate::types::{HookInput, HookOutput, HookResult}; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/// Errors that can occur during hook execution. +/// +/// These are distinct from [`HookResult::Failed`] which represents a hook +/// that ran but returned a failure signal. `ExecuteError` represents an +/// infrastructure-level problem that prevented the hook from running at all +/// (or from producing a valid result). +#[derive(Debug, thiserror::Error)] +pub enum ExecuteError { + /// The hook command could not be spawned (not found, permission denied, etc.). + #[error("failed to spawn hook command: {0}")] + SpawnFailed(String), + + /// The hook process was killed by a signal. + #[error("hook process killed by signal: {signal}")] + ProcessKilled { signal: String }, + + /// I/O error communicating with the hook process (stdin write, stdout read). + #[error("I/O error communicating with hook process: {0}")] + IoError(String), + + /// The hook's stdout was not valid JSON or did not match the `HookOutput` schema. + #[error("hook returned invalid JSON on stdout: {0}")] + InvalidOutput(String), + + /// An HTTP-level error occurred (network failure, non-2xx status, etc.). + #[error("HTTP hook error: {0}")] + HttpError(String), + + /// The hook timed out (caller wraps with tokio::time::timeout, but this + /// variant exists for the HTTP path which handles timeouts internally). + #[error("hook timed out after {0}s")] + Timeout(u64), + + /// The agent handler is not yet implemented. + #[error("agent handler not yet implemented")] + AgentNotImplemented, + + /// Generic catch-all for unexpected failures. + #[error("unexpected error: {0}")] + Other(String), +} + +// --------------------------------------------------------------------------- +// execute_hook -- top-level dispatcher +// --------------------------------------------------------------------------- + +/// Execute a single hook handler and return the [`HookResult`]. +/// +/// Dispatches to the type-specific executor based on the handler variant. +/// The `timeout_secs` parameter is the effective timeout (per-handler override +/// or global default). +/// +/// This function does **not** apply its own timeout wrapper -- the caller +/// (typically the dispatch engine) is expected to wrap the call in +/// `tokio::time::timeout`. +pub async fn execute_hook( + handler: &HookHandlerConfig, + input: &HookInput, + timeout_secs: u64, +) -> Result { + match handler { + HookHandlerConfig::Command(cmd) => execute_command_hook(cmd, input, timeout_secs).await, + HookHandlerConfig::Http(http) => execute_http_hook(http, input, timeout_secs).await, + HookHandlerConfig::Agent(agent) => execute_agent_hook(agent, input).await, + HookHandlerConfig::Plugin(plugin) => execute_plugin_hook(plugin, input, timeout_secs).await, + } +} + +// --------------------------------------------------------------------------- +// execute_single_hook -- entry point used by the dispatch engine +// --------------------------------------------------------------------------- + +/// Execute a single hook handler, returning the [`HookResult`]. +/// +/// This is the primary entry point used by the dispatch engine. It validates +/// that the handler is enabled before executing, and delegates to +/// [`execute_hook`]. +/// +/// Disabled handlers are treated as a no-op `Continue` result. +pub async fn execute_single_hook( + handler: &HookHandlerConfig, + input: &HookInput, + timeout_secs: u64, +) -> Result { + // Check if the handler is enabled. + if !is_handler_enabled(handler) { + return Ok(HookResult::Continue(HookOutput::continue_())); + } + + execute_hook(handler, input, timeout_secs).await +} + +/// Return `true` if the handler's `enabled` field is `true`. +fn is_handler_enabled(handler: &HookHandlerConfig) -> bool { + match handler { + HookHandlerConfig::Command(cmd) => cmd.enabled, + HookHandlerConfig::Http(http) => http.enabled, + HookHandlerConfig::Agent(agent) => agent.enabled, + HookHandlerConfig::Plugin(plugin) => plugin.enabled, + } +} + +// --------------------------------------------------------------------------- +// execute_command_hook +// --------------------------------------------------------------------------- + +/// Execute a command-type hook handler. +/// +/// # Protocol +/// +/// 1. The `HookInput` is serialized as JSON and piped to the command's stdin. +/// 2. The command's stdout is captured and deserialized as `HookOutput`. +/// 3. The exit code determines the outcome: +/// - **0**: `HookResult::Continue` (with the parsed output) +/// - **1**: `HookResult::Failed` (the hook reported a failure) +/// - **2**: `HookResult::Blocked` (the hook wants to block the operation) +/// - **other**: `HookResult::Failed` (unexpected exit code) +/// +/// If the command produces no stdout (empty), a default `HookOutput::continue_()` +/// is assumed. +/// +/// # Environment +/// +/// The child process inherits the current process environment, plus: +/// - Any variables from the handler's `env` field (after `${VAR}` expansion). +/// - `JCODE_HOOK_EVENT` set to the event name. +/// - `JCODE_HOOK_SESSION_ID` set to the session id. +/// - `JCODE_HOOK_CWD` set to the working directory. +/// +/// # Working directory +/// +/// If the handler config specifies a `cwd`, it is used as the child process +/// working directory. Otherwise, the `cwd` from the `HookInput` is used. +pub async fn execute_command_hook( + config: &CommandHandlerConfig, + input: &HookInput, + _timeout_secs: u64, +) -> Result { + let expanded_command = expand_env_var(&config.command); + + // Determine the working directory: handler override > input cwd > current dir. + let working_dir = config + .cwd + .as_deref() + .map(expand_env_var) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| { + if input.cwd.is_empty() { + std::env::current_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|_| "/tmp".to_string()) + } else { + input.cwd.clone() + } + }); + + // Build environment variables. + let env_vars = build_command_env(&config.env, input); + + // Spawn the child process via sh -c so that shell syntax works. + let mut child = Command::new("sh") + .arg("-c") + .arg(&expanded_command) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(&working_dir) + .envs(&env_vars) + .spawn() + .map_err(|e| ExecuteError::SpawnFailed(format!("{}: {}", expanded_command, e)))?; + + // Write HookInput JSON to stdin. + if let Some(mut stdin) = child.stdin.take() { + let json = serde_json::to_vec(input) + .map_err(|e| ExecuteError::IoError(format!("serialize input: {}", e)))?; + stdin + .write_all(&json) + .await + .map_err(|e| ExecuteError::IoError(format!("write stdin: {}", e)))?; + // Close stdin so the child knows input is complete. + drop(stdin); + } + + // Wait for the child to exit and collect output. + let output = child + .wait_with_output() + .await + .map_err(|e| ExecuteError::IoError(format!("wait for child: {}", e)))?; + + let exit_code = output.status.code().unwrap_or_else(|| { + // Process was killed by a signal on Unix. + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = output.status.signal() { + eprintln!( + "Hook command '{}' killed by signal {}", + expanded_command, sig + ); + } + } + 1 // Treat signal-killed as failure. + }); + + // Parse stdout as HookOutput (may be empty). + let stdout_str = String::from_utf8_lossy(&output.stdout); + let hook_output = if stdout_str.trim().is_empty() { + HookOutput::continue_() + } else { + serde_json::from_str::(stdout_str.trim()).unwrap_or_else(|e| { + eprintln!( + "Hook command '{}' produced invalid JSON on stdout: {} (raw: {:?})", + expanded_command, + e, + stdout_str.trim() + ); + // Treat invalid JSON as a simple continue (best-effort). + HookOutput::continue_() + }) + }; + + // Log stderr if non-empty (for debugging). + let stderr_str = String::from_utf8_lossy(&output.stderr); + if !stderr_str.trim().is_empty() { + eprintln!( + "Hook command '{}' stderr: {}", + expanded_command, + stderr_str.trim() + ); + } + + // Interpret exit code per protocol. + interpret_exit_code(exit_code, hook_output, &expanded_command) +} + +/// Build the full environment for a command hook child process. +/// +/// Starts with the current process environment, overlays the handler's `env` +/// (with `${VAR}` expansion), and adds the three standard `JCODE_HOOK_*` vars. +fn build_command_env( + handler_env: &HashMap, + input: &HookInput, +) -> HashMap { + let mut env: HashMap = std::env::vars().collect(); + + // Handler-specific env (expanded). + for (key, value) in handler_env { + env.insert(key.clone(), expand_env_var(value)); + } + + // Standard hook env vars. + env.insert( + "JCODE_HOOK_EVENT".to_string(), + input.hook_event_name.clone(), + ); + env.insert( + "JCODE_HOOK_SESSION_ID".to_string(), + input.session_id.clone(), + ); + env.insert("JCODE_HOOK_CWD".to_string(), input.cwd.clone()); + + env +} + +/// Interpret a process exit code and `HookOutput` into a [`HookResult`]. +/// +/// Exit code mapping: +/// - 0 => Continue (use the provided output) +/// - 1 => Failed +/// - 2 => Blocked +/// - other => Failed +fn interpret_exit_code( + exit_code: i32, + output: HookOutput, + command_label: &str, +) -> Result { + match exit_code { + 0 => Ok(HookResult::Continue(output)), + 1 => { + let reason = output + .stop_reason + .clone() + .or_else(|| output.reason.clone()) + .unwrap_or_else(|| format!("hook command '{}' exited with code 1", command_label)); + Ok(HookResult::Failed { error: reason }) + } + 2 => { + let reason = output + .stop_reason + .clone() + .or_else(|| output.reason.clone()) + .unwrap_or_else(|| { + format!("hook command '{}' blocked the operation", command_label) + }); + Ok(HookResult::Blocked { reason, output }) + } + other => { + let reason = format!( + "hook command '{}' exited with unexpected code {}", + command_label, other + ); + Ok(HookResult::Failed { error: reason }) + } + } +} + +// --------------------------------------------------------------------------- +// execute_http_hook +// --------------------------------------------------------------------------- + +/// Execute an HTTP-type hook handler. +/// +/// # Protocol +/// +/// 1. The `HookInput` is serialized as JSON and sent as the request body +/// (unless the handler config specifies a static `body` override). +/// 2. The response body is deserialized as `HookOutput`. +/// 3. A 2xx status code with a valid `HookOutput` is treated as success. +/// 4. Non-2xx status codes are treated as failures. +/// 5. If the response body sets `continue_: false`, the result is `Blocked`. +/// +/// # Headers +/// +/// The handler's `headers` values support `${VAR}` environment variable +/// expansion. A default `Content-Type: application/json` header is set +/// unless overridden. +/// +/// # Timeout +/// +/// The HTTP client timeout is set to `timeout_secs`. If the request times +/// out, an `ExecuteError::Timeout` is returned. +pub async fn execute_http_hook( + config: &HttpHandlerConfig, + input: &HookInput, + timeout_secs: u64, +) -> Result { + let url = expand_env_var(&config.url); + let method = config.method.to_uppercase(); + + // Build the HTTP client with timeout. + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| ExecuteError::Other(format!("build HTTP client: {}", e)))?; + + // Build the request based on method. + let mut request = match method.as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + other => { + return Err(ExecuteError::Other(format!( + "unsupported HTTP method: {}", + other + ))); + } + }; + + // Set headers (with env expansion). + let mut has_content_type = false; + for (key, value) in &config.headers { + let expanded_value = expand_env_var(value); + if key.to_lowercase() == "content-type" { + has_content_type = true; + } + request = request.header(key.as_str(), expanded_value.as_str()); + } + + // Default Content-Type if not explicitly set. + if !has_content_type { + request = request.header("Content-Type", "application/json"); + } + + // Set body: static body override or serialized HookInput. + let body_json = match &config.body { + Some(static_body) => serde_json::to_vec(static_body) + .map_err(|e| ExecuteError::Other(format!("serialize static body: {}", e)))?, + None => serde_json::to_vec(input) + .map_err(|e| ExecuteError::Other(format!("serialize hook input: {}", e)))?, + }; + request = request.body(body_json); + + // Execute the request. + let response = request.send().await.map_err(|e| { + if e.is_timeout() { + ExecuteError::Timeout(timeout_secs) + } else { + ExecuteError::HttpError(format!("request to {}: {}", url, e)) + } + })?; + + let status = response.status(); + + // Read response body. + let body_bytes = response + .bytes() + .await + .map_err(|e| ExecuteError::HttpError(format!("read response from {}: {}", url, e)))?; + + let body_str = String::from_utf8_lossy(&body_bytes); + + // Non-2xx => failure. + if !status.is_success() { + return Ok(HookResult::Failed { + error: format!( + "HTTP hook returned status {} from {}: {}", + status.as_u16(), + url, + body_str.chars().take(200).collect::() + ), + }); + } + + // Parse response as HookOutput. + let hook_output: HookOutput = if body_str.trim().is_empty() { + HookOutput::continue_() + } else { + serde_json::from_str(body_str.trim()).unwrap_or_else(|e| { + eprintln!( + "HTTP hook at '{}' returned invalid JSON: {} (raw: {:?})", + url, + e, + body_str.trim() + ); + HookOutput::continue_() + }) + }; + + // Interpret the output. + if hook_output.continue_ { + Ok(HookResult::Continue(hook_output)) + } else { + let reason = hook_output + .stop_reason + .clone() + .or_else(|| hook_output.reason.clone()) + .unwrap_or_else(|| format!("HTTP hook at '{}' blocked the operation", url)); + Ok(HookResult::Blocked { + reason, + output: hook_output, + }) + } +} + +// --------------------------------------------------------------------------- +// execute_agent_hook -- placeholder +// --------------------------------------------------------------------------- + +/// Execute an agent-type hook handler. +/// +/// **Not yet implemented.** This is a placeholder for future support of +/// inline jcode sub-agent dispatch. Currently returns +/// [`ExecuteError::AgentNotImplemented`]. +/// +/// When implemented, this function will: +/// 1. Resolve the agent by `agent_id` from jcode's agent registry. +/// 2. Construct a sub-agent invocation with the `HookInput` as context. +/// 3. Optionally override the system prompt if `config.system_prompt` is set. +/// 4. Wait for completion (if `config.wait_for_completion` is `true`). +/// 5. Parse the agent's response as `HookOutput`. +pub async fn execute_agent_hook( + config: &AgentHandlerConfig, + _input: &HookInput, +) -> Result { + eprintln!( + "Agent hook handler '{}' is not yet implemented; skipping", + config.agent_id + ); + Err(ExecuteError::AgentNotImplemented) +} + +// --------------------------------------------------------------------------- +// execute_plugin_hook +// --------------------------------------------------------------------------- + +/// Execute a plugin-type hook handler. +/// +/// Plugins are external executables that follow the same stdin/stdout protocol +/// as command hooks: +/// +/// 1. `HookInput` JSON is piped to the plugin's stdin. +/// 2. `HookOutput` JSON is read from the plugin's stdout. +/// 3. The exit code is interpreted per the standard protocol (0/1/2). +/// +/// Unlike command hooks, plugins are specified as a direct executable path +/// (not via `sh -c`), and receive CLI arguments from `config.args`. +/// +/// # Environment +/// +/// The plugin inherits the current process environment plus: +/// - `JCODE_HOOK_EVENT` +/// - `JCODE_HOOK_SESSION_ID` +/// - `JCODE_HOOK_CWD` +/// +/// # Arguments +/// +/// Each argument in `config.args` supports `${VAR}` expansion. +pub async fn execute_plugin_hook( + config: &PluginHandlerConfig, + input: &HookInput, + _timeout_secs: u64, +) -> Result { + let plugin_path = expand_env_var(&config.path); + + // Expand args. + let expanded_args: Vec = config.args.iter().map(|a| expand_env_var(a)).collect(); + + // Build environment. + let mut env_vars: HashMap = std::env::vars().collect(); + env_vars.insert( + "JCODE_HOOK_EVENT".to_string(), + input.hook_event_name.clone(), + ); + env_vars.insert( + "JCODE_HOOK_SESSION_ID".to_string(), + input.session_id.clone(), + ); + env_vars.insert("JCODE_HOOK_CWD".to_string(), input.cwd.clone()); + + // Spawn the plugin process. + let mut child = Command::new(&plugin_path) + .args(&expanded_args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(&input.cwd) + .envs(&env_vars) + .spawn() + .map_err(|e| ExecuteError::SpawnFailed(format!("plugin '{}': {}", plugin_path, e)))?; + + // Write HookInput JSON to stdin. + if let Some(mut stdin) = child.stdin.take() { + let json = serde_json::to_vec(input) + .map_err(|e| ExecuteError::IoError(format!("serialize input: {}", e)))?; + stdin + .write_all(&json) + .await + .map_err(|e| ExecuteError::IoError(format!("write stdin to plugin: {}", e)))?; + drop(stdin); + } + + // Wait for the plugin to exit. + let output = child + .wait_with_output() + .await + .map_err(|e| ExecuteError::IoError(format!("wait for plugin '{}': {}", plugin_path, e)))?; + + let exit_code = output.status.code().unwrap_or_else(|| { + #[cfg(unix)] + { + use std::os::unix::process::ExitStatusExt; + if let Some(sig) = output.status.signal() { + eprintln!("Plugin '{}' killed by signal {}", plugin_path, sig); + } + } + 1 + }); + + // Parse stdout. + let stdout_str = String::from_utf8_lossy(&output.stdout); + let hook_output = if stdout_str.trim().is_empty() { + HookOutput::continue_() + } else { + serde_json::from_str::(stdout_str.trim()).unwrap_or_else(|e| { + eprintln!( + "Plugin '{}' produced invalid JSON on stdout: {} (raw: {:?})", + plugin_path, + e, + stdout_str.trim() + ); + HookOutput::continue_() + }) + }; + + // Log stderr. + let stderr_str = String::from_utf8_lossy(&output.stderr); + if !stderr_str.trim().is_empty() { + eprintln!("Plugin '{}' stderr: {}", plugin_path, stderr_str.trim()); + } + + interpret_exit_code(exit_code, hook_output, &format!("plugin:{}", plugin_path)) +} + +// --------------------------------------------------------------------------- +// expand_env_var +// --------------------------------------------------------------------------- + +/// Expand `${VAR}` placeholders in a string with values from the current +/// process environment. +/// +/// # Syntax +/// +/// - `${VAR}` -- replaced with the value of environment variable `VAR`. +/// If `VAR` is not set, the placeholder is left as-is (literal `${VAR}`). +/// - `${VAR:-default}` -- replaced with the value of `VAR`, or `default` +/// if `VAR` is not set or is empty. +/// +/// # Examples +/// +/// ```ignore +/// assert_eq!(expand_env_var("hello"), "hello"); +/// // If HOME=/home/user: +/// assert_eq!(expand_env_var("${HOME}/bin"), "/home/user/bin"); +/// assert_eq!(expand_env_var("${MISSING:-fallback}"), "fallback"); +/// ``` +/// +/// # Safety +/// +/// This function does **not** perform shell command substitution or any +/// form of code execution. Only environment variable values are substituted. +pub fn expand_env_var(input: &str) -> String { + // Fast path: no dollar sign means nothing to expand. + if !input.contains('$') { + return input.to_string(); + } + + let re = + Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::(-)([^}]*))?\}").expect("valid env var regex"); + + let mut result = String::with_capacity(input.len()); + let mut last_end = 0; + + for caps in re.captures_iter(input) { + let full_match = caps.get(0).unwrap(); + let var_name = &caps[1]; + let has_default = caps.get(2).is_some(); + let default_value = caps.get(3).map(|m| m.as_str()).unwrap_or(""); + + // Append text before this match. + result.push_str(&input[last_end..full_match.start()]); + + // Look up the variable. + match std::env::var(var_name) { + Ok(val) if !val.is_empty() => { + result.push_str(&val); + } + _ if has_default => { + result.push_str(default_value); + } + _ => { + // Variable not set and no default: keep the original placeholder. + result.push_str(full_match.as_str()); + } + } + + last_end = full_match.end(); + } + + // Append any remaining text after the last match. + result.push_str(&input[last_end..]); + + result +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CommandHandlerConfig, HttpHandlerConfig, PluginHandlerConfig}; + use crate::types::HookInputBuilder; + + // -- expand_env_var -------------------------------------------------------- + + #[test] + fn expand_no_dollar() { + assert_eq!(expand_env_var("hello world"), "hello world"); + } + + #[test] + fn expand_empty_string() { + assert_eq!(expand_env_var(""), ""); + } + + #[test] + fn expand_existing_var() { + // PATH is always set. + let result = expand_env_var("${PATH}"); + assert!(!result.contains("${PATH}")); + assert!(!result.is_empty()); + } + + #[test] + fn expand_missing_var_no_default() { + // A variable that is extremely unlikely to exist. + let result = expand_env_var("${JCODE_HOOKS_TEST_VAR_987654321}"); + assert_eq!(result, "${JCODE_HOOKS_TEST_VAR_987654321}"); + } + + #[test] + fn expand_missing_var_with_default() { + let result = expand_env_var("${JCODE_HOOKS_TEST_MISSING:-fallback_value}"); + assert_eq!(result, "fallback_value"); + } + + #[test] + fn expand_existing_var_with_default() { + // PATH is set; the default should be ignored. + let result = expand_env_var("${PATH:-/usr/bin}"); + assert_ne!(result, "/usr/bin"); + assert!(!result.is_empty()); + } + + #[test] + fn expand_mixed_text() { + std::env::set_var("_JCODE_TEST_EXPAND", "REPLACED"); + let result = expand_env_var("before_${_JCODE_TEST_EXPAND}_after"); + assert_eq!(result, "before_REPLACED_after"); + std::env::remove_var("_JCODE_TEST_EXPAND"); + } + + #[test] + fn expand_multiple_vars() { + std::env::set_var("_JCODE_TEST_A", "AAA"); + std::env::set_var("_JCODE_TEST_B", "BBB"); + let result = expand_env_var("${_JCODE_TEST_A}/${_JCODE_TEST_B}"); + assert_eq!(result, "AAA/BBB"); + std::env::remove_var("_JCODE_TEST_A"); + std::env::remove_var("_JCODE_TEST_B"); + } + + #[test] + fn expand_dollar_brace_in_default() { + let result = expand_env_var("${_JCODE_TEST_UNDEF:-${ALSO_UNDEF}}"); + // When the var is not set, the default is "${ALSO_UNDEF}" (literal). + assert_eq!(result, "${ALSO_UNDEF}"); + } + + #[test] + fn expand_single_dollar_no_brace() { + // A bare $ without braces is not expanded. + assert_eq!(expand_env_var("price is $5"), "price is $5"); + } + + #[test] + fn expand_default_empty() { + let result = expand_env_var("${_JCODE_TEST_UNDEF:-}"); + assert_eq!(result, ""); + } + + // -- interpret_exit_code --------------------------------------------------- + + #[test] + fn exit_code_0_continue() { + let output = HookOutput::continue_(); + let result = interpret_exit_code(0, output, "test").unwrap(); + assert!(matches!(result, HookResult::Continue(_))); + } + + #[test] + fn exit_code_1_fail() { + let output = HookOutput::continue_(); + let result = interpret_exit_code(1, output, "test_cmd").unwrap(); + assert!(matches!(result, HookResult::Failed { .. })); + if let HookResult::Failed { error } = result { + assert!(error.contains("test_cmd")); + } + } + + #[test] + fn exit_code_2_block() { + let output = HookOutput::block("denied"); + let result = interpret_exit_code(2, output, "test_cmd").unwrap(); + assert!(matches!(result, HookResult::Blocked { .. })); + if let HookResult::Blocked { reason, .. } = result { + assert_eq!(reason, "denied"); + } + } + + #[test] + fn exit_code_2_block_with_output_reason() { + let output = HookOutput { + continue_: false, + suppress_output: None, + stop_reason: Some("custom reason".to_string()), + decision: Some("deny".to_string()), + reason: None, + system_message: None, + hook_specific_output: None, + }; + let result = interpret_exit_code(2, output, "test_cmd").unwrap(); + if let HookResult::Blocked { reason, .. } = result { + assert_eq!(reason, "custom reason"); + } + } + + #[test] + fn exit_code_other_fail() { + let output = HookOutput::continue_(); + let result = interpret_exit_code(127, output, "missing_cmd").unwrap(); + assert!(matches!(result, HookResult::Failed { .. })); + if let HookResult::Failed { error } = result { + assert!(error.contains("127")); + } + } + + // -- build_command_env ----------------------------------------------------- + + #[test] + fn build_env_includes_standard_vars() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("PreToolUse") + .build(); + let handler_env = HashMap::new(); + let env = build_command_env(&handler_env, &input); + assert_eq!(env.get("JCODE_HOOK_EVENT").unwrap(), "PreToolUse"); + assert_eq!(env.get("JCODE_HOOK_SESSION_ID").unwrap(), "ses_1"); + assert_eq!(env.get("JCODE_HOOK_CWD").unwrap(), "/project"); + } + + #[test] + fn build_env_merges_handler_env() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("PreToolUse") + .build(); + let mut handler_env = HashMap::new(); + handler_env.insert("MY_VAR".to_string(), "my_value".to_string()); + let env = build_command_env(&handler_env, &input); + assert_eq!(env.get("MY_VAR").unwrap(), "my_value"); + } + + // -- is_handler_enabled ---------------------------------------------------- + + #[test] + fn enabled_command() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + enabled: true, + command: "test".to_string(), + ..Default::default() + }); + assert!(is_handler_enabled(&handler)); + } + + #[test] + fn disabled_command() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + enabled: false, + command: "test".to_string(), + ..Default::default() + }); + assert!(!is_handler_enabled(&handler)); + } + + #[test] + fn enabled_http() { + let handler = HookHandlerConfig::Http(HttpHandlerConfig { + enabled: true, + url: "http://localhost".to_string(), + ..Default::default() + }); + assert!(is_handler_enabled(&handler)); + } + + #[test] + fn disabled_plugin() { + let handler = HookHandlerConfig::Plugin(PluginHandlerConfig { + enabled: false, + path: "/usr/bin/plugin".to_string(), + ..Default::default() + }); + assert!(!is_handler_enabled(&handler)); + } + + // -- execute_single_hook (disabled handler) -------------------------------- + + #[tokio::test] + async fn disabled_handler_returns_continue() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + enabled: false, + command: "echo should_not_run".to_string(), + ..Default::default() + }); + let input = HookInput::default(); + let result = execute_single_hook(&handler, &input, 5).await.unwrap(); + assert!(matches!(result, HookResult::Continue(_))); + } + + // -- execute_command_hook (integration, requires `echo`) ------------------- + + #[tokio::test] + async fn command_hook_echo_continue() { + let config = CommandHandlerConfig { + enabled: true, + command: "cat".to_string(), // cat reads stdin and echoes it + ..Default::default() + }; + let input = HookInputBuilder::new() + .session("ses_test", "/tmp") + .event("PreToolUse") + .build(); + let result = execute_command_hook(&config, &input, 5).await.unwrap(); + // cat with stdin JSON will echo the JSON; since it's valid HookInput + // but NOT valid HookOutput (it has different fields), it will fall back + // to continue_(). Exit code 0 => Continue. + assert!(matches!(result, HookResult::Continue(_))); + } + + #[tokio::test] + async fn command_hook_exit_2_blocks() { + let config = CommandHandlerConfig { + enabled: true, + command: + "echo '{\"continue_\": false, \"stop_reason\": \"blocked by test\"}' && exit 2" + .to_string(), + ..Default::default() + }; + let input = HookInput::default(); + let result = execute_command_hook(&config, &input, 5).await.unwrap(); + assert!(matches!(result, HookResult::Blocked { .. })); + if let HookResult::Blocked { reason, .. } = result { + assert_eq!(reason, "blocked by test"); + } + } + + #[tokio::test] + async fn command_hook_exit_1_fails() { + let config = CommandHandlerConfig { + enabled: true, + command: "exit 1".to_string(), + ..Default::default() + }; + let input = HookInput::default(); + let result = execute_command_hook(&config, &input, 5).await.unwrap(); + assert!(matches!(result, HookResult::Failed { .. })); + } + + // -- execute_command_hook with env vars ------------------------------------ + + #[tokio::test] + async fn command_hook_receives_env_vars() { + let config = CommandHandlerConfig { + enabled: true, + // The command checks that JCODE_HOOK_EVENT is set. + command: "test \"$JCODE_HOOK_EVENT\" = \"SessionStart\"".to_string(), + ..Default::default() + }; + let input = HookInputBuilder::new() + .session("ses_env", "/tmp") + .event("SessionStart") + .build(); + let result = execute_command_hook(&config, &input, 5).await.unwrap(); + assert!(matches!(result, HookResult::Continue(_))); + } + + // -- execute_agent_hook (placeholder) -------------------------------------- + + #[tokio::test] + async fn agent_hook_returns_not_implemented() { + let config = AgentHandlerConfig { + agent_id: "test_agent".to_string(), + ..Default::default() + }; + let input = HookInput::default(); + let result = execute_agent_hook(&config, &input).await; + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + ExecuteError::AgentNotImplemented + )); + } + + // -- execute_hook dispatches correctly ------------------------------------ + + #[tokio::test] + async fn execute_hook_dispatches_command() { + let handler = HookHandlerConfig::Command(CommandHandlerConfig { + enabled: true, + command: "exit 0".to_string(), + ..Default::default() + }); + let input = HookInput::default(); + let result = execute_hook(&handler, &input, 5).await.unwrap(); + assert!(matches!(result, HookResult::Continue(_))); + } + + #[tokio::test] + async fn execute_hook_dispatches_agent() { + let handler = HookHandlerConfig::Agent(AgentHandlerConfig { + agent_id: "test".to_string(), + ..Default::default() + }); + let input = HookInput::default(); + let result = execute_hook(&handler, &input, 5).await; + assert!(result.is_err()); + } + + // -- ExecuteError display -------------------------------------------------- + + #[test] + fn execute_error_display() { + let err = ExecuteError::SpawnFailed("not found".to_string()); + assert!(format!("{}", err).contains("not found")); + + let err = ExecuteError::Timeout(30); + assert!(format!("{}", err).contains("30")); + + let err = ExecuteError::AgentNotImplemented; + assert!(format!("{}", err).contains("not yet implemented")); + } + + // -- HookOutput JSON round-trip through command --------------------------- + + #[tokio::test] + async fn command_hook_output_json_roundtrip() { + // A command that outputs a valid HookOutput JSON. + let output_json = r#"{"continue_": true, "decision": "allow"}"#; + let config = CommandHandlerConfig { + enabled: true, + command: format!("echo '{}'", output_json), + ..Default::default() + }; + let input = HookInput::default(); + let result = execute_command_hook(&config, &input, 5).await.unwrap(); + if let HookResult::Continue(output) = result { + assert!(output.continue_); + assert_eq!(output.decision.as_deref(), Some("allow")); + } else { + panic!("expected Continue"); + } + } +} diff --git a/crates/jcode-hooks/src/lib.rs b/crates/jcode-hooks/src/lib.rs new file mode 100644 index 000000000..7d5dacc6a --- /dev/null +++ b/crates/jcode-hooks/src/lib.rs @@ -0,0 +1,25 @@ +//! Hooks module — lifecycle hooks for jcode events. + +pub mod cli; +pub mod config; +pub mod dispatch; +pub mod execute; +pub mod matcher; +pub mod registry; +pub mod types; + +pub use config::{ + load_hooks_config, AgentHandlerConfig, CommandHandlerConfig, HookEvent, HookHandlerConfig, + HookSettings, HooksConfig, HttpHandlerConfig, PluginHandlerConfig, +}; +pub use dispatch::{ + dispatch_hooks, get_hook_metrics, get_hook_metrics_for_event, ClassifiedOutcome, + ClassifiedResult, DispatchConfig, DispatchStats, +}; +pub use execute::{execute_command_hook, execute_hook, execute_http_hook}; +pub use matcher::{matches, parse_multi_pattern, HookMatcher, MatcherContext}; +pub use registry::{HookContext, HookRegistry}; +pub use types::*; + +#[cfg(test)] +mod tests; diff --git a/crates/jcode-hooks/src/matcher.rs b/crates/jcode-hooks/src/matcher.rs new file mode 100644 index 000000000..6d76f6005 --- /dev/null +++ b/crates/jcode-hooks/src/matcher.rs @@ -0,0 +1,144 @@ +//! Hook matcher logic - determines which hooks apply to which tools/events + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::{LazyLock, Mutex}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum HookMatcher { + Exact(String), + Multi(Vec), + Regex(String), + Wildcard, +} + +/// Context for matching a hook against an event +#[derive(Debug, Clone)] +pub struct MatcherContext<'a> { + /// The tool name or event identifier being matched + pub target: &'a str, + /// Additional context (e.g., full command for Bash hooks) + pub context: Option<&'a str>, +} + +impl<'a> MatcherContext<'a> { + /// Create a new matcher context + pub fn new(target: &'a str) -> Self { + Self { + target, + context: None, + } + } + + /// Create with additional context + pub fn with_context(target: &'a str, context: &'a str) -> Self { + Self { + target, + context: Some(context), + } + } +} + +/// Check if a matcher pattern matches the given context +pub fn matches(matcher: &HookMatcher, ctx: &MatcherContext) -> bool { + match matcher { + HookMatcher::Exact(pattern) => ctx.target == pattern, + HookMatcher::Multi(patterns) => patterns.iter().any(|p| ctx.target == p), + HookMatcher::Regex(pattern) => { + // Global regex cache: compile once per unique pattern string + static REGEX_CACHE: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + let re = { + let mut cache = REGEX_CACHE.lock().expect("regex cache poisoned"); + let re = cache.entry(pattern.to_string()).or_insert_with(|| { + Box::leak(Box::new( + Regex::new(pattern).unwrap_or_else(|e| { + eprintln!( + "[jcode-hooks] invalid regex pattern {:?}: {} — using never-match placeholder", + pattern, e + ); + Regex::new(r"(?!)a").unwrap() + }), + )) + }); + *re + }; + // Match against target + context (concatenated) for full flexibility + let match_str = match ctx.context { + Some(context) => format!("{}{}", ctx.target, context), + None => ctx.target.to_string(), + }; + re.is_match(&match_str) + } + HookMatcher::Wildcard => true, + } +} + +/// Parse a multi-value pattern string like "Write|Edit" into individual values +pub fn parse_multi_pattern(pattern: &str) -> Vec { + pattern.split('|').map(|s| s.trim().to_string()).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_exact_matcher() { + let matcher = HookMatcher::Exact("Bash".to_string()); + let ctx = MatcherContext::new("Bash"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::new("Write"); + assert!(!matches(&matcher, &ctx)); + } + + #[test] + fn test_multi_matcher() { + let matcher = HookMatcher::Multi(vec!["Bash".to_string(), "Write".to_string()]); + let ctx = MatcherContext::new("Bash"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::new("Write"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::new("Edit"); + assert!(!matches(&matcher, &ctx)); + } + + #[test] + fn test_multi_matcher_from_string() { + let patterns = parse_multi_pattern("Write|Edit|Glob"); + assert_eq!(patterns, vec!["Write", "Edit", "Glob"]); + } + + #[test] + fn test_regex_matcher() { + let matcher = HookMatcher::Regex("^Bash(git.*)".to_string()); + + let ctx = MatcherContext::new("Bash"); + assert!(!matches(&matcher, &ctx)); // No match without git prefix + + let ctx = MatcherContext::with_context("Bash", "git commit"); + assert!(matches(&matcher, &ctx)); + + let ctx = MatcherContext::with_context("Bash", "ls -la"); + assert!(!matches(&matcher, &ctx)); + } + + #[test] + fn test_wildcard_matcher() { + let matcher = HookMatcher::Wildcard; + let ctx = MatcherContext::new("Anything"); + assert!(matches(&matcher, &ctx)); + } + + #[test] + fn test_invalid_regex_falls_back() { + // Invalid regex falls back to a never-match placeholder with a warning. + let matcher = HookMatcher::Regex("never-match".to_string()); + let ctx = MatcherContext::new("anything"); + assert!(!matches(&matcher, &ctx)); + } +} diff --git a/crates/jcode-hooks/src/registry.rs b/crates/jcode-hooks/src/registry.rs new file mode 100644 index 000000000..c3feca9d5 --- /dev/null +++ b/crates/jcode-hooks/src/registry.rs @@ -0,0 +1,989 @@ +//! HookRegistry - manages hook registration and lookup by event type +//! +//! Provides efficient lookup of hooks filtered by event type and +//! matcher pattern against the current execution context. + +use std::collections::HashMap; + +use crate::config::{HookEvent, HookHandlerConfig, HooksConfig}; +use crate::matcher::{matches, HookMatcher, MatcherContext}; + +/// Context passed to hooks for matching decisions. +/// +/// Contains all information about the current execution context +/// that hooks can use to determine if they should run. +#[derive(Debug, Clone)] +pub struct HookContext { + /// Session identifier + pub session_id: String, + /// Path to the session transcript file + pub transcript_path: String, + /// Current working directory + pub cwd: String, + /// Name of the hook event being triggered + pub hook_event_name: String, + /// Optional agent ID + pub agent_id: Option, + /// Optional agent type + pub agent_type: Option, + /// Optional tool name being executed + pub tool_name: Option, + /// Optional tool input (serialized JSON) + pub tool_input: Option, + /// Optional tool use ID + pub tool_use_id: Option, + /// Optional permission mode + pub permission_mode: Option, + /// Optional model name (e.g. "claude-sonnet-4-20250514") + pub model: Option, + /// Optional user prompt text + pub prompt: Option, + /// Optional system prompt text + pub system_prompt: Option, + /// Optional current transcript/context size in bytes (used by PreCompact) + pub current_size_bytes: Option, + /// Optional task identifier (used by TaskCreated/TaskCompleted) + pub task_id: Option, + /// Optional file path (used by FileChanged) + pub file_path: Option, + /// Optional stop reason type (used by Stop) + pub stop_type: Option, +} + +impl HookContext { + /// Create a new empty HookContext + pub fn new(session_id: &str, transcript_path: &str, cwd: &str, hook_event_name: &str) -> Self { + Self { + session_id: session_id.to_string(), + transcript_path: transcript_path.to_string(), + cwd: cwd.to_string(), + hook_event_name: hook_event_name.to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a new HookContext for a tool-related event + pub fn for_tool(tool_name: String, session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "PreToolUse".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + pub fn for_session_start(session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "SessionStart".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + pub fn for_session_end(session_id: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "SessionEnd".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + pub fn for_permission_request( + tool_name: String, + session_id: String, + permission_mode: String, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "PermissionRequest".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: None, + tool_use_id: None, + permission_mode: Some(permission_mode), + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + pub fn for_permission_denied(session_id: String, permission_mode: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "PermissionDenied".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: Some(permission_mode), + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a PermissionAsked event. + /// + /// Fired when a permission request is presented to the user. This is a + /// blocking event — hooks can pre-approve (return "allow") to skip the + /// user prompt entirely. + pub fn for_permission_asked( + action: String, + session_id: String, + permission_mode: String, + request_id: String, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "PermissionAsked".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(action), + tool_input: None, + tool_use_id: Some(request_id), + permission_mode: Some(permission_mode), + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a PermissionReplied event. + /// + /// Fired after a permission decision is recorded (approve or deny). + /// This is an observational event — hooks cannot change the outcome. + pub fn for_permission_replied(request_id: String, session_id: String, approved: bool) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "PermissionReplied".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: Some(serde_json::json!({ "approved": approved })), + tool_use_id: Some(request_id), + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + pub fn for_tool_error(tool_name: String, session_id: String, error: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "ToolError".to_string(), + agent_id: None, + agent_type: None, + tool_name: Some(tool_name), + tool_input: Some(serde_json::json!({ "error": error })), + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a PreCompact event + pub fn for_pre_compact(session_id: String, cwd: String, current_size_bytes: u64) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "PreCompact".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: Some(current_size_bytes), + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a PostCompact event + pub fn for_post_compact(session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "PostCompact".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for an AutoCompactionControl event + pub fn for_auto_compaction_control( + session_id: String, + cwd: String, + auto_compaction_enabled: bool, + compaction_count: usize, + avg_saved_bytes: u64, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "AutoCompactionControl".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: Some(serde_json::json!({ + "auto_compaction_enabled": auto_compaction_enabled, + "compaction_count": compaction_count, + "avg_saved_bytes": avg_saved_bytes, + })), + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a Stop event + pub fn for_stop(session_id: String, cwd: String, stop_type: Option) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "Stop".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type, + } + } + + /// Create a HookContext for an AgentStart event + pub fn for_agent_start( + session_id: String, + cwd: String, + agent_id: Option, + agent_type: Option, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "AgentStart".to_string(), + agent_id, + agent_type, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for an AgentEnd event + pub fn for_agent_end(session_id: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "AgentEnd".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a SubagentStart event + pub fn for_subagent_start( + session_id: String, + agent_id: Option, + agent_type: Option, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "SubagentStart".to_string(), + agent_id, + agent_type, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a SubagentStop event + pub fn for_subagent_stop( + session_id: String, + agent_id: Option, + agent_type: Option, + ) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd: String::new(), + hook_event_name: "SubagentStop".to_string(), + agent_id, + agent_type, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a SessionUpdated event + pub fn for_session_updated(session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "SessionUpdated".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a SessionDiff event + pub fn for_session_diff(session_id: String, cwd: String, file_path: Option) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "SessionDiff".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path, + stop_type: None, + } + } + + /// Create a HookContext for a SessionError event + pub fn for_session_error(session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "SessionError".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Create a HookContext for a SessionIdle event + pub fn for_session_idle(session_id: String, cwd: String) -> Self { + Self { + session_id, + transcript_path: String::new(), + cwd, + hook_event_name: "SessionIdle".to_string(), + agent_id: None, + agent_type: None, + tool_name: None, + tool_input: None, + tool_use_id: None, + permission_mode: None, + model: None, + prompt: None, + system_prompt: None, + current_size_bytes: None, + task_id: None, + file_path: None, + stop_type: None, + } + } + + /// Build a MatcherContext for use with the hook matcher + /// + /// Uses tool_name as the primary target for pattern matching. + /// If additional context text is needed (e.g., full command for Bash), + /// use `with_context()` instead. + pub fn matcher_context(&self) -> MatcherContext<'_> { + MatcherContext::new(self.tool_name.as_deref().unwrap_or("")) + } + + /// Build a MatcherContext with additional context text + pub fn matcher_context_with_context<'a>(&'a self, context: &'a str) -> MatcherContext<'a> { + MatcherContext::with_context(self.tool_name.as_deref().unwrap_or(""), context) + } +} + +/// Registry of hooks organized by event type. +/// +/// Provides lookup of hooks by event type and filtering by matcher pattern. +#[derive(Debug, Clone)] +pub struct HookRegistry { + hooks: HashMap>, +} + +impl HookRegistry { + /// Create a new empty registry + pub fn new() -> Self { + Self { + hooks: HashMap::new(), + } + } + + /// Create a registry from a HooksConfig + /// + /// Converts the flat config entries into event-keyed vectors. + pub fn from_config(config: HooksConfig) -> Self { + let mut registry = Self::new(); + + // HooksConfig.events maps event names to a Vec of handler configs + for (event_name, handlers) in config.events.into_iter() { + // Parse the event name to get the HookEvent enum value + let event = if let Some(event) = HookEvent::parse(&event_name) { + event + } else { + HookEvent::Custom(event_name) + }; + registry.hooks.entry(event).or_default().extend(handlers); + } + + registry + } + + /// Get all hooks for a specific event type + pub fn get_hooks(&self, event: &HookEvent) -> &[HookHandlerConfig] { + self.hooks.get(event).map(|v| v.as_slice()).unwrap_or(&[]) + } + + /// Get hooks matching the given event and context criteria. + /// + /// Returns handlers whose matcher (if any) matches the tool_name + /// in the provided context. All 4 matcher types are supported: + /// - Exact: matches a single tool name exactly + /// - Multi: matches any of several tool names + /// - Regex: matches tool name via regex pattern + /// - Wildcard: matches any tool name + pub fn get_matching( + &self, + event: &HookEvent, + context: &HookContext, + ) -> Vec<&HookHandlerConfig> { + self.get_hooks(event) + .iter() + .filter(|handler| { + // Skip handlers that have an `if_` condition that evaluates to false + if let Some(condition) = self.get_handler_condition(handler) { + if !self.evaluate_condition(condition, context) { + return false; + } + } + + // Get the matcher for this handler + if let Some(matcher) = self.get_handler_matcher(handler) { + // Build matcher context - include command for regex matching + let ctx = context.matcher_context(); + matches(matcher, &ctx) + } else { + // No matcher means wildcard - always match + true + } + }) + .collect() + } + + /// Get the matcher from a handler configuration + /// + fn get_handler_matcher<'a>(&self, handler: &'a HookHandlerConfig) -> Option<&'a HookMatcher> { + match handler { + HookHandlerConfig::Command(cmd) => cmd.matcher.as_ref(), + HookHandlerConfig::Http(http) => http.matcher.as_ref(), + HookHandlerConfig::Agent(agent) => agent.matcher.as_ref(), + HookHandlerConfig::Plugin(plugin) => plugin.matcher.as_ref(), + } + } + + /// Get the condition (`if_`) from a handler configuration + fn get_handler_condition<'a>(&self, handler: &'a HookHandlerConfig) -> Option<&'a str> { + match handler { + HookHandlerConfig::Command(cmd) => cmd.if_.as_deref(), + HookHandlerConfig::Http(http) => http.if_.as_deref(), + HookHandlerConfig::Agent(agent) => agent.if_.as_deref(), + HookHandlerConfig::Plugin(plugin) => plugin.if_.as_deref(), + } + } + + /// Evaluate a condition against the context + /// + /// Conditions are shell-like expressions that can check context fields. + fn evaluate_condition(&self, condition: &str, context: &HookContext) -> bool { + // Simple condition evaluation + // Format: "field=value" or "field!=value" + if let Some((field, value)) = condition.split_once('=') { + let field = field.trim(); + let value = value.trim(); + match field { + "tool_name" => context.tool_name.as_deref() == Some(value), + "agent_type" => context.agent_type.as_deref() == Some(value), + "permission_mode" => context.permission_mode.as_deref() == Some(value), + _ => true, + } + } else if let Some((field, value)) = condition.split_once("!=") { + let field = field.trim(); + let value = value.trim(); + match field { + "tool_name" => context.tool_name.as_deref() != Some(value), + "agent_type" => context.agent_type.as_deref() != Some(value), + "permission_mode" => context.permission_mode.as_deref() != Some(value), + _ => true, + } + } else { + // Unknown condition format - allow by default + true + } + } + + /// Check if the registry is empty (no hooks registered) + pub fn is_empty(&self) -> bool { + self.hooks.is_empty() || self.hooks.values().all(Vec::is_empty) + } +} + +impl Default for HookRegistry { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::CommandHandlerConfig; + + #[test] + fn test_new_registry_is_empty() { + let registry = HookRegistry::new(); + assert!(registry.is_empty()); + } + + #[test] + fn test_from_empty_config() { + let config = HooksConfig::default(); + let registry = HookRegistry::from_config(config); + assert!(registry.is_empty()); + } + + #[test] + fn test_get_hooks_returns_empty_for_unknown_event() { + let registry = HookRegistry::new(); + let hooks = registry.get_hooks(&HookEvent::PreToolUse); + assert!(hooks.is_empty()); + } + + #[test] + fn test_from_config_with_single_event() { + let mut config = HooksConfig::default(); + config.events.insert( + "pre_tool_use".to_string(), + vec![HookHandlerConfig::Command(CommandHandlerConfig { + command: "test_command".to_string(), + ..Default::default() + })], + ); + + let registry = HookRegistry::from_config(config); + let hooks = registry.get_hooks(&HookEvent::PreToolUse); + assert_eq!(hooks.len(), 1); + assert!( + matches!(&hooks[0], HookHandlerConfig::Command(cmd) if cmd.command == "test_command") + ); + } + + #[test] + fn test_from_config_with_custom_event() { + let mut config = HooksConfig::default(); + config.events.insert( + "custom:my_event".to_string(), + vec![HookHandlerConfig::Command(CommandHandlerConfig { + command: "custom_handler".to_string(), + ..Default::default() + })], + ); + + let registry = HookRegistry::from_config(config); + let hooks = registry.get_hooks(&HookEvent::Custom("my_event".to_string())); + assert_eq!(hooks.len(), 1); + } + + #[test] + fn test_hook_context_for_tool() { + let context = HookContext::for_tool( + "Bash".to_string(), + "session-123".to_string(), + "/project".to_string(), + ); + + assert_eq!(context.session_id, "session-123"); + assert_eq!(context.cwd, "/project"); + assert_eq!(context.hook_event_name, "PreToolUse"); + assert_eq!(context.tool_name, Some("Bash".to_string())); + } + + #[test] + fn test_hook_context_matcher_context() { + let context = HookContext::for_tool( + "Bash".to_string(), + "session-123".to_string(), + "/project".to_string(), + ); + + let ctx = context.matcher_context(); + assert_eq!(ctx.target, "Bash"); + assert!(ctx.context.is_none()); + } + + #[test] + fn test_hook_context_matcher_context_with_context() { + let context = HookContext::for_tool( + "Bash".to_string(), + "session-123".to_string(), + "/project".to_string(), + ); + + let ctx = context.matcher_context_with_context("git commit -m 'test'"); + assert_eq!(ctx.target, "Bash"); + assert_eq!(ctx.context, Some("git commit -m 'test'")); + } + + #[test] + fn test_get_matching_returns_all_for_wildcard() { + let mut config = HooksConfig::default(); + config.events.insert( + "pre_tool_use".to_string(), + vec![HookHandlerConfig::Command(CommandHandlerConfig { + command: "test_command".to_string(), + ..Default::default() + })], + ); + + let registry = HookRegistry::from_config(config); + let context = HookContext::for_tool( + "Bash".to_string(), + "session-123".to_string(), + "/project".to_string(), + ); + + // Should return 1 handler (matches all since no matcher) + let matching = registry.get_matching(&HookEvent::PreToolUse, &context); + assert_eq!(matching.len(), 1); + } + + #[test] + fn test_get_matching_filters_by_event() { + let mut config = HooksConfig::default(); + config.events.insert( + "post_tool_use".to_string(), + vec![HookHandlerConfig::Command(CommandHandlerConfig { + command: "post_handler".to_string(), + ..Default::default() + })], + ); + + let registry = HookRegistry::from_config(config); + let context = HookContext::for_tool( + "Bash".to_string(), + "session-123".to_string(), + "/project".to_string(), + ); + + // Should return empty for pre_tool_use (only post_tool_use configured) + let matching = registry.get_matching(&HookEvent::PreToolUse, &context); + assert!(matching.is_empty()); + + // Should return 1 for post_tool_use + let matching = registry.get_matching(&HookEvent::PostToolUse, &context); + assert_eq!(matching.len(), 1); + } + + #[test] + fn test_from_config_with_multiple_handlers_per_event() { + let mut config = HooksConfig::default(); + config.events.insert( + "pre_tool_use".to_string(), + vec![ + HookHandlerConfig::Command(CommandHandlerConfig { + command: "first".to_string(), + ..Default::default() + }), + HookHandlerConfig::Command(CommandHandlerConfig { + command: "second".to_string(), + ..Default::default() + }), + ], + ); + + let registry = HookRegistry::from_config(config); + let hooks = registry.get_hooks(&HookEvent::PreToolUse); + assert_eq!(hooks.len(), 2); + } + + #[test] + fn test_new_context_fields_default_to_none() { + let context = HookContext::new("s1", "/t", "/cwd", "Test"); + assert!(context.model.is_none()); + assert!(context.prompt.is_none()); + assert!(context.system_prompt.is_none()); + assert!(context.current_size_bytes.is_none()); + assert!(context.task_id.is_none()); + assert!(context.file_path.is_none()); + assert!(context.stop_type.is_none()); + } + + #[test] + fn test_for_pre_compact_sets_size() { + let context = HookContext::for_pre_compact("s1".to_string(), "/cwd".to_string(), 1024); + assert_eq!(context.hook_event_name, "PreCompact"); + assert_eq!(context.current_size_bytes, Some(1024)); + } + + #[test] + fn test_for_stop_sets_stop_type() { + let context = HookContext::for_stop( + "s1".to_string(), + "/cwd".to_string(), + Some("end_turn".to_string()), + ); + assert_eq!(context.hook_event_name, "Stop"); + assert_eq!(context.stop_type, Some("end_turn".to_string())); + } + + #[test] + fn test_for_agent_start_sets_agent_fields() { + let context = HookContext::for_agent_start( + "s1".to_string(), + "/cwd".to_string(), + Some("agent-1".to_string()), + Some("coder".to_string()), + ); + assert_eq!(context.hook_event_name, "AgentStart"); + assert_eq!(context.agent_id, Some("agent-1".to_string())); + assert_eq!(context.agent_type, Some("coder".to_string())); + } + + #[test] + fn test_agent_handler_matcher_and_condition() { + use crate::config::AgentHandlerConfig; + + let mut config = HooksConfig::default(); + config.events.insert( + "agent_start".to_string(), + vec![HookHandlerConfig::Agent(AgentHandlerConfig { + agent_id: "my_agent".to_string(), + matcher: Some(HookMatcher::Exact("coder".to_string())), + if_: Some("agent_type=coder".to_string()), + ..Default::default() + })], + ); + + let registry = HookRegistry::from_config(config); + let context = HookContext::for_agent_start( + "s1".to_string(), + "/cwd".to_string(), + None, + Some("coder".to_string()), + ); + + // The matcher checks tool_name which is None, so it won't match "coder" + // But the condition checks agent_type=coder which matches + // Since matcher doesn't match (tool_name is None != "coder"), result is empty + let matching = registry.get_matching(&HookEvent::AgentStart, &context); + assert!(matching.is_empty()); + } + + #[test] + fn test_plugin_handler_in_registry() { + use crate::config::PluginHandlerConfig; + + let mut config = HooksConfig::default(); + config.events.insert( + "pre_tool_use".to_string(), + vec![HookHandlerConfig::Plugin(PluginHandlerConfig { + path: "/usr/bin/plugin".to_string(), + ..Default::default() + })], + ); + + let registry = HookRegistry::from_config(config); + let hooks = registry.get_hooks(&HookEvent::PreToolUse); + assert_eq!(hooks.len(), 1); + assert!(matches!(&hooks[0], HookHandlerConfig::Plugin(p) if p.path == "/usr/bin/plugin")); + } +} diff --git a/crates/jcode-hooks/src/tests.rs b/crates/jcode-hooks/src/tests.rs new file mode 100644 index 000000000..4ba6ca03b --- /dev/null +++ b/crates/jcode-hooks/src/tests.rs @@ -0,0 +1,1019 @@ +//! Comprehensive integration and unit tests for the jcode-hooks crate. +//! +//! Covers the full surface area: event parsing (28+1 variants), blocking +//! semantics, config merge, TOML round-trip, dispatch engine, kill-switch, +//! builder pattern, output serialization, and matcher logic. + +use crate::config::{ + load_hooks_config, parse_matcher_pattern, CommandHandlerConfig, HookEvent, HookHandlerConfig, + HooksConfig, HttpHandlerConfig, +}; +use crate::dispatch::{ + aggregate_decision, classify_decision, dispatch_hooks, ClassifiedOutcome, DispatchConfig, + DispatchStats, +}; +use crate::matcher::{matches, HookMatcher, MatcherContext}; +use crate::types::{ + AggregatedDecision, HookInput, HookInputBuilder, HookOutput, HookResult, ALL_EVENT_NAMES, +}; + +// =========================================================================== +// test_hook_event_parse_all_variants (28 standard + 1 Custom) +// =========================================================================== + +#[test] +fn test_hook_event_parse_all_variants() { + // 28 standard variants via PascalCase + let standard_cases: Vec<(&str, HookEvent)> = vec![ + ("PreToolUse", HookEvent::PreToolUse), + ("PostToolUse", HookEvent::PostToolUse), + ("PostToolUseFailure", HookEvent::PostToolUseFailure), + ("ToolError", HookEvent::ToolError), + ("UserPromptSubmit", HookEvent::UserPromptSubmit), + ("UserPromptExpansion", HookEvent::UserPromptExpansion), + ("SessionStart", HookEvent::SessionStart), + ("SessionEnd", HookEvent::SessionEnd), + ("SessionUpdated", HookEvent::SessionUpdated), + ("SessionDiff", HookEvent::SessionDiff), + ("SessionError", HookEvent::SessionError), + ("SessionIdle", HookEvent::SessionIdle), + ("PermissionRequest", HookEvent::PermissionRequest), + ("PermissionDenied", HookEvent::PermissionDenied), + ("PermissionAsked", HookEvent::PermissionAsked), + ("PermissionReplied", HookEvent::PermissionReplied), + ("AgentStart", HookEvent::AgentStart), + ("AgentEnd", HookEvent::AgentEnd), + ("SubagentStart", HookEvent::SubagentStart), + ("SubagentStop", HookEvent::SubagentStop), + ("Stop", HookEvent::Stop), + ("PreCompact", HookEvent::PreCompact), + ("PostCompact", HookEvent::PostCompact), + ("AutoCompactionControl", HookEvent::AutoCompactionControl), + ("TaskCreated", HookEvent::TaskCreated), + ("TaskCompleted", HookEvent::TaskCompleted), + ("Setup", HookEvent::Setup), + ("FileChanged", HookEvent::FileChanged), + ]; + + assert_eq!( + standard_cases.len(), + 28, + "must have exactly 28 standard variants" + ); + assert_eq!(ALL_EVENT_NAMES.len(), 28); + + for (input, expected) in &standard_cases { + let parsed = HookEvent::parse(input); + assert_eq!( + parsed.as_ref(), + Some(expected), + "PascalCase parse failed for '{}'", + input + ); + } + + // snake_case round-trip + for (pascal, expected) in &standard_cases { + let snake = pascal + .chars() + .flat_map(|c| { + if c.is_uppercase() { + vec!['_', c.to_ascii_lowercase()] + } else { + vec![c] + } + }) + .collect::(); + let snake = snake.trim_start_matches('_'); + assert_eq!( + HookEvent::parse(snake), + Some(expected.clone()), + "snake_case parse failed for '{}'", + snake + ); + } + + // kebab-case round-trip + for (pascal, expected) in &standard_cases { + let kebab = pascal + .chars() + .flat_map(|c| { + if c.is_uppercase() { + vec!['-', c.to_ascii_lowercase()] + } else { + vec![c] + } + }) + .collect::(); + let kebab = kebab.trim_start_matches('-'); + assert_eq!( + HookEvent::parse(kebab), + Some(expected.clone()), + "kebab-case parse failed for '{}'", + kebab + ); + } + + // Case-insensitive variations + assert_eq!(HookEvent::parse("PRETOOLUSE"), Some(HookEvent::PreToolUse)); + assert_eq!(HookEvent::parse("pretooluse"), Some(HookEvent::PreToolUse)); + assert_eq!( + HookEvent::parse("Pre Tool Use"), + Some(HookEvent::PreToolUse) + ); + + // Custom variant: custom: prefix + assert_eq!( + HookEvent::parse("custom:my_event"), + Some(HookEvent::Custom("my_event".to_string())) + ); + assert_eq!( + HookEvent::parse("Custom:my-event"), + Some(HookEvent::Custom("my-event".to_string())) + ); + assert_eq!( + HookEvent::parse("CUSTOM:foo"), + Some(HookEvent::Custom("foo".to_string())) + ); + assert_eq!( + HookEvent::parse("custom"), + Some(HookEvent::Custom(String::new())) + ); + + // Empty / unknown returns None + assert_eq!(HookEvent::parse(""), None); + assert_eq!(HookEvent::parse(" "), None); + assert_eq!(HookEvent::parse("NoSuchEvent"), None); +} + +// =========================================================================== +// test_hook_event_is_blocking +// =========================================================================== + +#[test] +fn test_hook_event_is_blocking() { + // Events that ARE blocking + let blocking_events = [ + HookEvent::PreToolUse, + HookEvent::UserPromptSubmit, + HookEvent::PermissionRequest, + HookEvent::PermissionAsked, + HookEvent::AgentStart, + HookEvent::Stop, + HookEvent::PreCompact, + ]; + for ev in &blocking_events { + assert!(ev.is_blocking(), "{:?} should be blocking", ev); + } + + // Events that are NOT blocking (exhaustive list of all remaining standard variants) + let non_blocking_events = [ + HookEvent::PostToolUse, + HookEvent::PostToolUseFailure, + HookEvent::ToolError, + HookEvent::UserPromptExpansion, + HookEvent::SessionStart, + HookEvent::SessionEnd, + HookEvent::SessionUpdated, + HookEvent::SessionDiff, + HookEvent::SessionError, + HookEvent::SessionIdle, + HookEvent::PermissionDenied, + HookEvent::PermissionReplied, + HookEvent::AgentEnd, + HookEvent::SubagentStart, + HookEvent::SubagentStop, + HookEvent::PostCompact, + HookEvent::AutoCompactionControl, + HookEvent::TaskCreated, + HookEvent::TaskCompleted, + HookEvent::Setup, + HookEvent::FileChanged, + HookEvent::Custom("anything".to_string()), + ]; + for ev in &non_blocking_events { + assert!(!ev.is_blocking(), "{:?} should NOT be blocking", ev); + } + + // All 28 standard accounted for: 7 blocking + 21 non-blocking = 28 + assert_eq!(blocking_events.len() + non_blocking_events.len() - 1, 28); +} + +// =========================================================================== +// test_hooks_config_merge_appends_handlers +// =========================================================================== + +#[test] +fn test_hooks_config_merge_appends_handlers() { + // Base config: one handler on PreToolUse, one on SessionStart + let mut base = HooksConfig::default(); + base.events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "base_hook_a".to_string(), + ..Default::default() + })); + base.settings.timeout_secs = 10; + + // Other config: one handler on PreToolUse (same event), one on SessionEnd (new event) + let mut other = HooksConfig::default(); + other + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "other_hook_b".to_string(), + ..Default::default() + })); + other + .events + .entry("SessionEnd".to_string()) + .or_default() + .push(HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://localhost/end".to_string(), + ..Default::default() + })); + other.settings.timeout_secs = 60; + other.settings.dry_run = true; + + base.merge(other); + + // Handlers are appended for existing events + assert_eq!(base.events["PreToolUse"].len(), 2); + match &base.events["PreToolUse"][0] { + HookHandlerConfig::Command(cmd) => assert_eq!(cmd.command, "base_hook_a"), + _ => panic!("expected Command"), + } + match &base.events["PreToolUse"][1] { + HookHandlerConfig::Command(cmd) => assert_eq!(cmd.command, "other_hook_b"), + _ => panic!("expected Command"), + } + + // New event key is added + assert!(base.events.contains_key("SessionEnd")); + assert_eq!(base.events["SessionEnd"].len(), 1); + + // Settings are overridden (not merged) + assert_eq!(base.settings.timeout_secs, 60); + assert!(base.settings.dry_run); +} + +// =========================================================================== +// test_toml_round_trip +// =========================================================================== + +#[test] +fn test_toml_round_trip() { + let toml_str = r#" +[settings] +timeout_secs = 45 +max_concurrency = 8 +dry_run = true +fail_closed = false + +[[events.PreToolUse]] +type = "command" +command = "check_security.sh" +enabled = true +timeout_secs = 10 +matcher = "Bash|Write" + +[[events.PreToolUse]] +type = "http" +url = "http://localhost:9090/hooks" +method = "POST" +timeout_secs = 5 + +[[events.SessionStart]] +type = "command" +command = "init_session.sh" +enabled = true + +[[events.Stop]] +type = "command" +command = "cleanup.sh" +matcher = "/^Bash/" +"#; + + // Parse from TOML + let config: HooksConfig = toml::from_str(toml_str).unwrap(); + + // Verify settings + assert_eq!(config.settings.timeout_secs, 45); + assert_eq!(config.settings.max_concurrency, 8); + assert!(config.settings.dry_run); + assert!(!config.settings.fail_closed); + + // Verify events + assert_eq!(config.events.len(), 3); + assert_eq!(config.events["PreToolUse"].len(), 2); + assert_eq!(config.events["SessionStart"].len(), 1); + assert_eq!(config.events["Stop"].len(), 1); + + // Verify first PreToolUse handler + match &config.events["PreToolUse"][0] { + HookHandlerConfig::Command(cmd) => { + assert_eq!(cmd.command, "check_security.sh"); + assert!(cmd.enabled); + assert_eq!(cmd.timeout_secs, Some(10)); + assert_eq!( + cmd.matcher, + Some(HookMatcher::Multi(vec![ + "Bash".to_string(), + "Write".to_string() + ])) + ); + } + _ => panic!("expected Command"), + } + + // Verify second PreToolUse handler + match &config.events["PreToolUse"][1] { + HookHandlerConfig::Http(http) => { + assert_eq!(http.url, "http://localhost:9090/hooks"); + assert_eq!(http.method, "POST"); + assert_eq!(http.timeout_secs, Some(5)); + } + _ => panic!("expected Http"), + } + + // Verify Stop handler has regex matcher + match &config.events["Stop"][0] { + HookHandlerConfig::Command(cmd) => { + assert_eq!(cmd.matcher, Some(HookMatcher::Regex("^Bash".to_string()))); + } + _ => panic!("expected Command"), + } + + // Serialize back to TOML and re-parse (round-trip stability) + let serialized = toml::to_string(&config).unwrap(); + let reparsed: HooksConfig = toml::from_str(&serialized).unwrap(); + assert_eq!(reparsed.settings.timeout_secs, 45); + assert_eq!(reparsed.events.len(), 3); + assert_eq!(reparsed.events["PreToolUse"].len(), 2); +} + +// =========================================================================== +// test_dispatch_empty_handlers +// =========================================================================== + +#[tokio::test] +async fn test_dispatch_empty_handlers() { + let config = DispatchConfig::default(); + let input = HookInput::default(); + let handlers: Vec<&HookHandlerConfig> = vec![]; + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &handlers, &config).await; + + assert_eq!(stats.total_dispatched, 0); + assert_eq!(stats.completed, 0); + assert_eq!(stats.failed, 0); + assert_eq!(stats.allowed, 0); + assert_eq!(stats.denied, 0); + assert_eq!(stats.asked, 0); + assert!(stats.results.is_empty()); + assert!(stats.all_succeeded()); + assert!(!stats.any_denied()); +} + +// =========================================================================== +// test_dispatch_single_continue +// =========================================================================== + +#[tokio::test] +async fn test_dispatch_single_continue() { + let config = DispatchConfig { + dry_run: true, + ..Default::default() + }; + let input = HookInput::default(); + let handlers: Vec = vec![HookHandlerConfig::Command(CommandHandlerConfig { + command: "echo ok".to_string(), + ..Default::default() + })]; + let refs: Vec<&HookHandlerConfig> = handlers.iter().collect(); + + let stats = dispatch_hooks(&HookEvent::PostToolUse, &input, &refs, &config).await; + + assert_eq!(stats.total_dispatched, 1); + assert_eq!(stats.allowed, 1); + assert_eq!(stats.failed, 0); + assert_eq!(stats.denied, 0); + assert!(stats.all_succeeded()); +} + +// =========================================================================== +// test_dispatch_deny_wins (aggregate_decision: deny > ask > allow) +// =========================================================================== + +#[test] +fn test_dispatch_deny_wins() { + // Mixed outcomes: allow + ask + deny -- deny should win + let outcomes = vec![ + ClassifiedOutcome::Allow, + ClassifiedOutcome::Ask { + reason: "need approval".to_string(), + }, + ClassifiedOutcome::Deny { + reason: "blocked by policy".to_string(), + }, + ClassifiedOutcome::Allow, + ]; + let decision = aggregate_decision(&outcomes, false); + match decision { + AggregatedDecision::Deny { reason, .. } => { + assert_eq!(reason, "blocked by policy"); + } + _ => panic!("expected Deny, got {:?}", format!("{:?}", decision)), + } + + // Only allow + ask: ask wins + let outcomes = vec![ + ClassifiedOutcome::Allow, + ClassifiedOutcome::Ask { + reason: "review".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, false); + assert!(matches!(decision, AggregatedDecision::Ask { .. })); + + // Only allow: allow wins + let outcomes = vec![ClassifiedOutcome::Allow, ClassifiedOutcome::Allow]; + let decision = aggregate_decision(&outcomes, false); + assert!(matches!(decision, AggregatedDecision::Allow)); + + // Empty: allow + let decision = aggregate_decision(&[], false); + assert!(matches!(decision, AggregatedDecision::Allow)); + + // Failure in fail-open mode is ignored + let outcomes = vec![ + ClassifiedOutcome::Allow, + ClassifiedOutcome::Failed { + error: "crash".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, false); + assert!(matches!(decision, AggregatedDecision::Allow)); + + // Failure in fail-closed mode becomes deny + let outcomes = vec![ + ClassifiedOutcome::Allow, + ClassifiedOutcome::Failed { + error: "crash".to_string(), + }, + ]; + let decision = aggregate_decision(&outcomes, true); + assert!(matches!(decision, AggregatedDecision::Deny { .. })); +} + +// =========================================================================== +// test_dispatch_disabled_skip +// =========================================================================== + +#[tokio::test] +async fn test_dispatch_disabled_skip() { + // A disabled command handler should be executed as continue (via execute_single_hook) + // but in dry-run mode we get Allow. The important thing is that it does not fail. + let config = DispatchConfig { + dry_run: true, + ..Default::default() + }; + let input = HookInput::default(); + let handlers: Vec = vec![ + HookHandlerConfig::Command(CommandHandlerConfig { + enabled: false, + command: "should_not_run".to_string(), + ..Default::default() + }), + HookHandlerConfig::Command(CommandHandlerConfig { + enabled: true, + command: "should_run".to_string(), + ..Default::default() + }), + ]; + let refs: Vec<&HookHandlerConfig> = handlers.iter().collect(); + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &refs, &config).await; + + assert_eq!(stats.total_dispatched, 2); + assert_eq!(stats.allowed, 2); + assert_eq!(stats.failed, 0); +} + +// =========================================================================== +// test_dispatch_timeout +// =========================================================================== + +#[tokio::test] +async fn test_dispatch_timeout() { + // A handler with a very short timeout that takes too long should fail/timeout. + // Using a real command that sleeps. + let config = DispatchConfig { + max_concurrency: 1, + timeout_secs: 1, + fail_closed: false, + dry_run: false, + }; + let input = HookInput::default(); + let handlers: Vec = vec![HookHandlerConfig::Command(CommandHandlerConfig { + enabled: true, + command: "sleep 10".to_string(), + timeout_secs: Some(1), + ..Default::default() + })]; + let refs: Vec<&HookHandlerConfig> = handlers.iter().collect(); + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &refs, &config).await; + + assert_eq!(stats.total_dispatched, 1); + assert_eq!(stats.failed, 1); + assert_eq!(stats.timed_out, 1); + assert!(!stats.all_succeeded()); + + // Verify the result contains a timeout error + assert_eq!(stats.results.len(), 1); + if let ClassifiedOutcome::Failed { error } = &stats.results[0].outcome { + assert!(error.contains("timed out"), "error was: {}", error); + } else { + panic!( + "expected Failed outcome, got {:?}", + &stats.results[0].outcome + ); + } +} + +// =========================================================================== +// test_kill_switch (DISABLE_JCODE_HOOKS) +// =========================================================================== + +#[test] +fn test_kill_switch() { + // Set the kill-switch env var + std::env::set_var("DISABLE_JCODE_HOOKS", "1"); + + let config = load_hooks_config(); + + // Should return empty/default config + assert!(config.is_empty()); + assert_eq!(config.settings.timeout_secs, 30); // default + assert!(!config.settings.dry_run); + + // Clean up + std::env::remove_var("DISABLE_JCODE_HOOKS"); +} + +// =========================================================================== +// test_hook_input_builder +// =========================================================================== + +#[test] +fn test_hook_input_builder() { + let input = HookInputBuilder::new() + .session("ses_builder", "/workspace/project") + .event("PreToolUse") + .agent("agent_42", "coder") + .tool( + "Bash", + serde_json::json!({"command": "cargo test"}), + "tool_use_7", + ) + .tool_output(serde_json::json!({"stdout": "all tests passed"})) + .duration(3500) + .build(); + + assert_eq!(input.schema_version, "2.0"); + assert_eq!(input.session_id, "ses_builder"); + assert_eq!(input.cwd, "/workspace/project"); + assert_eq!(input.hook_event_name, "PreToolUse"); + assert_eq!(input.agent_id, Some("agent_42".to_string())); + assert_eq!(input.agent_type, Some("coder".to_string())); + assert_eq!(input.tool_name, Some("Bash".to_string())); + assert_eq!(input.tool_use_id, Some("tool_use_7".to_string())); + assert!(input.tool_input.is_some()); + assert!(input.tool_output.is_some()); + assert_eq!(input.duration_ms, Some(3500)); + + // Verify serialization round-trip + let json = serde_json::to_string_pretty(&input).unwrap(); + let parsed: HookInput = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.session_id, "ses_builder"); + assert_eq!(parsed.tool_name, Some("Bash".to_string())); + assert_eq!(parsed.agent_id, Some("agent_42".to_string())); +} + +#[test] +fn test_hook_input_builder_permission() { + let input = HookInputBuilder::new() + .session("ses_perm", "/project") + .event("PermissionRequest") + .permission("auto", "req_001", "Execute bash command") + .build(); + + assert_eq!(input.permission_mode, Some("auto".to_string())); + assert_eq!(input.request_id, Some("req_001".to_string())); + assert_eq!( + input.action_description, + Some("Execute bash command".to_string()) + ); +} + +#[test] +fn test_hook_input_builder_error_and_prompt() { + let input = HookInputBuilder::new() + .session("ses_err", "/project") + .event("ToolError") + .error("command not found", 127) + .build(); + assert_eq!(input.error, Some("command not found".to_string())); + assert_eq!(input.error_code, Some(127)); + + let input = HookInputBuilder::new() + .session("ses_prompt", "/project") + .event("UserPromptSubmit") + .prompt("fix the bug in main.rs") + .build(); + assert_eq!( + input.prompt_text, + Some("fix the bug in main.rs".to_string()) + ); +} + +// =========================================================================== +// test_hook_output_serialization +// =========================================================================== + +#[test] +fn test_hook_output_serialization() { + // continue_ (default) + let output = HookOutput::continue_(); + let json = serde_json::to_string(&output).unwrap(); + let parsed: HookOutput = serde_json::from_str(&json).unwrap(); + assert!(parsed.continue_); + assert!(parsed.stop_reason.is_none()); + assert!(parsed.decision.is_none()); + + // block + let output = HookOutput::block("Dangerous command"); + let json = serde_json::to_string(&output).unwrap(); + let parsed: HookOutput = serde_json::from_str(&json).unwrap(); + assert!(!parsed.continue_); + assert_eq!(parsed.stop_reason.as_deref(), Some("Dangerous command")); + assert_eq!(parsed.decision.as_deref(), Some("deny")); + + // ask + let output = HookOutput::ask("Need approval"); + let json = serde_json::to_string(&output).unwrap(); + let parsed: HookOutput = serde_json::from_str(&json).unwrap(); + assert!(!parsed.continue_); + assert_eq!(parsed.decision.as_deref(), Some("ask")); + assert_eq!(parsed.reason.as_deref(), Some("Need approval")); + + // allow + let output = HookOutput::allow(); + let json = serde_json::to_string(&output).unwrap(); + let parsed: HookOutput = serde_json::from_str(&json).unwrap(); + assert!(parsed.continue_); + assert_eq!(parsed.decision.as_deref(), Some("allow")); + + // Empty JSON defaults to continue_ = true + let parsed: HookOutput = serde_json::from_str("{}").unwrap(); + assert!(parsed.continue_); + + // Explicit false + let parsed: HookOutput = + serde_json::from_str(r#"{"continue_": false, "stop_reason": "nope"}"#).unwrap(); + assert!(!parsed.continue_); + assert_eq!(parsed.stop_reason.as_deref(), Some("nope")); + + // skip_serializing_if: None fields should be omitted + let output = HookOutput::continue_(); + let json = serde_json::to_string(&output).unwrap(); + assert!(!json.contains("suppress_output")); + assert!(!json.contains("stop_reason")); + assert!(!json.contains("decision")); + assert!(!json.contains("system_message")); +} + +// =========================================================================== +// test_matcher_exact +// =========================================================================== + +#[test] +fn test_matcher_exact() { + let matcher = HookMatcher::Exact("Bash".to_string()); + + assert!(matches(&matcher, &MatcherContext::new("Bash"))); + assert!(!matches(&matcher, &MatcherContext::new("Write"))); + assert!(!matches(&matcher, &MatcherContext::new("bash"))); // case-sensitive + assert!(!matches(&matcher, &MatcherContext::new("Bashx"))); +} + +// =========================================================================== +// test_matcher_multi +// =========================================================================== + +#[test] +fn test_matcher_multi() { + let matcher = HookMatcher::Multi(vec![ + "Bash".to_string(), + "Write".to_string(), + "Edit".to_string(), + ]); + + assert!(matches(&matcher, &MatcherContext::new("Bash"))); + assert!(matches(&matcher, &MatcherContext::new("Write"))); + assert!(matches(&matcher, &MatcherContext::new("Edit"))); + assert!(!matches(&matcher, &MatcherContext::new("Read"))); + assert!(!matches(&matcher, &MatcherContext::new("bash"))); // case-sensitive +} + +#[test] +fn test_matcher_multi_parse() { + let patterns = crate::matcher::parse_multi_pattern("Write|Edit|Glob"); + assert_eq!(patterns, vec!["Write", "Edit", "Glob"]); + + let patterns = crate::matcher::parse_multi_pattern("Single"); + assert_eq!(patterns, vec!["Single"]); +} + +// =========================================================================== +// test_matcher_regex +// =========================================================================== + +#[test] +fn test_matcher_regex() { + // Match against target only + let matcher = HookMatcher::Regex("^Ba".to_string()); + assert!(matches(&matcher, &MatcherContext::new("Bash"))); + assert!(!matches(&matcher, &MatcherContext::new("Write"))); + + // Match against target + context + let matcher = HookMatcher::Regex("^Bash(git.*)".to_string()); + assert!(matches( + &matcher, + &MatcherContext::with_context("Bash", "git commit -m test") + )); + assert!(!matches( + &matcher, + &MatcherContext::with_context("Bash", "ls -la") + )); + + // Invalid regex patterns use a never-match placeholder. + // Valid regexes like "^Bash" work normally. + let matcher = HookMatcher::Regex("^Bash".to_string()); + assert!(matches(&matcher, &MatcherContext::new("Bash tool"))); + assert!(!matches(&matcher, &MatcherContext::new("other"))); +} + +// =========================================================================== +// test_matcher_wildcard +// =========================================================================== + +#[test] +fn test_matcher_wildcard() { + let matcher = HookMatcher::Wildcard; + + assert!(matches(&matcher, &MatcherContext::new("Bash"))); + assert!(matches(&matcher, &MatcherContext::new("Write"))); + assert!(matches(&matcher, &MatcherContext::new("anything"))); + assert!(matches(&matcher, &MatcherContext::new(""))); +} + +// =========================================================================== +// Additional: parse_matcher_pattern (config-level pattern parsing) +// =========================================================================== + +#[test] +fn test_parse_matcher_pattern() { + assert_eq!(parse_matcher_pattern("*"), HookMatcher::Wildcard); + assert_eq!( + parse_matcher_pattern("Bash"), + HookMatcher::Exact("Bash".to_string()) + ); + assert_eq!( + parse_matcher_pattern("Bash|Write|Edit"), + HookMatcher::Multi(vec![ + "Bash".to_string(), + "Write".to_string(), + "Edit".to_string() + ]) + ); + assert_eq!( + parse_matcher_pattern("/^Bash/"), + HookMatcher::Regex("^Bash".to_string()) + ); + assert_eq!(parse_matcher_pattern(" * "), HookMatcher::Wildcard); // trimmed +} + +// =========================================================================== +// Additional: classify_decision +// =========================================================================== + +#[test] +fn test_classify_decision_variants() { + // Continue with explicit allow + let result = HookResult::Continue(HookOutput::allow()); + assert!(matches!( + classify_decision(&result), + ClassifiedOutcome::Allow + )); + + // Continue with no decision (default continue) + let result = HookResult::Continue(HookOutput::continue_()); + assert!(matches!( + classify_decision(&result), + ClassifiedOutcome::Allow + )); + + // Continue with ask decision + let result = HookResult::Continue(HookOutput::ask("review needed")); + if let ClassifiedOutcome::Ask { reason } = classify_decision(&result) { + assert_eq!(reason, "review needed"); + } else { + panic!("expected Ask"); + } + + // Continue with deny decision + let output = HookOutput { + continue_: false, + suppress_output: None, + stop_reason: Some("blocked".to_string()), + decision: Some("deny".to_string()), + reason: None, + system_message: None, + hook_specific_output: None, + }; + let result = HookResult::Continue(output); + if let ClassifiedOutcome::Deny { reason } = classify_decision(&result) { + assert_eq!(reason, "blocked"); + } else { + panic!("expected Deny"); + } + + // Blocked + let result = HookResult::Blocked { + reason: "not allowed".to_string(), + output: HookOutput::block("not allowed"), + }; + if let ClassifiedOutcome::Deny { reason } = classify_decision(&result) { + assert_eq!(reason, "not allowed"); + } else { + panic!("expected Deny"); + } + + // Failed + let result = HookResult::Failed { + error: "timeout".to_string(), + }; + if let ClassifiedOutcome::Failed { error } = classify_decision(&result) { + assert_eq!(error, "timeout"); + } else { + panic!("expected Failed"); + } +} + +// =========================================================================== +// Additional: DispatchStats helpers +// =========================================================================== + +#[test] +fn test_dispatch_stats_helpers() { + let stats = DispatchStats::default(); + assert!(stats.all_succeeded()); + assert!(!stats.any_denied()); + assert!(!stats.any_asked()); +} + +// =========================================================================== +// Additional: DispatchConfig from_settings +// =========================================================================== + +#[test] +fn test_dispatch_config_from_settings() { + use crate::config::HookSettings; + + let settings = HookSettings { + timeout_secs: 60, + max_concurrency: 5, + dry_run: true, + fail_closed: true, + }; + let cfg = DispatchConfig::from_settings(&settings); + assert_eq!(cfg.max_concurrency, 5); + assert_eq!(cfg.timeout_secs, 60); + assert!(cfg.dry_run); + assert!(cfg.fail_closed); +} + +// =========================================================================== +// Additional: HooksConfig::is_empty +// =========================================================================== + +#[test] +fn test_hooks_config_is_empty() { + let config = HooksConfig::default(); + assert!(config.is_empty()); + + let mut config = HooksConfig::default(); + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::default()); + assert!(!config.is_empty()); +} + +// =========================================================================== +// Additional: HookEvent display and serde round-trip +// =========================================================================== + +#[test] +fn test_hook_event_display_and_serde() { + assert_eq!(format!("{}", HookEvent::PreToolUse), "PreToolUse"); + assert_eq!(format!("{}", HookEvent::Stop), "Stop"); + assert_eq!(format!("{}", HookEvent::Custom("foo".to_string())), "foo"); + + // Serde round-trip for all standard variants + for ev in HookEvent::all_standard() { + let json = serde_json::to_string(&ev).unwrap(); + let deserialized: HookEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(ev, deserialized); + } + + // Custom variant serde round-trip + let custom = HookEvent::Custom("my_thing".to_string()); + let json = serde_json::to_string(&custom).unwrap(); + let deserialized: HookEvent = serde_json::from_str(&json).unwrap(); + assert_eq!(custom, deserialized); +} + +// =========================================================================== +// Additional: TOML event key alias (`event` vs `events`) +// =========================================================================== + +#[test] +fn test_toml_event_key_alias() { + let toml_str = r#" +[[event.PreToolUse]] +type = "command" +command = "legacy_handler.sh" +"#; + let config: HooksConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.events["PreToolUse"].len(), 1); +} + +// =========================================================================== +// Additional: Full dispatch integration (dry-run with multiple handlers) +// =========================================================================== + +#[tokio::test] +async fn test_dispatch_dry_run_multiple_handlers() { + let config = DispatchConfig { + dry_run: true, + ..Default::default() + }; + let input = HookInput::default(); + let handlers: Vec = vec![ + HookHandlerConfig::Command(CommandHandlerConfig { + command: "hook_a.sh".to_string(), + ..Default::default() + }), + HookHandlerConfig::Command(CommandHandlerConfig { + command: "hook_b.sh".to_string(), + ..Default::default() + }), + HookHandlerConfig::Command(CommandHandlerConfig { + command: "hook_c.sh".to_string(), + ..Default::default() + }), + ]; + let refs: Vec<&HookHandlerConfig> = handlers.iter().collect(); + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &refs, &config).await; + + assert_eq!(stats.total_dispatched, 3); + assert_eq!(stats.allowed, 3); + assert_eq!(stats.failed, 0); + assert!(stats.all_succeeded()); + assert_eq!(stats.results.len(), 3); +} + +// =========================================================================== +// Additional: HookInput default field verification +// =========================================================================== + +#[test] +fn test_hook_input_default() { + let input = HookInput::default(); + assert_eq!(input.schema_version, "2.0"); + assert!(input.session_id.is_empty()); + assert!(input.cwd.is_empty()); + assert!(input.hook_event_name.is_empty()); + assert!(input.tool_name.is_none()); + assert!(input.agent_id.is_none()); + assert!(input.permission_mode.is_none()); + assert!(input.prompt_text.is_none()); + assert!(input.error.is_none()); +} diff --git a/crates/jcode-hooks/src/types.rs b/crates/jcode-hooks/src/types.rs new file mode 100644 index 000000000..6080b996c --- /dev/null +++ b/crates/jcode-hooks/src/types.rs @@ -0,0 +1,993 @@ +//! Hook v2 types — Input/Output protocol, event constants, result enums, and metrics. +//! +//! This module defines the complete JSON contract between jcode and hook handlers. +//! Every hook receives a `HookInput` via stdin and returns a `HookOutput` via stdout. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// =========================================================================== +// EVENT NAME CONSTANTS (28 events) +// =========================================================================== + +/// Core tool events +pub const EVENT_PRE_TOOL_USE: &str = "PreToolUse"; +pub const EVENT_POST_TOOL_USE: &str = "PostToolUse"; +pub const EVENT_POST_TOOL_USE_FAILURE: &str = "PostToolUseFailure"; +pub const EVENT_TOOL_ERROR: &str = "ToolError"; +pub const EVENT_USER_PROMPT_SUBMIT: &str = "UserPromptSubmit"; +pub const EVENT_USER_PROMPT_EXPANSION: &str = "UserPromptExpansion"; + +/// Session lifecycle events +pub const EVENT_SESSION_START: &str = "SessionStart"; +pub const EVENT_SESSION_END: &str = "SessionEnd"; +pub const EVENT_SESSION_UPDATED: &str = "SessionUpdated"; +pub const EVENT_SESSION_DIFF: &str = "SessionDiff"; +pub const EVENT_SESSION_ERROR: &str = "SessionError"; +pub const EVENT_SESSION_IDLE: &str = "SessionIdle"; + +/// Permission events +pub const EVENT_PERMISSION_REQUEST: &str = "PermissionRequest"; +pub const EVENT_PERMISSION_DENIED: &str = "PermissionDenied"; +pub const EVENT_PERMISSION_ASKED: &str = "PermissionAsked"; +pub const EVENT_PERMISSION_REPLIED: &str = "PermissionReplied"; + +/// Agent and subagent events +pub const EVENT_AGENT_START: &str = "AgentStart"; +pub const EVENT_AGENT_END: &str = "AgentEnd"; +pub const EVENT_SUBAGENT_START: &str = "SubagentStart"; +pub const EVENT_SUBAGENT_STOP: &str = "SubagentStop"; + +/// Execution control events +pub const EVENT_STOP: &str = "Stop"; + +/// Compaction events +pub const EVENT_PRE_COMPACT: &str = "PreCompact"; +pub const EVENT_POST_COMPACT: &str = "PostCompact"; +pub const EVENT_AUTO_COMPACTION_CONTROL: &str = "AutoCompactionControl"; + +/// Task and environment events +pub const EVENT_SETUP: &str = "Setup"; +pub const EVENT_TASK_CREATED: &str = "TaskCreated"; +pub const EVENT_TASK_COMPLETED: &str = "TaskCompleted"; + +/// File events +pub const EVENT_FILE_CHANGED: &str = "FileChanged"; + +/// All known event names as a static slice for validation and iteration. +pub const ALL_EVENT_NAMES: &[&str] = &[ + EVENT_PRE_TOOL_USE, + EVENT_POST_TOOL_USE, + EVENT_POST_TOOL_USE_FAILURE, + EVENT_TOOL_ERROR, + EVENT_USER_PROMPT_SUBMIT, + EVENT_USER_PROMPT_EXPANSION, + EVENT_SESSION_START, + EVENT_SESSION_END, + EVENT_SESSION_UPDATED, + EVENT_SESSION_DIFF, + EVENT_SESSION_ERROR, + EVENT_SESSION_IDLE, + EVENT_PERMISSION_REQUEST, + EVENT_PERMISSION_DENIED, + EVENT_PERMISSION_ASKED, + EVENT_PERMISSION_REPLIED, + EVENT_AGENT_START, + EVENT_AGENT_END, + EVENT_SUBAGENT_START, + EVENT_SUBAGENT_STOP, + EVENT_STOP, + EVENT_PRE_COMPACT, + EVENT_POST_COMPACT, + EVENT_AUTO_COMPACTION_CONTROL, + EVENT_SETUP, + EVENT_TASK_CREATED, + EVENT_TASK_COMPLETED, + EVENT_FILE_CHANGED, +]; + +// =========================================================================== +// HOOK INPUT — Stdin JSON contract +// =========================================================================== + +/// Standard input passed to every hook via stdin JSON. +/// +/// All fields except the five required ones are `Option` to allow +/// event-specific subsets. Hooks receive only the fields relevant +/// to the triggering event; unused fields arrive as `null`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct HookInput { + // === Always present (required) === + /// Schema version — always "2.0" for the v2 hook protocol. + pub schema_version: String, + /// Unique session identifier. + pub session_id: String, + /// Current working directory at the time the event fired. + pub cwd: String, + /// The event name (e.g. "PreToolUse", "SessionStart"). + pub hook_event_name: String, + /// UTC timestamp when the event was generated. + pub timestamp: DateTime, + + // === Session info === + pub transcript_path: Option, + pub agent_id: Option, + pub agent_type: Option, + + // === Tool-related === + pub tool_name: Option, + pub tool_input: Option, + pub tool_output: Option, + pub tool_use_id: Option, + pub error: Option, + pub error_code: Option, + pub duration_ms: Option, + + // === Permission-related === + pub permission_mode: Option, + pub permission_decision: Option, + pub request_id: Option, + pub action_description: Option, + + // === User prompt === + pub prompt: Option, + pub prompt_text: Option, + pub files: Option>, + pub expanded_prompt: Option, + + // === Agent lifecycle === + pub model: Option, + pub system_prompt: Option, + pub agent_turns: Option, + pub total_cost: Option, + pub parent_agent_id: Option, + pub subagent_id: Option, + pub subagent_type: Option, + + // === Compact === + pub current_size_bytes: Option, + pub target_size_bytes: Option, + pub message_count: Option, + pub compacted_size_bytes: Option, + pub saved_bytes: Option, + + // === Session state === + pub prev_state: Option, + pub new_state: Option, + pub update_reason: Option, + pub idle_duration_secs: Option, + pub idle_threshold_secs: Option, + pub last_activity: Option>, + + // === Task === + pub task_id: Option, + pub task_type: Option, + pub task_description: Option, + pub parent_task_id: Option, + pub task_result: Option, + + // === File === + pub file_path: Option, + pub change_type: Option, + pub diff: Option, + + // === Env === + pub env_vars: Option>, + pub config_path: Option, + pub start_time: Option>, + pub exit_reason: Option, + pub total_tool_calls: Option, + pub stop_type: Option, + pub stop_reason: Option, + pub continue_loop: Option, +} + +impl Default for HookInput { + fn default() -> Self { + Self { + schema_version: "2.0".to_string(), + session_id: String::new(), + cwd: String::new(), + hook_event_name: String::new(), + timestamp: Utc::now(), + + transcript_path: None, + agent_id: None, + agent_type: None, + + tool_name: None, + tool_input: None, + tool_output: None, + tool_use_id: None, + error: None, + error_code: None, + duration_ms: None, + + permission_mode: None, + permission_decision: None, + request_id: None, + action_description: None, + + prompt: None, + prompt_text: None, + files: None, + expanded_prompt: None, + + model: None, + system_prompt: None, + agent_turns: None, + total_cost: None, + parent_agent_id: None, + subagent_id: None, + subagent_type: None, + + current_size_bytes: None, + target_size_bytes: None, + message_count: None, + compacted_size_bytes: None, + saved_bytes: None, + + prev_state: None, + new_state: None, + update_reason: None, + idle_duration_secs: None, + idle_threshold_secs: None, + last_activity: None, + + task_id: None, + task_type: None, + task_description: None, + parent_task_id: None, + task_result: None, + + file_path: None, + change_type: None, + diff: None, + + env_vars: None, + config_path: None, + start_time: None, + exit_reason: None, + total_tool_calls: None, + stop_type: None, + stop_reason: None, + continue_loop: None, + } + } +} + +// =========================================================================== +// HOOK INPUT BUILDER +// =========================================================================== + +/// Builder pattern for constructing event-specific `HookInput` values. +/// +/// Ensures required fields are set and optional fields are correct per event. +/// +/// # Example +/// +/// ```ignore +/// let input = HookInputBuilder::new() +/// .session("ses_123", "/home/user/project") +/// .event("PreToolUse") +/// .agent("agent_1", "default") +/// .tool("Bash", serde_json::json!({"command": "ls"}), "tool_1") +/// .build(); +/// ``` +#[derive(Debug, Default)] +pub struct HookInputBuilder { + input: HookInput, +} + +impl HookInputBuilder { + /// Create a new builder with default (empty) values. + pub fn new() -> Self { + Self::default() + } + + /// Set the session identifier and working directory. + pub fn session(mut self, session_id: &str, cwd: &str) -> Self { + self.input.session_id = session_id.to_string(); + self.input.cwd = cwd.to_string(); + self + } + + /// Set the hook event name. + pub fn event(mut self, event_name: &str) -> Self { + self.input.hook_event_name = event_name.to_string(); + self + } + + /// Set agent identification fields. + pub fn agent(mut self, agent_id: &str, agent_type: &str) -> Self { + self.input.agent_id = Some(agent_id.to_string()); + self.input.agent_type = Some(agent_type.to_string()); + self + } + + /// Set tool-related fields: name, input payload, and use-id. + pub fn tool(mut self, name: &str, input: serde_json::Value, use_id: &str) -> Self { + self.input.tool_name = Some(name.to_string()); + self.input.tool_input = Some(input); + self.input.tool_use_id = Some(use_id.to_string()); + self + } + + /// Set the tool output value. + pub fn tool_output(mut self, output: serde_json::Value) -> Self { + self.input.tool_output = Some(output); + self + } + + /// Set permission-related fields. + pub fn permission(mut self, mode: &str, request_id: &str, description: &str) -> Self { + self.input.permission_mode = Some(mode.to_string()); + self.input.request_id = Some(request_id.to_string()); + self.input.action_description = Some(description.to_string()); + self + } + + /// Set error information. + pub fn error(mut self, error: &str, code: i32) -> Self { + self.input.error = Some(error.to_string()); + self.input.error_code = Some(code); + self + } + + /// Set the execution duration in milliseconds. + pub fn duration(mut self, ms: u64) -> Self { + self.input.duration_ms = Some(ms); + self + } + + /// Set session state transition fields (SessionUpdated). + pub fn session_state(mut self, prev_state: &str, new_state: &str, update_reason: &str) -> Self { + self.input.prev_state = Some(prev_state.to_string()); + self.input.new_state = Some(new_state.to_string()); + self.input.update_reason = Some(update_reason.to_string()); + self + } + + /// Set diff information (SessionDiff). + pub fn diff(mut self, diff_text: &str, file_path: Option<&str>) -> Self { + self.input.diff = Some(diff_text.to_string()); + self.input.file_path = file_path.map(|s| s.to_string()); + self + } + + /// Set idle state information (SessionIdle). + pub fn idle_state( + mut self, + idle_duration_secs: u64, + idle_threshold_secs: Option, + last_activity: Option>, + ) -> Self { + self.input.idle_duration_secs = Some(idle_duration_secs); + self.input.idle_threshold_secs = idle_threshold_secs; + self.input.last_activity = last_activity; + self + } + + /// Set the user prompt text. + pub fn prompt(mut self, text: &str) -> Self { + self.input.prompt_text = Some(text.to_string()); + self + } + + /// Consume the builder and produce the final `HookInput`. + pub fn build(self) -> HookInput { + self.input + } +} + +// =========================================================================== +// HOOK OUTPUT — Stdout JSON contract +// =========================================================================== + +/// Standard output returned by hook scripts via stdout JSON. +/// +/// Every field is optional — hooks return only what they need to override. +/// The `continue_` field defaults to `true` via serde when absent. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookOutput { + /// Whether execution should continue. Default: `true`. + /// For blocking events, setting this to `false` blocks/denies the operation. + #[serde(default = "default_true")] + pub continue_: bool, + + /// Suppress the tool/event output from being shown to the agent. + #[serde(skip_serializing_if = "Option::is_none")] + pub suppress_output: Option, + + /// Reason for stopping/blocking (shown to agent). + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, + + /// Decision for permission-type hooks: `"allow"`, `"deny"`, or `"ask"`. + #[serde(skip_serializing_if = "Option::is_none")] + pub decision: Option, + + /// Human-readable reason for the decision. + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + + /// System message to inject into the conversation. + #[serde(skip_serializing_if = "Option::is_none")] + pub system_message: Option, + + /// Event-specific output overrides. + #[serde(skip_serializing_if = "Option::is_none")] + pub hook_specific_output: Option, +} + +/// Serde default function: returns `true`. +fn default_true() -> bool { + true +} + +impl HookOutput { + /// Create a default "continue" output (all fields at defaults). + pub fn continue_() -> Self { + Self { + continue_: true, + suppress_output: None, + stop_reason: None, + decision: None, + reason: None, + system_message: None, + hook_specific_output: None, + } + } + + /// Create a "block/deny" output with the given reason. + pub fn block(reason: &str) -> Self { + Self { + continue_: false, + suppress_output: None, + stop_reason: Some(reason.to_string()), + decision: Some("deny".to_string()), + reason: None, + system_message: None, + hook_specific_output: None, + } + } + + /// Create an "ask the user" output with the given reason. + pub fn ask(reason: &str) -> Self { + Self { + continue_: false, + suppress_output: None, + stop_reason: None, + decision: Some("ask".to_string()), + reason: Some(reason.to_string()), + system_message: None, + hook_specific_output: None, + } + } + + /// Create an explicit "allow" output. + pub fn allow() -> Self { + Self { + continue_: true, + suppress_output: None, + stop_reason: None, + decision: Some("allow".to_string()), + reason: None, + system_message: None, + hook_specific_output: None, + } + } +} + +// =========================================================================== +// HOOK SPECIFIC OUTPUT +// =========================================================================== + +/// Event-specific output fields carried inside `HookOutput.hook_specific_output`. +/// +/// Each blocking event uses a subset of these fields to communicate +/// fine-grained overrides back to the engine. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookSpecificOutput { + /// The event name this output corresponds to. + pub hook_event_name: String, + + // Permission events + /// Permission decision override: `"allow"`, `"deny"`, or `"ask"`. + pub permission_decision: Option, + /// Reason accompanying the permission decision. + pub permission_decision_reason: Option, + + // Tool events — modify input before execution + /// Replacement tool input (PreToolUse). + pub updated_input: Option, + + // Prompt events — modify prompt before LLM + /// Replacement prompt text (UserPromptSubmit). + pub updated_prompt: Option, + + // Agent events — modify system prompt + /// Replacement system prompt (AgentStart). + pub updated_system_prompt: Option, + + // Compact events — override compacted system message + /// Replacement system message after compaction (PreCompact). + pub updated_system_message: Option, + + // General — inject context + /// Additional context string to inject into the conversation. + pub additional_context: Option, + + // Session setup — inject env vars + /// Additional environment variables to set (Setup). + pub additional_env_vars: Option>, + /// Updated configuration values (Setup). + pub updated_config: Option, +} + +// =========================================================================== +// HOOK RESULT +// =========================================================================== + +/// Result of executing a single hook handler. +#[derive(Debug)] +pub enum HookResult { + /// Hook completed successfully and execution should continue. + Continue(HookOutput), + /// Hook blocked the operation (exit code 2 or `continue_` = false). + Blocked { reason: String, output: HookOutput }, + /// Hook failed (non-zero exit code other than 2, HTTP error, timeout). + Failed { error: String }, +} + +// =========================================================================== +// AGGREGATED DECISION +// =========================================================================== + +/// Aggregated decision from multiple hooks for blocking events. +/// +/// Precedence: deny > ask > allow. +#[derive(Debug)] +pub enum AggregatedDecision { + /// All hooks say continue, or no hooks configured. + Allow, + /// At least one hook says "ask" and no hook says "deny". + Ask { reasons: Vec }, + /// At least one hook blocked/denied the operation. + Deny { reason: String, source_hook: String }, +} + +// =========================================================================== +// HOOK METRICS +// =========================================================================== + +/// Metrics collected per hook execution for observability. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HookMetrics { + /// The event this metric covers. + pub event_name: String, + /// Human-readable label for the handler (command string, URL, agent id). + pub handler_label: String, + /// Total number of times this hook has been executed. + pub execution_count: u64, + /// Number of executions that ended in failure. + pub failure_count: u64, + /// Number of executions that blocked the operation. + pub blocked_count: u64, + /// Cumulative execution time in milliseconds. + pub total_duration_ms: u64, + /// Average execution time in milliseconds. + pub avg_duration_ms: f64, + /// Timestamp of the most recent execution. + pub last_execution: Option>, + /// Error message from the most recent failure, if any. + pub last_error: Option, +} + +// =========================================================================== +// TESTS +// =========================================================================== + +#[cfg(test)] +mod tests { + use super::*; + + // --- Event name constants --- + + #[test] + fn test_all_event_names_count() { + assert_eq!(ALL_EVENT_NAMES.len(), 28); + } + + #[test] + fn test_event_name_constants_are_unique() { + let mut seen = std::collections::HashSet::new(); + for name in ALL_EVENT_NAMES { + assert!(seen.insert(*name), "duplicate event name: {}", name); + } + } + + #[test] + fn test_event_names_are_pascal_case() { + for name in ALL_EVENT_NAMES { + let first = name.chars().next().unwrap(); + assert!( + first.is_uppercase(), + "event name '{}' does not start with uppercase", + name + ); + } + } + + // --- HookInput --- + + #[test] + fn test_hook_input_default() { + let input = HookInput::default(); + assert_eq!(input.schema_version, "2.0"); + assert!(input.session_id.is_empty()); + assert!(input.cwd.is_empty()); + assert!(input.hook_event_name.is_empty()); + assert!(input.tool_name.is_none()); + assert!(input.agent_id.is_none()); + } + + #[test] + fn test_hook_input_serialization_roundtrip() { + let input = HookInput { + session_id: "ses_123".to_string(), + cwd: "/home/user".to_string(), + hook_event_name: EVENT_PRE_TOOL_USE.to_string(), + tool_name: Some("Bash".to_string()), + tool_input: Some(serde_json::json!({"command": "ls -la"})), + ..Default::default() + }; + let json = serde_json::to_string(&input).unwrap(); + let deserialized: HookInput = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.session_id, "ses_123"); + assert_eq!(deserialized.tool_name, Some("Bash".to_string())); + assert_eq!(deserialized.schema_version, "2.0"); + } + + #[test] + fn test_hook_input_json_omits_none_fields() { + let input = HookInput::default(); + let json = serde_json::to_string(&input).unwrap(); + // Optional fields should be serialized as null (serde default) + assert!(json.contains("\"schema_version\":\"2.0\"")); + } + + // --- HookInputBuilder --- + + #[test] + fn test_builder_session_and_event() { + let input = HookInputBuilder::new() + .session("ses_456", "/tmp/project") + .event("SessionStart") + .build(); + assert_eq!(input.session_id, "ses_456"); + assert_eq!(input.cwd, "/tmp/project"); + assert_eq!(input.hook_event_name, "SessionStart"); + } + + #[test] + fn test_builder_tool() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("PreToolUse") + .tool("Bash", serde_json::json!({"command": "ls"}), "tool_1") + .build(); + assert_eq!(input.tool_name, Some("Bash".to_string())); + assert_eq!(input.tool_use_id, Some("tool_1".to_string())); + assert!(input.tool_input.is_some()); + } + + #[test] + fn test_builder_tool_output() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("PostToolUse") + .tool("Read", serde_json::json!({"file": "main.rs"}), "tool_2") + .tool_output(serde_json::json!({"content": "fn main() {}"})) + .duration(42) + .build(); + assert!(input.tool_output.is_some()); + assert_eq!(input.duration_ms, Some(42)); + } + + #[test] + fn test_builder_agent() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("AgentStart") + .agent("agent_alpha", "coder") + .build(); + assert_eq!(input.agent_id, Some("agent_alpha".to_string())); + assert_eq!(input.agent_type, Some("coder".to_string())); + } + + #[test] + fn test_builder_permission() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("PermissionRequest") + .permission("auto", "req_001", "Execute bash command") + .build(); + assert_eq!(input.permission_mode, Some("auto".to_string())); + assert_eq!(input.request_id, Some("req_001".to_string())); + assert_eq!( + input.action_description, + Some("Execute bash command".to_string()) + ); + } + + #[test] + fn test_builder_error() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("ToolError") + .error("command not found", 127) + .build(); + assert_eq!(input.error, Some("command not found".to_string())); + assert_eq!(input.error_code, Some(127)); + } + + #[test] + fn test_builder_prompt() { + let input = HookInputBuilder::new() + .session("ses_1", "/project") + .event("UserPromptSubmit") + .prompt("fix the bug in main.rs") + .build(); + assert_eq!( + input.prompt_text, + Some("fix the bug in main.rs".to_string()) + ); + } + + #[test] + fn test_builder_full_chain() { + let input = HookInputBuilder::new() + .session("ses_full", "/workspace") + .event("PreToolUse") + .agent("agent_1", "default") + .tool("Bash", serde_json::json!({"command": "cargo test"}), "tu_1") + .duration(1500) + .build(); + assert_eq!(input.session_id, "ses_full"); + assert_eq!(input.hook_event_name, "PreToolUse"); + assert_eq!(input.agent_id, Some("agent_1".to_string())); + assert_eq!(input.tool_name, Some("Bash".to_string())); + assert_eq!(input.duration_ms, Some(1500)); + } + + // --- HookOutput --- + + #[test] + fn test_hook_output_continue() { + let output = HookOutput::continue_(); + assert!(output.continue_); + assert!(output.suppress_output.is_none()); + assert!(output.stop_reason.is_none()); + assert!(output.decision.is_none()); + } + + #[test] + fn test_hook_output_block() { + let output = HookOutput::block("Dangerous command"); + assert!(!output.continue_); + assert_eq!(output.stop_reason.as_deref(), Some("Dangerous command")); + assert_eq!(output.decision.as_deref(), Some("deny")); + } + + #[test] + fn test_hook_output_ask() { + let output = HookOutput::ask("Need approval"); + assert!(!output.continue_); + assert_eq!(output.decision.as_deref(), Some("ask")); + assert_eq!(output.reason.as_deref(), Some("Need approval")); + } + + #[test] + fn test_hook_output_allow() { + let output = HookOutput::allow(); + assert!(output.continue_); + assert_eq!(output.decision.as_deref(), Some("allow")); + } + + #[test] + fn test_hook_output_serialization_roundtrip() { + let output = HookOutput::block("nope"); + let json = serde_json::to_string(&output).unwrap(); + let deserialized: HookOutput = serde_json::from_str(&json).unwrap(); + assert!(!deserialized.continue_); + assert_eq!(deserialized.stop_reason.as_deref(), Some("nope")); + } + + #[test] + fn test_hook_output_default_true_on_empty_json() { + let json = r#"{}"#; + let output: HookOutput = serde_json::from_str(json).unwrap(); + assert!(output.continue_); + assert!(output.suppress_output.is_none()); + } + + #[test] + fn test_hook_output_continue_false_from_json() { + let json = r#"{"continue_": false, "stop_reason": "blocked"}"#; + let output: HookOutput = serde_json::from_str(json).unwrap(); + assert!(!output.continue_); + assert_eq!(output.stop_reason.as_deref(), Some("blocked")); + } + + // --- HookSpecificOutput --- + + #[test] + fn test_hook_specific_output_serialization() { + let specific = HookSpecificOutput { + hook_event_name: "PreToolUse".to_string(), + permission_decision: None, + permission_decision_reason: None, + updated_input: Some(serde_json::json!({"command": "safe-ls"})), + updated_prompt: None, + updated_system_prompt: None, + updated_system_message: None, + additional_context: None, + additional_env_vars: None, + updated_config: None, + }; + let json = serde_json::to_string(&specific).unwrap(); + assert!(json.contains("PreToolUse")); + assert!(json.contains("safe-ls")); + } + + #[test] + fn test_hook_specific_output_permission() { + let specific = HookSpecificOutput { + hook_event_name: "PermissionRequest".to_string(), + permission_decision: Some("allow".to_string()), + permission_decision_reason: Some("Safe read operation".to_string()), + updated_input: None, + updated_prompt: None, + updated_system_prompt: None, + updated_system_message: None, + additional_context: None, + additional_env_vars: None, + updated_config: None, + }; + assert_eq!(specific.permission_decision.as_deref(), Some("allow")); + } + + // --- HookResult --- + + #[test] + fn test_hook_result_variants() { + let continue_result = HookResult::Continue(HookOutput::continue_()); + assert!(matches!(continue_result, HookResult::Continue(_))); + + let blocked_result = HookResult::Blocked { + reason: "nope".to_string(), + output: HookOutput::block("nope"), + }; + assert!(matches!(blocked_result, HookResult::Blocked { .. })); + + let failed_result = HookResult::Failed { + error: "timeout".to_string(), + }; + assert!(matches!(failed_result, HookResult::Failed { .. })); + } + + // --- AggregatedDecision --- + + #[test] + fn test_aggregated_decision_allow() { + let decision = AggregatedDecision::Allow; + assert!(matches!(decision, AggregatedDecision::Allow)); + } + + #[test] + fn test_aggregated_decision_ask() { + let decision = AggregatedDecision::Ask { + reasons: vec!["needs review".to_string()], + }; + if let AggregatedDecision::Ask { reasons } = decision { + assert_eq!(reasons.len(), 1); + } else { + panic!("expected Ask variant"); + } + } + + #[test] + fn test_aggregated_decision_deny() { + let decision = AggregatedDecision::Deny { + reason: "forbidden".to_string(), + source_hook: "security_hook".to_string(), + }; + if let AggregatedDecision::Deny { + reason, + source_hook, + } = decision + { + assert_eq!(reason, "forbidden"); + assert_eq!(source_hook, "security_hook"); + } else { + panic!("expected Deny variant"); + } + } + + // --- HookMetrics --- + + #[test] + fn test_hook_metrics_serialization() { + let metrics = HookMetrics { + event_name: "PreToolUse".to_string(), + handler_label: "security_check.sh".to_string(), + execution_count: 100, + failure_count: 2, + blocked_count: 5, + total_duration_ms: 42000, + avg_duration_ms: 420.0, + last_execution: Some(Utc::now()), + last_error: None, + }; + let json = serde_json::to_string(&metrics).unwrap(); + assert!(json.contains("PreToolUse")); + assert!(json.contains("security_check.sh")); + + let deserialized: HookMetrics = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.execution_count, 100); + assert_eq!(deserialized.failure_count, 2); + assert_eq!(deserialized.blocked_count, 5); + } + + #[test] + fn test_hook_metrics_with_error() { + let metrics = HookMetrics { + event_name: "PostToolUse".to_string(), + handler_label: "logger.sh".to_string(), + execution_count: 50, + failure_count: 1, + blocked_count: 0, + total_duration_ms: 15000, + avg_duration_ms: 300.0, + last_execution: Some(Utc::now()), + last_error: Some("exit code 1".to_string()), + }; + assert_eq!(metrics.last_error.as_deref(), Some("exit code 1")); + } + + // --- Full protocol roundtrip --- + + #[test] + fn test_full_protocol_roundtrip() { + let input = HookInputBuilder::new() + .session("ses_proto", "/workspace") + .event("PreToolUse") + .agent("coder", "default") + .tool( + "Write", + serde_json::json!({"file_path": "main.rs", "content": "fn main() {}"}), + "tool_99", + ) + .build(); + + // Serialize to JSON (what the hook receives via stdin) + let json = serde_json::to_string_pretty(&input).unwrap(); + + // Deserialize back + let parsed: HookInput = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.schema_version, "2.0"); + assert_eq!(parsed.session_id, "ses_proto"); + assert_eq!(parsed.hook_event_name, "PreToolUse"); + assert_eq!(parsed.tool_name, Some("Write".to_string())); + + // Build a response + let output = HookOutput::allow(); + let output_json = serde_json::to_string(&output).unwrap(); + let parsed_output: HookOutput = serde_json::from_str(&output_json).unwrap(); + assert!(parsed_output.continue_); + assert_eq!(parsed_output.decision.as_deref(), Some("allow")); + } +} diff --git a/crates/jcode-tui/src/tui/app/helpers.rs b/crates/jcode-tui/src/tui/app/helpers.rs index 4b3d5ccb3..7b73633df 100644 --- a/crates/jcode-tui/src/tui/app/helpers.rs +++ b/crates/jcode-tui/src/tui/app/helpers.rs @@ -599,6 +599,22 @@ pub(super) fn build_resume_command( let title = format!("ā—Œ OpenCode {}", &session_id[..session_id.len().min(8)]); (exe, args, title) } + ResumeTarget::ForeignSession { + provider_slug, + session_id, + .. + } => { + let exe = launch_client_executable(); + let imported_id = + crate::casr_adapter::imported_session_id_for_provider(provider_slug, session_id); + let args = resume_invocation_args(&imported_id, socket); + let title = format!( + "šŸ’¾ {provider_slug} {}", + &session_id[..session_id.len().min(8)] + ); + (exe, args, title) + } + } } diff --git a/crates/jcode-tui/src/tui/app/inline_interactive.rs b/crates/jcode-tui/src/tui/app/inline_interactive.rs index 8c8b49f70..e62167735 100644 --- a/crates/jcode-tui/src/tui/app/inline_interactive.rs +++ b/crates/jcode-tui/src/tui/app/inline_interactive.rs @@ -1837,6 +1837,23 @@ impl App { failed.push(format!("failed to import {}: {}", name, err)); continue; } + ResumeTarget::CodexSession { session_id, .. } => { + crate::casr_adapter::imported_codex_session_id(session_id) + } + ResumeTarget::PiSession { session_path } => { + crate::casr_adapter::imported_pi_session_id(session_path) + } + ResumeTarget::OpenCodeSession { session_id, .. } => { + crate::casr_adapter::imported_opencode_session_id(session_id) + } + ResumeTarget::ForeignSession { + provider_slug, + session_id, + .. + } => { + crate::casr_adapter::imported_session_id_for_provider(provider_slug, session_id) + } + }; match spawn_resume_target_in_new_terminal(&resolved_target, &cwd, socket.as_deref()) { @@ -1942,12 +1959,26 @@ impl App { } }; - let ResumeTarget::JcodeSession { session_id } = resolved_target else { - self.push_display_message(DisplayMessage::error(format!( - "Cannot resume {} in the current terminal.", - name - ))); - return; + let resolved_target = match target { + ResumeTarget::JcodeSession { session_id } => session_id.clone(), + ResumeTarget::ClaudeCodeSession { session_id, .. } => { + crate::casr_adapter::imported_claude_code_session_id(session_id) + } + ResumeTarget::CodexSession { session_id, .. } => { + crate::casr_adapter::imported_codex_session_id(session_id) + } + ResumeTarget::PiSession { session_path } => { + crate::casr_adapter::imported_pi_session_id(session_path) + } + ResumeTarget::OpenCodeSession { session_id, .. } => { + crate::casr_adapter::imported_opencode_session_id(session_id) + } + ResumeTarget::ForeignSession { + provider_slug, + session_id, + .. + } => crate::casr_adapter::imported_session_id_for_provider(provider_slug, session_id), + }; if targets.len() > 1 { diff --git a/scripts/code_size_budget.json b/scripts/code_size_budget.json index cf7117b4b..25c917825 100644 --- a/scripts/code_size_budget.json +++ b/scripts/code_size_budget.json @@ -1,79 +1,76 @@ { "threshold_loc": 1200, "tracked_files": { - "crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs": 1336, + "crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs": 1367, "crates/jcode-app-core/src/overnight.rs": 1273, "crates/jcode-app-core/src/server.rs": 1893, - "crates/jcode-app-core/src/server/client_lifecycle.rs": 2842, + "crates/jcode-app-core/src/server/client_lifecycle.rs": 2944, "crates/jcode-app-core/src/server/client_session.rs": 1400, "crates/jcode-app-core/src/server/comm_control.rs": 1838, - "crates/jcode-app-core/src/server/provider_control.rs": 1440, + "crates/jcode-app-core/src/server/jade_relay.rs": 1422, + "crates/jcode-app-core/src/server/provider_control.rs": 1364, "crates/jcode-app-core/src/server/swarm.rs": 1682, "crates/jcode-app-core/src/tool/communicate.rs": 1599, - "crates/jcode-app-core/src/tool/session_search.rs": 1727, + "crates/jcode-app-core/src/tool/session_search.rs": 1690, "crates/jcode-app-core/src/update.rs": 1709, "crates/jcode-base/src/auth/lifecycle.rs": 1388, "crates/jcode-base/src/auth/lifecycle_driver.rs": 1974, - "crates/jcode-base/src/auth/mod.rs": 1401, + "crates/jcode-base/src/auth/mod.rs": 1361, "crates/jcode-base/src/auth/oauth.rs": 1436, "crates/jcode-base/src/background.rs": 1214, "crates/jcode-base/src/compaction.rs": 1788, "crates/jcode-base/src/memory.rs": 1850, - "crates/jcode-base/src/memory_agent.rs": 1709, - "crates/jcode-base/src/provider/anthropic.rs": 2640, - "crates/jcode-base/src/provider/bedrock.rs": 1860, - "crates/jcode-base/src/provider/mod.rs": 2117, + "crates/jcode-base/src/memory_agent.rs": 1708, + "crates/jcode-base/src/provider/anthropic.rs": 2562, + "crates/jcode-base/src/provider/bedrock.rs": 1858, + "crates/jcode-base/src/provider/mod.rs": 2116, "crates/jcode-base/src/provider/openai_stream_runtime.rs": 1589, "crates/jcode-base/src/provider/openrouter.rs": 2394, - "crates/jcode-base/src/session.rs": 1482, + "crates/jcode-base/src/session.rs": 1478, "crates/jcode-base/src/telemetry.rs": 1875, "crates/jcode-desktop/src/desktop_rich_text.rs": 2069, "crates/jcode-desktop/src/main.rs": 12959, "crates/jcode-desktop/src/render_helpers.rs": 1345, "crates/jcode-desktop/src/session_launch.rs": 1226, - "crates/jcode-desktop/src/single_session.rs": 9778, + "crates/jcode-desktop/src/single_session.rs": 9770, "crates/jcode-desktop/src/single_session_render.rs": 9957, "crates/jcode-desktop/src/single_session_render/handwriting.rs": 3005, "crates/jcode-desktop/src/workspace.rs": 1625, - "crates/jcode-protocol/src/wire.rs": 1221, - "crates/jcode-tui/src/tui/app.rs": 1922, - "crates/jcode-tui/src/tui/app/auth.rs": 2879, + "crates/jcode-hooks/src/config.rs": 1319, + "crates/jcode-protocol/src/wire.rs": 1215, + "crates/jcode-tui/src/tui/app.rs": 1804, + "crates/jcode-tui/src/tui/app/auth.rs": 2768, "crates/jcode-tui/src/tui/app/auth_account_picker.rs": 1248, - "crates/jcode-tui/src/tui/app/commands.rs": 3661, + "crates/jcode-tui/src/tui/app/commands.rs": 3602, "crates/jcode-tui/src/tui/app/helpers.rs": 1460, - "crates/jcode-tui/src/tui/app/inline_interactive.rs": 3122, - "crates/jcode-tui/src/tui/app/input.rs": 3741, + "crates/jcode-tui/src/tui/app/inline_interactive.rs": 3027, + "crates/jcode-tui/src/tui/app/input.rs": 3670, "crates/jcode-tui/src/tui/app/model_context.rs": 1486, "crates/jcode-tui/src/tui/app/navigation.rs": 1510, - "crates/jcode-tui/src/tui/app/remote.rs": 1606, + "crates/jcode-tui/src/tui/app/remote.rs": 1441, "crates/jcode-tui/src/tui/app/remote/key_handling.rs": 2400, - "crates/jcode-tui/src/tui/app/remote/server_events.rs": 1894, + "crates/jcode-tui/src/tui/app/remote/server_events.rs": 1876, "crates/jcode-tui/src/tui/app/state_ui.rs": 1880, - "crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs": 2609, - "crates/jcode-tui/src/tui/app/tui_state.rs": 1554, - "crates/jcode-tui/src/tui/app/turn.rs": 1398, - "crates/jcode-tui/src/tui/backend.rs": 1274, + "crates/jcode-tui/src/tui/app/state_ui_input_helpers.rs": 2599, + "crates/jcode-tui/src/tui/app/tui_state.rs": 1533, + "crates/jcode-tui/src/tui/app/turn.rs": 1362, + "crates/jcode-tui/src/tui/backend.rs": 1259, "crates/jcode-tui/src/tui/info_widget.rs": 2009, - "crates/jcode-tui/src/tui/mod.rs": 1670, + "crates/jcode-tui/src/tui/mod.rs": 1664, "crates/jcode-tui/src/tui/session_picker.rs": 1587, - "crates/jcode-tui/src/tui/session_picker/loading.rs": 2478, - "crates/jcode-tui/src/tui/ui.rs": 2656, - "crates/jcode-tui/src/tui/ui_input.rs": 2177, - "crates/jcode-tui/src/tui/ui_messages.rs": 2024, + "crates/jcode-tui/src/tui/session_picker/loading.rs": 2256, + "crates/jcode-tui/src/tui/ui.rs": 2620, + "crates/jcode-tui/src/tui/ui_input.rs": 2173, + "crates/jcode-tui/src/tui/ui_messages.rs": 1921, "crates/jcode-tui/src/tui/ui_pinned.rs": 1994, "crates/jcode-tui/src/tui/ui_prepare.rs": 1818, "crates/jcode-tui/src/tui/ui_tools.rs": 1460, - "src/bin/tui_bench.rs": 1709, + "src/bin/tui_bench.rs": 1706, "src/cli/args.rs": 1285, "src/cli/commands.rs": 3669, + "src/cli/dispatch.rs": 1229, "src/cli/login.rs": 1439, - "src/cli/provider_init.rs": 1798, - "crates/jcode-app-core/src/server/jade_relay.rs": 1422, - "crates/jcode-base/src/auth/live_provider_probes.rs": 1266, - "crates/jcode-base/src/auth/provider_e2e.rs": 1721, - "crates/jcode-base/src/provider/antigravity.rs": 1211, - "crates/jcode-provider-core/src/lib.rs": 1243, - "src/cli/dispatch.rs": 1229 + "src/cli/provider_init.rs": 1798 }, "version": 1 } diff --git a/scripts/panic_budget.json b/scripts/panic_budget.json index b0afbf4c6..319e4fa3e 100644 --- a/scripts/panic_budget.json +++ b/scripts/panic_budget.json @@ -1,5 +1,5 @@ { - "total": 20, + "total": 24, "tracked_files": { "crates/jcode-app-core/src/export.rs": 5, "crates/jcode-app-core/src/yolo_classifier.rs": 1, @@ -7,6 +7,10 @@ "crates/jcode-base/src/auth/oauth.rs": 3, "crates/jcode-desktop/src/main.rs": 2, "crates/jcode-desktop/src/single_session_render/wrapping.rs": 1, + "crates/jcode-hooks/src/cli.rs": 1, + "crates/jcode-hooks/src/dispatch.rs": 4, + "crates/jcode-hooks/src/execute.rs": 2, + "crates/jcode-tui/src/tui/app/helpers.rs": 1, "src/cli/commands.rs": 1, "src/orchestration_api.rs": 1, "crates/jcode-productivity-core/src/aggregate.rs": 1, diff --git a/scripts/swallowed_error_budget.json b/scripts/swallowed_error_budget.json index 343a6ecfa..941d5c745 100644 --- a/scripts/swallowed_error_budget.json +++ b/scripts/swallowed_error_budget.json @@ -1,15 +1,20 @@ { - "total": 2565, + "total": 2624, "totals_by_pattern": { - "dot_ok": 885, - "let_underscore": 1025, - "unwrap_or_default": 655 + "dot_ok": 878, + "let_underscore": 1065, + "unwrap_or_default": 681 }, "tracked_files": { "crates/jcode-app-core/src/agent.rs": { "dot_ok": 3, "let_underscore": 0, - "unwrap_or_default": 2 + "unwrap_or_default": 7 + }, + "crates/jcode-app-core/src/agent/compaction.rs": { + "dot_ok": 0, + "let_underscore": 0, + "unwrap_or_default": 6 }, "crates/jcode-app-core/src/agent/interrupts.rs": { "dot_ok": 0, @@ -24,17 +29,22 @@ "crates/jcode-app-core/src/agent/turn_execution.rs": { "dot_ok": 0, "let_underscore": 1, - "unwrap_or_default": 1 + "unwrap_or_default": 4 }, "crates/jcode-app-core/src/agent/turn_loops.rs": { "dot_ok": 0, "let_underscore": 1, - "unwrap_or_default": 2 + "unwrap_or_default": 3 + }, + "crates/jcode-app-core/src/agent/turn_streaming_broadcast.rs": { + "dot_ok": 0, + "let_underscore": 40, + "unwrap_or_default": 3 }, "crates/jcode-app-core/src/agent/turn_streaming_mpsc.rs": { "dot_ok": 0, "let_underscore": 43, - "unwrap_or_default": 3 + "unwrap_or_default": 4 }, "crates/jcode-app-core/src/agent/utils.rs": { "dot_ok": 1, @@ -76,6 +86,11 @@ "let_underscore": 5, "unwrap_or_default": 5 }, + "crates/jcode-app-core/src/dcg_bridge.rs": { + "dot_ok": 0, + "let_underscore": 1, + "unwrap_or_default": 1 + }, "crates/jcode-app-core/src/dcp_bridge.rs": { "dot_ok": 0, "let_underscore": 0, @@ -174,7 +189,7 @@ "crates/jcode-app-core/src/server/client_lifecycle.rs": { "dot_ok": 0, "let_underscore": 28, - "unwrap_or_default": 0 + "unwrap_or_default": 2 }, "crates/jcode-app-core/src/server/client_lifecycle_logging.rs": { "dot_ok": 1, @@ -348,7 +363,7 @@ }, "crates/jcode-app-core/src/setup_hints.rs": { "dot_ok": 2, - "let_underscore": 12, + "let_underscore": 11, "unwrap_or_default": 1 }, "crates/jcode-app-core/src/setup_hints/macos_launcher.rs": { @@ -446,6 +461,11 @@ "let_underscore": 0, "unwrap_or_default": 2 }, + "crates/jcode-app-core/src/tool/edit.rs": { + "dot_ok": 0, + "let_underscore": 1, + "unwrap_or_default": 1 + }, "crates/jcode-app-core/src/tool/glob.rs": { "dot_ok": 2, "let_underscore": 0, @@ -483,8 +503,8 @@ }, "crates/jcode-app-core/src/tool/mod.rs": { "dot_ok": 1, - "let_underscore": 3, - "unwrap_or_default": 1 + "let_underscore": 5, + "unwrap_or_default": 2 }, "crates/jcode-app-core/src/tool/patch.rs": { "dot_ok": 1, @@ -526,6 +546,11 @@ "let_underscore": 0, "unwrap_or_default": 0 }, + "crates/jcode-app-core/src/tool/todo.rs": { + "dot_ok": 0, + "let_underscore": 2, + "unwrap_or_default": 2 + }, "crates/jcode-app-core/src/tool/webfetch.rs": { "dot_ok": 3, "let_underscore": 0, @@ -538,8 +563,8 @@ }, "crates/jcode-app-core/src/tool/write.rs": { "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 + "let_underscore": 1, + "unwrap_or_default": 1 }, "crates/jcode-app-core/src/update.rs": { "dot_ok": 2, @@ -958,7 +983,7 @@ }, "crates/jcode-base/src/safety.rs": { "dot_ok": 4, - "let_underscore": 7, + "let_underscore": 10, "unwrap_or_default": 8 }, "crates/jcode-base/src/secret_input.rs": { @@ -1211,354 +1236,21 @@ "let_underscore": 0, "unwrap_or_default": 9 }, - "crates/jcode-logging/src/lib.rs": { - "dot_ok": 5, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-memory-types/src/graph.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 2 - }, - "crates/jcode-mobile-core/src/visual.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-mobile-sim/src/gpu_preview.rs": { - "dot_ok": 7, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-mobile-sim/src/lib.rs": { - "dot_ok": 0, - "let_underscore": 2, - "unwrap_or_default": 4 - }, - "crates/jcode-mobile-sim/src/main.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-notify-email/src/lib.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-overnight-core/src/lib.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 3 - }, - "crates/jcode-productivity-core/src/aggregate.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-productivity-core/src/scan.rs": { - "dot_ok": 5, - "let_underscore": 1, - "unwrap_or_default": 1 - }, - "crates/jcode-protocol/src/comm_format.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-provider-core/src/failover.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-provider-core/src/openai_schema.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-provider-core/src/pricing.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-provider-gemini/src/lib.rs": { - "dot_ok": 2, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-provider-metadata/src/lib.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-provider-openai/src/request.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-provider-openrouter/src/lib.rs": { - "dot_ok": 14, - "let_underscore": 4, - "unwrap_or_default": 0 - }, - "crates/jcode-session-types/src/lib.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-storage/src/active_pids.rs": { - "dot_ok": 6, - "let_underscore": 3, - "unwrap_or_default": 0 - }, - "crates/jcode-storage/src/lib.rs": { - "dot_ok": 2, - "let_underscore": 10, - "unwrap_or_default": 1 - }, - "crates/jcode-swarm-core/src/lib.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-terminal-launch/src/lib.rs": { - "dot_ok": 2, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-core/src/keybind.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-markdown/src/lib.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 2 - }, - "crates/jcode-tui-markdown/src/markdown_context.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-markdown/src/markdown_render_full.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-markdown/src/markdown_render_lazy.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-markdown/src/markdown_wrap.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-tui-mermaid/src/debug.rs": { - "dot_ok": 0, - "let_underscore": 1, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-mermaid/src/lib.rs": { - "dot_ok": 1, - "let_underscore": 4, - "unwrap_or_default": 2 - }, - "crates/jcode-tui-mermaid/src/mermaid_active.rs": { - "dot_ok": 3, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-tui-mermaid/src/mermaid_cache_render.rs": { - "dot_ok": 7, - "let_underscore": 2, - "unwrap_or_default": 1 - }, - "crates/jcode-tui-mermaid/src/mermaid_content.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-mermaid/src/mermaid_debug.rs": { - "dot_ok": 1, - "let_underscore": 2, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-mermaid/src/mermaid_runtime.rs": { - "dot_ok": 9, - "let_underscore": 2, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-mermaid/src/mermaid_svg.rs": { - "dot_ok": 19, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-mermaid/src/mermaid_viewport.rs": { - "dot_ok": 2, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-tui-mermaid/src/mermaid_widget.rs": { - "dot_ok": 2, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-render/src/layout.rs": { - "dot_ok": 4, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui-workspace/src/workspace_map.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 1 - }, - "crates/jcode-tui/src/tui/app.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 2 - }, - "crates/jcode-tui/src/tui/app/at_picker.rs": { - "dot_ok": 2, - "let_underscore": 1, - "unwrap_or_default": 1 - }, - "crates/jcode-tui/src/tui/app/auth.rs": { - "dot_ok": 6, - "let_underscore": 2, - "unwrap_or_default": 11 - }, - "crates/jcode-tui/src/tui/app/auth_account_commands.rs": { - "dot_ok": 0, - "let_underscore": 3, - "unwrap_or_default": 3 - }, - "crates/jcode-tui/src/tui/app/auth_account_picker.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 9 - }, - "crates/jcode-tui/src/tui/app/auth_account_picker_saved_accounts.rs": { - "dot_ok": 0, - "let_underscore": 0, - "unwrap_or_default": 4 - }, - "crates/jcode-tui/src/tui/app/catchup.rs": { - "dot_ok": 0, - "let_underscore": 1, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/commands.rs": { - "dot_ok": 4, - "let_underscore": 13, - "unwrap_or_default": 21 - }, - "crates/jcode-tui/src/tui/app/commands_improve.rs": { - "dot_ok": 0, - "let_underscore": 2, - "unwrap_or_default": 8 - }, - "crates/jcode-tui/src/tui/app/commands_overnight.rs": { - "dot_ok": 5, - "let_underscore": 2, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/commands_review.rs": { - "dot_ok": 5, - "let_underscore": 6, - "unwrap_or_default": 6 - }, - "crates/jcode-tui/src/tui/app/conversation_state.rs": { - "dot_ok": 0, - "let_underscore": 4, - "unwrap_or_default": 1 - }, - "crates/jcode-tui/src/tui/app/copy_selection.rs": { + "crates/jcode-hooks/src/cli.rs": { "dot_ok": 0, "let_underscore": 0, "unwrap_or_default": 2 }, - "crates/jcode-tui/src/tui/app/debug.rs": { - "dot_ok": 0, - "let_underscore": 2, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/debug_bench.rs": { - "dot_ok": 2, - "let_underscore": 1, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/debug_cmds.rs": { - "dot_ok": 5, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/debug_profile.rs": { + "crates/jcode-hooks/src/config.rs": { "dot_ok": 2, "let_underscore": 0, "unwrap_or_default": 0 }, - "crates/jcode-tui/src/tui/app/debug_script.rs": { - "dot_ok": 1, - "let_underscore": 4, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/dictation.rs": { - "dot_ok": 0, - "let_underscore": 5, - "unwrap_or_default": 1 - }, - "crates/jcode-tui/src/tui/app/handterm_native_scroll.rs": { - "dot_ok": 1, - "let_underscore": 4, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/helpers.rs": { - "dot_ok": 12, - "let_underscore": 6, - "unwrap_or_default": 6 - }, - "crates/jcode-tui/src/tui/app/inline_interactive.rs": { - "dot_ok": 5, - "let_underscore": 12, - "unwrap_or_default": 3 - }, - "crates/jcode-tui/src/tui/app/inline_interactive/helpers.rs": { + "crates/jcode-hooks/src/dispatch.rs": { "dot_ok": 0, "let_underscore": 0, "unwrap_or_default": 1 }, - "crates/jcode-tui/src/tui/app/inline_interactive/preview.rs": { - "dot_ok": 0, - "let_underscore": 1, - "unwrap_or_default": 1 - }, - "crates/jcode-tui/src/tui/app/input.rs": { - "dot_ok": 5, - "let_underscore": 6, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/local.rs": { - "dot_ok": 0, - "let_underscore": 5, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/model_context.rs": { - "dot_ok": 1, - "let_underscore": 4, - "unwrap_or_default": 4 - }, - "crates/jcode-tui/src/tui/app/onboarding_flow.rs": { - "dot_ok": 1, - "let_underscore": 0, - "unwrap_or_default": 0 - }, - "crates/jcode-tui/src/tui/app/onboarding_flow_control.rs": { - "dot_ok": 3, - "let_underscore": 2, "unwrap_or_default": 1 }, "crates/jcode-tui/src/tui/app/remote.rs": { diff --git a/scripts/test_size_budget.json b/scripts/test_size_budget.json index 57eab8287..04861b6e1 100644 --- a/scripts/test_size_budget.json +++ b/scripts/test_size_budget.json @@ -2,11 +2,11 @@ "threshold_loc": 1200, "tracked_files": { "crates/jcode-app-core/src/server/provider_control_tests.rs": 1203, - "crates/jcode-base/src/live_tests.rs": 2886, - "crates/jcode-base/src/provider/anthropic_tests.rs": 1281, - "crates/jcode-base/src/provider/openrouter_tests.rs": 1813, - "crates/jcode-base/src/provider/tests/model_resolution.rs": 1573, - "crates/jcode-base/src/session_tests/cases.rs": 1703, + "crates/jcode-base/src/live_tests.rs": 2880, + "crates/jcode-base/src/provider/anthropic_tests.rs": 1210, + "crates/jcode-base/src/provider/openrouter_tests.rs": 1792, + "crates/jcode-base/src/provider/tests/model_resolution.rs": 1565, + "crates/jcode-base/src/session_tests/cases.rs": 1569, "crates/jcode-desktop/src/main_tests.rs": 10143, "crates/jcode-desktop/src/session_launch/tests.rs": 1207, "crates/jcode-desktop/src/single_session_render/tests.rs": 1936, diff --git a/src/cli/tui_launch.rs b/src/cli/tui_launch.rs index 4caed1db5..55fbfb7be 100644 --- a/src/cli/tui_launch.rs +++ b/src/cli/tui_launch.rs @@ -452,7 +452,22 @@ pub fn list_sessions() -> Result<()> { exe.to_path_buf(), vec![ "--resume".to_string(), - crate::import::imported_opencode_session_id(session_id), + crate::casr_adapter::imported_opencode_session_id(session_id), + ], + ), + jcode_tui_session_picker::ResumeTarget::ForeignSession { + provider_slug, + session_id, + .. + } => ( + exe.to_path_buf(), + vec![ + "--resume".to_string(), + crate::casr_adapter::imported_session_id_for_provider( + provider_slug, + session_id, + ), + ], ), } @@ -508,7 +523,33 @@ pub fn list_sessions() -> Result<()> { if targets.len() == 1 { let target = &targets[0]; - let resolved_target = crate::import::resolve_resume_target_to_jcode(target)?; + let resolved_target = match target { + jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } => { + session_id.clone() + } + jcode_tui_session_picker::ResumeTarget::ClaudeCodeSession { + session_id, + .. + } => crate::casr_adapter::imported_claude_code_session_id(session_id), + jcode_tui_session_picker::ResumeTarget::CodexSession { session_id, .. } => { + crate::casr_adapter::imported_codex_session_id(session_id) + } + jcode_tui_session_picker::ResumeTarget::PiSession { session_path } => { + crate::casr_adapter::imported_pi_session_id(session_path) + } + jcode_tui_session_picker::ResumeTarget::OpenCodeSession { + session_id, .. + } => crate::casr_adapter::imported_opencode_session_id(session_id), + jcode_tui_session_picker::ResumeTarget::ForeignSession { + provider_slug, + session_id, + .. + } => crate::casr_adapter::imported_session_id_for_provider( + provider_slug, + session_id, + ), + }; + let mut session_cwd = cwd.clone(); if let jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } = &resolved_target @@ -531,14 +572,34 @@ pub fn list_sessions() -> Result<()> { let mut warned_no_terminal = false; for target in targets { - let resolved_target = - match crate::import::resolve_resume_target_to_jcode(&target) { - Ok(target) => target, - Err(e) => { - eprintln!("Failed to import selected session: {}", e); - continue; - } - }; + let resolved_target = match &target { + jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } => { + session_id.clone() + } + jcode_tui_session_picker::ResumeTarget::ClaudeCodeSession { + session_id, + .. + } => crate::casr_adapter::imported_claude_code_session_id(session_id), + jcode_tui_session_picker::ResumeTarget::CodexSession { + session_id, .. + } => crate::casr_adapter::imported_codex_session_id(session_id), + jcode_tui_session_picker::ResumeTarget::PiSession { session_path } => { + crate::casr_adapter::imported_pi_session_id(session_path) + } + jcode_tui_session_picker::ResumeTarget::OpenCodeSession { + session_id, + .. + } => crate::casr_adapter::imported_opencode_session_id(session_id), + jcode_tui_session_picker::ResumeTarget::ForeignSession { + provider_slug, + session_id, + .. + } => crate::casr_adapter::imported_session_id_for_provider( + provider_slug, + session_id, + ), + }; + let mut session_cwd = cwd.clone(); if let jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } = &resolved_target @@ -592,6 +653,28 @@ pub fn list_sessions() -> Result<()> { eprintln!("Failed to import selected session: {}", e); continue; } + jcode_tui_session_picker::ResumeTarget::ClaudeCodeSession { + session_id, + .. + } => crate::casr_adapter::imported_claude_code_session_id(session_id), + jcode_tui_session_picker::ResumeTarget::CodexSession { session_id, .. } => { + crate::casr_adapter::imported_codex_session_id(session_id) + } + jcode_tui_session_picker::ResumeTarget::PiSession { session_path } => { + crate::casr_adapter::imported_pi_session_id(session_path) + } + jcode_tui_session_picker::ResumeTarget::OpenCodeSession { + session_id, .. + } => crate::casr_adapter::imported_opencode_session_id(session_id), + jcode_tui_session_picker::ResumeTarget::ForeignSession { + provider_slug, + session_id, + .. + } => crate::casr_adapter::imported_session_id_for_provider( + provider_slug, + session_id, + ), + }; let mut session_cwd = cwd.clone(); if let jcode_tui_session_picker::ResumeTarget::JcodeSession { session_id } = diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs new file mode 100644 index 000000000..8a40e7533 --- /dev/null +++ b/src/hooks/mod.rs @@ -0,0 +1,6 @@ +//! Hooks module — re-exports from the `jcode-hooks` crate. +//! +//! This thin wrapper allows existing `crate::hooks::` import paths to keep +//! working while the actual implementation lives in `crates/jcode-hooks`. + +pub use jcode_hooks::*; diff --git a/src/lib.rs b/src/lib.rs index 104ff91ca..8f723b543 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -23,6 +23,20 @@ pub use jcode_tui::*; // Cli + entrypoint layer (kept in the root crate). pub mod cli; +pub mod crash_log; +pub mod customization; +pub mod extension_policy; +pub mod floating_diagram; +pub mod hooks; +pub mod model_failover; +pub mod model_routing; +pub mod orchestration_api; +pub mod prefix_cache_stable; +pub mod skill_disable; +pub mod skill_distillation; +pub mod theme; +pub mod turborag; + use anyhow::Result; diff --git a/tests/hooks_integration.rs b/tests/hooks_integration.rs new file mode 100644 index 000000000..81719dae5 --- /dev/null +++ b/tests/hooks_integration.rs @@ -0,0 +1,766 @@ +//! Integration tests for the jcode-hooks crate. +//! +//! Exercises the full hook lifecycle: config parsing, registry construction, +//! matcher filtering, condition evaluation, parallel dispatch, and aggregated +//! decision logic. + +use jcode_hooks::dispatch::aggregate_decision; +use jcode_hooks::{ + AgentHandlerConfig, AggregatedDecision, ClassifiedOutcome, CommandHandlerConfig, + DispatchConfig, HookContext, HookEvent, HookHandlerConfig, HookInput, HookInputBuilder, + HookMatcher, HookRegistry, HookSettings, HooksConfig, HttpHandlerConfig, MatcherContext, + PluginHandlerConfig, dispatch_hooks, matches, +}; + +// =========================================================================== +// test_hooks_full_flow (config -> registry -> dispatch -> decision) +// =========================================================================== + +#[tokio::test] +async fn test_hooks_full_flow() { + // Step 1: Build a HooksConfig from TOML (simulating a config file) + let toml_str = r#" +[settings] +timeout_secs = 15 +max_concurrency = 5 +dry_run = true +fail_closed = false + +[[events.PreToolUse]] +type = "command" +command = "security_check.sh" +enabled = true +timeout_secs = 10 +matcher = "Bash|Write" + +[[events.PreToolUse]] +type = "command" +command = "audit_log.sh" +enabled = true + +[[events.SessionStart]] +type = "command" +command = "init_session.sh" +enabled = true + +[[events.Stop]] +type = "http" +url = "http://localhost:9090/hooks/stop" +method = "POST" +timeout_secs = 5 +"#; + + let config: HooksConfig = toml::from_str(toml_str).unwrap(); + + // Verify config parsing + assert_eq!(config.settings.timeout_secs, 15); + assert_eq!(config.settings.max_concurrency, 5); + assert!(config.settings.dry_run); + assert!(!config.is_empty()); + assert_eq!(config.events["PreToolUse"].len(), 2); + assert_eq!(config.events["SessionStart"].len(), 1); + assert_eq!(config.events["Stop"].len(), 1); + + // Step 2: Build registry from config + let registry = HookRegistry::from_config(config); + assert!(!registry.is_empty()); + + // Step 3: Build context for a PreToolUse event on "Bash" + let context = HookContext::for_tool( + "Bash".to_string(), + "ses_flow_001".to_string(), + "/home/user/project".to_string(), + ); + + // Step 4: Get matching handlers + let matching = registry.get_matching(&HookEvent::PreToolUse, &context); + // Both handlers should match: security_check has matcher "Bash|Write" (Bash matches), + // audit_log has no matcher (wildcard). + assert_eq!( + matching.len(), + 2, + "both PreToolUse handlers should match Bash" + ); + + // Step 5: Build HookInput via the builder + let input = HookInputBuilder::new() + .session("ses_flow_001", "/home/user/project") + .event("PreToolUse") + .agent("agent_1", "default") + .tool( + "Bash", + serde_json::json!({"command": "ls -la"}), + "tool_use_1", + ) + .build(); + + assert_eq!(input.schema_version, "2.0"); + assert_eq!(input.hook_event_name, "PreToolUse"); + assert_eq!(input.tool_name, Some("Bash".to_string())); + + // Step 6: Dispatch (dry-run mode -- handlers are resolved but not executed) + let dispatch_config = DispatchConfig::from_settings(&HookSettings { + timeout_secs: 15, + max_concurrency: 5, + dry_run: true, + fail_closed: false, + }); + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &matching, &dispatch_config).await; + + assert_eq!(stats.total_dispatched, 2); + assert_eq!(stats.allowed, 2); + assert_eq!(stats.failed, 0); + assert!(stats.all_succeeded()); + assert!(!stats.any_denied()); + + // Step 7: Aggregate decision (all allowed -> Allow) + let outcomes: Vec = stats + .results + .iter() + .map(|r| match &r.outcome { + ClassifiedOutcome::Allow => ClassifiedOutcome::Allow, + ClassifiedOutcome::Ask { reason } => ClassifiedOutcome::Ask { + reason: reason.clone(), + }, + ClassifiedOutcome::Deny { reason } => ClassifiedOutcome::Deny { + reason: reason.clone(), + }, + ClassifiedOutcome::Failed { error } => ClassifiedOutcome::Failed { + error: error.clone(), + }, + }) + .collect(); + + let decision = aggregate_decision(&outcomes, false); + assert!( + matches!(decision, AggregatedDecision::Allow), + "all dry-run handlers should result in Allow" + ); + + // Also verify the full serialization round-trip of the input + let json = serde_json::to_string_pretty(&input).unwrap(); + let reparsed: HookInput = serde_json::from_str(&json).unwrap(); + assert_eq!(reparsed.session_id, "ses_flow_001"); + assert_eq!(reparsed.tool_name, Some("Bash".to_string())); +} + +// =========================================================================== +// test_parallel_hook_execution (multiple hooks concurrent) +// =========================================================================== + +#[tokio::test] +async fn test_parallel_hook_execution() { + // Use dry-run mode to verify multiple handlers are dispatched concurrently. + // In dry-run mode all handlers report Allow without actually running. + let config = DispatchConfig { + dry_run: true, + max_concurrency: 3, + timeout_secs: 10, + fail_closed: false, + }; + + let input = HookInputBuilder::new() + .session("ses_parallel", "/workspace") + .event("PreToolUse") + .tool("Bash", serde_json::json!({"command": "test"}), "tu_p1") + .build(); + + // Register 5 command handlers (all enabled, no matcher = wildcard) + let handlers: Vec = (0..5) + .map(|i| { + HookHandlerConfig::Command(CommandHandlerConfig { + enabled: true, + command: format!("parallel_hook_{}.sh", i), + ..Default::default() + }) + }) + .collect(); + + let refs: Vec<&HookHandlerConfig> = handlers.iter().collect(); + + let stats = dispatch_hooks(&HookEvent::PreToolUse, &input, &refs, &config).await; + + assert_eq!( + stats.total_dispatched, 5, + "all 5 handlers should be dispatched" + ); + assert_eq!(stats.allowed, 5, "all dry-run handlers should be allowed"); + assert_eq!(stats.failed, 0, "no handler should fail in dry-run"); + assert_eq!(stats.results.len(), 5); + assert!(stats.all_succeeded()); + + // Verify each handler has a distinct label + let mut labels: Vec<&str> = stats + .results + .iter() + .map(|r| r.handler_label.as_str()) + .collect(); + labels.sort(); + labels.dedup(); + assert_eq!(labels.len(), 5, "all handler labels should be unique"); + + // Verify total_duration is non-negative (Duration::ZERO or greater) + // In dry-run mode, this should be very fast. + let _ = stats.total_duration; // just access to confirm no panic + + // Verify that concurrency is bounded by the semaphore + let config_bounded = DispatchConfig { + dry_run: true, + max_concurrency: 2, // only 2 at a time + timeout_secs: 10, + fail_closed: false, + }; + + let handlers_bounded: Vec = (0..4) + .map(|i| { + HookHandlerConfig::Command(CommandHandlerConfig { + command: format!("bounded_{}.sh", i), + ..Default::default() + }) + }) + .collect(); + + let refs_bounded: Vec<&HookHandlerConfig> = handlers_bounded.iter().collect(); + let stats_bounded = dispatch_hooks( + &HookEvent::PreToolUse, + &input, + &refs_bounded, + &config_bounded, + ) + .await; + + assert_eq!(stats_bounded.total_dispatched, 4); + assert_eq!(stats_bounded.allowed, 4); + assert!(stats_bounded.all_succeeded()); +} + +// =========================================================================== +// test_config_layer_merge (3-layer merge) +// =========================================================================== + +#[test] +fn test_config_layer_merge() { + // Layer 1 (lowest priority): user-level config + let mut layer1 = HooksConfig::default(); + layer1.settings.timeout_secs = 10; + layer1.settings.max_concurrency = 5; + layer1.settings.dry_run = false; + layer1.settings.fail_closed = false; + + layer1 + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "user_security.sh".to_string(), + enabled: true, + ..Default::default() + })); + layer1 + .events + .entry("SessionStart".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "user_init.sh".to_string(), + enabled: true, + ..Default::default() + })); + + // Layer 2 (mid priority): project-level config + let mut layer2 = HooksConfig::default(); + layer2.settings.timeout_secs = 30; // override + layer2.settings.dry_run = true; // override + + layer2 + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "project_lint.sh".to_string(), + enabled: true, + matcher: Some(HookMatcher::Multi(vec![ + "Bash".to_string(), + "Write".to_string(), + ])), + ..Default::default() + })); + layer2 + .events + .entry("Stop".to_string()) + .or_default() + .push(HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://localhost:8080/stop".to_string(), + ..Default::default() + })); + + // Layer 3 (highest priority): env-level config + let mut layer3 = HooksConfig::default(); + layer3.settings.timeout_secs = 60; // final override + layer3.settings.max_concurrency = 5; // final override (explicit) + layer3.settings.dry_run = true; // final override (explicit) + layer3.settings.fail_closed = true; // final override + + layer3 + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "env_override.sh".to_string(), + enabled: true, + matcher: Some(HookMatcher::Exact("Read".to_string())), + ..Default::default() + })); + + // Merge: layer1 <- layer2 <- layer3 + layer1.merge(layer2); + layer1.merge(layer3); + + // Settings: layer3 wins on all overridden fields + assert_eq!( + layer1.settings.timeout_secs, 60, + "layer3 timeout_secs should win" + ); + assert_eq!( + layer1.settings.max_concurrency, 5, + "layer1 max_concurrency should be preserved (no override)" + ); + assert!( + layer1.settings.dry_run, + "layer2 dry_run=true should be preserved (layer3 did not override)" + ); + assert!( + layer1.settings.fail_closed, + "layer3 fail_closed=true should win" + ); + + // Events: handlers are APPENDED across layers + // PreToolUse should have 3 handlers (1 from layer1 + 1 from layer2 + 1 from layer3) + let pre_tool_handlers = &layer1.events["PreToolUse"]; + assert_eq!( + pre_tool_handlers.len(), + 3, + "PreToolUse should have 3 handlers from 3 layers" + ); + + // Verify handler order (append order) + match &pre_tool_handlers[0] { + HookHandlerConfig::Command(cmd) => assert_eq!(cmd.command, "user_security.sh"), + _ => panic!("expected Command from layer1"), + } + match &pre_tool_handlers[1] { + HookHandlerConfig::Command(cmd) => assert_eq!(cmd.command, "project_lint.sh"), + _ => panic!("expected Command from layer2"), + } + match &pre_tool_handlers[2] { + HookHandlerConfig::Command(cmd) => assert_eq!(cmd.command, "env_override.sh"), + _ => panic!("expected Command from layer3"), + } + + // SessionStart: only from layer1 (layer2 and layer3 didn't add to it) + assert_eq!(layer1.events["SessionStart"].len(), 1); + + // Stop: only from layer2 + assert_eq!(layer1.events["Stop"].len(), 1); + match &layer1.events["Stop"][0] { + HookHandlerConfig::Http(http) => { + assert_eq!(http.url, "http://localhost:8080/stop"); + } + _ => panic!("expected Http from layer2"), + } + + // Total unique event keys: PreToolUse, SessionStart, Stop + assert_eq!(layer1.events.len(), 3); +} + +// =========================================================================== +// test_matcher_filtering (registry filters by matcher) +// =========================================================================== + +#[test] +fn test_matcher_filtering() { + // Build a config with handlers using various matcher types + let mut config = HooksConfig::default(); + + // Handler 1: Exact matcher for "Bash" + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "bash_only.sh".to_string(), + matcher: Some(HookMatcher::Exact("Bash".to_string())), + ..Default::default() + })); + + // Handler 2: Multi matcher for "Write" or "Edit" + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "write_or_edit.sh".to_string(), + matcher: Some(HookMatcher::Multi(vec![ + "Write".to_string(), + "Edit".to_string(), + ])), + ..Default::default() + })); + + // Handler 3: Regex matcher for any tool starting with "Read" + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "read_pattern.sh".to_string(), + matcher: Some(HookMatcher::Regex("^Read".to_string())), + ..Default::default() + })); + + // Handler 4: Wildcard (no matcher = always matches) + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "catch_all.sh".to_string(), + matcher: None, // no matcher = wildcard + ..Default::default() + })); + + // Handler 5: HTTP handler with exact matcher for "Bash" + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://localhost/bash-hook".to_string(), + matcher: Some(HookMatcher::Exact("Bash".to_string())), + ..Default::default() + })); + + let registry = HookRegistry::from_config(config); + + // Context for "Bash" tool + let bash_ctx = HookContext::for_tool( + "Bash".to_string(), + "ses_match_1".to_string(), + "/project".to_string(), + ); + + let matching = registry.get_matching(&HookEvent::PreToolUse, &bash_ctx); + // Should match: bash_only (exact), catch_all (wildcard), http bash-hook (exact) + // Should NOT match: write_or_edit (multi: Write|Edit), read_pattern (regex: ^Read) + assert_eq!( + matching.len(), + 3, + "Bash should match: exact 'Bash', wildcard, and http 'Bash' -- got {:?}", + matching + .iter() + .map(|h| format!("{:?}", h)) + .collect::>() + ); + + // Verify the matched handlers are the right ones + let matched_commands: Vec<&str> = matching + .iter() + .filter_map(|h| match h { + HookHandlerConfig::Command(cmd) => Some(cmd.command.as_str()), + _ => None, + }) + .collect(); + assert!(matched_commands.contains(&"bash_only.sh")); + assert!(matched_commands.contains(&"catch_all.sh")); + assert!(!matched_commands.contains(&"write_or_edit.sh")); + assert!(!matched_commands.contains(&"read_pattern.sh")); + + let has_http = matching + .iter() + .any(|h| matches!(h, HookHandlerConfig::Http(_))); + assert!(has_http, "HTTP handler for Bash should be matched"); + + // Context for "Write" tool + let write_ctx = HookContext::for_tool( + "Write".to_string(), + "ses_match_2".to_string(), + "/project".to_string(), + ); + + let matching = registry.get_matching(&HookEvent::PreToolUse, &write_ctx); + // Should match: write_or_edit (multi), catch_all (wildcard) + assert_eq!( + matching.len(), + 2, + "Write should match: multi 'Write|Edit' and wildcard" + ); + + // Context for "Read" tool + let read_ctx = HookContext::for_tool( + "Read".to_string(), + "ses_match_3".to_string(), + "/project".to_string(), + ); + + let matching = registry.get_matching(&HookEvent::PreToolUse, &read_ctx); + // Should match: read_pattern (regex ^Read), catch_all (wildcard) + assert_eq!( + matching.len(), + 2, + "Read should match: regex '^Read' and wildcard" + ); + + // Context for "Glob" tool (no specific matcher matches, only wildcard) + let glob_ctx = HookContext::for_tool( + "Glob".to_string(), + "ses_match_4".to_string(), + "/project".to_string(), + ); + + let matching = registry.get_matching(&HookEvent::PreToolUse, &glob_ctx); + // Should match only: catch_all (wildcard) + assert_eq!( + matching.len(), + 1, + "Glob should only match the wildcard handler" + ); + + // Context for a non-matching event + let matching = registry.get_matching(&HookEvent::PostToolUse, &bash_ctx); + assert!( + matching.is_empty(), + "no PostToolUse handlers configured, should be empty" + ); + + // Direct matcher function tests for completeness + assert!(matches( + &HookMatcher::Wildcard, + &MatcherContext::new("Anything") + )); + assert!(matches( + &HookMatcher::Exact("Bash".to_string()), + &MatcherContext::new("Bash") + )); + assert!(!matches( + &HookMatcher::Exact("Bash".to_string()), + &MatcherContext::new("Write") + )); + assert!(matches( + &HookMatcher::Multi(vec!["Bash".to_string(), "Write".to_string()]), + &MatcherContext::new("Write") + )); + assert!(!matches( + &HookMatcher::Multi(vec!["Bash".to_string(), "Write".to_string()]), + &MatcherContext::new("Read") + )); +} + +// =========================================================================== +// test_condition_evaluation (if_ conditions) +// =========================================================================== + +#[test] +fn test_condition_evaluation() { + // Build config with handlers that have `if_` conditions + let mut config = HooksConfig::default(); + + // Handler 1: only runs when tool_name=Bash + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "bash_security.sh".to_string(), + if_: Some("tool_name=Bash".to_string()), + ..Default::default() + })); + + // Handler 2: only runs when tool_name=Write (positive match for a different tool) + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "write_only.sh".to_string(), + if_: Some("tool_name=Write".to_string()), + ..Default::default() + })); + + // Handler 3: only runs when agent_type=coder + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "coder_only.sh".to_string(), + if_: Some("agent_type=coder".to_string()), + ..Default::default() + })); + + // Handler 4: no condition (always runs) + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "always_run.sh".to_string(), + ..Default::default() + })); + + // Handler 5: condition with permission_mode + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "auto_approve.sh".to_string(), + if_: Some("permission_mode=auto".to_string()), + ..Default::default() + })); + + // Handler 6: HTTP handler with condition + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Http(HttpHandlerConfig { + url: "http://localhost/hook".to_string(), + if_: Some("tool_name=Bash".to_string()), + ..Default::default() + })); + + // Handler 7: Plugin handler with condition + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Plugin(PluginHandlerConfig { + path: "/usr/bin/plugin".to_string(), + if_: Some("tool_name=Write".to_string()), + ..Default::default() + })); + + // Handler 8: Agent handler with condition + config + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Agent(AgentHandlerConfig { + agent_id: "test_agent".to_string(), + if_: Some("agent_type=coder".to_string()), + ..Default::default() + })); + + let registry = HookRegistry::from_config(config); + + // Context: tool_name=Bash, agent_type=default (no permission_mode) + let bash_default_ctx = HookContext::for_tool( + "Bash".to_string(), + "ses_cond_1".to_string(), + "/project".to_string(), + ); + + let matching = registry.get_matching(&HookEvent::PreToolUse, &bash_default_ctx); + // Expected matches: + // - bash_security.sh (tool_name=Bash, condition met) + // - always_run.sh (no condition) + // - http hook (tool_name=Bash, condition met) + // NOT matched: + // - write_only.sh (tool_name=Write, but tool is Bash) + // - coder_only.sh (agent_type=coder, but context has no agent_type) + // - auto_approve.sh (permission_mode=auto, but context has no permission_mode) + // - plugin (tool_name=Write, but context is Bash) + // - agent (agent_type=coder, but context has no agent_type) + assert_eq!( + matching.len(), + 3, + "Bash + default agent should match: bash_security, always_run, http -- got {:?}", + matching + .iter() + .map(|h| format!("{:?}", h)) + .collect::>() + ); + + // Context: tool_name=Write, agent_type=coder + let mut write_coder_ctx = HookContext::for_tool( + "Write".to_string(), + "ses_cond_2".to_string(), + "/project".to_string(), + ); + write_coder_ctx.agent_type = Some("coder".to_string()); + + let matching = registry.get_matching(&HookEvent::PreToolUse, &write_coder_ctx); + // Expected matches: + // - write_only.sh (tool_name=Write, condition met) + // - coder_only.sh (agent_type=coder, condition met) + // - always_run.sh (no condition) + // - plugin (tool_name=Write, condition met) + // - agent (agent_type=coder, condition met) + // NOT matched: + // - bash_security.sh (tool_name=Bash, fails since tool is Write) + // - auto_approve.sh (permission_mode=auto, no permission_mode in context) + // - http hook (tool_name=Bash, fails since tool is Write) + assert_eq!( + matching.len(), + 5, + "Write + coder should match 5 handlers -- got {:?}", + matching + .iter() + .map(|h| format!("{:?}", h)) + .collect::>() + ); + + // Context: tool_name=Bash, permission_mode=auto + let mut bash_auto_ctx = HookContext::for_tool( + "Bash".to_string(), + "ses_cond_3".to_string(), + "/project".to_string(), + ); + bash_auto_ctx.permission_mode = Some("auto".to_string()); + + let matching = registry.get_matching(&HookEvent::PreToolUse, &bash_auto_ctx); + // Expected matches: + // - bash_security.sh (tool_name=Bash) + // - always_run.sh (no condition) + // - auto_approve.sh (permission_mode=auto) + // - http hook (tool_name=Bash) + // NOT matched: + // - non_bash_handler.sh (tool_name!=Bash) + // - coder_only.sh (agent_type=coder, no agent_type) + // - plugin (tool_name=Write, tool is Bash) + // - agent (agent_type=coder, no agent_type) + assert_eq!( + matching.len(), + 4, + "Bash + auto permission should match 4 handlers -- got {:?}", + matching + .iter() + .map(|h| format!("{:?}", h)) + .collect::>() + ); + + // Verify condition with unknown field passes through (returns true) + let mut config_unknown = HooksConfig::default(); + config_unknown + .events + .entry("PreToolUse".to_string()) + .or_default() + .push(HookHandlerConfig::Command(CommandHandlerConfig { + command: "unknown_field.sh".to_string(), + if_: Some("unknown_field=value".to_string()), + ..Default::default() + })); + + let registry_unknown = HookRegistry::from_config(config_unknown); + let ctx = HookContext::for_tool( + "Bash".to_string(), + "ses_cond_4".to_string(), + "/project".to_string(), + ); + let matching = registry_unknown.get_matching(&HookEvent::PreToolUse, &ctx); + assert_eq!( + matching.len(), + 1, + "unknown condition field should pass through (allow by default)" + ); +}