Merge remote-tracking branch 'origin/main' into ui

# Conflicts:
#	src/channels/mod.rs
#	src/main.rs
This commit is contained in:
Illia Polosukhin
2026-02-06 13:22:26 -08:00
39 changed files with 1694 additions and 2420 deletions
+6 -2
View File
@@ -151,6 +151,10 @@ src/
## Key Patterns
### Architecture
When designing new features or systems, always prefer generic/extensible architectures over hardcoding specific integrations. Ask clarifying questions about the desired abstraction level before implementing.
### Error Handling
- Use `thiserror` for error types in `error.rs`
- Never use `.unwrap()` in production code (tests are fine)
@@ -331,13 +335,13 @@ Key test patterns:
WASM tools are the preferred way to add new capabilities. They run in a sandboxed environment with explicit capabilities.
1. Create a new crate in `examples/wasm-tools/<name>/`
1. Create a new crate in `tools-src/wasm-tools/<name>/`
2. Implement the WIT interface (`wit/tool.wit`)
3. Create `<name>.capabilities.json` declaring required permissions
4. Build with `cargo build --target wasm32-wasip2 --release`
5. Install with `ironclaw tool install path/to/tool.wasm`
See `examples/wasm-tools/` for examples.
See `tools-src/wasm-tools/` for examples.
## Tool Architecture Principles
Generated
+288 -169
View File
@@ -663,21 +663,6 @@ dependencies = [
"winx",
]
[[package]]
name = "cassowary"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cbc"
version = "0.1.2"
@@ -775,6 +760,15 @@ version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
[[package]]
name = "clipboard-win"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
[[package]]
name = "cobs"
version = "0.3.0"
@@ -790,20 +784,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "compact_str"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
@@ -819,6 +799,24 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "coolor"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "980c2afde4af43d6a05c5be738f9eae595cff86dce1f38f88b95058a98c027f3"
dependencies = [
"crossterm 0.29.0",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -969,6 +967,54 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crokey"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c"
dependencies = [
"crokey-proc_macros",
"crossterm 0.29.0",
"once_cell",
"serde",
"strict",
]
[[package]]
name = "crokey-proc_macros"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231"
dependencies = [
"crossterm 0.29.0",
"proc-macro2",
"quote",
"strict",
"syn 2.0.114",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -988,6 +1034,15 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
@@ -1002,7 +1057,6 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.10.0",
"crossterm_winapi",
"futures-core",
"mio",
"parking_lot",
"rustix 0.38.44",
@@ -1011,6 +1065,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "crossterm"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
dependencies = [
"bitflags 2.10.0",
"crossterm_winapi",
"derive_more",
"document-features",
"mio",
"parking_lot",
"rustix 1.1.3",
"signal-hook",
"signal-hook-mio",
"winapi",
]
[[package]]
name = "crossterm_winapi"
version = "0.9.1"
@@ -1040,38 +1112,14 @@ dependencies = [
"cipher",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0"
dependencies = [
"darling_core 0.21.3",
"darling_macro 0.21.3",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.114",
"darling_core",
"darling_macro",
]
[[package]]
@@ -1088,24 +1136,13 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.114",
]
[[package]]
name = "darling_macro"
version = "0.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81"
dependencies = [
"darling_core 0.21.3",
"darling_core",
"quote",
"syn 2.0.114",
]
@@ -1164,6 +1201,28 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.114",
]
[[package]]
name = "diff"
version = "0.1.13"
@@ -1265,6 +1324,15 @@ dependencies = [
"serde_json",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
@@ -1310,6 +1378,12 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "endian-type"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d"
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -1347,6 +1421,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "error-code"
version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
[[package]]
name = "etcetera"
version = "0.8.0"
@@ -1689,8 +1769,6 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
"serde",
]
@@ -2045,15 +2123,6 @@ dependencies = [
"serde_core",
]
[[package]]
name = "indoc"
version = "2.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
dependencies = [
"rustversion",
]
[[package]]
name = "inout"
version = "0.1.4"
@@ -2064,19 +2133,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "instability"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c"
dependencies = [
"darling 0.20.11",
"indoc",
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "io-extras"
version = "0.18.4"
@@ -2124,7 +2180,7 @@ dependencies = [
"bytes",
"chrono",
"clap",
"crossterm",
"crossterm 0.28.1",
"deadpool-postgres",
"dirs 6.0.0",
"dotenvy",
@@ -2138,12 +2194,12 @@ dependencies = [
"postgres-types",
"pretty_assertions",
"rand 0.8.5",
"ratatui",
"refinery",
"regex",
"reqwest",
"rust_decimal",
"rust_decimal_macros",
"rustyline",
"secrecy",
"secret-service",
"security-framework",
@@ -2151,6 +2207,7 @@ dependencies = [
"serde_json",
"sha2",
"tempfile",
"termimad",
"testcontainers-modules",
"thiserror 2.0.18",
"tokio",
@@ -2203,15 +2260,6 @@ dependencies = [
"either",
]
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
@@ -2258,6 +2306,29 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "lazy-regex"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5c13b6857ade4c8ee05c3c3dc97d2ab5415d691213825b90d3211c425c1f907"
dependencies = [
"lazy-regex-proc_macros",
"once_cell",
"regex",
]
[[package]]
name = "lazy-regex-proc_macros"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a95c68db5d41694cea563c86a4ba4dc02141c16ef64814108cb23def4d5438"
dependencies = [
"proc-macro2",
"quote",
"regex",
"syn 2.0.114",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -2317,6 +2388,12 @@ version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2332,15 +2409,6 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -2417,6 +2485,15 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "minimad"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df8b688969b16915f3ecadc7829d5b7779dee4977e503f767f34136803d5c06f"
dependencies = [
"once_cell",
]
[[package]]
name = "mio"
version = "1.1.1"
@@ -2429,6 +2506,15 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nibble_vec"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43"
dependencies = [
"smallvec",
]
[[package]]
name = "nix"
version = "0.29.0"
@@ -2442,6 +2528,18 @@ dependencies = [
"memoffset",
]
[[package]]
name = "nix"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -2982,6 +3080,16 @@ version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
[[package]]
name = "radix_trie"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd"
dependencies = [
"endian-type",
"nibble_vec",
]
[[package]]
name = "rand"
version = "0.8.5"
@@ -3041,27 +3149,6 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
dependencies = [
"bitflags 2.10.0",
"cassowary",
"compact_str",
"crossterm",
"indoc",
"instability",
"itertools 0.13.0",
"lru",
"paste",
"strum",
"unicode-segmentation",
"unicode-truncate",
"unicode-width 0.2.0",
]
[[package]]
name = "rayon"
version = "1.11.0"
@@ -3371,6 +3458,15 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -3469,6 +3565,40 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "rustyline"
version = "17.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"clipboard-win",
"fd-lock",
"home",
"libc",
"log",
"memchr",
"nix 0.30.1",
"radix_trie",
"rustyline-derive",
"unicode-segmentation",
"unicode-width 0.2.0",
"utf8parse",
"windows-sys 0.60.2",
]
[[package]]
name = "rustyline-derive"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d66de233f908aebf9cc30ac75ef9103185b4b715c6f2fb7a626aa5e5ede53ab"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "ryu"
version = "1.0.22"
@@ -3702,7 +3832,7 @@ version = "3.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
dependencies = [
"darling 0.21.3",
"darling",
"proc-macro2",
"quote",
"syn 2.0.114",
@@ -3840,6 +3970,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strict"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f42444fea5b87a39db4218d9422087e66a85d0e7a0963a439b07bcdf91804006"
[[package]]
name = "stringprep"
version = "0.1.5"
@@ -3880,28 +4016,6 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "strum"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
dependencies = [
"heck",
"proc-macro2",
"quote",
"rustversion",
"syn 2.0.114",
]
[[package]]
name = "subtle"
version = "2.6.1"
@@ -4000,6 +4114,22 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "termimad"
version = "0.34.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "889a9370996b74cf46016ce35b96c248a9ac36d69aab1d112b3e09bc33affa49"
dependencies = [
"coolor",
"crokey",
"crossbeam",
"lazy-regex",
"minimad",
"serde",
"thiserror 2.0.18",
"unicode-width 0.1.14",
]
[[package]]
name = "testcontainers"
version = "0.23.3"
@@ -4508,17 +4638,6 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "unicode-truncate"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
"itertools 0.13.0",
"unicode-segmentation",
"unicode-width 0.1.14",
]
[[package]]
name = "unicode-width"
version = "0.1.14"
@@ -4922,7 +5041,7 @@ dependencies = [
"cranelift-frontend",
"cranelift-native",
"gimli",
"itertools 0.12.1",
"itertools",
"log",
"object 0.36.7",
"smallvec",
@@ -5666,7 +5785,7 @@ dependencies = [
"futures-sink",
"futures-util",
"hex",
"nix",
"nix 0.29.0",
"ordered-stream",
"rand 0.8.5",
"serde",
+4 -3
View File
@@ -48,9 +48,10 @@ async-trait = "0.1"
# CLI
clap = { version = "4", features = ["derive", "env"] }
# TUI
ratatui = "0.29"
crossterm = { version = "0.28", features = ["event-stream"] }
# Terminal
crossterm = "0.28"
rustyline = { version = "17", features = ["derive", "with-file-history"] }
termimad = "0.34"
# Channel integrations
axum = "0.8"
+191
View File
@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by the Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding any notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 NEAR AI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
View File
+15 -43
View File
@@ -43,7 +43,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
### Always Available
- **Multi-channel** - Reach your assistant via CLI, Telegram, WhatsApp, Slack, or HTTP webhooks
- **Multi-channel** - REPL, HTTP webhooks, and extensible WASM channels (Telegram, Slack, and more)
- **Heartbeat System** - Proactive background execution for monitoring and maintenance tasks
- **Parallel Jobs** - Handle multiple requests concurrently with isolated contexts
- **Self-repair** - Automatic detection and recovery of stuck operations
@@ -66,7 +66,7 @@ IronClaw is the AI assistant you can actually trust with your personal and profe
- Rust 1.85+
- PostgreSQL 15+ with pgvector extension
- NEAR AI session token (or other LLM provider)
- NEAR AI account (authentication handled via setup wizard)
### Build
@@ -90,36 +90,19 @@ createdb ironclaw
# Enable pgvector
psql ironclaw -c "CREATE EXTENSION IF NOT EXISTS vector;"
# Run migrations
refinery migrate -c refinery.toml
```
## Configuration
Copy `.env.example` to `.env` and configure:
Run the setup wizard to configure IronClaw:
```bash
# Required
DATABASE_URL=postgres://user:pass@localhost/ironclaw
NEARAI_SESSION_TOKEN=sess_...
# Optional: Enable channels
TELEGRAM_BOT_TOKEN=...
WHATSAPP_ACCESS_TOKEN=...
SLACK_BOT_TOKEN=xoxb-...
HTTP_PORT=8080
ironclaw setup
```
### Environment Variables
| Variable | Description | Required |
|----------|-------------|----------|
| `DATABASE_URL` | PostgreSQL connection string | Yes |
| `NEARAI_SESSION_TOKEN` | NEAR AI authentication token | Yes |
| `NEARAI_MODEL` | Model to use (default: claude-3-5-sonnet) | No |
| `AGENT_MAX_PARALLEL_JOBS` | Max concurrent jobs (default: 5) | No |
| `SECRETS_MASTER_KEY` | 32+ byte key for secret encryption | For secrets |
The wizard handles database connection, NEAR AI authentication (via browser OAuth),
and secrets encryption (using your system keychain). All settings are saved to
`~/.ironclaw/settings.toml`.
## Security
@@ -162,10 +145,10 @@ External content passes through multiple security layers:
```
┌─────────────────────────────────────────────────────────────────┐
│ Channels │
│ ┌─────┐ ┌──────────┐ ┌──────────┐ ┌───────┐ │
│ │ CLI │ │ Telegram │ │ WhatsApp │ │ Slack │
│ └──┬──┘ └────┬────┘ └─────────┘ └───┬───┘
│ └─────────┴─────────────┴────────────┘
│ ┌─────┐ ┌──────┐ ┌──────────────┐
│ │ REPL │ │ HTTP │ │ WASM Channels│
│ └──┬──┘ └─────┘ └─────────────┘
│ └─────────┴─────────────
│ │ │
│ ┌────▼────┐ │
│ │ Router │ Intent classification │
@@ -206,28 +189,17 @@ External content passes through multiple security layers:
## Usage
### CLI Mode
```bash
# Start interactive CLI
# First-time setup (configures database, auth, etc.)
ironclaw setup
# Start interactive REPL
cargo run
# With debug logging
RUST_LOG=ironclaw=debug cargo run
```
### HTTP Server
```bash
# Start with HTTP webhook server
HTTP_PORT=8080 cargo run
# Send a request
curl -X POST http://localhost:8080/webhook \
-H "Content-Type: application/json" \
-d '{"message": "Hello, IronClaw!"}'
```
## Development
```bash
+497
View File
@@ -0,0 +1,497 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "anyhow"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "bitflags"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown 0.16.1",
"serde",
"serde_core",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "leb128"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67"
[[package]]
name = "libc"
version = "0.2.180"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
[[package]]
name = "log"
version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "semver"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
[[package]]
name = "serde"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "slack-channel"
version = "0.1.0"
dependencies = [
"hex",
"hmac",
"serde",
"serde_json",
"sha2",
"wit-bindgen",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "spdx"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e17e880bafaeb362a7b751ec46bdc5b61445a188f80e0606e68167cd540fa3"
dependencies = [
"smallvec",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
name = "unicode-ident"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasm-encoder"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e913f9242315ca39eff82aee0e19ee7a372155717ff0eb082c741e435ce25ed1"
dependencies = [
"leb128",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "185dfcd27fa5db2e6a23906b54c28199935f71d9a27a1a27b3a88d6fee2afae7"
dependencies = [
"anyhow",
"indexmap",
"serde",
"serde_derive",
"serde_json",
"spdx",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8d07b6a3b550fefa1a914b6d54fc175dd11c3392da11eee604e6ffc759805d25"
dependencies = [
"ahash",
"bitflags",
"hashbrown 0.14.5",
"indexmap",
"semver",
]
[[package]]
name = "wit-bindgen"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a2b3e15cd6068f233926e7d8c7c588b2ec4fb7cc7bf3824115e7c7e2a8485a3"
dependencies = [
"wit-bindgen-rt",
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen-core"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b632a5a0fa2409489bd49c9e6d99fcc61bb3d4ce9d1907d44662e75a28c71172"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rt"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7947d0131c7c9da3f01dfde0ab8bd4c4cf3c5bd49b6dba0ae640f1fa752572ea"
dependencies = [
"bitflags",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4329de4186ee30e2ef30a0533f9b3c123c019a237a7c82d692807bf1b3ee2697"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "177fb7ee1484d113b4792cc480b1ba57664bbc951b42a4beebe573502135b1fc"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b505603761ed400c90ed30261f44a768317348e49f1864e82ecdc3b2744e5627"
dependencies = [
"anyhow",
"bitflags",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.220.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae2a7999ed18efe59be8de2db9cb2b7f84d88b27818c79353dfc53131840fe1a"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "zerocopy"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445"
+4 -2
View File
@@ -27,7 +27,7 @@ use serde::{Deserialize, Serialize};
// Re-export generated types
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse, PollConfig,
OutgoingHttpResponse, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
@@ -269,6 +269,8 @@ impl Guest for SlackChannel {
}
}
fn on_status(_update: StatusUpdate) {}
fn on_shutdown() {
channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down");
}
@@ -339,7 +341,7 @@ fn emit_message(
// Strip @ mentions of the bot from the text for cleaner messages
let cleaned_text = strip_bot_mention(&text);
channel_host::emit_message(EmittedMessage {
channel_host::emit_message(&EmittedMessage {
user_id,
user_name: None, // Could fetch from Slack API if needed
content: cleaned_text,
@@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize};
// Re-export generated types
use exports::near::agent::channel::{
AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest,
OutgoingHttpResponse,
OutgoingHttpResponse, StatusUpdate,
};
use near::agent::channel_host::{self, EmittedMessage};
@@ -417,6 +417,8 @@ impl Guest for WhatsAppChannel {
}
}
fn on_status(_update: StatusUpdate) {}
fn on_shutdown() {
channel_host::log(
channel_host::LogLevel::Info,
+13 -5
View File
@@ -295,11 +295,19 @@ impl Agent {
}
}
_ => {
// No target configured, just log
tracing::info!(
"Heartbeat notification (no target configured): {}",
&response.content
);
// No explicit target, broadcast to all channels
// for the default user so notifications actually
// reach someone instead of vanishing into logs.
let results = channels.broadcast_all("default", response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
"Failed to broadcast heartbeat to {}: {}",
ch,
e
);
}
}
}
}
}
+143 -1
View File
@@ -178,7 +178,7 @@ impl HeartbeatRunner {
pub async fn check_heartbeat(&self) -> HeartbeatResult {
// Get the heartbeat checklist
let checklist = match self.workspace.heartbeat_checklist().await {
Ok(Some(content)) if !content.trim().is_empty() => content,
Ok(Some(content)) if !is_effectively_empty(&content) => content,
Ok(_) => return HeartbeatResult::Skipped,
Err(e) => return HeartbeatResult::Failed(format!("Failed to read checklist: {}", e)),
};
@@ -257,6 +257,45 @@ impl HeartbeatRunner {
}
}
/// Check if heartbeat content is effectively empty.
///
/// Returns true if the content contains only:
/// - Whitespace
/// - Markdown headers (lines starting with #)
/// - HTML comments (`<!-- ... -->`)
/// - Empty list items (`- [ ]`, `- [x]`, `-`, `*`)
///
/// This skips the LLM call when the user hasn't added real tasks yet,
/// saving API costs.
fn is_effectively_empty(content: &str) -> bool {
let without_comments = strip_html_comments(content);
without_comments.lines().all(|line| {
let trimmed = line.trim();
trimmed.is_empty()
|| trimmed.starts_with('#')
|| trimmed == "- [ ]"
|| trimmed == "- [x]"
|| trimmed == "-"
|| trimmed == "*"
})
}
/// Remove HTML comments from content.
fn strip_html_comments(content: &str) -> String {
let mut result = String::with_capacity(content.len());
let mut rest = content;
while let Some(start) = rest.find("<!--") {
result.push_str(&rest[..start]);
match rest[start..].find("-->") {
Some(end) => rest = &rest[start + end + 3..],
None => return result, // unclosed comment, treat rest as comment
}
}
result.push_str(rest);
result
}
/// Spawn the heartbeat runner as a background task.
///
/// Returns a handle that can be used to stop the runner.
@@ -301,4 +340,107 @@ mod tests {
let disabled = HeartbeatConfig::default().disabled();
assert!(!disabled.enabled);
}
// ==================== strip_html_comments ====================
#[test]
fn test_strip_html_comments_no_comments() {
assert_eq!(strip_html_comments("hello world"), "hello world");
}
#[test]
fn test_strip_html_comments_single() {
assert_eq!(
strip_html_comments("before<!-- gone -->after"),
"beforeafter"
);
}
#[test]
fn test_strip_html_comments_multiple() {
let input = "a<!-- 1 -->b<!-- 2 -->c";
assert_eq!(strip_html_comments(input), "abc");
}
#[test]
fn test_strip_html_comments_multiline() {
let input = "# Title\n<!-- multi\nline\ncomment -->\nreal content";
assert_eq!(strip_html_comments(input), "# Title\n\nreal content");
}
#[test]
fn test_strip_html_comments_unclosed() {
let input = "before<!-- never closed";
assert_eq!(strip_html_comments(input), "before");
}
// ==================== is_effectively_empty ====================
#[test]
fn test_effectively_empty_empty_string() {
assert!(is_effectively_empty(""));
}
#[test]
fn test_effectively_empty_whitespace() {
assert!(is_effectively_empty(" \n\n \n "));
}
#[test]
fn test_effectively_empty_headers_only() {
assert!(is_effectively_empty("# Title\n## Subtitle\n### Section"));
}
#[test]
fn test_effectively_empty_html_comments_only() {
assert!(is_effectively_empty("<!-- this is a comment -->"));
}
#[test]
fn test_effectively_empty_empty_checkboxes() {
assert!(is_effectively_empty("# Checklist\n- [ ]\n- [x]"));
}
#[test]
fn test_effectively_empty_bare_list_markers() {
assert!(is_effectively_empty("-\n*\n-"));
}
#[test]
fn test_effectively_empty_seeded_template() {
let template = "\
# Heartbeat Checklist
<!-- Keep this file empty to skip heartbeat API calls.
Add tasks below when you want the agent to check something periodically.
Example:
- [ ] Check for unread emails needing a reply
- [ ] Review today's calendar for upcoming meetings
- [ ] Check CI build status for main branch
-->";
assert!(is_effectively_empty(template));
}
#[test]
fn test_effectively_empty_real_checklist() {
let content = "\
# Heartbeat Checklist
- [ ] Check for unread emails needing a reply
- [ ] Review today's calendar for upcoming meetings";
assert!(!is_effectively_empty(content));
}
#[test]
fn test_effectively_empty_mixed_real_and_headers() {
let content = "# Title\n\nDo something important";
assert!(!is_effectively_empty(content));
}
#[test]
fn test_effectively_empty_comment_plus_real_content() {
let content = "<!-- comment -->\nActual task here";
assert!(!is_effectively_empty(content));
}
}
-359
View File
@@ -1,359 +0,0 @@
//! Application state for the TUI.
use std::collections::VecDeque;
use crate::channels::cli::composer::ChatComposer;
use crate::channels::cli::model_selector::{ModelSelectorOverlay, ModelSelectorRequest};
use crate::channels::cli::overlay::{ApprovalOverlay, ApprovalRequest};
/// Events that can occur in the TUI.
#[derive(Debug, Clone)]
pub enum AppEvent {
/// Keyboard/mouse input event.
Input(crossterm::event::Event),
/// Response from the agent.
Response(String),
/// Tool execution started.
ToolStarted { name: String },
/// Tool execution completed.
ToolCompleted { name: String, success: bool },
/// Request approval for a tool.
ApprovalRequested(ApprovalRequest),
/// Streaming chunk received.
StreamChunk(String),
/// Log message from the application (shown in status line).
LogMessage(String),
/// Thinking/status message (shown in chat window).
ThinkingMessage(String),
/// Error message (shown in chat window).
ErrorMessage(String),
/// Available models fetched from API.
AvailableModels(Vec<String>),
/// Force a redraw.
Redraw,
/// Quit the application.
Quit,
}
/// Current input mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputMode {
/// Normal input mode.
Normal,
/// Editing input.
Editing,
/// Approval overlay is active.
Approval,
/// Model selector overlay is active.
ModelSelector,
}
/// Message in the chat history.
#[derive(Debug, Clone)]
pub struct ChatMessage {
/// Who sent this message.
pub role: MessageRole,
/// The message content.
pub content: String,
/// Optional status indicator.
pub status: Option<MessageStatus>,
}
/// Who sent a message.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageRole {
User,
Agent,
System,
}
/// Status of a message (for in-progress indicators).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageStatus {
Pending,
InProgress,
Complete,
Error,
}
impl ChatMessage {
pub fn user(content: impl Into<String>) -> Self {
Self {
role: MessageRole::User,
content: content.into(),
status: None,
}
}
pub fn agent(content: impl Into<String>) -> Self {
Self {
role: MessageRole::Agent,
content: content.into(),
status: None,
}
}
pub fn system(content: impl Into<String>) -> Self {
Self {
role: MessageRole::System,
content: content.into(),
status: None,
}
}
pub fn with_status(mut self, status: MessageStatus) -> Self {
self.status = Some(status);
self
}
}
/// Application state.
pub struct AppState {
/// Current input mode.
pub mode: InputMode,
/// Chat message history.
pub messages: Vec<ChatMessage>,
/// Input composer.
pub composer: ChatComposer,
/// Approval overlay (if active).
pub approval: Option<ApprovalOverlay>,
/// Model selector overlay (if active).
pub model_selector: Option<ModelSelectorOverlay>,
/// Scroll offset for messages.
pub scroll_offset: u16,
/// Whether the app should quit.
pub should_quit: bool,
/// Pending approvals queue.
pub pending_approvals: VecDeque<ApprovalRequest>,
/// Current streaming response buffer.
pub streaming_buffer: Option<String>,
/// Status line message.
pub status_message: Option<String>,
/// Whether Ctrl+D was pressed (waiting for second press to quit).
pub ctrl_d_pending: bool,
/// Currently selected model.
pub current_model: String,
/// Available models (fetched from API).
pub available_models: Vec<String>,
}
impl AppState {
/// Create a new app state.
pub fn new() -> Self {
// Load saved model from settings
let settings = crate::settings::Settings::load();
let current_model = settings.model_or("claude-3-5-sonnet-20241022");
Self {
mode: InputMode::Editing,
messages: vec![ChatMessage::system(
"Welcome to IronClaw. Type a message or /help for commands.",
)],
composer: ChatComposer::new(),
approval: None,
model_selector: None,
scroll_offset: 0,
should_quit: false,
pending_approvals: VecDeque::new(),
streaming_buffer: None,
status_message: None,
ctrl_d_pending: false,
current_model,
available_models: Vec::new(),
}
}
/// Show the model selector.
pub fn show_model_selector(&mut self) {
let request = ModelSelectorRequest {
current_model: self.current_model.clone(),
available_models: self.available_models.clone(),
};
self.model_selector = Some(ModelSelectorOverlay::new(request));
self.mode = InputMode::ModelSelector;
}
/// Handle model selection.
pub fn handle_model_selection(&mut self, selected: Option<String>) {
self.model_selector = None;
self.mode = InputMode::Editing;
if let Some(model) = selected {
if model != self.current_model {
self.current_model = model.clone();
// Save to settings
let mut settings = crate::settings::Settings::load();
if let Err(e) = settings.set_model(&model) {
tracing::warn!("Failed to save model setting: {}", e);
}
self.messages.push(ChatMessage::system(format!(
"Switched to model: {}",
ModelSelectorOverlay::format_model_name(&model)
)));
}
}
}
/// Set available models (also updates selector if open).
pub fn set_available_models(&mut self, models: Vec<String>) {
self.available_models = models.clone();
// Update the selector if it's currently open
if let Some(ref mut selector) = self.model_selector {
selector.request.available_models = models;
// Reset selection index if it's out of bounds
if selector.selection_index >= selector.request.available_models.len() {
selector.selection_index = 0;
}
}
}
/// Add a user message to history.
pub fn add_user_message(&mut self, content: impl Into<String>) {
self.messages.push(ChatMessage::user(content));
self.scroll_to_bottom();
}
/// Add an agent response to history.
pub fn add_agent_message(&mut self, content: impl Into<String>) {
// If we were streaming, finalize it
if self.streaming_buffer.is_some() {
self.streaming_buffer = None;
}
// Remove any pending thinking message before adding the response
self.clear_thinking();
self.messages.push(ChatMessage::agent(content));
self.scroll_to_bottom();
}
/// Add an error message to the chat.
pub fn add_error_message(&mut self, content: impl Into<String>) {
self.messages.push(
ChatMessage::system(format!("Error: {}", content.into()))
.with_status(MessageStatus::Error),
);
self.scroll_to_bottom();
}
/// Add or update a thinking/status message (shown as system message).
pub fn set_thinking(&mut self, content: impl Into<String>) {
let content = content.into();
// Check if last message is a thinking message (system with InProgress status)
if let Some(last) = self.messages.last_mut() {
if last.role == MessageRole::System && last.status == Some(MessageStatus::InProgress) {
last.content = content;
return;
}
}
// Add new thinking message
self.messages
.push(ChatMessage::system(content).with_status(MessageStatus::InProgress));
self.scroll_to_bottom();
}
/// Clear any thinking/status message.
pub fn clear_thinking(&mut self) {
// Remove any thinking messages (system with InProgress status)
self.messages.retain(|msg| {
!(msg.role == MessageRole::System && msg.status == Some(MessageStatus::InProgress))
});
}
/// Start streaming a response.
pub fn start_streaming(&mut self) {
self.streaming_buffer = Some(String::new());
self.messages
.push(ChatMessage::agent("").with_status(MessageStatus::InProgress));
}
/// Append to the streaming buffer.
pub fn append_stream(&mut self, chunk: &str) {
if let Some(ref mut buffer) = self.streaming_buffer {
buffer.push_str(chunk);
// Update the last message
if let Some(last) = self.messages.last_mut() {
if last.role == MessageRole::Agent {
last.content = buffer.clone();
}
}
}
}
/// Finalize streaming.
pub fn finish_streaming(&mut self) {
if let Some(last) = self.messages.last_mut() {
if last.role == MessageRole::Agent {
last.status = Some(MessageStatus::Complete);
}
}
self.streaming_buffer = None;
}
/// Show an approval request.
pub fn show_approval(&mut self, request: ApprovalRequest) {
self.approval = Some(ApprovalOverlay::new(request));
self.mode = InputMode::Approval;
}
/// Queue an approval request.
pub fn queue_approval(&mut self, request: ApprovalRequest) {
if self.approval.is_none() {
self.show_approval(request);
} else {
self.pending_approvals.push_back(request);
}
}
/// Handle approval response.
pub fn handle_approval_response(&mut self, approved: bool) -> Option<ApprovalRequest> {
let request = self.approval.take().map(|o| o.request);
// Show next pending approval if any
if let Some(next) = self.pending_approvals.pop_front() {
self.show_approval(next);
} else {
self.mode = InputMode::Editing;
}
if approved { request } else { None }
}
/// Clear all pending approvals.
pub fn clear_approvals(&mut self) {
self.approval = None;
self.pending_approvals.clear();
self.mode = InputMode::Editing;
}
/// Set the status message.
pub fn set_status(&mut self, message: impl Into<String>) {
self.status_message = Some(message.into());
}
/// Clear the status message.
pub fn clear_status(&mut self) {
self.status_message = None;
}
/// Scroll to the bottom of messages.
pub fn scroll_to_bottom(&mut self) {
// Will be calculated based on render area in render.rs
self.scroll_offset = 0;
}
/// Scroll up.
pub fn scroll_up(&mut self, amount: u16) {
self.scroll_offset = self.scroll_offset.saturating_add(amount);
}
/// Scroll down.
pub fn scroll_down(&mut self, amount: u16) {
self.scroll_offset = self.scroll_offset.saturating_sub(amount);
}
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
-318
View File
@@ -1,318 +0,0 @@
//! Input composer with history and completion.
use std::collections::VecDeque;
/// Maximum number of history entries to keep.
const MAX_HISTORY: usize = 100;
/// Available slash commands for completion.
const SLASH_COMMANDS: &[&str] = &[
"/help", "/job", "/status", "/cancel", "/list", "/tools", "/clear", "/quit",
];
/// Chat input composer with history navigation and slash command completion.
pub struct ChatComposer {
/// Current input buffer.
buffer: String,
/// Cursor position in the buffer.
cursor: usize,
/// Input history.
history: VecDeque<String>,
/// Current position in history (-1 = current input).
history_index: Option<usize>,
/// Saved current input when navigating history.
saved_input: String,
/// Completion candidates.
completions: Vec<String>,
/// Current completion index.
completion_index: Option<usize>,
}
impl ChatComposer {
/// Create a new composer.
pub fn new() -> Self {
Self {
buffer: String::new(),
cursor: 0,
history: VecDeque::with_capacity(MAX_HISTORY),
history_index: None,
saved_input: String::new(),
completions: Vec::new(),
completion_index: None,
}
}
/// Get the current input buffer.
pub fn buffer(&self) -> &str {
&self.buffer
}
/// Get the cursor position.
pub fn cursor(&self) -> usize {
self.cursor
}
/// Check if the buffer is empty.
pub fn is_empty(&self) -> bool {
self.buffer.is_empty()
}
/// Insert a character at the cursor.
pub fn insert(&mut self, c: char) {
self.clear_completion();
self.buffer.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
/// Insert a string at the cursor.
pub fn insert_str(&mut self, s: &str) {
self.clear_completion();
self.buffer.insert_str(self.cursor, s);
self.cursor += s.len();
}
/// Delete the character before the cursor (backspace).
pub fn backspace(&mut self) {
self.clear_completion();
if self.cursor > 0 {
// Find the previous character boundary
let prev = self.buffer[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
self.buffer.drain(prev..self.cursor);
self.cursor = prev;
}
}
/// Delete the character at the cursor (delete).
pub fn delete(&mut self) {
self.clear_completion();
if self.cursor < self.buffer.len() {
// Find the next character boundary
let next = self.buffer[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.buffer.len());
self.buffer.drain(self.cursor..next);
}
}
/// Move cursor left.
pub fn move_left(&mut self) {
if self.cursor > 0 {
self.cursor = self.buffer[..self.cursor]
.char_indices()
.next_back()
.map(|(i, _)| i)
.unwrap_or(0);
}
}
/// Move cursor right.
pub fn move_right(&mut self) {
if self.cursor < self.buffer.len() {
self.cursor = self.buffer[self.cursor..]
.char_indices()
.nth(1)
.map(|(i, _)| self.cursor + i)
.unwrap_or(self.buffer.len());
}
}
/// Move cursor to start.
pub fn move_home(&mut self) {
self.cursor = 0;
}
/// Move cursor to end.
pub fn move_end(&mut self) {
self.cursor = self.buffer.len();
}
/// Delete from cursor to end of line.
pub fn kill_line(&mut self) {
self.clear_completion();
self.buffer.truncate(self.cursor);
}
/// Delete from start to cursor.
pub fn kill_to_start(&mut self) {
self.clear_completion();
self.buffer.drain(..self.cursor);
self.cursor = 0;
}
/// Clear the entire buffer.
pub fn clear(&mut self) {
self.buffer.clear();
self.cursor = 0;
self.clear_completion();
}
/// Submit the current input and return it.
pub fn submit(&mut self) -> String {
let input = std::mem::take(&mut self.buffer);
self.cursor = 0;
self.clear_completion();
// Add to history if non-empty and different from last entry
if !input.is_empty() && self.history.front() != Some(&input) {
self.history.push_front(input.clone());
if self.history.len() > MAX_HISTORY {
self.history.pop_back();
}
}
self.history_index = None;
self.saved_input.clear();
input
}
/// Navigate to previous history entry.
pub fn history_prev(&mut self) {
if self.history.is_empty() {
return;
}
match self.history_index {
None => {
// Save current input and go to first history entry
self.saved_input = std::mem::take(&mut self.buffer);
self.history_index = Some(0);
self.buffer = self.history[0].clone();
}
Some(i) if i + 1 < self.history.len() => {
self.history_index = Some(i + 1);
self.buffer = self.history[i + 1].clone();
}
_ => {}
}
self.cursor = self.buffer.len();
self.clear_completion();
}
/// Navigate to next history entry.
pub fn history_next(&mut self) {
match self.history_index {
Some(0) => {
// Go back to saved input
self.history_index = None;
self.buffer = std::mem::take(&mut self.saved_input);
}
Some(i) => {
self.history_index = Some(i - 1);
self.buffer = self.history[i - 1].clone();
}
None => {}
}
self.cursor = self.buffer.len();
self.clear_completion();
}
/// Attempt tab completion.
pub fn complete(&mut self) {
// Only complete slash commands for now
if !self.buffer.starts_with('/') {
return;
}
if self.completions.is_empty() {
// Generate completions
let prefix = &self.buffer;
self.completions = SLASH_COMMANDS
.iter()
.filter(|cmd| cmd.starts_with(prefix))
.map(|s| s.to_string())
.collect();
if !self.completions.is_empty() {
self.completion_index = Some(0);
}
} else if let Some(i) = self.completion_index {
// Cycle through completions
self.completion_index = Some((i + 1) % self.completions.len());
}
// Apply completion
if let Some(i) = self.completion_index {
if let Some(completion) = self.completions.get(i) {
self.buffer = completion.clone();
self.cursor = self.buffer.len();
}
}
}
/// Clear completion state.
fn clear_completion(&mut self) {
self.completions.clear();
self.completion_index = None;
}
/// Get current completion hint (for display).
pub fn completion_hint(&self) -> Option<&str> {
if let Some(i) = self.completion_index {
self.completions.get(i).map(|s| s.as_str())
} else {
None
}
}
/// Get the number of completions available.
pub fn completion_count(&self) -> usize {
self.completions.len()
}
}
impl Default for ChatComposer {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_insert_and_backspace() {
let mut composer = ChatComposer::new();
composer.insert('h');
composer.insert('i');
assert_eq!(composer.buffer(), "hi");
composer.backspace();
assert_eq!(composer.buffer(), "h");
}
#[test]
fn test_history_navigation() {
let mut composer = ChatComposer::new();
composer.insert_str("first");
composer.submit();
composer.insert_str("second");
composer.submit();
composer.insert_str("current");
composer.history_prev();
assert_eq!(composer.buffer(), "second");
composer.history_prev();
assert_eq!(composer.buffer(), "first");
composer.history_next();
assert_eq!(composer.buffer(), "second");
composer.history_next();
assert_eq!(composer.buffer(), "current");
}
#[test]
fn test_completion() {
let mut composer = ChatComposer::new();
composer.insert_str("/hel");
composer.complete();
assert_eq!(composer.buffer(), "/help");
}
}
-333
View File
@@ -1,333 +0,0 @@
//! Event handling for the TUI.
use std::io;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;
use crate::channels::IncomingMessage;
use crate::channels::cli::app::{AppEvent, AppState, InputMode};
use crate::channels::cli::render;
/// Tick rate for the event loop (50ms = 20fps).
const TICK_RATE: Duration = Duration::from_millis(50);
/// Run the main event loop.
pub fn run_event_loop(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
app: &mut AppState,
msg_tx: mpsc::Sender<IncomingMessage>,
mut event_rx: mpsc::Receiver<AppEvent>,
) -> io::Result<()> {
loop {
// Render
terminal.draw(|f| render::render(f, app))?;
// Check for quit - send shutdown signal and exit
if app.should_quit {
// Send a shutdown message so the agent loop knows to exit
let shutdown_msg = IncomingMessage::new("tui", "system", "/shutdown");
let _ = msg_tx.blocking_send(shutdown_msg);
// Explicitly drop to close the channel
drop(msg_tx);
return Ok(());
}
// Poll for terminal events
if event::poll(TICK_RATE)? {
let evt = event::read()?;
if let Err(e) = handle_event(app, evt, &msg_tx) {
tracing::error!("Event handling error: {}", e);
}
}
// Check for app events from agent (non-blocking)
while let Ok(app_event) = event_rx.try_recv() {
handle_app_event(app, app_event);
}
}
}
/// Handle a crossterm event.
fn handle_event(
app: &mut AppState,
event: Event,
msg_tx: &mpsc::Sender<IncomingMessage>,
) -> io::Result<()> {
match event {
Event::Key(key) => handle_key(app, key, msg_tx),
Event::Mouse(_) => Ok(()), // Could handle mouse scrolling here
Event::Resize(_, _) => Ok(()), // Terminal will handle resize
_ => Ok(()),
}
}
/// Handle a key event.
fn handle_key(
app: &mut AppState,
key: KeyEvent,
msg_tx: &mpsc::Sender<IncomingMessage>,
) -> io::Result<()> {
// Global keybindings
if key.modifiers.contains(KeyModifiers::CONTROL) {
match key.code {
KeyCode::Char('c') => {
if app.mode == InputMode::Approval {
// Cancel all pending approvals
app.clear_approvals();
} else {
// Quit
app.should_quit = true;
}
app.ctrl_d_pending = false;
return Ok(());
}
KeyCode::Char('d') => {
if app.ctrl_d_pending {
// Second Ctrl+D, quit now
app.should_quit = true;
} else {
// First Ctrl+D, show hint
app.ctrl_d_pending = true;
app.set_status("Press Ctrl+D again to quit");
}
return Ok(());
}
_ => {
// Any other Ctrl+ combo clears the Ctrl+D pending state
app.ctrl_d_pending = false;
}
}
} else {
// Any non-Ctrl key clears the Ctrl+D pending state
app.ctrl_d_pending = false;
}
match app.mode {
InputMode::Normal => handle_normal_mode(app, key),
InputMode::Editing => handle_editing_mode(app, key, msg_tx),
InputMode::Approval => handle_approval_mode(app, key),
InputMode::ModelSelector => handle_model_selector_mode(app, key),
}
}
/// Handle keys in normal mode.
fn handle_normal_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
match key.code {
KeyCode::Char('i') | KeyCode::Char('a') => {
app.mode = InputMode::Editing;
}
KeyCode::Char('q') => {
app.should_quit = true;
}
KeyCode::Up | KeyCode::Char('k') => {
app.scroll_up(1);
}
KeyCode::Down | KeyCode::Char('j') => {
app.scroll_down(1);
}
KeyCode::PageUp => {
app.scroll_up(10);
}
KeyCode::PageDown => {
app.scroll_down(10);
}
KeyCode::Char('G') => {
app.scroll_to_bottom();
}
_ => {}
}
Ok(())
}
/// Handle keys in editing mode.
fn handle_editing_mode(
app: &mut AppState,
key: KeyEvent,
msg_tx: &mpsc::Sender<IncomingMessage>,
) -> io::Result<()> {
match key.code {
KeyCode::Enter => {
if !app.composer.is_empty() {
let input = app.composer.submit();
// Handle /model command locally (TUI-specific)
if input.trim().eq_ignore_ascii_case("/model") {
app.show_model_selector();
return Ok(());
}
app.add_user_message(&input);
// Send message to agent
let msg = IncomingMessage::new("tui", "local-user", &input);
let _ = msg_tx.blocking_send(msg);
}
}
KeyCode::Esc => {
app.mode = InputMode::Normal;
}
KeyCode::Backspace => {
app.composer.backspace();
}
KeyCode::Delete => {
app.composer.delete();
}
KeyCode::Left => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Move word left (simplified: just move to start)
app.composer.move_home();
} else {
app.composer.move_left();
}
}
KeyCode::Right => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
// Move word right (simplified: just move to end)
app.composer.move_end();
} else {
app.composer.move_right();
}
}
KeyCode::Home => {
app.composer.move_home();
}
KeyCode::End => {
app.composer.move_end();
}
KeyCode::Up => {
app.composer.history_prev();
}
KeyCode::Down => {
app.composer.history_next();
}
KeyCode::Tab => {
app.composer.complete();
}
KeyCode::Char(c) => {
if key.modifiers.contains(KeyModifiers::CONTROL) {
match c {
'a' => app.composer.move_home(),
'e' => app.composer.move_end(),
'k' => app.composer.kill_line(),
'u' => app.composer.kill_to_start(),
'w' => {
// Delete word backwards (simplified: clear)
app.composer.clear();
}
_ => {}
}
} else {
app.composer.insert(c);
}
}
_ => {}
}
Ok(())
}
/// Handle keys in approval mode.
fn handle_approval_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
if let Some(ref mut overlay) = app.approval {
match key.code {
KeyCode::Left | KeyCode::Char('h') => {
overlay.select_prev();
}
KeyCode::Right | KeyCode::Char('l') => {
overlay.select_next();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let (approved, _always) = overlay.confirm();
app.handle_approval_response(approved);
// TODO: If always, remember to auto-approve this tool
}
KeyCode::Char(c) => {
if let Some(approved) = overlay.handle_shortcut(c) {
app.handle_approval_response(approved);
}
}
KeyCode::Esc => {
// Deny this approval
app.handle_approval_response(false);
}
_ => {}
}
}
Ok(())
}
/// Handle keys in model selector mode.
fn handle_model_selector_mode(app: &mut AppState, key: KeyEvent) -> io::Result<()> {
if let Some(ref mut overlay) = app.model_selector {
match key.code {
KeyCode::Left | KeyCode::Char('h') => {
overlay.select_prev();
}
KeyCode::Right | KeyCode::Char('l') => {
overlay.select_next();
}
KeyCode::Enter | KeyCode::Char(' ') => {
let selected = overlay.selected_model().map(|s| s.to_string());
app.handle_model_selection(selected);
}
KeyCode::Esc => {
// Cancel without changing model
app.handle_model_selection(None);
}
_ => {}
}
}
Ok(())
}
/// Handle an application event.
fn handle_app_event(app: &mut AppState, event: AppEvent) {
match event {
AppEvent::Response(content) => {
app.add_agent_message(content);
}
AppEvent::ToolStarted { name } => {
app.set_thinking(format!("⚙️ Running tool: {}...", name));
}
AppEvent::ToolCompleted { name, success } => {
if success {
app.set_thinking(format!("✓ Tool {} completed", name));
} else {
app.set_thinking(format!("✗ Tool {} failed", name));
}
}
AppEvent::ApprovalRequested(request) => {
app.queue_approval(request);
}
AppEvent::StreamChunk(chunk) => {
if app.streaming_buffer.is_none() {
app.start_streaming();
}
app.append_stream(&chunk);
}
AppEvent::Redraw => {
// Just triggers a redraw on next loop iteration
}
AppEvent::Quit => {
app.should_quit = true;
}
AppEvent::Input(_) => {
// Already handled directly
}
AppEvent::LogMessage(msg) => {
app.set_status(msg);
}
AppEvent::ThinkingMessage(msg) => {
app.set_thinking(msg);
}
AppEvent::ErrorMessage(msg) => {
app.add_error_message(msg);
}
AppEvent::AvailableModels(models) => {
app.set_available_models(models);
}
}
}
-238
View File
@@ -1,238 +0,0 @@
//! Interactive TUI channel using Ratatui.
//!
//! Provides a rich terminal interface with:
//! - Input history navigation
//! - Slash command completion
//! - Approval overlays for tool execution
//! - Streaming response display
mod app;
mod composer;
mod events;
mod model_selector;
mod overlay;
mod render;
use std::io;
use std::sync::Arc;
use async_trait::async_trait;
use crossterm::{
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::{Mutex, mpsc};
use tokio_stream::wrappers::ReceiverStream;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
pub use app::{AppEvent, AppState, InputMode};
pub use composer::ChatComposer;
pub use model_selector::{ModelSelectorOverlay, ModelSelectorRequest};
pub use overlay::{ApprovalOverlay, ApprovalRequest};
/// TUI channel for interactive terminal input with Ratatui.
pub struct TuiChannel {
/// Channel for sending events to the TUI (created upfront for logging).
event_tx: mpsc::Sender<AppEvent>,
/// Receiver end, taken when start() is called.
event_rx: Arc<Mutex<Option<mpsc::Receiver<AppEvent>>>>,
}
impl TuiChannel {
/// Create a new TUI channel.
pub fn new() -> Self {
let (event_tx, event_rx) = mpsc::channel(64);
Self {
event_tx,
event_rx: Arc::new(Mutex::new(Some(event_rx))),
}
}
/// Get a log writer that sends messages to the TUI status line.
/// Use this to redirect tracing output to the TUI.
pub fn log_writer(&self) -> TuiLogWriter {
TuiLogWriter::new(self.event_tx.clone())
}
/// Get a sender for sending events to the TUI.
/// Use this to send available models or other events from outside the channel.
pub fn event_sender(&self) -> mpsc::Sender<AppEvent> {
self.event_tx.clone()
}
}
impl Default for TuiChannel {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Channel for TuiChannel {
fn name(&self) -> &str {
"tui"
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (msg_tx, msg_rx) = mpsc::channel(32);
// Take the event receiver (can only start once)
let event_rx = {
let mut guard = self.event_rx.lock().await;
guard.take().ok_or_else(|| ChannelError::StartupFailed {
name: "tui".to_string(),
reason: "TUI channel already started".to_string(),
})?
};
tokio::task::spawn_blocking(move || {
if let Err(e) = run_tui(msg_tx, event_rx) {
// Try to restore terminal even on error
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen);
eprintln!("TUI error: {}", e);
}
});
Ok(Box::pin(ReceiverStream::new(msg_rx)))
}
async fn respond(
&self,
_msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
self.event_tx
.send(AppEvent::Response(response.content))
.await
.map_err(|e| ChannelError::SendFailed {
name: "tui".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
async fn send_status(
&self,
status: StatusUpdate,
_metadata: &serde_json::Value,
) -> Result<(), ChannelError> {
let event = match status {
StatusUpdate::Thinking(msg) => AppEvent::ThinkingMessage(format!("🤔 {}", msg)),
StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted { name },
StatusUpdate::ToolCompleted { name, success } => {
AppEvent::ToolCompleted { name, success }
}
StatusUpdate::StreamChunk(chunk) => AppEvent::StreamChunk(chunk),
StatusUpdate::Status(msg) => AppEvent::ThinkingMessage(msg),
};
self.event_tx
.send(event)
.await
.map_err(|e| ChannelError::SendFailed {
name: "tui".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
async fn broadcast(
&self,
_user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
// For TUI, broadcasts appear as regular agent responses with a notification indicator
self.event_tx
.send(AppEvent::Response(response.content))
.await
.map_err(|e| ChannelError::SendFailed {
name: "tui".to_string(),
reason: e.to_string(),
})?;
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
// Channel is healthy if we haven't been closed
if self.event_tx.is_closed() {
Err(ChannelError::HealthCheckFailed {
name: "tui".to_string(),
})
} else {
Ok(())
}
}
async fn shutdown(&self) -> Result<(), ChannelError> {
let _ = self.event_tx.send(AppEvent::Quit).await;
Ok(())
}
}
/// Run the TUI event loop (blocking).
fn run_tui(
msg_tx: mpsc::Sender<IncomingMessage>,
event_rx: mpsc::Receiver<AppEvent>,
) -> io::Result<()> {
// Setup terminal
// Note: We don't enable mouse capture so users can select text normally
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
// Create app state
let mut app = AppState::new();
// Run event loop
let result = events::run_event_loop(&mut terminal, &mut app, msg_tx, event_rx);
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
/// TUI-compatible tracing writer that sends log messages to the TUI status line.
#[derive(Clone)]
pub struct TuiLogWriter {
tx: mpsc::Sender<AppEvent>,
}
impl TuiLogWriter {
pub fn new(tx: mpsc::Sender<AppEvent>) -> Self {
Self { tx }
}
}
impl std::io::Write for TuiLogWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if let Ok(s) = std::str::from_utf8(buf) {
let s = s.trim();
if !s.is_empty() {
// Fire and forget - don't block on logging
let _ = self.tx.try_send(AppEvent::LogMessage(s.to_string()));
}
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for TuiLogWriter {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
-156
View File
@@ -1,156 +0,0 @@
//! Model selector overlay for switching LLM models.
/// Request to show the model selector.
#[derive(Debug, Clone)]
pub struct ModelSelectorRequest {
/// Currently selected model.
pub current_model: String,
/// Available models to choose from.
pub available_models: Vec<String>,
}
/// Model selector overlay state.
#[derive(Debug, Clone)]
pub struct ModelSelectorOverlay {
/// The request that triggered this overlay.
pub request: ModelSelectorRequest,
/// Currently highlighted index.
pub selection_index: usize,
}
impl ModelSelectorOverlay {
/// Create a new model selector overlay.
pub fn new(request: ModelSelectorRequest) -> Self {
// Find the current model in the list, default to 0
let selection_index = request
.available_models
.iter()
.position(|m| m == &request.current_model)
.unwrap_or(0);
Self {
request,
selection_index,
}
}
/// Get the list of available models.
pub fn models(&self) -> &[String] {
&self.request.available_models
}
/// Move selection up.
pub fn select_prev(&mut self) {
let len = self.request.available_models.len();
if len == 0 {
return;
}
if self.selection_index > 0 {
self.selection_index -= 1;
} else {
// Wrap to bottom
self.selection_index = len - 1;
}
}
/// Move selection down.
pub fn select_next(&mut self) {
let len = self.request.available_models.len();
if len == 0 {
return;
}
if self.selection_index < len - 1 {
self.selection_index += 1;
} else {
// Wrap to top
self.selection_index = 0;
}
}
/// Get the currently selected model name.
pub fn selected_model(&self) -> Option<&str> {
self.request
.available_models
.get(self.selection_index)
.map(|s| s.as_str())
}
/// Check if the selection is the current model.
pub fn is_current(&self) -> bool {
self.selected_model() == Some(&self.request.current_model)
}
/// Format a model name for display (shorten long names).
pub fn format_model_name(model: &str) -> String {
// Shorten fireworks model names
if let Some(rest) = model.strip_prefix("fireworks::accounts/fireworks/models/") {
return format!("fireworks/{}", rest);
}
// Shorten other long prefixes
if let Some(rest) = model.strip_prefix("accounts/") {
return rest.to_string();
}
model.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_model_selector_navigation() {
let request = ModelSelectorRequest {
current_model: "gpt-4o".to_string(),
available_models: vec![
"claude-3-5-sonnet".to_string(),
"gpt-4o".to_string(),
"gpt-4o-mini".to_string(),
],
};
let mut overlay = ModelSelectorOverlay::new(request);
// Should start at gpt-4o index (1)
assert_eq!(overlay.selected_model(), Some("gpt-4o"));
// Navigate down
overlay.select_next();
assert_eq!(overlay.selected_model(), Some("gpt-4o-mini"));
// Navigate down (wrap)
overlay.select_next();
assert_eq!(overlay.selected_model(), Some("claude-3-5-sonnet"));
// Navigate up
overlay.select_prev();
assert_eq!(overlay.selected_model(), Some("gpt-4o-mini"));
}
#[test]
fn test_format_model_name() {
assert_eq!(
ModelSelectorOverlay::format_model_name("claude-3-5-sonnet-20241022"),
"claude-3-5-sonnet-20241022"
);
assert_eq!(
ModelSelectorOverlay::format_model_name(
"fireworks::accounts/fireworks/models/llama-v3p1-405b-instruct"
),
"fireworks/llama-v3p1-405b-instruct"
);
}
#[test]
fn test_empty_models() {
let request = ModelSelectorRequest {
current_model: "unknown".to_string(),
available_models: vec![],
};
let mut overlay = ModelSelectorOverlay::new(request);
assert_eq!(overlay.selected_model(), None);
// Should not panic
overlay.select_next();
overlay.select_prev();
}
}
-145
View File
@@ -1,145 +0,0 @@
//! Approval overlay modal.
use uuid::Uuid;
/// A request for user approval before executing a tool.
#[derive(Debug, Clone)]
pub struct ApprovalRequest {
/// Unique ID for this request.
pub id: Uuid,
/// Name of the tool requesting approval.
pub tool_name: String,
/// Description of what the tool will do.
pub description: String,
/// Parameters being passed to the tool.
pub parameters: serde_json::Value,
/// Whether this is a destructive operation.
pub destructive: bool,
}
impl ApprovalRequest {
/// Create a new approval request.
pub fn new(
tool_name: impl Into<String>,
description: impl Into<String>,
parameters: serde_json::Value,
) -> Self {
Self {
id: Uuid::new_v4(),
tool_name: tool_name.into(),
description: description.into(),
parameters,
destructive: false,
}
}
/// Mark as destructive operation.
pub fn destructive(mut self) -> Self {
self.destructive = true;
self
}
}
/// Current selection in the approval overlay.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApprovalSelection {
/// Yes, approve this action.
Yes,
/// No, deny this action.
No,
/// Always approve this tool (for this session).
Always,
}
impl ApprovalSelection {
/// Get the next selection (cycling).
pub fn next(self) -> Self {
match self {
Self::Yes => Self::No,
Self::No => Self::Always,
Self::Always => Self::Yes,
}
}
/// Get the previous selection (cycling).
pub fn prev(self) -> Self {
match self {
Self::Yes => Self::Always,
Self::No => Self::Yes,
Self::Always => Self::No,
}
}
}
/// Approval overlay state.
pub struct ApprovalOverlay {
/// The request being shown.
pub request: ApprovalRequest,
/// Current selection.
pub selection: ApprovalSelection,
}
impl ApprovalOverlay {
/// Create a new approval overlay.
pub fn new(request: ApprovalRequest) -> Self {
Self {
request,
selection: ApprovalSelection::Yes,
}
}
/// Move selection left.
pub fn select_prev(&mut self) {
self.selection = self.selection.prev();
}
/// Move selection right.
pub fn select_next(&mut self) {
self.selection = self.selection.next();
}
/// Handle keyboard shortcut.
pub fn handle_shortcut(&mut self, c: char) -> Option<bool> {
match c.to_ascii_lowercase() {
'y' => Some(true),
'n' => Some(false),
'a' => {
self.selection = ApprovalSelection::Always;
Some(true)
}
_ => None,
}
}
/// Confirm the current selection.
pub fn confirm(&self) -> (bool, bool) {
match self.selection {
ApprovalSelection::Yes => (true, false),
ApprovalSelection::No => (false, false),
ApprovalSelection::Always => (true, true),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_approval_selection_cycle() {
let sel = ApprovalSelection::Yes;
assert_eq!(sel.next(), ApprovalSelection::No);
assert_eq!(sel.next().next(), ApprovalSelection::Always);
assert_eq!(sel.next().next().next(), ApprovalSelection::Yes);
}
#[test]
fn test_approval_shortcuts() {
let request = ApprovalRequest::new("test", "Test operation", serde_json::json!({}));
let mut overlay = ApprovalOverlay::new(request);
assert_eq!(overlay.handle_shortcut('y'), Some(true));
assert_eq!(overlay.handle_shortcut('n'), Some(false));
assert_eq!(overlay.handle_shortcut('x'), None);
}
}
-341
View File
@@ -1,341 +0,0 @@
//! TUI rendering with Ratatui.
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Clear, Paragraph, Wrap},
};
use crate::channels::cli::app::{AppState, InputMode, MessageRole, MessageStatus};
use crate::channels::cli::model_selector::ModelSelectorOverlay;
use crate::channels::cli::overlay::ApprovalSelection;
/// Render the entire UI.
pub fn render(frame: &mut Frame, app: &AppState) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(3), // Messages
Constraint::Length(3), // Input
Constraint::Length(1), // Status
])
.split(frame.area());
render_messages(frame, app, chunks[0]);
render_input(frame, app, chunks[1]);
render_status(frame, app, chunks[2]);
// Render approval overlay if active
if app.mode == InputMode::Approval {
render_approval_overlay(frame, app);
}
}
/// Render the message history.
fn render_messages(frame: &mut Frame, app: &AppState, area: Rect) {
// Build all lines from all messages
let mut lines: Vec<Line> = Vec::new();
for msg in &app.messages {
let (prefix, style) = match msg.role {
MessageRole::User => ("You: ", Style::default().fg(Color::Cyan)),
MessageRole::Agent => ("Agent: ", Style::default().fg(Color::Green)),
MessageRole::System => (
"",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
),
};
let status_indicator = match msg.status {
Some(MessageStatus::Pending) => "",
Some(MessageStatus::InProgress) => " ⚙️",
Some(MessageStatus::Complete) => "",
Some(MessageStatus::Error) => "",
None => "",
};
// Split content by newlines and create a line for each
let content_lines: Vec<&str> = msg.content.lines().collect();
for (i, line_text) in content_lines.iter().enumerate() {
if i == 0 {
// First line gets the prefix
let line_content = if status_indicator.is_empty() {
format!("{}{}", prefix, line_text)
} else if content_lines.len() == 1 {
format!("{}{}{}", prefix, line_text, status_indicator)
} else {
format!("{}{}", prefix, line_text)
};
lines.push(Line::styled(line_content, style));
} else if i == content_lines.len() - 1 && !status_indicator.is_empty() {
// Last line gets status indicator
lines.push(Line::styled(
format!("{}{}", line_text, status_indicator),
style,
));
} else {
// Middle lines just get the content
lines.push(Line::styled(line_text.to_string(), style));
}
}
// Add empty line between messages for readability
lines.push(Line::from(""));
}
// Calculate scroll - show most recent messages
let visible_height = area.height.saturating_sub(2) as usize; // Account for borders
let total_lines = lines.len();
let scroll_offset = total_lines.saturating_sub(visible_height);
let text = Text::from(lines);
let messages = Paragraph::new(text)
.block(Block::default().borders(Borders::ALL).title("Chat"))
.wrap(Wrap { trim: false })
.scroll((scroll_offset as u16, 0));
frame.render_widget(messages, area);
}
/// Render the input area (or model selector when in ModelSelector mode).
fn render_input(frame: &mut Frame, app: &AppState, area: Rect) {
// In ModelSelector mode, render inline selector instead of input
if app.mode == InputMode::ModelSelector {
render_model_selector_inline(frame, app, area);
return;
}
let input_style = match app.mode {
InputMode::Editing => Style::default().fg(Color::Yellow),
InputMode::Normal => Style::default(),
InputMode::Approval | InputMode::ModelSelector => Style::default().fg(Color::DarkGray),
};
let buffer = app.composer.buffer();
let cursor = app.composer.cursor();
// Build the input text with cursor
let (before, after) = buffer.split_at(cursor.min(buffer.len()));
let cursor_char = after.chars().next().unwrap_or(' ');
let after_cursor = if after.is_empty() {
""
} else {
&after[cursor_char.len_utf8()..]
};
let input = Paragraph::new(Line::from(vec![
Span::raw(before),
Span::styled(
cursor_char.to_string(),
Style::default().bg(Color::White).fg(Color::Black),
),
Span::raw(after_cursor),
]))
.style(input_style)
.block(Block::default().borders(Borders::ALL).title("Input"));
frame.render_widget(input, area);
// Show cursor in editing mode
if app.mode == InputMode::Editing {
// Calculate cursor position accounting for the block border
let cursor_x = area.x + 1 + cursor as u16;
let cursor_y = area.y + 1;
frame.set_cursor_position((cursor_x, cursor_y));
}
}
/// Render inline model selector in the input area.
fn render_model_selector_inline(frame: &mut Frame, app: &AppState, area: Rect) {
let Some(ref overlay) = app.model_selector else {
return;
};
let models = overlay.models();
// Build horizontal list of models
let mut spans: Vec<Span> = Vec::new();
if models.is_empty() {
spans.push(Span::styled(
"Loading models...",
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
));
} else {
for (i, model) in models.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" "));
}
let display_name = ModelSelectorOverlay::format_model_name(model);
let is_selected = i == overlay.selection_index;
let is_current = model == &overlay.request.current_model;
let style = if is_selected {
Style::default().bg(Color::Blue).fg(Color::White)
} else if is_current {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::White)
};
let prefix = if is_current { "" } else { " " };
spans.push(Span::styled(format!("{}{}", prefix, display_name), style));
}
}
let content = Paragraph::new(Line::from(spans))
.block(Block::default().borders(Borders::ALL).title(Span::styled(
"Select Model",
Style::default().fg(Color::Cyan),
)))
.scroll((
0,
calculate_model_scroll(overlay, area.width.saturating_sub(2)),
));
frame.render_widget(content, area);
}
/// Calculate horizontal scroll offset to keep selected model visible.
fn calculate_model_scroll(overlay: &ModelSelectorOverlay, visible_width: u16) -> u16 {
let models = overlay.models();
if models.is_empty() {
return 0;
}
// Estimate position of selected model (rough calculation)
let mut pos: u16 = 0;
for (i, model) in models.iter().enumerate() {
let name_len = ModelSelectorOverlay::format_model_name(model).len() as u16 + 3; // +3 for prefix and spacing
if i == overlay.selection_index {
// Check if selection is beyond visible area
if pos > visible_width {
return pos.saturating_sub(visible_width / 2);
}
return 0;
}
pos += name_len;
}
0
}
/// Render the status line.
fn render_status(frame: &mut Frame, app: &AppState, area: Rect) {
let status_text = if let Some(ref msg) = app.status_message {
msg.clone()
} else {
match app.mode {
InputMode::Normal | InputMode::Editing => {
let model = ModelSelectorOverlay::format_model_name(&app.current_model);
format!("{} | /model to switch", model)
}
InputMode::Approval => "y=Yes, n=No, a=Always, Ctrl+C=Cancel".to_string(),
InputMode::ModelSelector => "←/→ navigate, Enter=select, Esc=cancel".to_string(),
}
};
let status = Paragraph::new(status_text).style(Style::default().fg(Color::DarkGray));
frame.render_widget(status, area);
}
/// Render the approval overlay.
fn render_approval_overlay(frame: &mut Frame, app: &AppState) {
let Some(ref overlay) = app.approval else {
return;
};
let area = frame.area();
// Calculate overlay size and position
let overlay_width = (area.width * 60 / 100).min(60);
let overlay_height = 12;
let overlay_x = (area.width - overlay_width) / 2;
let overlay_y = (area.height - overlay_height) / 2;
let overlay_area = Rect::new(overlay_x, overlay_y, overlay_width, overlay_height);
// Clear the area behind the overlay
frame.render_widget(Clear, overlay_area);
// Build overlay content
let title = if overlay.request.destructive {
"⚠️ Approval Required (Destructive)"
} else {
"Approval Required"
};
let title_style = if overlay.request.destructive {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
};
// Build the text content
let mut lines = vec![
Line::from(vec![
Span::styled("Tool: ", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(&overlay.request.tool_name),
]),
Line::from(""),
Line::from(overlay.request.description.as_str()),
Line::from(""),
];
// Add parameters preview (truncated)
let params_str = serde_json::to_string_pretty(&overlay.request.parameters)
.unwrap_or_else(|_| "{}".to_string());
let params_preview: String = params_str.chars().take(100).collect();
lines.push(Line::from(vec![
Span::styled("Params: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(params_preview, Style::default().fg(Color::DarkGray)),
]));
lines.push(Line::from(""));
// Add selection buttons
let yes_style = if overlay.selection == ApprovalSelection::Yes {
Style::default().bg(Color::Green).fg(Color::Black)
} else {
Style::default().fg(Color::Green)
};
let no_style = if overlay.selection == ApprovalSelection::No {
Style::default().bg(Color::Red).fg(Color::Black)
} else {
Style::default().fg(Color::Red)
};
let always_style = if overlay.selection == ApprovalSelection::Always {
Style::default().bg(Color::Blue).fg(Color::Black)
} else {
Style::default().fg(Color::Blue)
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(" [Y]es ", yes_style),
Span::raw(" "),
Span::styled(" [N]o ", no_style),
Span::raw(" "),
Span::styled(" [A]lways ", always_style),
]));
let content = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.title(Span::styled(title, title_style)),
)
.wrap(Wrap { trim: true });
frame.render_widget(content, overlay_area);
}
+23 -59
View File
@@ -1,6 +1,5 @@
//! HTTP webhook channel for receiving messages via HTTP POST.
use std::net::SocketAddr;
use std::sync::Arc;
use async_trait::async_trait;
@@ -32,8 +31,6 @@ struct HttpChannelState {
tx: RwLock<Option<mpsc::Sender<IncomingMessage>>>,
/// Pending responses keyed by message ID.
pending_responses: RwLock<std::collections::HashMap<Uuid, oneshot::Sender<String>>>,
/// Server shutdown signal.
shutdown_tx: RwLock<Option<oneshot::Sender<()>>>,
/// Expected webhook secret for authentication (if configured).
webhook_secret: Option<String>,
/// Fixed user ID for this HTTP channel.
@@ -74,7 +71,6 @@ impl HttpChannel {
state: Arc::new(HttpChannelState {
tx: RwLock::new(None),
pending_responses: RwLock::new(std::collections::HashMap::new()),
shutdown_tx: RwLock::new(None),
webhook_secret,
user_id,
rate_limit: tokio::sync::Mutex::new(RateLimitState {
@@ -84,6 +80,24 @@ impl HttpChannel {
}),
}
}
/// Return the channel's axum routes with state applied.
///
/// The returned `Router` shares the same `Arc<HttpChannelState>` that
/// `start()` later populates. Before `start()` is called the webhook
/// handler returns 503 ("Channel not started").
pub fn routes(&self) -> Router {
Router::new()
.route("/health", get(health_handler))
.route("/webhook", post(webhook_handler))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(self.state.clone())
}
/// Return the configured host and port for this channel.
pub fn addr(&self) -> (&str, u16) {
(&self.config.host, self.config.port)
}
}
#[derive(Debug, Deserialize)]
@@ -303,53 +317,11 @@ impl Channel for HttpChannel {
let (tx, rx) = mpsc::channel(256);
*self.state.tx.write().await = Some(tx);
let state = self.state.clone();
let host = self.config.host.clone();
let port = self.config.port;
// Parse address before spawning so we can return errors
let addr: SocketAddr =
format!("{}:{}", host, port)
.parse()
.map_err(|e| ChannelError::StartupFailed {
name: "http".to_string(),
reason: format!("Invalid address '{}:{}': {}", host, port, e),
})?;
// Bind listener before spawning so we can return errors
let listener =
tokio::net::TcpListener::bind(addr)
.await
.map_err(|e| ChannelError::StartupFailed {
name: "http".to_string(),
reason: format!("Failed to bind to {}: {}", addr, e),
})?;
tracing::info!("HTTP channel listening on {}", addr);
// Create router
let app = Router::new()
.route("/health", get(health_handler))
.route("/webhook", post(webhook_handler))
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.with_state(state.clone());
// Create shutdown channel
let (shutdown_tx, shutdown_rx) = oneshot::channel();
*self.state.shutdown_tx.write().await = Some(shutdown_tx);
// Spawn server (listener is already bound, serve errors are logged)
tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::info!("HTTP channel shutting down");
})
.await
{
tracing::error!("HTTP server error: {}", e);
}
});
tracing::info!(
"HTTP channel ready ({}:{})",
self.config.host,
self.config.port
);
Ok(Box::pin(ReceiverStream::new(rx)))
}
@@ -363,13 +335,10 @@ impl Channel for HttpChannel {
if let Some(tx) = self.state.pending_responses.write().await.remove(&msg.id) {
let _ = tx.send(response.content);
}
// For async webhooks, we'd need to make an HTTP callback here
// but that requires the caller to provide a callback URL
Ok(())
}
async fn health_check(&self) -> Result<(), ChannelError> {
// Check if we have an active sender
if self.state.tx.read().await.is_some() {
Ok(())
} else {
@@ -380,11 +349,6 @@ impl Channel for HttpChannel {
}
async fn shutdown(&self) -> Result<(), ChannelError> {
// Send shutdown signal
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
let _ = tx.send(());
}
// Clear the message sender
*self.state.tx.write().await = None;
Ok(())
}
+5 -5
View File
@@ -9,9 +9,9 @@
//! ┌─────────────────────────────────────────────────────────────────────┐
//! │ ChannelManager │
//! │ │
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
//! │ │ TuiChannel │ │ HttpChannel │ │ WasmChannel │ ...
//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
//! │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
//! │ │ ReplChannel │ │ HttpChannel │ │ WasmChannel │ ... │
//! │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
//! │ │ │ │ │
//! │ └─────────────────┴─────────────────┘ │
//! │ │ │
@@ -28,16 +28,16 @@
//! See the [`wasm`] module for details.
mod channel;
pub mod cli;
mod http;
mod manager;
mod repl;
pub mod wasm;
pub mod web;
mod webhook_server;
pub use channel::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
pub use cli::{AppEvent, TuiChannel};
pub use http::HttpChannel;
pub use manager::ChannelManager;
pub use repl::ReplChannel;
pub use web::GatewayChannel;
pub use webhook_server::{WebhookServer, WebhookServerConfig};
+189 -47
View File
@@ -1,6 +1,8 @@
//! Interactive REPL channel for debugging and testing.
//! Interactive REPL channel with line editing and markdown rendering.
//!
//! Provides a command-line interface for interacting with the agent.
//! Provides the primary CLI interface for interacting with the agent.
//! Uses rustyline for line editing, history, and tab-completion.
//! Uses termimad for rendering markdown responses inline.
//!
//! ## Commands
//!
@@ -14,23 +16,113 @@
//! - `/new` - Start a new thread
//! - `yes`/`no`/`always` - Respond to tool approval prompts
use std::io::{self, BufRead, Write};
use std::borrow::Cow;
use std::io::{self, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use async_trait::async_trait;
use rustyline::completion::Completer;
use rustyline::config::Config;
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{CompletionType, Editor, Helper};
use termimad::MadSkin;
use tokio::sync::mpsc;
use tokio_stream::wrappers::ReceiverStream;
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
use crate::error::ChannelError;
/// REPL channel for interactive agent debugging.
/// Slash commands available in the REPL.
const SLASH_COMMANDS: &[&str] = &[
"/help",
"/quit",
"/exit",
"/debug",
"/undo",
"/redo",
"/clear",
"/compact",
"/new",
"/interrupt",
];
/// Rustyline helper for slash-command tab completion.
struct ReplHelper;
impl Completer for ReplHelper {
type Candidate = String;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &rustyline::Context<'_>,
) -> rustyline::Result<(usize, Vec<String>)> {
if !line.starts_with('/') {
return Ok((0, vec![]));
}
let prefix = &line[..pos];
let matches: Vec<String> = SLASH_COMMANDS
.iter()
.filter(|cmd| cmd.starts_with(prefix))
.map(|cmd| cmd.to_string())
.collect();
Ok((0, matches))
}
}
impl Hinter for ReplHelper {
type Hint = String;
fn hint(&self, line: &str, pos: usize, _ctx: &rustyline::Context<'_>) -> Option<String> {
if !line.starts_with('/') || pos < line.len() {
return None;
}
SLASH_COMMANDS
.iter()
.find(|cmd| cmd.starts_with(line) && **cmd != line)
.map(|cmd| cmd[line.len()..].to_string())
}
}
impl Highlighter for ReplHelper {
fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
Cow::Owned(format!("\x1b[90m{hint}\x1b[0m"))
}
}
impl Validator for ReplHelper {}
impl Helper for ReplHelper {}
/// Build a termimad skin with our color scheme.
fn make_skin() -> MadSkin {
let mut skin = MadSkin::default();
skin.set_headers_fg(termimad::crossterm::style::Color::Yellow);
skin.bold.set_fg(termimad::crossterm::style::Color::White);
skin.italic
.set_fg(termimad::crossterm::style::Color::Magenta);
skin.inline_code
.set_fg(termimad::crossterm::style::Color::Green);
skin.code_block
.set_fg(termimad::crossterm::style::Color::Green);
skin
}
/// REPL channel with line editing and markdown rendering.
pub struct ReplChannel {
/// Optional single message to send (for -m flag).
single_message: Option<String>,
/// Debug mode flag (shared with input thread).
debug_mode: Arc<AtomicBool>,
/// Whether we're currently streaming (chunks have been printed without a trailing newline).
is_streaming: Arc<AtomicBool>,
}
impl ReplChannel {
@@ -39,6 +131,7 @@ impl ReplChannel {
Self {
single_message: None,
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
}
}
@@ -47,6 +140,7 @@ impl ReplChannel {
Self {
single_message: Some(message),
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
}
}
@@ -64,7 +158,7 @@ impl Default for ReplChannel {
fn print_help() {
println!(
r#"
IronClaw REPL - Interactive debugging mode
IronClaw REPL
Commands:
/help Show this help message
@@ -90,6 +184,14 @@ Tips:
);
}
/// Get the history file path (~/.ironclaw/history).
fn history_path() -> std::path::PathBuf {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".ironclaw")
.join("history")
}
#[async_trait]
impl Channel for ReplChannel {
fn name(&self) -> &str {
@@ -102,38 +204,50 @@ impl Channel for ReplChannel {
let debug_mode = Arc::clone(&self.debug_mode);
std::thread::spawn(move || {
// If single message mode, send it and exit
// Single message mode: send it and return
if let Some(msg) = single_message {
let incoming = IncomingMessage::new("repl", "user", &msg);
if tx.blocking_send(incoming).is_err() {
return;
}
// Wait a bit for response, then the channel will close
let _ = tx.blocking_send(incoming);
return;
}
// Interactive REPL mode
let stdin = io::stdin();
let mut stdout = io::stdout();
// Set up rustyline
let config = Config::builder()
.history_ignore_dups(true)
.expect("valid config")
.auto_add_history(true)
.completion_type(CompletionType::List)
.build();
let mut rl = match Editor::with_config(config) {
Ok(editor) => editor,
Err(e) => {
eprintln!("Failed to initialize line editor: {e}");
return;
}
};
rl.set_helper(Some(ReplHelper));
// Load history
let hist_path = history_path();
if let Some(parent) = hist_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = rl.load_history(&hist_path);
println!("IronClaw REPL - Type /help for commands, /quit to exit");
println!();
loop {
// Print prompt
let prompt = if debug_mode.load(Ordering::Relaxed) {
"[debug] > "
"\x1b[33m[debug]\x1b[0m \x1b[36m>\x1b[0m "
} else {
"> "
"\x1b[36m>\x1b[0m "
};
print!("{}", prompt);
let _ = stdout.flush();
// Read line
let mut line = String::new();
match stdin.lock().read_line(&mut line) {
Ok(0) => break, // EOF
Ok(_) => {
match rl.readline(prompt) {
Ok(line) => {
let line = line.trim();
if line.is_empty() {
continue;
@@ -164,9 +278,26 @@ impl Channel for ReplChannel {
break;
}
}
Err(_) => break,
Err(ReadlineError::Interrupted) => {
// Ctrl+C: send /interrupt
let msg = IncomingMessage::new("repl", "user", "/interrupt");
if tx.blocking_send(msg).is_err() {
break;
}
}
Err(ReadlineError::Eof) => {
// Ctrl+D: quit
break;
}
Err(e) => {
eprintln!("Input error: {e}");
break;
}
}
}
// Save history on exit
let _ = rl.save_history(&history_path());
});
Ok(Box::pin(ReceiverStream::new(rx)))
@@ -177,8 +308,23 @@ impl Channel for ReplChannel {
_msg: &IncomingMessage,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
// If we were streaming, the content was already printed via StreamChunk.
// Just finish the line and reset.
if self.is_streaming.swap(false, Ordering::Relaxed) {
println!();
println!();
return Ok(());
}
// Render markdown
let skin = make_skin();
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
println!();
println!("{}", response.content);
print!("{text}");
println!();
Ok(())
}
@@ -193,37 +339,27 @@ impl Channel for ReplChannel {
match status {
StatusUpdate::Thinking(msg) => {
if debug {
eprintln!("\x1b[90m[thinking] {}\x1b[0m", msg);
} else {
eprint!(".");
let _ = io::stderr().flush();
eprintln!("\x1b[90m[thinking] {msg}\x1b[0m");
}
}
StatusUpdate::ToolStarted { name } => {
if debug {
eprintln!("\x1b[33m[tool:start] {}\x1b[0m", name);
} else {
eprintln!("\x1b[33m⚡ {}\x1b[0m", name);
}
eprintln!(" \x1b[33m>> {name}\x1b[0m");
}
StatusUpdate::ToolCompleted { name, success } => {
if debug {
if success {
eprintln!("\x1b[32m[tool:done] {}\x1b[0m", name);
} else {
eprintln!("\x1b[31m[tool:fail] {}\x1b[0m", name);
}
} else if !success {
eprintln!("\x1b[31m✗ {} failed\x1b[0m", name);
if success {
eprintln!(" \x1b[32m<< {name}\x1b[0m");
} else {
eprintln!(" \x1b[31m<< {name} failed\x1b[0m");
}
}
StatusUpdate::StreamChunk(chunk) => {
print!("{}", chunk);
self.is_streaming.store(true, Ordering::Relaxed);
print!("{chunk}");
let _ = io::stdout().flush();
}
StatusUpdate::Status(msg) => {
if debug || msg.contains("approval") || msg.contains("Approval") {
eprintln!("\x1b[90m[status] {}\x1b[0m", msg);
eprintln!("\x1b[90m[status] {msg}\x1b[0m");
}
}
}
@@ -235,9 +371,15 @@ impl Channel for ReplChannel {
_user_id: &str,
response: OutgoingResponse,
) -> Result<(), ChannelError> {
println!();
println!("\x1b[36m[notification]\x1b[0m {}", response.content);
println!();
let skin = make_skin();
let width = crossterm::terminal::size()
.map(|(w, _)| w as usize)
.unwrap_or(80);
eprintln!("\x1b[36m[notification]\x1b[0m");
let text = termimad::FmtText::from(&skin, &response.content, Some(width));
eprint!("{text}");
eprintln!();
Ok(())
}
+1 -3
View File
@@ -95,9 +95,7 @@ pub use loader::{
DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir,
discover_channels,
};
pub use router::{
RegisteredEndpoint, WasmChannelRouter, WasmChannelServer, create_wasm_channel_router,
};
pub use router::{RegisteredEndpoint, WasmChannelRouter, create_wasm_channel_router};
pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig};
pub use schema::{
ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema,
-51
View File
@@ -4,7 +4,6 @@
//! registered paths. Handles secret validation at the host level.
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use axum::{
@@ -469,56 +468,6 @@ pub fn create_wasm_channel_router(
.with_state(state)
}
/// HTTP server for WASM channel webhooks.
pub struct WasmChannelServer {
router: Arc<WasmChannelRouter>,
extension_manager: Option<Arc<crate::extensions::ExtensionManager>>,
}
impl WasmChannelServer {
/// Create a new server.
pub fn new(router: Arc<WasmChannelRouter>) -> Self {
Self {
router,
extension_manager: None,
}
}
/// Set the extension manager for OAuth callback handling.
pub fn with_extension_manager(
mut self,
manager: Arc<crate::extensions::ExtensionManager>,
) -> Self {
self.extension_manager = Some(manager);
self
}
/// Start the HTTP server.
///
/// Returns a handle that can be used to shut down the server.
pub async fn start(
&self,
addr: SocketAddr,
) -> Result<tokio::task::JoinHandle<()>, std::io::Error> {
let app = create_wasm_channel_router(self.router.clone(), self.extension_manager.clone());
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!(
addr = %addr,
"WASM channel HTTP server started"
);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app).await {
tracing::error!("WASM channel HTTP server error: {}", e);
}
});
Ok(handle)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
+92
View File
@@ -0,0 +1,92 @@
//! Unified HTTP server for all webhook routes.
//!
//! Composes route fragments from HttpChannel, WASM channel router, etc.
//! into a single axum server. Channels define routes but never spawn servers.
use std::net::SocketAddr;
use axum::Router;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use crate::error::ChannelError;
/// Configuration for the unified webhook server.
pub struct WebhookServerConfig {
/// Address to bind the server to.
pub addr: SocketAddr,
}
/// A single HTTP server that hosts all webhook routes.
///
/// Channels contribute route fragments via `add_routes()`, then a single
/// `start()` call binds the listener and spawns the server task.
pub struct WebhookServer {
config: WebhookServerConfig,
routes: Vec<Router>,
shutdown_tx: Option<oneshot::Sender<()>>,
handle: Option<JoinHandle<()>>,
}
impl WebhookServer {
/// Create a new webhook server with the given bind address.
pub fn new(config: WebhookServerConfig) -> Self {
Self {
config,
routes: Vec::new(),
shutdown_tx: None,
handle: None,
}
}
/// Accumulate a route fragment. Each fragment should already have its
/// state applied via `.with_state()`.
pub fn add_routes(&mut self, router: Router) {
self.routes.push(router);
}
/// Bind the listener, merge all route fragments, and spawn the server.
pub async fn start(&mut self) -> Result<(), ChannelError> {
let mut app = Router::new();
for fragment in self.routes.drain(..) {
app = app.merge(fragment);
}
let listener = tokio::net::TcpListener::bind(self.config.addr)
.await
.map_err(|e| ChannelError::StartupFailed {
name: "webhook_server".to_string(),
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
})?;
tracing::info!("Webhook server listening on {}", self.config.addr);
let (shutdown_tx, shutdown_rx) = oneshot::channel();
self.shutdown_tx = Some(shutdown_tx);
let handle = tokio::spawn(async move {
if let Err(e) = axum::serve(listener, app)
.with_graceful_shutdown(async {
let _ = shutdown_rx.await;
tracing::info!("Webhook server shutting down");
})
.await
{
tracing::error!("Webhook server error: {}", e);
}
});
self.handle = Some(handle);
Ok(())
}
/// Signal graceful shutdown and wait for the server task to finish.
pub async fn shutdown(&mut self) {
if let Some(tx) = self.shutdown_tx.take() {
let _ = tx.send(());
}
if let Some(handle) = self.handle.take() {
let _ = handle.await;
}
}
}
-4
View File
@@ -41,10 +41,6 @@ pub struct Cli {
#[arg(long, global = true)]
pub no_db: bool,
/// Simple REPL mode without TUI (for testing)
#[arg(long, global = true)]
pub repl: bool,
/// Single message mode - send one message and exit
#[arg(short, long, global = true)]
pub message: Option<String>,
+65 -128
View File
@@ -8,10 +8,11 @@ use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitEx
use ironclaw::{
agent::{Agent, AgentDeps, SessionManager},
channels::{
AppEvent, ChannelManager, GatewayChannel, HttpChannel, ReplChannel, TuiChannel,
ChannelManager, GatewayChannel, HttpChannel, ReplChannel, WebhookServer,
WebhookServerConfig,
wasm::{
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter,
WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer,
WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router,
},
web::log_layer::{LogBroadcaster, WebLogLayer},
},
@@ -39,7 +40,7 @@ use ironclaw::{
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Handle non-agent commands first (they don't need TUI/full setup)
// Handle non-agent commands first (they don't need full setup)
match &cli.command {
Some(Command::Tool(tool_cmd)) => {
// Simple logging for CLI commands
@@ -177,8 +178,7 @@ async fn main() -> anyhow::Result<()> {
Err(e) => return Err(e.into()),
};
// Initialize session manager and authenticate BEFORE TUI setup
// This allows the auth menu to display cleanly without TUI interference
// Initialize session manager and authenticate before channel setup
let session_config = SessionConfig {
auth_base_url: config.llm.nearai.auth_base_url.clone(),
session_path: config.llm.nearai.session_path.clone(),
@@ -187,10 +187,9 @@ async fn main() -> anyhow::Result<()> {
let session = create_session_manager(session_config).await;
// Ensure we're authenticated before proceeding (may trigger login flow)
// This happens before TUI so the menu displays correctly
session.ensure_authenticated().await?;
// Initialize tracing and channels based on mode
// Initialize tracing
let env_filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("ironclaw=info,tower_http=debug"));
@@ -198,53 +197,19 @@ async fn main() -> anyhow::Result<()> {
// This gets wired to the gateway's /api/logs/events SSE endpoint later.
let log_broadcaster = Arc::new(LogBroadcaster::new());
// Determine which mode to use: REPL, single message, or TUI
let use_repl = cli.repl || cli.message.is_some();
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_target(false))
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
.init();
// Create appropriate channel based on mode
let (tui_channel, tui_event_sender, repl_channel) = if use_repl {
// REPL mode - use simple stdin/stdout
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_target(false))
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
.init();
let repl = if let Some(ref msg) = cli.message {
ReplChannel::with_message(msg.clone())
} else {
ReplChannel::new()
};
(None, None, Some(repl))
// Create CLI channel
let repl_channel = if let Some(ref msg) = cli.message {
Some(ReplChannel::with_message(msg.clone()))
} else if config.channels.cli.enabled {
// TUI mode
let channel = TuiChannel::new();
let log_writer = channel.log_writer();
let event_sender = channel.event_sender();
tracing_subscriber::registry()
.with(env_filter)
.with(
tracing_subscriber::fmt::layer()
.with_writer(log_writer)
.without_time()
.with_target(false)
.with_level(true),
)
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
.init();
(Some(channel), Some(event_sender), None)
Some(ReplChannel::new())
} else {
// No CLI - just logging
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_target(false))
.with(WebLogLayer::new(Arc::clone(&log_broadcaster)))
.init();
(None, None, None)
None
};
tracing::info!("Starting IronClaw...");
@@ -266,34 +231,6 @@ async fn main() -> anyhow::Result<()> {
let llm = create_llm_provider(&config.llm, session.clone())?;
tracing::info!("LLM provider initialized: {}", llm.model_name());
// Fetch available models and send to TUI (async, non-blocking)
if let Some(ref event_tx) = tui_event_sender {
let llm_for_models = llm.clone();
let event_tx = event_tx.clone();
tokio::spawn(async move {
match llm_for_models.list_models().await {
Ok(models) if !models.is_empty() => {
let _ = event_tx.send(AppEvent::AvailableModels(models)).await;
}
Ok(_) => {
let _ = event_tx
.send(AppEvent::ErrorMessage(
"No models available from API".into(),
))
.await;
}
Err(e) => {
let _ = event_tx
.send(AppEvent::ErrorMessage(format!(
"Failed to fetch models: {}",
e
)))
.await;
}
}
});
}
// Initialize safety layer
let safety = Arc::new(SafetyLayer::new(&config.safety));
tracing::info!("Safety layer initialized");
@@ -536,7 +473,6 @@ async fn main() -> anyhow::Result<()> {
// Initialize channel manager
let mut channels = ChannelManager::new();
// Add REPL channel if in REPL mode
if let Some(repl) = repl_channel {
channels.add(Box::new(repl));
if cli.message.is_some() {
@@ -545,25 +481,11 @@ async fn main() -> anyhow::Result<()> {
tracing::info!("REPL mode enabled");
}
}
// Add TUI channel if CLI is enabled (already created for logging hookup)
else if let Some(tui) = tui_channel {
channels.add(Box::new(tui));
tracing::info!("TUI channel enabled");
}
// Add HTTP channel if configured and not CLI-only mode
if !cli.cli_only && !use_repl {
if let Some(ref http_config) = config.channels.http {
channels.add(Box::new(HttpChannel::new(http_config.clone())));
tracing::info!(
"HTTP channel enabled on {}:{}",
http_config.host,
http_config.port
);
}
}
// Collect webhook route fragments; a single WebhookServer hosts them all.
let mut webhook_routes: Vec<axum::Router> = Vec::new();
// Load WASM channels if enabled
// Load WASM channels and register their webhook routes.
if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() {
match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) {
Ok(runtime) => {
@@ -575,7 +497,6 @@ async fn main() -> anyhow::Result<()> {
.await
{
Ok(results) => {
// Create router for WASM channel webhooks
let wasm_router = Arc::new(WasmChannelRouter::new());
let mut has_webhook_channels = false;
@@ -583,10 +504,8 @@ async fn main() -> anyhow::Result<()> {
let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name);
// Get webhook secret name from capabilities (generic)
let secret_name = loaded.webhook_secret_name();
// Get webhook secret for this channel from secrets store
let webhook_secret = if let Some(ref secrets) = secrets_store {
secrets
.get_decrypted("default", &secret_name)
@@ -597,12 +516,9 @@ async fn main() -> anyhow::Result<()> {
None
};
// Get the secret header name from capabilities
let secret_header =
loaded.webhook_secret_header().map(|s| s.to_string());
// Register channel with router for webhook handling
// Use known webhook path based on channel name
let webhook_path = format!("/webhook/{}", channel_name);
let endpoints = vec![RegisteredEndpoint {
channel_name: channel_name.clone(),
@@ -613,8 +529,6 @@ async fn main() -> anyhow::Result<()> {
let channel_arc = Arc::new(loaded.channel);
// Inject runtime config into the channel (tunnel_url, webhook_secret)
// This must be done before start() is called
{
let mut config_updates = std::collections::HashMap::new();
@@ -660,7 +574,6 @@ async fn main() -> anyhow::Result<()> {
.await;
has_webhook_channels = true;
// Inject credentials for this channel (generic pattern-based injection)
if let Some(ref secrets) = secrets_store {
match inject_channel_credentials(
&channel_arc,
@@ -688,32 +601,14 @@ async fn main() -> anyhow::Result<()> {
}
}
// Wrap in SharedWasmChannel for ChannelManager
// Both the router and ChannelManager share the same underlying channel
channels.add(Box::new(SharedWasmChannel::new(channel_arc)));
}
// Start WASM channel webhook server if we have channels with webhooks
if has_webhook_channels && config.tunnel.public_url.is_some() {
let mut server = WasmChannelServer::new(wasm_router);
if let Some(ref ext_mgr) = extension_manager {
server = server.with_extension_manager(Arc::clone(ext_mgr));
}
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8080));
match server.start(addr).await {
Ok(_handle) => {
tracing::info!(
"WASM channel webhook server started on {}",
addr
);
}
Err(e) => {
tracing::error!(
"Failed to start WASM channel webhook server: {}",
e
);
}
}
webhook_routes.push(create_wasm_channel_router(
wasm_router,
extension_manager.as_ref().map(Arc::clone),
));
}
for (path, err) in &results.errors {
@@ -735,6 +630,43 @@ async fn main() -> anyhow::Result<()> {
}
}
// Add HTTP channel if configured and not CLI-only mode.
// Extract its routes for the unified server; the channel itself just
// provides the mpsc stream.
let mut webhook_server_addr: Option<std::net::SocketAddr> = None;
if !cli.cli_only {
if let Some(ref http_config) = config.channels.http {
let http_channel = HttpChannel::new(http_config.clone());
webhook_routes.push(http_channel.routes());
let (host, port) = http_channel.addr();
webhook_server_addr = Some(
format!("{}:{}", host, port)
.parse()
.expect("HttpConfig host:port must be a valid SocketAddr"),
);
channels.add(Box::new(http_channel));
tracing::info!(
"HTTP channel enabled on {}:{}",
http_config.host,
http_config.port
);
}
}
// Start the unified webhook server if any routes were registered.
let mut webhook_server = if !webhook_routes.is_empty() {
let addr =
webhook_server_addr.unwrap_or_else(|| std::net::SocketAddr::from(([0, 0, 0, 0], 8080)));
let mut server = WebhookServer::new(WebhookServerConfig { addr });
for routes in webhook_routes {
server.add_routes(routes);
}
server.start().await?;
Some(server)
} else {
None
};
// Create workspace for agent (shared with memory tools)
let workspace = store.as_ref().map(|s| {
let mut ws = Workspace::new("default", s.pool());
@@ -811,6 +743,11 @@ async fn main() -> anyhow::Result<()> {
// Run the agent (blocks until shutdown)
agent.run().await?;
// Shut down the webhook server if one was started
if let Some(ref mut server) = webhook_server {
server.shutdown().await;
}
tracing::info!("Agent shutdown complete");
Ok(())
}
+109
View File
@@ -12,6 +12,34 @@ use tokio::fs;
use crate::context::JobContext;
use crate::tools::tool::{Tool, ToolError, ToolOutput};
use crate::workspace::paths as ws_paths;
/// Well-known workspace filenames that must go through memory_write, not write_file.
///
/// If the LLM tries to write one of these via the filesystem tool we reject
/// immediately and point it at the correct tool.
const WORKSPACE_FILES: &[&str] = &[
ws_paths::HEARTBEAT,
ws_paths::MEMORY,
ws_paths::IDENTITY,
ws_paths::SOUL,
ws_paths::AGENTS,
ws_paths::USER,
ws_paths::README,
];
/// Check whether `path` resolves to a workspace file that should be written
/// through `memory_write` instead of `write_file`.
fn is_workspace_path(path: &str) -> bool {
let filename = std::path::Path::new(path)
.file_name()
.and_then(|f| f.to_str())
.unwrap_or(path);
WORKSPACE_FILES.iter().any(|ws| *ws == filename)
|| path.starts_with("daily/")
|| path.starts_with("context/")
}
/// Maximum file size for reading (1MB).
const MAX_READ_SIZE: u64 = 1024 * 1024;
@@ -276,6 +304,15 @@ impl Tool for WriteFileTool {
.and_then(|v| v.as_str())
.ok_or_else(|| ToolError::InvalidParameters("missing 'path' parameter".into()))?;
// Reject workspace paths: these live in the database, not on disk.
if is_workspace_path(path_str) {
return Err(ToolError::InvalidParameters(format!(
"'{}' is a workspace memory file. Use the memory_write tool instead of write_file. \
For HEARTBEAT.md use target='heartbeat', for MEMORY.md use target='memory'.",
path_str
)));
}
let content = params
.get("content")
.and_then(|v| v.as_str())
@@ -726,6 +763,78 @@ mod tests {
assert!(content.contains("println!(\"new\")"));
}
#[tokio::test]
async fn test_write_file_rejects_workspace_paths() {
let dir = TempDir::new().unwrap();
let tool = WriteFileTool::new().with_base_dir(dir.path().to_path_buf());
let ctx = JobContext::default();
let workspace_files = &[
"HEARTBEAT.md",
"MEMORY.md",
"IDENTITY.md",
"SOUL.md",
"AGENTS.md",
"USER.md",
"README.md",
];
for filename in workspace_files {
let path = dir.path().join(filename);
let err = tool
.execute(
serde_json::json!({
"path": path.to_str().unwrap(),
"content": "test"
}),
&ctx,
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("memory_write"),
"Rejection for {} should mention memory_write, got: {}",
filename,
msg
);
}
// daily/ and context/ prefixes should also be rejected
for prefix_path in &["daily/2024-01-15.md", "context/vision.md"] {
let err = tool
.execute(
serde_json::json!({
"path": prefix_path,
"content": "test"
}),
&ctx,
)
.await
.unwrap_err();
assert!(
err.to_string().contains("memory_write"),
"Rejection for {} should mention memory_write",
prefix_path
);
}
// Regular files should still work
let regular_path = dir.path().join("normal.txt");
let result = tool
.execute(
serde_json::json!({
"path": regular_path.to_str().unwrap(),
"content": "fine"
}),
&ctx,
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_list_dir() {
let dir = TempDir::new().unwrap();
+20 -5
View File
@@ -133,10 +133,11 @@ impl Tool for MemoryWriteTool {
}
fn description(&self) -> &str {
"Write to persistent memory. Use for important facts, decisions, preferences, \
or lessons learned that should be remembered across sessions. Use 'memory' target \
for curated long-term facts, 'daily_log' for timestamped session notes, or \
provide a custom path for arbitrary file creation."
"Write to persistent memory (database-backed, NOT the local filesystem). \
Use for important facts, decisions, preferences, or lessons learned that should \
be remembered across sessions. Targets: 'memory' for curated long-term facts, \
'daily_log' for timestamped session notes, 'heartbeat' for the periodic \
checklist (HEARTBEAT.md), or provide a custom path for arbitrary file creation."
}
fn parameters_schema(&self) -> serde_json::Value {
@@ -149,7 +150,7 @@ impl Tool for MemoryWriteTool {
},
"target": {
"type": "string",
"description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, or a path like 'projects/alpha/notes.md'",
"description": "Where to write: 'memory' for MEMORY.md, 'daily_log' for today's log, 'heartbeat' for HEARTBEAT.md checklist, or a path like 'projects/alpha/notes.md'",
"default": "daily_log"
},
"append": {
@@ -214,6 +215,20 @@ impl Tool for MemoryWriteTool {
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
format!("daily/{}.md", chrono::Utc::now().format("%Y-%m-%d"))
}
"heartbeat" => {
if append {
self.workspace
.append(paths::HEARTBEAT, content)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
} else {
self.workspace
.write(paths::HEARTBEAT, content)
.await
.map_err(|e| ToolError::ExecutionFailed(format!("Write failed: {}", e)))?;
}
paths::HEARTBEAT.to_string()
}
path => {
if append {
self.workspace
+25 -1
View File
@@ -60,6 +60,23 @@ use uuid::Uuid;
use crate::error::WorkspaceError;
/// Default template seeded into HEARTBEAT.md on first access.
///
/// Intentionally comment-only so the heartbeat runner treats it as
/// "effectively empty" and skips the LLM call until the user adds
/// real tasks.
const HEARTBEAT_SEED: &str = "\
# Heartbeat Checklist
<!-- Keep this file empty to skip heartbeat API calls.
Add tasks below when you want the agent to check something periodically.
Example:
- [ ] Check for unread emails needing a reply
- [ ] Review today's calendar for upcoming meetings
- [ ] Check CI build status for main branch
-->";
/// Workspace provides database-backed memory storage for an agent.
///
/// Each workspace is scoped to a user (and optionally an agent).
@@ -246,10 +263,17 @@ impl Workspace {
}
/// Get the heartbeat checklist (HEARTBEAT.md).
///
/// Returns the DB-stored checklist if it exists, otherwise falls back
/// to the in-memory seed template. The seed is never written to the
/// database; the user creates the real file via `memory_write` when
/// they actually want periodic checks. The seed content is all HTML
/// comments, which the heartbeat runner treats as "effectively empty"
/// and skips the LLM call.
pub async fn heartbeat_checklist(&self) -> Result<Option<String>, WorkspaceError> {
match self.read(paths::HEARTBEAT).await {
Ok(doc) => Ok(Some(doc.content)),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(None),
Err(WorkspaceError::DocumentNotFound { .. }) => Ok(Some(HEARTBEAT_SEED.to_string())),
Err(e) => Err(e),
}
}
@@ -34,7 +34,7 @@ A standalone WASM component that provides Slack integration for IronClaw. This s
## Building
```bash
cd examples/wasm-tools/slack
cd tools-src/wasm-tools/slack
cargo component build --release
```