From afb7ecaf3f1cc44169252bc8dc0756f342350b3e Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:09:24 +0530 Subject: [PATCH 01/10] feat(documents): read spreadsheets, Word, decks, PDFs and barcodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn on io-harness's `documents` feature — the umbrella it already declares as `xlsx`, `docx`, `pptx`, `pdf` and `barcode`, which is exactly the five formats this release names. One word rather than five, because the harness had already grouped them and a list written out here would be a second copy to keep true. The agent gains twelve tools, and six of them write: `xlsx_write`, `xlsx_set_cell`, `docx_write`, `pdf_write`, `pdf_watermark` and `pdf_fill_form`. io-cli cannot take a tool out of io-harness's workspace set, so this is stated in the README and the CHANGELOG rather than left to be discovered — the same rule the `media` comment has followed since 0.9.0. `barcode` is `rxing`, and every published `rxing 0.9.x` pins `image` at exactly `=0.25.8`. This crate asks for `^0.25` and the lockfile stood at 0.25.10, which cargo cannot reconcile, so the lock moves down a patch to the version both requirements accept. It costs 159 transitive crates, and it means the `default-features = false` argument on `image` no longer wins: `rxing` and `docx-rs` both take that crate with its defaults, and features unify across a graph. Both consequences are written at the pin. No direct dependency is added. The ten-crate direct set is unchanged. --- Cargo.lock | 1716 +++++++++++++++++++++++++++++++++++++++++++++++++--- Cargo.toml | 27 +- 2 files changed, 1666 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1f50557..0a9c1a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,59 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "adobe-cmap-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" +dependencies = [ + "pom", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -17,6 +64,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -97,6 +153,39 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "atoi_simd" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" +dependencies = [ + "debug_unsafe", + "rustversion", +] + [[package]] name = "atomic" version = "0.6.1" @@ -118,6 +207,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + [[package]] name = "aws-lc-rs" version = "1.18.0" @@ -192,6 +304,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "1.3.2" @@ -204,6 +322,12 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "bitstream-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2" + [[package]] name = "block-buffer" version = "0.10.4" @@ -213,6 +337,39 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "built" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b" + [[package]] name = "bumpalo" version = "3.20.3" @@ -231,6 +388,12 @@ version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "byteorder-lite" version = "0.1.0" @@ -243,6 +406,23 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "calamine" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa68281b1a76b54a62156474adb06bb380a67e07dd60656e3217152b42183f3" +dependencies = [ + "atoi_simd", + "byteorder", + "codepage", + "encoding_rs", + "fast-float2", + "log", + "quick-xml", + "serde", + "zip", +] + [[package]] name = "castaway" version = "0.2.4" @@ -252,6 +432,24 @@ dependencies = [ "rustversion", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.4.3" @@ -264,6 +462,33 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" +dependencies = [ + "fnv", + "uuid", + "web-time", +] + +[[package]] +name = "cff-parser" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5810ca1a2b5870df2aab1c03e11c40c361ba51d6e3e361e56310f1cb3b4e087" + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -299,6 +524,36 @@ dependencies = [ "windows-link", ] +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.6" @@ -348,6 +603,30 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "codepage-437" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" +dependencies = [ + "csv", +] + [[package]] name = "color_quant" version = "1.1.0" @@ -384,6 +663,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "convert_case" version = "0.10.0" @@ -409,6 +694,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -442,6 +733,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + [[package]] name = "crossterm" version = "0.28.1" @@ -501,6 +817,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -508,7 +833,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", - "phf", + "phf 0.11.3", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -545,6 +900,43 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + [[package]] name = "deltae" version = "0.3.2" @@ -585,17 +977,29 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "proc-macro2", + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", "quote", "syn 3.0.3", ] @@ -609,18 +1013,82 @@ dependencies = [ "litrs", ] +[[package]] +name = "docx-rs" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fdf00e8af6d0b3e92d4bbf9b76f773d8b84ea80f310324ad16cbdc2e653e02c" +dependencies = [ + "base64 0.22.1", + "crc32fast", + "image", + "quick-xml", + "serde", + "serde_json", + "smallvec", + "thiserror 2.0.20", + "zip", +] + [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecb" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8bfa975b1aec2145850fcaa1c6fe269a16578c44705a532ae3edc92b8881c7" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ecb" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26f2a8b3e564eba0877223dc343703ad0385794e882e6d13f3a4dd5c6b1f41ac" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "either" version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -637,6 +1105,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.20.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +dependencies = [ + "num-traits", +] + [[package]] name = "euclid" version = "0.22.14" @@ -646,6 +1123,23 @@ dependencies = [ "num-traits", ] +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -679,6 +1173,23 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + [[package]] name = "fastrand" version = "2.5.0" @@ -737,6 +1248,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -905,12 +1417,12 @@ dependencies = [ [[package]] name = "gif" -version = "0.14.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.12", ] [[package]] @@ -967,6 +1479,24 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "html5gum" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "428502d3ec1742c35e015871aef742bc4722a9acfcb616b8ff79b519922e1c36" +dependencies = [ + "jetscii", +] + [[package]] name = "http" version = "1.5.0" @@ -1006,6 +1536,15 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -1200,18 +1739,23 @@ dependencies = [ [[package]] name = "image" -version = "0.25.10" +version = "0.25.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" dependencies = [ "bytemuck", "byteorder-lite", "color_quant", + "exr", "gif", "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", "zune-core", "zune-jpeg", @@ -1227,6 +1771,36 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imageproc" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602b4e8a4cc3e98372b766cd184ab532999bc0e839b7469e759511ccabc65d77" +dependencies = [ + "ab_glyph", + "approx", + "getrandom 0.2.17", + "image", + "itertools 0.12.1", + "nalgebra", + "num", + "rand 0.8.8", + "rand_distr", + "rayon", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + [[package]] name = "indexmap" version = "2.14.0" @@ -1246,6 +1820,26 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding 0.3.3", + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding 0.4.2", + "hybrid-array", +] + [[package]] name = "instability" version = "0.3.13" @@ -1259,9 +1853,20 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "io-cli" -version = "0.19.0" +version = "0.20.0" dependencies = [ "clap", "crossterm 0.28.1", @@ -1273,7 +1878,7 @@ dependencies = [ "syntect", "tempfile", "tokio", - "toml", + "toml 1.1.4+spec-1.1.0", ] [[package]] @@ -1283,21 +1888,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c9b7027129f38c071489894f9e9760b7b027a5f5025d3af81944da258ce9df" dependencies = [ "base64 0.23.1", + "calamine", + "docx-rs", "futures-util", "image", "libc", + "lopdf 0.44.0", + "pdf-extract", + "quick-xml", "regex", "reqwest", "rmcp", "rusqlite", + "rust_xlsxwriter", + "rxing", "serde", "serde_json", "tempfile", "thiserror 2.0.20", "tokio", - "toml", + "toml 1.1.4+spec-1.1.0", "tracing", + "umya-spreadsheet", "windows-sys 0.61.2", + "zip", ] [[package]] @@ -1312,6 +1926,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1327,6 +1950,65 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jetscii" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47f142fe24a9c9944451e8349de0a56af5f3e7226dc46f3ed4d4ecc0b85af75e" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -1420,12 +2102,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libm" version = "0.2.16" @@ -1491,6 +2189,72 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lopdf" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c" +dependencies = [ + "aes 0.8.4", + "bitflags 2.13.1", + "cbc 0.1.2", + "ecb 0.1.2", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap", + "itoa", + "log", + "md-5 0.10.6", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "sha2 0.10.9", + "stringprep", + "thiserror 2.0.20", + "ttf-parser", + "weezl 0.1.12", +] + +[[package]] +name = "lopdf" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2ec995d822e05cabc3f06d196ee43650af3fe4fe38012cacb35e0c3d113b68" +dependencies = [ + "aes 0.9.2", + "bitflags 2.13.1", + "cbc 0.2.1", + "chrono", + "ecb 0.2.1", + "encoding_rs", + "flate2", + "getrandom 0.4.3", + "indexmap", + "itoa", + "jiff", + "log", + "md-5 0.11.0", + "nom 8.0.0", + "rand 0.10.2", + "rangemap", + "rayon", + "sha2 0.11.0", + "stringprep", + "thiserror 2.0.20", + "time", + "weezl 0.2.1", +] + [[package]] name = "lru" version = "0.18.2" @@ -1516,6 +2280,46 @@ dependencies = [ "winapi", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1567,19 +2371,40 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.8.1" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", ] [[package]] -name = "nix" -version = "0.29.0" +name = "nalgebra" +version = "0.32.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "7b5c17de023a86f59ed79891b2e5d5a94c705dbe904a5b5c9c952ea6221b03e4" +dependencies = [ + "approx", + "matrixmultiply", + "num-complex", + "num-rational", + "num-traits", + "simba", + "typenum", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.1", "cfg-if", @@ -1610,6 +2435,55 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -1627,6 +2501,36 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1634,6 +2538,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1672,6 +2577,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + [[package]] name = "palette" version = "0.7.7" @@ -1728,6 +2642,29 @@ dependencies = [ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pdf-extract" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417e8fdc940f1d5bc62c5f89864c3a2255f74f69aa353c98509213d67df61e73" +dependencies = [ + "adobe-cmap-parser", + "cff-parser", + "encoding_rs", + "euclid 0.20.14", + "log", + "lopdf 0.42.0", + "postscript", + "type1-encoding-parser", + "unicode-normalization", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1782,8 +2719,28 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", ] [[package]] @@ -1792,8 +2749,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -1802,18 +2759,41 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.8", ] +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -1828,6 +2808,24 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1853,12 +2851,33 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pom" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" + [[package]] name = "portable-atomic" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postscript" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" + [[package]] name = "potential_utf" version = "0.1.6" @@ -1874,6 +2893,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -1897,18 +2925,80 @@ dependencies = [ "windows", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "encoding_rs", + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.11" @@ -1993,6 +3083,8 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ + "libc", + "rand_chacha", "rand_core 0.6.4", ] @@ -2007,11 +3099,24 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -2019,6 +3124,16 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_distr" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" +dependencies = [ + "num-traits", + "rand 0.8.8", +] + [[package]] name = "rand_pcg" version = "0.10.2" @@ -2028,6 +3143,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + [[package]] name = "ratatui" version = "0.30.2" @@ -2053,7 +3174,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -2079,45 +3200,136 @@ dependencies = [ ] [[package]] -name = "ratatui-termina" -version = "0.1.0" +name = "ratatui-termina" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +dependencies = [ + "instability", + "ratatui-core", + "termina", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "rav1e" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9" +dependencies = [ + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.12.1", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "once_cell", + "paste", + "profiling", + "rand 0.8.8", + "rand_chacha", + "simd_helpers", + "system-deps", + "thiserror 1.0.69", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5825c26fddd16ab9f515930d49028a630efec172e903483c94796cfe31893e6b" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "instability", - "ratatui-core", - "termina", + "either", + "rayon-core", ] [[package]] -name = "ratatui-termwiz" -version = "0.1.2" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "ratatui-core", - "termwiz", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] -name = "ratatui-widgets" -version = "0.3.2" +name = "reborrow" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" -dependencies = [ - "bitflags 2.13.1", - "hashbrown 0.17.1", - "indoc", - "instability", - "itertools", - "line-clipping", - "ratatui-core", - "serde", - "strum", - "time", - "unicode-segmentation", - "unicode-width", -] +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" [[package]] name = "redox_syscall" @@ -2197,6 +3409,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -2260,6 +3481,15 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rust_xlsxwriter" +version = "0.98.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093d730a6f64f9620e047b2de2876e61a691fbcaca3aaf436edade9b897c7a27" +dependencies = [ + "zip", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -2382,12 +3612,55 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rxing" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6609a7ccb6435312dd3a2bff9924cdbb1c96050510fff30676c3d7b8b0045739" +dependencies = [ + "chrono", + "chrono-tz", + "codepage-437", + "encoding_rs", + "fancy-regex 0.18.0", + "image", + "imageproc", + "num", + "once_cell", + "regex", + "rxing-one-d-proc-derive", + "serde", + "thiserror 2.0.20", + "unicode-segmentation", + "uriparse", + "urlencoding", +] + +[[package]] +name = "rxing-one-d-proc-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ee3a551320da932c705835119f5f9a69a144966d18780d35e3b394585c22ce3" +dependencies = [ + "quote", + "syn 3.0.3", +] + [[package]] name = "ryu" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safe_arch" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2484,6 +3757,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -2501,7 +3783,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2541,6 +3834,19 @@ dependencies = [ "libc", ] +[[package]] +name = "simba" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061507c94fc6ab4ba1c9a0305018408e312e17c041eb63bef8aa726fa33aceae" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "paste", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -2557,6 +3863,15 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -2628,6 +3943,17 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -2732,6 +4058,25 @@ dependencies = [ "walkdir", ] +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml 0.8.23", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + [[package]] name = "tempfile" version = "3.27.0" @@ -2765,8 +4110,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", - "nom", - "phf", + "nom 7.1.3", + "phf 0.11.3", "phf_codegen", ] @@ -2803,8 +4148,8 @@ dependencies = [ "ordered-float", "pest", "pest_derive", - "phf", - "sha2", + "phf 0.11.3", + "sha2 0.10.9", "signal-hook", "siphasher", "terminfo", @@ -2861,17 +4206,23 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thousands" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820" + [[package]] name = "tiff" -version = "0.11.3" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ "fax", "flate2", "half", "quick-error", - "weezl", + "weezl 0.1.12", "zune-jpeg", ] @@ -2888,6 +4239,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -2896,6 +4248,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -2983,6 +4345,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit", +] + [[package]] name = "toml" version = "1.1.4+spec-1.1.0" @@ -2991,11 +4365,20 @@ checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ "indexmap", "serde_core", - "serde_spanned", - "toml_datetime", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", ] [[package]] @@ -3007,13 +4390,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "winnow 0.7.15", +] + [[package]] name = "toml_parser" version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow", + "winnow 1.0.4", ] [[package]] @@ -3104,6 +4500,27 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "type1-encoding-parser" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa10c302f5a53b7ad27fd42a3996e23d096ba39b5b8dd6d9e683a05b01bee749" +dependencies = [ + "pom", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -3116,12 +4533,62 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "umya-spreadsheet" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560ef3816b56fce89975a58a7f271c57f7b3873aac8fba70572f93f3683be405" +dependencies = [ + "aes 0.9.2", + "base64 0.22.1", + "byteorder", + "cbc 0.2.1", + "cfb", + "chrono", + "encoding_rs", + "fancy-regex 0.18.0", + "hmac", + "html5gum", + "imagesize", + "jiff", + "md-5 0.11.0", + "num-traits", + "phf 0.14.0", + "quick-xml", + "rand 0.10.2", + "rgb", + "sha2 0.11.0", + "thousands", + "zip", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -3134,7 +4601,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -3151,6 +4618,16 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "url" version = "2.5.8" @@ -3163,6 +4640,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -3187,12 +4670,29 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -3345,6 +4845,12 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3363,7 +4869,7 @@ checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "uuid", ] @@ -3411,12 +4917,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" dependencies = [ "bitflags 1.3.2", - "euclid", + "euclid 0.22.14", "lazy_static", "serde", "wezterm-dynamic", ] +[[package]] +name = "wide" +version = "0.7.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3649,6 +5165,15 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + [[package]] name = "winnow" version = "1.0.4" @@ -3770,23 +5295,64 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" -version = "0.5.3" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-inflate" +version = "0.2.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] [[package]] name = "zune-jpeg" -version = "0.5.15" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index e5c2bb4..3a71ad9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "io-cli" -version = "0.19.0" +version = "0.20.0" edition = "2021" # io-harness declares 1.95, set by `libsqlite3-sys` calling the std `cfg_select!` # macro from its build script. Nothing here can be lower than what the harness @@ -40,7 +40,30 @@ path = "src/lib.rs" # consequence of enabling it. That is a capability change arriving inside an # interface release and it is stated in the README and the CHANGELOG rather than # left to be discovered. It is governed by the same policy as any other read. -io-harness = { version = "0.69", features = ["media", "browser"] } +# `documents` is on from 0.20.0, and it is the umbrella io-harness already +# declares — `["xlsx", "docx", "pptx", "pdf", "barcode"]`, exactly the five +# formats this release names. One word rather than five, because the harness +# had already grouped them and a list written out here would be a second copy +# to keep true. +# +# **It costs 159 transitive crates and it moves `image` down a patch, and both +# are consequences rather than choices.** `barcode` is `rxing`, and every +# published `rxing 0.9.x` pins `image = "=0.25.8"` exactly. This crate asks for +# `^0.25` and the lockfile stood at 0.25.10, which cargo cannot reconcile — so +# the lock is pinned to 0.25.8, which both requirements accept. A `cargo update` +# that lifts `image` again will fail to resolve until `rxing` relaxes; that is +# the whole of the constraint and it is written here rather than discovered in +# a red CI run. +# +# **The `default-features = false` on `image` below no longer wins, and saying +# so is the point.** `rxing` and `docx-rs` both take `image` with its defaults, +# and cargo unifies features across a graph — so `avif` (`ravif`, an AV1 +# encoder), `exr`, `qoi`, `dds` and `rayon` are compiled into this tree from +# 0.20.0 despite the argument made below for excluding them. That argument is +# still correct about what this *renderer* can be handed; it is simply no longer +# load-bearing on the build. Turning `barcode` back off is what would restore +# it, and `documents` would then have to be spelled out as its other four. +io-harness = { version = "0.69", features = ["media", "browser", "documents"] } # **`tui-textarea` is gone as of 0.18.0, and it is why this pair was frozen.** # The comment that stood here said ratatui stayed at 0.29 and crossterm at 0.28 # because the composer was built out of that widget and the 0.30 line carried From dfaaf13c1f6abf22c18e7e8119f61f92683cce1a Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:09:36 +0530 Subject: [PATCH 02/10] feat(hooks): run the operator's hook tables, in both arms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[[hook]]` tables have been parsed and silently inert since the harness gained them. They run now, and io-cli spawns nothing to do it: io-harness's `Hooks` is itself an `Observer` and already owns the subprocess, its timeout, its kill and its drained stdout. Building it is `Config::hooks()`; running it is installing it. The one-spawning-module rule holds untouched. Two installs, because the file has two halves. The `at = "before_tool"` tables reach a run through `TaskContract::with_tool_hooks`, attached in `contract::configured` — the one function both the session and `io exec` are built from, so a hook cannot reach a terminal and miss CI. The `on = [...]` tables reach it as an observer, which is the driver's job. `Fanout` exists because io-harness has no combinator to do it with. `Broadcast` is not a tee — it is a store-writing decorator over exactly one inner observer — and the only three `impl Observer` in that crate are `Hooks`, `Ignore` and `Broadcast`. So io-cli folds `Flow` itself, calling every observer even after one has cancelled, and folding so that `Cancel` wins over `Continue` whatever the order. A hook that asked to stop the turn is not overruled by whichever observer was registered first. The `is_empty` guard on the hooks is not an optimisation. io-harness disables read speculation on any run carrying a `Hooks` at all, even an empty one, so attaching it unconditionally would quietly make every session slower for the overwhelming majority of operators who have never written a hook — with nothing failing and nothing on screen to connect the loss to this release. `configure::refusal` gives a failed `Config::discover` a sentence an operator can act on. The commonest way a well-formed repository stops io from starting is a `[[hook]]` in the project file, which io-harness refuses because that file arrives with a `git clone`. That refusal is correct and is not softened; it is passed through whole, with the directory named, instead of arriving as a bare error string from a program that has already exited. --- src/configure.rs | 26 +++++++ src/contract.rs | 60 ++++++++++++++++ src/exec.rs | 27 +++++++- src/fanout.rs | 176 +++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + 5 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 src/fanout.rs diff --git a/src/configure.rs b/src/configure.rs index 7681177..c4e68b1 100644 --- a/src/configure.rs +++ b/src/configure.rs @@ -251,6 +251,32 @@ pub fn scope_path(root: &std::path::Path, scope: Scope) -> Option { } } +/// What to print when the configuration cannot be read at all. +/// +/// **The whole of io-harness's sentence, and one line of io-cli's own.** A +/// `Config::discover` that fails is not always a broken file: since io-harness +/// refuses a project-scoped `[[hook]]` outright — a hook runs a command, and +/// `io.toml` is the file a `git clone` delivers — the commonest way for a +/// perfectly well-formed repository to stop io from starting is a table somebody +/// added in good faith to the wrong one of three files. +/// +/// io-harness's own message names the key, says why, and names the two files that +/// may carry it. Nothing io-cli could write would be better, so it is passed +/// through whole. What io-cli adds is the one thing the harness cannot know: which +/// directory was being read, because an operator who ran `io` in the wrong place +/// is looking at a message about a file they have never opened. +/// +/// **This lives here and not in `main.rs`.** Nothing under `tests/` links the +/// binary, so a sentence composed in that file is one no test drives and no +/// sabotage can make fail — the same reasoning that put plain-mode resolution in +/// the library. +pub fn refusal(root: &std::path::Path, error: &io_harness::Error) -> String { + format!( + "the configuration in {} could not be read:\n{error}", + root.display() + ) +} + /// Write one change into the scope the operator picked, and prove it landed. /// /// **The write is verified by io-harness reading it back, and rolled back when it diff --git a/src/contract.rs b/src/contract.rs index 81e1bcf..c9bc424 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -203,6 +203,10 @@ refer to earlier output by where it is on the screen — it has scrolled."; /// `[app.io-cli]` is the fifth and strongest layer, and it belongs to [`session`] /// alone: it is io-cli's own table and `io exec` does not read it. pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> TaskContract { + // Kept before `root` is moved into the contract: both the plugin hook merge + // and nothing else needs it, and cloning a `PathBuf` once per turn is not a + // cost worth threading an argument to avoid. + let dir = root.clone(); let contract = config.apply_to(TaskContract::workspace(text, root).with_max_steps(MAX_STEPS)); let contract = match config.sandbox() { Some(sandbox) => contract.with_contained_exec(sandbox), @@ -217,12 +221,68 @@ pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> Ta // would bound a terminal and leave CI running with the defaults, which is // the 0.14.0 asymmetry this product deleted once already. let contract = crate::settings::ceilings(config).apply(contract); + // **The bundles, applied here and in no other function, which is what makes + // one call cover both arms.** `Config::plugins()` re-reads every directory a + // `[[plugin]]` entry names; `Plugins::apply_to` merges their `[[agent]]` + // definitions into the roster, extends `[[mcp]]` rather than assigning over + // it, and sets `contract.plugins` — which is the field + // `TaskContract::discover_skills` reads at run start to fold a bundle's + // skills in, and the field `emit_plugins` iterates at step 0 to report what + // loaded. Applied *after* `config.apply_to` on purpose: the merge has to see + // the operator's own roster to merge into it. + // + // Nothing here can fail. A bundle that will not load is not an error, it is + // a row in `Plugins::dropped()` — which is what stops one bad directory in a + // shared configuration file from costing a whole team their session, and + // which is why `/plugin` is the surface that reads it rather than this + // function returning a `Result`. + let plugins = config.plugins(); + let contract = plugins.apply_to(contract); + // **The hooks, and the `is_empty` guard is not an optimisation.** io-harness + // disables read speculation on any run carrying a `Hooks` at all — even one + // holding no hooks — so attaching it unconditionally would quietly make every + // session slower for the overwhelming majority of operators who have never + // written a hook, with nothing failing and nothing on screen to connect the + // loss to this release. + // + // `with_tool_hooks` is the *lifecycle* half — the `at = "before_tool"` tables + // that can turn a tool call back. The `on = [...]` event tables arrive by a + // different road entirely: the `Hooks` value is also an `Observer`, and the + // driver puts it in the fan-out beside the interface's own. Both installs, or + // one half of the file is accepted and silently never runs. + let hooks = plugins.apply_to_hooks(config.hooks(), &dir); + let contract = if hooks.is_empty() { + contract + } else { + contract.with_tool_hooks(std::sync::Arc::new(hooks)) + }; // `[run] skills` has had its say, and `io exec` reads no other key that can // name one — so for the headless arm this is already the point after every // key. [`session`] calls this again once `[app.io-cli]` has had its own. resolve_skills(contract) } +/// The hooks a run should be observed by, for the caller that installs the +/// fan-out. +/// +/// **The same value [`configured`] puts on the contract, built the same way and +/// deliberately built twice rather than threaded.** A `Hooks` is cheap — it is a +/// parsed `Vec` and a directory — and the two installs happen at different +/// points in different functions with different lifetimes: the lifecycle half +/// goes onto the contract behind an `Arc` before the turn, the event half is +/// borrowed by the fan-out for exactly the turn's duration. Returning one value +/// for both would mean handing an `Arc` to a `&dyn Observer` site and keeping the +/// contract's copy alive across a `/config` reload that rebuilt the `Config` it +/// came from. +/// +/// `None` where the file declares no hook, so the caller can leave the fan-out at +/// one observer and keep read speculation — the same guard [`configured`] makes, +/// made once here so the two cannot drift apart. +pub fn hooks(config: &Config, root: &std::path::Path) -> Option { + let hooks = config.plugins().apply_to_hooks(config.hooks(), root); + (!hooks.is_empty()).then_some(hooks) +} + /// The skills directory this session will really hand the agent, for the one /// surface that has to know it before a turn exists. /// diff --git a/src/exec.rs b/src/exec.rs index cea4d12..360edd5 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -471,6 +471,31 @@ impl WithProvider for Headless { let json = Ndjson::new(std::io::stdout()); let observer: &dyn Observer = if self.args.json { &json } else { &Ignore }; + // **The same composition the session arm builds, and it is here for the + // reason the asymmetry this product deleted in 0.14.0 was a defect.** A + // hook is configuration, and configuration that reaches a terminal and + // not CI is worse than configuration that reaches neither: an audit log + // set up for unattended runs would record nothing in exactly the place + // nobody is watching a screen to notice. So `[[hook]]` runs under + // `io exec` on the same terms. + // + // `Broadcast` is here too, and for `io exec` it is the more useful half: + // a headless run is the one somebody else's process is most likely to + // want to attach to. + let hooks = crate::contract::hooks(&self.config, self.session.root()); + let mut observers: Vec<&dyn Observer> = vec![observer]; + if let Some(hooks) = &hooks { + observers.push(hooks); + } + let fanout = crate::fanout::Fanout::new(observers); + let durable = crate::settings::store_path() + .and_then(|path| io_harness::Store::open(&path).ok()); + let broadcast = durable.map(|store| io_harness::Broadcast::new(store, &fanout)); + let watcher: &dyn Observer = match &broadcast { + Some(broadcast) => broadcast, + None => &fanout, + }; + let result = turn( &provider, &self.store, @@ -479,7 +504,7 @@ impl WithProvider for Headless { &self.policy, self.args.goal.clone(), self.args.sandbox.map(crate::cli::Sandbox::mode), - observer, + watcher, ) .await?; diff --git a/src/fanout.rs b/src/fanout.rs new file mode 100644 index 0000000..b61d9af --- /dev/null +++ b/src/fanout.rs @@ -0,0 +1,176 @@ +//! One `&dyn Observer` for a turn that has more than one watcher. +//! +//! io-harness takes exactly one observer per run, and 0.69.0 ships nothing that +//! combines two: the whole crate declares three implementations — `Hooks`, +//! `Ignore`, and `Broadcast`, which is a store-writing decorator over one inner +//! observer, not a tee. Until 0.20.0 that was enough, because io-cli had one +//! watcher: [`Bridge`](crate::bridge::Bridge), feeding the screen. Now the +//! operator's `[[hook]]` tables run as well, and those are driven by io-harness's +//! own `Hooks`, which is itself an `Observer`. Two observers, one slot. **The +//! combinator has to live here, because the crate that owns the trait does not +//! provide one** — not because io-cli wants its own abstraction over observing. +//! +//! # Cancel wins, whatever the order +//! +//! [`Flow`] folds one way: any `Cancel` makes the whole fan-out `Cancel`. A hook +//! that asked to stop the turn must not be overruled by whichever observer +//! happened to be registered ahead of it, and a watcher that returns `Continue` +//! — which is what a watcher always returns — must never be able to veto a stop. +//! Registration order is a call-site detail; whether the turn ends is not. So the +//! fold is a logical OR over `Cancel`, which is commutative, and the order the +//! observers were passed in cannot change the answer. +//! +//! # Every observer is called, always +//! +//! The loop never short-circuits on the first `Cancel`. An observer that was +//! registered for an event gets told about it even when an earlier one has +//! already asked to stop, because observers have side effects: a `[[hook]]` that +//! appends to an audit log has to record *the event that cancelled the turn*, +//! which is exactly the event a short-circuiting fold would hide from it. Cheap +//! to get right once here; impossible to debug later from a log with a hole in it. +//! +//! # What it costs +//! +//! [`Observer::event`] is synchronous and runs on the run's own task, in order, +//! for every event — so this loop is on the run's critical path, and so is every +//! observer in it. A slow observer slows the run; **a panic in one takes the run's +//! future with it and leaves the run's row `running`**, with no outcome ever +//! written. The fan-out adds nothing but a slice walk; the observers inside it +//! are where that cost is spent, and each is responsible for its own. + +use io_harness::{Flow, Observer, RunEvent}; + +/// Several observers behind the single `&dyn Observer` a turn accepts. +/// +/// Borrows rather than owns, to match the entry points: `turn_*_steered` takes +/// `&dyn Observer` for the length of one turn, so the observers already outlive +/// the call and there is nothing for this to own. +pub struct Fanout<'a> { + observers: Vec<&'a dyn Observer>, +} + +impl<'a> Fanout<'a> { + /// Fan every event out to `observers`, in the given order. + /// + /// Order decides who is called first and nothing else — see the module docs. + /// An empty fan-out is legal and behaves as `Ignore`. + pub fn new(observers: Vec<&'a dyn Observer>) -> Self { + Self { observers } + } +} + +impl Observer for Fanout<'_> { + fn event(&self, event: &RunEvent) -> Flow { + // Deliberately not `any(|o| o.event(event).is_cancel())`: that + // short-circuits, and an observer past the first `Cancel` would never + // see the event that stopped the turn. + let mut flow = Flow::Continue; + for observer in &self.observers { + if observer.event(event).is_cancel() { + flow = Flow::Cancel; + } + } + flow + } +} + +#[cfg(test)] +mod tests { + use super::*; + use io_harness::EventKind; + use std::sync::atomic::{AtomicUsize, Ordering}; + + /// Counts what it was told and answers with a fixed [`Flow`]. + struct Spy { + seen: AtomicUsize, + answer: Flow, + } + + impl Spy { + fn new(answer: Flow) -> Self { + Self { + seen: AtomicUsize::new(0), + answer, + } + } + + fn seen(&self) -> usize { + self.seen.load(Ordering::Relaxed) + } + } + + impl Observer for Spy { + fn event(&self, _event: &RunEvent) -> Flow { + self.seen.fetch_add(1, Ordering::Relaxed); + self.answer + } + } + + fn an_event() -> RunEvent { + RunEvent::new(1, 1, EventKind::Stalled) + } + + #[test] + fn every_observer_is_told_even_after_one_cancels() { + let first = Spy::new(Flow::Continue); + let stopper = Spy::new(Flow::Cancel); + let last = Spy::new(Flow::Continue); + + let flow = Fanout::new(vec![&first, &stopper, &last]).event(&an_event()); + + assert_eq!(flow, Flow::Cancel); + // The one that matters: an audit hook registered after the hook that + // stopped the turn still has to record the event that stopped it. + assert_eq!(last.seen(), 1, "the fold short-circuited on Cancel"); + assert_eq!(first.seen(), 1); + assert_eq!(stopper.seen(), 1); + } + + #[test] + fn cancel_wins_from_either_end() { + for cancel_first in [true, false] { + let canceller = Spy::new(Flow::Cancel); + let watcher = Spy::new(Flow::Continue); + let order: Vec<&dyn Observer> = if cancel_first { + vec![&canceller, &watcher] + } else { + vec![&watcher, &canceller] + }; + + let flow = Fanout::new(order).event(&an_event()); + + assert_eq!( + flow, + Flow::Cancel, + "registration order decided the turn (cancel_first={cancel_first})" + ); + assert_eq!(canceller.seen(), 1); + assert_eq!(watcher.seen(), 1); + } + } + + #[test] + fn all_continue_is_continue() { + let a = Spy::new(Flow::Continue); + let b = Spy::new(Flow::Continue); + + assert_eq!( + Fanout::new(vec![&a, &b]).event(&an_event()), + Flow::Continue, + "a fan-out must not invent a cancel nobody asked for" + ); + } + + #[test] + fn an_empty_fanout_watches_nothing() { + assert_eq!(Fanout::new(Vec::new()).event(&an_event()), Flow::Continue); + } + + #[test] + fn the_fanout_is_send_and_sync() { + // `Observer: Send + Sync`, so a type that is not cannot be one — and this + // fails at compile time, before any assertion runs. + fn require() {} + require::>(); + } +} diff --git a/src/lib.rs b/src/lib.rs index 05f3cd0..783b203 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,7 @@ pub mod editor; pub mod events; pub mod exec; pub mod failure; +pub mod fanout; pub mod fleet; pub mod fuzzy; pub mod glyphs; @@ -58,6 +59,7 @@ pub mod memory; pub mod picker; pub mod picture; pub mod plan; +pub mod pluginview; pub mod provider; pub mod providers; pub mod queue; From 00f21ae656b29cc71b219518d171a7a9c8ba630f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:09:51 +0530 Subject: [PATCH 03/10] feat: load capability bundles, and give the fleet names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/plugin` is the surface for a bundle an operator dropped in. A bundle is a directory with a `plugin.toml`, named by a `[[plugin]]` entry — a declaration, never a scan — and it can hand over skills, templates, agents, MCP servers, hooks and deny-only policy at once. io-cli had never called `Config::plugins()`, so none of it arrived and the two events that report it described a route that could not fire. `Plugins::apply_to` now runs in `contract::configured`, which is why one call covers both arms. `/plugin` lists what loaded and what it contributed, descends into a bundle for its agents, servers and policy layers, and lists a dropped bundle with io-harness's own sentence — which matters most for the one an operator will actually hit, where a project-declared bundle contributing hooks or servers is refused whole and the sentence is what names the two files it could move to instead. What cannot honestly be shown is not implied. A bundle's hooks are not listable: there is no `Plugin::hooks()` and `Hook` is private to io-harness, so the word `hooks` from `contributions()` is the entire signal, and the surface says so. Both plugin events stay silent on the stream and the route column stops lying about why. They fire at step 0 of every turn, so a line each would be a line about a directory that has not moved since the session started; `/plugin` is where standing facts belong, and it exists now. The fleet gains the two facts the event stream does not carry. `EventKind::Spawned` has neither address nor role, so both come from the store: a child draws as the address it was spawned under, with its roster role beside it, and a message one agent sent a named sibling is drawn with its body. Read on `Step` and never on `Spawned` — io-harness commits the row at the step boundary, and a read at the announcing event is a race that passes on a quiet laptop and fails on a loaded CI machine. Naming is unconditional and costs one query per fleet; the mailbox is read only while the pane that shows it is open, because `messages_for` returns a whole mailbox every call and polling it per step is the same quadratic sweep `status.rs` already records for the context read. `Enter` on a detached child attaches to it. The selection has driven only the highlight since 0.8.0; this is what it was for. It is inert on every other row on purpose — a working child's events are already in the scrollback, and replaying them from the store would be two copies of one run. Watching is a mode rather than a background poll, because the idle loop blocks on input and has no tick to hang one on. --- src/app.rs | 32 ++ src/commands.rs | 32 ++ src/fleet.rs | 655 ++++++++++++++++++++++++++++++++++++++++- src/main.rs | 366 ++++++++++++++++++++++- src/pluginview.rs | 736 ++++++++++++++++++++++++++++++++++++++++++++++ src/triage.rs | 16 +- 6 files changed, 1812 insertions(+), 25 deletions(-) create mode 100644 src/pluginview.rs diff --git a/src/app.rs b/src/app.rs index db475ad..2a28559 100644 --- a/src/app.rs +++ b/src/app.rs @@ -93,6 +93,21 @@ pub enum Command { /// A command rather than something this type does, because the transcript /// lives in the harness's store and the store belongs to the driver. Transcript, + /// Watch a child that is already running, by the run id the fleet is holding. + /// + /// **A command for exactly the reason [`Command::Transcript`] is one**: what + /// this asks for lives in the harness's store, and the store belongs to the + /// driver. `io_harness::Attach` reads the `run_events` table over a second + /// connection, and nothing in this type has ever held one. + /// + /// Asked for on a **detached** child, which is the case that means anything: a + /// detached child is still running and its parent has merely stopped waiting + /// for it, so it is the one with events still to come and nobody watching + /// them. Attaching to a child that is still being waited for is legal and + /// io-harness does not care, but its events are already arriving on the + /// stream this interface is drawing, so it would be a second copy of what is + /// already on screen. + Attach(i64), /// The first `Esc` at an empty prompt: say what undoing the last turn would /// undo, and wait for the second. /// @@ -1379,6 +1394,23 @@ impl App { self.fleet.move_by(1); return Command::None; } + // **`Enter` on a detached child, and nothing on any other row.** + // The pane has had a selection since 0.8.0 that drove only the + // highlight; this is what it was for. A detached child is the one + // an operator can usefully go and look at — it is still running + // and nothing is watching it — so it is the one row where `Enter` + // means something. On any other row it is deliberately inert + // rather than helpfully doing the nearest thing: a working child's + // events are already arriving in the scrollback, and re-showing + // them from the store would be two copies of one run. + KeyCode::Enter => { + if let Some(child) = self.fleet.selected_child() { + if child.state == crate::fleet::State::Detached { + return Command::Attach(child.run_id); + } + } + return Command::None; + } // `armed` and not `self.armed`: this function took the arming out // a few lines above, so the field is always `None` here and the // guard was always true. Pre-existing, and found while writing the diff --git a/src/commands.rs b/src/commands.rs index bd7f5f9..2ece3db 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -193,6 +193,16 @@ pub const COMMANDS: &[(&str, &str)] = &[ "/provider", "the providers configured, in the order a turn tries them", ), + // Beside `/mcp` and `/provider` because it is the third of the same kind: a + // surface that lists what the file declared and goes on to change it. A + // bundle is the widest of the three — it can hand over skills, templates, + // agents, servers, hooks and policy in one directory — which is the argument + // for it being visible at all, and the argument for it being under + // "configure" rather than "inspect". + ( + "/plugin", + "the capability bundles loaded, what each contributed, and the ones that failed", + ), ( "/profile", "switch to a named profile from the configuration, for this session", @@ -324,6 +334,7 @@ pub const GROUPS: &[(Group, &[&str])] = &[ "/memory", "/mcp", "/provider", + "/plugin", ], ), ]; @@ -856,6 +867,23 @@ pub enum Action { Provider, /// List the named profiles, and switch to one for this session. Profile, + /// List the capability bundles a `[[plugin]]` entry declared: what each one + /// contributed, and what each dropped bundle failed on. + /// + /// **What can honestly be shown is bounded by what io-harness makes public, + /// and the surface says so rather than implying more.** A bundle's agents, + /// MCP servers and policy layers are all readable and are listed by the names + /// io-harness namespaced them to. Its **hooks are not**: there is no + /// `Plugin::hooks()` and `Hook` is `pub(crate)`, so the word `hooks` from + /// `Plugin::contributions()` is the entire signal available — the bundle + /// declared some, and io-cli cannot say how many or what they run. + /// + /// A dropped bundle carries io-harness's own sentence, re-worded by nobody. + /// That matters most for the one an operator will actually hit: a bundle + /// declared in the project file that contributes `[[hook]]` or `[[mcp]]` is + /// refused **whole**, and the sentence is what names the two files it could + /// move to instead. + Plugin, } /// What `/copy` was asked for. @@ -1434,6 +1462,10 @@ pub fn parse(input: &str, keys: &Keys, theme: &Theme) -> Action { "skills" => Action::Skills, "provider" | "providers" => Action::Provider, "profile" | "profiles" => Action::Profile, + // `/plugins` is admitted for the same reason `/servers` and `/providers` + // are: the thing being listed is plural, so the plural is what a hand + // reaches for, and refusing it teaches nothing. + "plugin" | "plugins" => Action::Plugin, // **An alias earns no row of its own.** `/usage` is what an operator // coming from another agent types for "what is this costing me", and the // answer is `/status` — which already commits the spend, the budgets and diff --git a/src/fleet.rs b/src/fleet.rs index 6559b0c..40a81ee 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -1,9 +1,26 @@ //! The fleet: what a decomposed task looks like while it is running. //! -//! **A model over the event stream, and nothing else.** io-harness reports a -//! tree as five kinds of fact, each on the run it belongs to, and this assembles -//! them back into a shape a person can read. It owns no state io-harness does not -//! report, reads no store, and decides nothing about the run. +//! **A model over the event stream, and over what somebody else has already read +//! for it.** io-harness reports a tree as five kinds of fact, each on the run it +//! belongs to, and this assembles them back into a shape a person can read. It +//! owns no state io-harness does not report and decides nothing about the run. +//! +//! **It still reads no store; it now draws what one said.** Two of the things on +//! screen are simply not on the event stream. `EventKind::Spawned` carries a run +//! id and a goal and no name at all, so a child's *address* — the instance name it +//! is reachable at, `as` in `spawn_agent` or a derived `reviewer#42` — can only +//! come from `Store::tree_addresses`; and a message one agent sent a named sibling +//! is never emitted as an event, so it can only come from `Store::messages_for`. +//! Both arrive here as **arguments** ([`Fleet::name`], [`Fleet::traffic`]). This +//! module opens no connection, prepares no statement and holds no cursor: whoever +//! calls it owns the read, its schedule and its failure. That is what keeps every +//! row here testable without a database, and what keeps a disk read on main.rs's +//! clock rather than on the frame's. +//! +//! **`messages_for`, never `read_messages`.** The second stamps `read_at` and *is* +//! the delivery — a view that called it would consume a sibling's mail in order to +//! draw it, and the agent the message was addressed to would never see it. +//! Showing traffic must not be the same act as receiving it. //! //! Three properties of that stream shape everything here. //! @@ -19,7 +36,10 @@ //! frees and a run id exists. So a waiting child has no id, no goal and nothing to //! draw a row from — inventing a placeholder for one would put a row on screen for //! an agent that does not exist yet, which is the fabrication F4's sabotage arm -//! adds on purpose. +//! adds on purpose. **It has no address either**, and cannot: `queued_agents` +//! returns `(tier, goal)`, and an address is derived from a run id that has not +//! been allocated. That limit is real and stays — a count is the whole truth about +//! a child nothing has started. //! //! **Tier counts are replaced, never accumulated.** Each `Fleet` carries that //! tier's whole shape — working, queued and done as they now stand — including @@ -31,7 +51,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::Frame; -use io_harness::{EventKind, RunEvent}; +use io_harness::{AgentMessage, Agents, EventKind, RunEvent}; use crate::glyphs::Glyphs; use crate::picker::fit; @@ -59,11 +79,43 @@ impl State { } } +/// The character io-harness derives an unnamed child's address with: +/// `#`. +/// +/// Spelled here because the harness keeps its own `DERIVED_MARK` private, and +/// there is exactly one thing this is used for: splitting a derived address back +/// into the role it was derived from. **A copy of a constant is a thing that can +/// disagree**, so it is used only where being wrong is harmless — a mark that +/// changed would cost a role label, never a wrong address: the address is always +/// drawn whole, exactly as the store spelled it. +const DERIVED_MARK: char = '#'; + /// One admitted child. #[derive(Debug, Clone)] pub struct Child { /// Its own run id, which every event of its own carries. + /// + /// Kept even though a named child no longer draws by it: it is what every + /// event is keyed on, and what [`Fleet::name`] matches an address against. pub run_id: i64, + /// Where it is reachable — the instance name it was spawned under, or a + /// derived `reviewer#42` — or `None` until somebody has read one for it. + /// + /// **Not the role.** Several children can be the same kind of agent, and an + /// operator attaching to one, or an agent addressing mail to one, means a + /// particular one. This is the only field on a child that identifies *which*. + /// + /// `None` is honest and common: it is a child announced by an event whose + /// address has not been read back yet, and it stays `None` for as long as + /// that is true — see [`Fleet::name`]. + pub address: Option, + /// The `[[agent]]` definition it was spawned from, where that is knowable. + /// + /// Answers "what kind of agent is this", which the address deliberately does + /// not. `None` where the roster has nothing to say — which is not a defect, + /// see [`Fleet::name`] for the two shapes that resolve and the one that + /// cannot. + pub role: Option, /// Its nesting level: one below the event that announced it. pub depth: u32, /// What it was asked to do. @@ -77,6 +129,77 @@ pub struct Child { pub drawn: u64, } +impl Child { + /// What identifies this row to a person, role included where it is known. + /// + /// **The address, and `run ` only where there is none.** A run id is a + /// database key: it is unique, it is stable, and it is the one thing about an + /// agent an operator cannot use — they cannot address mail to it, and it does + /// not tell them which of three reviewers this is. The fallback is not a + /// lesser spelling of the same thing, it is an admission that nothing has told + /// us the name yet, and it stays visible rather than being hidden behind a + /// placeholder for the same reason a queued child gets no row at all. + /// + /// The role is drawn beside the address only when it is not the address, so a + /// child a parent named after its own role reads `scout` and not + /// `scout (scout)`. + fn label(&self) -> String { + let Some(address) = &self.address else { + return format!("run {}", self.run_id); + }; + match &self.role { + Some(role) if role != address => format!("{address} ({role})"), + _ => address.clone(), + } + } +} + +/// One message an agent sent a named sibling, as this view holds it. +/// +/// An owned four-field row rather than an `io_harness::AgentMessage`, for two +/// reasons. `AgentMessage` is `#[non_exhaustive]`, so a test in this crate could +/// not build one to assert a row against — a view type nothing outside a database +/// can construct is a view type nothing can test. And it is keyed by run id, +/// while everything on this screen is now keyed by address: the recipient's +/// address is not on the message at all (only `to_run_id` is), so somebody has to +/// resolve it, and doing that once at the boundary beats doing it per frame. +/// +/// `id`, `sent_at` and `read_at` are dropped deliberately. The order rows arrive +/// in is the order they are drawn in, a wall-clock stamp would spend columns the +/// body needs, and `read_at` is a fact about *delivery* that this view must never +/// look like it participates in. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + /// The sender's address, as it signed itself. + pub from: String, + /// The recipient's address, resolved by the caller from `to_run_id`. + pub to: String, + /// The sender's step when it sent this. + pub step: u32, + /// What was said. + pub body: String, +} + +impl Message { + /// One store row, addressed to the agent at `to`. + /// + /// Takes the recipient's address as an argument because the row does not + /// carry one: `AgentMessage::to_run_id` is a run id, and only + /// `Store::tree_addresses` can turn it into a name. The caller is holding + /// both by the time it calls this. + pub fn received(message: &AgentMessage, to: &str) -> Self { + Self { + // `from_name` is the sender's ADDRESS — the instance, not the roster + // definition it was spawned from. io-harness documents it that way and + // the whole mailbox depends on it: mail is addressed to instances. + from: message.from_name.clone(), + to: to.to_string(), + step: message.step, + body: message.body.clone(), + } + } +} + /// One nesting level of the tree, as io-harness last reported it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Tier { @@ -91,6 +214,13 @@ pub struct Tier { pub struct Fleet { children: Vec, tiers: Vec, + /// The traffic between them, in the order the caller read it. + /// + /// Held flat rather than hung off each child: a message is a fact about a + /// *pair* of agents, the sender is frequently not a child of this tree's root + /// at all, and the recipient may be the root itself — which has no row here, + /// because the root is not a child. + messages: Vec, /// Which row the operator has marked, as an index into [`Fleet::children`]. /// /// Held independently of the rows so that "nothing is selected" is @@ -105,8 +235,12 @@ impl Fleet { } /// Whether anything has happened that this can draw. + /// + /// Traffic counts. A view holding mail is a view with rows in it, and one that + /// called itself empty while `rows` returned something would be hidden with + /// its contents still there. pub fn is_empty(&self) -> bool { - self.children.is_empty() && self.tiers.is_empty() + self.children.is_empty() && self.tiers.is_empty() && self.messages.is_empty() } pub fn children(&self) -> &[Child] { @@ -121,6 +255,93 @@ impl Fleet { self.selected } + /// The marked child itself, or `None` when there is nothing to mark. + /// + /// **This is the handle, and [`Fleet::selection`] is not.** An index into a + /// private vector is worth exactly what the vector's current contents make it + /// worth: it drives the highlight and nothing else, and a caller that kept one + /// across an event would be holding a number whose meaning had moved. What a + /// caller outside this module actually wants is the child — its `run_id` to + /// attach to, and its `state` to know whether attaching is a sensible thing to + /// ask for. `State::Detached` is the case that matters: a detached child is + /// **still running**, its parent has merely stopped waiting for it, so it is + /// the one an operator can go and look at. That decision stays with the + /// caller; this hands over the two facts it needs to make it and refuses to + /// make it here, because a model that decided what could be attached to would + /// be deciding something about the run. + pub fn selected_child(&self) -> Option<&Child> { + self.selected.and_then(|at| self.children.get(at)) + } + + /// Every message this view is holding, oldest first. + pub fn messages(&self) -> &[Message] { + &self.messages + } + + /// Give the children their addresses, from `(address, run id)` pairs the + /// caller read out of `Store::tree_addresses`, and their roles from `roster`. + /// + /// **It takes the data instead of reading it.** A `Store` opened in here would + /// put a disk read on the render path, make every test of a row a test that + /// needs a database, and give this module a second opinion about the run + /// alongside the event stream. The caller already holds the store, already + /// knows the root run id, and already has a schedule that is not "once a + /// frame". + /// + /// **Idempotent, and safe to call with a partial answer.** A child no pair + /// names is left exactly as it was rather than being cleared — a read that + /// raced a spawn returns the addresses that existed when it ran, and a view + /// that blanked the rest would flicker a name off screen and back. Naming is + /// monotonic here: a child gets an address once and keeps it, which is also + /// what io-harness guarantees about the address itself across a restart. + /// + /// The pair for the root (`ROOT_ADDRESS`) matches no child and is ignored, + /// which is correct for the same reason the root's `Finished` matches no row: + /// the root is not a child. + /// + /// **Bounded per call by the size of the *tree*, not the length of the run.** + /// It is one pass over the pairs, each doing one scan of the children, and + /// both of those are the number of agents that have been spawned — a number + /// that does not grow with steps, events, tokens or messages. That is the + /// difference from the trap `src/status.rs` records for `context_events`, + /// where a per-event read of the whole history makes an `n`-step run cost + /// `n(n+1)/2`. Nothing here re-walks history. + pub fn name(&mut self, addresses: &[(String, i64)], roster: &Agents) { + for (address, run_id) in addresses { + let role = role_of(address, roster); + if let Some(child) = self.child_mut(*run_id) { + child.address = Some(address.clone()); + // Assigned rather than merged: `role_of` is a pure function of the + // address and the roster, so a `None` here means the roster cannot + // name this one, and keeping a stale role after an `/agents` + // reload would be showing a definition that no longer exists. + child.role = role; + } + } + } + + /// Replace the traffic with what the caller last read. + /// + /// **Replaced, never appended** — the same rule the tier counts follow, and + /// for a sharper version of the same reason. `Store::messages_for` returns a + /// recipient's whole mailbox every call, so a view that extended would show + /// every message twice after the second poll and three times after the third. + /// Replacement also means this holds no cursor of its own that could disagree + /// with the store about what has been read. + /// + /// Takes the rows by value: the caller has just built them and has no use for + /// them afterwards, so moving is the whole cost. Bounded per call by what was + /// handed over, and this does not walk the run to work out what changed. + /// + /// **The caller must have used `messages_for` and not `read_messages`.** The + /// latter stamps `read_at`, which is io-harness's delivery record: calling it + /// to populate a view would hand a sibling's mail to the screen instead of to + /// the agent it was addressed to, and the agent would wait forever for a + /// message that was already marked delivered. + pub fn traffic(&mut self, messages: Vec) { + self.messages = messages; + } + /// Forget the run this describes. /// /// Called where the conversation under it changes — a turn ending, `/resume`, @@ -130,6 +351,11 @@ impl Fleet { pub fn forget(&mut self) { self.children.clear(); self.tiers.clear(); + // Mail included, and it is the piece most likely to be missed: a message + // outlives the turn it was sent in — it is a row in a table, not an event + // — so a fleet that dropped its children and kept its traffic would draw a + // conversation between agents that are no longer on screen. + self.messages.clear(); self.selected = None; } @@ -148,6 +374,12 @@ impl Fleet { EventKind::Spawned { child_run_id, goal } if self.child(*child_run_id).is_none() => { self.children.push(Child { run_id: *child_run_id, + // No address and no role: the event carries neither, and a + // guess made from the goal or the tier would be a name an + // operator could type at nothing. It gets one when somebody + // reads one — see `Fleet::name`. + address: None, + role: None, depth: event.depth + 1, goal: goal.clone(), state: State::Working, @@ -242,28 +474,73 @@ impl Fleet { .join(" ") } - /// One row per admitted child, fitted to `width`. + /// One row per admitted child, then one per message, all fitted to `width`. + /// + /// **The children come first and stay index-aligned with + /// [`Fleet::children`]**, because [`Fleet::selected`] is an index into that + /// vector and [`Fleet::render`] marks a row by comparing it. Messages are + /// appended after the last child rather than interleaved under their + /// recipients: interleaving would put rows in front of children and silently + /// shift every index the marker uses, and a marker that lands on the wrong row + /// is an operator attaching to the wrong agent. It also reads better — the + /// traffic is a conversation, and a conversation is worth reading in the order + /// it happened rather than sorted into the tree. + /// + /// Bounded per call by what this holds — one pass, one row each, no history + /// walked. pub fn rows(&self, width: u16, glyphs: &Glyphs) -> Vec { let room = width as usize; - self.children + let sep = glyphs.separator.trim(); + let mut rows: Vec = self + .children .iter() .map(|child| { let indent = " ".repeat(child.depth.saturating_sub(1) as usize); let drawn = crate::status::format_tokens(child.drawn); let head = format!( - "{indent}run {} {} {} {} {drawn} drawn {} ", - child.run_id, - glyphs.separator.trim(), + "{indent}{} {sep} {} {sep} {drawn} drawn {sep} ", + child.label(), child.state.word(), - glyphs.separator.trim(), - glyphs.separator.trim(), ); // The goal is what gets cut, because everything in front of it // identifies the row and it is the only part that can be long. let room_for_goal = room.saturating_sub(head.chars().count()); - format!("{head}{}", fit(&child.goal, room_for_goal, glyphs)) + clamp( + format!("{head}{}", fit(&child.goal, room_for_goal, glyphs)), + room, + glyphs, + ) }) - .collect() + .collect(); + rows.extend(self.messages.iter().map(|message| { + // Indented one level under the tree, because that is what it is: a + // thing that happened inside the fleet rather than another member of + // it. The indent is the child rows' own unit, so the two read as one + // block whatever the terminal's width is. + let head = format!( + " {} {} {} {sep} step {} {sep} ", + message.from, + arrow(glyphs), + message.to, + message.step, + ); + // The body gives way, exactly as the goal does above, and for the same + // reason: the addresses either side of the arrow are what make the row + // mean anything, and a body cut to nothing still says that one agent + // wrote to another at a known step. + // + // Newlines are flattened rather than trimmed away: a body is free text + // an agent wrote, and one `\n` in it would otherwise turn a single row + // into several and push a child off an eight-row view. + let flat = message.body.replace(['\n', '\r'], " "); + let room_for_body = room.saturating_sub(head.chars().count()); + clamp( + format!("{head}{}", fit(&flat, room_for_body, glyphs)), + room, + glyphs, + ) + })); + rows } /// Draw the view: the tier line, then as many child rows as there is room for. @@ -293,6 +570,15 @@ impl Fleet { let mut marked_row = None; for (offset, row) in rows.iter().skip(first).take(visible).enumerate() { let index = first + offset; + // A message row can never match: `selected` is only ever set to an + // index into `children`, and `rows` puts every child before every + // message. That is the invariant the ordering in `rows` exists to + // hold — checked here rather than assumed, in the same spirit as the + // fall-through note on the `Spawned` arm. + debug_assert!( + self.selected.is_none_or(|at| at < self.children.len()), + "the marker must index a child, not a message row", + ); let tone = if Some(index) == self.selected { marked_row = Some(area.y + 1 + offset as u16); Tone::Normal @@ -311,3 +597,340 @@ impl Fleet { } } } + +/// The `[[agent]]` definition an address was spawned from, where the address can +/// still say. +/// +/// Two shapes resolve and one cannot, and the one that cannot is why this returns +/// an `Option` rather than a guess: +/// +/// * `reviewer#42` — an address io-harness **derived**, because the parent named +/// nothing. The part in front of the mark is the definition's own name, so the +/// role is recoverable exactly. +/// * `scout` — an address the parent **assigned** that happens to be a roster +/// name. Confirmed against the roster rather than assumed, for the reason +/// `Agents::get` documents: a name nobody registered is a misspelling, not a +/// permissive default. +/// * `left-hand` — an address the parent assigned that is nothing the roster has +/// heard of. **The role is genuinely unknown here**: the definition a spawn used +/// is not on the event stream and not in `tree_addresses`, and the nearest thing +/// to a guess — matching on the goal, or on which definitions exist — would put +/// a wrong role beside a right address. `None` is the true answer and it costs a +/// label, not a row. +/// +/// Pure and bounded: one split and one `BTreeMap` lookup, no allocation on the +/// path that fails. +fn role_of(address: &str, roster: &Agents) -> Option { + let stem = address.split(DERIVED_MARK).next().unwrap_or(address); + roster.get(stem).map(|def| def.name.clone()) +} + +/// The mark between a sender and a recipient, in whichever set is in play. +/// +/// **An arrow that keeps its direction when it cannot be an arrow.** `->` is not a +/// decoration standing in the same column, it is the same statement in the +/// character set the terminal admits to — which is the whole test +/// [`crate::glyphs`] sets for a mark that degrades. It is one cell wider in ASCII, +/// and nothing measures it except the row that then gives way in the body, so the +/// widths need not agree. +/// +/// Chosen by the set rather than added to [`Glyphs`] as an eleventh class, +/// because the set's own test for a class is that two call sites would otherwise +/// spell one mark differently, and there is one call site. It still answers to +/// `--plain` and to a locale that does not claim UTF-8, because it is the resolved +/// set it reads and never the environment. +fn arrow(glyphs: &Glyphs) -> &'static str { + if glyphs.name == crate::glyphs::ASCII.name { + "->" + } else { + "→" + } +} + +/// The last clamp on a finished row. +/// +/// The parts that give way have already given way by the time this runs, so this +/// only ever bites when the head *alone* overruns — a long assigned address, a +/// deep indent, two addresses either side of an arrow. A row that overran would +/// wrap, and a wrapped row in an eight-row view costs a whole other agent its +/// place on screen; a row that is cut costs the end of a name. The row is taken by +/// value and handed straight back when it fits, so the common case — every row on +/// a terminal wide enough — copies nothing. +fn clamp(row: String, room: usize, glyphs: &Glyphs) -> String { + if row.chars().count() <= room { + return row; + } + fit(&row, room, glyphs) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyphs::{ASCII, UNICODE}; + use io_harness::AgentDef; + + /// A spawn, as io-harness emits one: on the **parent's** run, at the parent's + /// depth, carrying the child's id. + fn spawned(parent: i64, depth: u32, child: i64, goal: &str) -> RunEvent { + RunEvent::at_depth( + parent, + 1, + depth, + EventKind::Spawned { + child_run_id: child, + goal: goal.to_string(), + }, + ) + } + + fn tier(tier: u32, working: u32, queued: u32, done: u32) -> RunEvent { + RunEvent::new( + 1, + 1, + EventKind::Fleet { + tier, + working, + queued, + done, + }, + ) + } + + fn message(from: &str, to: &str, step: u32, body: &str) -> Message { + Message { + from: from.to_string(), + to: to.to_string(), + step, + body: body.to_string(), + } + } + + #[test] + fn a_named_child_draws_by_address_and_role() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "read every file under src/")); + fleet.name( + // The root's pair is in here exactly as `tree_addresses` returns it, + // and must match nothing: the root is not a child. + &[("root".to_string(), 1), ("reviewer#7".to_string(), 7)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + let rows = fleet.rows(80, &UNICODE); + assert_eq!(rows.len(), 1, "the root got no row: {rows:?}"); + assert!(rows[0].contains("reviewer#7"), "{:?}", rows[0]); + assert!( + rows[0].contains("(reviewer)"), + "the role is beside the address: {:?}", + rows[0] + ); + assert!( + !rows[0].contains("run 7"), + "the database key is gone once there is a name: {:?}", + rows[0] + ); + } + + /// The address is what a parent assigned, and it is emphatically not the role: + /// two children of one definition are two different agents to address. + #[test] + fn siblings_of_one_role_draw_as_different_agents() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "left half")); + fleet.event(&spawned(1, 0, 8, "right half")); + fleet.name( + &[("reviewer#7".to_string(), 7), ("reviewer#8".to_string(), 8)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + let rows = fleet.rows(80, &UNICODE); + assert!(rows[0].contains("reviewer#7"), "{rows:?}"); + assert!(rows[1].contains("reviewer#8"), "{rows:?}"); + } + + /// An assigned address that *is* a roster name reads once, not twice. + #[test] + fn a_child_named_after_its_role_does_not_say_it_twice() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "look around")); + fleet.name( + &[("scout".to_string(), 7)], + &Agents::new().with(AgentDef::new("scout")), + ); + assert!(!fleet.rows(80, &UNICODE)[0].contains("scout (scout)")); + } + + #[test] + fn an_unnamed_child_falls_back_to_its_run_id() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "read every file under src/")); + assert!( + fleet.rows(80, &UNICODE)[0].contains("run 7"), + "an event stream alone knows no names", + ); + // A read that answered about some other run leaves this one alone rather + // than clearing it, and the fallback is still what shows. + fleet.name(&[("scout".to_string(), 99)], &Agents::new()); + assert_eq!(fleet.children()[0].address, None); + assert!(fleet.rows(80, &UNICODE)[0].contains("run 7")); + } + + /// An address the roster has never heard of keeps its address and gets no role + /// invented for it. + #[test] + fn an_unknown_address_gets_no_role() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.name( + &[("left-hand".to_string(), 7)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + assert_eq!(fleet.children()[0].role, None); + let row = &fleet.rows(80, &UNICODE)[0]; + assert!(row.contains("left-hand"), "{row:?}"); + assert!(!row.contains('('), "no role was guessed: {row:?}"); + } + + /// The 0.20.0 limit, still true: a queued child has no run id, so it has no + /// address, so it stays a number in the tier line. + #[test] + fn a_queued_child_stays_a_count() { + let mut fleet = Fleet::new(); + fleet.event(&tier(1, 1, 2, 0)); + fleet.name(&[("root".to_string(), 1)], &Agents::new()); + assert!(fleet.children().is_empty()); + assert!( + fleet.rows(80, &UNICODE).is_empty(), + "nothing was admitted, so nothing has a row", + ); + assert!(fleet.summary().contains("2 queued"), "{}", fleet.summary()); + } + + #[test] + fn a_message_draws_as_its_own_row() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.traffic(vec![message("scout#7", "reviewer#8", 3, "the file moved")]); + let rows = fleet.rows(80, &UNICODE); + assert_eq!(rows.len(), 2, "the child, then the message: {rows:?}"); + assert!( + rows[1].starts_with(" "), + "indented under the tree: {rows:?}" + ); + assert!(rows[1].contains("scout#7 → reviewer#8"), "{:?}", rows[1]); + assert!(rows[1].contains("step 3"), "{:?}", rows[1]); + assert!(rows[1].contains("the file moved"), "{:?}", rows[1]); + } + + /// Every new mark has an ASCII form that says the same thing. The arrow is the + /// only one this release adds — the role's parentheses and the `step` label are + /// letters and punctuation that every terminal draws. + #[test] + fn the_ascii_arrow_keeps_its_direction() { + let mut fleet = Fleet::new(); + fleet.traffic(vec![message("a", "b", 1, "hello")]); + let unicode = fleet.rows(80, &UNICODE).remove(0); + let ascii = fleet.rows(80, &ASCII).remove(0); + assert!(unicode.contains("a → b"), "{unicode:?}"); + assert!(ascii.contains("a -> b"), "{ascii:?}"); + assert!( + !ascii.chars().any(|c| !c.is_ascii()), + "the ASCII row is ASCII: {ascii:?}", + ); + // And the row is still assembled out of the set it was handed, separator + // included, rather than out of literals typed here. + assert!(ascii.contains(ASCII.separator.trim()), "{ascii:?}"); + } + + /// A child row in the ASCII set is ASCII too, address and all. + #[test] + fn the_ascii_child_row_is_ascii() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "read every file under src/")); + fleet.name( + &[("reviewer#7".to_string(), 7)], + &Agents::new().with(AgentDef::new("reviewer")), + ); + let row = fleet.rows(80, &ASCII).remove(0); + assert!(!row.chars().any(|c| !c.is_ascii()), "{row:?}"); + assert!(row.contains("reviewer#7 (reviewer)"), "{row:?}"); + } + + /// Eighty columns, a long address and a long body: the body gives way, and + /// nothing wraps. + #[test] + fn a_long_message_gives_way_at_eighty_columns() { + let mut fleet = Fleet::new(); + fleet.traffic(vec![message( + "an-unusually-long-assigned-address", + "another-unusually-long-assigned-address", + 12, + &"and a body far longer than any of it ".repeat(10), + )]); + for glyphs in [UNICODE, ASCII] { + let row = fleet.rows(80, &glyphs).remove(0); + assert!(row.chars().count() <= 80, "{} {row:?}", glyphs.name); + assert!(!row.contains('\n')); + } + // Even where the head alone overruns, the row is cut rather than wrapped. + let row = fleet.rows(24, &UNICODE).remove(0); + assert!(row.chars().count() <= 24, "{row:?}"); + } + + /// A body an agent wrote can contain newlines; a row cannot. + #[test] + fn a_multi_line_body_stays_one_row() { + let mut fleet = Fleet::new(); + fleet.traffic(vec![message("a", "b", 1, "first line\nsecond line")]); + let row = fleet.rows(80, &UNICODE).remove(0); + assert!(!row.contains('\n'), "{row:?}"); + assert!(row.contains("first line second line"), "{row:?}"); + } + + /// `messages_for` hands back the whole mailbox every call, so this replaces. + #[test] + fn traffic_replaces_rather_than_accumulates() { + let mut fleet = Fleet::new(); + let mail = vec![message("a", "b", 1, "one")]; + fleet.traffic(mail.clone()); + fleet.traffic(mail); + assert_eq!(fleet.messages().len(), 1); + assert_eq!(fleet.rows(80, &UNICODE).len(), 1); + } + + #[test] + fn forget_drops_the_mail_with_the_children() { + let mut fleet = Fleet::new(); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.traffic(vec![message("a", "b", 1, "one")]); + fleet.forget(); + assert!(fleet.is_empty()); + assert!( + fleet.rows(80, &UNICODE).is_empty(), + "mail outlives its turn in the store, but not on this screen", + ); + } + + /// What main.rs attaches with: the marked child's id, and whether it is the + /// kind of child there is any point attaching to. + #[test] + fn the_selected_child_carries_its_run_id_and_state() { + let mut fleet = Fleet::new(); + assert!(fleet.selected_child().is_none(), "nothing to mark yet"); + fleet.event(&spawned(1, 0, 7, "one")); + fleet.event(&spawned(1, 0, 8, "two")); + fleet.move_by(1); + let child = fleet.selected_child().expect("a marked child"); + assert_eq!(child.run_id, 8); + assert_eq!(child.state, State::Working); + + fleet.event(&RunEvent::new( + 1, + 2, + EventKind::ChildDetached { + child_run_id: 8, + after: Some(std::time::Duration::from_secs(30)), + }, + )); + let child = fleet.selected_child().expect("still marked"); + assert_eq!(child.state, State::Detached, "detached is still running"); + } +} diff --git a/src/main.rs b/src/main.rs index 8216ebe..644e408 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,10 +9,10 @@ use std::io::IsTerminal; use std::process::ExitCode; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use clap::Parser; -use crossterm::event::{Event, KeyEventKind}; +use crossterm::event::{Event, KeyCode, KeyEventKind}; use io_harness::{Config, Policy, Provider, ProviderSpec, Session, Steer, Store, Templates}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; @@ -111,7 +111,22 @@ async fn run(report: &mut Vec) -> Result { report.extend(io_cli::skills::install(&home)); } } - let config = Config::discover(&root).map_err(|error| error.to_string())?; + // **A refusal is not a crash, and until 0.20.0 this line made every refusal + // look like one.** io-harness refuses a whole `Config::discover` when the + // project-scoped file — the one a `git clone` delivers — declares `[[hook]]`, + // because a hook runs a command on this machine. That refusal is correct and + // io-cli does not soften it: there is no `Config` to be had, so the program + // genuinely cannot start. + // + // What it can do is say so in words the operator can act on. io-harness's own + // sentence names the key, the reason and the two files that may carry it, and + // it is worth more than anything written here — so it is passed through + // unreworded, with only a line above it saying which file was being read. + // Before this, the whole thing arrived as a bare error string from a program + // that had already exited, against a repository the operator had just cloned, + // with nothing connecting one table in one file to the failure. + let config = + Config::discover(&root).map_err(|error| io_cli::configure::refusal(&root, &error))?; // **A profile, if one was asked for, before anything reads the configuration.** // Applied here rather than per arm so a session and an `io exec` run get the // same overlay from the same decision, and refused with io-harness's own @@ -913,6 +928,69 @@ async fn loop_over Result>( // the row being drawn and this keystroke the operator may // have moved the file themselves, and a carried row would // then name a path that is no longer there. + // Read and closed. `Esc` and `Enter` do the same thing, + // which is the honest behaviour for a pane with nothing + // to choose. + Pick::Nothing => {} + Pick::Plugins(view) => { + // **Answered from the reading the rows were drawn + // from, and `/skills` does the opposite on purpose.** + // There, a row names a file the operator may have + // moved between two keystrokes, so the directory is + // read again and a row that moved is refused. Here the + // rows describe what is *loaded in this session* — the + // bundle a turn taken right now would actually use — + // and re-reading would answer a different question + // than the one the operator asked by choosing a row. + // + // The split is `pluginview::rows`'s own: loaded first, + // refused after, no headings, so the index is direct. + if let Some(plugin) = view.plugins.get(index) { + // `descended`, not `picker`: the assignment at the + // end of this match installs it, while `kind` + // still borrows the one being replaced. + // + // **A pane rather than a line in the scrollback, + // and `/mcp` above does the opposite for a good + // reason that does not hold here.** A server is + // one row of facts, so a sentence says all of it. + // A bundle is several *lists* — its agents, its + // servers, its policy layers — and a sentence + // holding three lists is a sentence nobody reads. + descended = Some(( + Picker::new( + plugin.id.clone(), + io_cli::pluginview::detail( + plugin, + screen.width(), + &app.theme.glyphs, + ), + ), + Pick::Nothing, + )); + } else if let Some(refused) = + view.refused.get(index - view.plugins.len()) + { + // **`Tone::Refused` and io-harness's own sentence, + // re-worded by nobody.** This is the one an + // operator will actually hit — a bundle declared + // in the project file that contributes hooks or + // servers is refused whole — and the harness's + // message is the only text that names both files + // it could move to instead. `record` rather than + // `say`: a refusal explains a boundary and outlives + // the keystroke that earned it. + app.record( + Tone::Refused, + format!( + "{} ({}): {}", + refused.id, + refused.path.display(), + refused.error, + ), + ); + } + } Pick::Skills(drawn) => { // **Located by what the row said, not by where it // sat.** The list is read again — the operator may @@ -1421,6 +1499,9 @@ async fn loop_over Result>( paint(screen, &mut app)?; } Command::Transcript => commit_transcript(screen, &session, &store, &app.theme)?, + Command::Attach(run_id) => { + watch_child(screen, &mut app, &store, inputs, run_id).await?; + } // The first `Esc`. Nothing has changed yet; this says what the second // one would change, in the turn's own words, so a confirmation is a // confirmation of something specific rather than of a keystroke. @@ -1529,6 +1610,39 @@ async fn loop_over Result>( )); } } + Action::Plugin => { + // **Read fresh, every time, and that is io-harness's design + // rather than a choice made here.** `Config::plugins()` + // re-walks every declared directory on each call, so a bundle + // the operator has just edited on disk is reflected without a + // reload — and a bundle they have just broken shows up as a + // refusal in the same breath. + let view = io_cli::pluginview::view(&config); + if view.is_empty() { + // Naming the file rather than the concept: an operator who + // has never declared a bundle needs to know where the + // entry goes, and one who has needs to know io-cli read + // the file they meant. + app.record( + Tone::Muted, + "no capability bundles are declared; add one with a \ + `[[plugin]]` entry naming its directory" + .to_string(), + ); + } else { + picker = Some(( + Picker::new( + "Plugins", + io_cli::pluginview::rows( + &view, + screen.width(), + &app.theme.glyphs, + ), + ), + Pick::Plugins(view), + )); + } + } Action::Provider => { let chain = io_cli::providers::chain(&config); if chain.is_empty() { @@ -2519,14 +2633,61 @@ async fn turn( // to read them from. They then persist, because `Status::forget_run` does not // clear them — the file does not change while a session runs. app.status.budgets = io_cli::status::Budgets::in_force(&contract); + // **The observer composition, and it is the spine of 0.20.0.** Through 0.19.0 + // this was one value — `Bridge`, the channel the interface draws from. Three + // things this release wants are all downstream of that single seam, so the + // seam is where they arrive: + // + // 1. `[[hook]]` tables run, because io-harness's `Hooks` *is* an `Observer` + // and running the operator's hooks is nothing more than putting it here. + // io-cli spawns no process of its own for this — the subprocess, its + // timeout, its kill and its drained stdout are all io-harness's, which is + // what keeps the one-spawning-module rule in `tests/dependencies.rs` true. + // 2. `Fanout` exists because io-harness has no combinator to do it with. + // `Broadcast` is *not* a tee — it is a store-writing decorator over exactly + // one inner observer — and the only three `impl Observer` in the whole + // crate are `Hooks`, `Ignore` and `Broadcast`. So io-cli folds `Flow` + // itself, and folds it so `Cancel` wins over `Continue` whatever the order. + // 3. `Broadcast` wraps the result for a different reason than the fan-out + // exists: it is the **only** writer of the `run_events` table, and that + // table is what `Attach` reads. Without this wrapper a detached child can + // be selected and attached to and will show nothing, forever, and look + // like a quiet child rather than a missing write. + // + // The order is deliberate: `Broadcast` outermost, so an event is durable + // before either the interface or a hook is told about it, and a hook that + // cancels the turn cannot leave a gap in the sequence a reader is following. + let hooks = io_cli::contract::hooks(config, session.root()); + let mut observers: Vec<&dyn io_harness::Observer> = vec![&observer]; + if let Some(hooks) = &hooks { + observers.push(hooks); + } + let fanout = io_cli::fanout::Fanout::new(observers); + // A second connection to the same file, which is what io-harness's own + // documentation asks for rather than tolerates: `Observer` is `Send + Sync`, + // `rusqlite::Connection` is `Send` and not `Sync`, so the run's borrowed + // `&Store` cannot live inside one. `Store::open` has set `journal_mode = WAL` + // and a busy timeout since 0.12.0 precisely so a second handle works. It is a + // spectator — it writes events and never takes a run lease — so it is not the + // two-drivers-over-one-file shape that has produced `DatabaseBusy` here. + // + // `None` where the store cannot be opened a second time: attach is the thing + // that stops working, and a turn that runs without a durable event stream is + // strictly better than a turn that refuses to start. + let durable = io_cli::settings::store_path().and_then(|path| Store::open(&path).ok()); + let broadcast = durable.map(|store| io_harness::Broadcast::new(store, &fanout)); + let watcher: &dyn io_harness::Observer = match &broadcast { + Some(broadcast) => broadcast, + None => &fanout, + }; let mut running: std::pin::Pin< Box> + '_>, > = match containment { Some(caps) => Box::pin(session.turn_contained_bounded_steered( - &contract, provider, store, policy, &approver, caps, &observer, &inbox, + &contract, provider, store, policy, &approver, caps, watcher, &inbox, )), None => Box::pin(session.turn_bounded_steered( - &contract, provider, store, policy, &approver, &observer, &inbox, + &contract, provider, store, policy, &approver, watcher, &inbox, )), }; @@ -2574,6 +2735,7 @@ async fn turn( // same turn. The trace is the fallback for the window between a // step landing and the first completion call being seen. note_context(app, store, &event, seen, &contract); + note_fleet(app, store, &event, &contract); commit_viewed(screen, app, &root, policy, &event)?; commit_fold(app, store, &event, &mut folding); paint(screen, app)?; @@ -2908,6 +3070,7 @@ async fn turn( // for: the last step of a turn is exactly the one whose event the select // loop loses to the turn's own return. note_context(app, store, &event, seen, &contract); + note_fleet(app, store, &event, &contract); // And the picture, for the same reason and the same race: a `view_image` // on the turn's last step is exactly the one the drain would otherwise // lose. @@ -3042,6 +3205,74 @@ async fn turn( /// there is no request to measure and the section the trace records is the only /// number there is — better than a blank field, and it converges the moment a /// request has been seen. +/// Give the fleet the two facts the event stream does not carry. +/// +/// **On `Step` and on nothing else, and the reason is a race rather than a +/// preference.** io-harness documents `Step` as emitted once the step has been +/// committed to the store, so the spawn row is there to be read. The obvious +/// place — `Spawned`, the event that announces the child — is a read of a row +/// that may not exist yet, and it fails the way races fail: green on a quiet +/// laptop, intermittent on a loaded CI machine. `src/status.rs` already records +/// this rule for the context read; this is the same rule applied to addresses. +/// +/// **Naming is cheap and unconditional; traffic is not and is conditional.** +/// `tree_addresses` costs one query returning one row per agent — a number that +/// grows with the size of the fleet and not with the length of the run — and it +/// is what makes a child draw as `reviewer` rather than `run 41`, which is worth +/// having on screen whether or not the fleet pane is open, because the scrollback +/// keeps it. +/// +/// The mailbox is the opposite shape. `Store::messages_for` returns a +/// recipient's **whole** mailbox every call, so polling it once a step would +/// re-read every message already read — `n(n+1)/2` reads over an `n`-message +/// run, which is exactly the trap `src/status.rs` names for `context_events`. +/// So it is read only while the pane that shows it is open. That is not a +/// half-measure: a message an operator cannot see has nowhere to arrive, and the +/// pane is refreshed by the next step the moment they open it. +fn note_fleet( + app: &mut App, + store: &Store, + event: &io_harness::RunEvent, + contract: &io_harness::TaskContract, +) { + if !matches!(event.kind, io_harness::EventKind::Step { .. }) { + return; + } + // A run with no children has an empty tree and nothing to name. Asking the + // store anyway would be one query per step of every ordinary turn, which is + // the overwhelming majority of them. + if app.fleet.is_empty() { + return; + } + let Ok(root) = store.run_root(event.run_id) else { + return; + }; + if let Ok(addresses) = store.tree_addresses(root) { + app.fleet.name(&addresses, &contract.agents); + } + if !app.fleet_open() { + return; + } + // One mailbox per child that has an address, and a child without one has no + // name to show a message under yet. Bounded by the fleet, and re-read whole + // because `Fleet::traffic` replaces rather than appends — `messages_for` is + // the non-consuming reader, so this never takes delivery of a sibling's mail. + let mut traffic: Vec = Vec::new(); + for child in app.fleet.children() { + let Some(address) = child.address.as_deref() else { + continue; + }; + if let Ok(messages) = store.messages_for(child.run_id) { + traffic.extend( + messages + .iter() + .map(|message| io_cli::fleet::Message::received(message, address)), + ); + } + } + app.fleet.traffic(traffic); +} + fn note_context( app: &mut App, store: &Store, @@ -3354,6 +3585,28 @@ enum Pick { path: std::path::PathBuf, enabled: bool, }, + /// The capability bundles this configuration declared, loaded and refused. + /// + /// **The whole view rather than a list of ids, and for the same reason + /// [`Pick::Skills`] carries pairs**: descending into a bundle has to show + /// what that bundle contributed, and re-reading `Config::plugins()` to answer + /// a keystroke would be a second reading of directories that can change + /// between two of them — so the rows an operator chose from and the detail + /// they descend into come from one reading. + /// + /// A refused bundle is in here too, and choosing one shows io-harness's + /// sentence rather than a detail pane: there is nothing to descend into, + /// because the bundle contributed nothing at all. + Plugins(io_cli::pluginview::View), + /// A pane that is read and closed, and answers no keystroke. + /// + /// **The absence of a choice, made representable.** Every other variant here + /// names what choosing a row means; this one exists for a pane where choosing + /// a row means nothing — a bundle's contributions, which are facts to read + /// rather than levers to pull. Without it the alternative is a pane that + /// silently does something arbitrary on `Enter`, which is how a display turns + /// into an accident. + Nothing, /// The named profiles a file declares, in the order `configure::profiles` /// sorted them. Profile(Vec), @@ -3430,6 +3683,109 @@ fn completion( ))) } +/// How often an attached child's events are asked for. +/// +/// io-harness is explicit that `Attach` is a poll and that there is no push, so +/// this number is the whole of the latency an operator sees. A tenth of a second +/// is under the threshold at which a terminal reads as live, and the query behind +/// it is one indexed read of rows after a cursor — not a scan — so the cost of +/// asking and finding nothing is close to nothing. +const ATTACH_POLL: Duration = Duration::from_millis(100); + +/// Watch a child that is already running, until it stops or the operator leaves. +/// +/// **A mode rather than a background poll, and the idle loop's own shape is the +/// argument.** That loop blocks on `inputs.recv()` and has no tick — there is +/// nowhere for a periodic read to live without giving every idle session a timer +/// it does not need. Watching a child is also inherently something an operator is +/// *doing*: they picked a row and asked to see it. So this borrows the loop for +/// as long as they want it and gives it back on `Esc`. +/// +/// **`from_now`, not from the beginning.** A detached child may have been running +/// for minutes and its earlier steps are in the trace already; replaying them +/// into the scrollback would bury the thing the operator opened this to see. The +/// cursor starts where they asked. +/// +/// The events are fed to the same `App::event` the live stream uses, so a child's +/// step draws exactly as it would have if its parent had waited for it. That is +/// the property worth having: attaching changes when an operator sees a run, not +/// what it looks like. +async fn watch_child( + screen: &mut Screen>, + app: &mut App, + store: &Store, + inputs: &mut UnboundedReceiver, + run_id: i64, +) -> Result<(), String> { + // **The watch's own clock, and the alternative would be a worse lie.** The + // `at` an event carries into `App::event` is an age, used to say how long a + // tool call took. A detached child has been running since before this watch + // began — possibly for minutes — so no clock this session holds gives its + // steps their true ages. Timing from the moment of attaching at least + // measures something real: how long the operator has been watching. The + // driver's own `Instant` is not in scope here and reaching for it would put + // the same wrong number behind a more convincing name. + let watching = Instant::now(); + let mut attach = match io_harness::Attach::to(store, run_id).from_now() { + Ok(attach) => attach, + Err(error) => { + // The store is readable — the session is running out of it — so this + // is the run being unknown to it, which is a fact worth saying rather + // than a silence to interpret. + app.record(Tone::Refused, format!("cannot watch run {run_id}: {error}")); + return Ok(()); + } + }; + app.record( + Tone::Muted, + format!("watching run {run_id} — Esc to stop watching"), + ); + paint(screen, app)?; + loop { + match attach.poll() { + Ok(events) => { + for event in &events { + app.event(event, watching.elapsed()); + // The child is finished when it says so. Leaving on its own + // `Finished` rather than on a status query keeps this reading + // one stream instead of two sources that can disagree. + if matches!(event.kind, io_harness::EventKind::Finished { .. }) + && event.run_id == run_id + { + app.record(Tone::Muted, format!("run {run_id} finished")); + paint(screen, app)?; + return Ok(()); + } + } + if !events.is_empty() { + paint(screen, app)?; + } + } + Err(error) => { + app.record(Tone::Refused, format!("stopped watching: {error}")); + paint(screen, app)?; + return Ok(()); + } + } + // `Esc` leaves and everything else is ignored, deliberately: this is a + // window onto a run the operator is not driving, so a key that did + // something here would be a key that did it to the wrong run. + match tokio::time::timeout(ATTACH_POLL, inputs.recv()).await { + Ok(Some(Event::Key(key))) + if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc => + { + app.record(Tone::Muted, format!("stopped watching run {run_id}")); + paint(screen, app)?; + return Ok(()); + } + // The channel closing is the terminal going away, which the caller + // handles by returning; there is nothing to watch for any more. + Ok(None) => return Ok(()), + Ok(Some(_)) | Err(_) => {} + } + } +} + fn paint( screen: &mut Screen>, app: &mut App, diff --git a/src/pluginview.rs b/src/pluginview.rs new file mode 100644 index 0000000..66b6032 --- /dev/null +++ b/src/pluginview.rs @@ -0,0 +1,736 @@ +//! `/plugin` — which capability bundles this configuration declared, what each +//! one brought, and which ones io-harness refused to load. +//! +//! A plugin is a directory with a `plugin.toml` contributing skills, templates, +//! agents, MCP servers, hooks and deny-only policy layers, and io-cli has never +//! had a surface for one. It needs one for a reason the other management panels +//! do not: **every other capability an operator sees is one they put there.** A +//! skill file is theirs or io-cli's, an `[[mcp]]` entry is a line they wrote, a +//! policy layer came from a posture they chose. A bundle is none of those — it is +//! a directory somebody else wrote, named once by path, that then adds names to +//! four subsystems at once. The question this surface exists to answer is *what +//! did that directory put in my session*, and until now the only way to answer it +//! was to open the manifest. +//! +//! # A dropped bundle is the state this surface is really for +//! +//! [`io_harness::Plugins`] has no error path. A bundle with no manifest, +//! unparseable TOML, a bad id, or a contribution its declaring scope may not make +//! is **dropped** — recorded, reported, and otherwise silently absent while every +//! other bundle loads. io-harness's own module docs call that a deliberate trade +//! with a cost: *a bundle an operator believes is loaded can be silently absent +//! for a week*. This panel is where that week ends, so the refused set is a field +//! of [`View`] rather than an error, drawn beside the loaded set and never +//! instead of it. +//! +//! **The reason is io-harness's sentence and it is carried verbatim.** It names +//! the file, names the key, and — for the project-scope refusal — explains in two +//! clauses why a `git clone`d `io.toml` may not name a program this machine will +//! run. io-cli could not write that better and must not try: a reworded refusal +//! is io-cli's opinion about somebody else's rule, and the operator who has to fix +//! it needs the words of whoever enforced it. [`rows`] may *shorten* the sentence +//! to fit a row, with the mark that says so; [`Refused::error`] holds it whole, +//! and the driver prints it whole beneath the list. +//! +//! # Hooks cannot be listed, and this file says so on screen +//! +//! There is no `Plugin::hooks()` and `Hook` is `pub(crate)`. A bundle's hooks are +//! applied — [`io_harness::Plugins::apply_to_hooks`] installs them — and there is +//! no API by which io-cli can name one, count them, or say what any of them runs. +//! The string `"hooks"` from [`io_harness::Plugin::contributions`] is the entire +//! honest signal, and [`detail`] draws exactly that: a row saying hooks were +//! declared and that io-cli cannot say what they do. **The alternative was to omit +//! the row**, which reads as a bundle with no hooks — the one reading that is +//! actually false, on the contribution kind that runs programs. +//! +//! # The names drawn here are already namespaced, and that is the point +//! +//! io-harness rewrites every contributed agent name, MCP server id and policy +//! layer name to `__` at load, so `agents()[0].name` is +//! `rust-review__reviewer`. This surface draws that string unchanged. It is not a +//! prettier name with the prefix stripped, because the namespaced form is what a +//! refusal will name in `PolicyEvent::layer`, what a call will name in +//! `McpEvent::server`, and what the operator will type to spawn the agent — and a +//! panel that showed a shorter name than the trace does would have invented a +//! third spelling of the same thing. +//! +//! # No TOML, no terminal I/O, no keys +//! +//! Nothing here opens a `plugin.toml`. Every fact comes from +//! [`io_harness::Config::plugins`], which re-reads each declared bundle from disk +//! itself; a second reader in this crate would be a second opinion about what a +//! manifest means, and `tests/dependencies.rs` forbids one by path. Like +//! [`crate::servers`] and [`crate::skillview`], this is a data model and pure +//! functions: the driver in `src/main.rs` owns the keyboard and applies what +//! [`add`] and [`remove`] return. + +use std::path::{Path, PathBuf}; + +use crate::glyphs::Glyphs; +use crate::picker::{fit, fit_left, Row}; + +/// The mark on a bundle that loaded. +/// +/// **One ASCII character in both glyph sets, and that is the ASCII form N4 asks +/// for**, by [`crate::skillview::Origin::word`]'s argument: a mark degrades only +/// where the substitute still carries the meaning, and a pair of dots or arrows +/// that both fall back to `*` would leave an operator unable to tell a loaded +/// bundle from a refused one on the terminal that needed the fallback. `+` and +/// `!` are the marks [`crate::commands`] already uses for *present* and for +/// *attention*, they are legible in either set, so they need no set. +pub const LOADED_MARK: &str = "+"; + +/// The mark on a bundle that was declared and did not load. See [`LOADED_MARK`]. +pub const REFUSED_MARK: &str = "!"; + +/// The narrowest a bundle's root may be drawn at, before its separator. +/// +/// Twenty cells is `...bundles/rust-review` — the last segments and the mark +/// saying the front went, which is what identifies a directory on a machine where +/// every bundle shares the first several segments of its path. The same floor +/// [`crate::skillview`] fits a skill file to, and for the same reason: a row may +/// lose a fact, and may not draw one that cannot be read. +const ROOT_FLOOR: usize = 20; + +/// One bundle that loaded, as `/plugin` lists it. +/// +/// Everything is owned and copied out of the borrowed [`io_harness::Plugin`], +/// because `Config::plugins()` returns a fresh value that re-read the disk: the +/// borrow dies with the call, and a view that held it would tie every surface to +/// the lifetime of one read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Listed { + /// The manifest's `name`, which is also what every contribution below is + /// namespaced by. + pub id: String, + /// The manifest's one line for a human, if it carried one. + pub description: Option, + /// The bundle's own version. **Documentation and nothing else** — io-harness + /// resolves nothing with it and compares no two bundles by it — so it is + /// drawn as a fact about the directory, never as a claim about compatibility. + pub version: Option, + /// The directory the manifest was read from. + pub root: PathBuf, + /// Which kinds of contribution it declared, in io-harness's own fixed order: + /// skills, templates, agents, mcp, hooks, policy. + /// + /// **This is the only place `hooks` can come from.** See the module docs. + pub contributions: Vec<&'static str>, + /// The skills directory it contributes, absolute, if it declared one. + pub skills: Option, + /// The templates directory it contributes, absolute, if it declared one. + pub templates: Option, + /// The agent names it contributes, already namespaced. + pub agents: Vec, + /// The MCP server ids it contributes, already namespaced. + pub servers: Vec, + /// The policy layer names it contributes, already namespaced. Deny rules + /// only — io-harness drops a bundle whose layer carries anything else. + pub layers: Vec, +} + +/// One bundle that was declared and did not load. +/// +/// Named for what happened rather than mirroring io-harness's `Dropped`: from +/// this side of the boundary the fact is that a configuration asked for a bundle +/// and the loader said no, and the operator's next move is in the sentence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Refused { + /// The manifest's `name` where it could be read, and the directory's own name + /// where it could not. A label, not a key — [`Refused::path`] identifies it. + pub id: String, + /// The directory the `[[plugin]]` entry named. + pub path: PathBuf, + /// io-harness's own sentence, whole. **Never reworded and never summarised**; + /// [`rows`] may shorten it to fit, and this field is what the driver prints + /// underneath. See the module docs. + pub error: String, +} + +/// Everything `/plugin` draws. +/// +/// Two lists rather than a `Result`, for [`crate::skillview::View`]'s reason: they +/// are not alternatives. One broken bundle costs exactly itself in io-harness, so +/// a refused bundle sits beside four that loaded and both facts are true at once. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct View { + /// The bundles that loaded, in the order they were declared — which is the + /// order their policy layers stack and their contributions are applied in. + pub plugins: Vec, + /// The bundles that did not, with the reason each one did not. + pub refused: Vec, +} + +impl View { + /// Whether the configuration declared no bundle at all. + /// + /// **Both lists, because a configuration whose only bundle was refused has + /// declared one.** `Plugins::is_empty` says nothing about `dropped()`, and a + /// surface that read it alone would tell an operator with a broken manifest + /// that they have no plugins — which is the false sentence this module exists + /// to stop being told. + #[must_use] + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() && self.refused.is_empty() + } +} + +/// Every bundle this configuration declared, loaded and refused. +/// +/// Infallible, because [`io_harness::Config::plugins`] is: it re-reads every +/// declared directory from disk on the call and reports what it could not read +/// rather than returning an error. That re-read is deliberate here — the surface +/// an operator opens after editing a manifest must see the edit, and a set cached +/// at session start would not. +#[must_use] +pub fn view(config: &io_harness::Config) -> View { + let plugins = config.plugins(); + View { + plugins: plugins + .iter() + .map(|plugin| Listed { + id: plugin.id().to_string(), + description: plugin.description().map(str::to_string), + version: plugin.version().map(str::to_string), + root: plugin.root().to_path_buf(), + contributions: plugin.contributions(), + skills: plugin.skills_dir(), + templates: plugin.templates_dir(), + agents: plugin + .agents() + .iter() + .map(|def| def.name.clone()) + .collect(), + servers: plugin + .mcp_servers() + .iter() + .map(|server| server.id.clone()) + .collect(), + layers: plugin + .policy_layers() + .iter() + .map(|layer| layer.name.clone()) + .collect(), + }) + .collect(), + refused: plugins + .dropped() + .iter() + .map(|dropped| Refused { + id: dropped.id.clone(), + path: dropped.path.clone(), + error: dropped.error.clone(), + }) + .collect(), + } +} + +/// The picker rows for the whole list, fitted for a terminal this wide. +/// +/// **One row per bundle, loaded first and refused after, and no headings.** The +/// picker hands a chosen index straight back into the caller's own list, so a +/// heading row is an index that maps to no bundle — [`crate::commands`] carries a +/// parallel vector of `Held::Nothing` to survive that, and this surface has two +/// lists to index rather than one. So the contract is positional and written down: +/// **index `i` is `view.plugins[i]` while `i < view.plugins.len()`, and +/// `view.refused[i - view.plugins.len()]` after it.** The mark, not a heading, +/// says which — and unlike a heading it survives a typed query, which is exactly +/// when a refused bundle is hardest to tell from a loaded one. +/// +/// # The narrow form, and what gives way in it +/// +/// The id is the label. The contributions are unconditional, because they are the +/// answer to what the bundle *did to this session* and are the only field here +/// with no other home. The version follows when there is one, the description +/// takes what is left, and **the root path is the field that gives way** — below +/// [`ROOT_FLOOR`] it is dropped whole rather than drawn as an ellipsis and an +/// extension. At eighty columns a row is therefore the id, what it contributed and +/// as much description as fits; widen the terminal and the path arrives. +/// +/// A refused row never carries the root, and not because it did not fit: +/// io-harness's sentence already opens with the path, so appending it would draw +/// the same string twice on the row with the least room to spare. +pub fn rows(view: &View, width: u16, glyphs: &Glyphs) -> Vec { + let separator = glyphs.separator; + let separator_width = separator.chars().count(); + // Worth appending only if there is room for more than the mark that would say + // it had been shortened, measured off the set: the ellipsis is one cell in + // Unicode and three in ASCII. + let floor = separator_width + glyphs.ellipsis.chars().count(); + + let mut out = Vec::with_capacity(view.plugins.len() + view.refused.len()); + + for plugin in &view.plugins { + // Commas inside a field, the glyph set's separator between fields: this + // is one fact — the list of kinds — and punctuating it with the same run + // that divides the row would read as five fields. + let mut detail = plugin.contributions.join(", "); + if detail.is_empty() { + // A manifest with a `name` and nothing else. Loaded, contributing + // nothing, and saying so is the whole point of the row. + detail = "contributes nothing".to_string(); + } + if let Some(version) = &plugin.version { + detail.push_str(separator); + detail.push_str(version); + } + + // The picker's own arithmetic, mirrored rather than guessed: two cells of + // marker, the label, two cells of gap. A budget one cell out is how an + // ellipsis ends up on the floor. + let mut left = (width as usize) + .saturating_sub(4) + .saturating_sub(plugin.id.chars().count()) + .saturating_sub(detail.chars().count()); + + if let Some(description) = &plugin.description { + if left > floor { + let described = fit(description, left - separator_width, glyphs); + left -= separator_width + described.chars().count(); + detail.push_str(separator); + detail.push_str(&described); + } + } + if left >= separator_width + ROOT_FLOOR { + detail.push_str(separator); + // From the left, because every bundle on one machine shares the first + // several segments of its path and the end is what identifies it. + detail.push_str(&fit_left( + &plugin.root.display().to_string(), + left - separator_width, + glyphs, + )); + } + + out.push(Row::marked(LOADED_MARK, plugin.id.clone(), detail)); + } + + for refused in &view.refused { + let mut detail = String::from("not loaded"); + let left = (width as usize) + .saturating_sub(4) + .saturating_sub(refused.id.chars().count()) + .saturating_sub(detail.chars().count()); + if left > floor { + detail.push_str(separator); + // Shortened from the head like any other prose, with the mark that + // says so. `Refused::error` still holds the sentence whole, and the + // driver prints it whole — a row is a pointer, not the report. + detail.push_str(&fit(&refused.error, left - separator_width, glyphs)); + } + out.push(Row::marked(REFUSED_MARK, refused.id.clone(), detail)); + } + + out +} + +/// The picker rows for one bundle: what it actually put into the session. +/// +/// Grouped under headings, which are free here and are not free in [`rows`]: this +/// list is read rather than chosen from, so an index that maps to no bundle costs +/// nothing. The order is io-harness's own contribution order, so a bundle reads +/// the same way here as it does in the trace. +/// +/// Every name is drawn **namespaced, exactly as io-harness rewrote it** — see the +/// module docs. +pub fn detail(plugin: &Listed, width: u16, glyphs: &Glyphs) -> Vec { + // The picker's own arithmetic again: marker and gap, with no detail column to + // budget for on the rows that are a path. + let room = (width as usize).saturating_sub(4); + let mut out = Vec::new(); + + if let Some(dir) = &plugin.skills { + out.push(Row::heading("skills")); + out.push(Row::new(fit_left(&dir.display().to_string(), room, glyphs))); + } + if let Some(dir) = &plugin.templates { + out.push(Row::heading("prompt templates")); + out.push(Row::new(fit_left(&dir.display().to_string(), room, glyphs))); + } + if !plugin.agents.is_empty() { + out.push(Row::heading("agents")); + out.extend(plugin.agents.iter().map(|name| Row::new(name.clone()))); + } + if !plugin.servers.is_empty() { + out.push(Row::heading("mcp servers")); + out.extend(plugin.servers.iter().map(|id| Row::new(id.clone()))); + } + if plugin.contributions.contains(&"hooks") { + out.push(Row::heading("hooks")); + // **The whole of what io-cli knows, stated as the whole of what it + // knows.** There is no accessor and `Hook` is `pub(crate)`, so the count, + // the events and the argv are all unreachable — and a bundle's hooks are + // the contribution that runs programs. Omitting the group would read as a + // bundle with no hooks, which is the one reading that is false. + out.push(Row::with_detail( + "declared", + fit( + // No dash and no other mark that would need a glyph set: this is a + // rendered string, and the one class of mark in it — the + // separator between the two clauses — is a semicolon in every + // terminal there is. + "io-harness does not expose a bundle's hooks, so io-cli cannot say what they run; \ + read the bundle's plugin.toml", + room.saturating_sub("declared".chars().count() + 2), + glyphs, + ), + )); + } + if !plugin.layers.is_empty() { + out.push(Row::heading("policy layers, deny only")); + out.extend(plugin.layers.iter().map(|name| Row::new(name.clone()))); + } + + if out.is_empty() { + // A manifest that declared a name and nothing else. It loaded, so it is + // in the list; this is the row that says the list entry is all there is. + out.push(Row::new("this bundle contributes nothing")); + } + out +} + +/// The edit that declares a bundle. +/// +/// A whole `[[plugin]]` entry, because an array of tables grows by gaining a block +/// and [`crate::edit::Edit::set`] can only reach inside one that exists — the same +/// shape [`crate::servers::add`] writes for `[[mcp]]`. +/// +/// The path is written as given. **A relative one resolves against the discovery +/// root**, not against the file that named it, so io-cli neither absolutises it +/// here nor tidies it: a relative path in a committed `io.toml` is how a bundle +/// vendored into a repository is shared, and rewriting it to this machine's +/// absolute path would break that for everyone else who clones it. +pub fn add(dir: &Path) -> crate::edit::Edit { + crate::edit::Edit::append( + "plugin", + format!("path = {}", quoted(&dir.display().to_string())), + ) +} + +/// The edit that removes the `index`-th declared bundle whole. +/// +/// By index rather than by id, because the id is the *manifest's* and the entry is +/// the *configuration's*: a bundle that was refused for having an unusable `name` +/// has no id to remove it by, and it is exactly the entry an operator wants gone. +pub fn remove(index: usize) -> crate::edit::Edit { + crate::edit::Edit::remove(format!("plugin[{index}]")) +} + +/// A TOML basic string, escaped. +/// +/// The twin of [`crate::servers`]'s own, which is private to that module — here +/// rather than shared because a value is the one thing `toml::to_string` cannot be +/// asked for on its own, and because this crate writes VALUES. It earns its keep +/// on Windows, where a bundle's path is full of backslashes that a bare `"{}"` +/// would hand to the parser as escapes. +fn quoted(text: &str) -> String { + let escaped = text.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::glyphs::{ASCII, UNICODE}; + + /// A bundle with something of every kind that can be listed. + fn listed() -> Listed { + Listed { + id: "rust-review".to_string(), + description: Some("Everything our Rust reviews need.".to_string()), + version: Some("1.2.0".to_string()), + root: PathBuf::from("/Users/someone/code/io-cli/bundles/rust-review"), + contributions: vec!["skills", "agents", "hooks", "policy"], + skills: Some(PathBuf::from( + "/Users/someone/code/io-cli/bundles/rust-review/skills", + )), + templates: None, + agents: vec!["rust-review__reviewer".to_string()], + servers: Vec::new(), + layers: vec!["rust-review__no-secrets".to_string()], + } + } + + fn view_of(plugin: Listed) -> View { + View { + plugins: vec![plugin], + refused: Vec::new(), + } + } + + #[test] + fn a_wide_row_carries_every_fact_in_both_glyph_sets() { + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view_of(listed()), 200, glyphs); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "rust-review"); + assert_eq!(rows[0].mark, Some(LOADED_MARK)); + let detail = rows[0].detail.clone().expect("a listed bundle has detail"); + assert!( + detail.starts_with("skills, agents, hooks, policy"), + "{}: the contributions lead the row: {detail}", + glyphs.name + ); + assert!(detail.contains("1.2.0"), "{}: {detail}", glyphs.name); + assert!( + detail.contains("Everything our Rust reviews need."), + "{}: {detail}", + glyphs.name + ); + assert!( + detail.contains("bundles/rust-review"), + "{}: a wide row keeps the root: {detail}", + glyphs.name + ); + assert!( + detail.contains(glyphs.separator), + "{}: fields are divided by the set's own separator: {detail}", + glyphs.name + ); + } + } + + #[test] + fn at_eighty_columns_the_root_is_what_gives_way() { + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view_of(listed()), 80, glyphs); + let detail = rows[0].detail.clone().expect("a listed bundle has detail"); + assert!( + detail.starts_with("skills, agents, hooks, policy"), + "{}: the contributions never give way: {detail}", + glyphs.name + ); + assert!( + !detail.contains("/Users/someone"), + "{}: the root is dropped rather than drawn illegibly: {detail}", + glyphs.name + ); + // The picker's own budget: marker, label, gap, detail. + assert!( + rows[0].label.chars().count() + detail.chars().count() + 4 <= 80, + "{}: the row fits: {detail}", + glyphs.name + ); + } + } + + #[test] + fn a_bundle_that_contributes_nothing_says_so_rather_than_drawing_an_empty_field() { + let mut plugin = listed(); + plugin.contributions = Vec::new(); + let rows = rows(&view_of(plugin), 120, &UNICODE); + let detail = rows[0].detail.clone().expect("a listed bundle has detail"); + assert!(detail.starts_with("contributes nothing"), "{detail}"); + } + + #[test] + fn a_refused_bundle_is_marked_apart_and_carries_the_harness_sentence() { + let sentence = "/repo/bundles/empty: no plugin.toml; a plugin is a directory with a \ + manifest at its root"; + let view = View { + plugins: vec![listed()], + refused: vec![Refused { + id: "empty".to_string(), + path: PathBuf::from("/repo/bundles/empty"), + error: sentence.to_string(), + }], + }; + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view, 200, glyphs); + // The positional contract `rows` documents: loaded first, refused + // after, one row each and nothing in between. + assert_eq!(rows.len(), 2, "{}", glyphs.name); + assert_eq!(rows[0].mark, Some(LOADED_MARK), "{}", glyphs.name); + assert_eq!(rows[1].mark, Some(REFUSED_MARK), "{}", glyphs.name); + assert_eq!(rows[1].label, "empty"); + let detail = rows[1].detail.clone().expect("a refused bundle has detail"); + assert!(detail.starts_with("not loaded"), "{detail}"); + assert!( + detail.contains(sentence), + "{}: the sentence is carried verbatim where it fits: {detail}", + glyphs.name + ); + } + } + + #[test] + fn a_refused_row_is_shortened_rather_than_reworded() { + let sentence = "/repo/bundles/x: key `mcp`: a plugin declared in a project-scoped \ + `io.toml` may not contribute `[[mcp]]`"; + let view = View { + plugins: Vec::new(), + refused: vec![Refused { + id: "x".to_string(), + path: PathBuf::from("/repo/bundles/x"), + error: sentence.to_string(), + }], + }; + for glyphs in [&UNICODE, &ASCII] { + let rows = rows(&view, 80, glyphs); + let detail = rows[0].detail.clone().expect("a refused bundle has detail"); + assert!( + detail.ends_with(glyphs.ellipsis), + "{}: a shortened sentence carries the set's own mark: {detail}", + glyphs.name + ); + assert!( + sentence.starts_with( + detail + .trim_start_matches("not loaded") + .trim_start_matches(glyphs.separator) + .trim_end_matches(glyphs.ellipsis) + ), + "{}: what is drawn is a prefix of the harness's own words: {detail}", + glyphs.name + ); + assert!( + rows[0].label.chars().count() + detail.chars().count() + 4 <= 80, + "{}: the row fits: {detail}", + glyphs.name + ); + } + } + + #[test] + fn the_detail_view_groups_what_a_bundle_contributed() { + for glyphs in [&UNICODE, &ASCII] { + let rows = detail(&listed(), 100, glyphs); + let labels: Vec<&str> = rows.iter().map(|row| row.label.as_str()).collect(); + assert!(labels.contains(&"skills"), "{}: {labels:?}", glyphs.name); + assert!(labels.contains(&"agents"), "{}: {labels:?}", glyphs.name); + assert!( + labels.contains(&"rust-review__reviewer"), + "{}: the namespaced name io-harness wrote, not a stripped one: {labels:?}", + glyphs.name + ); + assert!( + labels.contains(&"rust-review__no-secrets"), + "{}: {labels:?}", + glyphs.name + ); + assert!( + !labels.contains(&"mcp servers"), + "{}: a group with nothing in it is not drawn: {labels:?}", + glyphs.name + ); + assert!( + rows.iter().any(|row| row.heading), + "{}: the groups are headings", + glyphs.name + ); + } + } + + #[test] + fn hooks_are_declared_as_unlistable_rather_than_omitted() { + let rows = detail(&listed(), 120, &UNICODE); + let position = rows + .iter() + .position(|row| row.label == "hooks") + .expect("a bundle declaring hooks says so"); + assert!(rows[position].heading); + let said = rows[position + 1] + .detail + .clone() + .expect("the hooks row explains itself"); + assert!( + said.contains("io-harness does not expose"), + "io-cli says what it cannot say: {said}" + ); + + // And a bundle with no hooks has no such group, so the sentence above is + // never drawn over a bundle it is not true of. + let mut quiet = listed(); + quiet.contributions = vec!["skills"]; + assert!(detail(&quiet, 120, &UNICODE) + .iter() + .all(|row| row.label != "hooks")); + } + + #[test] + fn a_bundle_with_nothing_listable_still_draws_a_row() { + let plugin = Listed { + id: "bare".to_string(), + description: None, + version: None, + root: PathBuf::from("/repo/bare"), + contributions: Vec::new(), + skills: None, + templates: None, + agents: Vec::new(), + servers: Vec::new(), + layers: Vec::new(), + }; + let rows = detail(&plugin, 80, &ASCII); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "this bundle contributes nothing"); + } + + #[test] + fn the_edits_name_the_plugin_array() { + assert_eq!( + add(Path::new("bundles/rust-review")), + crate::edit::Edit::append("plugin", "path = \"bundles/rust-review\"") + ); + assert_eq!( + remove(2), + crate::edit::Edit::remove("plugin[2]".to_string()) + ); + } + + #[test] + fn a_windows_path_is_escaped_rather_than_handed_over_as_escapes() { + assert_eq!( + add(Path::new(r"C:\bundles\rust-review")), + crate::edit::Edit::append("plugin", r#"path = "C:\\bundles\\rust-review""#) + ); + } + + #[test] + fn the_view_reads_the_loaded_and_the_refused_out_of_one_configuration() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path(); + std::fs::create_dir_all(root.join("good").join("skills")).expect("the bundle directory"); + std::fs::write( + root.join("good").join("plugin.toml"), + "name = \"rust-review\"\ndescription = \"reviews\"\nversion = \"1.2.0\"\n\ + skills = \"skills\"\n\n[[agent]]\nname = \"reviewer\"\n", + ) + .expect("the manifest"); + std::fs::create_dir(root.join("bad")).expect("the empty bundle directory"); + std::fs::write( + root.join("io.local.toml"), + "[[plugin]]\npath = \"good\"\n\n[[plugin]]\npath = \"bad\"\n", + ) + .expect("the configuration"); + + let config = io_harness::Config::discover(root).expect("the configuration loads"); + let view = view(&config); + assert!(!view.is_empty()); + + let plugin = view + .plugins + .iter() + .find(|plugin| plugin.id == "rust-review") + .expect("the good bundle loaded"); + assert_eq!(plugin.description.as_deref(), Some("reviews")); + assert_eq!(plugin.version.as_deref(), Some("1.2.0")); + assert_eq!(plugin.contributions, vec!["skills", "agents"]); + assert_eq!( + plugin.agents, + vec!["rust-review__reviewer".to_string()], + "the name is namespaced by io-harness before io-cli ever sees it", + ); + + let refused = view + .refused + .iter() + .find(|refused| refused.path.ends_with("bad")) + .expect("the manifest-less bundle was dropped rather than erroring"); + assert!( + refused.error.contains("plugin.toml"), + "the harness's own sentence, whole: {}", + refused.error + ); + } +} diff --git a/src/triage.rs b/src/triage.rs index 7f29b17..b376a29 100644 --- a/src/triage.rs +++ b/src/triage.rs @@ -41,7 +41,7 @@ pub enum Disposition { Silent, } -/// Every kind io-harness 0.66 declares, in its own declaration order. +/// Every kind io-harness 0.69 declares, in its own declaration order. /// /// The order is the enum's rather than alphabetical so that this table can be /// read down the side of `observe.rs` when the pin moves. @@ -165,8 +165,10 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ ( "plugin_loaded", Disposition::Silent, - "`io exec --json` and the durable trace; a bundle that loaded changed nothing an operator \ - asked about mid-session", + "`/plugin`, which lists it and what it contributed; also `io exec --json` and the durable \ + trace. Silent on the stream because it fires at step 0 of every turn — a bundle that \ + loaded changed nothing an operator asked about mid-session, and a line each time would be \ + a line about a directory that has not moved since the session started", ), ("lsp_started", Disposition::Status, "the lsp field"), ("browser_started", Disposition::Status, "the web field"), @@ -183,7 +185,13 @@ pub const TRIAGE: &[(&str, Disposition, &str)] = &[ ( "plugin_dropped", Disposition::Silent, - "`io exec --json` and the durable trace", + "`/plugin`, which lists it with io-harness's own sentence for why; also `io exec --json` \ + and the durable trace. **Silent is a close call and it is recorded as one.** A bundle \ + that failed to load is exactly the kind of fact this interface argues should be visible — \ + but it is a standing misconfiguration rather than an event, it fires at step 0 of every \ + turn for as long as it goes unfixed, and a refusal repeated once a turn teaches an \ + operator to stop reading refusals. So it goes to the surface that holds standing facts, \ + and `/plugin` exists from this release for that", ), ( "rewound", From b71c0c4d79fb9ccbe9da33cfcce98526a0080204 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:09:57 +0530 Subject: [PATCH 04/10] docs: bundles, hooks and documents, where the release makes them false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README gains sections on capability bundles, hooks and documents, and its command table gains `/plugin`. The documents section names all twelve tools with the six that write, and states what they cannot do — Word is generate-and-read with no in-place edit, PowerPoint is read-only, PDF reading order is best effort and a scanned page comes back empty rather than erroring, `xlsx_set_cell` preserves a workbook in practice rather than by guarantee, and there is no OCR and no barcode generation. An operator who finds that out by losing work has been failed by this file. `docs/config.example.toml` documents `[[plugin]]` and `[[hook]]` for the first time, with the scope-trust rules as comments. The CHANGELOG names the `image` pin, because an operator running `cargo update` will meet it. --- CHANGELOG.md | 102 ++++++++++++++++ README.md | 249 ++++++++++++++++++++++++++++++++++----- docs/config.example.toml | 105 +++++++++++++++-- 3 files changed, 420 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b9e345a..2e529fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,108 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.20.0] - 2026-08-27 + +A directory somebody else wrote can add to your session, and you can see exactly +what it added. + +**Capability bundles load, and `/plugin` says what each one brought.** A bundle +is a directory with a `plugin.toml`, named by a `[[plugin]] path = "..."` entry +in a configuration file — a declaration and never a scan, so nothing loads by +being present on disk. One can contribute six kinds of thing at once: skills, +prompt templates, `[[agent]]` definitions, `[[mcp]]` servers, `[[hook]]` tables +and policy layers. `/plugin` lists what loaded and what each bundle contributed, +and under that the row it really exists for: every bundle that was declared and +dropped, carrying io-harness's own sentence for why. That loader has no error +path — a dropped bundle is recorded and otherwise silently absent while every +other one loads, which is how a bundle you believe is running stays gone for a +week. + +**Which file declared a bundle decides what it may contribute.** A bundle named +in the project-scoped `io.toml` — the file a `git clone` delivers — may +contribute skills, templates, agents and policy, and may not contribute hooks or +MCP servers, because both run a program on this machine. One that tries is +refused **whole**: it contributes nothing, not the half that would have been +safe. The same directory named from `io.local.toml` or the user file loads +completely, so the rule is about which file, not about the bundle. Its policy may +only **deny** as well: a `[policy] defaults` table is refused by name and any rule +whose effect is not `deny` drops the bundle, so the worst a bundle can do to your +boundary is narrow it. An id matches `[a-z0-9][a-z0-9-]{0,31}`, and every agent +name, server id and layer name it contributes is namespaced to `__` +by io-harness — which is the string `/plugin` draws, because it is the one a +refusal, a call and a spawn all use. + +**A bundle's hooks are the one contribution io-cli cannot itemise, and the row +says so rather than being left out.** io-harness's `Hook` type is private and +there is no accessor, so `/plugin` can report that a bundle contributed hooks and +nothing about what they run. An omitted row would read as a bundle with no hooks, +which is the false reading, on the contribution kind that runs programs. + +**`[[hook]]` tables run.** They were parsed and inert before this — a file asking +for every event to be appended to `audit.jsonl` produced an empty file and no +error — and they now run in a session turn and in `io exec` from the same call. A +hook either appends one JSON line per event to a file or runs an argv array, +never a shell string. `on` names the events to observe and empty means all of +them; `at = "before_tool"` puts it in front of a tool call, with `tools` to +filter which calls it sees; the two are mutually exclusive and an `at` hook must +have a `run`. `on_failure` decides what a failure costs: `continue` carries on, +`cancel` ends the turn at the next step boundary and leaves the run resumable, +`refuse` turns that one tool call back. `timeout_ms` is 5000 when absent. + +**A project-scoped file may not declare `[[hook]]`, and that refusal is now +readable.** io-harness refuses the whole configuration rather than dropping the +table, because a hook runs a command on this machine and `io.toml` arrives with a +`git clone`. There is no `Config` to be had and `io` genuinely cannot start — +what changed is the words. io-harness's own sentence names the key, the reason +and the two files that may carry it, and it is printed whole under a line saying +which file was being read. Before this it arrived as a bare error string from a +program that had already exited, against a repository the operator had just +cloned. + +**The fleet has names.** A child is drawn by the address it was spawned under — +the `as` argument, or a derived one like `reviewer#42` — with its roster role +beside it, instead of by a run number nobody chose. A message one agent sent a +named sibling is shown in the tree with its body, which is the case a run number +told you nothing about. And a child that detached rather than being waited for +can be selected and attached to: a parent that stops waiting is not a parent that +stops the work, and that child was a row you could read and could not reach. A +queued child is still a count and still has no address, because io-harness names +a child when it admits one. + +**Documents: twelve tools, and six of them write.** io-cli turns on io-harness's +`documents` feature — `xlsx`, `docx`, `pptx`, `pdf`, `barcode` — so the agent +gains `xlsx_sheets`, `xlsx_read`, `docx_read`, `pptx_read`, `pdf_read` and +`barcode_decode` to read with, and `xlsx_write`, `xlsx_set_cell`, `docx_write`, +`pdf_write`, `pdf_watermark` and `pdf_fill_form` to write with. `xlsx_write` +replaces a file that already exists. Every one of them passes the policy gate, +the approval prompt and the refusal rendering any other read or write passes — +and io-cli cannot take a tool out of io-harness's workspace tool set, which is +the ground `view_image` was disclosed on and the reason the six writers are named +here one by one rather than counted. The reader is chosen by the tool the model +called and not by the file's extension. + +**What the document tools do not do is in the README**, and is worth reading +before pointing one at a file that matters. Word is generate-and-read with no +edit in place, so a read-then-write drops comments, content controls, fields and +vendor extensions; PowerPoint is read-only; PDF text extraction is best-effort +about reading order, and a scanned page comes back with empty text rather than an +error; `xlsx_set_cell` preserves the rest of a workbook in practice rather than +by guarantee, and not for chart-, pivot- or macro-heavy files. There is no OCR +and no barcode generation. + +**`documents` costs 159 transitive crates and pins `image` down a patch.** +`barcode` is `rxing`, and every published `rxing 0.9.x` requires `image` at +exactly `=0.25.8`; this crate asks for `^0.25` and the lockfile stood at 0.25.10, +which cargo cannot reconcile, so the lock is pinned to 0.25.8 — the one version +both requirements accept. A `cargo update` that lifts `image` again will fail to +resolve until `rxing` relaxes. It is written here because an operator otherwise +meets it in a red resolve rather than in a release note. + +**`/plugin` is the twenty-seventh command**, and it is in the **configure** +group beside `/mcp` and `/provider` because it is the third surface of that kind: +something a configuration file declares by name whose effect on the session is +otherwise invisible. Configure goes to seven. + ## [0.19.0] - 2026-08-27 Ask for a thing io can do, in your own words, and have it happen. diff --git a/README.md b/README.md index 3c49b24..e70ed31 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ if this crate ever grows an HTTP client, a TLS stack, a database or a sandbox. - [Install](#install) · [First run](#first-run) · [While it works](#while-it-works) - [Keys](#keys) · [Commands](#commands) · [Configuration](#configuration) -- [The fleet](#the-fleet) · [Pictures](#pictures) · [Background jobs](#background-jobs) +- [Capability bundles](#capability-bundles) · [Hooks](#hooks) · [The fleet](#the-fleet) +- [Pictures](#pictures) · [Documents](#documents) · [Background jobs](#background-jobs) - [Reading it without seeing it](#reading-it-without-seeing-it) · [Headless](#headless) - [What this release is not](#what-this-release-is-not) · [Platform support](#platform-support) · [Stability](#stability) @@ -44,6 +45,7 @@ on one row and the keys and the posture on the next.](docs/screenshot.png) | **Headless** | `io exec` runs one goal to completion with documented exit codes and `--json` | | **Readable without seeing it** | `--plain` animates nothing and commits every state change as text, for a screen reader, a braille display or a log | | **Markdown, rendered** | Headings, bullets, code and emphasis drawn as themselves rather than printed as notation | +| **Documents, read and written** | Spreadsheets, Word, slide decks, PDFs and barcodes through io-harness's own tools — twelve of them, six of which write, every one under the same gate as any other read or write | ## It never takes your terminal @@ -390,6 +392,12 @@ above a row that ranked there for reasons having nothing to do with it. | `/memory` | what io remembers: the instruction files, and the agent's own notes | | `/mcp` | the MCP servers configured, and what this session has seen of each | | `/provider` | the providers configured, in the order a turn tries them | +| `/plugin` | the capability bundles loaded, what each contributed, and the ones that failed | + +`/plugin` is new in 0.20.0 and sits beside those two because it is the third +surface of the same kind: something a configuration file declares by name, whose +effect on the session is otherwise invisible. See [Capability +bundles](#capability-bundles). `/mcp` and `/provider` sit under **configure** from 0.19.0, and it is a correction rather than a promotion: both open with a list, and both go on from @@ -536,6 +544,116 @@ the limit who gains five more would otherwise get no skills at all as their upgrade. io-cli counts first, installs up to the ceiling and stops, and says how many it installed and how many it withheld. +## Capability bundles + +**A bundle is a directory with a `plugin.toml`, and it is in your session because +a file of yours named it.** One `[[plugin]]` entry, and nothing else: + +```toml +[[plugin]] +path = "~/bundles/rust-review" +``` + +That is a declaration and never a scan. There is no directory io walks looking +for bundles, no registry it fetches from, and nothing that loads by being present +on disk — declaring one is the whole of installing one, and deleting the line is +the whole of removing one. + +One directory can hand over six kinds of thing at once: skills, prompt templates, +`[[agent]]` definitions for a fan-out to draw children from, `[[mcp]]` servers, +`[[hook]]` tables, and policy layers. That breadth is why `/plugin` exists. +Every other capability in a session is one you put there — a skill file is yours +or io-cli's, an `[[mcp]]` entry is a line you wrote, a policy layer came from a +posture you chose. A bundle is a directory somebody else wrote that adds names to +four subsystems on one line, and *what did that directory put in my session* is a +question whose only previous answer was to open the manifest. + +**`/plugin` answers it, and it answers the dropped ones too.** One row per bundle +with its id, its root and what it contributed; choosing one opens what it brought, +by name. Under those, one row per bundle that was *declared and did not load*, +carrying io-harness's own sentence whole. That second list is what the surface is +really for: io-harness's plugin loader has no error path, so a bundle with no +manifest, unparseable TOML, an unusable id or a contribution its scope may not +make is dropped, recorded, and otherwise silently absent while every other bundle +loads. A bundle you believe is running can be gone for a week. This is where that +week ends. + +**Which file declared a bundle decides what it may contribute.** A bundle named +in the project-scoped `io.toml` — the file a `git clone` delivers — may +contribute skills, templates, agents and policy, and may **not** contribute +hooks or MCP servers, because both of those run a program on this machine. A +project-scoped bundle that tries is refused **whole**: it contributes nothing at +all, not the half that would have been safe. Move the `[[plugin]]` line into +`io.local.toml` or into your user file and the same directory loads completely. +The rule is about which file names it, exactly as it is for `[browser]`. + +**A bundle's policy may only narrow.** Its layers may deny and may never allow: a +`[policy] defaults` table in a manifest is refused by name, and a single rule +whose effect is anything but `deny` drops the bundle. So the worst a bundle you +misjudged can do to your permission boundary is take something out of it. + +**A bundle id must match `[a-z0-9][a-z0-9-]{0,31}`**, and every name it +contributes is rewritten by io-harness to `__` at load — an agent's +name, an MCP server's id, a policy layer's name. `/plugin` draws that namespaced +string unchanged rather than a prettier short form, because it is what a refusal +will name, what a tool call will name, and what you will type to spawn the agent. +A shorter name here would be a third spelling of the same thing. + +**Hooks are the one contribution io cannot itemise, and the row says so.** +io-harness applies a bundle's hooks and keeps its `Hook` type private, so there +is no API by which this program can count them or say what any of them runs. +`/plugin` therefore draws a row saying the bundle contributed hooks and that io +cannot say what they do. The alternative was to leave the row out, which reads as +a bundle with no hooks — the one reading that is false, on the contribution kind +that runs programs. + +## Hooks + +**`[[hook]]` tables run from 0.20.0.** They were parsed before this release and +then installed on nothing, so a file asking for every event to be written to +`audit.jsonl` produced an empty file and no error. They now run in a session turn +and in `io exec` alike, from the same call that builds everything else. + +A hook either writes events down or runs a program: + +```toml +[[hook]] +on = [] # the events to observe; empty means every one +append = "audit.jsonl" # one JSON line per event, appended + +[[hook]] +at = "before_tool" # the only `at` there is +tools = ["shell"] # which calls this one sees +run = ["./scripts/gate.sh"] # argv, never a shell string +on_failure = "refuse" +timeout_ms = 5000 # the default +``` + +`on` and `at` are mutually exclusive, because the first is an observer of events +and the second is a gate in front of a tool call. An `at` hook must have a `run`. +Exactly one of `append` and `run`: a hook that did both would be a hook whose +failure meant two things. + +**`on_failure` is where a hook's power actually is.** `continue` lets the turn go +on, which is what an audit hook wants. `cancel` ends the turn at the next step +boundary and **the run stays resumable** — it is a stop, not a crash. `refuse` +turns that single tool call back and leaves the turn running, which makes a +`before_tool` hook a rule of your own standing beside the policy engine's. + +**`run` is an argv array and never a shell string.** Nothing is word-split and +nothing is expanded: the program you named is the program that runs, with the +arguments you wrote. + +**A project-scoped file may not declare `[[hook]]` at all.** io-harness refuses +the whole configuration rather than dropping the table — a hook runs a command on +this machine and `io.toml` is the file a `git clone` delivers. There is no +`Config` to be had, so `io` genuinely cannot start, and 0.20.0 does not soften +that. What it changes is the words: io-harness's own sentence, which names the +key, the reason and the two files that may carry it, under a line saying which +file was being read. Before this it arrived as a bare error string from a program +that had already exited, against a repository you had just cloned. Write the +table in `io.local.toml` or in your user file. + ## The fleet An agent can break a task into sub-agents and run them over the same workspace. @@ -560,9 +678,28 @@ tree's remaining budget on the status line beside everything else. A refused spawn says which cap refused it and that the agent carries on with what it has. A report collected from a child lands in the transcript where it arrives. +**From 0.20.0 a child is shown by the name it was spawned under.** io-harness +gives every admitted child an address — the `as` argument the parent chose, or +one derived from the agent it drew, like `reviewer#42` — and that is what the row +carries, with its roster role beside it, instead of a run number nobody picked. A +run id identifies a row in the store; an address is what the parent used to reach +the child, what a message between two of them names, and what you type to attach +to one. + +**A message one agent sent a named sibling is drawn in the tree, with its body.** +Children talking to each other is the case a run number told you nothing about: +one addressed line with the text under it, landing where it happened. + +**A child that detached can be selected and attached to.** A parent that stops +waiting is not a parent that stops the work — a detached child is still running, +and until now it was a row you could read and could not reach. + **A waiting child is a number and not a row**, because until a concurrency slot -frees it has no run of its own to name. A fleet that is queueing and a fleet that -is stuck look identical without that count, which is why it is there. +frees it has no run of its own to name. It has no address either, for the same +reason: io-harness names a child when it admits one, so there is nothing to call +a queued child even now that the admitted ones have names. A fleet that is +queueing and a fleet that is stuck look identical without that count, which is +why it is there. **And from 0.12.0 that is all it costs you.** `Ctrl+C` still ends the turn, at the next point where no child is in flight, and the interface tells you that is what @@ -623,11 +760,16 @@ are refused **by name**, because a refusal that says which format it was is one you can act on. A provider that does not accept images at all is refused at the door rather than after you have typed the prompt. -**The agent can look at images in the workspace from this release**, using -io-harness's own `view_image` tool, which enabling its `media` feature switches -on. It is bounded by the same policy as any other read. When it looks, the same -picture goes into your scrollback at that point in the conversation, so you are -reading what it read rather than a path you would have to open yourself. +**The agent can look at images in the workspace**, using io-harness's own +`view_image` tool, which enabling its `media` feature switches on. It is bounded +by the same policy as any other read. When it looks, the same picture goes into +your scrollback at that point in the conversation, so you are reading what it +read rather than a path you would have to open yourself. + +That is the shape every capability of this kind arrives in, and 0.20.0 adds +twelve more of them: **io-cli cannot take a tool out of io-harness's workspace +tool set**, so a feature this crate turns on is a tool the agent has, and the +only honest thing to do with that is say so. See [Documents](#documents). A picture is drawn from half blocks — `▀` splits a cell into two halves that are each about square — fitted to your terminal's width and bounded in height. On @@ -643,6 +785,50 @@ picture at all — one line naming the file, its format and its size. A half-blo picture is colour carrying the entire meaning, which is the one thing this interface will not do. +## Documents + +**The agent can read and write spreadsheets, Word files, slide decks, PDFs and +barcodes from 0.20.0**, because io-cli turns on io-harness's `documents` feature +— `xlsx`, `docx`, `pptx`, `pdf` and `barcode`. That is twelve tools in its +workspace tool set, and **six of them write**: + +| Format | Reads with | Writes with | +| --- | --- | --- | +| Spreadsheets | `xlsx_sheets`, `xlsx_read` | `xlsx_write`, `xlsx_set_cell` | +| Word | `docx_read` | `docx_write` | +| PowerPoint | `pptx_read` | — | +| PDF | `pdf_read` | `pdf_write`, `pdf_watermark`, `pdf_fill_form` | +| Barcodes | `barcode_decode` | — | + +Every one of them is a read or a write like any other: the same policy gate, the +same approval prompt answered where it was asked, and the same refusal naming the +act, the target, the rule and the layer. **`xlsx_write` replaces a file that +already exists** — under the write gate, so it is proposed to you before it +happens rather than reported afterwards. + +**Which reader runs is decided by the tool the model called, not by the file's +extension.** A `.docx` handed to `pdf_read` is a failed read rather than a guess, +and renaming a file changes nothing about what can be done to it. + +**What they do not do**, because a document tool that half-works is worse than +one that is absent: + +- **Word is generate-and-read, with no edit in place.** A read followed by a + write produces a new document out of the text that came back, so comments, + content controls, fields and vendor extensions that were in the original are + not in the result. It is the right tool for producing a document and the wrong + one for touching up somebody else's contract. +- **PowerPoint is read-only.** There is no `pptx_write`, and the table above is + the whole of it. +- **PDF text extraction is best-effort about reading order**, which is what + extracting text from a page-description format means. **A scanned page comes + back with empty text rather than an error**, because there is nothing in it to + extract — there is no OCR anywhere in this. +- **`xlsx_set_cell` preserves the rest of a workbook in practice rather than by + guarantee.** It is the tool for changing a value in a sheet of data, and not + the tool for a workbook heavy with charts, pivot tables or macros. +- **There is no barcode generation**, only decoding. + ## Background jobs An agent can start something that outlives the step that started it — a dev @@ -826,6 +1012,11 @@ tries them. Reorder it and you have arranged the fallback chain io-harness has supported since its 0.27.0. The twenty-one presets it reaches through one `Compatible` provider are offered by name with the endpoint each resolves to. +**`/plugin`** shows the capability bundles a `[[plugin]]` entry declared: what +each one contributed, by name, and every bundle that was declared and dropped +with io-harness's own reason beside it. See [Capability +bundles](#capability-bundles). + **`/profile`** switches to a named `[profile.]` for the session, and `--profile ` picks one for a single run without writing anything. @@ -908,7 +1099,9 @@ rewrites a permission boundary on disk is the opposite of what that key is for. from the same call.** `[sandbox]` limits, `[run]` budgets, `[run.commit_identity]`, `[[agent]]`, `[web]`, `[memory]`, `[instructions]`, `[[mcp]]`, `[[lsp]]` and `[browser]` are all applied to a turn's contract, in your terminal exactly as in -CI. There is no longer a section of this file that a session reads past. +CI — and from 0.20.0 so are `[[plugin]]` and `[[hook]]`, which are the last two +that reached nothing. There is no longer a section of this file that a session +reads past. The layers run weakest to strongest, and that order is asserted rather than described: io-harness's own defaults, then io-cli's step floor, then everything @@ -961,24 +1154,20 @@ it](#reading-it-without-seeing-it). ## What this release is not -0.14.0 is the release where the configuration file reaches your terminal: every -section of it bounds a session turn as it already bounded `io exec`, `/status` -commits the whole picture into the scrollback, and the ceilings in force are on -the status line beside what has been drawn against them. - -**Four sections of the file are still not applied, and each has a reason.** -`[[hook]]` and capability bundles reach a contract through their own builders and -need a surface that reports what loaded and what was dropped; `[prices]` is not -part of a contract at all and belongs with the release that reads the -provider-call rows; there is no `[verify]` section to apply, and giving a session -verification gates needs its own surface; and `run.templates` is the thirteenth -`[run]` key, reachable only through its own accessor. None of them is a silent -omission any more — this is where they are named. - -**There is no way to change a key from inside the session.** `/status` reads the -state and never writes it, and editing configuration is 0.16.0 — a surface for -changing a key is worth building once changing the key does something, which is -what this release is. +The configuration file has reached your terminal since 0.14.0: every section of +it bounds a session turn as it already bounded `io exec`, `/status` commits the +whole picture into the scrollback, and the ceilings in force are on the status +line beside what has been drawn against them. + +**`[[hook]]` and capability bundles are applied from 0.20.0**, each with the +surface the omission was waiting on: `/plugin` for what a bundle brought and what +was dropped, and io-harness's own refusal sentence for a hook a project file may +not declare. That leaves two sections and one key still unapplied, and each has a +reason. `[prices]` is not part of a contract at all and belongs with the release +that reads the provider-call rows; there is no `[verify]` section to apply, and +giving a session verification gates needs its own surface; and `run.templates` is +the thirteenth `[run]` key, reachable only through its own accessor. None of them +is a silent omission — this is where they are named. **Sixel is still absent**, because encoding it means palette quantisation and another dependency, for terminals that either speak one of the two protocols @@ -1007,6 +1196,12 @@ than the provider would mean reading the live catalogue on every attach, and it would still not close the door the agent opens. **If you work with images, choose a model that accepts them.** +**The twelve document tools cannot be taken out of that tool set either**, and +six of them write. A model that reaches for `docx_write` in a session where you +never meant a document to be written is stopped by the write gate rather than by +the absence of the tool — which is what the gate is for, and why the writers are +named one by one in [Documents](#documents) rather than counted. + **An image the agent was *given* rather than asked for is not shown.** A picture returned by an MCP tool, and a browser screenshot, both become images inside io-harness — but through private plumbing and with no event of any kind, so diff --git a/docs/config.example.toml b/docs/config.example.toml index c16f405..a56fe06 100644 --- a/docs/config.example.toml +++ b/docs/config.example.toml @@ -98,6 +98,10 @@ net = "deny" # [[agent]] the roster a fan-out draws its children from # [web] provider-executed search and fetch # [memory] how much durable memory a run may keep +# [[plugin]] capability bundles, declared by path. 0.20.0; see the +# block below +# [[hook]] what to do on an event, or in front of a tool call. +# Parsed and inert before 0.20.0; see the block below # # THIS BLOCK USED TO SAY THE OPPOSITE, and the reason it gave went stale a # release before the block did. Through 0.13.1 these sections reached `io exec` @@ -160,9 +164,93 @@ net = "deny" # max_chars = 200000 # the total size of them # max_entry_chars = 4000 # the size of any one of them +# --- Capability bundles (0.20.0) -------------------------------------------- +# +# A bundle is a directory with a `plugin.toml` in it, and it is loaded because +# THIS FILE NAMES IT. There is no directory io walks looking for bundles and no +# registry it fetches from: a `[[plugin]]` entry is the whole of installing one, +# and deleting the entry is the whole of removing one. +# +# One directory can contribute six kinds of thing at once — skills, prompt +# templates, [[agent]] definitions, [[mcp]] servers, [[hook]] tables and policy +# layers — which is why there is a surface for it. `/plugin` lists what loaded, +# what each bundle contributed, and every bundle that was declared and DROPPED +# with io-harness's own reason beside it. That loader has no error path: a +# dropped bundle is recorded and otherwise silently absent while every other one +# loads. +# +# WHICH FILE DECLARES A BUNDLE DECIDES WHAT IT MAY CONTRIBUTE. A bundle named in +# the project-scoped io.toml — the file a `git clone` delivers — may contribute +# skills, templates, agents and policy, and may NOT contribute hooks or MCP +# servers, because both of those run a program on this machine. One that tries is +# refused WHOLE: it contributes nothing at all, not the half that would have been +# safe. Name it from io.local.toml or from this user-scope file and the same +# directory loads completely. The rule is about which file, not about the bundle. +# +# A BUNDLE'S POLICY MAY ONLY DENY. A `[policy] defaults` table in a manifest is +# refused by name, and a single rule whose effect is not `deny` drops the bundle. +# So the worst a bundle can do to the boundary above is narrow it. +# +# An id must match [a-z0-9][a-z0-9-]{0,31}, and every agent name, MCP server id +# and policy layer name a bundle contributes is rewritten to `__` +# by io-harness at load. That is the name a refusal, a call and a spawn will all +# use, and the name `/plugin` draws. +# +# io-cli CANNOT LIST A BUNDLE'S HOOKS. io-harness's `Hook` type is private and +# there is no accessor, so `/plugin` reports that a bundle contributed hooks and +# nothing about how many there are or what they run. +# +# [[plugin]] +# path = "~/bundles/rust-review" + +# --- Hooks (0.20.0) --------------------------------------------------------- +# +# What to do when something happens. PARSED AND INERT BEFORE 0.20.0 — a table +# here was read, accepted and then installed on nothing, so a file asking for +# every event to be written to a log produced an empty file and no error. From +# 0.20.0 hooks run in a session turn and in an `io exec` run alike. +# +# A hook either writes events down or runs a program, and exactly one of `append` +# and `run` says which — a hook that did both would be a hook whose failure meant +# two things. `on` and `at` are mutually exclusive: `on` observes events, `at` +# gates a tool call, and an `at` hook must have a `run`. +# +# on the event names to observe. EMPTY MEANS EVERY EVENT +# at "before_tool", which is the only one there is +# tools for an `at` hook, which tool calls it sees +# append a file to append one JSON line per event to +# run argv, NEVER a shell string: nothing is word-split or expanded +# on_failure continue | cancel | refuse +# timeout_ms 5000 when absent +# +# `on_failure` is where a hook's power actually is. `continue` lets the turn go +# on, which is what an audit hook wants. `cancel` ends the turn at the next step +# boundary and the run STAYS RESUMABLE — it is a stop, not a crash. `refuse` +# turns that one tool call back and leaves the turn running, which makes a +# `before_tool` hook a rule of your own standing beside the policy engine's. +# +# A PROJECT-SCOPED FILE MAY NOT DECLARE [[hook]] AT ALL, and io-harness refuses +# the WHOLE configuration rather than dropping the table — a hook runs a command +# on this machine, and io.toml is the file a `git clone` delivers. There is no +# Config to be had, so io genuinely cannot start against such a file; what 0.20.0 +# changed is that it prints io-harness's own sentence, which names the key, the +# reason and the two files that may carry it, instead of a bare error string. +# Write the table here or in io.local.toml. +# +# [[hook]] +# on = [] # every event +# append = "audit.jsonl" +# +# [[hook]] +# at = "before_tool" +# tools = ["shell"] +# run = ["./scripts/gate.sh"] +# on_failure = "refuse" +# timeout_ms = 5000 + # --- io-cli's own section --------------------------------------------------- # -# Six keys and five tables. io-harness does not validate any of it, so an +# Eight keys and five tables. io-harness does not validate any of it, so an # unrecognised VALUE reads as the default rather than stopping a session. A # table io-harness cannot parse at all is different: the session starts on the # defaults and says so, carrying the harness's own message. @@ -201,14 +289,13 @@ plain = false # # How many steps one turn may take. # -# DEPRECATED IN 0.14.0, STILL WINNING, AND REMOVED IN 0.16.0. It exists because -# the flat turn once had no way to raise io-harness's own cap of twelve, and -# `[run] max_steps` above now does the same job from a table io-harness documents -# itself. Until 0.16.0 this key keeps working and keeps beating `[run] max_steps` -# — it is applied last, over everything the harness's own table said — and a file -# that carries it is told so once at session start, naming where the number -# moves to. A file with neither key gets io-cli's own floor of a thousand, which -# is not a budget you set and therefore draws no field on the status line. +# DEPRECATED IN 0.14.0 AND REMOVED IN 0.16.0. It existed because the flat turn +# once had no way to raise io-harness's own cap of twelve, and `[run] max_steps` +# above does the same job from a table io-harness documents itself. It is no +# longer read at all: a file still carrying it is told so once at session start, +# with the steps it asks for named and the key they move to. A file with neither +# key gets io-cli's own floor of a thousand, which is not a budget you set and +# therefore draws no field on the status line. # # max_steps = 40 From 88e43e1cb9a2dba8c4adab2571143d9b5915707f Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:11:36 +0530 Subject: [PATCH 05/10] test: /plugin is a decision the command list records --- tests/commands.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/commands.rs b/tests/commands.rs index 1c74fba..223b05a 100644 --- a/tests/commands.rs +++ b/tests/commands.rs @@ -64,6 +64,11 @@ fn the_commands_are_the_commands() { "/skills", "/mcp", "/provider", + // 0.20.0 — the third surface that lists what the file declared and + // goes on to change it, and the widest of the three: one directory + // can hand over skills, templates, agents, servers, hooks and policy + // at once. That breadth is the argument for it being visible at all. + "/plugin", "/profile", "/contain", // 0.12.0 — the planning phase stopped being something From 5e0c1771c70c242e309930dc781e714b79b83bb7 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:13:03 +0530 Subject: [PATCH 06/10] test: the criteria for bundles and hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourteen tests over the two halves of the release that had none. The plugin tests build real bundle directories with `tempfile` and assert through io-harness's own calls rather than io-cli's account of them: a declared bundle reaches the contract with its agents namespaced; each way a bundle can be broken produces one dropped row and leaves the others loading; a project-declared bundle contributing hooks or servers is refused whole, and the same bundle in local scope loads fully. The `Edit` builders are tested through `edit::apply` on a file carrying sections io-cli has no type for, because surviving those is the property that matters about splicing bytes rather than re-serialising. The hook tests defend the guard that nothing else would notice. `contract::hooks` returning `Some` on a file with no hook is a change no test fails on and no operator can see — it just quietly costs everyone read speculation — so there is a test whose only job is that it returns `None`, and another asserting the contract carries no tool hooks to match. The rest assert io-cli adds no check of its own: a malformed hook is refused by io-harness, in io-harness's words, and the project-scope refusal keeps the harness's sentence intact while naming the directory it was read from. --- tests/hooks.rs | 323 ++++++++++++++++++++++++ tests/plugins.rs | 644 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 967 insertions(+) create mode 100644 tests/hooks.rs create mode 100644 tests/plugins.rs diff --git a/tests/hooks.rs b/tests/hooks.rs new file mode 100644 index 0000000..41d2bae --- /dev/null +++ b/tests/hooks.rs @@ -0,0 +1,323 @@ +//! F8 and F9 — the `[[hook]]` tables: the scope that may declare one, the +//! configurations that get a `Hooks` at all, and the validation io-cli must not +//! reimplement. +//! +//! **io-cli has no opinion about what a hook is.** Every rule below — which scope +//! may declare one, `on` against `at`, exactly one of `append` and `run`, whether +//! `tools` means anything without a lifecycle point — is enforced inside +//! `Config::discover`, and this file asserts io-harness's own sentences reach the +//! operator rather than asserting that io-cli reproduced them. A second copy of +//! those rules in this crate would be a second thing to keep true, and the release +//! it drifted in would be the one where a hook loads in io-cli and is refused by +//! the run. +//! +//! **What io-cli does own is the guard**, and it is the subject of F9. io-harness +//! disables read speculation on any run carrying a `Hooks` value at all — even one +//! holding no hooks — so whether that value is attached is a decision about every +//! operator who has never written a hook, which is nearly all of them. +//! +//! Fixtures that must be exact about the *absence* of a hook use +//! `Config::from_toml`, which reads only the text it is given. +//! `Config::discover` also reads a user-scope file where the machine has one. + +use std::path::PathBuf; + +use io_harness::config::{Config, LOCAL_FILE, PROJECT_FILE}; +use io_harness::PLUGIN_FILE; + +/// A temporary root, kept alive by the returned guard. +fn root() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path().to_path_buf(); + (dir, root) +} + +/// A root whose `file` holds `text`. +fn written(file: &str, text: &str) -> (tempfile::TempDir, PathBuf) { + let (dir, root) = root(); + std::fs::write(root.join(file), text).expect("the configuration"); + (dir, root) +} + +/// A hook that watches an event and runs a program. Valid in every scope but the +/// project one. +const EVENT_HOOK: &str = "[[hook]]\non = [\"finished\"]\nrun = [\"true\"]\n"; + +// --------------------------------------------------------------------------- +// F8 — the scope that may declare a hook +// --------------------------------------------------------------------------- + +/// **F8.** A `[[hook]]` in the committed `io.toml` fails discovery, and io-cli's +/// refusal carries io-harness's own sentence and names the directory it came from. +/// +/// Both halves matter and neither substitutes for the other. The sentence is the +/// only thing that tells an operator what to do — move the table to +/// `io.local.toml` — and the directory is the only thing that tells them *which* +/// checkout is refusing, which is the whole question when a session will not start +/// in one of six clones on the same machine. +/// +/// Sabotage: reword `configure::refusal` to "your io.toml is invalid", the sort of +/// tidy-up that reads as an improvement in review. Under it only this test fails, +/// and what ships is an operator staring at a session that will not start, with no +/// key named, no rule explained and no file to open. +#[test] +fn f8_a_project_scoped_hook_is_refused_and_io_cli_names_the_rule_and_the_root() { + let (_dir, root) = written(PROJECT_FILE, EVENT_HOOK); + + let error = Config::discover(&root) + .err() + .expect("a hook in the file a `git clone` delivers is refused"); + let said = io_cli::configure::refusal(&root, &error); + + // io-harness's own words, not a paraphrase of them. + assert!( + said.contains( + "a project-scoped file may not declare hooks, because a hook runs or writes on \ + this machine" + ), + "io-cli reworded the refusal: {said}", + ); + assert!( + said.contains(LOCAL_FILE), + "the operator is not told where the table may live: {said}", + ); + assert!( + said.contains(&root.display().to_string()), + "the refusal names no directory, so an operator with six checkouts cannot tell \ + which one refused: {said}", + ); + + // The same table one file over is the operator's own machine talking, and it + // loads — so the test above is about the scope rather than about hooks. + let (_dir, local) = written(LOCAL_FILE, EVENT_HOOK); + let config = Config::discover(&local).expect("a local hook is the operator's own"); + assert!(!config.hooks().is_empty()); +} + +// --------------------------------------------------------------------------- +// F9 — the guard, which is the most expensive line in this release to get wrong +// --------------------------------------------------------------------------- + +/// **F9.** A configuration that declares no hook gets no `Hooks` at all — not an +/// empty one. +/// +/// **The sabotage is returning `Some` unconditionally**, and it is the reason this +/// is the most important test in the file. Nothing fails under it. Every hook that +/// was ever written still runs, every test about hook behaviour stays green, and +/// `/config` looks identical. What changes is that io-harness stops speculating +/// reads on every run carrying a `Hooks` value — so the product gets slower for the +/// overwhelming majority of operators who have never written a hook, with nothing +/// on screen connecting the loss to this release and nothing in the suite to catch +/// it on the way out. +/// +/// Both io-cli's callers are asserted, because the guard is made twice — once in +/// `contract::configured` for the lifecycle half and once in `contract::hooks` for +/// the fan-out's event half — and a fix to one is not a fix to the other. +#[test] +fn f9_a_configuration_with_no_hook_attaches_no_hooks_at_all() { + let (_dir, root) = root(); + + // The text-only reading, which no file on this machine can influence. + let bare = Config::from_toml("").expect("an empty configuration"); + assert!( + io_cli::contract::hooks(&bare, &root).is_none(), + "a configuration with nothing in it was handed a Hooks", + ); + assert!( + io_cli::contract::configured("go", root.clone(), &bare) + .tool_hooks + .is_none(), + "an empty configuration put a Hooks on the contract, which turns off read \ + speculation for every operator who has never written one", + ); + + // And a configuration that says plenty, none of it a hook — so `None` is not + // `None` because there was no file to read. + let (_dir, busy) = written( + LOCAL_FILE, + "[run]\nmax_steps = 40\n\n[[mcp]]\nid = \"docs\"\ntransport = \"stdio\"\n\ + command = \"mcp-docs\"\n", + ); + let config = Config::discover(&busy).expect("the configuration loads"); + assert!(config.hooks().is_empty()); + assert!( + io_cli::contract::hooks(&config, &busy).is_none(), + "a configuration with servers and a step cap and no hook was handed a Hooks", + ); + let contract = io_cli::contract::configured("go", busy.clone(), &config); + assert!( + contract.tool_hooks.is_none(), + "the same, on the contract the turn carries", + ); + assert_eq!( + contract.max_steps, 40, + "the fixture's other keys did not apply, so this proves nothing about a busy file", + ); +} + +/// **F9, the other side.** A configuration that declares one gets it, on both +/// roads. +/// +/// Without this the guard above is indistinguishable from `None` always — which +/// would pass every assertion in it, and would ship a product where `[[hook]]` is +/// a section io-harness validates, io-cli reports, and nothing ever runs. +/// +/// Sabotage: invert the guard, or drop `with_tool_hooks` while keeping the +/// fan-out install. Under the second only the contract assertion here fails, and it +/// fails by accepting half the file: an `on = [...]` table runs and an +/// `at = "before_tool"` table is silently never consulted, so a hook written to +/// refuse a tool call refuses nothing. +#[test] +fn f9_a_declared_hook_reaches_both_the_contract_and_the_fan_out() { + let (_dir, root) = written( + LOCAL_FILE, + "[[hook]]\nat = \"before_tool\"\ntools = [\"write_file\"]\nrun = [\"true\"]\n\n\ + [[hook]]\non = [\"finished\"]\nappend = \"audit.jsonl\"\n", + ); + let config = Config::discover(&root).expect("the configuration loads"); + + let hooks = io_cli::contract::hooks(&config, &root).expect("the file declares two hooks"); + assert!(!hooks.is_empty()); + + let contract = io_cli::contract::configured("go", root.clone(), &config); + assert!( + contract.tool_hooks.is_some(), + "the lifecycle half of the file is accepted and never consulted", + ); +} + +/// **F9, through a bundle.** A hook a capability bundle contributes counts as a +/// declared hook, even though the configuration file itself declares none. +/// +/// Sabotage: build the value from `config.hooks()` alone and drop the +/// `plugins().apply_to_hooks` merge. Under it only this test fails, and it fails +/// silently in the direction that matters least visibly: `/plugin` still draws the +/// bundle's `hooks` row, the manifest still declares them, and not one of them ever +/// runs — for a contribution kind whose entire purpose is to run programs. +#[test] +fn f9_a_hook_a_bundle_contributes_is_a_declared_hook() { + let (_dir, root) = root(); + let bundle = root.join("runner"); + std::fs::create_dir_all(&bundle).expect("the bundle directory"); + std::fs::write( + bundle.join(PLUGIN_FILE), + "name = \"runner\"\n\n[[hook]]\nat = \"before_tool\"\nrun = [\"true\"]\n", + ) + .expect("the manifest"); + std::fs::write(root.join(LOCAL_FILE), "[[plugin]]\npath = \"runner\"\n") + .expect("the configuration"); + + let config = Config::discover(&root).expect("the configuration loads"); + assert!( + config.hooks().is_empty(), + "the file itself declares a hook, so this test would pass without the merge", + ); + assert!( + config.plugins().dropped().is_empty(), + "the bundle did not load: {:?}", + config.plugins().dropped(), + ); + + assert!( + io_cli::contract::hooks(&config, &root).is_some(), + "the bundle's hook is declared and nothing in this session will run it", + ); + assert!( + io_cli::contract::configured("go", root.clone(), &config) + .tool_hooks + .is_some(), + "the same, on the contract the turn carries", + ); +} + +// --------------------------------------------------------------------------- +// Validation — io-cli's job is to carry the refusal, not to make it +// --------------------------------------------------------------------------- + +/// A malformed `[[hook]]` is refused by io-harness, and io-cli adds no check and +/// no wording of its own. +/// +/// The four shapes are the ones the table's keys make representable and its +/// meaning forbids. Each is checked in `Hook::check`, none of them is expressible +/// in the type — `deny_unknown_fields` cannot say "exactly one of these two" — and +/// every one of them would otherwise be a hook that loads, installs and never +/// fires, which is the failure this module can least afford. +/// +/// Sabotage: give io-cli its own validator, however small — an `if +/// hook.at.is_some() && !hook.on.is_empty()` in `configure`, or a friendlier +/// message wrapped around the harness's. Under it only this test fails, and it +/// fails on the assertion that the refusal is io-harness's string and nothing +/// else. What it prevents is the release where io-cli's copy and io-harness's rule +/// disagree, and a hook the panel accepts is refused by the run that carries it. +#[test] +fn a_malformed_hook_is_refused_by_io_harness_and_io_cli_adds_no_check_of_its_own() { + for (table, phrase) in [ + ( + "[[hook]]\non = [\"finished\"]\nat = \"before_tool\"\nrun = [\"true\"]\n", + "a hook attaches to events or to a lifecycle point", + ), + ( + "[[hook]]\nat = \"before_tool\"\nappend = \"audit.jsonl\"\n", + "a lifecycle hook decides whether a call happens, so it needs `run`", + ), + ( + "[[hook]]\non = [\"finished\"]\nappend = \"audit.jsonl\"\nrun = [\"true\"]\n", + "a hook has one action", + ), + ( + "[[hook]]\non = [\"finished\"]\ntools = [\"write_file\"]\nrun = [\"true\"]\n", + "`tools` filters a lifecycle hook, and this one has no `at`", + ), + ] { + let (_dir, root) = written(LOCAL_FILE, table); + let error = Config::discover(&root) + .err() + .unwrap_or_else(|| panic!("io-harness accepted a malformed hook: {table}")); + + assert!( + error.to_string().contains(phrase), + "refused for something other than the shape under test: {error}", + ); + + // **The whole of io-cli's contribution, asserted as an equality.** Anything + // io-cli added — a rewording, a hint, a check of its own that fired first — + // makes this fail, which is the point: the operator reads the words of + // whoever enforced the rule. + assert_eq!( + io_cli::configure::refusal(&root, &error), + format!( + "the configuration in {} could not be read:\n{error}", + root.display() + ), + "io-cli put something of its own between the operator and the refusal", + ); + } +} + +/// A hook that names an event io-harness does not emit is refused rather than +/// installed. +/// +/// The one validation whose absence is pure silence: a misspelled tag deserializes +/// perfectly, installs perfectly, and matches no event for the life of the +/// session. There is nothing to notice. +/// +/// Sabotage: drop the `EVENT_NAMES` loop from `Hook::check`. Under it only this +/// test fails, and what an operator gets is a hook they wrote, that `/config` +/// lists, that io-cli attaches to the fan-out, and that never runs once. +#[test] +fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() { + let (_dir, root) = written(LOCAL_FILE, "[[hook]]\non = [\"finsihed\"]\nrun = [\"true\"]\n"); + let error = Config::discover(&root) + .err() + .expect("a misspelled event name is a hook that would never fire"); + assert!( + error.to_string().contains("is not an event this crate emits"), + "{error}", + ); + + // The correctly spelled one loads, so the assertion above is about the + // spelling rather than about the key. + let (_dir, spelled) = written(LOCAL_FILE, EVENT_HOOK); + let config = Config::discover(&spelled).expect("the configuration loads"); + assert!(io_cli::contract::hooks(&config, &spelled).is_some()); +} diff --git a/tests/plugins.rs b/tests/plugins.rs new file mode 100644 index 0000000..61d956f --- /dev/null +++ b/tests/plugins.rs @@ -0,0 +1,644 @@ +//! F1 to F4 — capability bundles: what a declared directory puts into a turn, +//! what a broken one costs, what a project-scoped one may not contribute, and the +//! two writes `/plugin` makes. +//! +//! **Every claim about what a bundle contributed goes through io-harness's own +//! call**, never through io-cli's account of it. `Config::plugins()` re-reads each +//! declared directory from disk on every call, `Plugins::apply_to` is what merges +//! a bundle's agents and servers into a contract, and the namespacing that turns +//! `reviewer` into `rust-review__reviewer` happens inside `load_one` before io-cli +//! has seen a byte of it. A test that asserted on `pluginview::View` alone would +//! be asserting that io-cli copied fields correctly, which is not the question: +//! the question is whether the turn the operator gets carries the bundle. +//! +//! **Nothing here touches the environment.** `Config::discover` reads a user-scope +//! file where one exists, so every fixture that must be exact about the *absence* +//! of something uses `Config::from_toml`, which has no scope but the text; the +//! rest declare their bundles in a temporary root of their own and run in +//! parallel. + +use std::path::{Path, PathBuf}; + +use io_cli::pluginview; +use io_harness::config::{Config, LOCAL_FILE, PROJECT_FILE}; +use io_harness::PLUGIN_FILE; + +/// The manifest io-harness's own module docs open with: a bundle contributing +/// something of five of the six kinds, and nothing that runs a program. +/// +/// Written out here rather than assembled from parts, because it is the shape a +/// bundle author copies out of the documentation and it has to keep loading. +const MINIMAL: &str = r#" +name = "rust-review" +description = "Everything our Rust reviews need." +skills = "skills" +templates = "templates" + +[[agent]] +name = "reviewer" +model = "cheap-model" +deny_write = true + +[policy] +layers = [ + { name = "no-secrets", rules = [{ act = "write", effect = "deny", pattern = "secrets/**" }] }, +] +"#; + +/// A temporary root, kept alive by the returned guard. +fn root() -> (tempfile::TempDir, PathBuf) { + let dir = tempfile::tempdir().expect("a temporary directory"); + let root = dir.path().to_path_buf(); + (dir, root) +} + +/// A bundle directory holding `manifest`, with the two directories a manifest can +/// name already on disk. +fn bundle(root: &Path, at: &str, manifest: &str) -> PathBuf { + let dir = root.join(at); + std::fs::create_dir_all(dir.join("skills")).expect("the skills directory"); + std::fs::create_dir_all(dir.join("templates")).expect("the templates directory"); + // `PLUGIN_FILE` rather than a literal: the name a directory is recognised by + // is io-harness's to state, and a fixture that spelled it itself would keep + // passing through a release that renamed it. + std::fs::write(dir.join(PLUGIN_FILE), manifest).expect("the manifest"); + dir +} + +/// A directory that is declared as a bundle and holds no manifest at all. +fn empty_bundle(root: &Path, at: &str) -> PathBuf { + let dir = root.join(at); + std::fs::create_dir_all(&dir).expect("the bundle directory"); + dir +} + +/// Write `file` in `root` declaring each named directory as a bundle, in order. +fn declaring(root: &Path, file: &str, paths: &[&str]) { + let text: String = paths + .iter() + .map(|path| format!("[[plugin]]\npath = \"{path}\"\n\n")) + .collect(); + std::fs::write(root.join(file), text).expect("the configuration"); +} + +/// The dropped row for the bundle at `at`, by the directory the entry named. +/// +/// Keyed on the path rather than on `Dropped::id`, because a bundle whose manifest +/// could not be read has no id — the field falls back to the directory's own name +/// — and keying on it would make a test about unparseable TOML depend on the +/// fallback rather than on the refusal. +fn dropped_at<'a>(plugins: &'a io_harness::Plugins, at: &str) -> &'a io_harness::plugin::Dropped { + plugins + .dropped() + .iter() + .find(|dropped| dropped.path.ends_with(at)) + .unwrap_or_else(|| { + panic!( + "no bundle at `{at}` was dropped; dropped: {:?}", + plugins + .dropped() + .iter() + .map(|d| d.path.display().to_string()) + .collect::>() + ) + }) +} + +// --------------------------------------------------------------------------- +// F1 — a declared bundle reaches the turn +// --------------------------------------------------------------------------- + +/// **F1.** A declared bundle loads, and what it contributed is on the contract +/// under the namespaced name io-harness gave it. +/// +/// Sabotage: drop the `plugins.apply_to(contract)` line from +/// `contract::configured`. Under it only this test fails, and it fails silently in +/// the field: `/plugin` still lists the bundle, `Config::plugins()` still loads it, +/// the trace still reports it — and the agent it defines cannot be spawned, its +/// skills never reach the run, and the operator has no way to tell a bundle that +/// did nothing from one that was never read. +#[test] +fn f1_a_declared_bundle_reaches_the_turn_with_every_name_namespaced() { + let (_dir, root) = root(); + bundle(&root, "bundles/rust-review", MINIMAL); + declaring(&root, PROJECT_FILE, &["bundles/rust-review"]); + + let config = Config::discover(&root).expect("the configuration loads"); + + // io-harness's own verdict first: this bundle loaded and nothing was dropped. + let plugins = config.plugins(); + assert_eq!(plugins.names(), vec!["rust-review"]); + assert_eq!(plugins.len(), 1); + assert!(!plugins.is_empty()); + assert!( + plugins.dropped().is_empty(), + "a manifest out of io-harness's own documentation was refused: {:?}", + plugins.dropped(), + ); + + let plugin = plugins.get("rust-review").expect("loaded by id"); + assert_eq!(plugin.id(), "rust-review"); + assert_eq!(plugin.root(), root.join("bundles/rust-review")); + assert_eq!( + plugin.description(), + Some("Everything our Rust reviews need.") + ); + assert_eq!(plugin.version(), None, "the manifest declared none"); + assert_eq!( + plugin.skills_dir(), + Some(root.join("bundles/rust-review/skills")), + ); + assert_eq!( + plugin.templates_dir(), + Some(root.join("bundles/rust-review/templates")), + ); + assert_eq!( + plugin.contributions(), + vec!["skills", "templates", "agents", "policy"], + "the kinds io-harness reports, in its own fixed order", + ); + assert_eq!(plugin.agents()[0].name, "rust-review__reviewer"); + assert_eq!(plugin.policy_layers()[0].name, "rust-review__no-secrets"); + assert!(plugin.mcp_servers().is_empty()); + + // And the contract a turn actually carries. + let contract = io_cli::contract::configured("review this", root.clone(), &config); + assert!( + contract.agents.get("rust-review__reviewer").is_some(), + "the bundle's agent is not on the contract, so nothing can spawn it: {:?}", + contract.agents.names(), + ); + assert!( + contract.agents.get("reviewer").is_none(), + "the bare name is on the roster, so the trace and the roster spell the same \ + agent two different ways: {:?}", + contract.agents.names(), + ); + assert_eq!( + contract.plugins.len(), + 1, + "`contract.plugins` is what `discover_skills` reads at run start to fold a \ + bundle's skills in, and it is empty", + ); + + // io-cli's own surface agrees with the harness it read. + let view = pluginview::view(&config); + assert!(!view.is_empty()); + assert_eq!(view.plugins.len(), 1); + assert!(view.refused.is_empty()); + assert_eq!(view.plugins[0].agents, vec!["rust-review__reviewer"]); +} + +// --------------------------------------------------------------------------- +// F2 — a broken bundle is one row, never an error +// --------------------------------------------------------------------------- + +/// **F2.** Each of the five ways a bundle can be refused costs exactly that +/// bundle, and `Config::plugins()` returns rather than failing. +/// +/// Sabotage: the `if let Some(bad) = plugins.dropped().first() { return Err(...) }` +/// io-harness's own module docs offer, written into `contract::configured` — or, +/// equivalently, a `/plugin` that returned a `Result`. Under it only this test +/// fails, and it fails the way a shared `io.toml` fails a team: one operator +/// commits a bundle with a typo in its `[policy]` block and every session in the +/// repository stops starting, including the four bundles that were fine. +#[test] +fn f2_five_ways_a_bundle_breaks_each_cost_exactly_that_bundle() { + let (_dir, root) = root(); + + bundle(&root, "good", MINIMAL); + // 1. A directory declared as a bundle with no manifest in it. + empty_bundle(&root, "no-manifest"); + // 2. A manifest that is not TOML. + bundle(&root, "unparseable", "this is not a toml document at all\n"); + // 3. An id outside `[a-z0-9][a-z0-9-]{0,31}`. + bundle(&root, "bad-id", "name = \"Rust_Review\"\n"); + // 4. A `[policy]` carrying a `defaults` table — accepted by the parser and + // refused by name, because a default decides every action no rule mentions. + bundle( + &root, + "policy-defaults", + "name = \"defaulting\"\n\n[policy.defaults]\nwrite = \"allow\"\n", + ); + // 5. A `[policy]` rule whose effect is not `deny`. A bundle may take capability + // away and may never hand it out. + bundle( + &root, + "policy-allow", + "name = \"widening\"\n\n[policy]\nlayers = [{ name = \"open\", rules = [\ + { act = \"write\", effect = \"allow\", pattern = \"**\" }] }]\n", + ); + + declaring( + &root, + LOCAL_FILE, + &[ + "good", + "no-manifest", + "unparseable", + "bad-id", + "policy-defaults", + "policy-allow", + ], + ); + + // The call returns. That is the first half of the criterion and it is asserted + // by there being a line after it. + let config = Config::discover(&root).expect("a broken bundle is not a broken configuration"); + let plugins = config.plugins(); + + assert_eq!( + plugins.names(), + vec!["rust-review"], + "the one sound bundle did not load beside five broken ones", + ); + assert_eq!(plugins.dropped().len(), 5); + + // Each one, by the reason io-harness gave for it. + for (at, phrase) in [ + ( + "no-manifest", + "a plugin is a directory with a manifest at its root", + ), + ("unparseable", PLUGIN_FILE), + ("bad-id", "is not a usable plugin id"), + ("policy-defaults", "key `policy.defaults`"), + ("policy-allow", "may only contribute `deny`"), + ] { + let dropped = dropped_at(&plugins, at); + assert!( + dropped.error.contains(phrase), + "the bundle at `{at}` was dropped for something other than the reason \ + under test: {}", + dropped.error, + ); + assert!( + plugins.get(&dropped.id).is_none(), + "`{at}` is both dropped and loaded", + ); + } + + // And io-cli's surface holds both facts at once rather than one instead of the + // other — which is what `View::is_empty` reading both lists exists for. + let view = pluginview::view(&config); + assert_eq!(view.plugins.len(), 1); + assert_eq!(view.refused.len(), 5); + assert!(!view.is_empty()); +} + +// --------------------------------------------------------------------------- +// F3 — the trust rule, and that it costs the whole bundle +// --------------------------------------------------------------------------- + +/// A bundle that contributes an agent, skills, and one thing that runs a program. +fn executing(kind: &str) -> String { + let block = match kind { + "hook" => "[[hook]]\non = [\"finished\"]\nrun = [\"touch\", \"ran\"]\n", + "mcp" => "[[mcp]]\nid = \"docs\"\ntransport = \"stdio\"\ncommand = \"mcp-docs\"\n", + other => panic!("no such contribution: {other}"), + }; + format!("name = \"runner\"\nskills = \"skills\"\n\n[[agent]]\nname = \"reviewer\"\n\n{block}") +} + +/// **F3.** A bundle declared in the committed `io.toml` that contributes a +/// `[[hook]]` or an `[[mcp]]` is dropped **whole**. +/// +/// The assertion is deliberately not "the hook was skipped". A half-applied +/// stranger's manifest is the failure the rule exists to prevent: the bundle's +/// agent, its skills directory and its policy come from the same file as the argv, +/// and a loader that kept them would be trusting the author it just refused. +/// +/// Sabotage: change `refuse_executing_contributions` from an error into a filter +/// that clears `manifest.hook` and `manifest.mcp` and lets the rest load — which is +/// the reading a reviewer would call the friendlier fix. Under it only this test +/// fails, and what ships is a `git clone` that installs an agent definition and a +/// skills directory of the cloned repository's choosing into every session run in +/// it. +#[test] +fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { + for kind in ["hook", "mcp"] { + let (_dir, root) = root(); + bundle(&root, "runner", &executing(kind)); + declaring(&root, PROJECT_FILE, &["runner"]); + + let config = Config::discover(&root).expect("a refused bundle is not a refused file"); + let plugins = config.plugins(); + + assert!( + plugins.is_empty(), + "{kind}: the bundle loaded from the file a `git clone` delivers", + ); + let dropped = dropped_at(&plugins, "runner"); + assert!( + dropped.error.contains(&format!("may not contribute `[[{kind}]]`")), + "{kind}: dropped for something else: {}", + dropped.error, + ); + + // **Nothing at all, rather than everything but the offending array.** + let contract = io_cli::contract::configured("go", root.clone(), &config); + assert!( + contract.agents.get("runner__reviewer").is_none(), + "{kind}: the refused bundle's agent reached the turn anyway: {:?}", + contract.agents.names(), + ); + assert!( + contract.plugins.is_empty(), + "{kind}: the refused bundle is on the contract, so its skills directory \ + is folded in at run start", + ); + assert!( + !contract.mcp.iter().any(|server| server.id.contains("runner")), + "{kind}: the refused bundle's server reached the turn", + ); + assert!( + io_cli::contract::hooks(&config, &root).is_none(), + "{kind}: the refused bundle's hooks are installed", + ); + } +} + +/// **F3, the other half.** The same manifest declared in `io.local.toml` loads, +/// with all six kinds. +/// +/// Without this the rule above is indistinguishable from "a bundle may never +/// contribute a hook or a server", which would make the feature useless rather +/// than safe — and a fix that refused everywhere would pass every assertion in the +/// test above. +/// +/// Sabotage: apply `refuse_executing_contributions` in every scope rather than +/// under `if scope == Scope::Project`. Under it only this test fails, and the +/// product ships a bundle format whose two most useful contributions can never be +/// declared by anyone. +#[test] +fn f3_the_same_manifest_declared_locally_contributes_all_of_it() { + for kind in ["hook", "mcp"] { + let (_dir, root) = root(); + bundle(&root, "runner", &executing(kind)); + // The local file, which does not travel with a clone — and no `io.toml` at + // all, so nothing but the scope differs from the test above. + declaring(&root, LOCAL_FILE, &["runner"]); + + let config = Config::discover(&root).expect("the configuration loads"); + let plugins = config.plugins(); + + assert!( + plugins.dropped().is_empty(), + "{kind}: dropped from the local scope: {:?}", + plugins.dropped(), + ); + assert_eq!(plugins.names(), vec!["runner"]); + let plugin = plugins.get("runner").expect("loaded"); + assert!( + plugin.contributions().contains(&kind_name(kind)), + "{kind}: loaded without the contribution under test: {:?}", + plugin.contributions(), + ); + + let contract = io_cli::contract::configured("go", root.clone(), &config); + assert!( + contract.agents.get("runner__reviewer").is_some(), + "{kind}: the bundle loaded and its agent did not reach the turn: {:?}", + contract.agents.names(), + ); + assert_eq!(contract.plugins.len(), 1); + match kind { + "mcp" => assert!( + contract.mcp.iter().any(|s| s.id == "runner__docs"), + "the namespaced server is not on the contract: {:?}", + contract.mcp.iter().map(|s| &s.id).collect::>(), + ), + _ => assert!( + io_cli::contract::hooks(&config, &root).is_some(), + "the bundle's hook is declared and nothing will run it", + ), + } + } +} + +/// What `Plugin::contributions` calls each kind, which is not what the TOML array +/// is called. +fn kind_name(kind: &str) -> &'static str { + match kind { + "hook" => "hooks", + "mcp" => "mcp", + other => panic!("no such contribution: {other}"), + } +} + +// --------------------------------------------------------------------------- +// F4 — the two writes +// --------------------------------------------------------------------------- + +/// A file with a comment, a bundle already declared, and two sections io-cli does +/// not model. +const OPERATORS: &str = "\ +# the bundles this repository uses +[[plugin]] +path = \"fixtures/nowhere/alpha\" + +[instructions] +files = [\"AGENTS.md\"] + +[run] +max_steps = 30 +"; + +/// **F4.** `add` appends a whole `[[plugin]]` entry, and everything else in the +/// file survives it byte for byte. +/// +/// This crate splices bytes rather than re-serialising, precisely so a key it has +/// no model for survives a write to its neighbour — and that property has to be +/// asserted rather than assumed, because the alternative implementation is the +/// obvious one. +/// +/// Sabotage: implement `add` by deserializing the file into io-cli's own types, +/// pushing an entry and writing the result back. Under it only this test fails, +/// and it fails by deleting the operator's comment, their `[instructions]` table +/// and every key of a section a later io-harness added — silently, on a keystroke +/// they pressed to add one line. +#[test] +fn f4_add_appends_an_entry_and_the_rest_of_the_file_survives_untouched() { + let after = io_cli::edit::apply( + OPERATORS, + &[pluginview::add(Path::new("fixtures/nowhere/beta"))], + ) + .expect("the edit applies"); + + // Everything that was there is still there, line for line — the comment + // included. + for line in OPERATORS.lines().filter(|line| !line.trim().is_empty()) { + assert!(after.contains(line), "line lost: {line:?}"); + } + + // The result is still the harness's schema, and the harness's own reader is + // what says which bundles it now declares and in what order. + let config = Config::from_toml(&after).expect("the written file loads"); + let declared: Vec = config + .plugins() + .dropped() + .iter() + .map(|dropped| dropped.path.display().to_string().replace('\\', "/")) + .collect(); + assert_eq!( + declared, + vec!["./fixtures/nowhere/alpha", "./fixtures/nowhere/beta"], + "the new entry is not the last declaration, so it does not stack last", + ); +} + +/// **F4.** `remove` takes the indexed entry whole and leaves its siblings and the +/// sections around it. +/// +/// By index rather than by id, and the fixture says why: the second entry names a +/// directory with no manifest, so it has no id to be removed by — and it is +/// exactly the entry an operator opens `/plugin` to be rid of. +/// +/// Sabotage: `Edit::remove("plugin")` without the index, or an index off by one. +/// Under it only this test fails, and it fails by removing a bundle the operator +/// did not pick — or by taking the `[run]` table that followed the last entry with +/// it, which is a step cap silently back to io-harness's twelve. +#[test] +fn f4_remove_takes_the_indexed_entry_and_leaves_its_siblings() { + const TWO: &str = "\ +[[plugin]] +path = \"fixtures/nowhere/alpha\" + +[[plugin]] +path = \"fixtures/nowhere/beta\" + +[run] +max_steps = 30 +"; + let after = io_cli::edit::apply(TWO, &[pluginview::remove(0)]).expect("the edit applies"); + + let config = Config::from_toml(&after).expect("the written file loads"); + let declared: Vec = config + .plugins() + .dropped() + .iter() + .map(|dropped| dropped.path.display().to_string().replace('\\', "/")) + .collect(); + assert_eq!( + declared, + vec!["./fixtures/nowhere/beta"], + "the removal did not reach exactly the entry it named", + ); + assert!( + after.contains("max_steps = 30"), + "the section after the last entry was taken with it", + ); +} + +// --------------------------------------------------------------------------- +// F2/F3 rendering — the sentence, and the index mapping +// --------------------------------------------------------------------------- + +/// A root with one bundle that loads and one that does not. +fn one_of_each() -> (tempfile::TempDir, PathBuf, Config) { + let (dir, root) = root(); + bundle(&root, "good", MINIMAL); + empty_bundle(&root, "broken"); + declaring(&root, LOCAL_FILE, &["good", "broken"]); + let config = Config::discover(&root).expect("the configuration loads"); + (dir, root, config) +} + +/// **F2/F3 rendering.** A refused bundle's row carries io-harness's sentence +/// unmodified, and the row index maps straight back into the two lists. +/// +/// The sentence is the operator's only instruction. It names the directory, names +/// the key, and for the project-scope refusal explains in two clauses why a cloned +/// `io.toml` may not name a program this machine will run. io-cli could not write +/// that better and must not try. +/// +/// Sabotage, either half: reword the row to "this plugin could not be loaded", or +/// insert a heading row above the refused group. Under the first, an operator is +/// told a bundle is broken and never told what to change. Under the second, every +/// index past the heading is off by one and `/plugin` acts on the wrong bundle — +/// which is the defect `rows` is heading-free to avoid, and which the unit tests +/// in `src/pluginview.rs` cannot see because they build their `View` by hand. +#[test] +fn f2_a_refused_row_carries_the_harness_sentence_and_the_index_still_maps() { + let (_dir, _root, config) = one_of_each(); + let view = pluginview::view(&config); + assert_eq!(view.plugins.len(), 1); + assert_eq!(view.refused.len(), 1); + + for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { + // Wide enough that nothing has to be shortened, so "verbatim" is a + // comparison rather than a prefix check. + let rows = pluginview::rows(&view, 400, glyphs); + assert_eq!( + rows.len(), + view.plugins.len() + view.refused.len(), + "{}: `rows` is one row per bundle and no headings", + glyphs.name, + ); + + // The positional contract, asserted as the contract states it. + assert_eq!(rows[0].label, view.plugins[0].id, "{}", glyphs.name); + assert_eq!(rows[0].mark, Some(pluginview::LOADED_MARK)); + assert_eq!(rows[1].label, view.refused[0].id, "{}", glyphs.name); + assert_eq!(rows[1].mark, Some(pluginview::REFUSED_MARK)); + assert!( + rows.iter().all(|row| !row.heading), + "{}: a heading is an index that maps to no bundle", + glyphs.name, + ); + + let detail = rows[1].detail.clone().expect("a refused row has a detail"); + assert_eq!( + detail, + format!("not loaded{}{}", glyphs.separator, view.refused[0].error), + "{}: the row is not io-harness's sentence, whole", + glyphs.name, + ); + assert!( + view.refused[0] + .error + .contains("a plugin is a directory with a manifest at its root"), + "the sentence io-cli carried is not the one io-harness wrote: {}", + view.refused[0].error, + ); + } +} + +/// **N4.** Every row fits eighty columns in both glyph sets, and the ASCII set +/// draws nothing an ASCII terminal cannot. +/// +/// Asserted over a `View` built from a real configuration rather than by hand, so +/// what is being fitted is a real tempdir path and io-harness's real sentence — +/// both far longer than anything a hand-built fixture would think to use, and both +/// what an operator will actually have on the row. +/// +/// Sabotage: budget the row against `width` instead of `width - 4`, which is the +/// picker's marker and gap. Under it only this test fails, and it fails on the +/// operator's terminal rather than in the suite: every `/plugin` row wraps, the +/// list doubles in height, and the panel scrolls where it used to fit. +#[test] +fn n4_every_row_fits_eighty_columns_in_both_glyph_sets() { + let (_dir, _root, config) = one_of_each(); + let view = pluginview::view(&config); + + for glyphs in [&io_cli::glyphs::UNICODE, &io_cli::glyphs::ASCII] { + for row in pluginview::rows(&view, 80, glyphs) { + let detail = row.detail.clone().expect("every bundle row has a detail"); + assert!( + row.label.chars().count() + detail.chars().count() + 4 <= 80, + "{}: the row does not fit: {} / {detail}", + glyphs.name, + row.label, + ); + if glyphs.name == io_cli::glyphs::ASCII.name { + for character in detail.chars().chain(row.label.chars()) { + assert!( + character.is_ascii(), + "the ASCII set drew {character:?}, which the terminal that \ + needed the fallback cannot: {detail}", + ); + } + } + } + } +} From 40c43fd78130a69298779174d2a45fc7b0e74c20 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:14:12 +0530 Subject: [PATCH 07/10] test: the observer the driver passes is a composition now MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `f5_both_arms_are_handed_a_steer_inbox` reads the driver's source and asserted the literal `&observer` in both turn calls. That argument is `watcher` from 0.20.0 — the local holding `Broadcast` over `Fanout` over the bridge and the operator's hooks — so the needle is re-pointed at its new shape rather than widened until it matches again, which would convert the gate into a rubber stamp. What the test asserts is unchanged: both arms take the caller's contract and a steer inbox, in that positional order. --- tests/steer.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/steer.rs b/tests/steer.rs index cb2d0af..49a1ce0 100644 --- a/tests/steer.rs +++ b/tests/steer.rs @@ -75,6 +75,14 @@ fn squashed(text: &str) -> String { /// reads. Only this test fails — and it is the one failure an operator cannot /// detect from the screen, because the send still returns `Ok` and no event ever /// said otherwise. +/// +/// **The observer argument is spelled `watcher` from 0.20.0, and the change is +/// the point rather than a rename.** Through 0.19.0 the driver passed `&observer` +/// — the `Bridge` itself, one value, the only observer there was. It now passes +/// `Broadcast` over a `Fanout` over the bridge and the operator's `Hooks`, and +/// the local holding that composition is named for what it is. What this test +/// asserts is unchanged: both arms take the caller's contract **and** the inbox, +/// in that positional order. #[test] fn f5_both_arms_are_handed_a_steer_inbox() { let text = driver(); @@ -82,7 +90,7 @@ fn f5_both_arms_are_handed_a_steer_inbox() { assert!( flat.contains( - "session.turn_bounded_steered(&contract,provider,store,policy,&approver,&observer,\ + "session.turn_bounded_steered(&contract,provider,store,policy,&approver,watcher,\ &inbox" ), "the flat arm takes the contract and the inbox", @@ -90,7 +98,7 @@ fn f5_both_arms_are_handed_a_steer_inbox() { assert!( flat.contains( "session.turn_contained_bounded_steered(&contract,provider,store,policy,&approver,\ - caps,&observer,&inbox" + caps,watcher,&inbox" ), "and so does the contained one, which is the arm that could not have one at all before \ io-harness 0.67.0", From c1796c52aab3733feadc7d11ed1cea7ede05df68 Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:17:27 +0530 Subject: [PATCH 08/10] style: the gates CI runs, and one it does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clippy `-D warnings` had four: two `.err().expect()` in the new hook tests, which `expect_err` says in one call, and two hand-rolled ASCII sweeps in the fleet's tests that `str::is_ascii` already answers. `cargo fmt` reflowed five files. The fourth gate is the one io-cli's CI does not run and io-harness's does: RUSTDOCFLAGS="--cfg docsrs -D warnings" cargo +nightly doc --all-features --no-deps It caught two doc links in this release's own new modules, both invisible to stable `cargo doc` and to clippy: `Fleet::rows` linked `Fleet::selected`, a private field, and `pluginview::rows` linked `ROOT_FLOOR`, a private const. A public item's documentation may only link public items — the fix is to name the private collaborator in prose, which is what both now do. Four more of the same class are older than this release and are left alone: `context.rs` has an unresolved `ContextBudget::effective_tokens` and a redundant explicit target on `MCP_TOOL_PREFIX`, `home.rs` links `chosen` and `picker.rs` links `Picker::refilter`, both private. They are recorded here rather than fixed because they belong to modules this release does not touch, and because the gate that finds them is not in `ci.yml` — which is the more useful thing to know. --- src/exec.rs | 4 ++-- src/fleet.rs | 12 +++++------- src/main.rs | 6 +----- src/pluginview.rs | 11 ++++------- tests/hooks.rs | 17 ++++++++++------- tests/plugins.rs | 9 +++++++-- 6 files changed, 29 insertions(+), 30 deletions(-) diff --git a/src/exec.rs b/src/exec.rs index 360edd5..5d150ee 100644 --- a/src/exec.rs +++ b/src/exec.rs @@ -488,8 +488,8 @@ impl WithProvider for Headless { observers.push(hooks); } let fanout = crate::fanout::Fanout::new(observers); - let durable = crate::settings::store_path() - .and_then(|path| io_harness::Store::open(&path).ok()); + let durable = + crate::settings::store_path().and_then(|path| io_harness::Store::open(&path).ok()); let broadcast = durable.map(|store| io_harness::Broadcast::new(store, &fanout)); let watcher: &dyn Observer = match &broadcast { Some(broadcast) => broadcast, diff --git a/src/fleet.rs b/src/fleet.rs index 40a81ee..1159409 100644 --- a/src/fleet.rs +++ b/src/fleet.rs @@ -477,8 +477,9 @@ impl Fleet { /// One row per admitted child, then one per message, all fitted to `width`. /// /// **The children come first and stay index-aligned with - /// [`Fleet::children`]**, because [`Fleet::selected`] is an index into that - /// vector and [`Fleet::render`] marks a row by comparing it. Messages are + /// [`Fleet::children`]**, because the selection this type holds is an index + /// into that vector — see [`Fleet::selection`] — and [`Fleet::render`] marks a + /// row by comparing it. Messages are /// appended after the last child rather than interleaved under their /// recipients: interleaving would put rows in front of children and silently /// shift every index the marker uses, and a marker that lands on the wrong row @@ -831,10 +832,7 @@ mod tests { let ascii = fleet.rows(80, &ASCII).remove(0); assert!(unicode.contains("a → b"), "{unicode:?}"); assert!(ascii.contains("a -> b"), "{ascii:?}"); - assert!( - !ascii.chars().any(|c| !c.is_ascii()), - "the ASCII row is ASCII: {ascii:?}", - ); + assert!(ascii.is_ascii(), "the ASCII row is ASCII: {ascii:?}"); // And the row is still assembled out of the set it was handed, separator // included, rather than out of literals typed here. assert!(ascii.contains(ASCII.separator.trim()), "{ascii:?}"); @@ -850,7 +848,7 @@ mod tests { &Agents::new().with(AgentDef::new("reviewer")), ); let row = fleet.rows(80, &ASCII).remove(0); - assert!(!row.chars().any(|c| !c.is_ascii()), "{row:?}"); + assert!(row.is_ascii(), "{row:?}"); assert!(row.contains("reviewer#7 (reviewer)"), "{row:?}"); } diff --git a/src/main.rs b/src/main.rs index 644e408..b43638e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1633,11 +1633,7 @@ async fn loop_over Result>( picker = Some(( Picker::new( "Plugins", - io_cli::pluginview::rows( - &view, - screen.width(), - &app.theme.glyphs, - ), + io_cli::pluginview::rows(&view, screen.width(), &app.theme.glyphs), ), Pick::Plugins(view), )); diff --git a/src/pluginview.rs b/src/pluginview.rs index 66b6032..9b6114d 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -196,11 +196,7 @@ pub fn view(config: &io_harness::Config) -> View { contributions: plugin.contributions(), skills: plugin.skills_dir(), templates: plugin.templates_dir(), - agents: plugin - .agents() - .iter() - .map(|def| def.name.clone()) - .collect(), + agents: plugin.agents().iter().map(|def| def.name.clone()).collect(), servers: plugin .mcp_servers() .iter() @@ -243,8 +239,9 @@ pub fn view(config: &io_harness::Config) -> View { /// answer to what the bundle *did to this session* and are the only field here /// with no other home. The version follows when there is one, the description /// takes what is left, and **the root path is the field that gives way** — below -/// [`ROOT_FLOOR`] it is dropped whole rather than drawn as an ellipsis and an -/// extension. At eighty columns a row is therefore the id, what it contributed and +/// a floor this module keeps privately it is dropped whole rather than drawn as an +/// ellipsis and an extension. At eighty columns a row is therefore the id, what it +/// contributed and /// as much description as fits; widen the terminal and the path arrives. /// /// A refused row never carries the root, and not because it did not fit: diff --git a/tests/hooks.rs b/tests/hooks.rs index 41d2bae..c86b09a 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -64,9 +64,8 @@ const EVENT_HOOK: &str = "[[hook]]\non = [\"finished\"]\nrun = [\"true\"]\n"; fn f8_a_project_scoped_hook_is_refused_and_io_cli_names_the_rule_and_the_root() { let (_dir, root) = written(PROJECT_FILE, EVENT_HOOK); - let error = Config::discover(&root) - .err() - .expect("a hook in the file a `git clone` delivers is refused"); + let error = + Config::discover(&root).expect_err("a hook in the file a `git clone` delivers is refused"); let said = io_cli::configure::refusal(&root, &error); // io-harness's own words, not a paraphrase of them. @@ -306,12 +305,16 @@ fn a_malformed_hook_is_refused_by_io_harness_and_io_cli_adds_no_check_of_its_own /// lists, that io-cli attaches to the fan-out, and that never runs once. #[test] fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() { - let (_dir, root) = written(LOCAL_FILE, "[[hook]]\non = [\"finsihed\"]\nrun = [\"true\"]\n"); + let (_dir, root) = written( + LOCAL_FILE, + "[[hook]]\non = [\"finsihed\"]\nrun = [\"true\"]\n", + ); let error = Config::discover(&root) - .err() - .expect("a misspelled event name is a hook that would never fire"); + .expect_err("a misspelled event name is a hook that would never fire"); assert!( - error.to_string().contains("is not an event this crate emits"), + error + .to_string() + .contains("is not an event this crate emits"), "{error}", ); diff --git a/tests/plugins.rs b/tests/plugins.rs index 61d956f..e19ca80 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -330,7 +330,9 @@ fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { ); let dropped = dropped_at(&plugins, "runner"); assert!( - dropped.error.contains(&format!("may not contribute `[[{kind}]]`")), + dropped + .error + .contains(&format!("may not contribute `[[{kind}]]`")), "{kind}: dropped for something else: {}", dropped.error, ); @@ -348,7 +350,10 @@ fn f3_a_bundle_that_names_a_program_is_dropped_whole_from_the_committed_file() { is folded in at run start", ); assert!( - !contract.mcp.iter().any(|server| server.id.contains("runner")), + !contract + .mcp + .iter() + .any(|server| server.id.contains("runner")), "{kind}: the refused bundle's server reached the turn", ); assert!( From 5870312a0a20fc5d3a28a00a561196f8a1b761fd Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:31:04 +0530 Subject: [PATCH 09/10] fix: eight defects an adversarial review found behind a green suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite was green for all of them. Two were severe. **`Enter` was swallowed whenever the fleet pane was open.** The new attach arm returned on every `Enter` rather than only at an empty prompt, so a line typed to be queued behind a running turn was silently dropped, and `Shift+Enter` never reached the composer — a multi-line prompt could not be written. The pane is drawn over the prompt, not in front of the keyboard, and the queue surface four lines below had the guard right all along. Worse, the pane is open exactly when a turn is running, which is when queueing a line is most likely. `tests/queue_surface.rs`'s existing fleet test went on passing, vacuously, because `Enter` no longer reached anything. **The fleet was never cleared between turns.** `Fleet::forget` was reachable only from `/resume`, `/fork`, a rewind and `/clear`, so a turn that fanned out left its children in the model for the rest of the conversation. 0.19.0 could afford that. 0.20.0 could not: the model now also holds mail, so turn two drew turn one's agents and their messages as though they were current — and because `note_fleet`'s cheap early return is `Fleet::is_empty`, one fan-out anywhere in a session put a `run_root` and a `tree_addresses` query on every step of every ordinary turn afterwards, which is the exact cost that guard exists to avoid. Cleared at the start of a turn rather than the end of one, so a fleet stays readable after the turn that produced it. The rest, all in the attach path: - Attaching to a child that had already finished polled forever and printed nothing. `from_now()` does not look at the run, and a detached child's `Finished` reaches nobody once its parent's turn has ended — so the row still reads `detached` and is precisely the row an operator selects. The run's status is now asked first. - A watched child's events went through `App::event`, which folds them into the status line and the fleet. Its tokens inflated this session's total permanently, its provider and model replaced this session's on the footer, and its tree was grafted into the pane opened to look at it. `App::watched` draws the lines and counts nothing. - `Resize` was dropped while watching, leaving the screen believing a width the terminal no longer had. - `Ctrl+C` did nothing while watching — the one key a configuration file may not rebind, inert on the one surface that had no other way out. - The poll re-armed its timeout after every key, so an auto-repeating key or a dragged window edge turned ten store reads a second into tens. And one outside it: `contract::configured` built a `Hooks` unconditionally, including for `server_notices`, which calls it with an empty root at startup. `Hooks::new` creates every `append` path it is given — so a relative `append = "audit.jsonl"` left a stray empty file in whatever directory `io` was launched from. `/plugin` also gains the removal the contract promised and this release had not built. The entry is found by matching the directory across all three scope files, never by picker row: `rows()` lists loaded bundles then refused ones, an order with no relation to any file's, so a row number handed to `pluginview::remove` deletes a bundle nobody named. Where no file names the directory, io says so and removes nothing, and the directory itself is never touched. Two findings are documented rather than fixed, in the README's hooks section, because both belong to io-harness: a `run` hook executes on the turn's own critical path with `timeout_ms` as its only bound, and a hook that fails reports through a log this binary installs no subscriber for. --- CHANGELOG.md | 6 +- README.md | 30 ++++++ src/app.rs | 73 +++++++++++-- src/commands.rs | 17 ++- src/contract.rs | 29 +++++- src/main.rs | 259 +++++++++++++++++++++++++++++++++++++++------- src/pluginview.rs | 57 ++++++++++ 7 files changed, 412 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e529fa..6e44b0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,11 @@ and under that the row it really exists for: every bundle that was declared and dropped, carrying io-harness's own sentence for why. That loader has no error path — a dropped bundle is recorded and otherwise silently absent while every other one loads, which is how a bundle you believe is running stays gone for a -week. +week. A bundle can also be stopped from that list: the last row under its +contributions removes its `[[plugin]]` entry after a confirmation naming the scope +it will edit. The entry is found by matching the directory across all three scope +files rather than by counting rows on screen, and where no file names it, io says +so and removes nothing. The directory itself is never touched. **Which file declared a bundle decides what it may contribute.** A bundle named in the project-scoped `io.toml` — the file a `git clone` delivers — may diff --git a/README.md b/README.md index e70ed31..036c6b9 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,19 @@ make is dropped, recorded, and otherwise silently absent while every other bundl loads. A bundle you believe is running can be gone for a week. This is where that week ends. +**And a bundle can be stopped from the same list.** The last row under a bundle's +contributions removes its `[[plugin]]` entry, after a confirmation that names the +scope and the entry it will take out. io finds that entry by matching the +directory across all three scope files rather than by counting rows on screen — +the two lists have no relation to the order entries appear in any file, and a row +number read against the wrong list removes a bundle you never mentioned. Where no +file names the directory, io says so and removes nothing. + +The directory itself is never touched. This surface edits a configuration file, +and deleting somebody's work because they stopped loading it is not a thing a list +should do. Declaring a bundle is still a line you write yourself: a path is +something you type more comfortably into your own file than into a picker. + **Which file declared a bundle decides what it may contribute.** A bundle named in the project-scoped `io.toml` — the file a `git clone` delivers — may contribute skills, templates, agents and policy, and may **not** contribute @@ -644,6 +657,23 @@ turns that single tool call back and leaves the turn running, which makes a nothing is expanded: the program you named is the program that runs, with the arguments you wrote. +**A `run` hook runs on the turn's own critical path, and `timeout_ms` is the only +thing bounding it.** An observer is called synchronously by the run, and the run +shares a task with the loop that reads your keyboard — so while a hook's program +is running, the interface is not repainting and not answering keys. A script that +takes a tenth of a second, on a hook matching every event, costs that tenth of a +second per event. Keep a `run` hook fast, match it to the events you actually +want with `on`, and lower `timeout_ms` from its five-second default if the program +can hang. An `append` hook has none of this cost: it is a line written to a file. + +**A hook that fails is quiet.** io-harness reports a failed hook through a log +this binary installs no subscriber for, so an `append` path that cannot be written +and a `run` program that does not exist both leave the session looking normal — +and a hook with `on_failure = "cancel"` ends the turn without saying which hook +did it. Verify a new hook by checking that it did something: read the file, or +give the program a visible side effect. This is a real limitation of 0.20.0 rather +than a subtlety. + **A project-scoped file may not declare `[[hook]]` at all.** io-harness refuses the whole configuration rather than dropping the table — a hook runs a command on this machine and `io.toml` is the file a `git clone` delivers. There is no diff --git a/src/app.rs b/src/app.rs index 2a28559..91091b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -629,6 +629,23 @@ impl App { // wants of the row above the prompt is how long THIS turn has been going // and what it is costing — not how long the terminal has been open. self.status.start_run(); + // **The fleet belongs to one turn, and it is cleared here rather than + // when a turn ends.** `Fleet::forget` was reachable only from `/resume`, + // `/fork`, a rewind and `/clear`, so a turn that fanned out left its + // children in the model for the rest of the conversation. 0.19.0 could + // afford that — the rows were stale and that was all. 0.20.0 cannot: the + // model now also holds mail, and `note_fleet`'s cheap early return is + // `Fleet::is_empty`, so a single fan-out anywhere in a session would (a) + // draw the *previous* turn's agents and their messages as though they were + // this turn's, and (b) put a `run_root` and a `tree_addresses` query on + // every step of every ordinary turn afterwards — the exact cost that guard + // exists to avoid. + // + // **At the start of a turn and not at the end of one**, so a fleet stays + // readable after the turn that produced it. An operator whose agents have + // just finished can still open the pane and see what happened; it is the + // *next* prompt that means those rows are no longer about anything. + self.fleet.forget(); self.quits = 0; self.announce(); } @@ -985,6 +1002,30 @@ impl App { self.pending.extend(lines); } + /// Take an event from a run this session is **watching** rather than driving. + /// + /// **The same lines as [`App::event`] and none of the bookkeeping**, and the + /// difference is the whole reason this exists. `App::event` also folds the + /// event into `status_from` and into the fleet — both of which describe *this + /// conversation*. A detached child is somebody else's run: its tokens are not + /// this session's spend, its provider is not the one this session is talking + /// to, its `Fleet` tier is not this turn's fan-out shape, and its own children + /// are not this turn's children. + /// + /// Routing a watched run through `App::event` therefore corrupts the footer + /// permanently — `Status::start_run` resets the clock and the run's tokens and + /// nothing else, so a session total inflated by a watched child never comes + /// back down — and it grafts that child's tree into the pane the operator + /// opened to look at it. Neither is recoverable without ending the session, + /// and neither is visible as a bug: the numbers are simply wrong afterwards. + /// + /// So a watched event draws and does nothing else. What the operator asked for + /// is to see the run; they did not ask for it to be counted as theirs. + pub fn watched(&mut self, event: &RunEvent, at: Duration) { + let lines = self.events.event(event, at); + self.pending.extend(lines); + } + /// What a step changed, as diffs. /// /// Handed in by the driver rather than read here. The store belongs to the @@ -1394,16 +1435,28 @@ impl App { self.fleet.move_by(1); return Command::None; } - // **`Enter` on a detached child, and nothing on any other row.** - // The pane has had a selection since 0.8.0 that drove only the - // highlight; this is what it was for. A detached child is the one - // an operator can usefully go and look at — it is still running - // and nothing is watching it — so it is the one row where `Enter` - // means something. On any other row it is deliberately inert - // rather than helpfully doing the nearest thing: a working child's - // events are already arriving in the scrollback, and re-showing - // them from the store would be two copies of one run. - KeyCode::Enter => { + // **`Enter` on a detached child, at an empty prompt, and nothing + // on any other row.** The pane has had a selection since 0.8.0 + // that drove only the highlight; this is what it was for. A + // detached child is the one an operator can usefully go and look + // at — it is still running and nothing is watching it — so it is + // the one row where `Enter` means something. + // + // **`self.composer.is_empty()` is the whole guard, and leaving it + // out is a real defect this arm shipped without for one adversarial + // review.** This pane is drawn *over the prompt*, not in front of + // the keyboard — the module comment above says so — and the queue + // surface below gets it right for exactly this reason. Without the + // guard, every `Enter` while the pane is open returns here: a line + // typed to be queued behind a running turn is silently dropped, and + // `Shift+Enter` never reaches the composer, so a multi-line prompt + // cannot even be written. Both are invisible — no message, no + // change on screen — and the pane is open precisely when a turn is + // running, which is when queueing a line is most likely. + // + // With text in the composer `Enter` goes on meaning what it means + // everywhere else, which is *queue this*. + KeyCode::Enter if self.composer.is_empty() => { if let Some(child) = self.fleet.selected_child() { if child.state == crate::fleet::State::Detached { return Command::Attach(child.run_id); diff --git a/src/commands.rs b/src/commands.rs index 2ece3db..809f864 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -194,11 +194,18 @@ pub const COMMANDS: &[(&str, &str)] = &[ "the providers configured, in the order a turn tries them", ), // Beside `/mcp` and `/provider` because it is the third of the same kind: a - // surface that lists what the file declared and goes on to change it. A - // bundle is the widest of the three — it can hand over skills, templates, - // agents, servers, hooks and policy in one directory — which is the argument - // for it being visible at all, and the argument for it being under - // "configure" rather than "inspect". + // surface that reads a declaration out of the configuration file and can + // take one back out of it. A bundle is the widest of the three — it can hand + // over skills, templates, agents, servers, hooks and policy in one directory — + // which is the argument for it being visible at all, and the argument for it + // being under "configure" rather than "inspect". + // + // **What it does not do is add one, and the description says "loaded" rather + // than implying otherwise.** Declaring a bundle means naming a directory, + // which is a path an operator types far more comfortably into their own file + // than into a picker; removing one means finding the right entry among three + // scope files, which is exactly the part a surface can do better than a + // person. So this does the second and not the first. ( "/plugin", "the capability bundles loaded, what each contributed, and the ones that failed", diff --git a/src/contract.rs b/src/contract.rs index c9bc424..e859417 100644 --- a/src/contract.rs +++ b/src/contract.rs @@ -250,11 +250,26 @@ pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> Ta // different road entirely: the `Hooks` value is also an `Observer`, and the // driver puts it in the fan-out beside the interface's own. Both installs, or // one half of the file is accepted and silently never runs. - let hooks = plugins.apply_to_hooks(config.hooks(), &dir); - let contract = if hooks.is_empty() { - contract + // + // **And not at all where the caller has no root, which is not a nicety.** + // `io_harness::Hooks::new` **creates every `append` path it is given, empty, + // as it is built** — so building one against `PathBuf::new()` resolves a + // relative `append = "audit.jsonl"` against the *process working directory* + // and leaves a stray empty file wherever `io` happened to be launched from. + // [`server_notices`] calls this function with exactly that empty root, at + // startup, purely to read the merged `[[mcp]]` and `[[lsp]]` lists back off a + // throwaway contract — so before 0.20.0 the empty root cost nothing and now it + // would cost a file in the operator's home. A contract with no root cannot run + // a turn, so it has no use for hooks either. + let hooks = if dir.as_os_str().is_empty() { + None } else { - contract.with_tool_hooks(std::sync::Arc::new(hooks)) + let hooks = plugins.apply_to_hooks(config.hooks(), &dir); + (!hooks.is_empty()).then_some(hooks) + }; + let contract = match hooks { + Some(hooks) => contract.with_tool_hooks(std::sync::Arc::new(hooks)), + None => contract, }; // `[run] skills` has had its say, and `io exec` reads no other key that can // name one — so for the headless arm this is already the point after every @@ -279,6 +294,12 @@ pub fn configured(text: impl Into, root: PathBuf, config: &Config) -> Ta /// one observer and keep read speculation — the same guard [`configured`] makes, /// made once here so the two cannot drift apart. pub fn hooks(config: &Config, root: &std::path::Path) -> Option { + // The same empty-root guard [`configured`] makes, and for the same reason: + // building a `Hooks` creates its `append` files, so a rootless caller would + // leave them in the process working directory. + if root.as_os_str().is_empty() { + return None; + } let hooks = config.plugins().apply_to_hooks(config.hooks(), root); (!hooks.is_empty()).then_some(hooks) } diff --git a/src/main.rs b/src/main.rs index b43638e..63d6532 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use clap::Parser; -use crossterm::event::{Event, KeyCode, KeyEventKind}; +use crossterm::event::{Event, KeyCode, KeyEventKind, KeyModifiers}; use io_harness::{Config, Policy, Provider, ProviderSpec, Session, Steer, Store, Templates}; use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver}; @@ -928,10 +928,90 @@ async fn loop_over Result>( // the row being drawn and this keystroke the operator may // have moved the file themselves, and a carried row would // then name a path that is no longer there. - // Read and closed. `Esc` and `Enter` do the same thing, - // which is the honest behaviour for a pane with nothing - // to choose. - Pick::Nothing => {} + Pick::PluginEntry { + id, + bundle, + action_at, + } => { + // Every row but one is a fact to read. + if index == *action_at { + match io_cli::pluginview::declared_at(session.root(), bundle) { + Some((scope, at)) => { + descended = Some(( + Picker::new( + format!("Stop loading {id}?"), + vec![ + Row::new("leave it".to_string()), + Row::with_detail( + "stop loading it".to_string(), + format!( + "removes `plugin[{at}]` from the {} \ + scope; the directory is left alone", + io_cli::configure::Decided::File { + scope, + path: Default::default(), + } + .word() + ), + ), + ], + ), + Pick::PluginRemove { + id: id.clone(), + scope, + index: at, + }, + )); + } + // **Said, not guessed at.** No file names this + // path, so there is no entry to remove — and + // removing whichever entry happened to sit at + // some other index would take a bundle the + // operator never mentioned, silently. + None => app.record( + Tone::Refused, + format!( + "no `[[plugin]]` entry in any scope names {}; \ + nothing was removed", + bundle.display() + ), + ), + } + } + } + // `at` and not `index`: the outer `index` is the row the + // operator chose, and the entry's own position is a + // different number entirely. Binding both to one name is + // how a confirmation removes the wrong bundle. + Pick::PluginRemove { + id, + scope, + index: at, + } => { + // Row 0 is "leave it", which is the default and does + // nothing — the same shape `/skills`' toggle uses. + if index == 1 { + let edit = io_cli::pluginview::remove(*at); + match io_cli::configure::write(session.root(), *scope, &[edit]) { + Ok(()) => { + // The directory is untouched on purpose: + // this surface edits a configuration file, + // and deleting an operator's directory + // because they stopped loading it is not a + // thing a list should do. + app.record( + Tone::Success, + format!( + "{id} is no longer declared; its directory is \ + untouched, and the change is in force from the \ + next turn", + ), + ); + } + Err(refusal) => app.record(Tone::Refused, refusal), + } + } + } Pick::Plugins(view) => { // **Answered from the reading the rows were drawn // from, and `/skills` does the opposite on purpose.** @@ -957,16 +1037,30 @@ async fn loop_over Result>( // A bundle is several *lists* — its agents, its // servers, its policy layers — and a sentence // holding three lists is a sentence nobody reads. + let mut rows = io_cli::pluginview::detail( + plugin, + screen.width(), + &app.theme.glyphs, + ); + // **The action's index is taken before the row is + // pushed, never worked out afterwards.** Every + // other index in this surface addresses a list + // somewhere else, and the whole class of defect + // here is a row number being read against the + // wrong list — so this one is the length of what + // was already there, which cannot be wrong. + let action_at = rows.len(); + rows.push(Row::with_detail( + "stop loading this bundle".to_string(), + "removes its `[[plugin]]` entry".to_string(), + )); descended = Some(( - Picker::new( - plugin.id.clone(), - io_cli::pluginview::detail( - plugin, - screen.width(), - &app.theme.glyphs, - ), - ), - Pick::Nothing, + Picker::new(plugin.id.clone(), rows), + Pick::PluginEntry { + id: plugin.id.clone(), + bundle: plugin.root.clone(), + action_at, + }, )); } else if let Some(refused) = view.refused.get(index - view.plugins.len()) @@ -3594,15 +3688,32 @@ enum Pick { /// sentence rather than a detail pane: there is nothing to descend into, /// because the bundle contributed nothing at all. Plugins(io_cli::pluginview::View), - /// A pane that is read and closed, and answers no keystroke. + /// One bundle's contributions, and the one thing that can be done about it. + /// + /// `bundle` is the resolved directory, which is the only thing a row on screen + /// and an entry in a file genuinely share — `Config::plugins()` reports neither + /// the scope that declared a bundle nor its position in that file, so the path + /// is what `pluginview::declared_at` matches on to find the entry. + /// + /// `action_at` is where the removal row sits, recorded by the code that built + /// the rows rather than recomputed here. + PluginEntry { + id: String, + bundle: std::path::PathBuf, + action_at: usize, + }, + /// The second half of removing a bundle: which entry, and are you sure. /// - /// **The absence of a choice, made representable.** Every other variant here - /// names what choosing a row means; this one exists for a pane where choosing - /// a row means nothing — a bundle's contributions, which are facts to read - /// rather than levers to pull. Without it the alternative is a pane that - /// silently does something arbitrary on `Enter`, which is how a display turns - /// into an accident. - Nothing, + /// **Two steps rather than one, for the reason [`Pick::SkillToggle`] gives** — + /// this is a picker that would otherwise change a file on the way past. It + /// carries the scope and index `declared_at` resolved, so the confirmation acts + /// on the entry that was found rather than searching again against a file the + /// operator may have edited in between. + PluginRemove { + id: String, + scope: io_harness::config::Scope, + index: usize, + }, /// The named profiles a file declares, in the order `configure::profiles` /// sorted them. Profile(Vec), @@ -3722,6 +3833,38 @@ async fn watch_child( // driver's own `Instant` is not in scope here and reaching for it would put // the same wrong number behind a more convincing name. let watching = Instant::now(); + // **Ask whether there is anything left to watch, before opening a watch that + // can never end.** `Attach::from_now()` sets the cursor to the store's current + // position and does not look at the run — so attaching to a child that has + // already finished succeeds, polls forever, and prints nothing. That is the + // ordinary case rather than an exotic one: a detached child finishes on its + // own schedule, its `Finished` reaches nobody once the parent's turn has + // ended, so the fleet row still reads `detached` long after the child stopped. + // The operator selects exactly that row, because it is the one the pane + // invites them to select. + match store.run_status(run_id) { + Ok(Some(io_harness::RunStatus::Running)) => {} + Ok(Some(status)) => { + app.record( + Tone::Muted, + format!( + "run {run_id} is no longer running ({}); there is nothing left to watch", + format!("{status:?}").to_lowercase() + ), + ); + return Ok(()); + } + // No row, or a store that cannot answer. Neither is a reason to open a + // watch that would show nothing; both are worth saying. + Ok(None) => { + app.record(Tone::Refused, format!("run {run_id} is not in the store")); + return Ok(()); + } + Err(error) => { + app.record(Tone::Refused, format!("cannot watch run {run_id}: {error}")); + return Ok(()); + } + } let mut attach = match io_harness::Attach::to(store, run_id).from_now() { Ok(attach) => attach, Err(error) => { @@ -3740,11 +3883,20 @@ async fn watch_child( loop { match attach.poll() { Ok(events) => { + let mut drawn = false; for event in &events { - app.event(event, watching.elapsed()); + // **`watched`, not `event`.** This run's tokens are not this + // session's spend and its tree is not this turn's fan-out; + // `App::watched` draws the lines and folds nothing into the + // status line or the fleet. + app.watched(event, watching.elapsed()); + drawn = true; // The child is finished when it says so. Leaving on its own // `Finished` rather than on a status query keeps this reading - // one stream instead of two sources that can disagree. + // one stream instead of two sources that can disagree — and + // the rest of the batch is drawn first rather than abandoned + // mid-loop, so a `HandleKilled` sitting behind the `Finished` + // is not lost. if matches!(event.kind, io_harness::EventKind::Finished { .. }) && event.run_id == run_id { @@ -3753,7 +3905,7 @@ async fn watch_child( return Ok(()); } } - if !events.is_empty() { + if drawn { paint(screen, app)?; } } @@ -3763,21 +3915,50 @@ async fn watch_child( return Ok(()); } } - // `Esc` leaves and everything else is ignored, deliberately: this is a - // window onto a run the operator is not driving, so a key that did - // something here would be a key that did it to the wrong run. - match tokio::time::timeout(ATTACH_POLL, inputs.recv()).await { - Ok(Some(Event::Key(key))) - if key.kind == KeyEventKind::Press && key.code == KeyCode::Esc => - { - app.record(Tone::Muted, format!("stopped watching run {run_id}")); - paint(screen, app)?; - return Ok(()); + // **One deadline per poll, and the loop waits out the whole of it.** + // Re-entering `timeout` with a fresh `ATTACH_POLL` after every key would + // busy-spin: an auto-repeating key or a dragged window edge returns + // immediately, and the loop would go straight back to `attach.poll()` — + // tens of store reads a second instead of ten, on the connection the + // driver is also using. + let deadline = tokio::time::Instant::now() + ATTACH_POLL; + loop { + match tokio::time::timeout_at(deadline, inputs.recv()).await { + // Both stop keys, and `Ctrl+C` is here because it is the one key + // this product refuses to let a configuration file rebind. An + // operator who wants out of anything presses it; a watch it did + // nothing to would be the only surface in the interface where + // that is false. + Ok(Some(Event::Key(key))) + if key.kind == KeyEventKind::Press + && (key.code == KeyCode::Esc + || (key.code == KeyCode::Char('c') + && key.modifiers.contains(KeyModifiers::CONTROL))) => + { + app.record(Tone::Muted, format!("stopped watching run {run_id}")); + paint(screen, app)?; + return Ok(()); + } + // A resize while watching is a resize, and the other two loops in + // this file already say so. Dropping it leaves the `Screen` + // believing in a width the terminal no longer has, and every row + // drawn after the watch ends writes past the edge. + Ok(Some(Event::Resize(width, height))) => { + screen + .resize(width, height) + .map_err(|error| error.to_string())?; + paint(screen, app)?; + } + // The channel closing is the terminal going away, which the caller + // handles by returning; there is nothing to watch for any more. + Ok(None) => return Ok(()), + // Any other key is ignored on purpose: this is a window onto a run + // the operator is not driving, so a key that did something here + // would be a key that did it to the wrong run. + Ok(Some(_)) => {} + // The deadline, which is the only way out of this inner loop. + Err(_) => break, } - // The channel closing is the terminal going away, which the caller - // handles by returning; there is nothing to watch for any more. - Ok(None) => return Ok(()), - Ok(Some(_)) | Err(_) => {} } } } diff --git a/src/pluginview.rs b/src/pluginview.rs index 9b6114d..ac68509 100644 --- a/src/pluginview.rs +++ b/src/pluginview.rs @@ -409,10 +409,67 @@ pub fn add(dir: &Path) -> crate::edit::Edit { /// By index rather than by id, because the id is the *manifest's* and the entry is /// the *configuration's*: a bundle that was refused for having an unusable `name` /// has no id to remove it by, and it is exactly the entry an operator wants gone. +/// +/// **`index` is an index into the file's `[[plugin]]` array and into nothing +/// else.** In particular it is *not* a row number from [`rows`], which lists the +/// loaded bundles and then the refused ones — two lists whose order has no +/// relation to the order the entries appear in any file. Handing a row number to +/// this function deletes a different bundle than the one on screen. Find the entry +/// with [`declared_at`], which answers the question this argument asks. pub fn remove(index: usize) -> crate::edit::Edit { crate::edit::Edit::remove(format!("plugin[{index}]")) } +/// Which scope file declares `root`, and at which `[[plugin]]` index. +/// +/// **The bridge between a row on screen and an entry in a file, and it exists +/// because there is no other honest way across.** `Config::plugins()` hands back +/// loaded bundles and dropped ones as two lists, in neither case ordered by the +/// file that named them, and it says nothing at all about which of the three +/// scopes carried a given entry. So the only thing a row and an entry genuinely +/// share is the path — which is why this matches on that and refuses to guess when +/// it cannot. +/// +/// Each scope's file is read and its `plugin[i].path` values are compared, both +/// as written and resolved against `root`, because a declaration may be relative +/// or absolute and both name the same directory. The first scope that matches wins, +/// searched local-first — the same precedence order the harness itself applies, so +/// a bundle declared twice is removed from the file that was actually deciding. +/// +/// `None` means no file names that path, and the caller must say so rather than +/// removing something. Deleting the wrong `[[plugin]]` entry is silent: the +/// operator loses a bundle they never mentioned and finds out when its skills stop +/// being offered. +pub fn declared_at(root: &Path, bundle: &Path) -> Option<(io_harness::config::Scope, usize)> { + use io_harness::config::Scope; + for scope in [Scope::Local, Scope::Project, Scope::User] { + let Some(path) = crate::configure::scope_path(root, scope) else { + continue; + }; + let Ok(text) = std::fs::read_to_string(&path) else { + continue; + }; + // The array is walked until a gap, which is what `value_at` reports by + // returning `None` — an array of tables is contiguous, so the first miss + // is the end of it. + for index in 0.. { + let Some(raw) = crate::edit::value_at(&text, &format!("plugin[{index}].path")) else { + break; + }; + let declared = PathBuf::from(raw.trim().trim_matches('"')); + let resolved = if declared.is_absolute() { + declared.clone() + } else { + root.join(&declared) + }; + if declared == bundle || resolved == bundle { + return Some((scope, index)); + } + } + } + None +} + /// A TOML basic string, escaped. /// /// The twin of [`crate::servers`]'s own, which is private to that module — here From 7f6dda984254b8d177784febaadda851752d939b Mon Sep 17 00:00:00 2001 From: aakashpawar1999 Date: Thu, 27 Aug 2026 07:39:24 +0530 Subject: [PATCH 10/10] test: the defects the review found, and one it found while testing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten tests over the fixes, and one more fix they exposed. `pluginview::declared_at` gets five: the entry's own index rather than a position in some other list, a relative and an absolute declaration resolving alike, four near-miss paths that must return `None` rather than guess, local scope winning when two files declare the same bundle, and a file with no `[[plugin]]` at all. The near-miss test is the one that matters — the caller must refuse rather than remove something, because deleting the wrong entry is silent and the operator finds out when a bundle's skills stop being offered. The hooks tests lead with a positive control: with a real root `contract::hooks` returns `Some` **and** the `append` file now exists, which is what makes "no `Hooks` built" mean "no file written". Then the rootless arm, and `server_notices` driven directly — the caller that passes an empty root at startup — asserting the working directory gained no `audit.jsonl`. The fleet gets three, and one of them found something: guarding the pane's `Enter` on an empty composer was not enough, because the arm matched regardless of modifiers, so `Shift+Enter` at an empty prompt was still swallowed. A newline is a smaller loss than a dropped queued line but it is the same defect, and `modifiers.is_empty()` is the rest of the guard. Two things are recorded rather than covered, and neither is papered over. Isolating the process working directory would have made every other test in that binary order-dependent, so the CWD half is the control plus the `server_notices` observation rather than a `set_current_dir`. And `tests/queue_surface.rs`'s fleet test is passing vacuously — it cannot reach the `!self.fleet_open` guard it was written for, because the fleet's own `Enter` arm matches first, and it was vacuous before this release too. Left alone rather than widened into a rubber stamp. --- src/app.rs | 8 +- tests/fleet.rs | 149 +++++++++++++++++++++++++++++++ tests/hooks.rs | 140 +++++++++++++++++++++++++++++ tests/plugins.rs | 226 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 522 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 91091b1..c963666 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1456,7 +1456,13 @@ impl App { // // With text in the composer `Enter` goes on meaning what it means // everywhere else, which is *queue this*. - KeyCode::Enter if self.composer.is_empty() => { + // **`modifiers.is_empty()` as well, because a bare `Enter` and a + // `Shift+Enter` are different keys and only the first is this + // one.** Guarding on the empty composer alone still swallowed the + // newline at an empty prompt: an operator opening a multi-line + // prompt with `Shift+Enter` while watching a fan-out got nothing, + // which is the same defect one keystroke smaller. + KeyCode::Enter if self.composer.is_empty() && key.modifiers.is_empty() => { if let Some(child) = self.fleet.selected_child() { if child.state == crate::fleet::State::Detached { return Command::Attach(child.run_id); diff --git a/tests/fleet.rs b/tests/fleet.rs index 2a84632..841a0cb 100644 --- a/tests/fleet.rs +++ b/tests/fleet.rs @@ -406,3 +406,152 @@ fn f5_forgetting_a_run_forgets_the_fleet_and_shuts_the_view() { assert!(!app.fleet_open()); assert!(app.fleet.is_empty()); } + +/// A fleet belongs to one turn, and the turn that clears it is the turn that +/// starts. +/// +/// **Not the turn that ends, and the difference is the whole of it.** Clearing on +/// `finished` would throw away the tree at the exact moment the operator is most +/// likely to open `/fleet` and read what their fan-out did — which is why +/// `f5_the_view_closes_when_the_turn_ends_and_the_model_survives` above asserts the +/// opposite for `App::finished`, and why the two tests have to sit beside each +/// other. Clearing at the start keeps the record readable for as long as it is the +/// record of the last thing that happened, and no longer. +/// +/// Sabotage: drop `self.fleet.forget()` from `App::started`. **Nothing else in +/// this suite fails.** Every test above builds its fleet inside one turn, so a +/// model that never clears agrees with all of them. What ships instead is a +/// session where one fan-out anywhere poisons the rest of the conversation: turn +/// two's `/fleet` draws turn one's agents and their mail as though they were +/// current — rows for children that finished minutes ago, under a pane whose whole +/// claim is that it shows what is running now — and `note_fleet`'s `is_empty` +/// early return is defeated for good, so every ordinary step of every later turn +/// pays a `run_root` and a `tree_addresses` query for a tree that is not there. +/// One of those is a lie on screen and the other is a cost with nothing to show +/// for it, and neither leaves a mark anyone would trace back here. +#[test] +fn f5_a_turn_starting_clears_the_previous_turns_fleet() { + let mut app = App::new(DARK, "a-model"); + + // Turn one fans out. + app.started(); + app.event( + &spawned(1, 0, 7, "read every file under src/"), + Duration::ZERO, + ); + app.event(&tier(1, 1, 0, 0), Duration::ZERO); + app.finished(); + assert_eq!( + app.fleet.children().len(), + 1, + "the tree survives the turn it belonged to, which is the other half of \ + this rule", + ); + + // Turn two starts, and it starts with nothing. + app.started(); + assert!( + app.fleet.is_empty(), + "turn one's agents are still in the model, so `/fleet` will draw them as \ + this turn's: {:?}", + app.fleet.children(), + ); + assert!( + app.fleet.children().is_empty(), + "the rows in particular, which are what reaches the screen", + ); + assert!( + app.fleet.tiers().is_empty(), + "and the counts, which are what the summary line is built from", + ); + assert_eq!( + app.fleet.selection(), + None, + "nothing is marked, rather than a row zero that no longer exists", + ); +} + +/// F5 — `Enter` with something typed is still `queue this`, with the pane open. +/// +/// The pane is drawn *over the prompt*, not in front of the keyboard: `Ctrl+C` +/// still interrupts through it and the composer still takes typing, because the +/// moment this view is worth opening is mid-turn. `Enter` on a fleet row means +/// something only at an empty prompt — there is one row it can act on and nothing +/// half-written to lose — so with text in the composer it has to fall through. +/// +/// Sabotage: drop the `if self.composer.is_empty()` guard from the `KeyCode::Enter` +/// arm of the fleet block in `App::key`, which is what this arm actually shipped +/// with. **The return value does not change** — a prompt queued mid-turn is +/// `Command::None` and a swallowed key is `Command::None` — so nothing a caller can +/// see distinguishes the two, and the assertion that bites is the queue's contents. +/// What the operator gets is a line they typed against a running turn, pressed +/// `Enter` on, and that is simply gone: no message, no change on screen, and the +/// pane is open at precisely the moment queueing a line is most likely. +#[test] +fn f5_enter_with_a_written_prompt_reaches_the_queue_through_the_open_view() { + let mut app = App::new(DARK, "a-model"); + app.started(); + app.event( + &spawned(1, 0, 7, "read every file under src/"), + Duration::ZERO, + ); + app.toggle_fleet(); + assert!(app.fleet_open()); + + for character in "and then run the tests".chars() { + app.key(key(KeyCode::Char(character))); + } + assert_eq!( + app.composer.text(), + "and then run the tests", + "the pane is over the prompt, not in front of the keyboard", + ); + + app.key(key(KeyCode::Enter)); + + assert_eq!( + app.queued_prompts(), + ["and then run the tests"], + "the line was swallowed by a pane that had nothing to do with it", + ); + assert!( + app.composer.is_empty(), + "and the prompt is clear for the next one, the way every other submit \ + leaves it", + ); + assert!( + app.fleet_open(), + "queueing a line is not a reason to close the view that was being read", + ); +} + +/// F5 — `Shift+Enter` writes a newline through the open view. +/// +/// The same guard, on the key that has no other road at all. `Enter` at least had +/// a reading while the pane was up; a shifted `Enter` never means "act on a fleet +/// row" under any circumstances, so an arm that matched `KeyCode::Enter` without +/// looking at the modifiers took a key it could not possibly want. +/// +/// Sabotage: the same missing `composer.is_empty()`. Under it a multi-line prompt +/// cannot be *written* while the pane is open — the second line never starts, the +/// cursor does not move, and the operator concludes the key is broken rather than +/// that a view they opened is eating it. +#[test] +fn f5_shift_enter_still_writes_a_newline_through_the_open_view() { + let mut app = App::new(DARK, "a-model"); + app.toggle_fleet(); + for character in "first line".chars() { + app.key(key(KeyCode::Char(character))); + } + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); + for character in "second line".chars() { + app.key(key(KeyCode::Char(character))); + } + + assert_eq!( + app.composer.text(), + "first line\nsecond line", + "the shifted Enter never reached the composer, so a multi-line prompt \ + cannot be written while the view is open", + ); +} diff --git a/tests/hooks.rs b/tests/hooks.rs index c86b09a..14479d8 100644 --- a/tests/hooks.rs +++ b/tests/hooks.rs @@ -324,3 +324,143 @@ fn a_hook_naming_an_event_that_does_not_exist_is_refused_rather_than_installed() let config = Config::discover(&spelled).expect("the configuration loads"); assert!(io_cli::contract::hooks(&config, &spelled).is_some()); } + +// --------------------------------------------------------------------------- +// The empty-root guard — a `Hooks` is a write, so building one is a decision +// --------------------------------------------------------------------------- +// +// `io_harness::Hooks::new` creates every `append` path it is given, empty, as the +// value is constructed (`io-harness-0.69.0/src/hooks.rs`) — deliberately, so that +// "the filter matched nothing" stays distinguishable from "the hook was never +// installed". That makes *constructing* a `Hooks` a write to the filesystem, and +// it resolves a relative `append` against the directory it was handed. Handed +// `PathBuf::new()`, that directory is the process working directory. +// +// Which would be a curiosity except that `contract::server_notices` calls +// `configured(String::new(), PathBuf::new(), config)` at startup — with an empty +// root on purpose, because all it wants is the merged `[[mcp]]` and `[[lsp]]` +// lists off a throwaway contract that will never run a turn. So an operator with +// `[[hook]] append = "audit.jsonl"` in their configuration got a stray empty +// `audit.jsonl` dropped in whatever directory they happened to launch `io` from, +// every single start, in a session where no hook ran and no turn was taken. + +/// A configuration whose one hook appends to a path relative to the root. +const APPENDING_HOOK: &str = "[[hook]]\non = [\"finished\"]\nappend = \"audit.jsonl\"\n"; + +/// A caller with no root gets no `Hooks`, on both roads, even though the +/// configuration declares one. +/// +/// **The two assertions are the whole of the guard and neither covers the other** +/// — it is made once in `contract::hooks` for the fan-out's event half and once in +/// `contract::configured` for the lifecycle half, and `server_notices` goes through +/// the second. `f9_a_declared_hook_reaches_both_the_contract_and_the_fan_out` +/// above is the other side of this pair: the same file, with a root, gets its +/// hooks. So `None` here is about the root and nothing else. +/// +/// **What this proves about the file, and what it does not.** The first half below +/// is the positive control: with a real root the `Hooks` is built and io-harness +/// creates `audit.jsonl` inside it, which is the mechanism the guard exists for and +/// which would otherwise be a claim in a comment. Given that, `None` from the two +/// rootless calls means no `Hooks` was constructed at all — and a file that is only +/// ever created by that constructor cannot appear when it is never run. The test +/// does not `set_current_dir` to watch the empty directory stay empty: the process +/// working directory is shared by every test in this binary and they run in +/// parallel, so a test that moved it would be reaching into the others. +/// +/// Sabotage: delete `if root.as_os_str().is_empty() { return None; }` from +/// `contract::hooks`, or the `dir.as_os_str().is_empty()` arm from +/// `contract::configured`. Nothing else in the suite fails — every hook that was +/// ever written still runs, `/config` is identical, the contract that carries a +/// turn is unchanged — and what ships is io-cli littering an empty file into the +/// operator's home, their repository, or whatever directory they started in, once +/// per launch, for a hook that never fired. +#[test] +fn a_rootless_caller_builds_no_hooks_and_therefore_writes_no_append_file() { + let (_dir, root) = written(LOCAL_FILE, APPENDING_HOOK); + let config = Config::discover(&root).expect("the configuration loads"); + + // The positive control: this is what building a `Hooks` costs. + assert!( + !root.join("audit.jsonl").exists(), + "the fixture already had the file, so its appearance below proves nothing", + ); + assert!( + io_cli::contract::hooks(&config, &root).is_some(), + "a declared hook with a root is a Hooks", + ); + assert!( + root.join("audit.jsonl").exists(), + "io-harness no longer creates `append` paths as the value is built, which is \ + the entire reason the guard below exists — reread it before deleting either", + ); + + // And the same configuration with no root writes nothing, because it builds + // nothing. + assert!( + io_cli::contract::hooks(&config, std::path::Path::new("")).is_none(), + "a rootless caller was handed a Hooks, which created `audit.jsonl` in \ + whatever directory `io` was launched from", + ); + assert!( + io_cli::contract::configured("", PathBuf::new(), &config) + .tool_hooks + .is_none(), + "the road `server_notices` actually takes at startup: an empty root put a \ + Hooks on a throwaway contract that will never run a turn", + ); +} + +/// `server_notices` — the caller the guard was written for — reads its lists +/// without leaving a file in the directory `io` was launched from. +/// +/// Asserted through the real function rather than through the empty root it +/// happens to pass today, because the guard is only as good as the call that +/// reaches it: a `server_notices` that started resolving a root before asking +/// would pass every assertion in the test above and reintroduce the write at a +/// different address. +/// +/// **The relative path is watched where it would actually land, and the process +/// working directory is not moved to do it.** `Hooks::new` resolves +/// `append = "audit.jsonl"` against the empty directory it is handed, which is the +/// bare relative path `audit.jsonl` — so a broken guard drops the file into +/// whatever directory the suite is running in, and that is where this looks. It is +/// recorded before and compared after rather than asserted absent outright, so a +/// checkout that happens to hold such a file is not a red test about the wrong +/// thing. Nothing is moved and nothing global is set, so this stays safe beside +/// every other test in this binary running at the same time. +/// +/// The `[[mcp]]` entry is here so the call has something to find: a +/// `server_notices` that returned early on a configuration it had nothing to say +/// about would never reach `configured`, and this would be asserting that an +/// unentered branch writes no file. +/// +/// Sabotage: either guard — `contract::hooks`'s or `contract::configured`'s — or a +/// `server_notices` that names a root. Under the second only this test fails, and +/// it fails by leaving the very file it is describing. +#[test] +fn server_notices_leaves_no_append_file_in_the_directory_io_was_launched_from() { + let (_dir, root) = written( + LOCAL_FILE, + &format!( + "{APPENDING_HOOK}\n[[mcp]]\nid = \"docs\"\ntransport = \"stdio\"\n\ + command = \"mcp-docs\"\n" + ), + ); + let config = Config::discover(&root).expect("the configuration loads"); + assert!(!config.hooks().is_empty(), "the fixture declares a hook"); + + // Exactly what `Hooks::new` would `create` for this hook under an empty root. + let stray = std::path::Path::new("audit.jsonl"); + let before = stray.exists(); + + let _ = io_cli::contract::server_notices(&config, &io_cli::contract::Capabilities::default()); + + assert_eq!( + stray.exists(), + before, + "reading the merged server lists at startup created {} in the working \ + directory — which is what every launch of `io` does, for a hook that \ + never fires", + stray.display(), + ); +} diff --git a/tests/plugins.rs b/tests/plugins.rs index e19ca80..3b81e5a 100644 --- a/tests/plugins.rs +++ b/tests/plugins.rs @@ -647,3 +647,229 @@ fn n4_every_row_fits_eighty_columns_in_both_glyph_sets() { } } } + +// --------------------------------------------------------------------------- +// `declared_at` — the bridge from a row on screen to an entry in a file +// --------------------------------------------------------------------------- +// +// `/plugin`'s remove is `edit::Edit::remove("plugin[i]")`, and `i` is an index +// into one file's `[[plugin]]` array. Nothing io-harness returns carries that +// number: `Config::plugins()` hands back loaded bundles and dropped ones as two +// lists ordered by neither the file nor the entry, and it never says which of the +// three scopes declared any of them. So the only thing a row and an entry share is +// the path, and `declared_at` is the whole of the crossing. Everything below is +// about the two ways that crossing can be wrong — landing on the wrong entry, or +// claiming to have found one that is not there — because both are silent: the +// operator loses a bundle they never mentioned and finds out a week later when its +// skills stop being offered. +// +// These fixtures declare paths inside a fresh `tempfile` root, so the user-scope +// file `declared_at` also reads on the machine running the suite cannot name one +// of them and cannot answer instead. + +/// A `[[plugin]]` entry naming `path`, spelled as a TOML basic string. +/// +/// The backslash escaping is not decoration: `pluginview::add` writes the value +/// through `quoted` for the same reason, and an unescaped absolute Windows path in +/// `path = "C:\Users\..."` is a different path or a parse error. A fixture that +/// wrote `format!("path = \"{}\"", ...)` would be green on Unix and would test the +/// wrong string on the one platform the escaping exists for. +fn declaration(path: &Path) -> String { + format!( + "[[plugin]]\npath = \"{}\"\n\n", + path.display().to_string().replace('\\', "\\\\") + ) +} + +/// The entry's index is the entry's own, not the position of anything else. +/// +/// The target sits second of three in the file, and the two either side are there +/// so that an off-by-one, a `.position()` over the loaded list, or a row number +/// from `rows` cannot accidentally agree with the right answer. +/// +/// Sabotage: return the row's index — `rows(&view, ..)` position — instead of +/// walking the file, which is exactly the shortcut this function exists to refuse +/// and which reads as obviously equivalent in review. Under it only this test +/// fails, and what ships is `/plugin` confirming the bundle on screen and deleting +/// the `[[plugin]]` entry above or below it. +#[test] +fn declared_at_finds_the_entrys_own_index_and_not_a_position_in_some_other_list() { + let (_dir, root) = root(); + let target = root.join("bundles/rust-review"); + let text = format!( + "{}{}{}", + declaration(&root.join("bundles/first")), + declaration(&target), + declaration(&root.join("bundles/third")), + ); + std::fs::write(root.join(LOCAL_FILE), text).expect("the configuration"); + + assert_eq!( + pluginview::declared_at(&root, &target), + Some((io_harness::config::Scope::Local, 1)), + "the second of three entries is entry one", + ); + // The neighbours resolve to themselves, so the answer above is a match rather + // than a constant. + assert_eq!( + pluginview::declared_at(&root, &root.join("bundles/third")), + Some((io_harness::config::Scope::Local, 2)), + ); +} + +/// A declaration written relative and one written absolute name the same +/// directory, and both are found. +/// +/// Both spellings are legal and both are common — `path = "bundles/rust-review"` +/// is what an operator types, and `pluginview::add` writes whatever the picker +/// resolved, which is absolute. What comes back from `Config::plugins()` is always +/// the resolved directory, so a comparison against the raw string alone would find +/// the relative entry never, and a comparison against the resolved value alone +/// would find nothing in a file whose author wrote an absolute path with a +/// trailing difference. Both roads are walked and this asserts both. +/// +/// Sabotage: drop the `resolved` half and compare only `declared == bundle`. Under +/// it only this test fails, and it fails in the direction that costs most: every +/// relative declaration — the spelling a human writes by hand — becomes a bundle +/// `/plugin` can list and can never remove, so the panel's remove key refuses on a +/// row that is plainly there. +#[test] +fn declared_at_matches_a_relative_declaration_and_an_absolute_one_alike() { + let bundle = "bundles/rust-review"; + + let (_dir, relative) = root(); + std::fs::write( + relative.join(LOCAL_FILE), + format!("[[plugin]]\npath = \"{bundle}\"\n"), + ) + .expect("the configuration"); + assert_eq!( + pluginview::declared_at(&relative, &relative.join(bundle)), + Some((io_harness::config::Scope::Local, 0)), + "a relative declaration is resolved against the root before it is compared", + ); + + let (_dir, absolute) = root(); + let resolved = absolute.join(bundle); + std::fs::write(absolute.join(LOCAL_FILE), declaration(&resolved)).expect("the configuration"); + assert_eq!( + pluginview::declared_at(&absolute, &resolved), + Some((io_harness::config::Scope::Local, 0)), + "an absolute declaration is compared as written", + ); +} + +/// A path no file names is `None`, and that is the answer the caller has to be +/// able to get. +/// +/// **The most important test here, and the one whose absence is invisible.** The +/// caller's alternative to `None` is removing *something* — the nearest index, row +/// zero, the last entry it looked at — and every one of those deletes a +/// `[[plugin]]` line the operator never pointed at. There is no error, no +/// confirmation that reads wrong, and no way back: the bundle is simply gone from +/// the file, and the next session is missing skills, agents and MCP servers nobody +/// removed. +/// +/// The fixture declares a real entry, so `None` here is a miss rather than an +/// empty file — and the near-miss path is a sibling of the declared one, because +/// the mistakes this guards against are prefix and suffix confusions rather than +/// wild pointers. +/// +/// Sabotage: end the scope loop with `Some((Scope::Local, 0))` instead of `None` +/// — a "we must have meant the first one" fallback, the kind that gets added to +/// silence an `Option` the caller found awkward. Under it only this test fails. +#[test] +fn declared_at_refuses_a_path_no_file_names_rather_than_guessing() { + let (_dir, root) = root(); + std::fs::write( + root.join(LOCAL_FILE), + declaration(&root.join("bundles/rust-review")), + ) + .expect("the configuration"); + + for missing in [ + root.join("bundles/rust-reviewer"), + root.join("bundles/rust-review/skills"), + root.join("bundles"), + root.join("elsewhere/rust-review"), + ] { + assert_eq!( + pluginview::declared_at(&root, &missing), + None, + "{} is named by no file, and the caller was handed an entry to delete", + missing.display(), + ); + } + + // The declared one is still found, so the four answers above are misses rather + // than a function that has stopped working. + assert!(pluginview::declared_at(&root, &root.join("bundles/rust-review")).is_some()); +} + +/// Declared in two scopes, the local file answers. +/// +/// Local-first is the precedence io-harness itself applies, so the entry that was +/// actually deciding is the entry that gets removed. Removing the project one +/// instead is worse than doing nothing twice over: the bundle keeps loading — +/// `io.local.toml` still names it — and the operator has silently edited a file +/// that is committed and shared with everyone else on the repository. +/// +/// Sabotage: iterate `[Scope::User, Scope::Project, Scope::Local]`, which is the +/// order the enum declares its variants in and therefore the order a rewrite +/// naturally reaches for. Under it only this test fails. +#[test] +fn declared_at_answers_the_local_file_when_two_scopes_declare_the_same_bundle() { + let (_dir, root) = root(); + let target = root.join("bundles/rust-review"); + // Two entries in the project file, so a scope confusion would also land on a + // different index and cannot be mistaken for a coincidence. + std::fs::write( + root.join(PROJECT_FILE), + format!( + "{}{}", + declaration(&root.join("bundles/first")), + declaration(&target) + ), + ) + .expect("the project configuration"); + std::fs::write(root.join(LOCAL_FILE), declaration(&target)).expect("the local configuration"); + + assert_eq!( + pluginview::declared_at(&root, &target), + Some((io_harness::config::Scope::Local, 0)), + "the file that was deciding is the file that is edited", + ); +} + +/// A configuration with no `[[plugin]]` at all is `None`, and so is no +/// configuration at all. +/// +/// The walk stops at the first index `value_at` cannot answer, on the argument +/// that an array of tables is contiguous. At index zero of a file that has no such +/// array that argument has to terminate the loop rather than run it — and a file +/// that is not there at all has to be skipped rather than end the search of the +/// scopes after it. +/// +/// Sabotage: `for index in 0..` with a `continue` on the miss instead of a +/// `break`. Nothing fails: the suite is green, `/plugin` works, and the process +/// hangs on the first configuration that declares no bundle — which is every +/// configuration in the world until somebody writes their first `[[plugin]]`. +/// Under this test it fails by never returning, which is what a hang looks like in +/// a suite and is the only way this shape can be caught at all. +#[test] +fn declared_at_is_none_where_no_file_declares_a_plugin_array() { + let (_dir, empty) = root(); + std::fs::write(empty.join(LOCAL_FILE), "[run]\nmax_steps = 40\n").expect("the configuration"); + assert_eq!( + pluginview::declared_at(&empty, &empty.join("bundles/rust-review")), + None, + ); + + // And with nothing written at all, so an unreadable scope file is skipped + // rather than ending the search. + let (_dir, bare) = root(); + assert_eq!( + pluginview::declared_at(&bare, &bare.join("bundles/rust-review")), + None, + ); +}