M0: scaffold Cargo workspace, core types, event bus, permission engine, storage actor

Sets up the Cargo workspace (harness-core, harness-tools, harness-providers, harness-mcp, harness-lsp, harness-app, harness-tui) and the ten architecture docs under docs/. harness-core gets its foundational types (session/message/part/model ids), an in-process event bus, a table-driven permission evaluate function, and a SQLite-backed storage actor with schema and roundtrip coverage. Every crate compiles empty and a bin stub prints its version.
This commit is contained in:
2026-07-08 16:28:42 +02:00
commit 420f00494f
43 changed files with 2963 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
/target
**/*.rs.bk
.DS_Store
*.sqlite
*.sqlite-*
Generated
+894
View File
@@ -0,0 +1,894 @@
# 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 = "aho-corasick"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
[[package]]
name = "async-trait"
version = "0.1.89"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bstr"
version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79"
dependencies = [
"memchr",
"serde_core",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.2.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
[[package]]
name = "futures"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-sink"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
[[package]]
name = "futures-task"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
[[package]]
name = "futures-util"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "globset"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3"
dependencies = [
"aho-corasick",
"bstr",
"log",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "harness-app"
version = "0.1.0"
dependencies = [
"harness-core",
"harness-lsp",
"harness-mcp",
"harness-providers",
"harness-tools",
]
[[package]]
name = "harness-core"
version = "0.1.0"
dependencies = [
"async-trait",
"futures",
"globset",
"rusqlite",
"schemars",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-util",
"tracing",
"ulid",
]
[[package]]
name = "harness-lsp"
version = "0.1.0"
dependencies = [
"harness-core",
]
[[package]]
name = "harness-mcp"
version = "0.1.0"
dependencies = [
"harness-core",
]
[[package]]
name = "harness-providers"
version = "0.1.0"
dependencies = [
"harness-core",
]
[[package]]
name = "harness-tools"
version = "0.1.0"
dependencies = [
"harness-core",
]
[[package]]
name = "harness-tui"
version = "0.1.0"
dependencies = [
"harness-app",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.103"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mio"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "schemars"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"schemars_derive",
"serde",
"serde_json",
]
[[package]]
name = "schemars_derive"
version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[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_derive_internals"
version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.150"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.52.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
dependencies = [
"bytes",
"libc",
"mio",
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "ulid"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe"
dependencies = [
"rand",
"serde",
"web-time",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.126"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "zerocopy"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
+65
View File
@@ -0,0 +1,65 @@
[workspace]
resolver = "2"
members = [
"crates/harness-core",
"crates/harness-providers",
"crates/harness-tools",
"crates/harness-mcp",
"crates/harness-lsp",
"crates/harness-app",
"crates/harness-tui",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
repository = "https://github.com/esimon/ai-harness"
[workspace.dependencies]
harness-core = { path = "crates/harness-core" }
harness-providers = { path = "crates/harness-providers" }
harness-tools = { path = "crates/harness-tools" }
harness-mcp = { path = "crates/harness-mcp" }
harness-lsp = { path = "crates/harness-lsp" }
harness-app = { path = "crates/harness-app" }
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
futures = "0.3"
async-stream = "0.3"
async-trait = "0.1"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json", "gzip"] }
eventsource-stream = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
schemars = "0.8"
serde_yaml_ng = "0.10"
jsonc-parser = { version = "0.26", features = ["serde"] }
rusqlite = { version = "0.32", features = ["bundled"] }
ratatui = "0.29"
crossterm = { version = "0.28", features = ["event-stream"] }
tui-textarea = "0.7"
pulldown-cmark = "0.12"
rmcp = "0.1"
lsp-types = "0.97"
ignore = "0.4"
globset = "0.4"
grep-searcher = "0.1"
grep-regex = "0.1"
grep-matcher = "0.1"
similar = "2"
ulid = { version = "1", features = ["serde"] }
thiserror = "2"
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
dirs = "5"
shell-words = "1"
[workspace.lints.rust]
unsafe_code = "deny"
[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
+8
View File
@@ -0,0 +1,8 @@
# ai-harness
A terminal AI coding harness in Rust — ratatui TUI, GitHub Copilot / OpenAI / Anthropic providers, and fully config-driven multi-agent orchestration inspired by [opencode](https://github.com/sst/opencode) and [oh-my-opencode-slim](https://github.com/alfianlensundev/oh-my-opencode-slim).
**Status:** design phase. The full design lives in [`docs/`](docs/00-overview.md).
- [Overview & document map](docs/00-overview.md)
- [Milestone plan](docs/10-milestones.md)
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "harness-app"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
harness-providers = { workspace = true }
harness-tools = { workspace = true }
harness-mcp = { workspace = true }
harness-lsp = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// Composition root: builds the Engine and registers providers/tools/mcp/lsp. Lands in M1+.
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "harness-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
tokio = { workspace = true }
tokio-util = { workspace = true }
futures = { workspace = true }
async-trait = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
schemars = { workspace = true }
rusqlite = { workspace = true }
globset = { workspace = true }
ulid = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tokio = { workspace = true, features = ["test-util", "macros"] }
[lints]
workspace = true
+151
View File
@@ -0,0 +1,151 @@
use serde::Serialize;
use tokio::sync::broadcast;
use crate::types::{Message, Part, PartId, Session, SessionId};
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RunOutcome {
Stopped,
Aborted,
Errored { message: String },
}
#[derive(Debug, Clone, Copy, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Level {
Info,
Warn,
Error,
}
#[derive(Debug, Clone, Serialize)]
pub struct PermissionRequest {
pub id: String,
pub session_id: SessionId,
pub permission: String,
pub patterns: Vec<String>,
pub metadata: serde_json::Value,
}
#[derive(Debug, Clone, Serialize)]
pub struct JobRecordEvent(pub serde_json::Value);
/// Serializable so a future HTTP server can pipe this straight to SSE.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AppEvent {
SessionCreated {
session: Session,
},
SessionUpdated {
session: Session,
},
MessageCreated {
message: Message,
},
MessageUpdated {
message: Message,
},
PartUpdated {
part: Part,
},
PartDelta {
part_id: PartId,
message_id: crate::types::MessageId,
delta: String,
},
RunStarted {
session_id: SessionId,
},
RunFinished {
session_id: SessionId,
outcome: RunOutcome,
},
PermissionAsked {
request: PermissionRequest,
},
PermissionResolved {
id: String,
},
JobUpdated {
job: JobRecordEvent,
},
AuthPrompt {
provider: String,
user_code: String,
verification_uri: String,
},
ServerNotice {
level: Level,
text: String,
},
}
const EVENT_BUS_CAPACITY: usize = 1024;
/// Thin wrapper over `broadcast`; subscribers that lag just miss old events (`RecvError::Lagged`).
#[derive(Clone)]
pub struct EventBus(broadcast::Sender<AppEvent>);
impl EventBus {
pub fn new() -> Self {
let (tx, _rx) = broadcast::channel(EVENT_BUS_CAPACITY);
Self(tx)
}
pub fn publish(&self, event: AppEvent) {
// No subscribers is a normal state (e.g. headless `harness run`); ignore the error.
let _ = self.0.send(event);
}
pub fn subscribe(&self) -> broadcast::Receiver<AppEvent> {
self.0.subscribe()
}
}
impl Default for EventBus {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn publish_reaches_subscriber() {
let bus = EventBus::new();
let mut rx = bus.subscribe();
bus.publish(AppEvent::RunStarted {
session_id: SessionId::new(),
});
let event = rx.recv().await.unwrap();
assert!(matches!(event, AppEvent::RunStarted { .. }));
}
#[tokio::test]
async fn publish_with_no_subscribers_does_not_panic() {
let bus = EventBus::new();
bus.publish(AppEvent::RunStarted {
session_id: SessionId::new(),
});
}
#[tokio::test]
async fn multiple_subscribers_each_get_the_event() {
let bus = EventBus::new();
let mut rx1 = bus.subscribe();
let mut rx2 = bus.subscribe();
bus.publish(AppEvent::PermissionResolved { id: "abc".into() });
assert!(matches!(
rx1.recv().await.unwrap(),
AppEvent::PermissionResolved { .. }
));
assert!(matches!(
rx2.recv().await.unwrap(),
AppEvent::PermissionResolved { .. }
));
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod event;
pub mod llm;
pub mod permission;
pub mod store;
pub mod types;
pub use event::{AppEvent, EventBus};
+12
View File
@@ -0,0 +1,12 @@
use serde::{Deserialize, Serialize};
// Full LlmEvent/LlmRequest/Provider trait land in M1 alongside harness-providers.
// FinishReason lives here (not types/) because it's part of the provider-facing vocabulary.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum FinishReason {
Stop,
ToolCalls,
Length,
ContentFilter,
Unknown(String),
}
@@ -0,0 +1,3 @@
pub mod rule;
pub use rule::{evaluate, Action, Rule, Ruleset};
+115
View File
@@ -0,0 +1,115 @@
use globset::GlobBuilder;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Action {
Allow,
Deny,
Ask,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub permission: String,
pub pattern: String,
pub action: Action,
}
pub type Ruleset = Vec<Rule>;
/// Wildcard match, `*` crosses separators (opencode/globset `literal_separator(false)`).
fn glob_match(pattern: &str, value: &str) -> bool {
if pattern == "*" {
return true;
}
GlobBuilder::new(pattern)
.literal_separator(false)
.build()
.map(|g| g.compile_matcher().is_match(value))
.unwrap_or(false)
}
/// Flatten `stack` (lowest to highest precedence) and return the action of the *last*
/// rule whose `permission` and `pattern` both wildcard-match. Defaults to `Ask`.
pub fn evaluate(stack: &[&Ruleset], permission: &str, pattern: &str) -> Action {
let mut result = Action::Ask;
for ruleset in stack {
for rule in ruleset.iter() {
if glob_match(&rule.permission, permission) && glob_match(&rule.pattern, pattern) {
result = rule.action;
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(permission: &str, pattern: &str, action: Action) -> Rule {
Rule {
permission: permission.to_string(),
pattern: pattern.to_string(),
action,
}
}
#[test]
fn defaults_to_ask_when_nothing_matches() {
let rules: Ruleset = vec![rule("bash", "ls*", Action::Allow)];
assert_eq!(evaluate(&[&rules], "bash", "rm -rf /"), Action::Ask);
}
#[test]
fn exact_match_wins() {
let rules: Ruleset = vec![rule("edit", "*.lock", Action::Deny)];
assert_eq!(evaluate(&[&rules], "edit", "Cargo.lock"), Action::Deny);
assert_eq!(evaluate(&[&rules], "edit", "main.rs"), Action::Ask);
}
#[test]
fn wildcard_crosses_path_separators() {
let rules: Ruleset = vec![rule("read", "src/*", Action::Allow)];
assert_eq!(evaluate(&[&rules], "read", "src/a/b/c.rs"), Action::Allow);
}
#[test]
fn last_match_within_a_ruleset_wins() {
let rules: Ruleset = vec![
rule("bash", "git *", Action::Allow),
rule("bash", "git push*", Action::Deny),
];
assert_eq!(evaluate(&[&rules], "bash", "git status"), Action::Allow);
assert_eq!(evaluate(&[&rules], "bash", "git push origin"), Action::Deny);
}
#[test]
fn later_ruleset_in_the_stack_overrides_earlier_ones() {
let config: Ruleset = vec![rule("edit", "*", Action::Ask)];
let agent: Ruleset = vec![rule("edit", "*", Action::Deny)];
let session: Ruleset = vec![rule("edit", "main.rs", Action::Allow)];
// config < agent < session precedence, matching engine's [config, agent, session] stack.
assert_eq!(
evaluate(&[&config, &agent, &session], "edit", "main.rs"),
Action::Allow
);
assert_eq!(
evaluate(&[&config, &agent, &session], "edit", "other.rs"),
Action::Deny
);
}
#[test]
fn permission_field_itself_can_wildcard_match() {
let rules: Ruleset = vec![rule("mcp", "*", Action::Ask)];
assert_eq!(evaluate(&[&rules], "mcp", "context7_search"), Action::Ask);
}
#[test]
fn empty_stack_defaults_to_ask() {
assert_eq!(evaluate(&[], "bash", "ls"), Action::Ask);
}
}
+130
View File
@@ -0,0 +1,130 @@
use rusqlite::{params, Connection};
use tokio::sync::oneshot;
use crate::types::{Message, MessageId, Part, Session, SessionId};
use super::api::StoreError;
const SCHEMA: &str = include_str!("schema.sql");
type Reply<T> = oneshot::Sender<Result<T, StoreError>>;
pub enum StoreCmd {
UpsertSession(Session, Reply<()>),
UpsertMessage(Message, Reply<()>),
UpsertPart(Part, Reply<()>),
Sessions(Reply<Vec<Session>>),
Messages(SessionId, Reply<Vec<Message>>),
Parts(MessageId, Reply<Vec<Part>>),
}
fn init_schema(conn: &Connection) -> rusqlite::Result<()> {
conn.execute_batch(SCHEMA)
}
fn upsert_session(conn: &Connection, session: &Session) -> Result<(), StoreError> {
let data = serde_json::to_string(session)?;
conn.execute(
"INSERT INTO session (id, parent_id, created_at, updated_at, data) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET parent_id = ?2, updated_at = ?4, data = ?5",
params![
session.id.as_ref(),
session.parent_id.as_ref().map(|p| p.as_ref().to_string()),
session.created_at,
session.updated_at,
data
],
)?;
Ok(())
}
fn upsert_message(conn: &Connection, message: &Message) -> Result<(), StoreError> {
let data = serde_json::to_string(message)?;
conn.execute(
"INSERT INTO message (id, session_id, created_at, data) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(id) DO UPDATE SET data = ?4",
params![
message.id.as_ref(),
message.session_id.as_ref(),
message.created_at,
data
],
)?;
Ok(())
}
fn upsert_part(conn: &Connection, part: &Part) -> Result<(), StoreError> {
let data = serde_json::to_string(part)?;
conn.execute(
"INSERT INTO part (id, message_id, session_id, idx, data) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET data = ?5",
params![
part.id.as_ref(),
part.message_id.as_ref(),
part.session_id.as_ref(),
part.idx,
data
],
)?;
Ok(())
}
fn list_sessions(conn: &Connection) -> Result<Vec<Session>, StoreError> {
let mut stmt = conn.prepare("SELECT data FROM session ORDER BY id")?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
rows.iter()
.map(|data| serde_json::from_str(data).map_err(StoreError::from))
.collect()
}
fn list_messages(conn: &Connection, session_id: &SessionId) -> Result<Vec<Message>, StoreError> {
let mut stmt = conn.prepare("SELECT data FROM message WHERE session_id = ?1 ORDER BY id")?;
let rows = stmt
.query_map(params![session_id.as_ref()], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
rows.iter()
.map(|data| serde_json::from_str(data).map_err(StoreError::from))
.collect()
}
fn list_parts(conn: &Connection, message_id: &MessageId) -> Result<Vec<Part>, StoreError> {
let mut stmt = conn.prepare("SELECT data FROM part WHERE message_id = ?1 ORDER BY idx")?;
let rows = stmt
.query_map(params![message_id.as_ref()], |row| row.get::<_, String>(0))?
.collect::<Result<Vec<_>, _>>()?;
rows.iter()
.map(|data| serde_json::from_str(data).map_err(StoreError::from))
.collect()
}
/// Runs on a dedicated OS thread; the async facade in `api.rs` talks to it over `mpsc`.
pub fn run(conn: Connection, mut rx: tokio::sync::mpsc::Receiver<StoreCmd>) {
if let Err(e) = init_schema(&conn) {
tracing::error!("store: failed to initialize schema: {e}");
return;
}
while let Some(cmd) = rx.blocking_recv() {
match cmd {
StoreCmd::UpsertSession(session, reply) => {
let _ = reply.send(upsert_session(&conn, &session));
}
StoreCmd::UpsertMessage(message, reply) => {
let _ = reply.send(upsert_message(&conn, &message));
}
StoreCmd::UpsertPart(part, reply) => {
let _ = reply.send(upsert_part(&conn, &part));
}
StoreCmd::Sessions(reply) => {
let _ = reply.send(list_sessions(&conn));
}
StoreCmd::Messages(session_id, reply) => {
let _ = reply.send(list_messages(&conn, &session_id));
}
StoreCmd::Parts(message_id, reply) => {
let _ = reply.send(list_parts(&conn, &message_id));
}
}
}
}
+216
View File
@@ -0,0 +1,216 @@
use std::path::Path;
use rusqlite::Connection;
use tokio::sync::{mpsc, oneshot};
use crate::types::{Message, MessageId, Part, Session, SessionId};
use super::actor::{self, StoreCmd};
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("sqlite: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("serde: {0}")]
Serde(#[from] serde_json::Error),
#[error("store actor is not running")]
ActorGone,
}
const COMMAND_CHANNEL_CAPACITY: usize = 256;
/// Typed async facade over the storage actor thread (single writer, embedded db).
#[derive(Clone)]
pub struct Store {
tx: mpsc::Sender<StoreCmd>,
}
impl Store {
fn spawn(conn: Connection) -> Self {
let (tx, rx) = mpsc::channel(COMMAND_CHANNEL_CAPACITY);
std::thread::spawn(move || actor::run(conn, rx));
Self { tx }
}
pub fn open(path: &Path) -> Result<Self, StoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
StoreError::Sqlite(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CANTOPEN),
Some(e.to_string()),
))
})?;
}
let conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
Ok(Self::spawn(conn))
}
pub fn open_in_memory() -> Result<Self, StoreError> {
let conn = Connection::open_in_memory()?;
Ok(Self::spawn(conn))
}
async fn call<T: Send + 'static>(
&self,
make_cmd: impl FnOnce(oneshot::Sender<Result<T, StoreError>>) -> StoreCmd,
) -> Result<T, StoreError> {
let (reply_tx, reply_rx) = oneshot::channel();
self.tx
.send(make_cmd(reply_tx))
.await
.map_err(|_| StoreError::ActorGone)?;
reply_rx.await.map_err(|_| StoreError::ActorGone)?
}
pub async fn upsert_session(&self, session: Session) -> Result<(), StoreError> {
self.call(|reply| StoreCmd::UpsertSession(session, reply))
.await
}
pub async fn upsert_message(&self, message: Message) -> Result<(), StoreError> {
self.call(|reply| StoreCmd::UpsertMessage(message, reply))
.await
}
pub async fn upsert_part(&self, part: Part) -> Result<(), StoreError> {
self.call(|reply| StoreCmd::UpsertPart(part, reply)).await
}
pub async fn sessions(&self) -> Result<Vec<Session>, StoreError> {
self.call(StoreCmd::Sessions).await
}
pub async fn messages(&self, session_id: SessionId) -> Result<Vec<Message>, StoreError> {
self.call(|reply| StoreCmd::Messages(session_id, reply))
.await
}
pub async fn parts(&self, message_id: MessageId) -> Result<Vec<Part>, StoreError> {
self.call(|reply| StoreCmd::Parts(message_id, reply)).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ModelRef, PartBody, Role};
#[tokio::test]
async fn session_roundtrips() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let id = session.id.clone();
store.upsert_session(session.clone()).await.unwrap();
let sessions = store.sessions().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].id, id);
assert_eq!(sessions[0].agent, "orchestrator");
}
#[tokio::test]
async fn session_upsert_overwrites_by_id() {
let store = Store::open_in_memory().unwrap();
let mut session =
Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
store.upsert_session(session.clone()).await.unwrap();
session.title = "renamed".into();
session.updated_at = 2;
store.upsert_session(session.clone()).await.unwrap();
let sessions = store.sessions().await.unwrap();
assert_eq!(sessions.len(), 1);
assert_eq!(sessions[0].title, "renamed");
}
#[tokio::test]
async fn message_roundtrips_and_filters_by_session() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let other_session = Session::new_root("explorer", ModelRef::new("anthropic", "claude"), 1);
let msg1 = Message::new_user(session.id.clone(), 1);
let msg2 = Message::new_user(other_session.id.clone(), 2);
store.upsert_message(msg1.clone()).await.unwrap();
store.upsert_message(msg2).await.unwrap();
let messages = store.messages(session.id.clone()).await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].id, msg1.id);
assert_eq!(messages[0].role, Role::User);
}
#[tokio::test]
async fn part_roundtrips_and_orders_by_idx() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let message = Message::new_user(session.id.clone(), 1);
let part_b = Part {
id: crate::types::PartId::new(),
message_id: message.id.clone(),
session_id: session.id.clone(),
idx: 1,
body: PartBody::Text {
text: "second".into(),
synthetic: false,
},
};
let part_a = Part {
id: crate::types::PartId::new(),
message_id: message.id.clone(),
session_id: session.id.clone(),
idx: 0,
body: PartBody::Text {
text: "first".into(),
synthetic: false,
},
};
store.upsert_part(part_b).await.unwrap();
store.upsert_part(part_a).await.unwrap();
let parts = store.parts(message.id.clone()).await.unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0].idx, 0);
assert_eq!(parts[1].idx, 1);
}
#[tokio::test]
async fn tool_state_variants_roundtrip_through_json() {
let store = Store::open_in_memory().unwrap();
let session = Session::new_root("orchestrator", ModelRef::new("anthropic", "claude"), 1);
let message = Message::new_user(session.id.clone(), 1);
let part = Part {
id: crate::types::PartId::new(),
message_id: message.id.clone(),
session_id: session.id.clone(),
idx: 0,
body: PartBody::Tool {
call_id: "call_1".into(),
name: "bash".into(),
state: crate::types::ToolState::Completed {
input: serde_json::json!({"command": "ls"}),
title: "ls".into(),
output: "a.txt".into(),
metadata: serde_json::json!({}),
duration_ms: 12,
},
},
};
let part_id = part.id.clone();
store.upsert_part(part).await.unwrap();
let parts = store.parts(message.id.clone()).await.unwrap();
assert_eq!(parts.len(), 1);
assert_eq!(parts[0].id, part_id);
match &parts[0].body {
PartBody::Tool { name, state, .. } => {
assert_eq!(name, "bash");
assert!(matches!(state, crate::types::ToolState::Completed { .. }));
}
_ => panic!("expected Tool part"),
}
}
}
+4
View File
@@ -0,0 +1,4 @@
mod actor;
mod api;
pub use api::{Store, StoreError};
+37
View File
@@ -0,0 +1,37 @@
CREATE TABLE IF NOT EXISTS session (
id TEXT PRIMARY KEY,
parent_id TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS message (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS part (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
session_id TEXT NOT NULL,
idx INTEGER NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job (
task_id TEXT PRIMARY KEY,
parent_session_id TEXT NOT NULL,
data TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE INDEX IF NOT EXISTS message_session ON message(session_id);
CREATE INDEX IF NOT EXISTS part_message ON part(message_id);
CREATE INDEX IF NOT EXISTS session_parent ON session(parent_id);
+66
View File
@@ -0,0 +1,66 @@
use serde::{Deserialize, Serialize};
use std::fmt;
use ulid::Ulid;
macro_rules! id_newtype {
($name:ident, $prefix:literal) => {
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct $name(pub String);
impl $name {
pub fn new() -> Self {
Self(format!("{}_{}", $prefix, Ulid::new()))
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for $name {
fn from(s: String) -> Self {
Self(s)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
};
}
id_newtype!(SessionId, "ses");
id_newtype!(MessageId, "msg");
id_newtype!(PartId, "prt");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_prefixed_and_unique() {
let a = SessionId::new();
let b = SessionId::new();
assert!(a.0.starts_with("ses_"));
assert_ne!(a, b);
}
#[test]
fn ids_sort_chronologically() {
let a = MessageId::new();
std::thread::sleep(std::time::Duration::from_millis(2));
let b = MessageId::new();
assert!(a < b);
}
}
+69
View File
@@ -0,0 +1,69 @@
use serde::{Deserialize, Serialize};
use super::ids::{MessageId, SessionId};
use super::model::{ModelRef, TokenUsage};
use crate::llm::FinishReason;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Role {
User,
Assistant,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MessageError {
Aborted,
Provider(String),
OutputLength,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
pub id: MessageId,
pub session_id: SessionId,
pub role: Role,
pub model: Option<ModelRef>,
pub agent: Option<String>,
pub usage: TokenUsage,
pub cost: f64,
pub finished: Option<FinishReason>,
pub error: Option<MessageError>,
pub created_at: i64,
}
impl Message {
pub fn new_user(session_id: SessionId, now: i64) -> Self {
Self {
id: MessageId::new(),
session_id,
role: Role::User,
model: None,
agent: None,
usage: TokenUsage::default(),
cost: 0.0,
finished: None,
error: None,
created_at: now,
}
}
pub fn new_assistant(
session_id: SessionId,
model: ModelRef,
agent: impl Into<String>,
now: i64,
) -> Self {
Self {
id: MessageId::new(),
session_id,
role: Role::Assistant,
model: Some(model),
agent: Some(agent.into()),
usage: TokenUsage::default(),
cost: 0.0,
finished: None,
error: None,
created_at: now,
}
}
}
+11
View File
@@ -0,0 +1,11 @@
pub mod ids;
pub mod message;
pub mod model;
pub mod part;
pub mod session;
pub use ids::{MessageId, PartId, SessionId};
pub use message::{Message, MessageError, Role};
pub use model::{ModelInfo, ModelRef, TokenUsage};
pub use part::{Part, PartBody, ToolState};
pub use session::Session;
+44
View File
@@ -0,0 +1,44 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ModelRef {
pub provider_id: String,
pub model_id: String,
}
impl ModelRef {
pub fn new(provider_id: impl Into<String>, model_id: impl Into<String>) -> Self {
Self {
provider_id: provider_id.into(),
model_id: model_id.into(),
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenUsage {
pub input: u64,
pub output: u64,
pub reasoning: u64,
pub cache_read: u64,
pub cache_write: u64,
}
impl TokenUsage {
pub fn add(&mut self, other: &TokenUsage) {
self.input += other.input;
self.output += other.output;
self.reasoning += other.reasoning;
self.cache_read += other.cache_read;
self.cache_write += other.cache_write;
}
}
// Full cost/context-limit metadata is populated by harness-providers (models.dev) in M1/M3;
// this placeholder only carries what harness-core needs to key on.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelInfo {
pub model: ModelRef,
pub context_limit: u64,
pub output_limit: u64,
}
+72
View File
@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use super::ids::{MessageId, PartId, SessionId};
use super::model::TokenUsage;
use crate::llm::FinishReason;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Part {
pub id: PartId,
pub message_id: MessageId,
pub session_id: SessionId,
pub idx: u32,
pub body: PartBody,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum PartBody {
Text {
text: String,
synthetic: bool,
},
Reasoning {
text: String,
signature: Option<String>,
},
Tool {
call_id: String,
name: String,
state: ToolState,
},
StepStart,
StepFinish {
usage: TokenUsage,
cost: f64,
reason: FinishReason,
},
Subtask {
child_session: SessionId,
agent: String,
description: String,
background: bool,
},
Compaction {
replaces_up_to: MessageId,
summary: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ToolState {
Pending {
partial_input: String,
},
Running {
input: serde_json::Value,
title: Option<String>,
metadata: serde_json::Value,
},
Completed {
input: serde_json::Value,
title: String,
output: String,
metadata: serde_json::Value,
duration_ms: u64,
},
Error {
input: serde_json::Value,
error: String,
},
}
+54
View File
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
use super::ids::SessionId;
use super::model::{ModelRef, TokenUsage};
use crate::permission::rule::Ruleset;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
pub id: SessionId,
pub parent_id: Option<SessionId>,
pub depth: u8,
pub title: String,
pub agent: String,
pub model: ModelRef,
pub usage: TokenUsage,
pub cost: f64,
pub extra_rules: Ruleset,
pub created_at: i64,
pub updated_at: i64,
}
impl Session {
pub fn new_root(agent: impl Into<String>, model: ModelRef, now: i64) -> Self {
Self {
id: SessionId::new(),
parent_id: None,
depth: 0,
title: String::new(),
agent: agent.into(),
model,
usage: TokenUsage::default(),
cost: 0.0,
extra_rules: Vec::new(),
created_at: now,
updated_at: now,
}
}
pub fn new_child(parent: &Session, agent: impl Into<String>, now: i64) -> Self {
Self {
id: SessionId::new(),
parent_id: Some(parent.id.clone()),
depth: parent.depth + 1,
title: String::new(),
agent: agent.into(),
model: parent.model.clone(),
usage: TokenUsage::default(),
cost: 0.0,
extra_rules: Vec::new(),
created_at: now,
updated_at: now,
}
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-lsp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// LSP client pool + diagnostics service land here in M5.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// MCP stdio client → Tool adapters land here in M5.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-providers"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// Provider implementations (Copilot, OpenAI, Anthropic) land here in M1/M3.
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "harness-tools"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
harness-core = { workspace = true }
[lints]
workspace = true
+1
View File
@@ -0,0 +1 @@
// Built-in tools (bash, read, edit, write, glob, grep, todo, webfetch, task) land here in M1/M4.
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "harness-tui"
version.workspace = true
edition.workspace = true
license.workspace = true
[[bin]]
name = "harness"
path = "src/main.rs"
[dependencies]
harness-app = { workspace = true }
[lints]
workspace = true
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("harness {}", env!("CARGO_PKG_VERSION"));
}
+33
View File
@@ -0,0 +1,33 @@
# AI Coding Harness — Design Overview
A terminal AI coding harness written in Rust, modeled on [opencode](https://github.com/sst/opencode) (engine, tools, permissions) and [oh-my-opencode-slim](https://github.com/alfianlensundev/oh-my-opencode-slim) (multi-agent orchestration), with one deliberate departure: **agent behavior is defined entirely in config + markdown files**, not hardcoded prompts.
## Goals
- **Interface v1:** ratatui TUI. The core is a library with a serializable event stream, so a future HTTP server (SSE) + web client slots in without rearchitecting.
- **Providers:** GitHub Copilot (OAuth device flow), OpenAI (API key), Anthropic (API key). Hand-written HTTP clients — no SDK dependency — normalized to a single `LlmEvent` stream type.
- **Multiagent:** full orchestration in the oh-my-opencode-slim style — a `task` tool spawning subagent child sessions, background/parallel subagents, a job board injected into the orchestrator's context each turn, session reuse by alias, subagent depth limits. All agents (including the bundled defaults) are markdown definitions users can override, replace, or disable.
- **Integrations:** MCP client (stdio), LSP diagnostics after edits, markdown-defined slash commands and skills.
- **Permissions:** opencode-style rulesets `{permission, pattern, action: allow|deny|ask}` with last-match-wins wildcard evaluation and an async "ask" that blocks tool execution until the user replies in the TUI (once / always / reject).
## Document map
| Doc | Contents |
|---|---|
| [01-architecture.md](01-architecture.md) | Cargo workspace layout, crate boundaries, dependencies, domain model, event bus, channel topology |
| [02-engine.md](02-engine.md) | The agent loop (outer/inner), permission engine, retry, doom-loop guard, cancellation, compaction seam |
| [03-providers.md](03-providers.md) | Provider trait, wire codecs, Copilot/OpenAI/Anthropic specifics, auth flows and storage, cost accounting |
| [04-multiagent.md](04-multiagent.md) | Agent definitions and registry, task tool, background jobs, job board, session reuse, depth limits |
| [05-tools.md](05-tools.md) | Built-in tools, truncation, edit replacer strategy, permission keys |
| [06-config.md](06-config.md) | Config files, precedence, schema, agent/command/skill markdown loading |
| [07-tui.md](07-tui.md) | Screen layout, event loop, streaming markdown rendering, modals, keybinds |
| [08-storage.md](08-storage.md) | SQLite schema, storage actor, write cadence, session resume |
| [09-integrations.md](09-integrations.md) | MCP client, LSP diagnostics, commands and skills |
| [10-milestones.md](10-milestones.md) | Phased milestone plan, testing strategy, risks, reference files |
## Reference codebases
Findings in these documents come from direct exploration of:
- `~/repos/opencode` — TypeScript, Effect-based. Core loop in `packages/opencode/src/session/{prompt,processor}.ts`, tools in `packages/opencode/src/tool/`, permissions in `packages/opencode/src/permission/index.ts`, providers in `packages/opencode/src/plugin/{github-copilot,openai}/` and `packages/llm/`.
- `~/repos/oh-my-opencode-slim` — opencode plugin. Orchestration in `src/hooks/task-session-manager/`, `src/utils/background-job-board.ts`; agent prompts in `src/agents/*.ts`.
+221
View File
@@ -0,0 +1,221 @@
# 01 — Architecture
## Cargo workspace layout
```
ai-harness/
├── Cargo.toml # [workspace] members, workspace.dependencies, lints
├── crates/
│ ├── harness-core/ # domain types, event bus, permission engine, session engine,
│ │ # agent loop, agent registry, job board, config, storage, auth.json
│ ├── harness-providers/ # Provider impls: copilot, openai, anthropic; auth flows; models.dev
│ ├── harness-tools/ # built-in tools: bash, read, edit, write, glob, grep, todo, webfetch, task
│ ├── harness-mcp/ # rmcp stdio client → Tool adapters
│ ├── harness-lsp/ # LSP client pool + diagnostics service
│ ├── harness-app/ # composition root: builds Engine, registers providers/tools/mcp/lsp
│ └── harness-tui/ # binary `harness`: ratatui frontend over EngineHandle + event bus
└── assets/
├── agents/*.md # bundled default agent definitions (embedded via include_str!)
├── prompts/*.md # system prompt fragments (tool usage rules, environment header)
└── models-snapshot.json # compiled fallback of models.dev/api.json
```
### Boundary rationale
- **`harness-core` owns the vocabulary** (`Session`/`Message`/`Part`, `AppEvent`, `LlmEvent`, `Tool` trait, `Provider` trait, `Rule`/`Ruleset`) **and the engine** (run loop, stream processor, permission service, storage actor, job board). Everything depends inward on core; core depends on nothing else in the workspace. This is the crate a future `harness-server` wraps: `AppEvent` is `Serialize`, so an SSE endpoint is `bus.subscribe() → serde_json → SSE frames`, and `EngineHandle` is the future HTTP API surface (prompt, abort, permission reply, list sessions, login).
- **Feature crates never depend on each other.** Tools reach LSP through a core trait (`DiagnosticsSource`, see [09-integrations.md](09-integrations.md)), keeping `harness-tools` decoupled from `harness-lsp`.
- **`harness-app` is the only crate that knows about everything.** It reads config, constructs the `Engine`, registers built-ins. `harness-tui` today and `harness-server` tomorrow both depend on `harness-app` and get an identical engine.
- **`harness-tui` contains zero business logic** — it reads `AppEvent`s and calls `EngineHandle` methods. This enforces the "TUI is a client" discipline opencode gets from its HTTP split, without the HTTP.
Config and storage live inside `harness-core` (modules `config`, `store`) rather than separate crates — they're consumed only by core and app; splitting creates dependency churn for no benefit at this size.
### `harness-core` module map
```
src/
types/ # ids.rs, session.rs, message.rs, part.rs, model.rs (ModelRef, ModelInfo, TokenUsage)
event.rs # AppEvent + EventBus (broadcast wrapper)
llm.rs # LlmEvent, LlmRequest, FinishReason, ProviderError, Provider trait
tool.rs # Tool trait, ToolCtx, ToolOutput, ToolRegistry, truncation helper
permission/ # rule.rs (evaluate), service.rs (pending asks, oneshots)
agent/ # def.rs (AgentDef), registry.rs (bundled + override loading), frontmatter.rs
engine/ # engine.rs (Engine, EngineHandle, RunHandle), loop.rs (outer loop),
# processor.rs (stream state machine), retry.rs, doomloop.rs,
# compaction.rs (seam), system.rs (system prompt assembly), jobs.rs (JobBoard)
config/ # schema.rs (serde), load.rs (discovery + merge + interpolation), markdown.rs
store/ # schema.sql, actor.rs (rusqlite thread + mpsc), api.rs (typed async facade)
auth.rs # auth.json read/write, mode 0600
```
## Dependencies
| Crate | Why |
|---|---|
| `tokio` (full) | async runtime; `sync::{broadcast, mpsc, oneshot, Mutex}`, `process`, `select!` |
| `tokio-util` | `CancellationToken` (abort tree: session run → tool → subagent via `child_token()`) |
| `futures`, `async-stream` | `Stream` combinators; `try_stream!` to write SSE decoders as readable generators |
| `reqwest` (rustls-tls, stream, json, gzip) | all HTTP; `bytes_stream()` feeds SSE decoding |
| `eventsource-stream` | SSE frame parsing over any byte stream (CRLF, multi-line `data:`, comments); mature, tiny |
| `serde`, `serde_json` | everything; `RawValue` for pass-through tool schemas |
| `schemars` | derive JSON Schema for tool parameter structs (single source of truth for tool inputs) |
| `serde_yaml_ng` | YAML frontmatter of agent/command/skill markdown (`serde_yaml` proper is archived) |
| `jsonc-parser` | JSONC config files (comments + trailing commas, like opencode) |
| `rusqlite` (bundled) | storage; runs on a dedicated thread behind an mpsc actor — simpler than sqlx for a single-writer embedded DB |
| `ratatui` + `crossterm` (event-stream) | TUI; `crossterm::event::EventStream` integrates with `tokio::select!` |
| `tui-textarea` | multi-line prompt input widget (cursor, kill-ring) |
| `pulldown-cmark` | markdown parsing for the chat renderer (we write the ratatui renderer) |
| `rmcp` (client, transport-child-process) | official Rust MCP SDK, stdio client |
| `lsp-types` | LSP protocol types; JSON-RPC framing is ~150 lines hand-rolled (client crates are immature) |
| `ignore` + `globset` | glob tool (gitignore-aware walking) and permission wildcard matching |
| `grep-searcher`, `grep-regex`, `grep-matcher` | ripgrep's engine as a library — no external `rg` binary dependency |
| `similar` | unified diffs for edit/write permission metadata and part records |
| `ulid` | sortable ids for sessions/messages/parts (chronological `ORDER BY id`) |
| `thiserror` / `anyhow` | error enums in libs; `anyhow` only in binaries |
| `tracing` + `tracing-subscriber` + `tracing-appender` | logging to file (the TUI owns the terminal) |
| `dirs` | XDG paths (`~/.config/ai-harness`, `~/.local/share/ai-harness`, `~/.cache/ai-harness`) |
| `htmd` (or `html2text`) | webfetch HTML → markdown |
| `shell-words` | tokenize bash commands for permission pattern building |
| `insta` (dev) | snapshot tests for decoders and TUI TestBackend renders |
| `wiremock` (dev) | HTTP mocking for provider/auth tests |
Deliberately avoided: actor frameworks, generic middleware, `sqlx` (compile-time SQL is overkill for 5 tables). Native `async fn` in traits where object safety allows; `#[async_trait]` for `Tool`/`Provider` since `dyn` is needed.
## Domain model
```rust
// types/ids.rs — newtypes over ULID strings
pub struct SessionId(pub String); // "ses_01H..."
pub struct MessageId(pub String);
pub struct PartId(pub String);
// types/session.rs
pub struct Session {
pub id: SessionId,
pub parent_id: Option<SessionId>,
pub depth: u8, // 0 = root; computed at creation from parent
pub title: String,
pub agent: String,
pub model: ModelRef, // { provider_id, model_id }
pub usage: TokenUsage, // running totals
pub cost: f64,
pub extra_rules: Ruleset, // session-scoped "always allow" rules
pub created_at: i64, pub updated_at: i64,
}
pub struct TokenUsage { pub input: u64, pub output: u64, pub reasoning: u64,
pub cache_read: u64, pub cache_write: u64 }
// types/message.rs
pub enum Role { User, Assistant }
pub struct Message {
pub id: MessageId, pub session_id: SessionId, pub role: Role,
pub model: Option<ModelRef>, pub agent: Option<String>,
pub usage: TokenUsage, pub cost: f64,
pub finished: Option<FinishReason>, // None while streaming
pub error: Option<MessageError>, // aborted | provider error | output-length
pub created_at: i64,
}
// types/part.rs — discriminated union, serde(tag = "type")
pub struct Part { pub id: PartId, pub message_id: MessageId, pub session_id: SessionId,
pub idx: u32, pub body: PartBody }
pub enum PartBody {
Text { text: String, synthetic: bool }, // synthetic = injected (job board, reminders)
Reasoning { text: String, signature: Option<String> },
Tool { call_id: String, name: String, state: ToolState },
StepStart,
StepFinish{ usage: TokenUsage, cost: f64, reason: FinishReason },
Subtask { child_session: SessionId, agent: String, description: String, background: bool },
Compaction{ replaces_up_to: MessageId, summary: String }, // v1.5
}
pub enum ToolState {
Pending { partial_input: String }, // streaming JSON args
Running { input: serde_json::Value, title: Option<String>, metadata: serde_json::Value },
Completed { input: serde_json::Value, title: String, output: String,
metadata: serde_json::Value, duration_ms: u64 },
Error { input: serde_json::Value, error: String },
}
```
```rust
// llm.rs — normalized provider stream
pub enum LlmEvent {
TextStart { id: String }, TextDelta { id: String, text: String }, TextEnd { id: String },
ReasoningStart { id: String }, ReasoningDelta { id: String, text: String },
ReasoningEnd { id: String, signature: Option<String> },
ToolInputStart { call_id: String, name: String },
ToolInputDelta { call_id: String, json: String },
ToolCall { call_id: String, name: String, input: serde_json::Value }, // args complete
Finish { reason: FinishReason, usage: TokenUsage },
}
pub enum FinishReason { Stop, ToolCalls, Length, ContentFilter, Unknown(String) }
pub type LlmEventStream = Pin<Box<dyn Stream<Item = Result<LlmEvent, ProviderError>> + Send>>;
pub enum ProviderError {
RateLimited { retry_after: Option<Duration> },
Overloaded, // 5xx / "overloaded_error"
ContextOverflow, // NEVER retried
Auth(String), // triggers token refresh path
Http { status: u16, body: String },
Network(String), Decode(String), Cancelled,
}
```
```rust
// tool.rs
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters(&self) -> serde_json::Value; // JSON Schema (schemars)
async fn execute(&self, input: serde_json::Value, ctx: ToolCtx) -> Result<ToolOutput, ToolError>;
}
pub struct ToolCtx {
pub session_id: SessionId, pub message_id: MessageId, pub call_id: String,
pub agent: Arc<AgentDef>,
pub cwd: PathBuf, pub worktree: PathBuf,
pub cancel: CancellationToken, // linked to run token
pub ask: PermissionHandle, // async ask(AskInput) -> Result<(), Denied|Rejected>
pub metadata: MetadataSink, // set(Value) — live-updates Running ToolState + emits PartUpdated
pub engine: EngineHandle, // needed by the task tool; cheap Arc clone
}
pub struct ToolOutput { pub title: String, pub output: String, pub metadata: serde_json::Value }
```
```rust
// event.rs — serializable so a future server can pipe it to SSE unchanged
#[derive(Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AppEvent {
SessionCreated { session: Session }, SessionUpdated { session: Session },
MessageCreated { message: Message }, MessageUpdated { message: Message },
PartUpdated { part: Part }, // full-part upsert (simple, correct)
PartDelta { part_id: PartId, message_id: MessageId, delta: String }, // cheap text streaming
RunStarted { session_id: SessionId },
RunFinished { session_id: SessionId, outcome: RunOutcome },
PermissionAsked { request: PermissionRequest }, // {id, session_id, permission, patterns, metadata}
PermissionResolved{ id: String },
JobUpdated { job: JobRecord },
AuthPrompt { provider: String, user_code: String, verification_uri: String },
ServerNotice{ level: Level, text: String },
}
pub struct EventBus(broadcast::Sender<AppEvent>); // capacity 1024; subscribers tolerate Lagged
```
## Channel topology
```
Provider SSE ──(Stream<LlmEvent>)──▶ Processor ──▶ StorageTx (mpsc<StoreCmd>, oneshot replies)
└──▶ EventBus (broadcast<AppEvent>) ──▶ TUI event loop
└─▶ (future) SSE handler
Tool.execute ── ctx.ask() ──▶ PermissionService.pending: HashMap<id, oneshot::Sender<Reply>>
TUI keypress ── EngineHandle::permission_reply(id, Once|Always|Reject) ──▶ oneshot ──▶ tool resumes
EngineHandle::abort(session) ──▶ RunHandle.cancel.cancel() ──▶ token tree (tools, subagents, HTTP)
Background subagent: tokio::spawn(run_loop(child)) → completion → JobBoard.update → AppEvent::JobUpdated
```
+105
View File
@@ -0,0 +1,105 @@
# 02 — Engine: Agent Loop, Permissions, Retry
Direct port of opencode's two-level loop (`packages/opencode/src/session/prompt.ts` outer `runLoop` + `session/processor.ts` inner stream state machine) onto tokio.
## Outer loop
`engine/loop.rs::run_session` — one tokio task per active session, tracked in `Engine.runs: Mutex<HashMap<SessionId, RunHandle>>` where `RunHandle { cancel: CancellationToken, join: JoinHandle<RunOutcome> }`.
```
loop:
msgs = store.messages(session)
if last assistant message finished with reason != ToolCalls and has no pending tool parts:
break (Stop)
agent = registry.get(session.agent)
system = system_prompt(agent, env_header, custom_instructions)
tools = tool_registry.for_agent(agent) // filtered by agent tool permissions
req = build_request(model, system, convert(msgs), tools, temperature)
// + job-board synthetic part appended to last user message if agent.mode == Primary
step = run_step(req) // inner processor, wrapped in retry policy
match step { Continue => loop, Stop => break, Compact => compact(); loop }
```
Exit condition matches opencode: the loop keeps stepping as long as the model finished with `ToolCalls` (tool results need to be fed back) and stops once it produces a final answer. `agent.max_steps` bounds iterations; when exceeded, append a final assistant-role nudge message (opencode's `MAX_STEPS_PROMPT` behavior).
## Inner processor
`engine/processor.rs` — a state machine struct consuming the `LlmEventStream`:
```rust
struct Processor<'a> {
assistant: Message, // created on first event, persisted immediately
parts: Vec<Part>,
active_text: Option<PartId>,
active_reasoning: Option<PartId>,
pending_tools: HashMap<String /*call_id*/, PartId>,
flusher: DeltaFlusher, // buffers TextDelta; flush to store every 50 ms or 2 KB;
// emits AppEvent::PartDelta immediately (UI realtime,
// SQLite writes throttled)
}
```
Event handling:
- `TextStart` → new `Text` part. `TextDelta` → append + `PartDelta` event. Same for `Reasoning*`.
- `ToolInputStart``Tool` part in `Pending` (streaming partial JSON shown live in the TUI).
- `ToolCall` → transition to `Running`, then **execute inline, sequentially, in stream order** (matching opencode): doom-loop check → tool lookup → `tool.execute(input, ctx)` under `select!` with `cancel` → persist `Completed`/`Error` state → `PartUpdated`.
- `Finish` → write `StepFinish` part, add usage/cost to message + session, return:
```rust
enum StepResult { Continue, Stop, Compact }
// Continue if reason == ToolCalls
// Compact if usage.input + usage.output > model.context_limit * 0.90 (seam, stub until M6)
// Stop otherwise
```
## Permission engine
`permission/rule.rs` + `permission/service.rs` — port of `packages/opencode/src/permission/index.ts`.
```rust
pub struct Rule { pub permission: String, pub pattern: String, pub action: Action }
pub enum Action { Allow, Deny, Ask }
pub type Ruleset = Vec<Rule>;
```
- `evaluate(permission, pattern, stack)`: flatten `[config_rules, agent_rules, session.extra_rules]` in that order, find the **last** rule where wildcards match both `permission` and `pattern`; default `Ask`. Wildcard = `*` matching across separators (`globset` with `literal_separator(false)`).
- `ctx.ask(AskInput { permission, patterns, always_patterns, metadata })` flow:
1. Evaluate each pattern. `Deny``Err(Denied)` → tool returns an error telling the model the action was denied (loop continues, model can adjust).
2. `Ask` → insert a `oneshot::Sender<Reply>` into the pending map, publish `AppEvent::PermissionAsked`, then `select! { reply, cancel }`.
3. Reply `Once` → proceed. `Always` → append `Rule { permission, pattern: always_pattern, action: Allow }` to `session.extra_rules` (persisted), proceed. `Reject``Err(Rejected)` → tool error.
- Subagent sessions evaluate against the **intersection** of parent-effective and child rulesets (see [04-multiagent.md](04-multiagent.md)).
## Retry policy
`engine/retry.rs` — wraps the **start** of a step only; once any event has been consumed we surface the error rather than retry (opencode behavior).
- Retryable: `RateLimited`, `Overloaded`, `Network`, `Http { 5xx }`.
- Delay: `retry-after`/`retry-after-ms` header if present, else `min(2s · 2^attempt, 30s)`; max 8 attempts; every wait raced against `cancel`.
- `ContextOverflow`: never retried → `StepResult::Compact` (until compaction lands: error notice advising a new session).
- `Auth`: one token-refresh attempt, then retry once.
## Doom-loop guard
`engine/doomloop.rs`: keep the last 3 `(tool_name, blake3(input_json))` per run; if the next call equals all 3, force a `ctx.ask` with permission key `doom_loop` regardless of ruleset ("model appears stuck repeating {tool}"). Ported from opencode's `DOOM_LOOP_THRESHOLD = 3`.
## Cancellation
`RunHandle.cancel` is the root token. Tools receive `cancel.child_token()`; subagent runs receive another child. On abort:
- in-flight tool parts → `ToolState::Error { error: "interrupted" }`
- the assistant message is marked `error: Aborted`
- `RunFinished { Aborted }` is emitted
- provider HTTP streams end because the reqwest futures are dropped inside `select!`
## Compaction seam (v1.5 / M6)
```rust
trait Compactor { async fn compact(&self, session: &Session, msgs: &[Message]) -> Result<Summary> }
```
The loop already branches on `StepResult::Compact`. M1M5 ship a stub. M6 implements: summarize history via `small_model`, write a `Compaction` part, and have `convert(msgs)` cut history at the compaction point.
## System prompt assembly
`engine/system.rs`: ordered blocks — environment header (cwd, platform, date, git status), agent prompt (markdown body from the agent definition), project instructions (`config.instructions` files, e.g. AGENTS.md), skills index (names + descriptions). Blocks stay separate in `LlmRequest.system: Vec<String>` because Anthropic cache breakpoints need the structure.
+112
View File
@@ -0,0 +1,112 @@
# 03 — Provider Layer
`harness-providers`: three providers sharing wire codecs, because Copilot multiplexes all three wire formats (chat completions, responses, anthropic messages).
```
src/
registry.rs # ProviderRegistry: id → Arc<dyn Provider>; model resolution "provider/model"
auth.rs # AuthStorage (auth.json, 0600)
modelsdev.rs # models.dev metadata cache
codec/
openai_chat.rs # request builder + SSE decoder for /chat/completions
openai_responses.rs # request builder + SSE decoder for /responses
anthropic.rs # request builder + SSE decoder for /v1/messages
openai.rs
anthropic.rs
copilot/
device_flow.rs
token.rs
provider.rs
```
## Provider trait & request model
```rust
#[async_trait]
pub trait Provider: Send + Sync {
fn id(&self) -> &str;
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError>;
async fn stream(&self, req: LlmRequest, cancel: CancellationToken)
-> Result<LlmEventStream, ProviderError>;
}
pub struct LlmRequest {
pub model: String,
pub system: Vec<String>, // ordered blocks (cache breakpoints need structure)
pub messages: Vec<WireMessage>, // role + Vec<WireContent{Text, ToolCall, ToolResult, Image}>
pub tools: Vec<ToolSchema>, // { name, description, parameters: Value }
pub temperature: Option<f32>, pub max_tokens: Option<u32>,
pub reasoning: Option<ReasoningOpts>, // effort low/medium/high; anthropic thinking budget
pub initiator: Initiator, // User | Agent → copilot x-initiator header
}
```
Decoders are pure functions `fn decode(sse: impl Stream<Item = Event>) -> LlmEventStream` written with `async_stream::try_stream!` over `eventsource_stream::Eventsource` — unit-testable against fixture files.
## Anthropic
- Auth: `x-api-key` header (API key from config/auth.json/`ANTHROPIC_API_KEY`).
- Headers: `anthropic-beta: interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14` (from opencode).
- **Prompt caching:** `cache_control: {type: "ephemeral"}` breakpoints on the first 2 system blocks + last 2 non-system messages (opencode's `applyCaching` in `provider/transform.ts`).
- SSE mapping: `content_block_start(thinking)``ReasoningStart`; `thinking_delta`/`signature_delta` deltas; `content_block_start(tool_use)``ToolInputStart`; `input_json_delta``ToolInputDelta` (accumulate, emit `ToolCall` at `content_block_stop`); `message_delta.usage``Finish`. Cache tokens from `cache_creation_input_tokens` / `cache_read_input_tokens`.
## OpenAI
- Auth: `Authorization: Bearer <api key>`.
- **Responses API** for gpt-* / o-* models; chat completions as fallback for compatible endpoints.
- Responses SSE: `response.output_item.added/delta/done` events; `response.completed` for usage. Chat SSE: `choices[].delta.tool_calls[]` accumulated by index; `[DONE]` sentinel.
- Reasoning models: `reasoning: { effort }`, `reasoning_summary: auto`; reasoning tokens counted separately in usage.
## GitHub Copilot
### Auth — GitHub OAuth device flow (`copilot/device_flow.rs`)
1. `POST https://github.com/login/device/code` body `{client_id, scope: "read:user"}`, `Accept: application/json``{verification_uri, user_code, device_code, interval}`. Emit `AppEvent::AuthPrompt` → TUI modal shows the code + URL.
2. Poll `POST https://github.com/login/oauth/access_token` body `{client_id, device_code, grant_type: "urn:ietf:params:oauth:grant-type:device_code"}` at `interval`; handle `authorization_pending` and `slow_down` (+5 s per RFC 8628).
3. Store the OAuth token in auth.json.
Client ID: `Ov23li8tweQw6odWQebz` (opencode's; confirm or register our own).
### Token strategy (`copilot/token.rs`) — flagged risk
opencode currently sends the **GitHub OAuth token directly** as the Bearer for `api.githubcopilot.com` (no exchange, `expires: 0`). The classic Copilot API instead requires exchanging it at `GET https://api.github.com/copilot_internal/v2/token` for a short-lived `{token, expires_at}`. Implement **both behind one function**: exchange as primary (cached until `expires_at 120s`), direct-Bearer fallback if the exchange endpoint rejects. Verify against the live API early in M3.
### Requests (`copilot/provider.rs`)
- Base: `https://api.githubcopilot.com` (enterprise: `https://copilot-api.<domain>`).
- Headers: `Authorization: Bearer`, `User-Agent: ai-harness/<version>`, `X-GitHub-Api-Version: 2026-06-01`, `Openai-Intent: conversation-edits`, `x-initiator: agent|user` (subagent/utility sessions → `agent`), `Copilot-Vision-Request: true` when messages contain images. Anthropic-backed Copilot models also get `anthropic-beta: interleaved-thinking-2025-05-14`.
- `GET /models`: capabilities, billing, and `supported_endpoints` per model → route each model to the right codec: `/chat/completions` → openai_chat, `/responses` → openai_responses, `/v1/messages` → anthropic codec against `<base>/v1`. Only `model_picker_enabled` models surface in the picker.
## Auth storage (`auth.rs`)
Single file `~/.local/share/ai-harness/auth.json`, written mode `0600`, keyed by provider id:
```rust
enum AuthRecord {
OAuth { access: String, refresh: String, expires: i64 }, // expires 0 = never
Api { key: String },
}
```
Token refresh lives inside each provider's HTTP path: on 401 or known expiry, refresh under a `tokio::Mutex` (single-flight), persist, retry once.
Login is exposed as `EngineHandle::login(provider_id) -> LoginFlow`:
```rust
enum LoginFlow {
DeviceCode { user_code: String, verification_uri: String, done: oneshot::Receiver<Result<()>> },
ApiKey { submit: /* fn(String) */ },
}
```
## Model metadata & cost (`modelsdev.rs`)
- Fetch `https://models.dev/api.json` → cache `~/.cache/ai-harness/models.json` (24 h TTL); fallback to baked `assets/models-snapshot.json`.
- Yields `ModelInfo { context_limit, output_limit, cost { input, output, cache_read, cache_write } (per-million), reasoning, tool_call, attachment }` keyed by `(provider, model)`.
- At `Finish`: `cost = Σ usage_component × rate`; accumulate on message and session. Copilot models cost 0 unless metadata says otherwise.
## Testing
- Recorded SSE fixtures per codec (`tests/fixtures/{anthropic,openai_chat,openai_responses,copilot}/*.sse`): text-only, tool call, parallel tool calls, thinking, mid-stream error, `data:` split across chunks → assert exact `Vec<LlmEvent>` via `insta`.
- Request builders: snapshot outgoing JSON (cache_control placement, tool schemas, header sets) via `wiremock`.
- Device flow: wiremock sequence `authorization_pending → slow_down → success`; refresh single-flight test.
+118
View File
@@ -0,0 +1,118 @@
# 04 — Multiagent Orchestration
The oh-my-opencode-slim model (orchestrator schedules, specialists execute, background jobs tracked on a board) reimplemented natively — but **all agent behavior lives in markdown + config**. The engine only understands `mode`, tool filters, permissions, and depth. No prompt text is hardcoded in Rust.
## Agent definitions
```rust
pub struct AgentDef {
pub name: String, pub description: String,
pub mode: AgentMode, // Primary | Subagent | All
pub model: Option<ModelRef>, // None = follow session model (slim's default)
pub temperature: Option<f32>,
pub prompt: String, // markdown body
pub permissions: Ruleset, // frontmatter `permission:` list
pub tools: HashMap<String, bool>, // enable/disable, wildcard keys
pub max_steps: Option<u32>,
pub source: AgentSource, // Bundled | Global | Project | Config
}
```
Markdown format (opencode-compatible shape):
```markdown
---
description: Read-only reconnaissance specialist
mode: subagent
model: anthropic/claude-haiku-4-5 # optional; omit to follow session model
temperature: 0.1
tools: { write: false, edit: false, bash: true, task: false }
permission:
- { permission: "edit", pattern: "*", action: deny }
---
You are Explorer... (prompt body)
```
### Loading order (later wins by name)
1. **Bundled**`include_str!` of `assets/agents/{orchestrator,explorer,oracle,librarian,fixer,designer}.md`. Prompts ported from oh-my-opencode-slim's `src/agents/*.ts` with plugin-specific mechanics stripped; the orchestrator's `<Agents>` routing section is generated at load time from the enabled agents' `description` fields (so disabling an agent removes it from routing text automatically).
2. Global: `~/.config/ai-harness/agent/*.md`
3. Project: `<project>/.harness/agent/*.md`
4. Inline `agents` table in config — **patch semantics** (only set fields override: model, temperature, prompt, tools…).
`disable: true` in frontmatter or config removes an agent. Unknown names in config with at least a prompt/model create custom agents.
## Task tool
`harness-tools/src/task.rs`, input:
```json
{ "description": "...", "prompt": "...", "subagent_type": "explorer",
"task_id": "exp-1 (optional, reuse)", "background": false }
```
Execution:
1. Resolve `subagent_type` in the registry; must be `mode: Subagent | All`. Ask permission `task`/`<agent name>`.
2. **Depth limit:** `session.depth + 1 > config.orchestration.depth_limit` (default 3) → error string telling the model to do the work itself. Depth is stored on the session at creation → O(1) check.
3. **Session reuse:** if `task_id` is set, `JobBoard::resolve(parent_id, alias_or_id)`; the job must be terminal-completed (reusable). Reuse its child session — history intact — and append the new prompt. Otherwise create a child `Session { parent_id, depth: parent.depth + 1, agent }`.
4. **Permission intersection:** child asks evaluate against both parent-effective and child rulesets, taking the more restrictive verdict (`deny > ask > allow`) — `evaluate_intersected()` in `permission/rule.rs`. Bundled default rules additionally deny `task` and `todo*` for any session with `depth > 0` (config-overridable).
5. **Foreground:** run `run_session(child)` with a token childed from the calling tool's token; await; return the final assistant text as `ToolOutput`. A `Subtask` part on the parent links the child for TUI drill-in.
6. **Background:** `JobBoard::register_launch` (assign alias), `tokio::spawn` the child run with a token childed from the **parent session's run token** — not the tool's, since the tool returns immediately but the job must die with the session — and return `"Launched background task {alias} ({child_session_id}). Check the job board."`. On completion, the spawned task updates the board and publishes `JobUpdated`.
## Job board
`engine/jobs.rs` — simplified port of slim's `src/utils/background-job-board.ts`. In-memory `RwLock<HashMap>` + persisted to the `job` table (resume works).
```rust
pub struct JobRecord {
pub task_id: String, pub alias: String, // alias: first 3 chars of agent + counter → "exp-1"
pub parent_session: SessionId, pub child_session: SessionId,
pub agent: String, pub description: String, pub objective: Option<String>,
pub state: JobState, // Running | Completed | Error | Cancelled
pub reconciled: bool, // orchestrator has seen the terminal result
pub result_summary: Option<String>, // final text, truncated
pub context_files: Vec<ContextFile>, // fed by the read tool in child sessions
pub launched_at: i64, pub updated_at: i64, pub last_used_at: i64,
}
```
- Read-tool executions in child sessions report files (≥10 lines read) to the board; max 8 shown per job.
- Reusable = terminal-completed; trimmed to `max_reusable_per_agent` (default 2) per agent per parent, LRU by `last_used_at`.
### Injection
In `build_request`, if the agent is `Primary` and the board is non-empty for this session, append a **synthetic, non-persisted** text part to the last user message:
```
### Background Job Board
Do not poll running jobs; wait for completion. Reconcile terminal jobs before your final response.
Completed sessions are reusable by alias for the same specialist.
#### Active / Unreconciled
- exp-1 / ses_x / explorer / running — Objective: ...
#### Reusable Sessions
- fix-1 / ses_y / fixer / completed
Objective: ...
Context read: src/a.rs, src/b.rs
```
Terminal jobs included in an injection are marked `reconciled` after the step finishes successfully.
## Reminders (optional, off by default)
slim's phase reminder / post-file-tool nudge become `config.orchestration.reminders: Option<HashMap<String, String>>` — user-provided strings injected as synthetic parts at the corresponding hook points (`turn_start`, `after_file_tool`). **No bundled nudge text** — this is part of the "less baked-in" requirement.
## Config
```jsonc
{
"orchestration": {
"depth_limit": 3,
"job_board": true,
"max_reusable_per_agent": 2,
"reminders": null
}
}
```
+48
View File
@@ -0,0 +1,48 @@
# 05 — Built-in Tools
`harness-tools`. All parameter structs derive `schemars::JsonSchema` (single source of truth for the schema sent to the model). Invalid-params errors are returned to the model as tool errors with the schema echoed (opencode's `invalid` pattern).
## Shared: output truncation
`truncate.rs`: cap output at 30,000 chars; keep head + tail with `... [N chars truncated; full output: <data_dir>/tool-output/<call_id>.txt] ...`; full output spilled to the session data dir.
## Tool table
| Tool | Params | Behavior | Permission key / pattern |
|---|---|---|---|
| `bash` | `{command, timeout_ms?, cwd?, description}` | `tokio::process::Command` `sh -c`, own process group (`setsid`); kill group on cancel/timeout (default 2 min, max 10); merge stdout+stderr; truncate | `bash` / command string; "always" pattern = `<first word> *` via `shell-words` |
| `read` | `{file_path, offset?, limit?}` | 2000-line default window, `cat -n` numbering, 2000-char line clamp, binary/image detection; reports context files to the JobBoard when in a child session | `read` / worktree-relative path (bundled default: allow) |
| `edit` | `{file_path, old_string, new_string, replace_all?}` | replacer chain (below); CRLF/BOM preservation; per-file `tokio::Mutex` lock map; unified diff via `similar` in permission metadata + part metadata; LSP diagnostics appended | `edit` / relative path; always-pattern `*` |
| `write` | `{file_path, content}` | mkdir -p; diff vs existing in metadata; LSP diagnostics | `edit` (same key as opencode) / relative path |
| `glob` | `{pattern, path?}` | `ignore::WalkBuilder` (respects .gitignore) + `globset`; sort mtime desc; cap 100 | none (read-only) |
| `grep` | `{pattern, path?, include?}` | `grep-regex` + `grep-searcher`, gitignore-aware; cap 100 matches, sorted by file mtime | none |
| `todowrite` / `todoread` | list of `{content, status: pending\|in_progress\|completed, priority}` | stored on the session row (JSON); part metadata drives a TUI checklist | none |
| `webfetch` | `{url, format: text\|markdown\|html, timeout?}` | reqwest GET (redirect cap 5), 5 MB cap, HTML→markdown via `htmd`, truncate | `webfetch` / URL |
| `task` | see [04-multiagent.md](04-multiagent.md) | spawn/reuse subagent sessions | `task` / agent name |
| `skill` | `{name}` | returns the SKILL.md body on demand | `skill` / name (default allow) |
MCP tools are registered alongside these, namespaced `{server}_{tool}` — see [09-integrations.md](09-integrations.md).
## Edit tool: replacer chain
**Port opencode's replacer chain verbatim** from `~/repos/opencode/packages/opencode/src/tool/edit.ts`, including its test table (`edit.test.ts`) — this is the highest-regression-risk code in the project:
1. Simple (exact match)
2. LineTrimmed
3. BlockAnchor (first/last line anchors, ≥0.65 similarity on middle lines, Levenshtein)
4. WhitespaceNormalized
5. IndentationFlexible
6. EscapeNormalized
7. TrimmedBoundary
8. ContextAware
9. MultiOccurrence (with `replace_all`)
Plus the disproportionate-match guard (reject matches that differ too much in size from `old_string`).
## Registry & agent filtering
`ToolRegistry::for_agent(agent)`:
1. Filter by the agent's `tools` wildcard map (`{ "write": false, "mcp_*": false }`).
2. Hard-remove tools denied for the session depth (e.g. `task`, `todowrite` at depth > 0 by default).
3. The `task` tool's description is generated dynamically, listing available subagents with their descriptions (so routing knowledge comes from agent frontmatter, not hardcoded text).
+65
View File
@@ -0,0 +1,65 @@
# 06 — Config System
`harness-core/src/config/`.
## Locations & precedence (lowest → highest)
1. Bundled defaults (compiled in)
2. Global: `~/.config/ai-harness/config.json`
3. Project chain: walk up from cwd collecting `.harness/config.json`, apply root-most first
4. Env overrides (`AI_HARNESS_MODEL`, `AI_HARNESS_CONFIG`, provider key env vars)
JSONC via `jsonc-parser` (comments + trailing commas). Deep-merge objects, replace arrays. After merge: string interpolation of `{env:VAR}` and `{file:path}` (the latter useful for prompts).
## Schema
```rust
pub struct Config {
pub model: Option<String>, // "anthropic/claude-sonnet-4-5"
pub small_model: Option<String>, // titles/summaries/compaction
pub providers: HashMap<String, ProviderConfig>, // { enabled, api_key, base_url }
pub agents: HashMap<String, AgentPatch>, // patch semantics over loaded agent defs
pub permissions: Vec<Rule>, // ordered; last-match-wins
pub orchestration: OrchestrationConfig, // { depth_limit: 3, job_board: true,
// max_reusable_per_agent: 2, reminders: None }
pub mcp: HashMap<String, McpServerConfig>, // { command, args, env, enabled }
pub lsp: HashMap<String, LspServerConfig>, // { command, args, extensions: ["rs"] }
pub tui: TuiConfig, // theme; keybinds later
pub instructions: Vec<String>, // files appended to system prompt (AGENTS.md, ...)
}
pub struct Rule { pub permission: String, pub pattern: String, pub action: Action }
```
Example:
```jsonc
{
"model": "github-copilot/claude-sonnet-4.5",
"small_model": "openai/gpt-4o-mini",
"providers": {
"anthropic": { "api_key": "{env:ANTHROPIC_API_KEY}" }
},
"agents": {
"explorer": { "model": "github-copilot/gpt-4o", "temperature": 0 },
"designer": { "disable": true },
// unknown key + prompt/model → custom agent
"reviewer": { "mode": "subagent", "prompt": "{file:./prompts/reviewer.md}" }
},
"permissions": [
{ "permission": "bash", "pattern": "git status*", "action": "allow" },
{ "permission": "edit", "pattern": "*.lock", "action": "deny" }
],
"mcp": {
"context7": { "command": "npx", "args": ["-y", "@upstash/context7-mcp"] }
}
}
```
## Markdown asset loading (`config/markdown.rs`)
All three kinds use YAML frontmatter + markdown body, from `~/.config/ai-harness/<kind>/` (global) and `<project>/.harness/<kind>/` (project wins by name):
- **Agents** — `agent/*.md``AgentDef` (see [04-multiagent.md](04-multiagent.md)).
- **Commands** — `command/*.md``CommandDef { name (filename), description, agent?, model?, template }`. Invoked as `/name args` in the TUI; template substitutes `$ARGUMENTS` / `$1..$9` and becomes the user message, optionally switching agent/model for that run.
- **Skills** — `skill/<name>/SKILL.md` → advertised (name + description) in a system prompt section; the full body is fetched on demand via the built-in `skill` tool.
+49
View File
@@ -0,0 +1,49 @@
# 07 — TUI
`harness-tui`, binary `harness`. Single ratatui app over the engine; zero business logic — it renders `AppEvent`s and calls `EngineHandle`.
## Layout
```
┌─ header: session title · agent · provider/model ─────────────┐
│ chat viewport (scroll) │
│ user block / assistant markdown / tool cards │
│ (tool card: status icon, title, collapsible output, diff) │
├─ jobs strip when active: ● exp-1 running · ✓ fix-1 done │
├─ input (tui-textarea, multiline, / command completion) │
└─ status: tokens in/out · cost · spinner · keymap hints ──────┘
```
## Event loop
`tokio::select!` over:
1. `crossterm::event::EventStream` (keys, resize)
2. `bus.subscribe()``AppEvent`s
3. 33 ms render tick
Events mutate `AppState { sessions, current: SessionView { messages: Vec<MessageView> }, modal: Option<Modal>, jobs, input }` and set a dirty flag; render at most once per tick.
## Streaming markdown rendering
- `MessageView` caches rendered `Vec<Line>` per part, keyed `(part_id, revision, width)`.
- Finished parts render once through the `pulldown-cmark` → ratatui renderer (headings, bold/italic, fenced code, lists, blockquotes; syntect highlighting optional in M6).
- The in-flight tail part re-renders only itself on `PartDelta`; only the portion after the last blank line gets full markdown treatment — earlier paragraphs of the same part are frozen into the cache. This bounds per-delta work.
- **Risk:** ratatui has no incremental layout. The cache + tail-only strategy is the mitigation; fall back to a plain-text tail if profiling shows stutter.
## Modals
- **Permission dialog** (on `PermissionAsked`): permission key, pattern list, metadata — for edits, a colorized unified diff. Keys: `y` allow once, `a` allow always, `n` reject → `engine.permission_reply(id, …)`. Multiple pending asks queue FIFO.
- **Device-code login** (on `AuthPrompt`): shows user code + verification URL; dismisses when the flow completes.
- **Pickers** (shared fuzzy-filter list widget): sessions `Ctrl+S`, agents (`mode: Primary|All`) `Ctrl+A`, models (grouped by provider) `Ctrl+M`.
- **Jobs pane** `Ctrl+J`: lists `JobRecord`s (alias, agent, state, age, objective); `Enter` drills into the child session view — a read-only live follow of its stream (trivial since all sessions publish to the same bus).
## Keybinds & commands
- `Esc` aborts the running turn / closes modal; `Ctrl+C` twice quits.
- Slash commands: `/model`, `/agent`, `/new`, `/sessions`, `/login <provider>`, `/compact`, plus markdown-defined commands with completion.
## Terminal hygiene
- Logging via `tracing` to `~/.local/share/ai-harness/log/` (the TUI owns the terminal — nothing writes to stdout).
- Panic hook + `Drop` guard restore the terminal (disable raw mode, leave alternate screen).
+40
View File
@@ -0,0 +1,40 @@
# 08 — Storage
`harness-core/src/store/`. SQLite per project: `~/.local/share/ai-harness/db/<project-slug>.sqlite` (slug = sanitized worktree path + hash), WAL mode.
## Schema
```sql
CREATE TABLE session (id TEXT PRIMARY KEY, parent_id TEXT, created_at INTEGER, updated_at INTEGER,
data TEXT NOT NULL); -- full Session JSON
CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, created_at INTEGER,
data TEXT NOT NULL);
CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL, session_id TEXT NOT NULL,
idx INTEGER NOT NULL, data TEXT NOT NULL);
CREATE TABLE job (task_id TEXT PRIMARY KEY, parent_session_id TEXT NOT NULL, data TEXT NOT NULL);
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); -- schema_version, cache stamps
CREATE INDEX message_session ON message(session_id);
CREATE INDEX part_message ON part(message_id);
CREATE INDEX session_parent ON session(parent_id);
```
JSON `data` columns (opencode's approach): schema evolution without a migration per field. IDs are ULIDs, so `ORDER BY id` is chronological.
## Storage actor
One dedicated thread owns the `rusqlite::Connection`; commands arrive over `mpsc::Sender<StoreCmd>`, each carrying a `oneshot` reply channel. `store::api` exposes typed async facades: `upsert_session`, `upsert_message`, `upsert_part`, `messages(session)`, `parts(message)`, `sessions()`, `jobs(parent)`, …
Rationale vs sqlx/async drivers: single writer, embedded DB, tiny schema — an actor thread is simpler, avoids async-driver complexity, and serializes writes for free.
## Write cadence
- Messages and tool-state transitions: written immediately.
- Streaming text deltas: buffered by the processor's `DeltaFlusher` — flushed to the part row every 50 ms or 2 KB, while `AppEvent::PartDelta` goes to the UI immediately. UI is realtime; SQLite writes are throttled.
- Synthetic parts (job board injection, reminders) are **not** persisted.
## Session resume
- Session list from the DB; opening a session replays its messages/parts into the TUI view.
- Crash recovery: on load, an unfinished assistant message (no `finished`, no error) is marked aborted; in-flight tool parts become `Error { "interrupted" }`.
- Job records reload from the `job` table so the board survives restarts (running jobs whose runs died are marked `Error` on load).
+36
View File
@@ -0,0 +1,36 @@
# 09 — Integrations: MCP, LSP, Commands & Skills
## MCP client (`harness-mcp`)
- For each configured server (`config.mcp`): `rmcp` client over `TokioChildProcess` transport — spawn, `initialize`, `list_tools`.
- Each MCP tool wraps into `McpTool: Tool` named `{server}_{tool}` (sanitized to `[a-zA-Z0-9_-]`, 64-char cap). `parameters()` passes the server's JSON Schema through untouched. `execute``call_tool`; text content items concatenated, images noted and saved to the session data dir.
- **Permissions:** key `mcp`, pattern `{server}_{tool}`; bundled default rule `{ permission: "mcp", pattern: "*", action: "ask" }`.
- Failure handling: a server that fails to start is marked failed with a `ServerNotice` — other tools unaffected; restart attempted on next use after a crash.
- Out of scope for v1: MCP resources, prompts, sampling; non-stdio transports (HTTP/SSE later).
## LSP diagnostics (`harness-lsp`)
Implements a seam trait defined in core, so `harness-tools` never links this crate:
```rust
// in harness-core
pub trait DiagnosticsSource: Send + Sync {
async fn touch(&self, path: &Path); // spawn-if-needed + didOpen/didChange
async fn diagnostics(&self, path: &Path, wait: Duration) -> Vec<Diagnostic>;
}
```
- `LspPool`: extension → server config. Built-ins (spawned only if the binary exists on PATH, all config-overridable): rust-analyzer (.rs), typescript-language-server (.ts/.tsx/.js), gopls (.go), pyright/basedpyright (.py).
- Client = hand-rolled JSON-RPC over child stdio (`Content-Length` framing, ~150 lines: request map `id → oneshot`, notification broadcast) using `lsp-types` for the payloads. Only ~6 methods needed: `initialize`, `initialized`, `didOpen`, `didChange`, `publishDiagnostics`, optionally `textDocument/diagnostic`.
- Edit/write tools call `touch(path)` then `diagnostics(path, 1500ms)` — wait for the first `publishDiagnostics` after the change version, or time out. Error-severity items are appended to the tool output:
`LSP errors detected in this file, please fix:\n{file}: L{line}: {message}`; the full set goes into part metadata for the TUI.
- All failures are silent to the model (log only) — diagnostics are best-effort.
## Commands & skills
- **Commands** (`command/*.md`, see [06-config.md](06-config.md)): a pure input-layer concern — `/name args` expands the template into the user message, optionally switching agent/model for that run.
- **Skills** (`skill/<name>/SKILL.md`): the system prompt lists name + description of each available skill; the model loads a skill's body on demand with the built-in `skill` tool. Per-agent skill filtering via the agent's `tools`/permission config.
## Child-process hygiene
MCP servers, LSP servers, and bash all spawn children. A single `ChildRegistry` tracks them; process-group kills + `Drop` guards ensure cleanup on exit or panic (flagged risk: zombie children).
+66
View File
@@ -0,0 +1,66 @@
# 10 — Milestones, Testing, Risks
## Milestones
Each milestone ends with a verifiable outcome (✅).
### M0 — Scaffolding
Workspace + all crates compile empty; CI (fmt, clippy `-D warnings`, test). `harness-core`: id types, event bus, permission `evaluate` with table-driven tests, storage actor with roundtrip tests.
`cargo test` green; a bin stub prints version.
### M1 — Headless core loop + Anthropic
Anthropic codec + provider (against fixtures), config loading, `Tool` trait + read/bash/glob/grep/edit/write (edit replacers ported **with opencode's test table**), engine loop end-to-end with a `MockProvider`, permission service with an auto-approve stub frontend. Debug command `harness run -p "<prompt>"`.
✅ Integration test: scripted MockProvider does text → tool call (read) → final text with correct parts persisted; `harness run` works against real Anthropic.
### M2 — TUI
Event loop, chat viewport + markdown renderer + cache, input, streaming render, permission modal wired to the real oneshot path, session picker/resume, abort.
✅ Manually drive a multi-turn session with permission-gated edits; TestBackend snapshot tests pass.
### M3 — All providers + auth
OpenAI (responses + chat codecs), Copilot (device-flow modal, token exchange with direct-Bearer fallback, `/models` routing to the three codecs), auth.json + token refresh, models.dev metadata + cost display, retry policy + doom-loop guard.
✅ The same session works on all three providers; device-flow login completes inside the TUI; costs shown.
### M4 — Multiagent
Agent registry (bundled markdown + overrides), task tool foreground/background, JobBoard + persistence + injection, aliases/reuse, depth limits, permission intersection, jobs pane + subtask drill-in.
✅ An orchestrator session spawns a background explorer; the board appears in next-turn context (verify via request logging); reuse by alias continues the same child session.
### M5 — MCP, LSP, commands/skills
rmcp integration + namespaced tools + permissions; LSP pool + diagnostics in edit/write output; markdown commands + skill tool.
✅ A config-declared MCP server's tool is callable with ask-permission; editing a broken `.rs` file surfaces rust-analyzer errors in the tool result; `/mycommand` works.
### M6 — Polish
Auto-compaction (Compactor impl + overflow trigger), session title generation (small_model), tool-output spill dir, theming, syntax-highlighted code blocks, crash-resume hardening, streaming-render perf pass.
✅ A session driven past the context limit compacts and continues correctly.
## Testing strategy
- **Provider decoders:** recorded SSE fixtures per codec (text-only, tool call, parallel tool calls, thinking, errors, mid-stream disconnect, `data:` split across chunks) → assert exact `Vec<LlmEvent>` via `insta` snapshots. Request builders snapshot outgoing JSON (cache_control placement, tool schemas, header sets) with `wiremock`.
- **Auth:** device-flow poll loop against wiremock (`authorization_pending → slow_down → success`); token-exchange expiry + refresh single-flight.
- **Tools:** tempdir-based unit tests; edit replacers get a direct port of opencode's `edit.test.ts` case table; bash timeout/kill-process-group; truncation boundaries.
- **Engine integration (MockProvider):** multi-step tool loop; permission ask/deny/reject via the real oneshot path; retry backoff (mock returns RateLimited twice); doom-loop trigger; cancellation mid-tool; task tool foreground + background with job-board assertions; depth-limit rejection; permission intersection.
- **Permission engine:** table-driven last-match-wins/wildcard tests mirroring opencode's semantics.
- **TUI:** ratatui `TestBackend` snapshots (empty session, streaming message, tool card, permission modal, jobs pane); input-handling unit tests on `AppState`.
- **Storage:** actor roundtrips; resume-after-simulated-crash (unfinished assistant → aborted).
## Top risks
1. **Copilot token exchange variance** — opencode ships direct-Bearer; classic Copilot API wants the `copilot_internal/v2/token` exchange. Mitigation: dual-path `token.rs`, verify live early in M3.
2. **SSE edge cases** — events split across TCP chunks, keepalive comments, provider-specific mid-stream error events. Mitigation: `eventsource-stream` + adversarial fixtures.
3. **Streaming markdown perf in ratatui** — no incremental layout. Mitigation: per-part render cache + tail-only re-render + plain-text-tail fallback.
4. **Edit replacer fidelity** — port the test suite, not just the code.
5. **Child-process lifecycle (MCP/LSP/bash)** — zombies. Mitigation: process-group kills + `Drop` guards + central `ChildRegistry`.
## Key reference files
| What | Where |
|---|---|
| Inner stream state machine to mirror | `~/repos/opencode/packages/opencode/src/session/processor.ts` |
| Outer loop semantics | `~/repos/opencode/packages/opencode/src/session/prompt.ts` |
| Permission evaluate/ask semantics | `~/repos/opencode/packages/opencode/src/permission/index.ts` |
| Edit replacer chain + tests (port verbatim) | `~/repos/opencode/packages/opencode/src/tool/edit.ts` |
| Copilot auth/headers/model routing | `~/repos/opencode/packages/opencode/src/plugin/github-copilot/{copilot,models}.ts` |
| Anthropic caching/beta headers | `~/repos/opencode/packages/opencode/src/provider/transform.ts` |
| Retry policy details | `~/repos/opencode/packages/opencode/src/session/retry.ts` |
| JobRecord, aliases, formatForPrompt | `~/repos/oh-my-opencode-slim/src/utils/background-job-board.ts` |
| Session-reuse mechanics | `~/repos/oh-my-opencode-slim/src/hooks/task-session-manager/index.ts` |
| Agent prompt content to port into markdown | `~/repos/oh-my-opencode-slim/src/agents/*.ts` |